diff --git a/benchmark/runtime/planning.py b/benchmark/runtime/planning.py index 94b55a2d3b..86082f6924 100644 --- a/benchmark/runtime/planning.py +++ b/benchmark/runtime/planning.py @@ -89,6 +89,13 @@ def validate_plan_readback(result: dict, before: dict, after: dict) -> dict: raise ValueError("planning referenced non-runnable or unclaimed work") if status == "blocked" and not set(ids).issubset(after["blocking_todo_ids"]): raise ValueError("blocked planning result requires unresolved blocking Todos") + requirements = after.get("goal_acceptance", {}).get("planning_requirements", {}) + if status == "ready" and ( + requirements.get("agent_id") != after["agent_id"] + or requirements.get("declaration_current") is not True + or not set(ids).issubset(requirements.get("linked_todo_ids", [])) + ): + raise ValueError("ready planning requires canonical overall requirements linked to its Todos") return { "input_digest": before["input_digest"], "status": status, @@ -96,5 +103,6 @@ def validate_plan_readback(result: dict, before: dict, after: dict) -> dict: "goal_id": after["goal_id"], "agent_id": after["agent_id"], "state_readback_verified": True, + **({"goal_requirements_readback": requirements} if status == "ready" else {}), "execution_owner": "caller", } diff --git a/benchmark/tests/test_task_entry.py b/benchmark/tests/test_task_entry.py index 3ba270be27..571e83e78f 100644 --- a/benchmark/tests/test_task_entry.py +++ b/benchmark/tests/test_task_entry.py @@ -229,6 +229,20 @@ def planning_env(tmp_path): import json, os, pathlib, subprocess, sys print(json.dumps({"type": "thread.started", "thread_id": "planning-fixture-session"}), flush=True) packet = json.loads(sys.stdin.read().split("Host-supplied planning checkpoint:\\n", 1)[1]) +prefix = [os.environ["LOOPX_CLI"], "--format", "json", "--registry", os.environ["LOOPX_REGISTRY"], + "--runtime-root", os.environ["LOOPX_RUNTIME_ROOT"], "goal-acceptance"] +route = ["--goal-id", "planning-goal", "--agent-id", "planner"] +current = packet["goal_acceptance"] +own = [o for o in current["goal_acceptance_contract"].get("outcome_requirements", {}).get("outcomes", []) if o["owner_agent_id"] == "planner"] +refinement = pathlib.Path(os.environ["LOOPX_PLANNING_RESULT"]).with_name("agent-requirements.json") +if not own: + document = {"outcome_id": "repair", "owner_agent_id": "planner", "objective": "Repair the failure and preserve a regression test.", + "requirements": [{"id": "repair", "description": "The failure is repaired and a regression test retained."}], + "source": {"ref": "user-task", "summary": packet["text"].strip()}} + refinement.write_text(json.dumps(document)) + subprocess.run(prefix + ["refine"] + route + ["--document", str(refinement), + "--expected-provider-revision", current["provider_revision"], "--operation-id", "planner-requirements", "--execute"], + check=True, capture_output=True, text=True) command = [os.environ["LOOPX_CLI"], "--format", "json", "--registry", os.environ["LOOPX_REGISTRY"], "--runtime-root", os.environ["LOOPX_RUNTIME_ROOT"], "todo", "add", "--goal-id", "planning-goal", "--role", "agent", "--claimed-by", "planner", @@ -239,6 +253,13 @@ def planning_env(tmp_path): todo_id = written["todo_id"] else: todo_id = packet["runnable_todo_ids"][0] +if not own or todo_id not in own[0]["todo_ids"]: + current = json.loads(subprocess.run(prefix + ["inspect"] + route, check=True, capture_output=True, text=True).stdout) + document = {"outcome_id": "repair", "todo_ids": [todo_id]} + refinement.write_text(json.dumps(document)) + subprocess.run(prefix + ["refine"] + route + ["--document", str(refinement), + "--expected-provider-revision", current["provider_revision"], "--operation-id", "planner-requirements-work", "--execute"], + check=True, capture_output=True, text=True) result = {"input_digest": packet["input_digest"], "status": "ready", "todo_ids": [todo_id]} pathlib.Path(sys.argv[sys.argv.index("--output-last-message") + 1]).write_text(json.dumps(result)) pathlib.Path(os.environ["CODEX_HOME"], "seen-argv.json").write_text(json.dumps(sys.argv)) @@ -303,11 +324,50 @@ def test_planning_writes_real_todo_then_reuses_it_without_executing_task( ) +def test_real_planning_rejects_todos_without_agent_requirements(planning_env): + binary = Path(planning_env["CODEX_BIN"]) + source = binary.read_text().replace("if not own:", "if False:").replace( + 'if not own or todo_id not in own[0]["todo_ids"]:', "if False:") + binary.write_text(source) + with pytest.raises(ValueError, match="canonical overall requirements"): + run_once(planning_env) + assert not Path(planning_env["LOOPX_PLANNING_RESULT"]).exists() + receipts = list(Path(planning_env["LOOPX_WAKE_LOG_DIR"]).glob("*/receipt.json")) + assert len(receipts) == 1 and not json.loads(receipts[0].read_text())["ok"] + from benchmark.runtime.planning import task_plan_packet + from benchmark.runtime.worker import loopx_command + packet = task_plan_packet(planning_env, loopx_command(planning_env)) + assert len(packet["runnable_todo_ids"]) == 1, "An actual task alone cannot satisfy ready" + assert not packet["goal_acceptance"]["planning_requirements"]["declaration_current"] + + +def test_stopped_goal_cannot_launch_first_planner_or_fabricate_requirements(planning_env): + from loopx.control_plane.goals.first_party_host_admission import FirstPartyHostRuntimeRejected + from benchmark.runtime.planning import task_plan_packet + from benchmark.runtime.worker import loopx_command + + command = loopx_command(planning_env) + ["goal-lifecycle", "--goal-id", "planning-goal", "--operation", "stop"] + preview = json.loads(subprocess.run(command, env=planning_env, capture_output=True, + text=True, check=True).stdout) + stopped = subprocess.run(command + ["--actor-kind", "owner", "--expected-state-fingerprint", + preview["observed_state_fingerprint"], "--execute"], env=planning_env, capture_output=True, + text=True, check=True) + assert json.loads(stopped.stdout)["after_state"] == "stopped" + with pytest.raises(FirstPartyHostRuntimeRejected, match="goal_stopped"): + run_once(planning_env) + packet = task_plan_packet(planning_env, loopx_command(planning_env)) + assert not packet["existing_todos"] + assert not packet["goal_acceptance"]["planning_requirements"]["declaration_current"] + assert not Path(planning_env["LOOPX_PLANNING_RESULT"]).exists() + assert not (Path(planning_env["LOOPX_CODEX_HOME"]) / "seen-argv.json").exists() + + def test_prose_or_fabricated_todo_does_not_qualify_planning(planning_env): binary = Path(planning_env["CODEX_BIN"]) binary.write_text( binary.read_text().replace( - '"todo_ids": [todo_id]', '"todo_ids": ["todo_fabricated"]' + '"status": "ready", "todo_ids": [todo_id]', + '"status": "ready", "todo_ids": ["todo_fabricated"]' ) ) with pytest.raises(ValueError, match="missing or unrelated"): @@ -398,6 +458,10 @@ def test_plan_readback_rejects_stale_input_unclaimed_work_and_false_blockers(): ], "runnable_todo_ids": ["todo_owned"], "blocking_todo_ids": ["todo_gate"], + "goal_acceptance": {"planning_requirements": { + "agent_id": "agent", "declaration_current": True, + "outcome_ids": ["repair"], "linked_todo_ids": ["todo_owned"], + }}, } result = {"input_digest": "input-a", "status": "ready", "todo_ids": ["todo_owned"]} assert validate_plan_readback(result, packet, packet)["status"] == "ready" @@ -415,6 +479,13 @@ def test_plan_readback_rejects_stale_input_unclaimed_work_and_false_blockers(): ]: with pytest.raises(ValueError): validate_plan_readback(invalid, packet, packet) + for bad in ({}, {"declaration_current": False}, {"agent_id": "peer"}, {"linked_todo_ids": []}): + after = packet | {"goal_acceptance": {"planning_requirements": packet["goal_acceptance"]["planning_requirements"] | bad}} + if not bad: + after["goal_acceptance"] = {} + with pytest.raises(ValueError, match="canonical overall requirements"): + validate_plan_readback(result, packet, after) + assert validate_plan_readback(result | {"status": "blocked", "todo_ids": ["todo_gate"]}, packet, after)["status"] == "blocked" def test_planned_phase_preserves_waits_and_does_not_prewrite_a_todo( diff --git a/docs/reference/goal-acceptance-observations.md b/docs/reference/goal-acceptance-observations.md index 8bd037d348..1ad7b24455 100644 --- a/docs/reference/goal-acceptance-observations.md +++ b/docs/reference/goal-acceptance-observations.md @@ -98,6 +98,25 @@ and natural native Goal completion retain their existing owners. 已经启动过会话的旧创建记录保持原义,不自动迁移。停止、权限、预算、独立检查及 原生 Goal 的自然结束规则保持有效。 +The first planning step reuses these existing creation requirements and links its +actual work to them. It adds only missing source-grounded requirements; creating +another outcome for unchanged completion criteria is not a prerequisite. + +The shared `/loopx` and `todo plan` first-planning contract asks the Agent to +declare source-grounded overall requirements through `goal-acceptance refine`, +link the actual task Todos and read the declaration back. The caller supplies +the original task and execution facts; it does not write the Agent's semantic +requirements. Reuse the same outcome on continuation. Unchanged requirements +need no rewrite, and independent executable checks remain optional. + +An open-ended objective remains open-ended. A maximum timeout is an upper +bound, not an instruction to use every remaining minute. Local Todo completion +leaves unmet overall requirements with the existing frontier/replan owner; +real Stop, budget, refusal and waiting conditions still apply. Benchmark's +`loopx-planned` ready handoff checks the canonical Agent-owned work links. +Blocked planning can return its actual blocker without fabricating a declaration. +The structural readback proves persistence, not model comprehension or task success. + Goal Overview shows supporting outcomes recorded by a bound Agent: original objective, cumulative requirements, linked work, sources and separate Agent review/check results. The local owner can **Add a requirement** and read the diff --git a/loopx/bootstrap_command_pack.py b/loopx/bootstrap_command_pack.py index 440711f17c..f2a0476e59 100644 --- a/loopx/bootstrap_command_pack.py +++ b/loopx/bootstrap_command_pack.py @@ -1845,7 +1845,7 @@ def actionable_shell_command(value: Any) -> str: if step.step_id == "connect_if_needed" else str(command).splitlines()[0] ) - step_lines.append(f" - command/source: `{rendered_command}`") + step_lines.append(f" - `{rendered_command}`") preserve = transaction.get("preserve_todos_policy") preserve = preserve if isinstance(preserve, dict) else {} identity_gate = transaction.get("identity_selection_gate") diff --git a/loopx/control_plane/goals/acceptance_authority.ts b/loopx/control_plane/goals/acceptance_authority.ts index c5d1b6ef81..d723b26394 100644 --- a/loopx/control_plane/goals/acceptance_authority.ts +++ b/loopx/control_plane/goals/acceptance_authority.ts @@ -1,5 +1,5 @@ import {bindGoalOutcomesStateOwner, GoalOutcomeValidationError, goalOutcomeCheckBasis, refineGoalOutcome, - outcomeContextNavigation} from "./outcome_requirements.ts"; + outcomeContextNavigation, outcomePlanningReadback} from "./outcome_requirements.ts"; /** Acceptance effects use the existing canonical head, CAS and operation journal. * Only trusted local owner/host adapters may invoke these mutation exports. */ import {isAbsolute} from "node:path"; @@ -600,6 +600,7 @@ export async function inspectGoalAcceptance( ...(agentId === undefined || currentGoalObjective(head.head) === null ? {} : {objective_work_digest: goalObjectiveWorkDigest(head.head, agentId), goal_objective_guard: goalObjectiveWorkGuard(head.head, agentId)}), ...(context === null ? {} : {outcome_context: {...context, provider_revision: head.provider_revision}}), + ...(agentId === undefined ? {} : {planning_requirements: outcomePlanningReadback(contract, agentId)}), goal_acceptance_contract: contract}); } diff --git a/loopx/control_plane/goals/outcome_requirements.ts b/loopx/control_plane/goals/outcome_requirements.ts index a79dc241e6..d9d295921b 100644 --- a/loopx/control_plane/goals/outcome_requirements.ts +++ b/loopx/control_plane/goals/outcome_requirements.ts @@ -238,6 +238,19 @@ export function projectGoalOutcomes(head: JsonObject, goalId: string, acceptance execution_authority_granted: false, goal_completion_assessed: false}, acceptance); } +/** Structural first-plan readback, not evidence of comprehension or acceptance. + * This projection shares the exact canonical declaration/lifetime observation. + * Hosts check returned Todo ids against it without inventing semantic criteria. */ +export function outcomePlanningReadback(acceptance: JsonObject, agentId: string): JsonObject { + const declaration = acceptance.outcome_requirements as JsonObject | undefined; + const current = declaration?.current_lifetime === true; + const outcomes = current ? (declaration!.outcomes as JsonObject[]) + .filter(outcome => outcome.owner_agent_id === agentId) : []; + return {agent_id: agentId, declaration_current: current && outcomes.length > 0, + outcome_ids: outcomes.map(outcome => outcome.id), + linked_todo_ids: [...new Set(outcomes.flatMap(outcome => outcome.todo_ids as string[]))]}; +} + /** Suggest reading order from registered relationships, never reading obligations. * Use the complete canonical work snapshot and the existing relation owner; * the bounded planning horizon cannot establish this registered-relation closure. diff --git a/loopx/control_plane/goals/start_contract.py b/loopx/control_plane/goals/start_contract.py index 0236c8a5af..303d4e99d3 100644 --- a/loopx/control_plane/goals/start_contract.py +++ b/loopx/control_plane/goals/start_contract.py @@ -16,6 +16,25 @@ "substitute them for the requested deliverable." ) +_GOAL_REQUIREMENTS_GUIDANCE = ( + "In this first planning step, read the original Goal/task and existing outcome " + "requirements, including owner-approved Goal creation completion criteria. As the Agent, " + "reuse the existing outcome when it covers the request; declare source-grounded overall requirements through " + "goal-acceptance refine only for missing requirements before task Todos, then link the actual authored/reused " + "Todo ids and read the canonical declaration back. Preserve explicit user criteria, " + "constraints, permissions and Stop; an open-ended objective stays open-ended, and " + "a maximum timeout is not a requirement to spend it. Do not invent metrics, minimum " + "duration, authority or paid work. Reuse the same outcome and original Goal/Agent " + "on continuation; retain cumulative requirements and reconcile conflicts through " + "the existing owner. Unchanged declarations need no rewrite. Configure independent " + "executable checks only when separately appropriate; they are optional and do not " + "enable ordinary requirement retention. Local Todo completion does not close an " + "unmet overall requirement: use the existing frontier/replan or accurate wait/Stop. " + "The caller supplies raw facts; you author requirements. Retry the original operation " + "after response loss; reread and reconcile conflicts. Use this same planning invocation, " + "not an additional model call or a setup Todo." +) + def goal_planner_contract(*, fine_grained: bool = False) -> dict[str, Any]: planner = { @@ -56,6 +75,7 @@ def goal_planner_contract(*, fine_grained: bool = False) -> dict[str, Any]: "to strengthen delivery; replan uncertain later work." ), "first_delivery_guidance": _FIRST_DELIVERY_GUIDANCE, + "goal_requirements_guidance": _GOAL_REQUIREMENTS_GUIDANCE, } if fine_grained: planner["fine_grained_plan_horizon"] = ( @@ -226,7 +246,7 @@ def build_goal_start_prompt( Rules: 1. Identity: use verified binding/active contract; a stable unbound host gets a fresh public-safe agent. Existing lanes require explicit takeover plus bind/readback before Todo; never infer peers from Todo/worktree/arguments. Unknown host: `loopx agent-onboard --list-agent-types`. 2. Capability: only `selected_capability_route`; run entry/admission and its later `capability show`; never infer from text/URLs. Capability state owns facts; generic Todos schedule. -3. Todos: {todo_rule}. {_FIRST_DELIVERY_GUIDANCE} +3. Goal requirements and Todos: {_GOAL_REQUIREMENTS_GUIDANCE} {todo_rule}. {_FIRST_DELIVERY_GUIDANCE} 4. Writeback: current Todo evidence + next executable Todo, then `loopx refresh-state --goal-id {goal_id}` and quota readback. Chat/model summaries are not durable state. 5. Host loop: after Todo write, activate missing/unknown/stale/type-changed Codex App heartbeat automation, CLI/TraeX `/goal`, Claude `/loop`, OpenCode bridge, Ark one-shot, or custom gate. Else surface the exact pasteable gate; never claim autonomy. 6. Run the returned typed `quota_guard`; finish one bounded segment with validation + LoopX writeback or an exact blocker. Setup/planning/claim is not delivery. diff --git a/loopx/control_plane/goals/start_goal_todo_delta.py b/loopx/control_plane/goals/start_goal_todo_delta.py index 84ac4553a6..936b664546 100644 --- a/loopx/control_plane/goals/start_goal_todo_delta.py +++ b/loopx/control_plane/goals/start_goal_todo_delta.py @@ -133,6 +133,23 @@ def todo_authoring_steps( agent_id=agent_id, registry_path=registry_path, ) + prefix = render_cli_command_prefix(cli_bin=cli_bin, runtime_root=runtime_root) + if registry_path is not None: + prefix += f" --registry {shell_arg(str(registry_path))}" + acceptance = f"{prefix} --format json goal-acceptance" + route = f"--goal-id {shell_arg(str(goal_id))}" + if agent_id: + route += f" --agent-id {shell_arg(str(agent_id))}" + # The existing planning checkpoint owns declaration and later work linking. + # Keep their exact routed commands reachable without adding model stages. + requirement_commands = { + "inspect_command": f"{acceptance} inspect {route}", + "refine_command_template": ( + f"{acceptance} refine {route} --document '' " + "--expected-provider-revision '' " + "--operation-id '' --execute" + ), + } # This adapter already owns routed CLI templates for both planning entrypoints. readback = { "id": "read_back_authored_todos", @@ -147,15 +164,20 @@ def todo_authoring_steps( "purpose": ( "Read each authored/reused id: one match, todo_detail_projection.source_complete=true; " "compare .todo.text, status/claim. Excerpts cannot verify writes. " - "Missing/ambiguous/changed: reinspect before handoff. Readback grants no guard/lease authority." + "Read back the same overall requirements, provenance and work links too; " + "missing/ambiguous/changed: reinspect before handoff. Readback grants no guard/lease authority." ), } + readback["goal_requirements_readback"] = ( + "Repeat goal_requirements.inspect_command from the planning checkpoint after linking." + ) if not existing_runnable_frontier: return [ { "id": "plan_ranked_todos", "kind": "model_checkpoint", "prompt": plan_prompt, + "goal_requirements": requirement_commands, "purpose": ( "produce one public-safe, small, verifiable checkpoint Todo; keep " "later options as evidence-linked planning notes" @@ -182,6 +204,7 @@ def todo_authoring_steps( "id": "compare_planned_todos_with_frontier", "kind": "model_checkpoint", "prompt": plan_prompt, + "goal_requirements": requirement_commands, "purpose": ( "compare the continuation with existing_runnable_frontier " "by text/action_kind before any writeback" @@ -216,7 +239,13 @@ def append_todo_delta_render_line( raw_step: Mapping[str, Any], step_lines: list[str], ) -> None: - """Render the Todo-delta decision rule compactly for the markdown packet.""" + """Keep planning instructions reachable without repeating routed commands.""" + requirements = raw_step.get("goal_requirements") + if isinstance(requirements, Mapping): + step_lines.append( + " - overall requirements: reuse existing or declare missing requirements before Todos, link actual ids, then read back; " + "use this checkpoint's goal_requirements commands in the JSON detail packet." + ) todo_delta = raw_step.get("todo_delta") if not isinstance(todo_delta, Mapping): return diff --git a/loopx/control_plane/goals/task_planning.py b/loopx/control_plane/goals/task_planning.py index f12076289e..ccf9e86183 100644 --- a/loopx/control_plane/goals/task_planning.py +++ b/loopx/control_plane/goals/task_planning.py @@ -22,6 +22,7 @@ TODO_TASK_CLASS_USER_GATE, ) from .start_contract import goal_planner_contract +from .acceptance import inspect_goal_acceptance from .start_goal_todo_delta import todo_authoring_steps @@ -95,6 +96,10 @@ def build_task_planning_packet( ) identity = {"goal_id": goal_id, "agent_id": agent_id, "text": text} digest = hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest() + acceptance = inspect_goal_acceptance( + registry_path=registry_path, goal_id=goal_id, agent_id=agent_id, + runtime_root=runtime_root_arg, include_work_details=False, + ) return { "ok": True, "read_only": True, @@ -104,6 +109,11 @@ def build_task_planning_packet( **identity, "input_digest": digest, "planner": goal_planner_contract(fine_grained=fine), + # Planning needs declarations and revision-bound work links, not private + # check commands or a second copy of every task's validation details. + "goal_acceptance": {key: acceptance[key] for key in ( + "provider_revision", "source_authority", "goal_acceptance_contract", "planning_requirements", + )}, "ordered_steps": steps, "existing_todos": todos, "runnable_todo_ids": [ @@ -131,7 +141,9 @@ def build_task_planning_packet( "task_body": ( "Execute the LoopX task-planning checkpoint for this already registered Goal/Agent. " "Use the attached planner and ordered_steps, shared with /loopx. Read the exact text " - "and inspect the workspace as needed; make the approach and acceptance explicit " + "and inspect the workspace as needed; follow goal_requirements_guidance to " + "reuse existing requirements or declare missing sourced requirements through the existing outcome owner, " + "link actual work and read it back before ready. Make the approach explicit " "before writing task Todos through the routed public CLI. Compare all existing " "work and waits; reuse/update covered work and add only uncovered work. " "Do not create a planning/setup Todo, restart the Goal, complete task Todos, " diff --git a/loopx/control_plane/testing/cli_output_semantics.py b/loopx/control_plane/testing/cli_output_semantics.py index 536fd42ca0..2ec77d247a 100644 --- a/loopx/control_plane/testing/cli_output_semantics.py +++ b/loopx/control_plane/testing/cli_output_semantics.py @@ -68,7 +68,7 @@ def collect(value: Any) -> None: # not exact must not mask a later complete instruction. rendered_candidates = re.finditer( r"^\d+\. `read_back_authored_todos` \(operator_or_agent_actions\): (?P.*?)\n" - r" - command/source: `loopx [^\n]*--todo-id ''`$", + r" - (?:command/source: )?`loopx [^\n]*--todo-id ''`$", text, re.MULTILINE, ) observed["complete_todo_readback"] = any( diff --git a/loopx/slash_command_install.py b/loopx/slash_command_install.py index 96fd06cf96..48ce38034f 100644 --- a/loopx/slash_command_install.py +++ b/loopx/slash_command_install.py @@ -205,7 +205,8 @@ def _command_prompt_specs(*, cli_bin: str, include_legacy_aliases: bool) -> list host_surface=None, ), "Treat the returned `ordered_steps` and `goal_start_contract` as authoritative. Follow their identity, capability-route, Todo, writeback, host-loop, quota, and stop/gate rules before substantive work; do not reconstruct those rules from skill memory.", - "When the host explicitly supplies a `loopx_task_planning_v0` packet from `loopx todo plan` for a registered Goal/Agent, execute that bounded planning checkpoint instead of starting another Goal. Follow its shared planner and Todo delta, then return actual Todo ids for readback. Its caller-owned execution_handoff retains host activation and quota; do not create a planning Todo, execute task work, or claim delivery during the checkpoint.", + "During first planning, follow the shared goal_requirements_guidance: reuse existing owner-approved requirements and declare only missing source-grounded overall requirements through the existing outcome owner, link actual Todos and read back the canonical declaration. Preserve existing requirements on resume; maximum timeout is not a minimum work commitment and independent executable checks stay optional.", + "When the host explicitly supplies a `loopx_task_planning_v0` packet from `loopx todo plan` for a registered Goal/Agent, execute that bounded planning checkpoint instead of starting another Goal. Follow its shared planner, requirement declaration/readback and Todo delta in the same invocation, then return actual Todo ids for readback. Its caller-owned execution_handoff retains host activation and quota; do not create a planning Todo, execute task work, or claim delivery during the checkpoint.", "For a Codex App heartbeat, run the returned activation command, require ok=true, and save its `LoopX managed heartbeat bootstrap v2` task_body through automation_update. The saved loader fetches the current thin contract on every wake; do not persist a raw thin/compact/full execution body. Preserve the current goal, registered agent, task binding and existing schedule; read back the automation through the same App.", "If the packet exposes a goal-selection gate, rerun one exact choice before any mutation.", "When authoring task Todos, treat `--action-kind` as the documented extensible public-safe token: choose a short task-relevant value such as `implement`, `test`, or `review`; do not search the LoopX source for an allowlist.", diff --git a/tests/control_plane/test_cli_output_differential.py b/tests/control_plane/test_cli_output_differential.py index 536a0789c5..f0863d3bd3 100644 --- a/tests/control_plane/test_cli_output_differential.py +++ b/tests/control_plane/test_cli_output_differential.py @@ -382,7 +382,8 @@ def test_authoring_observations_require_complete_structured_or_rendered_guidance f"5. `read_back_authored_todos` (operator_or_agent_actions): {purpose}\n" f" - command/source: `{step['command_template']}`" ) - for text in (json.dumps({"steps": [step]}), rendered): + compact_rendered = rendered.replace(" - command/source: ", " - ") + for text in (json.dumps({"steps": [step]}), rendered, compact_rendered): assert authoring_input_observations(text)["complete_todo_readback"] assert not authoring_input_observations(text.replace("Excerpts cannot verify writes. ", ""))[ "complete_todo_readback" diff --git a/tests/control_plane/test_goal_create_completion.py b/tests/control_plane/test_goal_create_completion.py index c03bb20600..b0e25ec3b5 100644 --- a/tests/control_plane/test_goal_create_completion.py +++ b/tests/control_plane/test_goal_create_completion.py @@ -107,6 +107,33 @@ def test_full_approved_creation_text_is_not_truncated(environment, app, provider assert requirement["description"] == criteria +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_first_plan_reuses_creation_requirements_without_a_second_declaration(environment, app, provider): + from loopx.control_plane.goals.task_planning import build_task_planning_packet + from benchmark.runtime.planning import validate_plan_readback + + configure, _, _, project, runtime = environment + _, request, _ = app + configure(provider) + criteria = "\U0001f4dd" * 1000 # Main's creation limit is 2,000 UTF-16 units. + proposal = preview_goal(request, completion_criteria=criteria) + status, applied = request(f"/api/actions/{proposal['proposal_id']}/apply", {}) + assert status == 200, applied + before = inspect(project, runtime) + packet = build_task_planning_packet(registry_path=project / ".loopx/registry.json", + runtime_root_arg=str(runtime), goal_id="report", agent_id="codex", + project=project, text="Deliver the report.") + outcomes = packet["goal_acceptance"]["goal_acceptance_contract"]["outcome_requirements"]["outcomes"] + assert len(outcomes) == 1 and outcomes[0]["id"] == "goal-creation" + assert outcomes[0]["requirements"][0]["description"] == criteria + assert outcomes[0]["sources"][0]["ref"] == f"goal-create:{proposal['proposal_id']}:completion" + ids = applied["proposal"]["receipt"]["resource_ids"]["todo_ids"] + verified = validate_plan_readback({"input_digest": packet["input_digest"], + "status": "ready", "todo_ids": ids}, packet, packet) + assert verified["goal_requirements_readback"]["outcome_ids"] == ["goal-creation"] + assert inspect(project, runtime) == before, "Reuse is read-only, without another declaration or revision" + + def test_no_criteria_does_not_infer_requirements_from_objective(environment, app): _, _, _, project, runtime = environment _, request, _ = app @@ -181,7 +208,7 @@ def test_managed_creation_retains_requirements_before_one_host_dispatch(environm original = getattr(target, method) def lose_response(*args, **kwargs): - result = original(*args, **kwargs) + original(*args, **kwargs) raise TimeoutError("Committed creation response was lost") monkeypatch.setattr(target, method, lose_response) diff --git a/tests/control_plane/test_task_planning.py b/tests/control_plane/test_task_planning.py index 635047ea85..d40952be6d 100644 --- a/tests/control_plane/test_task_planning.py +++ b/tests/control_plane/test_task_planning.py @@ -20,8 +20,8 @@ from loopx.control_plane.goals.task_planning import build_task_planning_packet -@pytest.fixture -def bound_goal(tmp_path): +@pytest.fixture(params=["file", "sqlite"]) +def bound_goal(tmp_path, request): state = tmp_path / "state.md" state.write_text("# Active Goal State\n\n## Agent Todos\n\n## User Todos\n") registry = tmp_path / "registry.json" @@ -44,7 +44,7 @@ def bound_goal(tmp_path): ) ) initialize_canonical_source_fixture( - tmp_path / "runtime", "planning-goal", state_path=state, provider="sqlite", + tmp_path / "runtime", "planning-goal", state_path=state, provider=request.param, ) return dict( registry_path=registry, @@ -216,3 +216,63 @@ def test_public_cli_returns_a_read_only_checkpoint_and_rejects_execution(bound_g assert main(command + ["--execute"]) == 1 assert "unsupported" in json.loads(output.getvalue())["error"] assert not list(Path(bound_goal["runtime_root_arg"]).rglob("*rollout*")) + + +def test_first_plan_declares_then_links_and_reads_canonical_requirements(bound_goal): + def cli(*args): + out = io.StringIO() + with contextlib.redirect_stdout(out): + status = main(["--format", "json", "--registry", str(bound_goal["registry_path"]), + "--runtime-root", bound_goal["runtime_root_arg"], *args]) + result = json.loads(out.getvalue()) + assert status == 0, result + return result + + route = ["--goal-id", "planning-goal", "--agent-id", "planner"] + before = build_task_planning_packet(**bound_goal) + assert before["goal_acceptance"]["planning_requirements"]["linked_todo_ids"] == [] + assert before["existing_todos"] == [] + document = {"outcome_id": "repair", "owner_agent_id": "planner", "objective": bound_goal["text"], + "requirements": [{"id": "stable-api", "description": "Keep the API stable while repairing the failure."}], + "source": {"ref": "user-task", "summary": bound_goal["text"]}} + path = bound_goal["project"] / "requirements.json" + path.write_text(json.dumps(document)) + declaration = ["goal-acceptance", "refine", *route, "--document", str(path), + "--expected-provider-revision", before["goal_acceptance"]["provider_revision"], + "--operation-id", "first-declaration", "--execute"] + cli(*declaration) # The write committed, but its caller lost the response. + declared = cli(*declaration) + assert declared["status"] in {"replayed", "recovered"} + outcome = declared["goal_acceptance_contract"]["outcome_requirements"]["outcomes"][0] + assert outcome["todo_ids"] == [] + assert not build_task_planning_packet(**bound_goal)["existing_todos"], "Declaration precedes task Todos" + written = cli("todo", "add", "--goal-id", "planning-goal", "--role", "agent", "--claimed-by", "planner", + "--text", "[P0] Reproduce and repair the failure.", "--task-class", "advancement_task", + "--action-kind", "implement", "--execute") + current = cli("goal-acceptance", "inspect", *route) + path.write_text(json.dumps({"outcome_id": "repair", "todo_ids": [written["todo_id"]]})) + command = ["goal-acceptance", "refine", *route, "--document", str(path), + "--expected-provider-revision", current["provider_revision"], "--operation-id", "link-first-work", "--execute"] + linked = cli(*command) + replay = cli(*command) + assert replay["status"] in {"replayed", "recovered"} + assert replay["goal_acceptance_contract"] == linked["goal_acceptance_contract"] + after = build_task_planning_packet(**bound_goal) + assert after["goal_acceptance"]["planning_requirements"] == { + "agent_id": "planner", "declaration_current": True, + "outcome_ids": ["repair"], "linked_todo_ids": [written["todo_id"]], + } + assert after["goal_acceptance"]["goal_acceptance_contract"].get("criteria", []) == [], "Independent checks stay optional" + assert after["goal_acceptance"]["goal_acceptance_contract"]["outcome_requirements"]["outcomes"][0]["sources"] == [document["source"]] + + +def test_shared_planning_preserves_open_intent_and_stop_in_both_entries(bound_goal): + guidance = goal_planner_contract()["goal_requirements_guidance"] + for requirement in ("source-grounded overall requirements", "an open-ended objective stays open-ended", + "maximum timeout is not a requirement to spend it", "independent executable checks", "optional", + "Unchanged declarations need no rewrite", "reuse the existing outcome", + "only for missing requirements", "accurate wait/Stop", "not an additional model call"): + assert requirement in guidance + packet = build_task_planning_packet(**bound_goal) + assert packet["planner"]["goal_requirements_guidance"] == guidance + assert guidance in build_goal_start_prompt(goal_text=bound_goal["text"], goal_id="planning-goal", agent_id="planner", fine_grained=False) diff --git a/tests/control_plane_ts/goal_outcome_refinement.test.ts b/tests/control_plane_ts/goal_outcome_refinement.test.ts index c251a22112..e4b045ed66 100644 --- a/tests/control_plane_ts/goal_outcome_refinement.test.ts +++ b/tests/control_plane_ts/goal_outcome_refinement.test.ts @@ -112,8 +112,15 @@ for (const provider of ["file", "sqlite"] as const) test(`${provider}: one canon assert.equal(empty.metadata.goal_acceptance_contract, undefined); assert.equal(empty.metadata.goal_acceptance_oracle_observation_basis, undefined); const basis = await inspectGoalAcceptance(store, goal); + const planning = () => inspectGoalAcceptance(store, goal, undefined, undefined, false, "author"); + assert.deepEqual((await planning()).planning_requirements, {agent_id: "author", declaration_current: false, + outcome_ids: [], linked_todo_ids: []}); assert.equal((await refineGoalAcceptanceOutcome(store, {goal_id: goal, actor_agent_id: "author", operation_id: "outcome", expected_provider_revision: basis.provider_revision, refinement: patch()})).status, "applied"); + assert.deepEqual((await planning()).planning_requirements, {agent_id: "author", declaration_current: true, + outcome_ids: ["tour"], linked_todo_ids: ["todo_tour"]}); + assert.deepEqual((await inspectGoalAcceptance(store, goal, undefined, undefined, false, "peer")).planning_requirements, + {agent_id: "peer", declaration_current: false, outcome_ids: [], linked_todo_ids: []}); assert.equal((await snapshot()).metadata.goal_acceptance_oracle_observation_basis, undefined, "ordinary outcome declarations do not authorize host artifact reads"); const current = await inspectGoalAcceptance(store, goal); diff --git a/tests/control_plane_ts/postgresql_authority_store.integration.test.ts b/tests/control_plane_ts/postgresql_authority_store.integration.test.ts index 4841898d76..888d1c6af7 100644 --- a/tests/control_plane_ts/postgresql_authority_store.integration.test.ts +++ b/tests/control_plane_ts/postgresql_authority_store.integration.test.ts @@ -20,6 +20,9 @@ import { import { openLocalAuthorityStoreHandle } from "../../loopx/control_plane/coordination/local_authority_provider.ts"; import {readLocalCoordinationTodoSource} from "../../loopx/control_plane/coordination/local_authority_read.ts"; import {coordinationTodoReadModel} from "../../loopx/control_plane/coordination/coordination_projection.ts"; +import {inspectGoalAcceptance, refineGoalAcceptanceOutcome} from "../../loopx/control_plane/goals/acceptance_authority.ts"; +import {authorityProjectionFixture} from "./authority_projection_fixture.ts"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; import { authorityStoreCommitFixture as commit, registerAuthorityStoreConformance, @@ -121,6 +124,35 @@ async function cleanScope(tenantId: string, goalId: string): Promise { } if (database && installed) { + test("PostgreSQL planning reads the Agent declaration and work links from the canonical head", async t => { + await installed; + const options = {tenant_id: `tenant-${randomUUID()}`, goal_id: `goal-${randomUUID()}`}; + t.after(() => cleanScope(options.tenant_id, options.goal_id)); + const store = new PostgreSqlAuthorityStore(database, options); + const projection = authorityProjectionFixture(options.goal_id, [{todo_id: "todo_plan", role: "agent", + status: "open", done: false, archive_state: "active", text: "Implement the requested outcome", + task_class: "advancement_task", claimed_by: "author"}]); + await store.commitAuthority({expected_provider_revision: null, operation_id: "seed-planning", + events: [], receipts: [], next_projection: projection}); + const inspect = () => inspectGoalAcceptance(store, options.goal_id, undefined, undefined, false, "author"); + const before = await inspect(); + assert.equal((before.planning_requirements as JsonObject).declaration_current, false); + const result = await refineGoalAcceptanceOutcome(store, {goal_id: options.goal_id, + actor_agent_id: "author", expected_provider_revision: before.provider_revision, operation_id: "declare-planning", + refinement: {outcome_id: "requested", owner_agent_id: "author", objective: "Deliver the requested outcome", + requirements: [{id: "complete", description: "Complete the requested implementation"}], + todo_ids: ["todo_plan"], source: {ref: "message:request", summary: "The owner requested this implementation"}}}); + assert.equal(result.status, "applied"); + const after = await inspect(); + const loaded = await store.loadAuthority(); + assert.equal(loaded.status, "loaded"); + if (loaded.status !== "loaded") return; + assert.equal(after.provider_revision, loaded.provider_revision); + assert.deepEqual(after.planning_requirements, {agent_id: "author", declaration_current: true, + outcome_ids: ["requested"], linked_todo_ids: ["todo_plan"]}); + assert.deepEqual((after.goal_acceptance_contract as JsonObject).criteria ?? [], [], "Independent executable checks remain optional"); + }); + test("PostgreSQL historical Todo source retains the original frontier", async t => { await installed; const options = {tenant_id: `tenant-${randomUUID()}`, goal_id: `goal-${randomUUID()}`};