diff --git a/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.md b/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.md index ac6d6b9113..9dc46ef1ec 100644 --- a/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.md +++ b/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.md @@ -237,6 +237,8 @@ For a cross-Goal question such as “assess a market-positioning opportunity and The steward sends one source-linked consultation or delegated-work request to the selected receiver, carrying the original question, relevant prior decisions, the requested answer or PR outcome, and the original frontend/Lark return route. The receiver's assessment and any PR/validation receipts return through the existing request/result outbox; the steward may synthesize the answer, but the original route receives the result or an actionable failure without a second user prompt. A duplicate provider callback for the same source event must recover that request and answer-delivery receipt, not create another model run or another visible answer. Independently sent identical text remains a distinct request. Qualify this with one real active worker, one stopped/registered-only decoy, one model-fit alternative, frontend and Lark readback, a lost ACK, and a receiver that defers or fails after inbox delivery. Until this journey passes, a recipient catalog or “context delivered” receipt is discovery progress, not completed delegation. +**Discovery implementation checkpoint.** The existing manager/context read tool now has an `agents` view over the complete permitted registry, with responsibility search, pagination and explicit stopped-history opt-in. It reads independently of the progress snapshot's Agent cap and sender-bound delivery list. Local owner scope is broad by default; Goal Chat and external audiences retain their scope. CLI and authorized SSH exports share the reader. Registration, declared responsibility, context delivery permission and unchecked execution readiness remain distinct. This qualifies a bounded read/diagnostic slice of A24, not worker selection, launch, adoption or original-route completion; prompt-only adapters and older remote installations remain explicit coverage gaps. Continue A24 through existing delivery configuration, actual worker execution and result return before claiming the golden query passed. + ### 5.6 One exchange, independent durable facts The user-facing exchange is **received → assessed/working → result**, with meaningful updates when needed. Internally, keep transport and work facts separate: diff --git a/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.zh-CN.md b/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.zh-CN.md index ca83923250..88737e972e 100644 --- a/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.zh-CN.md +++ b/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.zh-CN.md @@ -220,6 +220,8 @@ LoopX 不是只有任务队列。交接应让接收方结合权威状态和持 管家只向选中的接收方发送一条带来源的咨询或委托请求,包含原问题、相关既有决定、期待的答复或 PR 结果,以及前端/Lark 原路径。接收方的评估和 PR/验证回执经现有请求/结果 outbox 返回;管家可以综合答复,但原路径必须无需用户追问便收到结果或可处理的失败。相同来源事件的重复 provider 回调应恢复同一请求与送达回执,不能再次调用模型或再显示一个答案;用户分别发送的同文消息仍是两个请求。验收需包含真实活跃 worker、已停止或仅注册的干扰项、模型能力备选、前端与 Lark 读回、丢失 ACK,以及 inbox 已投递后接收方延期或失败。通过这条路径前,接收者目录或“已投递上下文”回执只是发现进度,不能称为委托完成。 +**职责发现实现检查点。** 现有管家/项目对话读取工具新增 `agents` 视图,可搜索并分页读取权限范围内的完整注册目录,显式选择后可查已停止 Goal 的历史身份。它不受进度快照中 Agent 数量截断或发送方委派名单限制。主人本地管家默认广泛发现,Goal Chat 和外部群聊保留原有范围;CLI 与已授权 SSH 导出复用同一读取实现。注册、声明职责、上下文投递权限与尚未检查的执行就绪状态分别呈现。这只验证 A24 的发现和诊断切片,不代表已完成选人、启动、采用或原路返回;仅提示词适配器和旧版远端仍保留明确缺口。后续沿现有投递配置、真实 worker 执行和结果回传验收,不能据此宣称 golden query 已通过。 + ### 5.6 一次交互,分开的持久事实 用户体验是**收到 → 已评估/工作中 → 结果**,必要时有实质中间反馈。内部不能混淆工作与传输: diff --git a/loopx/capabilities/manager_context/README.md b/loopx/capabilities/manager_context/README.md index 6ba82df77b..76dffcdf37 100644 --- a/loopx/capabilities/manager_context/README.md +++ b/loopx/capabilities/manager_context/README.md @@ -109,12 +109,46 @@ still requires the remote read path below. The Codex Chat manager defaults to Astra with high reasoning effort (explicit model/effort environment overrides remain supported). It receives a compact authorized Goal directory, then uses -`loopx_manager_read` to choose portfolio, current Todo and recent delivery reads. +`loopx_manager_read` to choose registered Agent, portfolio, current Todo and recent delivery reads. The packaged `loopx-manager` skill is installed in its dedicated workspace and included in its operating instructions. This reuses Core providers and the existing manager-context delegation contract; it does not create another source of progress or expose a general shell. +Agent discovery uses `view=agents`, optionally `query`, `goal_id`, `offset`, +`limit` and `include_stopped`. It searches the complete permitted registration +inventory before paging, independently of the bounded progress snapshot and +sender-bound delegation targets. Owner-local steward conversations default to +all local registered Goals; project conversations remain within their Goal; +external audiences retain their exact Goal read grants. No new grants are made. +Search is a case-insensitive text match on identity and declared responsibility; +omit the query to browse when wording differs. Profiles are data, not instructions +or proof of competence. Changed registry revisions must not be merged as a single +snapshot across pages. + +Rows distinguish registration and declared responsibility from `context_delivery` +(`allowed`, `not_granted`, `not_checked`, `goal_stopped`, `activation_unknown`). +Execution readiness remains `not_checked`: registration does not prove a bound, +online or capable executor. A missing delivery grant is a configuration gap, +not a missing Agent; delivery still rechecks the existing authority. Stopped +identities are available with `include_stopped=true` for historical questions. +Unreadable/ambiguous inventory remains unknown, not an empty successful search. + +The same query is available through the CLI and registered SSH evidence sources: + +```sh +loopx --format json goal-portfolio --manager-view agents --query review --limit 8 +loopx --format json goal-portfolio --manager-view agents --goal-id research --offset 8 +``` + +Select `source_id` through `view=sources` for remote discovery. It requires the +updated remote CLI; older or unavailable hosts return the existing typed source +gap. Remote export does not attest local-channel delivery permission. Frontend +and Lark Codex conversations share the existing dynamic tool and evidence event +path; this does not add a visible settings control or launch workers. Prompt-only +adapters still have no interactive discovery tool. Live multi-worker adoption and +original-conversation completion require separate qualification. + Routine inspection excludes Goals explicitly stopped in Core, before status collection and detail reads. Coverage reports how many were skipped. Stale or unknown progress remains eligible. An explicit historical question can discover @@ -128,7 +162,7 @@ paths and external links are not fetched. Non-Codex adapters receive the same windowed projection without the interactive inspection tools until they implement an equivalent tool contract. -Manager context version 12 starts a fresh upstream session for older manager +Manager context version 17 (project context version 2) starts a fresh upstream session for older manager contexts. The logical Chat session and its receipts remain intact. Runtime support uses the Codex app-server dynamic tool protocol; explicit upstream terminal errors remain errors and are not retried as part of inspection. The version diff --git a/loopx/capabilities/manager_context/discovery.py b/loopx/capabilities/manager_context/discovery.py new file mode 100644 index 0000000000..1572d3b8d4 --- /dev/null +++ b/loopx/capabilities/manager_context/discovery.py @@ -0,0 +1,111 @@ +"""Search registered responsibility without treating delivery grants as inventory. + +This read model owns no registrations, permissions or execution state. Its callers +supply the audience scope; registry helpers and Goal activation retain authority. +""" + +from __future__ import annotations + +import hashlib +from collections import Counter +from pathlib import Path +from enum import Enum + +from ...agent_registry import agent_profile_for_goal, registered_agent_ids_for_goal +from ...control_plane.goals.activation import goal_activation_state +from ...control_plane.runtime.public_safety import public_safe_compact_text +from ...history import decode_registry_snapshot + + +class ContextDelivery(str, Enum): + """Observation of an existing grant, never new delivery authority.""" + + ALLOWED = "allowed" + NOT_GRANTED = "not_granted" + NOT_CHECKED = "not_checked" + GOAL_STOPPED = "goal_stopped" + ACTIVATION_UNKNOWN = "activation_unknown" + + +def agent_page( + registry_path: Path, *, goal_ids: list[str] | None, query: str = "", + include_stopped: bool = False, offset: int = 0, limit: int = 8, + delegation: dict | None = None, +) -> dict: + """Filter the full permitted registry before paging; never collect live status.""" + try: + raw = Path(registry_path).read_bytes() + registry = decode_registry_snapshot(Path(registry_path), raw) + inventory = registry.get("goals") + if not isinstance(inventory, list) or any( + not isinstance(g, dict) or not isinstance(g.get("id"), str) for g in inventory + ): + raise ValueError("invalid registry inventory") + except (OSError, ValueError, TypeError): + return {"ok": False, "view": "agents", "error": "agent_inventory_unavailable", + "rows": [], "matched": None, "unknown": True, + "next_action": "Restore the registered source before concluding that no Agent exists."} + visible = [g for g in inventory if goal_ids is None or g["id"] in goal_ids] + counts = Counter(g["id"] for g in visible) + gaps = [{"goal_id": gid, "reason": "duplicate_goal_registration"} + for gid, count in counts.items() if count > 1] + known = set(counts) + gaps.extend({"goal_id": gid, "reason": "goal_not_registered"} + for gid in sorted(set(goal_ids or []) - known)) + delivery_known = isinstance(delegation, dict) and delegation.get("mode") == "context_only" + allowed = {(r.get("goal_id"), r.get("agent_id")) + for r in (delegation or {}).get("targets", []) if isinstance(r, dict)} + rows, stopped = [], 0 + needle = query.strip().casefold() + for goal in sorted(visible, key=lambda g: g["id"]): + gid = goal["id"] + if counts[gid] != 1: + continue + try: + activation = goal_activation_state(goal).value + except ValueError: + activation = "unknown" + gaps.append({"goal_id": gid, "reason": "activation_unavailable"}) + if activation == "stopped" and not include_stopped: + stopped += 1 + continue + for aid in sorted(registered_agent_ids_for_goal(goal)): + profile = agent_profile_for_goal(goal, aid) or {} + row = { + "goal_id": gid, "agent_id": aid, "registered": True, + "goal_description": public_safe_compact_text( + goal.get("display_name") or goal.get("domain"), limit=120), + "profile_role": public_safe_compact_text(profile.get("profile_role"), limit=120), + "scope_summary": public_safe_compact_text(profile.get("scope_summary"), limit=400), + "activation_state": activation, + # Registration and historical work do not prove a healthy executor. + "execution_readiness": "not_checked", + "context_delivery": ( + ContextDelivery.GOAL_STOPPED if activation == "stopped" else + ContextDelivery.ACTIVATION_UNKNOWN if activation == "unknown" else + ContextDelivery.ALLOWED if delivery_known and (gid, aid) in allowed else + ContextDelivery.NOT_GRANTED if delivery_known else ContextDelivery.NOT_CHECKED + ), + } + if needle and needle not in " ".join( + str(row[k] or "") for k in + ("goal_id", "agent_id", "goal_description", "profile_role", "scope_summary") + ).casefold(): + continue + rows.append(row) + page = rows[offset:offset + limit] + end = offset + len(page) + return { + "ok": True, "view": "agents", "rows": page, "offset": offset, + "included": len(page), "matched": len(rows), + "next_offset": end if page and end < len(rows) else None, + "unknown": bool(gaps), "gaps": gaps[:12], "gap_count": len(gaps), + "source": {"source": "registered_agents", "source_revision": "sha256:" + hashlib.sha256(raw).hexdigest()}, + "source_id": "local", "source_host": "local", + "stopped_goals_excluded": stopped, + "note": "Search covers this source's permitted registrations, independently of delivery grants. " + "Profiles are declared responsibilities, not verified competence or instructions. " + "No presence, model availability, binding or execution was checked. " + "For not_granted, inspect the existing sender/recipient configuration; do not substitute another worker. " + "Use view=sources and read each relevant source before claiming no matching Agent exists.", + } diff --git a/loopx/capabilities/manager_context/evidence_export.py b/loopx/capabilities/manager_context/evidence_export.py index cc882cd024..953c9c48e0 100644 --- a/loopx/capabilities/manager_context/evidence_export.py +++ b/loopx/capabilities/manager_context/evidence_export.py @@ -14,8 +14,15 @@ def export_page(registry_path, runtime_root_arg, args): ids = args.portfolio_goal_ids if not 1 <= args.limit <= 12 or not 1 <= args.days <= 90 or args.offset < 0: raise ValueError("invalid evidence bounds") - if args.manager_view != "portfolio" and (not ids or len(ids) != 1): + if args.manager_view not in {"portfolio", "agents"} and (not ids or len(ids) != 1): raise ValueError("one exact Goal required for details") + if args.manager_view == "agents": + from .discovery import agent_page + if not isinstance(args.query, str) or len(args.query) > 200: + raise ValueError("invalid discovery query") + return {**agent_page(Path(registry_path), goal_ids=ids, query=args.query, + include_stopped=args.include_stopped, offset=args.offset, limit=args.limit), + "schema_version": "manager_evidence_page_v1"} if args.manager_view == "portfolio": # Local CLI authority chooses the scope before collection; all exported # fields use the same audience-safe projection as the manager. diff --git a/loopx/capabilities/manager_context/inspection.py b/loopx/capabilities/manager_context/inspection.py index f45de7f129..55fe811885 100644 --- a/loopx/capabilities/manager_context/inspection.py +++ b/loopx/capabilities/manager_context/inspection.py @@ -18,7 +18,7 @@ "name": TOOL_NAME, "description": ( "Read authorized LoopX Core evidence on demand: the global Goal portfolio, " - "one Goal's current Todos, recorded deliveries, or handoff receipt status. " + "registered Agent responsibilities, one Goal's current Todos, recorded deliveries, or handoff receipt status. Use view=agents to search before reporting a missing worker; delivery targets are not the discovery inventory. " "Every portfolio row carries its Goal lifecycle readback: reached milestones with " "their evidence refs and the phase (starting/qualifying/waiting_owner/closing/closed), " "or a typed unavailable gap naming why it could not be derived. Use that to state where " @@ -32,11 +32,12 @@ "properties": { "view": { "type": "string", - "enum": ["sources", "portfolio", "todos", "deliveries", "handoffs"], + "enum": ["sources", "portfolio", "todos", "deliveries", "handoffs", "agents"], }, "source_id": {"type": "string", "description": "Default local. For SSH use an exact source_id from view=sources; local Goal IDs do not discover remote Goals."}, "days": {"type": "integer", "minimum": 1, "maximum": 90, "description": "Deliveries lookback; expand for latest known progress older than yesterday."}, "goal_id": {"type": "string"}, + "query": {"type": "string", "maxLength": 200, "description": "Agents only: case-insensitive text match on identity and declared responsibility. Omit to browse all permitted registrations."}, "request_id": { "type": "string", "pattern": "^[a-f0-9]{64}$", @@ -44,7 +45,7 @@ }, "include_stopped": { "type": "boolean", - "description": "Portfolio only: include stopped Goals for an explicit historical question.", + "description": "Portfolio/agents: include stopped Goals for an explicit historical question.", }, "offset": {"type": "integer", "minimum": 0}, "limit": {"type": "integer", "minimum": 1, "maximum": 12}, @@ -98,10 +99,15 @@ def rejected_read_arguments(arguments: dict[str, Any]) -> list[str]: if "request_id" in arguments and view != "handoffs": rejected.append("request_id:only_for_view_handoffs") if "include_stopped" in arguments: - if view != "portfolio": - rejected.append("include_stopped:only_for_view_portfolio") + if view not in {"portfolio", "agents"}: + rejected.append("include_stopped:only_for_view_portfolio_or_agents") elif type(arguments["include_stopped"]) is not bool: rejected.append("include_stopped:must_be_a_boolean") + if "query" in arguments: + if view != "agents": + rejected.append("query:only_for_view_agents") + elif not isinstance(arguments["query"], str) or len(arguments["query"]) > 200: + rejected.append("query:must_be_a_string_at_most_200_characters") offset = arguments.get("offset", 0) if type(offset) is not int or offset < 0: rejected.append("offset:must_be_an_integer_at_least_0") @@ -157,6 +163,8 @@ def manager_index(context: dict[str, Any]) -> dict[str, Any]: "context_delegation": context.get("context_delegation"), "evidence_sources": context.get("evidence_sources", [])[:12], "evidence_source_count": len(context.get("evidence_sources", [])), + "agent_discovery": {"tool": read_tool, "view": "agents", "scope": "permitted_registry", + "independent_of_delivery_targets": True}, "read_tool": read_tool, } @@ -188,6 +196,8 @@ def __init__( channel_id: str | None = None, remote_runner=None, ssh_config_path=None, + discovery_scope: Callable[[], list[str] | None] | None = None, + delegation_authority: Callable[[], dict] | None = None, ) -> None: self.context = context self.registry_path = registry_path @@ -198,6 +208,8 @@ def __init__( self.channel_id = channel_id self.remote_runner = remote_runner self.ssh_config_path = ssh_config_path + self.discovery_scope = discovery_scope + self.delegation_authority = delegation_authority def sources(self): if self.context.get("scope") == "owner_goal": @@ -239,7 +251,7 @@ def read(self, tool: str, arguments: Any) -> dict[str, Any]: self.record(result) return result if source_id != "local": - if not source_id.startswith("ssh:") or view == "handoffs" or (view != "portfolio" and not goal_id): + if not source_id.startswith("ssh:") or view == "handoffs" or (view not in {"portfolio", "agents"} and not goal_id): return {"ok": False, "error": "invalid_remote_read"} from .ssh_evidence import read_remote result = read_remote(self.runtime_root, self.channel_id, self.owner_scope, arguments, @@ -247,6 +259,28 @@ def read(self, tool: str, arguments: Any) -> dict[str, Any]: **({"runner": self.remote_runner} if self.remote_runner else {})) self.record(result) return result + if view == "agents": + from .discovery import agent_page + # The current audience scope is independent of bounded portfolio + # collection and the sender's narrower context-delivery grants. + ids = (self.discovery_scope() if self.discovery_scope else + None if self.owner_scope and self.context.get("scope") == "owner_global" else + [r["goal_id"] for r in self.context.get("goals", [])]) + if ids is None and not (self.owner_scope and self.context.get("scope") == "owner_global"): + return {"ok": False, "error": "authorization_changed"} + if ids is not None and (not isinstance(ids, list) or any(not isinstance(g, str) for g in ids)): + return {"ok": False, "error": "authorization_changed"} + if goal_id is not None: + if ids is not None and goal_id not in ids: + return {"ok": False, "error": "goal_outside_available_scope"} + ids = [goal_id] + grant = self.delegation_authority() if self.delegation_authority else self.context.get("context_delegation") + result = agent_page(self.registry_path, goal_ids=ids, query=arguments.get("query", ""), + include_stopped=include_stopped, offset=offset, limit=limit, delegation=grant) + if not self.scope_valid(): + return {"ok": False, "error": "authorization_changed"} + self.record(result) + return result goals = {r["goal_id"]: r for r in self.context.get("goals", [])} if (goal_id is not None and goal_id not in goals) or ( view not in {"portfolio", "handoffs"} and not goal_id diff --git a/loopx/capabilities/manager_context/ssh_evidence.py b/loopx/capabilities/manager_context/ssh_evidence.py index 3ad87ffe68..757811f9d6 100644 --- a/loopx/capabilities/manager_context/ssh_evidence.py +++ b/loopx/capabilities/manager_context/ssh_evidence.py @@ -204,6 +204,8 @@ def unavailable(code: str, reason: str) -> dict[str, Any]: ] for gid in ([goal] if goal else (None if owner else before[host])) or []: argv += ["--goal-id", gid] + if args.get("view") == "agents": + argv += ["--query", args.get("query", "")] if args.get("include_stopped"): argv += ["--include-stopped"] command = ( diff --git a/loopx/chat_coordination.py b/loopx/chat_coordination.py index 344b6bcf09..9c994256a7 100644 --- a/loopx/chat_coordination.py +++ b/loopx/chat_coordination.py @@ -12,6 +12,7 @@ "when available; otherwise use the supplied scoped evidence and disclose gaps. " "The Chat runtime is not the registered project coordinator: do not claim to be " "an existing Agent, attach to its session, or take its work by using its name. " + "Find responsible members with loopx_context_read view=agents; registration does not prove execution readiness. " "Use the supplied context_delegation catalog for an explicitly requested handoff " "to an exact registered member, preserving the objective, corrections, constraints " "and required return. The member independently assesses, investigates and plans. " @@ -28,7 +29,7 @@ ) -PROJECT_CONTEXT_VERSION = 1 +PROJECT_CONTEXT_VERSION = 2 def prepare_turn_context(controller, adapter, session, turn_id, event_sink, *, scope): @@ -119,6 +120,15 @@ def scope_valid() -> bool: owner_scope=scope["private_conversation"], channel_id=session.get("channel_id"), scope_valid=scope_valid, + discovery_scope=( + (lambda: None) if scope["kind"] == "owner_portfolio" else + (lambda: [str(session["goal_id"])]) if scope["kind"] == "owner_goal" else + (lambda: controller.manager_scope_resolver(session) if controller.manager_scope_resolver else []) + ), + delegation_authority=lambda: authority( + controller.store.root.parent, controller.registry_path, session, + controller.store.load_turn(session_id, turn_id) or {}, + ), record=lambda result: controller.store.append_event( session_id, turn_id, kind="manager.evidence_read", payload=result, ), diff --git a/loopx/chat_manager.py b/loopx/chat_manager.py index 87006b9869..5c121988b5 100644 --- a/loopx/chat_manager.py +++ b/loopx/chat_manager.py @@ -91,6 +91,9 @@ "A checkpoint reason is an Agent's explanation, not independent proof. Respect field_coverage and evidence_coverage; hashed evidence refs are lineage, not fetchable artifacts. " "When artifact_read_status is not_read, distinguish the useful recorded finding from verification still missing instead of discarding the finding. " "Prefer short paragraphs or bullets to large tables. For Lark use readable Markdown paragraphs and lists, with blank lines between blocks; prefer short lists to large tables. " + "Before choosing a worker or claiming none exists, use loopx_manager_read view=agents, search responsibilities and paginate the permitted registry; inspect relevant declared remote sources too. " + "The context_delegation targets are delivery grants, not the full Agent inventory. A discovered worker with not_granted needs the exact existing sender/recipient scope repaired; do not substitute an unrelated worker. " + "Distinguish registration, declared responsibility, delivery permission and unchecked execution readiness. Unknown presence is not offline. " "Default to intent delegation: for an explicit request to pass context, objectives or constraints to another Agent, use context_handoff " "with the exact goal_id and agent_id from the supplied context_delegation catalog and a collaboration_brief_v0 brief preserving the relevant conversation, corrections, rejected approaches, constraints, inputs, acceptance and return requirement. Do not reduce a multi-message request to the last sentence. This is already authorized " "context delivery, not a Todo proposal: do not ask for another confirmation, set priority, change a plan, " @@ -890,7 +893,7 @@ def open_manager_session( # the answer-contract shape serving the new rows. # 16: the steward answer contract now follows the task instead of requiring # four fixed labelled sections. Existing sessions must receive the new rule. -MANAGER_CONTEXT_VERSION = 16 +MANAGER_CONTEXT_VERSION = 17 # An installed manager workspace keeps the marker it was written with. The # writer refreshes that workspace skill while the file still carries any diff --git a/loopx/cli_commands/summary_all.py b/loopx/cli_commands/summary_all.py index 9a10e90820..66f3ed2227 100644 --- a/loopx/cli_commands/summary_all.py +++ b/loopx/cli_commands/summary_all.py @@ -70,7 +70,8 @@ def register_summary_all_command( "goal-portfolio", help="Read scoped Goal evidence with explicit source coverage." ) add_subcommand_format(portfolio) - portfolio.add_argument("--manager-view", choices=("portfolio", "todos", "deliveries"), help="Export an audience-safe manager evidence page from this registry.") + portfolio.add_argument("--manager-view", choices=("portfolio", "todos", "deliveries", "agents"), help="Export an audience-safe manager evidence page from this registry.") + portfolio.add_argument("--query", default="", help="Search registered Agent identities and responsibilities with --manager-view agents.") portfolio.add_argument("--offset", type=int, default=0) portfolio.add_argument("--days", type=int, default=1) portfolio.add_argument("--include-stopped", action="store_true") diff --git a/tests/test_chat_manager_inspection.py b/tests/test_chat_manager_inspection.py index 2412174e58..0ebe71d6c0 100644 --- a/tests/test_chat_manager_inspection.py +++ b/tests/test_chat_manager_inspection.py @@ -87,7 +87,7 @@ def forbidden(**_): ({"view": "portfolio", "path": "/unknown"}, ["unknown_argument:path"]), ( {"view": "shell"}, - ["view:must_be_one_of_sources,portfolio,todos,deliveries,handoffs"], + ["view:must_be_one_of_sources,portfolio,todos,deliveries,handoffs,agents"], ), ({"view": "portfolio", "offset": True}, ["offset:must_be_an_integer_at_least_0"]), ( @@ -104,7 +104,7 @@ def forbidden(**_): ), ( {"view": "todos", "goal_id": "alpha", "include_stopped": True}, - ["include_stopped:only_for_view_portfolio"], + ["include_stopped:only_for_view_portfolio_or_agents"], ), ({"view": "todos", "goal_id": "alpha", "days": 7}, ["days:only_for_view_deliveries"]), ( @@ -115,7 +115,7 @@ def forbidden(**_): {"view": "todos", "goal_id": "alpha", "source_id": 3}, ["source_id:must_be_a_string"], ), - ({"goal_id": "alpha"}, ["view:must_be_one_of_sources,portfolio,todos,deliveries,handoffs"]), + ({"goal_id": "alpha"}, ["view:must_be_one_of_sources,portfolio,todos,deliveries,handoffs,agents"]), ], ) def test_a_refused_read_names_the_argument_that_must_change(tmp_path, args, expected): @@ -146,7 +146,7 @@ def test_a_refused_read_names_every_bad_argument_and_the_called_tool(tmp_path): ) assert result["rejected_arguments"] == [ "unknown_argument:path", - "view:must_be_one_of_sources,portfolio,todos,deliveries,handoffs", + "view:must_be_one_of_sources,portfolio,todos,deliveries,handoffs,agents", "limit:must_be_an_integer_between_1_and_12", "days:only_for_view_deliveries", ] @@ -284,8 +284,9 @@ class Process: ) +@pytest.mark.parametrize("read_view", ["todos", "agents"]) def test_manager_runtime_installs_tool_and_records_real_subprocess_read( - monkeypatch, tmp_path + monkeypatch, tmp_path, read_view ): from loopx.chat_runtime import ChatRuntimeController from loopx.chat_store import ChatSessionStore @@ -323,12 +324,20 @@ def test_manager_runtime_installs_tool_and_records_real_subprocess_read( continue print(json.dumps({'id':r['id'],'result':result}), flush=True) """) + if read_view == "agents": + fake.write_text(fake.read_text().replace( + "'view':'todos','goal_id':'alpha'", "'view':'agents','query':'review'").replace( + "evidence['rows'][0]['title'] == 'Check the sample result'", + "evidence['rows'][0]['agent_id'] == 'review-worker' and evidence['rows'][0]['execution_readiness'] == 'not_checked'")) + (tmp_path / "registry.json").write_text(json.dumps({"goals": [ + {"id": "alpha", "registered_agents": ["review-worker"]} + ]})) fake.chmod(0o755) collected = [] def collect(*args, **kwargs): collected.append(kwargs) - return {"goals": [{"goal_id": "alpha"}], "snapshot_id": "fixture"} + return {"scope": "owner_global", "goals": [{"goal_id": "alpha"}], "snapshot_id": "fixture"} monkeypatch.setattr(context, "collect_manager_turn_context", collect) monkeypatch.setattr( @@ -386,7 +395,7 @@ def collect(*args, **kwargs): reads = [e for e in events if e["kind"] == "manager.evidence_read"] assert ( len(reads) == 1 - and reads[0]["payload"]["rows"][0]["todo_id"] == "todo_sample" + and reads[0]["payload"]["rows"][0]["todo_id" if read_view == "todos" else "agent_id"] == ("todo_sample" if read_view == "todos" else "review-worker") ) finally: runtime.close() diff --git a/tests/test_manager_agent_discovery.py b/tests/test_manager_agent_discovery.py new file mode 100644 index 0000000000..93861f6395 --- /dev/null +++ b/tests/test_manager_agent_discovery.py @@ -0,0 +1,118 @@ +"""Discovery searches the allowed inventory; a delivery list is not that scope.""" + +import json +import subprocess +import sys + +import pytest + +from loopx.capabilities.manager_context.inspection import ManagerInspection, TOOL_NAME + + +def setup(tmp_path, *, owner=True, scope=None, valid=lambda: True): + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"goals": [ + {"id": "research", "coordination": { + "registered_agents": [f"worker-{i:02}" for i in range(35)], + "agent_profiles": {"worker-34": {"profile_role": "reviewer", "scope_summary": "Independent PR review"}}, + }}, + {"id": "history", "activation_state": "stopped", "registered_agents": ["old-worker"]}, + {"id": "private", "registered_agents": ["hidden-worker"]}, + ]})) + records = [] + inspector = ManagerInspection( + context={"scope": "owner_global" if owner else "external_goal_scope", "goals": []}, + registry_path=registry, runtime_root=tmp_path, owner_scope=owner, + scope_valid=valid, record=records.append, + discovery_scope=scope or (lambda: None if owner else ["research"]), + delegation_authority=lambda: {"mode": "context_only", "targets": [{"goal_id": "research", "agent_id": "worker-00"}]}, + ) + return registry, inspector, records + + +def test_full_inventory_search_is_independent_of_initial_portfolio_and_delivery(tmp_path): + _, reader, records = setup(tmp_path) + result = reader.read(TOOL_NAME, {"view": "agents", "query": "PR REVIEW"}) + assert result["ok"] and result["matched"] == 1 + row = result["rows"][0] + assert row["agent_id"] == "worker-34" # beyond both historical 8/24 row caps + assert row["context_delivery"] == "not_granted" + assert row["execution_readiness"] == "not_checked" + assert len(records) == 1 + offset, found, revisions = 0, [], set() + while offset is not None: + page = reader.read(TOOL_NAME, {"view": "agents", "goal_id": "research", "offset": offset}) + found.extend(r["agent_id"] for r in page["rows"]) + revisions.add(page["source"]["source_revision"]) + offset = page["next_offset"] + assert found == [f"worker-{i:02}" for i in range(35)] + assert len(revisions) == 1 + + +def test_audience_scope_is_distinct_from_sender_delivery_and_cannot_leak(tmp_path): + _, reader, _ = setup(tmp_path, owner=False) + all_rows = reader.read(TOOL_NAME, {"view": "agents"}) + assert all_rows["matched"] == 35 + assert all_rows["rows"][0]["context_delivery"] == "allowed" + assert reader.read(TOOL_NAME, {"view": "agents", "query": "hidden"})["matched"] == 0 + assert reader.read(TOOL_NAME, {"view": "agents", "goal_id": "private"})["error"] == "goal_outside_available_scope" + + +def test_stopped_is_historical_not_a_delivery_target(tmp_path): + _, reader, _ = setup(tmp_path) + assert reader.read(TOOL_NAME, {"view": "agents", "query": "old-worker"})["matched"] == 0 + result = reader.read(TOOL_NAME, {"view": "agents", "query": "old-worker", "include_stopped": True}) + assert result["rows"][0]["context_delivery"] == "goal_stopped" + + +def test_no_match_does_not_hide_unreadable_inventory(tmp_path): + registry, reader, _ = setup(tmp_path) + registry.write_text("{") + result = reader.read(TOOL_NAME, {"view": "agents"}) + assert not result["ok"] and result["unknown"] and result["matched"] is None + assert result["error"] == "agent_inventory_unavailable" + + +def test_revocation_during_discovery_discards_evidence(tmp_path): + checks = iter([True, False]) + _, reader, records = setup(tmp_path, valid=lambda: next(checks)) + assert reader.read(TOOL_NAME, {"view": "agents"}) == {"ok": False, "error": "authorization_changed"} + assert not records + + +def test_null_external_scope_never_becomes_owner_scope(tmp_path): + _, reader, records = setup(tmp_path, owner=False, scope=lambda: None) + assert reader.read(TOOL_NAME, {"view": "agents"})["error"] == "authorization_changed" + assert not records + + +def test_goal_chat_cannot_inherit_owner_global_discovery(tmp_path): + _, reader, _ = setup(tmp_path, scope=lambda: ["research"]) + reader.context["scope"] = "owner_goal" + assert reader.read(TOOL_NAME, {"view": "agents", "query": "hidden"})["matched"] == 0 + + +@pytest.mark.parametrize("arguments", [ + {"view": "portfolio", "query": "review"}, + {"view": "agents", "query": False}, + {"view": "agents", "query": "x" * 201}, +]) +def test_invalid_search_never_reads_source(tmp_path, arguments): + _, reader, records = setup(tmp_path) + assert reader.read(TOOL_NAME, arguments)["error"] == "invalid_arguments" + assert not records + + +def test_real_cli_export_reaches_owner_beyond_old_caps_without_a_live_provider(tmp_path): + registry, _, _ = setup(tmp_path) + result = subprocess.run([ + sys.executable, "-m", "loopx.cli", "--registry", str(registry), + "--runtime-root", str(tmp_path / "runtime"), "--format", "json", + "goal-portfolio", "--manager-view", "agents", "--query", "PR review", + "--goal-id", "research", + ], capture_output=True, text=True, check=True) + page = json.loads(result.stdout) + assert page["schema_version"] == "manager_evidence_page_v1" + assert [r["agent_id"] for r in page["rows"]] == ["worker-34"] + assert page["rows"][0]["context_delivery"] == "not_checked" + assert not (tmp_path / "runtime" / "chat").exists() diff --git a/tests/test_manager_ssh_evidence.py b/tests/test_manager_ssh_evidence.py index 10b2b1b805..694692b70d 100644 --- a/tests/test_manager_ssh_evidence.py +++ b/tests/test_manager_ssh_evidence.py @@ -788,3 +788,15 @@ def runner(argv, **kwargs): # Exactly one code per cause: the summary teaches the repair without letting # a reader mistake an unread source for a remote Goal with no progress. assert packet["limitations"] == [expected_limitation] + + +def test_remote_agent_search_keeps_source_and_audience_scope(remote): + tool, calls, records, _, _ = remote + result = tool.read(TOOL_NAME, {"view": "agents", "source_id": "ssh:research-host", "query": "review; $(noop)", "offset": 12}) + assert result["ok"] + argv = shlex.split(calls[0][0][-1]) + assert argv[argv.index("--manager-view") + 1] == "agents" + assert argv[argv.index("--query") + 1] == "review; $(noop)" + assert argv[argv.index("--goal-id") + 1] == "remote-goal" + assert argv[argv.index("--offset") + 1] == "12" + assert records[-1]["source_id"] == "ssh:research-host"