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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions loopx/capabilities/manager_context/inspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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. "
Expand Down
19 changes: 17 additions & 2 deletions loopx/chat/coordination.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions loopx/chat_manager_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]:
Expand All @@ -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,
)
Expand Down Expand Up @@ -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.
Expand Down
9 changes: 7 additions & 2 deletions loopx/goal_portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
):
Expand Down Expand Up @@ -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(
{
Expand Down Expand Up @@ -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":
Expand Down
19 changes: 19 additions & 0 deletions tests/control_plane/test_bound_owner_web_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
71 changes: 71 additions & 0 deletions tests/test_native_steward_private.py
Original file line number Diff line number Diff line change
Expand Up @@ -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?"))
Expand Down
Loading