diff --git a/docs/reference/protocols/manager-evidence-and-continuity-v0.md b/docs/reference/protocols/manager-evidence-and-continuity-v0.md index 278b267926..58a9182579 100644 --- a/docs/reference/protocols/manager-evidence-and-continuity-v0.md +++ b/docs/reference/protocols/manager-evidence-and-continuity-v0.md @@ -108,6 +108,24 @@ an answer needs Goal status, the executor uses `loopx_manager_read` with Native execution, observations and prompt-only endpoints keep their existing evidence preparation. +Agent discovery without a `source_id` treats local and authorized SSH sources +equally. The reader fans out a bounded batch only when called, and interleaves +source-qualified rows so a large local registry cannot hide a remote project. +It preserves each source's read quality and `next_reads` for remaining pages, +failures or sources beyond the batch; a scoped continuation does not reread other +hosts. Explicit `source_id=local` and Goal-scoped conversations retain local-only +reads. The initial local preview is not evidence that the subject belongs locally. +Registration search does not cover requirements: browse responsibilities and +read relevant Todos when a shorthand has no match. Todo `query` searches permitted +full text before paging; exact Todo reads recover excerpted decision terms. +Older source CLIs that cannot apply this query return a compatibility gap, not +an apparently empty or unfiltered search. All existing audience grants remain. + +Legacy bound steward sessions without a stored executor allocation adopt explicit +machine model/effort choices at the next idle boundary. Unset fields retain their +observed settings; this repair does not change endpoints, interrupt an active +Turn, discard history or affect worker configuration. + 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/discovery.py b/loopx/capabilities/manager_context/discovery.py index bef3d0304f..3f632debcb 100644 --- a/loopx/capabilities/manager_context/discovery.py +++ b/loopx/capabilities/manager_context/discovery.py @@ -161,6 +161,7 @@ def agent_page( "source_id": "local", "source_host": "local", "stopped_goals_excluded": stopped, "note": "Search covers this source's permitted registrations, independently of delivery grants. " + "It does not search Goal requirements or Todo text; a zero match does not resolve project shorthand. " "Profiles are declared responsibilities, not verified competence or instructions. " "Registered host-binding summaries cover only that row's Goal; no host addresses are exposed. " "No presence, model availability or execution was checked. Binding metadata is context, not recipient selection or permission. " diff --git a/loopx/capabilities/manager_context/discovery_projection.ts b/loopx/capabilities/manager_context/discovery_projection.ts new file mode 100644 index 0000000000..d2798b8555 --- /dev/null +++ b/loopx/capabilities/manager_context/discovery_projection.ts @@ -0,0 +1,54 @@ +import type {JsonObject} from "../../control_plane/effect_program.ts"; +import {requireJsonObject, requireInteger} from "../../control_plane/runtime_decode.ts"; +import {EffectRuntimeRequestError} from "../../control_plane/effect_runtime_errors.ts"; + +/** A source-qualified directory, never topic ownership or execution authority. */ +export function projectAgentDiscovery(input: JsonObject): JsonObject { + const args = requireJsonObject(input.arguments, "discovery arguments"); + const limit = requireInteger(args.limit ?? 8, "discovery limit"); + if (limit < 1 || limit > 12 || !Array.isArray(input.sources) || !Array.isArray(input.pages)) { + throw new EffectRuntimeRequestError("invalid discovery projection bounds"); + } + const sources = input.sources.map(value => requireJsonObject(value, "discovery source")); + const pages = input.pages.map(value => requireJsonObject(value, "discovery page")); + const usable = sources.map((_, index) => { + const page = pages[index]; + return page?.ok === true && Array.isArray(page.rows) + && Number.isSafeInteger(page.matched) && Number(page.matched) >= page.rows.length; + }); + const counts = sources.map(() => 0); + const rows: JsonObject[] = []; + // A large local registry cannot crowd every other host out of the first page. + for (let rank = 0; rank < limit && rows.length < limit; rank++) { + sources.forEach((source, index) => { + const candidates = usable[index] ? pages[index].rows as JsonObject[] : []; + if (rows.length < limit && rank < candidates.length) { + rows.push({...requireJsonObject(candidates[rank], "discovery row"), + source_id: source.source_id, source_host: source.source_host}); + counts[index]++; + } + }); + } + const nextReads: JsonObject[] = []; + const sourcePages = sources.map((source, index) => { + const page = pages[index]; + const known = usable[index]; + const more = !known || counts[index] < Number(page.matched); + const read = {...args, source_id: source.source_id, offset: counts[index]}; + if (more) nextReads.push(read); + return {...source, status: known ? "read" : page ? "unavailable" : "not_read", + matched: known ? page.matched : null, included: counts[index], + unknown: !known || page.unknown === true, source: page?.source ?? null, + gaps: page?.gaps ?? [], error: page?.error ?? null, + reason_code: page?.reason_code ?? null, reason: page?.reason ?? null, + ...(more ? {next_read: read} : {})}; + }); + return {ok: true, view: "agents", rows, included: rows.length, + matched: usable.every(Boolean) ? pages.reduce((sum, page) => sum + Number(page.matched), 0) : null, + unknown: sourcePages.some(page => page.unknown), source_pages: sourcePages, + next_reads: nextReads, + note: "Local and SSH sources have equal discovery scope. Follow next_reads for remaining source pages; " + + "copy each row's source_id for details. This searches registered identities and responsibilities, " + + "not Goal requirements or Todo text. A zero match does not resolve shorthand: browse registrations " + + "and read relevant source-qualified Todos. Unread or failed sources remain unknown."}; +} diff --git a/loopx/capabilities/manager_context/evidence_export.py b/loopx/capabilities/manager_context/evidence_export.py index 98635d4c1e..1088e07499 100644 --- a/loopx/capabilities/manager_context/evidence_export.py +++ b/loopx/capabilities/manager_context/evidence_export.py @@ -44,6 +44,8 @@ def export_page(registry_path, runtime_root_arg, args): record=lambda _: None, ) query = {"view": args.manager_view, "offset": args.offset, "limit": args.limit} + if args.query: + query["query"] = args.query if args.manager_view == "portfolio": query["include_stopped"] = args.include_stopped else: @@ -53,6 +55,8 @@ def export_page(registry_path, runtime_root_arg, args): if getattr(args, "context_todo_id", None) is not None: query["todo_id"] = args.context_todo_id result = inspector.read(TOOL_NAME, query) + if args.manager_view == "todos" and args.query and result.get("ok"): + result["query"] = args.query for row in result.get("rows", []): row.setdefault("goal_id", query.get("goal_id")) result["schema_version"] = "manager_evidence_page_v1" diff --git a/loopx/capabilities/manager_context/inspection.py b/loopx/capabilities/manager_context/inspection.py index 7a8085a5d4..31e2a4fbba 100644 --- a/loopx/capabilities/manager_context/inspection.py +++ b/loopx/capabilities/manager_context/inspection.py @@ -20,7 +20,7 @@ "name": TOOL_NAME, "description": ( "Read authorized LoopX Core evidence on demand: the global Goal portfolio, " - "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. Use view=agent_route with exact goal_id and agent_id to observe a binding on the selected source host before asking the user for a task link. A readable route is not execution readiness or delivery authority. " + "registered Agent responsibilities, one Goal's current Todos, recorded deliveries, or handoff receipt status. Use view=agents without source_id to discover across authorized local and SSH sources equally; delivery targets are not the discovery inventory. Follow next_reads for source-specific remaining pages. Agent search does not search task requirements: browse registrations when shorthand has no match, then read relevant source-qualified Todos. Use view=agent_route with exact goal_id and agent_id to observe a binding on the selected source host before asking the user for a task link. A readable route is not execution readiness or delivery authority. " "For a current-work overview, start with the index's bounded Agent Todo summaries; " "use view=portfolio for source and coverage, and view=todos for needed detail. " "Delivery history answers past results, not live activity. " @@ -46,7 +46,7 @@ "type": "string", "enum": ["sources", "portfolio", "todos", "deliveries", "handoffs", "agents", "agent_route", "report_source"], }, - "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."}, + "source_id": {"type": "string", "description": "Agents defaults to all authorized sources; other views default local. Copy a discovered row's source_id for its Goal details. Set local only to deliberately restrict discovery. Follow next_reads for federated continuation; scoped pages use next_offset."}, "days": {"type": "integer", "minimum": 1, "maximum": 90, "description": "Deliveries lookback; expand for latest known progress older than yesterday."}, "include_outcome_gaps": {"type": "boolean", "description": "Local deliveries: include recorded outcome_gap counterevidence alongside progress. Default false for ordinary delivery queries; report_source always includes it. Observations and untyped host failures are not delivery outcomes."}, "window_start": {"type": "string", "description": "Local deliveries/report_source: exact inclusive ISO-8601 timestamp with offset. Pair with window_end instead of days, e.g. a frozen report window."}, @@ -62,7 +62,7 @@ "description": "Agent_route only: optionally narrow to an observed binding's exact host surface. This does not select another machine or grant delivery."}, "todo_id": {"type": "string", "minLength": 1, "description": "Todos only, with goal_id: read one exact record including its complete permitted text. Recover content_truncated excerpts; a completed record does not authorize new work."}, - "query": {"type": "string", "maxLength": 200, "description": "Agents only: case-insensitive text match on identity and declared responsibility. Omit to browse all permitted registrations."}, + "query": {"type": "string", "maxLength": 200, "description": "Agents: match identity/responsibility, not Goal requirements. Todos: search permitted full task text before paging; requires goal_id and the discovered source_id. Omit to browse."}, "request_id": { "type": "string", "pattern": "^[a-f0-9]{64}$", @@ -150,10 +150,12 @@ def rejected_read_arguments(arguments: dict[str, Any]) -> list[str]: 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") + if view not in {"agents", "todos"}: + rejected.append("query:only_for_view_agents_or_todos") elif not isinstance(arguments["query"], str) or len(arguments["query"]) > 200: rejected.append("query:must_be_a_string_at_most_200_characters") + if view == "todos" and "todo_id" in arguments: + rejected.append("query:cannot_combine_with_todo_id") offset = arguments.get("offset", 0) if type(offset) is not int or offset < 0: rejected.append("offset:must_be_an_integer_at_least_0") @@ -271,6 +273,7 @@ def manager_index(context: dict[str, Any], *, status_on_demand: bool = False) -> "goals": [ { "goal_id": row["goal_id"], + "source_id": "local", **( {"goal_instance_id": row["goal_instance_id"]} if row.get("goal_instance_id") @@ -299,7 +302,9 @@ def manager_index(context: dict[str, Any], *, status_on_demand: bool = False) -> "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, "route_view": "agent_route"}, + "independent_of_delivery_targets": True, "route_view": "agent_route", + "source_scope": "local" if context.get("scope") == "owner_goal" else "all_authorized", + "note": "The initial Goal preview is local only, not a preference or complete inventory. Discover across sources before resolving project shorthand; preserve source_id for detail reads."}, "read_tool": read_tool, "agent_work_note": ( "Agent Todos are bounded declared work from this portfolio snapshot, not live activity " @@ -420,6 +425,50 @@ def _registered_goals(self) -> list[dict[str, Any]]: raise ValueError("Goal inventory is unreadable") return inventory + def _discover_sources(self, tool, arguments, sources): + """Bounded transport fanout; the typed projection owns fair presentation. + + This runs only for an explicit discovery read, never at Turn preparation. + Keep per-source continuations instead of conflating host-local offsets. + """ + from concurrent.futures import ThreadPoolExecutor + from .ssh_evidence import grants, read_remote, MANAGER_REMOTE_EVIDENCE_HOST_TIMEOUT_SECONDS + + if arguments.get("offset", 0): + return {"ok": False, "error": "invalid_arguments", + "rejected_arguments": ["offset:select_source_id_from_next_reads"]} + try: + scope = self._goal_scope() + except ValueError: + return {"ok": False, "error": "authorization_changed"} + grant = grants(self.runtime_root, self.channel_id) + + def collect(source): + args = {**arguments, "source_id": source["source_id"]} + if source["source_id"] == "local": + return self.read(tool, args) + return read_remote(self.runtime_root, self.channel_id, self.owner_scope, args, + self.scope_valid, config_path=self.ssh_config_path, + timeout_seconds=MANAGER_REMOTE_EVIDENCE_HOST_TIMEOUT_SECONDS, + **({"runner": self.remote_runner} if self.remote_runner else {})) + + # Cap connections and returned evidence, not authorization. Sources past + # this bounded batch remain visible with an exact read continuation. + with ThreadPoolExecutor(max_workers=min(12, len(sources))) as pool: + pages = list(pool.map(collect, sources[:12])) + try: + current_scope = self._goal_scope() + except ValueError: + return {"ok": False, "error": "authorization_changed"} + if (not self.scope_valid() or current_scope != scope + or grants(self.runtime_root, self.channel_id) != grant or self.sources() != sources): + return {"ok": False, "error": "authorization_changed"} + result = effect_runtime_result("manager.discovery.project", { + "arguments": arguments, "sources": sources, "pages": pages, + }) + self.record(result) + return result + def _goal_scope(self) -> list[str] | None: ids = (self.discovery_scope() if self.discovery_scope else None if self.owner_scope and self.context.get("scope") == "owner_global" else @@ -497,6 +546,10 @@ def read(self, tool: str, arguments: Any) -> dict[str, Any]: return self._report_source(tool, arguments) if self.context.get("scope") == "owner_goal" and source_id != "local": return {"ok": False, "error": "source_outside_available_scope"} + if view == "agents" and "source_id" not in arguments: + sources = self.sources() + if len(sources) > 1: + return self._discover_sources(tool, arguments, sources) if view == "sources": rows = self.sources() if not self.scope_valid(): @@ -588,11 +641,12 @@ def read(self, tool: str, arguments: Any) -> dict[str, Any]: limit=limit, offset=offset, todo_id=arguments.get("todo_id"), + query=arguments.get("query", ""), ) page = source.pop("todos", []) # Completed title joins remain available through the delivery view. source.pop("completed_todos", None) - matched = source.get("coverage", {}).get("active") + matched = source.get("coverage", {}).get("matched", source.get("coverage", {}).get("active")) else: source = read_manager_delivery_history( self.runtime_root, goal_id, limit=limit, offset=offset, lookback_days=arguments.get("days", 1), diff --git a/loopx/capabilities/manager_context/skills/loopx-manager/SKILL.md b/loopx/capabilities/manager_context/skills/loopx-manager/SKILL.md index 2ab4f55e34..e93337afda 100644 --- a/loopx/capabilities/manager_context/skills/loopx-manager/SKILL.md +++ b/loopx/capabilities/manager_context/skills/loopx-manager/SKILL.md @@ -3,7 +3,7 @@ name: loopx-manager description: Inspect authorized LoopX Goals, Todos and deliveries to explain progress, identify owner decisions and delegate intent to the right worker. --- - + # LoopX manager @@ -49,6 +49,11 @@ that work. A matching Todo or historical owner is not required: use the authoriz directory's responsibilities and context to choose a qualified recipient, and describe that as a new selection rather than proven historical ownership. Resolve shorthand against the requested subject, project, purpose and destination. +Local and authorized remote sources are peers: use source-neutral discovery +when the subject is unresolved, without waiting for the owner to mention SSH. +A matching date, PR number or local topic is not proof of the intended requirement. +Agent search covers registrations, not task text; a zero match calls for browsing +responsibilities and reading relevant source-qualified Todos, not guessing. A role alias or matching Agent id is a discovery lead, not a selected recipient. Compare plausible recipients using the permitted directory and relevant current work; when the user means recent research, inspect relevant recent deliveries @@ -103,11 +108,31 @@ do not reduce a substantive result to an inventory of IDs. Keep model output to ordinary Markdown text; never offer executable HTML as answer content. Choose reads according to the user's question. The initial Goal directory is -an index, not a completed investigation. In Chat, use `loopx_manager_read`: +an index, not a completed investigation. For prioritization, planning or a +cross-project tradeoff, actively read the relevant Goals' current work and +accepted requirements, plus recent deliveries when they could change the +decision. Do not wait for the owner to request these reads explicitly. A fresh +read of old recorded progress is still historical evidence; distinguish its +date from the read time. Unrelated factual questions need no full portfolio scan. +In Chat, use `loopx_manager_read`; omit optional pagination parameters unless +needed, respect the tool's declared limits and follow returned `next_offset`. +Recover an indicated exact Todo or delivery when its excerpt is insufficient. + +For prioritizing several PRs/MRs against a requirement or deciding how to unblock +their checks, use `loopx-pr-program`. Resolve the requirement from relevant +authorized Goal/work and owner-source evidence before ranking candidates. +Separate product priority, dependency order and current review/check readiness; +green checks alone establish none of the first two. Return a useful ordered +decision with evidence, next action and missing work, rather than only PR rows. +An assessment is not permission to create issues, start workers, rerun checks +or merge. Use the existing authorization for separately requested follow-through. + +Available views: - `sources`: discover configured and audience-authorized evidence sources. - For a remote/SSH question, select the matching `source_id` (for example - `ssh:research-host`) for portfolio, Todo and delivery reads. Never substitute + `agents` without `source_id` discovers across these sources by default; + follow its `next_reads` for omitted pages or sources. Preserve the selected + row's `source_id` for portfolio, Todo and delivery reads. Never substitute local tasks mentioning SSH for a report from the remote registry. An empty declared `host_id` is not a reason to skip an available SSH source: the read supplies `source_host` and source-qualified Goal identity. Report each source's @@ -119,7 +144,9 @@ an index, not a completed investigation. In Chat, use `loopx_manager_read`: Goals are excluded by default. Use `include_stopped: true` only for an explicit historical/stopped-Goal question; a specific Goal ID can then be read. - `todos` with a Goal ID: read current task titles, declared priorities, - dependencies and owner decisions. Follow `next_offset` where relevant. + dependencies and owner decisions. `query` searches permitted full task text + before paging; exact-read matching records when excerpts are insufficient. + If a source lacks search support, browse its scoped pages. Follow `next_offset`. - `deliveries` with a Goal ID: inspect recorded findings, evidence references and validation for the recent reporting window. Join the supplied titles; distinguish recorded claims from independently verified artifacts. diff --git a/loopx/capabilities/manager_context/ssh_evidence.py b/loopx/capabilities/manager_context/ssh_evidence.py index 79487f87fa..07aab486c6 100644 --- a/loopx/capabilities/manager_context/ssh_evidence.py +++ b/loopx/capabilities/manager_context/ssh_evidence.py @@ -207,8 +207,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("view") in {"agents", "todos"} and args.get("query"): + argv.append(f"--query={args['query']}") if args.get("todo_id"): argv += ["--todo-id", args["todo_id"]] if args.get("include_stopped"): @@ -256,6 +256,12 @@ def unavailable(code: str, reason: str) -> dict[str, Any]: ) return unavailable(code, reason) packet = json.loads(result.stdout) + # Older source CLIs accepted --query but ignored it for Todos. Never + # report their unfiltered first page as a successful search result. + if args.get("view") == "todos" and args.get("query") and ( + not isinstance(packet, dict) or packet.get("query") != args["query"] + ): + return unavailable("remote_query_unsupported", "Source CLI must support Todo search; browse scoped pages or upgrade it.") if not route_read and ( not isinstance(packet, dict) or packet.get("schema_version") != "manager_evidence_page_v1" diff --git a/loopx/chat/manager_details.py b/loopx/chat/manager_details.py index f14d119a40..7745149657 100644 --- a/loopx/chat/manager_details.py +++ b/loopx/chat/manager_details.py @@ -32,7 +32,7 @@ def _safe_context(value: Any) -> Any: def read_manager_goal_details( registry_path: Path, runtime_root: Path, goal_id: str, *, owner_scope: bool, limit: int = 48, completed_todo_ids: set[str] | None = None, offset: int = 0, - todo_id: str | None = None, + todo_id: str | None = None, query: str = "", ) -> dict[str, Any]: """Use Core's canonical-first read; never parse a private project document.""" observed_at = datetime.now(timezone.utc).isoformat() @@ -43,14 +43,15 @@ def read_manager_goal_details( # The Manager service root is a default carrier, not a caller's # explicit diagnostic override of a declared source registry. runtime_root_arg=None if route["routed_to_source_registry"] else str(runtime_root), - goal_id=goal_id, todo_id=todo_id, + goal_id=goal_id, todo_id=todo_id, full_text=bool(query), ) if result.get("ok") is not True: raise ValueError("Todo authority unavailable or conflicting") records = ([result["todo"]] if result.get("todo") else []) if todo_id else result.get("todos", []) page = effect_runtime_result("todo.context.page", { - "records": records, "owner_scope": owner_scope, "offset": offset, + "records": _safe_context(records) if query else records, "owner_scope": owner_scope, "offset": offset, "limit": limit, "todo_id": todo_id, + **({"query": query} if query else {}), }, large_local_snapshot=True) rows = _safe_context(page["todos"]) completed = [r for r in records if r.get("status") == "done" diff --git a/loopx/chat_manager.py b/loopx/chat_manager.py index cef5b31882..d6bbab50c7 100644 --- a/loopx/chat_manager.py +++ b/loopx/chat_manager.py @@ -1009,9 +1009,34 @@ def manager_session_model_allocation( persisted binding, including a restart's original service environment. """ previous = session.get("manager_executor_allocation") + defaults = steward_machine_defaults(controller) + if previous is None: + # Native bound conversations created before allocation persistence still + # belong to machine settings. Adopt concrete choices at the same idle + # boundary, preserving unset fields and the existing endpoint/history. + endpoint = session.get("agent_id") + if (session.get("goal_id") != MANAGER_AGENT_GOAL_ID or defaults is None + or defaults.get("status") != "ready" + or _machine_default_text(defaults, "executor_endpoint") != endpoint + or not any(_machine_default_text(defaults, name) for name in + ("executor_model", "executor_reasoning_effort"))): + return None + proposed = manager_executor_allocation(controller, endpoint, machine_defaults=defaults, + environ=operator_credential_resolution(controller)["environ"]) + if proposed["executor_endpoint"] != endpoint: + return None + if not proposed["configured_model"]: + if not session.get("agent_model_id"): + return None + proposed["model"] = session["agent_model_id"] + proposed["model_source"] = "session_binding" + if not proposed["configured_reasoning_effort"]: + if not session.get("agent_reasoning_effort"): + return None + proposed["reasoning_effort"] = session["agent_reasoning_effort"] + return proposed if not isinstance(previous, Mapping): return None - defaults = steward_machine_defaults(controller) if (defaults is None or defaults.get("status") != "ready" or defaults.get("configuration_revision") == previous.get("configuration_revision")): return None @@ -1072,7 +1097,8 @@ def manager_session_model_allocation( # native history. Merely removing it from new prompts leaves old upstream # conversations overfull and unable to compact before their next request. # 22: resolve role shorthand by subject evidence; a past handoff is not ownership. -MANAGER_CONTEXT_VERSION = 22 +# 23: source-neutral discovery and task-search tools reach existing conversations. +MANAGER_CONTEXT_VERSION = 23 # An installed manager workspace keeps the marker it was written with. The # writer refreshes that workspace skill while the file still carries any @@ -1083,8 +1109,9 @@ def manager_session_model_allocation( MANAGED_SKILL_MARKER_V3 = "" MANAGED_SKILL_MARKER_V4 = "" MANAGED_SKILL_MARKER_V5 = "" -MANAGED_SKILL_MARKERS = (MANAGED_SKILL_MARKER_V1, MANAGED_SKILL_MARKER_V2, MANAGED_SKILL_MARKER_V3, MANAGED_SKILL_MARKER_V4, MANAGED_SKILL_MARKER_V5) -MANAGED_SKILL_CURRENT_MARKER = MANAGED_SKILL_MARKER_V5 +MANAGED_SKILL_MARKER_V6 = "" +MANAGED_SKILL_MARKERS = (MANAGED_SKILL_MARKER_V1, MANAGED_SKILL_MARKER_V2, MANAGED_SKILL_MARKER_V3, MANAGED_SKILL_MARKER_V4, MANAGED_SKILL_MARKER_V5, MANAGED_SKILL_MARKER_V6) +MANAGED_SKILL_CURRENT_MARKER = MANAGED_SKILL_MARKER_V6 def manager_skill_text() -> str: diff --git a/loopx/cli_commands/summary_all_registration.py b/loopx/cli_commands/summary_all_registration.py index 5c00588e9e..04a15d6f00 100644 --- a/loopx/cli_commands/summary_all_registration.py +++ b/loopx/cli_commands/summary_all_registration.py @@ -29,7 +29,7 @@ def register_summary_all_command( ) add_subcommand_format(portfolio) 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("--query", default="", help="Search Agent identities/responsibilities (agents), or permitted full task text before paging (todos).") portfolio.add_argument("--todo-id", dest="context_todo_id", help="Read an exact permitted Todo with --manager-view todos and one --goal-id.") portfolio.add_argument("--offset", type=int, default=0) portfolio.add_argument("--days", type=int, default=1) diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 8e3922cb15..e19b21dd14 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -334,6 +334,7 @@ export function createEffectRuntimeHandlers( lazyHandler(() => import("./work_items/action_portfolio.ts"), ({reconcileRetainedActionSelection}) => reconcileRetainedActionSelection), ], ["todo.context.page", lazyHandler(() => import("./todos/context_projection.ts"), ({projectTodoContextPage}) => projectTodoContextPage)], + ["manager.discovery.project", lazyHandler(() => import("../capabilities/manager_context/discovery_projection.ts"), ({projectAgentDiscovery}) => projectAgentDiscovery)], ["work_item.task_graph.status", lazyHandler(() => import("./work_items/task_graph.ts"), ({projectStatusTaskGraph}) => projectStatusTaskGraph)], ["work_item.task_graph.goal_topology", lazyHandler(() => import("./work_items/task_graph.ts"), ({projectGoalTaskMap}) => projectGoalTaskMap)], ["work_item.refresh_recommendation.resolve", lazyHandler(() => import("./work_items/refresh_recommendation.ts"), ({resolveRefreshRecommendation}) => resolveRefreshRecommendation)], diff --git a/loopx/control_plane/todos/context_projection.ts b/loopx/control_plane/todos/context_projection.ts index 20b39cc3e9..f920604fac 100644 --- a/loopx/control_plane/todos/context_projection.ts +++ b/loopx/control_plane/todos/context_projection.ts @@ -79,6 +79,10 @@ export function projectTodoContextPage(input: JsonObject): JsonObject { const limit = requireInteger(input.limit, "limit"); if (offset < 0 || limit < 1 || limit > 48) throw new EffectRuntimeRequestError("Todo context page is out of range"); const exact = input.todo_id !== undefined && input.todo_id !== null; + const query = input.query ?? ""; + if (typeof query !== "string" || Array.from(query).length > 200 || (exact && query)) { + throw new EffectRuntimeRequestError("invalid Todo context query"); + } if (exact && (typeof input.todo_id !== "string" || !input.todo_id)) { throw new EffectRuntimeRequestError("todo_id must be a non-empty string"); } @@ -88,7 +92,13 @@ export function projectTodoContextPage(input: JsonObject): JsonObject { active.sort((a, b) => Number(a.role !== "user") - Number(b.role !== "user") || (String(a.priority ?? "Z") < String(b.priority ?? "Z") ? -1 : String(a.priority ?? "Z") > String(b.priority ?? "Z") ? 1 : 0)); - const todos = active.slice(offset, offset + limit).map(record => { + const needle = query.trim().toLocaleLowerCase(); + // Search only fields this audience can read. A private continuation must not + // become a membership oracle for a group, even when its text is not returned. + const matched = needle ? active.filter(record => [record.todo_id, record.title, record.text, + ...(owner ? [record.note, record.continuation_hint] : [])] + .some(value => typeof value === "string" && value.toLocaleLowerCase().includes(needle))) : active; + const todos = matched.slice(offset, offset + limit).map(record => { const row: JsonObject = {}; for (const key of CONTEXT_FIELDS) { if (record[key] !== undefined && record[key] !== null) row[key] = record[key]; @@ -107,8 +117,8 @@ export function projectTodoContextPage(input: JsonObject): JsonObject { row.content_truncated = truncated; return row; }); - return {todos, coverage: {active: active.length, included: todos.length, - omitted: Math.max(0, active.length - todos.length)}}; + return {todos, coverage: {active: active.length, ...(needle ? {matched: matched.length} : {}), included: todos.length, + omitted: Math.max(0, matched.length - todos.length)}}; } /** Lossless CLI presentation only; the full decision read model stays intact. */ diff --git a/skills/loopx-pr-program/SKILL.md b/skills/loopx-pr-program/SKILL.md index 0ce68fabb3..289d0519c8 100644 --- a/skills/loopx-pr-program/SKILL.md +++ b/skills/loopx-pr-program/SKILL.md @@ -23,11 +23,22 @@ ordinary status question remains read-only. ## Start From LoopX State -Start or join the project goal before material work. Represent the program with -one advancement todo for the current reconciliation and one +For a one-off assessment, read the relevant existing Goal/work and request +context without creating a Goal, Todo or monitor just to answer. For an +authorized durable program, start or join its project Goal before material +work. Represent the program with one advancement todo for reconciliation and, +when recurring observation was requested, one `continuous_monitor` todo for recurring observation. Do not create one monitor todo per change request. +Resolve requirement shorthand, dates and aliases from the owner's accepted +request and relevant Goal requirements, current Todos and recent deliveries. +Then reconcile with the current implementation and source-control evidence. +A title search is a discovery lead, not the requirement definition; an old +Goal record is not proof that a PR remains open or its blocker still applies. +If multiple plausible requirement sources would change the ranking, state the +uncertainty and ask only for that unresolved choice while assessing known work. + Use a stable monitor identity: ```text @@ -66,6 +77,22 @@ as `.local/loopx/pr-program//`. Verify the path with `git check-ignore` before writing. If no ignored path is available, use a temporary directory and keep only a redacted evidence summary in LoopX state. +Acquire a compact complete inventory first: identity, title, head/base revision, +lifecycle, requirement/dependency references and check/review summaries. Read +the bodies, relevant reviews, changed-file evidence and failure details of +candidates that could affect the decision. Do not fetch every full comment, +check log or trajectory in the inventory call. A short row is not evidence that +the underlying implementation was reviewed. + +Retain each response once with its exact revision, query scope, observation time +and completeness; select further details from that capture instead of querying +the same long body again. Batch independent reads within the combined output +budget. Preserve running-command handles and collect their completion rather +than repeating the command. If output is truncated, recover the original +capture or read the missing bounded section before relying on it. Refresh a +candidate when its head or relevant external state changes, and recheck current +authority before effects; retained evidence is not a durable authorization cache. + ## Reconcile Material Changes Run the bundled delta helper before manually comparing rows: @@ -91,6 +118,23 @@ explicitly when no change request implements part of the requested behavior; do not call the requirement complete because a neighboring parameter or feature landed. +For each leading candidate, explain requirement coverage, prerequisites, the +next useful action and its responsible owner separately from merge readiness. +A blocked high-value change can remain the highest priority while an +independent ready change proceeds. Do not rank by check color or imply merge +permission from priority. + +Before recommending a CI repair work item, inspect the relevant failing check +at the candidate's current head and compare the same failure on its target +base/main when needed for attribution. Distinguish a code regression, shared +baseline/infrastructure failure, cancellation and a still-running check. +Reuse a matching existing issue or work item; recommend a new one only for a +remaining gap with a concrete owner, reproduction and acceptance condition. +Link shared blockers to all affected candidates instead of creating a duplicate +per PR. Creating a tracker neither repairs CI nor proves checks will pass. +An assessment may propose follow-through, but performs writes only under the +owner's existing authorization for that operation. + ## Compose With An Integration Branch When several selected change requests belong to one repository, compose this diff --git a/tests/control_plane_ts/manager_discovery_projection.test.ts b/tests/control_plane_ts/manager_discovery_projection.test.ts new file mode 100644 index 0000000000..c794f3468a --- /dev/null +++ b/tests/control_plane_ts/manager_discovery_projection.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {projectAgentDiscovery} from "../../loopx/capabilities/manager_context/discovery_projection.ts"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; + +test("bounded discovery retains unread sources and independent continuation offsets", () => { + const sources = Array.from({length: 14}, (_, i) => ({source_id: `ssh:source-${i}`, source_host: `source-${i}`})); + const pages = sources.slice(0, 12).map(() => ({ok: true, matched: 2, rows: [ + {goal_id: "shared-name", agent_id: "first"}, {goal_id: "shared-name", agent_id: "second"}]})); + const result = projectAgentDiscovery({arguments: {view: "agents", limit: 8}, sources, pages}); + assert.equal((result.rows as unknown[]).length, 8); + assert.equal(result.unknown, true); + assert.equal(result.matched, null); + const next = result.next_reads as JsonObject[]; + assert.equal(next.length, 14); + assert.equal(next[0].offset, 1); + assert.equal(next[8].offset, 0); + assert.equal((result.source_pages as JsonObject[])[13].status, "not_read"); + assert.equal(new Set((result.rows as JsonObject[]).map(row => row.source_id)).size, 8); +}); + +test("a failed source never becomes an empty successful search", () => { + const result = projectAgentDiscovery({arguments: {view: "agents", query: "release"}, + sources: [{source_id: "local"}, {source_id: "ssh:sample"}], + pages: [{ok: true, matched: 0, rows: []}, {ok: false, error: "unavailable", reason_code: "timeout", rows: []}]}); + assert.equal(result.matched, null); + assert.equal(result.unknown, true); + assert.equal((result.source_pages as JsonObject[])[1].reason_code, "timeout"); + assert.deepEqual(result.next_reads, [{view: "agents", query: "release", source_id: "ssh:sample", offset: 0}]); +}); diff --git a/tests/control_plane_ts/todo_context_projection.test.ts b/tests/control_plane_ts/todo_context_projection.test.ts index d1f3904ad0..2fbaea8444 100644 --- a/tests/control_plane_ts/todo_context_projection.test.ts +++ b/tests/control_plane_ts/todo_context_projection.test.ts @@ -7,6 +7,17 @@ const page = (records: JsonObject[], options: JsonObject = {}) => projectTodoCon records, owner_scope: true, offset: 0, limit: 48, ...options, }); +test("task search reaches text beyond the excerpt and filters before paging", () => { + const records = Array.from({length: 30}, (_, i) => ({todo_id: `task-${i}`, status: "open", + title: "Short title", text: "Context. ".repeat(90) + (i >= 28 ? "Release violet" : "Routine work")})); + const result = page(records, {query: "VIOLET", offset: 1, limit: 1}); + assert.deepEqual(result.coverage, {active: 30, matched: 2, included: 1, omitted: 1}); + assert.equal((result.todos as JsonObject[])[0].todo_id, "task-29"); + const privateNote = [{todo_id: "private-note", status: "open", text: "Public summary", note: "violet"}]; + assert.equal((page(privateNote, {query: "violet", owner_scope: false}).todos as unknown[]).length, 0); + assert.equal((page(privateNote, {query: "violet"}).todos as unknown[]).length, 1); +}); + // Independent consumer oracle: follow response-local JSON pointers, never ids. function expandReferences(value: unknown, root: unknown): unknown { if (Array.isArray(value)) return value.map(child => expandReferences(child, root)); diff --git a/tests/test_chat_manager_model_adoption.py b/tests/test_chat_manager_model_adoption.py index 77883df783..72949a736b 100644 --- a/tests/test_chat_manager_model_adoption.py +++ b/tests/test_chat_manager_model_adoption.py @@ -137,6 +137,38 @@ def test_idle_conversation_adopts_model_preserving_context_and_authority(convers assert len(starts) == 2 +def test_legacy_bound_steward_adopts_explicit_machine_model_once(conversation): + root, store, runtime, session, starts = conversation + sid = session["session_id"] + import json + legacy = {**store.load_session(sid), "agent_model_id": "gpt-6-sol", "agent_reasoning_effort": "xhigh"} + legacy.pop("manager_executor_allocation", None) + store._session_path(sid).write_text(json.dumps(legacy)) # retained pre-allocation disk format + store.append_message(sid, role="user", text="Preserve the source of the requirement.") + configure(root, model="gpt-6.1-sol", effort="high") + runtime._ensure_adapter(legacy, work_dir=root, objective="manager") + current = store.load_session(sid) + assert starts[-1][0]["executor_model"] == {"model": "gpt-6.1-sol", "reasoning_effort": "high"} + assert starts[-1][0]["history"] == [{"role": "user", "content": "Preserve the source of the requirement."}] + assert current["channel_id"] == session["channel_id"] + assert current["manager_executor_allocation"]["configured_model"] == "gpt-6.1-sol" + count = len(starts) + runtime._ensure_adapter(current, work_dir=root, objective="manager") + assert len(starts) == count + + +def test_legacy_model_adoption_preserves_unset_fields_and_endpoint(conversation): + from loopx.chat_manager import manager_session_model_allocation + root, store, runtime, session, _ = conversation + legacy = {**session, "manager_executor_allocation": None, + "agent_model_id": "gpt-6-sol", "agent_reasoning_effort": "xhigh"} + configure(root, model=None, effort="high") + allocation = manager_session_model_allocation(runtime, legacy) + assert allocation["model"] == "gpt-6-sol" and allocation["reasoning_effort"] == "high" + configure(root, model="gemini-4-argon-high", effort="high", endpoint="antigravity") + assert manager_session_model_allocation(runtime, legacy) is None + + def test_readback_observation_does_not_cache_later_allocation(conversation, monkeypatch): _, store, runtime, session, _ = conversation before = store.load_session(session["session_id"]) diff --git a/tests/test_manager_agent_discovery.py b/tests/test_manager_agent_discovery.py index 9f764ca3e2..489dcfe76c 100644 --- a/tests/test_manager_agent_discovery.py +++ b/tests/test_manager_agent_discovery.py @@ -17,7 +17,7 @@ def setup(tmp_path, *, owner=True, scope=None, valid=lambda: True): "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": "history", "activation": {"state": "stopped"}, "registered_agents": ["old-worker"]}, {"id": "private", "registered_agents": ["hidden-worker"]}, ]})) records = [] diff --git a/tests/test_manager_source_discovery.py b/tests/test_manager_source_discovery.py new file mode 100644 index 0000000000..0aa226f476 --- /dev/null +++ b/tests/test_manager_source_discovery.py @@ -0,0 +1,134 @@ +"""One ordinary read discovers peers across authorized hosts without status fanout.""" +import json +import shlex +import subprocess +import sys + +from loopx.capabilities.manager_context.inspection import ManagerInspection, TOOL_NAME +from loopx.capabilities.manager_context import POLICY_SCHEMA, _root + + +def reader_fixture(tmp_path, *, fail=False, revoke=False, todo_text=None): + local = tmp_path / "registry.json" + local.write_text(json.dumps({"goals": [{"id": "project", "registered_agents": + [f"local-{i}" for i in range(20)]}]})) + remote = tmp_path / "remote.json" + remote.write_text(json.dumps({"goals": [ + {"id": "project", "registered_agents": ["remote-worker"], "coordination": { + "agent_profiles": {"remote-worker": {"scope_summary": "Current service rollout"}}}}, + {"id": "private", "registered_agents": ["outside-audience"]}, + ]})) + remote_runtime = tmp_path / "remote-runtime" + if todo_text is not None: + for registry, runtime in ((local, tmp_path), (remote, remote_runtime)): + registry.unlink() + project = tmp_path / registry.stem + project.mkdir() + for command in ( + ["bootstrap", "--project", str(project), "--goal-id", "project", + "--objective", "Validate task evidence", "--no-global-sync"], + ["todo", "add", "--goal-id", "project", "--role", "agent", "--text", todo_text], + ): + result = subprocess.run( + [sys.executable, "-m", "loopx.entrypoint", "--registry", str(registry), + "--runtime-root", str(runtime), "--format", "json", *command], + capture_output=True, text=True, timeout=60, + ) + assert result.returncode == 0, result.stdout + result.stderr + config = tmp_path / "ssh_config" + config.write_text("Host sample-host\n HostName example.invalid\n") + policy = _root(tmp_path) / "policy.json" + policy.parent.mkdir(parents=True) + policy.write_text(json.dumps({"schema_version": POLICY_SCHEMA, "sources": { + "manager.external.test": {"evidence_ssh_hosts": {"sample-host": ["project"]}}}})) + calls = [] + + def run(argv, **kwargs): + calls.append(argv) + view = "agents" if todo_text is None else "todos" + assert f"goal-portfolio --manager-view {view}" in argv[-1] + assert "--goal-id project" in argv[-1] + if fail: + raise subprocess.TimeoutExpired("ssh", kwargs["timeout"]) + # Use the real source CLI and registry decoder; only replace SSH transport. + command = shlex.split(argv[-1]) + result = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(remote), + "--runtime-root", str(remote_runtime), "--format", "json", + *command[command.index("goal-portfolio"):]], capture_output=True, text=True) + if revoke: + policy.write_text(json.dumps({"schema_version": POLICY_SCHEMA, "sources": {}})) + return result + + records = [] + reader = ManagerInspection(context={"scope": "external_goal_scope", "goals": []}, + registry_path=local, runtime_root=tmp_path, owner_scope=False, + channel_id="manager.external.test", scope_valid=lambda: True, + discovery_scope=lambda: ["project"], record=records.append, + ssh_config_path=config, remote_runner=run) + return reader, calls + + +def test_default_discovery_includes_remote_and_keeps_source_qualified_paging(tmp_path): + reader, calls = reader_fixture(tmp_path) + result = reader.read(TOOL_NAME, {"view": "agents", "limit": 3}) + assert result["unknown"] is False and result["matched"] == 21 + assert [(row["source_id"], row["agent_id"]) for row in result["rows"]] == [ + ("local", "local-0"), ("ssh:sample-host", "remote-worker"), ("local", "local-1")] + assert len(result["next_reads"]) == 1 + next_page = reader.read(TOOL_NAME, result["next_reads"][0]) + assert next_page["offset"] == 2 and next_page["rows"][0]["agent_id"] == "local-10" + assert len(calls) == 1 # Continuing local results does not poll the remote again. + assert "outside-audience" not in json.dumps(result) + + +def test_topic_search_is_federated_without_an_explicit_remote_hint(tmp_path): + reader, calls = reader_fixture(tmp_path) + result = reader.read(TOOL_NAME, {"view": "agents", "query": "rollout"}) + assert result["matched"] == 1 and result["rows"][0]["source_id"] == "ssh:sample-host" + assert len(calls) == 1 and result["next_reads"] == [] + + +def test_todo_search_reads_canonical_tail_locally_and_over_ssh(tmp_path): + astral_query = "𠀀" * 101 + reader, _ = reader_fixture(tmp_path, todo_text="Repeat the public check. " * 28 + "--execute " + astral_query) + for source in ("local", "ssh:sample-host"): + scope = {"view": "todos", "goal_id": "project", "source_id": source} + overview = reader.read(TOOL_NAME, scope) + assert overview["ok"] and len(overview["rows"]) == 1, overview + expected_id = overview["rows"][0]["todo_id"] + assert "--execute" not in overview["rows"][0]["title"] + for query in ("--execute", astral_query): + result = reader.read(TOOL_NAME, {**scope, "query": query}) + assert result["ok"] and result["matched"] == 1, result + assert result["rows"][0]["todo_id"] == expected_id + + +def test_source_failure_is_unknown_and_local_scope_remains_explicit(tmp_path): + reader, calls = reader_fixture(tmp_path, fail=True) + result = reader.read(TOOL_NAME, {"view": "agents", "query": "rollout"}) + assert result["unknown"] and result["matched"] is None + assert result["next_reads"][0]["source_id"] == "ssh:sample-host" + reader.read(TOOL_NAME, {"view": "agents", "source_id": "local"}) + assert len(calls) == 1 + reader.context["scope"] = "owner_goal" + reader.read(TOOL_NAME, {"view": "agents"}) + assert len(calls) == 1 + + +def test_revoked_source_cannot_survive_federated_read(tmp_path): + reader, _ = reader_fixture(tmp_path, revoke=True) + result = reader.read(TOOL_NAME, {"view": "agents"}) + assert result == {"ok": False, "error": "authorization_changed"} + + +def test_legacy_remote_cannot_silently_ignore_task_query(tmp_path): + from loopx.capabilities.manager_context.ssh_evidence import read_remote + reader, _ = reader_fixture(tmp_path) + result = read_remote(tmp_path, reader.channel_id, False, + {"view": "todos", "source_id": "ssh:sample-host", "goal_id": "project", "query": "release"}, + lambda: True, config_path=reader.ssh_config_path, + runner=lambda *args, **kwargs: subprocess.CompletedProcess([], 0, json.dumps({ + "schema_version": "manager_evidence_page_v1", "ok": True, + "rows": [{"goal_id": "project", "title": "Unfiltered old page"}]}), "")) + assert result["reason_code"] == "remote_query_unsupported" + assert not result["rows"] diff --git a/tests/test_manager_ssh_evidence.py b/tests/test_manager_ssh_evidence.py index eea20b2829..0246b42bd0 100644 --- a/tests/test_manager_ssh_evidence.py +++ b/tests/test_manager_ssh_evidence.py @@ -294,6 +294,7 @@ def list_todos(**kwargs): limit=8, offset=0, days=1, + query="", context_todo_id=todo_id, ) packet = export_page(registry, str(tmp_path), args) @@ -928,7 +929,7 @@ def test_remote_agent_search_keeps_source_and_audience_scope(remote): 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 "--query=review; $(noop)" in argv assert argv[argv.index("--goal-id") + 1] == "remote-goal" assert argv[argv.index("--offset") + 1] == "12" assert records[-1]["source_id"] == "ssh:research-host"