diff --git a/docs/reference/protocols/manager-evidence-and-continuity-v0.md b/docs/reference/protocols/manager-evidence-and-continuity-v0.md index b389bcdd2c..278b267926 100644 --- a/docs/reference/protocols/manager-evidence-and-continuity-v0.md +++ b/docs/reference/protocols/manager-evidence-and-continuity-v0.md @@ -101,6 +101,13 @@ in a `manager.context` event, and supplies it to the executor. Chat prose is never the inventory. Normal manager questions no longer silently use a limited frontend projection; explicitly choosing status-only still uses that projection. +An ordinary Codex turn in a bound steward conversation starts with the current +authorized Goal directory. Its unread status rows carry omitted coverage. When +an answer needs Goal status, the executor uses `loopx_manager_read` with +`view=portfolio` to collect the requested scope. Each read rechecks the grant. +Native execution, observations and prompt-only endpoints keep their existing +evidence preparation. + The manager channel's model and reasoning effort follow its executor. On the interactive CLI endpoint the defaults are `gpt-6.1-sol` with `xhigh` reasoning; on the managed host they are the managed execution profile diff --git a/loopx/capabilities/manager_context/inspection.py b/loopx/capabilities/manager_context/inspection.py index 3ce5076240..7a8085a5d4 100644 --- a/loopx/capabilities/manager_context/inspection.py +++ b/loopx/capabilities/manager_context/inspection.py @@ -246,7 +246,7 @@ def agent_work_summary(agent: dict[str, Any]) -> dict[str, Any]: } -def manager_index(context: dict[str, Any]) -> dict[str, Any]: +def manager_index(context: dict[str, Any], *, status_on_demand: bool = False) -> dict[str, Any]: """A small directory, never a second mutable progress store.""" read_tool = CONTEXT_TOOL_NAME if context.get("scope") == "owner_goal" else TOOL_NAME execution = context.get("context_execution") @@ -304,8 +304,13 @@ def manager_index(context: dict[str, Any]) -> dict[str, Any]: "agent_work_note": ( "Agent Todos are bounded declared work from this portfolio snapshot, not live activity " "or completed results. Respect each Goal's quality, recorded timestamp and Agent coverage. " - "An empty or unverified projection does not prove idle or stopped. For a current-work " - "question, answer the known overview first; read only missing or requested detail." + "An empty or unverified projection does not prove idle or stopped. " + + ( + "Use the scoped read tool with view=portfolio before drawing conclusions " + "that depend on current Goal status or attention." + if status_on_demand else + "For a current-work question, answer the known overview first; read only missing or requested detail." + ) ), "attention_note": ( "Attention is a directory preview, not complete decision terms or an action grant. " diff --git a/loopx/chat/coordination.py b/loopx/chat/coordination.py index 68a19105a5..1f47003fcd 100644 --- a/loopx/chat/coordination.py +++ b/loopx/chat/coordination.py @@ -79,11 +79,26 @@ def prepare_turn_context(controller, adapter, session, turn_id, event_sink, *, s runtime_root = getattr(controller, "coordination_runtime_root", controller.store.root.parent) from ..chat_manager_context import collect_manager_turn_context observation_only = getattr(adapter, "observation_only", False) is True - event_sink("agent.phase", {"phase": "manager_context", "label": "正在读取当前 Goal 的工作与协作" if scope["kind"] == "owner_goal" else "正在读取授权范围内的 Goal 状态"}) + turn = controller.store.load_turn(session_id, turn_id) or {} + interactive_reader = isinstance(adapter, CodexAppServerAdapter) and not observation_only + # The bound steward advertises the scoped portfolio tool. Its ordinary + # questions can start once the current directory and grant are resolved. + status_on_demand = ( + interactive_reader and scope.get("bound_steward") is True + and not (turn.get("loopx_request") or turn.get("loopx_execution") + or turn.get("origin") == "peer" or "collaboration_request" in turn) + ) + label = "正在读取授权范围内的 Goal 状态" + if scope["kind"] == "owner_goal": + label = "正在读取当前 Goal 的工作与协作" + elif status_on_demand: + label = "正在读取授权范围内的 Goal 目录" + event_sink("agent.phase", {"phase": "manager_context", "label": label}) context = collect_manager_turn_context( controller.registry_path, session, runtime_root, controller.manager_scope_resolver, **({"include_details": False} if isinstance(adapter, CodexAppServerAdapter) or observation_only else {}), **({"include_stopped": True} if observation_only else {}), + **({"read_goal_status": False} if status_on_demand else {}), # An interactive endpoint reads the declared sources on # demand, but a prompt-only segment can only receive them, # so it gets the bounded read inline. @@ -198,7 +213,7 @@ def scope_valid() -> bool: ) adapter.session.read_tool_handler = inspection.read context["evidence_sources"] = inspection.sources() - context = manager_index(context) + context = manager_index(context, status_on_demand=status_on_demand) from ..capabilities.native_chat.external_conversations import ChatExternalConversations recovery = ChatExternalConversations(controller).recovery_context( session, controller.store.load_turn(session_id, turn_id) or {}) if scope.get("bound_steward") else None diff --git a/loopx/chat_manager_context.py b/loopx/chat_manager_context.py index b2208a67c9..0b8773c5b2 100644 --- a/loopx/chat_manager_context.py +++ b/loopx/chat_manager_context.py @@ -510,6 +510,7 @@ def manager_turn_context( requested_goal_ids: list[str] | None = None, include_stopped: bool = False, include_details: bool = True, + read_goal_status: bool = True, evidence_window_days: int | None = None, remote_evidence: bool = False, remote_runner=None, @@ -518,6 +519,7 @@ def manager_turn_context( ) -> dict[str, Any]: conversation = conversation_scope(session) local_goal = conversation["kind"] == "owner_goal" + include_details = include_details and read_goal_status if evidence_window_days is None: evidence_window_days, days_source, days_reason = resolve_evidence_window_days() elif ( @@ -585,6 +587,7 @@ def manager_turn_context( include_goal_lifecycle=True, include_goal_attention=True, include_goal_instance_id=True, + read_status=read_goal_status, ) labels: dict[str, str] = {} try: @@ -744,6 +747,7 @@ def collect_manager_turn_context( *, include_details: bool = True, include_stopped: bool = False, + read_goal_status: bool = True, remote_evidence: bool = False, remote_runner=None, ) -> dict[str, Any]: @@ -754,6 +758,7 @@ def collect_manager_turn_context( runtime_root, include_details=include_details, include_stopped=include_stopped, + read_goal_status=read_goal_status, remote_evidence=remote_evidence, remote_runner=remote_runner, ) @@ -815,6 +820,7 @@ def scope_unchanged() -> bool: authorized_goal_ids=before, include_details=include_details, include_stopped=include_stopped, + read_goal_status=read_goal_status, remote_evidence=remote_evidence, remote_runner=remote_runner, # Source reads and the Turn owner check the same exact authority. diff --git a/loopx/goal_portfolio.py b/loopx/goal_portfolio.py index 94d50b0996..3a5db95e47 100644 --- a/loopx/goal_portfolio.py +++ b/loopx/goal_portfolio.py @@ -339,6 +339,7 @@ def build_goal_portfolio( include_goal_lifecycle: bool = False, include_goal_attention: bool = False, include_goal_instance_id: bool = False, + read_status: bool = True, ) -> dict[str, Any]: """Read only the requested registry scope; never derive inventory from chat.""" if not 1 <= limit <= 128 or not 0 < max_age_hours <= 8760: @@ -393,7 +394,8 @@ def build_goal_portfolio( shared_status = None before = {} if ( - goal_ids is None + read_status + and goal_ids is None and 1 < len(inspected) <= limit and all(counts[g] == 1 for g in inspected) ): @@ -421,6 +423,9 @@ def build_goal_portfolio( "warnings": ["duplicate_registry_identity"], } ) + elif not read_status: + rows.append({"goal_id": goal_id, "quality": SourceQuality.OMITTED.value, + "progress": "unknown", "warnings": ["goal_not_read"]}) elif positions[goal_id] >= limit: rows.append( { @@ -464,7 +469,7 @@ def build_goal_portfolio( changed = True if changed: for row in rows: - if row["quality"] != "omitted" or row["goal_id"] in stopped: + if not read_status or row["quality"] != "omitted" or row["goal_id"] in stopped: row.update(quality="conflicting", progress="unknown", activation_state="unknown") row["warnings"].append("inventory_changed_during_collection") if row.get("goal_lifecycle", {}).get("status") != "unavailable": diff --git a/tests/control_plane/test_bound_owner_web_input.py b/tests/control_plane/test_bound_owner_web_input.py index b39e5d98ac..a8b57d79c3 100644 --- a/tests/control_plane/test_bound_owner_web_input.py +++ b/tests/control_plane/test_bound_owner_web_input.py @@ -246,6 +246,25 @@ def test_http_local_input_cannot_inherit_a_group_topic(bound_chat): assert status == 409 and "sessions" not in denied +def test_http_bound_steward_starts_with_current_directory_and_deferred_status(bound_chat, monkeypatch): + from loopx import goal_portfolio + + store, runtime, _, _, post, open_bound, _, _ = bound_chat + _, session = open_bound("steward") + monkeypatch.setattr(goal_portfolio, "collect_status", + lambda **_: pytest.fail("ordinary steward input must not eagerly collect Goal status")) + status, accepted = post(session, { + "message": "Which approved changes are still open?", "client_turn_id": "directory-only", + }) + assert status == 202, accepted + turn = runtime.wait_for_turn(session_id=session["session_id"], turn_id=accepted["turn_id"], timeout_sec=15) + assert turn["status"] == "completed" and turn["origin"] == "web", turn + events = store.events_after(session["session_id"], turn["turn_id"], None) + context = next(event["payload"] for event in events if event["kind"] == "manager.context") + assert context["authorization_scope_id"] and context["coverage"]["attempted"] == 0 + assert [(row["goal_id"], row["quality"]) for row in context["goals"]] == [("goal-a", "omitted")] + + def test_http_bound_steward_keeps_web_provenance_at_coordination_root(bound_chat, monkeypatch): store, runtime, _, _, post, open_bound, _, canonical = bound_chat _, session = open_bound("steward") diff --git a/tests/test_native_steward_private.py b/tests/test_native_steward_private.py index 7ba6791a45..d60debfa53 100644 --- a/tests/test_native_steward_private.py +++ b/tests/test_native_steward_private.py @@ -467,6 +467,77 @@ def test_legacy_delivery_only_receipt_does_not_block_steward_conversation(stewar assert sum(p["method"] == "turn/start" for p in packets) == 1 +def test_steward_chat_reads_goal_status_only_when_the_executor_asks(steward, monkeypatch): + from loopx import goal_portfolio + + store, runtime, provider, transport, binding, capture, _ = steward + registry = json.loads(runtime.registry_path.read_text()) + registry["goals"] = [{"id": "alpha"}, {"id": "beta"}] + runtime.registry_path.write_text(json.dumps(registry)) + + fake = Path(runtime.codex_bin) + source = fake.read_text().replace( + ' elif method == "turn/start":', + ''' elif request_id in {900, 901}: + evidence = json.loads(request["result"]["contentItems"][0]["text"]) + if request_id == 900: + assert request["result"]["success"] + assert [row["goal_id"] for row in evidence["rows"]] == ["beta"] + assert evidence["source"]["coverage"]["attempted"] == 1 + print(json.dumps({"id": 901, "method": "item/tool/call", "params": { + "threadId": "durable-thread", "turnId": active_turn, "tool": "loopx_manager_read", + "arguments": {"view": "portfolio", "goal_id": "ungranted"}}}), flush=True) + else: + assert evidence == {"ok": False, "error": "goal_outside_available_scope"} + print(json.dumps({"method": "item/agentMessage/delta", "params": { + "threadId": "durable-thread", "turnId": active_turn, "delta": "Read one Goal."}}), flush=True) + print(json.dumps({"method": "turn/completed", "params": { + "threadId": "durable-thread", "turn": {"id": active_turn, "status": "completed"}}}), flush=True) + continue + elif method == "turn/start":''', + ).replace( + " response = 'Visible runtime response.", + ''' if "Read the current status of beta" in json.dumps(request["params"]["input"]): + print(json.dumps({"id": 900, "method": "item/tool/call", "params": { + "threadId": "durable-thread", "turnId": active_turn, "tool": "loopx_manager_read", + "arguments": {"view": "portfolio", "goal_id": "beta"}}}), flush=True) + continue + response = 'Visible runtime response.''', + ) + fake.write_text(source) + reads = [] + collect_status = goal_portfolio.collect_status + + def observe_status(**kwargs): + calls = [json.loads(line) for line in capture.read_text().splitlines()] + reads.append((kwargs.get("goal_id"), sum(row.get("method") == "turn/start" for row in calls))) + return collect_status(**kwargs) + + monkeypatch.setattr(goal_portfolio, "collect_status", observe_status) + + def ask(key, text): + event = provider.event("steward-app", key, text) + assert transport.admit("steward-app", event)["status"] == "durably_accepted" + row = transport.core.read_request(identity_ref(binding["provider_ref"], event["message_id"])) + assert finish(runtime, row)["status"] == "completed" + return row + + first = ask("plain-question", "Which approved PRs are still open?") + assert reads == [] + events = store.events_after(first["session_id"], first["turn_id"], None) + context = next(event["payload"] for event in events if event["kind"] == "manager.context") + assert context["coverage"]["attempted"] == 0 and context["coverage"]["complete"] is False + assert [(row["goal_id"], row["quality"], row["progress"]) for row in context["goals"]] == [ + ("alpha", "omitted", "unknown"), ("beta", "omitted", "unknown"), + ] + second = ask("scoped-question", "Read the current status of beta") + assert second["session_id"] == first["session_id"] + assert reads == [("beta", 2)] + events = store.events_after(second["session_id"], second["turn_id"], None) + read = next(event["payload"] for event in events if event["kind"] == "manager.evidence_read") + assert read["goal_id"] == "beta" + + def test_verified_empty_steward_is_not_missing_authorization_and_does_not_inherit(steward): store, runtime, provider, transport, binding, _, _ = steward transport.admit("steward-app", provider.event("steward-app", "empty", "What new work do you manage?"))