Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions benchmark/runtime/planning.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,20 @@ 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,
"todo_ids": ids,
"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",
}
73 changes: 72 additions & 1 deletion benchmark/tests/test_task_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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))
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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"
Expand All @@ -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(
Expand Down
19 changes: 19 additions & 0 deletions docs/reference/goal-acceptance-observations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion loopx/bootstrap_command_pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
3 changes: 2 additions & 1 deletion loopx/control_plane/goals/acceptance_authority.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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});
}

Expand Down
13 changes: 13 additions & 0 deletions loopx/control_plane/goals/outcome_requirements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 21 additions & 1 deletion loopx/control_plane/goals/start_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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"] = (
Expand Down Expand Up @@ -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.
Expand Down
33 changes: 31 additions & 2 deletions loopx/control_plane/goals/start_goal_todo_delta.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<agent-authored-file>' "
"--expected-provider-revision '<current-provider-revision>' "
"--operation-id '<stable-original-operation-id>' --execute"
),
}
# This adapter already owns routed CLI templates for both planning entrypoints.
readback = {
"id": "read_back_authored_todos",
Expand All @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading