From 159a04f6ffcc5cbef38c17ec46a708142fbbee58 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:18:15 +0800 Subject: [PATCH 1/4] feat(explore): unify evidence and planning with turn-start context Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../capability-configuration-fields.tsx | 4 +- .../capability-localization.ts | 15 +- .../capability-workbench.tsx | 2 +- .../goal-capability-settings.tsx | 2 +- loopx/capabilities/configuration_ui.py | 5 +- loopx/capabilities/explore/activation.py | 7 +- loopx/capabilities/explore/catalog_entry.py | 12 +- loopx/capabilities/explore/turn_context.py | 160 ++++++++++++++++++ loopx/chat_goal_configuration_api.py | 9 +- loopx/cli_commands/explore.py | 11 ++ loopx/cli_commands/quota.py | 4 + loopx/cli_commands/registry_admin.py | 1 + .../cli_commands/registry_admin_configure.py | 6 +- loopx/cli_commands/turn.py | 4 + loopx/configuration_catalog.py | 69 ++------ loopx/configure_goal.py | 16 +- .../capabilities/explore_configuration.ts | 47 +++++ .../capabilities/explore_turn_context.ts | 48 ++++++ .../control_plane/effect_runtime_handlers.ts | 3 + loopx/control_plane/quota/goal_boundary.py | 4 +- loopx/explore_graph.py | 40 ++++- loopx/history.py | 4 +- loopx/registry.py | 6 +- .../project_registry_io_manifest_v1.json | 12 +- 24 files changed, 392 insertions(+), 99 deletions(-) create mode 100644 loopx/capabilities/explore/turn_context.py create mode 100644 loopx/control_plane/capabilities/explore_configuration.ts create mode 100644 loopx/control_plane/capabilities/explore_turn_context.ts diff --git a/apps/presentation/dashboard/src/features/personal-workspace/capability-configuration-fields.tsx b/apps/presentation/dashboard/src/features/personal-workspace/capability-configuration-fields.tsx index eeea715829..333eb74967 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/capability-configuration-fields.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/capability-configuration-fields.tsx @@ -3,7 +3,7 @@ import { useId, type ReactNode } from "react"; import type { CapabilityConfigurationEditor } from "../../data/chat"; import { PeriodicReportScheduleField } from "./periodic-report-schedule-field"; -type FieldCopy = Record; +type FieldCopy = Record }>; type ConfigurationField = CapabilityConfigurationEditor["fields"][number]; type FieldValue = boolean | number | string | string[] | Record | null; type FieldChange = (key: string, value: FieldValue) => void; @@ -40,7 +40,7 @@ function ConfigurationFieldControl({ copy, field, id, onChange, value, timezone {label} ); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts b/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts index 005a9afcad..440450bffd 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts @@ -8,7 +8,7 @@ type LocalizedCopy = Readonly<{ readOnlyReason?: string; }>; -type FieldCopy = Record>; +type FieldCopy = Record }>>; const capabilityCopy: Record> = { en: { @@ -36,7 +36,7 @@ const capabilityCopy: Record> = { }, explore_harness: { displayName: "Explore Harness", - description: "Selects a capability-owned planning and research harness profile for bounded multi-step exploration.", + description: "Keeps an evidence graph of exploration, with optional branch planning. Planning includes evidence; worker permissions remain separate.", }, lark_event_inbox: { displayName: "Lark event inbox", @@ -102,7 +102,7 @@ const capabilityCopy: Record> = { }, explore_harness: { displayName: "探索 Harness", - description: "为有界的多步探索选择由能力负责的规划与研究 Harness profile。", + description: "记录探索证据图谱,可选开启分支规划;规划自动配套证据层,派生 Agent 权限独立。", }, lark_event_inbox: { displayName: "飞书事件收件箱", @@ -223,7 +223,14 @@ export function localizeCapability( }; } -export function localizedCapabilityFieldCopy(locale: WorkspaceLocale): FieldCopy { +export function localizedCapabilityFieldCopy(locale: WorkspaceLocale, capabilityId?: string): FieldCopy { + if (capabilityId === "explore_harness") return {...fieldCopy[locale], mode: locale === "zh-CN" ? { + label: "探索模式", description: "规划包含证据图谱;派生 Agent 和执行权限仍单独控制。", + options: {off: "关闭", evidence: "仅记录证据", planning: "证据与规划"}, + } : { + label: "Exploration mode", description: "Planning includes the evidence graph; worker and execution permissions remain separate.", + options: {off: "Off", evidence: "Evidence only", planning: "Evidence and planning"}, + }}; return fieldCopy[locale]; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/capability-workbench.tsx b/apps/presentation/dashboard/src/features/personal-workspace/capability-workbench.tsx index 0465b212cc..78c3f9ad8d 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/capability-workbench.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/capability-workbench.tsx @@ -156,7 +156,7 @@ export function CapabilityDetailHeader({ capability, locale, source }: Readonly< {locale === "zh-CN" ? "配置说明" : "Configuration help"}

{localized.description}

{capability.configuration_editor.fields.map((field) => { - const copy = localizedCapabilityFieldCopy(locale)[field.key]; + const copy = localizedCapabilityFieldCopy(locale, capability.capability_id)[field.key]; const description = copy?.description ?? field.description; return description ?
{copy?.label ?? field.label}
{description}
: null; })}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx index 3aec0a83c7..42fd6fa399 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx @@ -299,7 +299,7 @@ function CapabilityCatalog({ callbacks, catalog, goalId, notification, onApplied {editorMode === "guided" ?
dict[str, Any]: """Flush an enabled graph after the goal-level refresh transaction. - Explore Graph activation is independent from Explore Harness planning. A + Explore Harness planning includes its evidence graph. A configured graph reuses the existing idempotent projection/sink adapter; disabled or absent policy performs no graph reads or writes. """ goal = load_goal_from_registry(registry_path, goal_id) policy = compact_explore_graph_policy( - goal.get("explore_graph") if isinstance(goal, dict) else None + (goal or {}).get("explore_graph"), ((goal or {}).get("spawn_policy") or {}).get("explore_harness") + ) + external_sink_delivery_authorized = external_sink_delivery_authorized and ( + ((goal or {}).get("explore_graph") or {}).get("enabled") is True ) base = { "ok": True, diff --git a/loopx/capabilities/explore/catalog_entry.py b/loopx/capabilities/explore/catalog_entry.py index 665ce0c53f..d05748dcc2 100644 --- a/loopx/capabilities/explore/catalog_entry.py +++ b/loopx/capabilities/explore/catalog_entry.py @@ -13,7 +13,7 @@ "site_root": "capabilities/explore", "canonical": "README.md", }, - "title": "Explore evidence topology", + "title": "Explore Harness", "status": "active-preview", "real_world_anchor": ( "goal-scoped questions, hypotheses, experiments, findings, and result projections" @@ -24,6 +24,16 @@ ), "entry_command": "loopx explore summary --goal-id --format json", "commands": [ + { + "command": "loopx configure-goal --goal-id --explore-mode planning", + "purpose": "Preview evidence-and-planning mode; use evidence for graph only or off to disable.", + "write_boundary": "preview only; --execute applies configuration without granting spawn authority", + }, + { + "command": "loopx explore turn-context --goal-id --agent-id --format json", + "purpose": "Read the bounded context requested by the enabled turn-start hook.", + "write_boundary": "read-only; no evidence, claim, lease, worker or quota writes", + }, { "command": "loopx explore finding --goal-id --title --evidence-ref --format json", "purpose": "Append one public-safe, attributable finding to the canonical result log.", diff --git a/loopx/capabilities/explore/turn_context.py b/loopx/capabilities/explore/turn_context.py new file mode 100644 index 0000000000..e29aa005a9 --- /dev/null +++ b/loopx/capabilities/explore/turn_context.py @@ -0,0 +1,160 @@ +"""Read adapters for Explore's typed, bounded turn context and existing hooks.""" + +from pathlib import Path +import shlex + +from ...agent_registry import load_goal_from_registry, require_registered_agent_id +from ...control_plane.capability_hooks import ( + TURN_START_HOOK_RESULT_SCHEMA_VERSION, + TurnStartHookRegistration, + dispatch_turn_start_hooks, +) +from ...control_plane.effect_runtime import effect_runtime_result +from ...explore_graph import compact_explore_graph_policy +from ...todos import list_goal_todos +from .result_log import ( + build_explore_result_projection, + explore_result_log_path, + load_explore_result_events, +) +from .todo_branch_plan import ( + build_explore_todo_branch_plan, + resolve_todo_branch_plan_gate, +) + + +def _policy(registry_path, goal_id): + goal = load_goal_from_registry(registry_path, goal_id) + if goal is None: + raise ValueError(f"Goal {goal_id!r} is not registered") + graph = compact_explore_graph_policy( + goal.get("explore_graph"), + (goal.get("spawn_policy") or {}).get("explore_harness"), + )["enabled"] + gate = resolve_todo_branch_plan_gate(goal.get("spawn_policy"), requested_width=3) + return goal, graph, gate + + +def explore_turn_context( + *, registry_path: Path, runtime_root: Path, goal_id: str, agent_id: str +): + goal, graph, gate = _policy(registry_path, goal_id) + require_registered_agent_id( + registry_path=registry_path, goal_id=goal_id, agent_id=agent_id + ) + projection, plan = {}, {} + if graph or gate["enabled"]: + events = load_explore_result_events( + explore_result_log_path(runtime_root, goal_id), goal_id=goal_id + ) + projection = build_explore_result_projection( + events, goal_id=goal_id, finding_limit=3, mermaid_node_limit=3 + ) + if gate["enabled"]: + todos = list_goal_todos( + registry_path=registry_path, + goal_id=goal_id, + role="agent", + status="open", + agent_id=agent_id, + runtime_root_arg=str(runtime_root), + ) + plan = build_explore_todo_branch_plan( + goal_id=goal_id, + agent_id=agent_id, + todos=todos.get("todos") or [], + projection=projection, + orchestration=goal.get("spawn_policy"), + width=3, + ) + route = [ + "loopx", + "--registry", + str(registry_path), + "--runtime-root", + str(runtime_root), + "--format", + "json", + ] + return effect_runtime_result( + "explore.turn_context", + { + "goal_id": goal_id, + "agent_id": agent_id, + "graph_enabled": graph, + "harness_gate": gate, + "projection": projection, + "plan": plan, + "route": route, + }, + ) + + +def extend_turn_start_dispatch( + dispatch, + *, + registry_path: Path, + runtime_root: Path, + goal_id: str, + agent_id: str | None, +): + if not agent_id: + return dispatch + _, graph, gate = _policy(registry_path, goal_id) + if not graph and not gate["enabled"]: + return dispatch + + def produce(): + return { + "schema_version": TURN_START_HOOK_RESULT_SCHEMA_VERSION, + "hook_id": "explore.turn_context", + "capability_id": "explore", + "phase": "turn_start", + "status": "observed", + "observation_count": 1, + "agent_read_required": True, + "external_reads_performed": False, + "external_writes_performed": False, + "local_private_state_mutated": False, + "private_content_returned": False, + "provider_payload_returned": False, + "error_code": None, + } + + command = shlex.join( + [ + "loopx", + "--registry", + str(registry_path), + "--runtime-root", + str(runtime_root), + "--format", + "json", + "explore", + "turn-context", + "--goal-id", + goal_id, + "--agent-id", + agent_id, + ] + ) + hook = TurnStartHookRegistration( + hook_id="explore.turn_context", + capability_id="explore", + requested_read_scope=("goal_capability_configuration",), + requested_write_scope=(), + producer=produce, + required_read={ + "kind": "explore_turn_context", + "command": command, + "reason": "Read enabled Explore evidence and branch guidance before choosing work. Preserve negative evidence; planning grants no claim, spawn or execution authority.", + "ordering": "before_work", + }, + ) + extra = dispatch_turn_start_hooks((hook,)) + result = dict(dispatch or {}) + for key in ("results", "required_reads", "failures"): + result[key] = list(result.get(key) or []) + list(extra.get(key) or []) + for key in ("registered_count", "invoked_count"): + result[key] = int(result.get(key) or 0) + int(extra.get(key) or 0) + return result diff --git a/loopx/chat_goal_configuration_api.py b/loopx/chat_goal_configuration_api.py index e66eeac997..98546c9baf 100644 --- a/loopx/chat_goal_configuration_api.py +++ b/loopx/chat_goal_configuration_api.py @@ -112,10 +112,11 @@ def _peer_task_coordination_options(config: Mapping[str, Any]) -> dict[str, Any] def _explore_harness_options(config: Mapping[str, Any]) -> dict[str, Any]: profile = str(config.get("profile") or "").strip() or None + if "mode" in config and "enabled" in config: + raise ValueError("Use Explore mode or the legacy enabled flag, not both") return { - "explore_harness_enabled": _boolean_configuration( - "explore_harness", config, "enabled" - ), + **({"explore_mode": config["mode"]} if "mode" in config else { + "explore_harness_enabled": _boolean_configuration("explore_harness", config, "enabled")}), "explore_harness_profile": profile, "clear_explore_harness_profile": profile is None, } @@ -215,7 +216,7 @@ def _goal_capability_options( }, "peer_task_coordination": {"coordinator_agent_id"}, "explore_graph": {"enabled"}, - "explore_harness": {"enabled", "profile"}, + "explore_harness": {"mode", "enabled", "profile"}, "pull_request_review": {"wait_for_ci", "review_priority"}, "change_quality_qualification": {"enabled", "safe_fix", "strict_receipt"}, "progress_review": {"mode", "signal", "drift_threshold", "contract_revision"}, diff --git a/loopx/cli_commands/explore.py b/loopx/cli_commands/explore.py index 8dfc604ab9..7fc2597c6d 100644 --- a/loopx/cli_commands/explore.py +++ b/loopx/cli_commands/explore.py @@ -80,6 +80,11 @@ def register_explore_commands( ) sub = parser.add_subparsers(dest="explore_command", required=True) + context = sub.add_parser("turn-context", help="Read bounded enabled Explore evidence and planning guidance for an agent turn.") + add_subcommand_format(context) + context.add_argument("--goal-id", required=True) + context.add_argument("--agent-id", required=True) + schema = sub.add_parser("schema", help="Print the result-board schema and LoopX mapping.") add_subcommand_format(schema) @@ -306,6 +311,8 @@ def _tree_lines(tree: object, *, indent: int = 0) -> list[str]: def render_explore_markdown(payload: dict[str, object]) -> str: + if "graph_enabled" in payload and "harness_enabled" in payload: + return "# Explore Harness turn context\n\n```json\n" + json.dumps(payload, ensure_ascii=False, indent=2) + "\n```\n" lines = ["# LoopX Explore", ""] if not payload.get("ok"): lines.extend([f"- ok: `{payload.get('ok')}`", f"- error: `{payload.get('error')}`", ""]) @@ -494,6 +501,10 @@ def handle_explore_command( ) if args.explore_command == "schema": payload = lark_explore_schema_payload() + elif args.explore_command == "turn-context": + from ..capabilities.explore.turn_context import explore_turn_context + payload = explore_turn_context(registry_path=Path(str(source_runtime_route["source_registry"])), runtime_root=runtime_root, + goal_id=args.goal_id, agent_id=args.agent_id) elif args.explore_command == "node": event = build_explore_node_event( goal_id=args.goal_id, diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index 72f9a3ab3d..c7fb43df53 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -329,6 +329,10 @@ def _dispatch_quota_turn_start_hooks( ) dispatch = extend_cadence_turn_start_dispatch(dispatch, registry_path=registry_path, runtime_root=root, goal_id=args.goal_id, agent_id=args.agent_id) + if args.agent_id: + from ..capabilities.explore.turn_context import extend_turn_start_dispatch as extend_explore + dispatch = extend_explore(dispatch, registry_path=registry_path, runtime_root=root, + goal_id=args.goal_id, agent_id=args.agent_id) local_private_state_mutated = any( isinstance(result, Mapping) and result.get("local_private_state_mutated") is True diff --git a/loopx/cli_commands/registry_admin.py b/loopx/cli_commands/registry_admin.py index ff74cd6cd0..560dd8f0ba 100644 --- a/loopx/cli_commands/registry_admin.py +++ b/loopx/cli_commands/registry_admin.py @@ -493,6 +493,7 @@ def handle_registry_admin_command( explore_harness_profile=args.explore_harness_profile, clear_explore_harness_profile=bool(args.clear_explore_harness_profile), explore_graph_enabled=args.explore_graph_enabled, + explore_mode=args.explore_mode, lark_kanban_heartbeat_sync=args.lark_kanban_heartbeat_sync, registered_agents=args.registered_agents, clear_registered_agents=bool(args.clear_registered_agents), diff --git a/loopx/cli_commands/registry_admin_configure.py b/loopx/cli_commands/registry_admin_configure.py index 68c52344d6..4d9b9174cf 100644 --- a/loopx/cli_commands/registry_admin_configure.py +++ b/loopx/cli_commands/registry_admin_configure.py @@ -212,13 +212,17 @@ def register_configure_goal_command(subparsers: argparse._SubParsersAction) -> N action="store_true", help="Clear allowed child-agent domains.", ) + configure_goal_parser.add_argument( + "--explore-mode", choices=("off", "evidence", "planning"), + help="Explore Harness: off, evidence only, or evidence with read-only planning. Does not grant spawn authority.", + ) configure_goal_parser.add_argument( "--explore-graph-enabled", action=argparse.BooleanOptionalAction, default=None, help=( "Enable or disable automatic Explore Graph projection at material " - "refresh boundaries. This is independent from Explore Harness planning." + "refresh boundaries. Legacy alias for the Explore Harness evidence layer." ), ) configure_goal_parser.add_argument( diff --git a/loopx/cli_commands/turn.py b/loopx/cli_commands/turn.py index f2d34ddbd4..d136c9a519 100644 --- a/loopx/cli_commands/turn.py +++ b/loopx/cli_commands/turn.py @@ -134,6 +134,10 @@ def handle_turn_command( from ..capabilities.semantic_preference.agent_preferences import extend_turn_start_dispatch as extend_preferences turn_start_hook_dispatch = extend_preferences(turn_start_hook_dispatch, runtime_root=runtime_root, registry_path=registry_path, goal_id=args.goal_id, agent_id=args.agent_id) + from ..capabilities.explore.turn_context import extend_turn_start_dispatch as extend_explore + turn_start_hook_dispatch = extend_explore(turn_start_hook_dispatch, + registry_path=registry_path, runtime_root=runtime_root, + goal_id=args.goal_id, agent_id=args.agent_id) # `run-once` and `managed-step` must resolve the same governing decision # from the same live status, scheduler context and capability hooks, so # this Turn takes all of them -- and its later settle-against inputs -- diff --git a/loopx/configuration_catalog.py b/loopx/configuration_catalog.py index 6cb7a535d9..60e668848e 100644 --- a/loopx/configuration_catalog.py +++ b/loopx/configuration_catalog.py @@ -6,6 +6,7 @@ from .capabilities.configuration_ui import build_capability_configuration_catalog from .control_plane.agent_context import agent_context_descriptor +from .explore_graph import explore_configuration from .control_plane.goals.goal_vision_policy import completed_todo_replan_threshold DEFAULT_MULTI_SUBAGENT_MAX_CHILDREN = 2 @@ -116,9 +117,9 @@ def build_goal_configuration_catalog( if isinstance(feature_summary.get("coordination_runtime_shadow"), Mapping) else {} ) - graph_enable_args = ("--explore-graph-enabled",) + explore = explore_configuration(graph, harness) harness_enable_args = ( - "--explore-harness-enabled", + "--explore-mode", "planning", "--explore-harness-profile", "generic", ) @@ -476,62 +477,13 @@ def build_goal_configuration_catalog( "path": "loopx/capabilities/progress_review/README.md", }, }, - { - "feature_id": "explore_graph", - "display_name": "Explore Graph", - "availability": "supported_opt_in", - "default": {"enabled": False}, - "current": {"enabled": graph.get("enabled") is True}, - "consider_when": ( - "The goal needs a durable topology of hypotheses, evidence, decisions, " - "or an already configured operator-facing graph sink." - ), - "effect": "Projects durable Explore evidence after material refreshes.", - "does_not": [ - "enable Explore Harness", - "spawn workers, claim todos, or spend quota by itself", - ], - "commands": { - "preview_enable": _configure_command(goal_id, *graph_enable_args), - "apply_enable": _configure_command( - goal_id, *graph_enable_args, execute=True - ), - "preview_disable": _configure_command( - goal_id, "--no-explore-graph-enabled" - ), - "apply_disable": _configure_command( - goal_id, "--no-explore-graph-enabled", execute=True - ), - "verify": [ - inspect_command, - shlex.join( - [ - "loopx", - "explore", - "graph", - "--goal-id", - goal_id, - "--graph-format", - "mermaid", - ] - ), - ], - }, - "documentation": { - "path": "loopx/capabilities/explore/README.md", - "url": ( - "https://github.com/loopx-project/loopx/blob/main/" - "loopx/capabilities/explore/README.md" - ), - }, - }, { "feature_id": "explore_harness", "display_name": "Explore Harness", "availability": "supported_opt_in", - "default": {"enabled": False, "profile": "generic"}, + "default": {"mode": "off", "profile": "generic"}, "current": { - "enabled": harness.get("enabled") is True, + "mode": explore["mode"], "profile": harness.get("profile"), }, "profiles": list(explore_harness_profiles), @@ -539,21 +491,22 @@ def build_goal_configuration_catalog( "The goal benefits from comparing alternative branches with explicit " "evaluation criteria and guardrails." ), - "effect": "Enables read-only Explore branch and worker-lane planning.", + "effect": "Records durable exploration evidence, with optional read-only branch planning. Planning includes the evidence graph.", "does_not": [ - "enable Explore Graph", - "launch workers, claim todos, acquire leases, mutate state, or spend quota", + "grant permission to launch workers, claim todos, acquire leases or spend quota", ], "commands": { + "preview_evidence_only": _configure_command(goal_id, "--explore-mode", "evidence"), + "apply_evidence_only": _configure_command(goal_id, "--explore-mode", "evidence", execute=True), "preview_enable": _configure_command(goal_id, *harness_enable_args), "apply_enable": _configure_command( goal_id, *harness_enable_args, execute=True ), "preview_disable": _configure_command( - goal_id, "--no-explore-harness-enabled" + goal_id, "--explore-mode", "off" ), "apply_disable": _configure_command( - goal_id, "--no-explore-harness-enabled", execute=True + goal_id, "--explore-mode", "off", execute=True ), "verify": [ inspect_command, diff --git a/loopx/configure_goal.py b/loopx/configure_goal.py index a3c01fcd98..4072bcdbf8 100644 --- a/loopx/configure_goal.py +++ b/loopx/configure_goal.py @@ -66,7 +66,7 @@ apply_goal_execution_profile_change, compact_execution_profile, ) -from .explore_graph import compact_explore_graph_policy +from .explore_graph import compact_explore_graph_policy, explore_configuration, plan_explore_configuration from .orchestration import ( EXPLORE_HARNESS_PROFILES, MULTI_SUBAGENT_ORCHESTRATION_MODE, @@ -265,7 +265,7 @@ def _settings_summary(goal: dict[str, Any]) -> dict[str, Any]: "change_quality_qualification": change_quality_goal_policy_summary(goal), "progress_review": progress_review_config.configuration_summary(goal), "goal_capability_organization": improvement_config.configuration_summary(goal), - "explore_graph": compact_explore_graph_policy(goal.get("explore_graph")), + "explore_graph": compact_explore_graph_policy(goal.get("explore_graph"), orchestration.get("explore_harness")), "orchestration": orchestration, "waiting_on": goal.get("waiting_on"), "write_scope": normalize_goal_write_scope(coordination.get("write_scope") or []) @@ -472,6 +472,7 @@ def configure_goal( explore_harness_profile: str | None = None, clear_explore_harness_profile: bool = False, explore_graph_enabled: bool | None = None, + explore_mode: str | None = None, registered_agents: list[str] | None = None, clear_registered_agents: bool = False, peer_task_coordinator: str | None = None, @@ -965,7 +966,16 @@ def configure_goal( apply_reward_memory_goal_configuration(goal, reward_memory_plan) - if explore_graph_enabled is not None: + if any(value is not None for value in (explore_mode, explore_graph_enabled, explore_harness_enabled)): + existing_harness = (goal.get("spawn_policy") or {}).get("explore_harness") + explore_plan = plan_explore_configuration( + explore_configuration(goal.get("explore_graph"), existing_harness), + {"mode": explore_mode, "evidence_enabled": explore_graph_enabled, + "planning_enabled": explore_harness_enabled}, + ) + explore_graph_enabled = explore_plan["evidence_enabled"] + if explore_mode is not None or explore_harness_enabled is not None or existing_harness: + explore_harness_enabled = explore_plan["planning_enabled"] goal["explore_graph"] = {"enabled": explore_graph_enabled} if ( diff --git a/loopx/control_plane/capabilities/explore_configuration.ts b/loopx/control_plane/capabilities/explore_configuration.ts new file mode 100644 index 0000000000..d21798d756 --- /dev/null +++ b/loopx/control_plane/capabilities/explore_configuration.ts @@ -0,0 +1,47 @@ +/** Explore Harness product modes; legacy storage fields remain replayable. */ +import type {JsonObject} from "../effect_program.ts"; +import {requireJsonObject} from "../runtime_decode.ts"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; + +export const EXPLORE_MODES = ["off", "evidence", "planning"] as const; +export type ExploreMode = typeof EXPLORE_MODES[number]; + +function mode(value: unknown): ExploreMode { + if (!EXPLORE_MODES.includes(value as ExploreMode)) { + throw new EffectRuntimeRequestError("explore_mode must be off, evidence or planning"); + } + return value as ExploreMode; +} + +export function resolveExploreConfiguration(params: JsonObject): JsonObject { + const planning = params.planning_enabled === true; + const evidence = params.evidence_enabled === true || planning; + return {mode: planning ? "planning" : evidence ? "evidence" : "off", + evidence_enabled: evidence, planning_enabled: planning}; +} + +export function planExploreConfiguration(params: JsonObject): JsonObject { + const current = requireJsonObject(params.current, "Explore current configuration"); + const changes = requireJsonObject(params.changes, "Explore configuration changes"); + const desired = changes.mode; + const graph = changes.evidence_enabled; + const harness = changes.planning_enabled; + if (desired != null && (graph != null || harness != null)) { + throw new EffectRuntimeRequestError("Use --explore-mode or legacy Explore flags, not both"); + } + if (desired != null) { + const selected = mode(desired); + return {mode: selected, evidence_enabled: selected !== "off", planning_enabled: selected === "planning"}; + } + for (const value of [graph, harness]) { + if (value != null && typeof value !== "boolean") { + throw new EffectRuntimeRequestError("Explore enable flags must be boolean"); + } + } + const planning = harness ?? current.planning_enabled === true; + if (graph === false && planning) { + throw new EffectRuntimeRequestError("Explore planning requires its evidence graph; use --explore-mode off to disable both, or --explore-mode evidence to keep evidence only"); + } + return resolveExploreConfiguration({planning_enabled: planning, + evidence_enabled: graph ?? current.evidence_enabled === true}); +} diff --git a/loopx/control_plane/capabilities/explore_turn_context.ts b/loopx/control_plane/capabilities/explore_turn_context.ts new file mode 100644 index 0000000000..4420105baf --- /dev/null +++ b/loopx/control_plane/capabilities/explore_turn_context.ts @@ -0,0 +1,48 @@ +/** Compact Explore read model. Existing Graph/Harness owners retain all gates. */ +import type {JsonObject} from "../effect_program.ts"; +import {requireJsonObject, requireNonEmptyString, requireStringArray} from "../runtime_decode.ts"; + +function rows(value: unknown): JsonObject[] { + return Array.isArray(value) ? value.map(item => requireJsonObject(item, "Explore row")) : []; +} +function compact(row: JsonObject, fields: string[]): JsonObject { + return Object.fromEntries(fields.filter(key => row[key] !== undefined) + .map(key => [key, typeof row[key] === "string" ? (row[key] as string).slice(0, 240) : row[key]])); +} +export function projectExploreTurnContext(params: JsonObject): JsonObject { + const goal = requireNonEmptyString(params.goal_id, "goal_id"); + const agent = requireNonEmptyString(params.agent_id, "agent_id"); + const route = requireStringArray(params.route, "route"); + const gate = requireJsonObject(params.harness_gate, "harness_gate"); + const graph = params.graph_enabled === true; + const harness = gate.enabled === true; + const projection = requireJsonObject(params.projection, "projection"); + const plan = requireJsonObject(params.plan, "plan"); + const nodes = rows(projection.nodes); + const findings = rows(projection.findings); + const branches = rows(plan.selected_branches); + const command = (...args: string[]) => [...route, "explore", ...args, "--goal-id", goal]; + return { + ok: true, goal_id: goal, agent_id: agent, graph_enabled: graph, harness_enabled: harness, + graph: graph ? { + counts: projection.counts ?? {}, + recent_nodes: nodes.slice(-3).map(row => compact(row, ["node_id", "title", "status", "blocked_reason"])), + recent_findings: findings.slice(0, 3).map(row => compact(row, ["finding_id", "node_id", "title", "status"])), + omitted_nodes: Math.max(0, nodes.length - 3), + summary_command: command("summary"), + record_node_template: command("node", "--title", "", "--status", "exploring"), + record_finding_template: command("finding", "--node", "", "--title", "", "--status", ""), + guidance: "Use existing evidence before repeating a route. Record meaningful hypotheses and supported or refuted results with stable node ids. Fill templates from actual evidence; do not create ceremonial nodes or infer findings from a score alone.", + } : null, + harness: harness ? { + orchestration_gate: gate, + candidate_count: plan.candidate_count ?? 0, + selected_branches: branches.slice(0, 3).map(row => compact(row, ["todo_id", "text", "branch_role", "score", "confidence"])), + omitted_selected_branches: Math.max(0, branches.length - 3), + plan_command: command("worker-branch-plan", "--agent-id", agent), + guidance: "Use the read-only planner when choosing among real alternatives. With one candidate, identify useful alternatives only when evidence warrants them. Planning neither launches workers nor changes spawn permission; execute through normal quota, claim and lease admission.", + } : null, + boundary: {read_only: true, changes_configuration: false, writes_evidence: false, + claims_todos: false, starts_agents: false, changes_quota: false}, + }; +} diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 88ad5d52da..83fa062eee 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -725,6 +725,9 @@ export function createEffectRuntimeHandlers( ["work_item.replan_semantics.project", lazyHandler(() => import("./work_items/replan_semantics.ts"), ({projectReplanSemantics}) => projectReplanSemantics)], ["work_item.replan_context.project", lazyHandler(() => import("./work_items/replan_context.ts"), ({projectReplanContext}) => projectReplanContext)], ["work_item.replan_context.project_snapshot", lazyHandler(() => import("./work_items/replan_context.ts"), ({projectReplanContextSnapshot}) => projectReplanContextSnapshot)], + ["explore.configuration.resolve", lazyHandler(() => import("./capabilities/explore_configuration.ts"), ({resolveExploreConfiguration}) => resolveExploreConfiguration)], + ["explore.configuration.plan", lazyHandler(() => import("./capabilities/explore_configuration.ts"), ({planExploreConfiguration}) => planExploreConfiguration)], + ["explore.turn_context", lazyHandler(() => import("./capabilities/explore_turn_context.ts"), ({projectExploreTurnContext}) => projectExploreTurnContext)], ["explore.research.normalize", lazyHandler(() => import("./capabilities/explore_research.ts"), ({normalizeResearchObservation}) => normalizeResearchObservation)], ["explore.research.validate_attribution", lazyHandler(() => import("./capabilities/explore_research.ts"), ({validateResearchAttribution}) => validateResearchAttribution)], ["explore.research.frontier", lazyHandler(() => import("./capabilities/explore_research.ts"), ({projectResearchFrontier}) => projectResearchFrontier)], diff --git a/loopx/control_plane/quota/goal_boundary.py b/loopx/control_plane/quota/goal_boundary.py index c5d67f93f3..c4e0877e3d 100644 --- a/loopx/control_plane/quota/goal_boundary.py +++ b/loopx/control_plane/quota/goal_boundary.py @@ -420,9 +420,9 @@ def goal_boundary( boundary.setdefault("capabilities", {})["reward_memory"] = reward_capability if goal.get("next_probe"): boundary["next_probe"] = str(goal.get("next_probe")) - if isinstance(goal.get("explore_graph"), dict): + if isinstance(goal.get("explore_graph"), dict) or (goal.get("spawn_policy") or {}).get("explore_harness"): boundary["explore_graph"] = compact_explore_graph_policy( - goal.get("explore_graph") + goal.get("explore_graph"), (goal.get("spawn_policy") or {}).get("explore_harness") ) spawn_policy = ( goal.get("spawn_policy") if isinstance(goal.get("spawn_policy"), dict) else None diff --git a/loopx/explore_graph.py b/loopx/explore_graph.py index 2ef08ecf6b..6c7bc7e117 100644 --- a/loopx/explore_graph.py +++ b/loopx/explore_graph.py @@ -1,11 +1,43 @@ +"""Compatibility adapters for the typed Explore Harness mode owner.""" + from __future__ import annotations from collections.abc import Mapping +from functools import lru_cache from typing import Any +from .control_plane.effect_runtime import effect_runtime_result, EffectRuntimeRejected + + +@lru_cache(maxsize=4) +def _resolve(evidence: bool, planning: bool) -> dict[str, Any]: + return effect_runtime_result( + "explore.configuration.resolve", + { + "evidence_enabled": evidence, + "planning_enabled": planning, + }, + ) + + +def explore_configuration(graph: Any, harness: Any = None) -> dict[str, Any]: + graph = graph if isinstance(graph, Mapping) else {} + harness = harness if isinstance(harness, Mapping) else {} + return dict(_resolve(graph.get("enabled") is True, harness.get("enabled") is True)) + + +def compact_explore_graph_policy(value: Any, harness: Any = None) -> dict[str, bool]: + """Graph is the evidence layer, including for legacy planning-only policies.""" + return {"enabled": explore_configuration(value, harness)["evidence_enabled"]} -def compact_explore_graph_policy(value: Any) -> dict[str, bool]: - """Return the strict, default-off per-goal Explore Graph gate.""" - policy = value if isinstance(value, Mapping) else {} - return {"enabled": policy.get("enabled") is True} +def plan_explore_configuration( + current: Mapping[str, Any], changes: Mapping[str, Any] +) -> dict[str, Any]: + try: + return effect_runtime_result( + "explore.configuration.plan", + {"current": dict(current), "changes": dict(changes)}, + ) + except EffectRuntimeRejected as exc: + raise ValueError(str(exc)) from exc diff --git a/loopx/history.py b/loopx/history.py index 0adc982f59..cedef36a70 100644 --- a/loopx/history.py +++ b/loopx/history.py @@ -430,9 +430,7 @@ def collect_history( "adapter_kind": adapter.get("kind"), "adapter_status": adapter.get("status"), "coordination": meta.get("coordination") if isinstance(meta.get("coordination"), dict) else None, - "explore_graph": compact_explore_graph_policy(meta.get("explore_graph")) - if isinstance(meta.get("explore_graph"), dict) - else None, + "explore_graph": compact_explore_graph_policy(meta.get("explore_graph"), (meta.get("spawn_policy") or {}).get("explore_harness")) if meta.get("explore_graph") is not None or (meta.get("spawn_policy") or {}).get("explore_harness") else None, "spawn_policy": meta.get("spawn_policy") if isinstance(meta.get("spawn_policy"), dict) else None, "execution_profile": compact_execution_profile(meta.get("execution_profile")) if registry_member else None, "control_plane": compact_control_plane_policy(meta.get("control_plane")) if registry_member else None, diff --git a/loopx/registry.py b/loopx/registry.py index 1f7a40f210..a5577a6a42 100644 --- a/loopx/registry.py +++ b/loopx/registry.py @@ -471,11 +471,7 @@ def add_problem( "operator_question": raw_goal.get("operator_question"), "recommended_action": raw_goal.get("recommended_action"), "next_handoff_condition": raw_goal.get("next_handoff_condition"), - "explore_graph": compact_explore_graph_policy( - raw_goal.get("explore_graph") - ) - if isinstance(raw_goal.get("explore_graph"), dict) - else None, + "explore_graph": compact_explore_graph_policy(raw_goal.get("explore_graph"), spawn_policy.get("explore_harness")) if raw_goal.get("explore_graph") is not None or (raw_goal.get("spawn_policy") or {}).get("explore_harness") else None, "orchestration": orchestration, "orchestration_mode": orchestration.get("mode"), "spawn_allowed": spawn_policy.get("allowed"), diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 8ee6abd8a6..f6961f8816 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -655,7 +655,7 @@ }, { "site": "loopx/cli_commands/explore.py::.handle_explore_command::codec_read:load_registry#1", - "line": 466, + "line": 473, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -991,7 +991,7 @@ }, { "site": "loopx/configure_goal.py::.configure_goal::codec_transaction:project_registry_transaction#1", - "line": 519, + "line": 520, "column": 14, "kind": "codec_transaction", "api": "project_registry_transaction", @@ -999,7 +999,7 @@ }, { "site": "loopx/configure_goal.py::.configure_goal::codec_read:load_project_registry#1", - "line": 729, + "line": 730, "column": 14, "kind": "codec_read", "api": "load_project_registry", @@ -1967,7 +1967,7 @@ }, { "site": "loopx/history.py::.inspect_index_duplicates::codec_read:load_registry#1", - "line": 605, + "line": 603, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -1975,7 +1975,7 @@ }, { "site": "loopx/history.py::.rebuild_index_artifact_collisions::codec_read:load_registry#1", - "line": 819, + "line": 817, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -1983,7 +1983,7 @@ }, { "site": "loopx/history.py::.repair_index_duplicates::codec_read:load_registry#1", - "line": 709, + "line": 707, "column": 16, "kind": "codec_read", "api": "load_registry", From 6b37744752126730de3b0f0fcf79454339c49e1d Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:18:15 +0800 Subject: [PATCH 2/4] test(explore): qualify mode compatibility and hook readback Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- examples/explore-configure-goal-smoke.py | 13 +- .../test_capability_configuration_ui.py | 2 +- .../capabilities/test_explore_turn_context.py | 277 ++++++++++++++++++ tests/test_chat_machine_configuration_api.py | 2 +- 4 files changed, 289 insertions(+), 5 deletions(-) create mode 100644 tests/capabilities/test_explore_turn_context.py diff --git a/examples/explore-configure-goal-smoke.py b/examples/explore-configure-goal-smoke.py index 5d7ca255d8..598c66cd34 100644 --- a/examples/explore-configure-goal-smoke.py +++ b/examples/explore-configure-goal-smoke.py @@ -250,7 +250,7 @@ def main() -> int: assert closed["written"] is True, closed assert goal(registry)["spawn_policy"]["explore_harness"] == {"enabled": False} - graph_closed_harness_open = run_cli( + rejected_graphless_planning = run_cli( registry, runtime_root, "configure-goal", @@ -259,9 +259,16 @@ def main() -> int: "--no-explore-graph-enabled", "--explore-harness-enabled", "--execute", + check=False, ) - assert graph_closed_harness_open["written"] is True, graph_closed_harness_open - assert goal(registry)["explore_graph"] == {"enabled": False} + assert rejected_graphless_planning["ok"] is False, rejected_graphless_planning + assert "planning requires its evidence graph" in rejected_graphless_planning["error"] + assert goal(registry)["explore_graph"] == {"enabled": True} + assert goal(registry)["spawn_policy"]["explore_harness"] == {"enabled": False} + unified = run_cli(registry, runtime_root, "configure-goal", "--goal-id", GOAL_ID, + "--explore-mode", "planning", "--execute") + assert unified["ok"] + assert goal(registry)["explore_graph"] == {"enabled": True} assert goal(registry)["spawn_policy"]["explore_harness"] == {"enabled": True} conflict = run_cli( diff --git a/tests/capabilities/test_capability_configuration_ui.py b/tests/capabilities/test_capability_configuration_ui.py index 375949dda4..3447b7f9b6 100644 --- a/tests/capabilities/test_capability_configuration_ui.py +++ b/tests/capabilities/test_capability_configuration_ui.py @@ -366,7 +366,7 @@ def test_resolution_rejects_values_for_unsupported_scopes() -> None: ("multi_subagent", ["goal"]), ("peer_task_coordination", ["goal"]), ("explore_harness", ["goal"]), - ("explore_graph", ["goal"]), + ("explore_harness", ["goal"]), ("progress_review", ["goal"]), ("reward_memory", ["goal"]), ("lark_kanban_heartbeat_sync", ["goal"]), diff --git a/tests/capabilities/test_explore_turn_context.py b/tests/capabilities/test_explore_turn_context.py new file mode 100644 index 0000000000..82fde3c550 --- /dev/null +++ b/tests/capabilities/test_explore_turn_context.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +import json +import shlex + +import pytest + +from loopx.capabilities.explore.turn_context import ( + explore_turn_context, + extend_turn_start_dispatch, +) +from loopx.capabilities.explore.result_log import ( + append_explore_result_event, + build_explore_node_event, + build_explore_finding_event, + explore_result_log_path, +) +from loopx.configure_goal import configure_goal +from loopx.chat_goal_configuration_api import _goal_capability_options +from loopx.control_plane.effect_runtime import ( + effect_runtime_result, + EffectRuntimeRejected, +) +from loopx.explore_graph import compact_explore_graph_policy + + +def registry(tmp_path, *, graph=False, planning=False): + path = tmp_path / "registry.json" + (tmp_path / "goal.md").write_text("# Goal\n\n## Agent Todos\n") + path.write_text( + json.dumps( + { + "schema_version": 1, + "common_runtime_root": str(tmp_path / "runtime"), + "goals": [ + { + "id": "research", + "status": "active", + "repo": str(tmp_path), + "state_file": "goal.md", + "coordination": {"registered_agents": ["worker"]}, + "explore_graph": {"enabled": graph}, + "spawn_policy": { + "allowed": False, + "explore_harness": {"enabled": planning}, + }, + } + ], + } + ) + ) + return path + + +@pytest.mark.parametrize( + "mode,graph,planning", + [("off", False, False), ("evidence", True, False), ("planning", True, True)], +) +def test_mode_preview_apply_and_readback(tmp_path, mode, graph, planning): + path = registry(tmp_path) + original = path.read_bytes() + options = _goal_capability_options( + "explore_harness", {"mode": mode, "profile": "generic"} + ) + configure_goal(registry_path=path, goal_id="research", execute=False, **options) + assert path.read_bytes() == original + result = configure_goal( + registry_path=path, goal_id="research", execute=True, **options + ) + assert result["ok"] + goal = json.loads(path.read_text())["goals"][0] + assert goal["explore_graph"]["enabled"] is graph + assert goal["spawn_policy"]["explore_harness"]["enabled"] is planning + assert goal["spawn_policy"]["allowed"] is False + catalog = configure_goal(registry_path=path, goal_id="research", execute=False)[ + "configuration_catalog" + ] + features = {f["feature_id"]: f for f in catalog["features"]} + assert "explore_graph" not in features + assert features["explore_harness"]["current"]["mode"] == mode + + +def test_legacy_planning_implies_evidence_and_graph_disable_is_actionable(tmp_path): + path = registry(tmp_path, planning=True) + assert compact_explore_graph_policy({"enabled": False}, {"enabled": True}) == { + "enabled": True + } + original = path.read_bytes() + with pytest.raises(ValueError, match="planning requires its evidence graph"): + configure_goal( + registry_path=path, + goal_id="research", + explore_graph_enabled=False, + execute=True, + ) + assert path.read_bytes() == original + configure_goal( + registry_path=path, + goal_id="research", + explore_harness_enabled=False, + execute=True, + ) + goal = json.loads(path.read_text())["goals"][0] + assert goal["explore_graph"]["enabled"] is True + assert goal["spawn_policy"]["explore_harness"]["enabled"] is False + + +@pytest.mark.parametrize( + "changes", [{"mode": "unknown"}, {"mode": "off", "planning_enabled": True}] +) +def test_invalid_mode_plan_is_rejected(changes): + with pytest.raises(EffectRuntimeRejected): + effect_runtime_result( + "explore.configuration.plan", {"current": {}, "changes": changes} + ) + + +def test_disabled_hook_keeps_packet_and_does_not_read_evidence(tmp_path, monkeypatch): + path = registry(tmp_path) + original = {"registered_count": 2, "required_reads": [{"command": "existing"}]} + + def unexpected(*args, **kwargs): + pytest.fail("disabled Explore read evidence") + + monkeypatch.setattr( + "loopx.capabilities.explore.turn_context.load_explore_result_events", unexpected + ) + assert ( + extend_turn_start_dispatch( + original, + registry_path=path, + runtime_root=tmp_path / "runtime", + goal_id="research", + agent_id="worker", + ) + is original + ) + assert ( + explore_turn_context( + registry_path=path, + runtime_root=tmp_path / "runtime", + goal_id="research", + agent_id="worker", + )["graph"] + is None + ) + + +def test_enabled_hook_and_read_preserve_evidence_and_authority(tmp_path): + path = registry(tmp_path, planning=True) + root = tmp_path / "runtime" + log = explore_result_log_path(root, "research") + for i in range(5): + append_explore_result_event( + log, + build_explore_node_event( + goal_id="research", + title=f"Route {i}", + node_id=f"route-{i}", + status="dead_end" if i == 4 else "exploring", + ), + ) + append_explore_result_event( + log, + build_explore_finding_event( + goal_id="research", + title="Route falsified by controlled experiment", + node_id="route-4", + status="refuted", + ), + ) + before = {p: p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()} + packet = extend_turn_start_dispatch( + {}, registry_path=path, runtime_root=root, goal_id="research", agent_id="worker" + ) + assert packet["registered_count"] == 1 + read = packet["required_reads"][0] + assert read["ordering"] == "before_work" + assert "turn-context" in shlex.split(read["command"]) + result = explore_turn_context( + registry_path=path, runtime_root=root, goal_id="research", agent_id="worker" + ) + assert result["graph_enabled"] and result["harness_enabled"] + assert len(result["graph"]["recent_nodes"]) == 3 + assert result["graph"]["omitted_nodes"] == 2 + assert result["graph"]["recent_findings"][0]["status"] == "refuted" + assert result["harness"]["orchestration_gate"]["state"] == "analysis_only" + assert not result["boundary"]["starts_agents"] + for p, content in before.items(): + assert p.read_bytes() == content + with pytest.raises(ValueError, match="not registered"): + explore_turn_context( + registry_path=path, + runtime_root=root, + goal_id="research", + agent_id="stranger", + ) + + +def test_public_goal_editor_retains_registered_profiles(tmp_path): + from loopx.capabilities.configuration_inspection import project_goal_configuration + + path = registry(tmp_path) + raw = configure_goal(registry_path=path, goal_id="research", execute=False) + projected = project_goal_configuration(raw) + catalog = projected["capability_catalog"]["capabilities"] + harness = next(c for c in catalog if c["capability_id"] == "explore_harness") + profile = next( + f for f in harness["configuration_editor"]["fields"] if f["key"] == "profile" + ) + assert "generic" in profile["options"] + assert "adaptive-resilient" in profile["options"] + + +def test_legacy_planning_does_not_grant_existing_sink_publication(tmp_path): + from loopx.capabilities.explore.activation import ( + sync_explore_graph_after_material_refresh, + ) + + path = registry(tmp_path, planning=True) + observed = [] + + def syncer(**kwargs): + observed.append(kwargs["external_sink_delivery_authorized"]) + return {"ok": True, "status": "not_configured"} + + sync_explore_graph_after_material_refresh( + registry_path=path, goal_id="research", syncer=syncer + ) + assert observed == [False] + + +def test_real_quota_packet_exposes_replayable_read_without_admitting_unhealthy_goal( + tmp_path, +): + import subprocess + import sys + + path = registry(tmp_path, graph=True) + prefix = [ + sys.executable, + "-m", + "loopx.cli", + "--format", + "json", + "--registry", + str(path), + ] + packet_run = subprocess.run( + prefix + + [ + "quota", + "should-run", + "--goal-id", + "research", + "--agent-id", + "worker", + "--turn-instance-id", + "fixture-turn", + ], + capture_output=True, + text=True, + ) + packet = json.loads(packet_run.stdout) + assert packet["ok"] is False # This fixture deliberately has no healthy adapter. + required = next( + r for r in packet["required_reads"] if r["kind"] == "explore_turn_context" + ) + read = subprocess.run( + [sys.executable, "-m", "loopx.cli", *shlex.split(required["command"])[1:]], + capture_output=True, + text=True, + check=True, + ) + result = json.loads(read.stdout) + assert result["graph_enabled"] is True + assert result["harness"] is None diff --git a/tests/test_chat_machine_configuration_api.py b/tests/test_chat_machine_configuration_api.py index 083a299fea..053b24b3b6 100644 --- a/tests/test_chat_machine_configuration_api.py +++ b/tests/test_chat_machine_configuration_api.py @@ -149,7 +149,7 @@ def test_real_chat_http_catalog_and_machine_write_boundary(tmp_path: Path) -> No assert {item["capability_id"] for item in catalog} >= { "periodic_report", "multi_subagent", - "explore_graph", + "explore_harness", } connection.request( "POST", From a5b5bad4b6683e811401f9ca16fa96b28f65d3cc Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:18:15 +0800 Subject: [PATCH 3/4] docs(explore): document unified modes and live adoption boundary Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../research-exploration-control-plane-v0.md | 11 +++ loopx/capabilities/explore/README.md | 96 +++++++++---------- 2 files changed, 59 insertions(+), 48 deletions(-) diff --git a/docs/architecture/rfcs/research-exploration-control-plane-v0.md b/docs/architecture/rfcs/research-exploration-control-plane-v0.md index b7c32355fe..85cee15193 100644 --- a/docs/architecture/rfcs/research-exploration-control-plane-v0.md +++ b/docs/architecture/rfcs/research-exploration-control-plane-v0.md @@ -1038,6 +1038,17 @@ This is a living RFC, not an append-only diary. | 2026-08-13 | Separate eligibility from ranking: the control plane owns a legal bounded candidate set, while the model autonomously prioritizes among multiple eligible candidates. A selection receipt proves a scheduling choice, not research truth. Defer the protocol to M4 rather than adding it to the #3173 runtime slice. | | 2026-10-04 | Propose §11.5 as a bounded M3 prerequisite: exact result -> scoped interpretation -> adopted task step/successor. Reuse Explore and task/replan receipts; keep Turn settlement, native scoring and scientific qualification independent. This RFC update delivers no automatic policy. | +The Explore Harness product entry now groups evidence-only and evidence-with-planning +modes under the existing `explore` capability. The typed configuration owner retains +legacy storage and flags; planning includes the graph without granting spawn or +publication authority. A turn-start hook requests bounded evidence and branch context +through the existing hook contract. This is an M3 entry/adoption prerequisite, not +qualification of experiment selection, result interpretation or scientific benefit. +CLI/file-state and packaged settings verify mode changes, stale-preview recovery and +feature-off behavior; live long-horizon trajectories must still establish actual +model adoption and continuation. The mode vocabulary is local to Explore configuration, +not a new kernel lifecycle or scheduler contract. + ## 20. Acceptance Criteria for the RFC Merge accepts this design basis. Implementation qualification still requires evidence that: diff --git a/loopx/capabilities/explore/README.md b/loopx/capabilities/explore/README.md index 979c6e1ca8..f55ec27ef4 100644 --- a/loopx/capabilities/explore/README.md +++ b/loopx/capabilities/explore/README.md @@ -1,19 +1,19 @@ -# Exploration Result Layer +# Explore Harness Status: supported optional capability; default-off harness execution contract. ## At a Glance -LoopX Explore is a supported, default-off optional capability for +Explore Harness is a supported, default-off optional capability for long-running exploration goals (software research, security attack-surface mapping, domain studies). It turns "go look around" into a bounded, observable, gated process with three pillars: -1. **Explore Graph** - an append-only, public-safe evidence topology +1. **Evidence graph** (Explore Graph) - an append-only, public-safe evidence topology (nodes / edges / findings) plus bounded projections, Mermaid export, and canonical/executive presentation. It answers: what has been explored, where the loop is blocked and why, and what was found. -2. **Explore Harness** - deny-by-default, read-only branch planners +2. **Optional planning** - deny-by-default, read-only branch planners (`todo-branch-plan`, `worker-branch-plan`) that rank and bundle next steps (DSpark-style confidence/prefix/load, `adaptive-resilient` and `moe-router` profiles, resource-aware portfolio), without claiming, @@ -36,11 +36,11 @@ executes it through the normal LoopX lifecycle. ## Quick Start -Enable the gates, record evidence, project, and plan: +Choose a mode, record evidence, project, and plan: ```bash -loopx configure-goal --goal-id --explore-graph-enabled \ - --explore-harness-enabled --explore-harness-profile adaptive-resilient --execute +loopx configure-goal --goal-id --explore-mode planning \ + --explore-harness-profile adaptive-resilient --execute loopx explore node --goal-id --title "Attack surface A" --status exploring loopx explore edge --goal-id --from A --to B --type leads_to @@ -51,8 +51,7 @@ loopx explore graph --goal-id --graph-format mermaid --out explore.mmd loopx explore worker-branch-plan --goal-id --harness-profile adaptive-resilient --worker-width 3 ``` -Both gates are separate and default-off (see "Independent Per-Goal Opt-In -Gates"). When closed, evidence-backed surfaces have an explicit reason to be +Explore Harness is off by default (see "Explore Harness Modes"). When closed, evidence-backed surfaces have an explicit reason to be tested together, the next `quota should-run` / turn packet projects a composition gap and can derive a joint-experiment successor todo (see "Composition Frontier"). The detailed contract follows. @@ -357,51 +356,52 @@ deny-by-default disabled packet carries the `boundary` block plus the opt-in operator to decide which workers to start, but it cannot launch workers or mutate the control plane on its own. -### Independent Per-Goal Opt-In Gates - -Explore Graph and Explore Harness are separate optional capabilities. Enabling -one never enables the other: - -- `explore_graph.enabled` controls durable graph projection and any already - configured presentation sink. After each successful material - `refresh-state` transaction, LoopX folds the canonical Explore evidence and - runs the configured sink. Semantic digests make an unchanged refresh a - zero-write operation. A configured row sink is complete only after a - row/result-id readback verifies the projection. A failed sync or readback - does not advance its digest, so the next material refresh retries it. - Visual sinks also preflight their deterministic delivery marker: an existing - marker reconciles the prior write without publishing again, while a bounded - readback timeout stops further calls in that stage batch and leaves a - retryable receipt instead of blindly repeating remote writes. -- `spawn_policy.explore_harness.enabled` controls only the read-only branch - planners described below. It does not create, update, or publish a graph. - -Both gates are absent/false by default. A common operating mode is Graph on -and Harness off: keep an operator-facing topology current without changing -how work is planned. +### Explore Harness Modes -```yaml -# inside the registered goal entry -explore_graph: - enabled: true - -spawn_policy: - explore_harness: - enabled: false -``` +Explore Harness is one optional capability with an evidence graph and optional +read-only planning. Goal settings offer one editor with three modes: -Configure the gates independently instead of editing the registry: +| Mode | Evidence graph | Branch planning | +| --- | --- | --- | +| `off` (default) | Inactive | Inactive | +| `evidence` | Active | Inactive | +| `planning` | Active | Active | ```bash -loopx configure-goal --goal-id \ - --explore-graph-enabled \ - --no-explore-harness-enabled \ - --execute +loopx configure-goal --goal-id --explore-mode planning # preview +loopx configure-goal --goal-id --explore-mode planning --execute +loopx configure-goal --goal-id # read back +loopx explore turn-context --goal-id --agent-id +loopx configure-goal --goal-id --explore-mode evidence --execute +loopx configure-goal --goal-id --explore-mode off --execute ``` -Use `--no-explore-graph-enabled` to stop automatic graph work. Disabling the -gate preserves existing evidence and display state; it only prevents future -automatic projection and sink writes. +Existing `explore_graph.enabled` and `spawn_policy.explore_harness` storage, +record ids and CLI aliases remain supported. A legacy Graph-only Goal maps to +`evidence`; a legacy Harness-enabled Goal maps to `planning`, now including the +graph. Enabling planning writes both internal flags. Disabling planning through +the legacy Harness flag retains the evidence layer. Disabling the Graph while +planning remains enabled fails with an actionable mode command. Do not combine +`--explore-mode` with the legacy enable flags in one request. + +Both enabled modes register an `explore.turn_context` turn-start hook. Its +`required_reads` entry is a **before-work read obligation** in the normal packet. +The command returns at most three recent nodes/findings and, in planning mode, +three suggested Todo branches, plus structured commands for detail or evidence +recording. The read folds existing history but bounds the returned context; +it does not claim to reduce history IO. The agent chooses evidence-backed work; +planner suggestions do not require branching on every turn or recording empty +ceremonial nodes. Use the detail command when the short view is insufficient. + +Mode selection does not grant spawn, claim, lease, execution, quota or external +publication authority. `spawn_allowed=false` retains analysis-only planning. +Feature-off adds no Explore hook or evidence reads. Turning off preserves all +recorded evidence and existing display state. + +The evidence layer reuses the material-refresh projection and any separately +configured, authorized sink. Semantic digests avoid unchanged writes. Row sinks +must read back result ids; failed sync/readback leaves the digest retryable. +Visual sinks reconcile deterministic delivery markers before retrying writes. When a single run may update local state but is not authorized to write any configured external sink, keep the graph enabled and pass From 642e381d2682cfd86158caf129e9edca41e87c80 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 5 Oct 2026 05:03:21 +0800 Subject: [PATCH 4/4] fix(explore): retain recent revisited evidence and disabled parity Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- ...arch-exploration-control-plane-v0.zh-CN.md | 8 +++ loopx/capabilities/explore/activation.py | 11 ++-- .../capabilities/explore_turn_context.ts | 6 ++- .../capabilities/test_explore_turn_context.py | 52 +++++++++++++++++++ 4 files changed, 72 insertions(+), 5 deletions(-) diff --git a/docs/architecture/rfcs/research-exploration-control-plane-v0.zh-CN.md b/docs/architecture/rfcs/research-exploration-control-plane-v0.zh-CN.md index 56a2dc1b28..a7336db871 100644 --- a/docs/architecture/rfcs/research-exploration-control-plane-v0.zh-CN.md +++ b/docs/architecture/rfcs/research-exploration-control-plane-v0.zh-CN.md @@ -950,6 +950,14 @@ evidence-backed terminal result。 | 2026-08-13 | 将 eligibility 与 ranking 分离:控制面拥有合法、有界 candidate set,模型在多个 eligible candidate 中自主择优;selection receipt 只证明调度选择,不构成 research truth。该协议延后到 M4,不进入 #3173 的首期 runtime。 | | 2026-10-04 | 提议 §11.5 作为有界 M3 prerequisite:精确 result -> 有范围 interpretation -> adopted task step/successor。复用 Explore 和 task/replan receipt;Turn settlement、原生评分与科学资格独立。本次 RFC 更新不交付自动策略。 | +探索 Harness 的产品入口现在将仅证据和证据加规划模式统一到既有 `explore` +能力。类型化配置 owner 保留旧存储和 flag;规划配套图谱,但不授予 spawn 或外部 +发布权限。轮次开始 hook 通过既有 hook 契约请求有界证据和分支上下文。这是 M3 +入口与采纳的前置条件,不是实验选择、结果解释或科学收益的验收。 +CLI/File 状态与打包设置验证模式切换、过期预览恢复和关闭态行为;真实长程轨迹 +仍须验证模型实际采纳和后续推进。模式词汇仅属于 Explore 配置,不是新的 kernel +生命周期或调度契约。 + ## 20. RFC 验收标准 RFC 合入即接受设计依据。实现资格仍需要以下证据: diff --git a/loopx/capabilities/explore/activation.py b/loopx/capabilities/explore/activation.py index 0a1cbda3fa..a3592e749e 100644 --- a/loopx/capabilities/explore/activation.py +++ b/loopx/capabilities/explore/activation.py @@ -91,9 +91,6 @@ def sync_explore_graph_after_material_refresh( policy = compact_explore_graph_policy( (goal or {}).get("explore_graph"), ((goal or {}).get("spawn_policy") or {}).get("explore_harness") ) - external_sink_delivery_authorized = external_sink_delivery_authorized and ( - ((goal or {}).get("explore_graph") or {}).get("enabled") is True - ) base = { "ok": True, "schema_version": EXPLORE_GRAPH_ACTIVATION_SCHEMA_VERSION, @@ -128,6 +125,14 @@ def sync_explore_graph_after_material_refresh( ), } + # Disabled activation preserves the caller's authorization observation without + # reading or publishing anything. An enabled legacy Harness does not grant a + # sink permission merely by supplying its local evidence graph. + external_sink_delivery_authorized = external_sink_delivery_authorized and ( + ((goal or {}).get("explore_graph") or {}).get("enabled") is True + ) + base["external_sink_delivery_authorized"] = external_sink_delivery_authorized + if syncer is None: status = "projection_sink_provider_unavailable" return { diff --git a/loopx/control_plane/capabilities/explore_turn_context.ts b/loopx/control_plane/capabilities/explore_turn_context.ts index 4420105baf..2858fdb5d2 100644 --- a/loopx/control_plane/capabilities/explore_turn_context.ts +++ b/loopx/control_plane/capabilities/explore_turn_context.ts @@ -18,7 +18,9 @@ export function projectExploreTurnContext(params: JsonObject): JsonObject { const harness = gate.enabled === true; const projection = requireJsonObject(params.projection, "projection"); const plan = requireJsonObject(params.plan, "plan"); - const nodes = rows(projection.nodes); + // The canonical graph retains creation order; this bounded view needs update order. + const nodes = rows(projection.nodes).sort((a, b) => + String(b.last_updated_at ?? "").localeCompare(String(a.last_updated_at ?? ""))); const findings = rows(projection.findings); const branches = rows(plan.selected_branches); const command = (...args: string[]) => [...route, "explore", ...args, "--goal-id", goal]; @@ -26,7 +28,7 @@ export function projectExploreTurnContext(params: JsonObject): JsonObject { ok: true, goal_id: goal, agent_id: agent, graph_enabled: graph, harness_enabled: harness, graph: graph ? { counts: projection.counts ?? {}, - recent_nodes: nodes.slice(-3).map(row => compact(row, ["node_id", "title", "status", "blocked_reason"])), + recent_nodes: nodes.slice(0, 3).map(row => compact(row, ["node_id", "title", "status", "blocked_reason"])), recent_findings: findings.slice(0, 3).map(row => compact(row, ["finding_id", "node_id", "title", "status"])), omitted_nodes: Math.max(0, nodes.length - 3), summary_command: command("summary"), diff --git a/tests/capabilities/test_explore_turn_context.py b/tests/capabilities/test_explore_turn_context.py index 82fde3c550..b8d4498007 100644 --- a/tests/capabilities/test_explore_turn_context.py +++ b/tests/capabilities/test_explore_turn_context.py @@ -275,3 +275,55 @@ def test_real_quota_packet_exposes_replayable_read_without_admitting_unhealthy_g result = json.loads(read.stdout) assert result["graph_enabled"] is True assert result["harness"] is None + + +@pytest.mark.parametrize("authorized", [True, False]) +def test_disabled_activation_preserves_caller_authorization_observation(tmp_path, authorized): + from loopx.capabilities.explore.activation import sync_explore_graph_after_material_refresh + + path = registry(tmp_path) + + def unexpected(**kwargs): + pytest.fail("disabled activation invoked a sink") + + result = sync_explore_graph_after_material_refresh( + registry_path=path, goal_id="research", syncer=unexpected, + external_sink_delivery_authorized=authorized, + ) + assert result["status"] == "disabled" + assert result["external_sink_delivery_authorized"] is authorized + assert result["delivery_postcondition"]["required"] is False + + +def test_real_cli_recent_context_includes_revisited_old_node(tmp_path): + import subprocess + import sys + + path = registry(tmp_path, graph=True) + root = tmp_path / "runtime" + log = explore_result_log_path(root, "research") + for i in range(5): + append_explore_result_event(log, build_explore_node_event( + goal_id="research", title=f"Route {i}", node_id=f"route-{i}", + status="exploring", recorded_at=f"2026-01-0{i + 1}T00:00:00Z", + )) + append_explore_result_event(log, build_explore_node_event( + goal_id="research", title="Route 0", node_id="route-0", status="blocked", + blocked_reason="Required observation is unavailable", + recorded_at="2026-02-01T00:00:00Z", + )) + before = log.read_bytes() + prefix = [sys.executable, "-m", "loopx.cli", "--format", "json", "--registry", str(path), + "--runtime-root", str(root), "explore"] + result = subprocess.run(prefix + ["turn-context", "--goal-id", "research", "--agent-id", "worker"], + capture_output=True, text=True, check=True) + graph = json.loads(result.stdout)["graph"] + assert [row["node_id"] for row in graph["recent_nodes"]] == ["route-0", "route-4", "route-3"] + assert graph["recent_nodes"][0]["blocked_reason"] == "Required observation is unavailable" + assert graph["omitted_nodes"] == 2 + summary = subprocess.run(prefix + ["summary", "--goal-id", "research"], + capture_output=True, text=True, check=True) + canonical = json.loads(summary.stdout) + assert canonical["ok"] is True + assert [row["node_id"] for row in canonical["nodes"]] == [f"route-{i}" for i in range(5)] + assert log.read_bytes() == before