From 25d9852c00c1ba9ca1489712d8d8374d02b756a4 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:03:37 +0800 Subject: [PATCH 1/5] feat(multi-subagent): record Turn-bound native child reports Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/capabilities/multi_subagent/cli.py | 82 +++++ .../multi_subagent/native_child_receipts.py | 294 ++++++++++++++++++ loopx/cli.py | 11 + loopx/cli_commands/agent_context.py | 32 ++ loopx/control_plane/subagent_context.ts | 41 ++- .../presentation/renderers/status_markdown.py | 18 ++ loopx/rollout_event_log.py | 10 +- loopx/status.py | 29 ++ 8 files changed, 510 insertions(+), 7 deletions(-) create mode 100644 loopx/capabilities/multi_subagent/cli.py create mode 100644 loopx/capabilities/multi_subagent/native_child_receipts.py diff --git a/loopx/capabilities/multi_subagent/cli.py b/loopx/capabilities/multi_subagent/cli.py new file mode 100644 index 0000000000..d411ac8f6d --- /dev/null +++ b/loopx/capabilities/multi_subagent/cli.py @@ -0,0 +1,82 @@ +"""Provider-neutral, Turn-scoped native child receipt commands.""" + +from __future__ import annotations + +from ...agent_registry import load_goal_from_registry, registered_agent_ids_for_goal +from ...orchestration import compact_orchestration_policy +from .native_child_receipts import load_native_child_activity, record_native_child + + +def register_native_child_commands(subparsers, add_format): + parser = subparsers.add_parser( + "native-child", help="Record or read typed host-native child activity for an admitted Turn." + ) + add_format(parser) + parser.add_argument("native_child_action", choices=("record", "read")) + parser.add_argument("--goal-id", required=True) + parser.add_argument("--agent-id", required=True) + parser.add_argument("--turn-instance-id", required=True) + parser.add_argument("--operation-id", help="Stable identity for one host-native child operation.") + parser.add_argument("--stage", choices=("decision", "result", "review")) + parser.add_argument("--operation", choices=("spawn", "followup", "skip")) + parser.add_argument("--outcome") + parser.add_argument("--entrypoint-id", help="Opaque host entrypoint identity, not a host-specific enum.") + parser.add_argument("--reason-code") + parser.add_argument("--evidence-ref", help="Public-safe opaque reference for accepted evidence.") + parser.add_argument("--validation-ref", help="Public-safe opaque reference for parent validation.") + parser.add_argument("--execute", action="store_true", help="Append the receipt; otherwise preview only.") + + +def handle_native_child_command(args, registry_path, runtime_root, print_payload, output_format): + if args.command != "native-child": + return None + try: + goal = load_goal_from_registry(registry_path, args.goal_id) + if goal is None or args.agent_id not in registered_agent_ids_for_goal(goal): + raise ValueError("coordinator is not registered for this Goal") + orchestration = compact_orchestration_policy(goal.get("spawn_policy")) + if not ( + orchestration.get("mode") == "multi_subagent" + and orchestration.get("spawn_allowed") is True + and int(orchestration.get("max_children") or 0) > 0 + ): + raise ValueError("native child receipts require enabled multi_subagent policy") + configured_limit = int(orchestration["max_children"]) + if args.native_child_action == "read": + if any((args.operation_id, args.stage, args.operation, args.outcome, + args.entrypoint_id, args.reason_code, args.evidence_ref, + args.validation_ref, args.execute)): + raise ValueError("read accepts only goal, agent and Turn identity") + payload = {"ok": True, "native_child_activity": load_native_child_activity( + runtime_root, goal_id=args.goal_id, agent_id=args.agent_id, + turn_instance_id=args.turn_instance_id, configured_limit=configured_limit, + )} + else: + if not args.operation_id or not args.stage or not args.outcome: + raise ValueError("record requires operation-id, stage and outcome") + payload = record_native_child( + runtime_root=runtime_root, goal_id=args.goal_id, agent_id=args.agent_id, + turn_instance_id=args.turn_instance_id, operation_id=args.operation_id, + configured_limit=configured_limit, stage=args.stage, + operation=args.operation, outcome=args.outcome, + entrypoint_id=args.entrypoint_id, reason_code=args.reason_code, + evidence_ref=args.evidence_ref, validation_ref=args.validation_ref, + execute=args.execute, + ) + except (OSError, ValueError, KeyError) as exc: + payload = {"ok": False, "error": str(exc)} + print_payload(payload, output_format(args), render_native_child) + return 0 if payload["ok"] else 1 + + +def render_native_child(payload): + if not payload["ok"]: + return str(payload["error"]) + activity = payload["native_child_activity"] + return ( + f"Native child activity for {activity['turn_instance_id']}: " + f"{activity['observation']}; {activity['launched_count']} reported starts, " + f"{activity['skipped_count']} skips, " + f"{activity['capacity_rejected_count']} capacity rejections, " + f"{activity['host_failed_count']} host failures." + ) diff --git a/loopx/capabilities/multi_subagent/native_child_receipts.py b/loopx/capabilities/multi_subagent/native_child_receipts.py new file mode 100644 index 0000000000..ad9d3e57c5 --- /dev/null +++ b/loopx/capabilities/multi_subagent/native_child_receipts.py @@ -0,0 +1,294 @@ +"""Turn-bound reports of host-native child-tool decisions. + +The native tool belongs to the host. LoopX can durably reconcile the +coordinator's typed report of its result, but cannot attest that a host call +occurred unless the host itself supplies an integration. This distinction is +part of the projection, not an implicit promise of configured capacity. +""" + +from __future__ import annotations + +import re +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + +from ...control_plane.quota.heartbeat_receipt import find_heartbeat_receipt +from ...control_plane.runtime.public_safety import validate_public_safe_value +from ...rollout_event_log import ( + append_rollout_event_once, + build_rollout_event, + load_rollout_events, + rollout_event_log_path, +) + + +SCHEMA_VERSION = "native_subagent_activity_v0" +EVENT_KINDS = { + "decision": "native_child_decision", + "result": "native_child_result", + "review": "native_child_review", +} +OPERATIONS = frozenset({"spawn", "followup", "skip"}) +DECISION_OUTCOMES = frozenset({"started", "capacity_rejected", "host_failed", "skipped"}) +RESULT_OUTCOMES = frozenset({"completed", "failed", "cancelled"}) +REVIEW_OUTCOMES = frozenset({"accepted", "deferred", "rejected"}) +SKIP_REASONS = frozenset({ + "no_independent_work", "duplicate_evidence", "brief_incomplete", + "parent_work_priority", "scope_not_admitted", "capacity_deferred", +}) +FAILURE_REASONS = frozenset({"host_unavailable", "host_rejected", "host_failed"}) +REVIEW_REASONS = frozenset({"evidence_incomplete", "source_unverified", "contradicted", "not_needed"}) +_OPAQUE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$") +_MAX_VISIBLE = 8 + + +def _id(value: Any, *, field: str) -> str: + if not isinstance(value, str) or not _OPAQUE_ID.fullmatch(value): + raise ValueError(f"{field} must be a compact opaque id") + validate_public_safe_value(value, path=field) + return value + + +def _choice(value: Any, *, field: str, choices: frozenset[str]) -> str: + if value not in choices: + raise ValueError(f"{field} must be one of: {', '.join(sorted(choices))}") + return str(value) + + +def _details(event: Mapping[str, Any]) -> dict[str, Any]: + value = event.get("details") + return dict(value) if isinstance(value, Mapping) else {} + + +def _events_for_turn( + events: Sequence[Mapping[str, Any]], *, agent_id: str, turn_instance_id: str, +) -> list[dict[str, Any]]: + return [dict(event) for event in events + if event.get("event_kind") in EVENT_KINDS.values() + and event.get("agent_id") == agent_id + and event.get("run_id") == turn_instance_id] + + +def native_child_activity( + events: Sequence[Mapping[str, Any]], *, goal_id: str, agent_id: str, + turn_instance_id: str, configured_limit: int, +) -> dict[str, Any]: + """One read model for CLI, agent-context and product projections.""" + rows = _events_for_turn(events, agent_id=agent_id, turn_instance_id=turn_instance_id) + operations: dict[str, dict[str, Any]] = {} + for event in rows: + details = _details(event) + operation_id = str(event.get("case_id") or "") + if not operation_id: + continue + row = operations.setdefault(operation_id, {"operation_id": operation_id}) + kind = event.get("event_kind") + if kind == EVENT_KINDS["decision"]: + row.update({key: details[key] for key in + ("entrypoint_id", "observation_source", "operation", "outcome", "reason_code") + if key in details}) + row["recorded_at"] = event.get("recorded_at") + elif kind == EVENT_KINDS["result"]: + row["result"] = details.get("outcome") + elif kind == EVENT_KINDS["review"]: + row["parent_review"] = details.get("outcome") + if details.get("outcome") == "accepted": + row["evidence_ref"] = details.get("evidence_ref") + row["validation_ref"] = details.get("validation_ref") + ordered = [row for row in operations.values() if "operation" in row] + ordered.sort(key=lambda row: (str(row.get("recorded_at") or ""), row["operation_id"])) + launched = sum(row.get("outcome") == "started" and row.get("operation") == "spawn" + for row in ordered) + attempted = sum(row.get("operation") in {"spawn", "followup"} for row in ordered) + rejected = sum(row.get("outcome") == "capacity_rejected" for row in ordered) + host_failed = sum(row.get("outcome") == "host_failed" for row in ordered) + return { + "schema_version": SCHEMA_VERSION, + "goal_id": goal_id, "agent_id": agent_id, + "turn_instance_id": turn_instance_id, + "entrypoint_scope": "host_native_child_tools", + "observation": "coordinator_reported" if ordered else "unknown", + "host_attested": False, + "configured_limit_kind": "upper_bound", + "configured_limit": configured_limit, + "observed_capacity": "capacity_rejection_reported" if rejected else + "host_failure_reported" if host_failed else + "attempt_reported" if attempted else "not_observed", + "attempted_count": attempted, + "launched_count": launched, + "skipped_count": sum(row.get("outcome") == "skipped" for row in ordered), + "capacity_rejected_count": rejected, + "host_failed_count": host_failed, + "parent_accepted_count": sum(row.get("parent_review") == "accepted" for row in ordered), + "retry_same_turn": False if rejected or host_failed else None, + "operation_count": len(ordered), + "visible_operation_count": min(len(ordered), _MAX_VISIBLE), + "operations": ordered[-_MAX_VISIBLE:], + "quota_spend_slots": 0, + } + + +def load_native_child_activity( + runtime_root: Path, *, goal_id: str, agent_id: str, + turn_instance_id: str, configured_limit: int, limit: int | None = None, +) -> dict[str, Any]: + events = load_rollout_events(rollout_event_log_path(runtime_root, goal_id), limit=limit) + return native_child_activity( + events, goal_id=goal_id, agent_id=agent_id, + turn_instance_id=turn_instance_id, configured_limit=configured_limit, + ) + + +def latest_native_child_activity( + events: Sequence[Mapping[str, Any]], *, goal_id: str, configured_limit: int, +) -> dict[str, Any] | None: + """Expose only the latest reported Turn in existing Goal status surfaces.""" + decisions = [event for event in events + if event.get("goal_id") == goal_id + and event.get("event_kind") == EVENT_KINDS["decision"] + and event.get("agent_id") and event.get("run_id")] + if not decisions: + return None + latest = max(decisions, key=lambda event: str(event.get("recorded_at") or "")) + return native_child_activity( + events, goal_id=goal_id, agent_id=str(latest["agent_id"]), + turn_instance_id=str(latest["run_id"]), configured_limit=configured_limit, + ) + + +def _normalized_fields( + *, stage: str, operation: str | None, outcome: str, + entrypoint_id: str | None, reason_code: str | None, + evidence_ref: str | None, validation_ref: str | None, +) -> dict[str, str]: + if stage == "decision": + op = _choice(operation, field="operation", choices=OPERATIONS) + result = _choice(outcome, field="outcome", choices=DECISION_OUTCOMES) + host = _id(entrypoint_id, field="entrypoint_id") + if (op == "skip") != (result == "skipped"): + raise ValueError("skip operation and skipped outcome must occur together") + if result == "skipped": + reason = _choice(reason_code, field="reason_code", choices=SKIP_REASONS) + elif result == "capacity_rejected": + if reason_code not in {None, "host_capacity_exhausted"}: + raise ValueError("capacity rejection reason must be host_capacity_exhausted") + reason = "host_capacity_exhausted" + elif result == "host_failed": + reason = _choice(reason_code, field="reason_code", choices=FAILURE_REASONS) + else: + if reason_code is not None: + raise ValueError("started outcome must not include a reason_code") + reason = None + if evidence_ref or validation_ref: + raise ValueError("evidence refs belong to parent review") + return {"entrypoint_id": host, "observation_source": "coordinator_reported", + "operation": op, "outcome": result, + **({"reason_code": reason} if reason else {})} + if operation or entrypoint_id: + raise ValueError("result/review reuse the decision's operation and entrypoint_id") + if stage == "result": + result = _choice(outcome, field="outcome", choices=RESULT_OUTCOMES) + if reason_code or evidence_ref or validation_ref: + raise ValueError("result accepts only a typed outcome") + return {"outcome": result} + if stage == "review": + result = _choice(outcome, field="outcome", choices=REVIEW_OUTCOMES) + if result == "accepted": + if reason_code: + raise ValueError("accepted review must not include a reason_code") + return {"outcome": result, "evidence_ref": _id(evidence_ref, field="evidence_ref"), + "validation_ref": _id(validation_ref, field="validation_ref")} + if evidence_ref or validation_ref: + raise ValueError("deferred/rejected review must not adopt evidence") + return {"outcome": result, "reason_code": + _choice(reason_code, field="reason_code", choices=REVIEW_REASONS)} + raise ValueError("stage must be decision, result or review") + + +def record_native_child( + *, runtime_root: Path, goal_id: str, agent_id: str, + turn_instance_id: str, operation_id: str, configured_limit: int, + stage: str, outcome: str, operation: str | None = None, + entrypoint_id: str | None = None, reason_code: str | None = None, + evidence_ref: str | None = None, validation_ref: str | None = None, + execute: bool = False, +) -> dict[str, Any]: + """Preview or append a typed report; never launch a child or spend quota.""" + goal_id = _id(goal_id, field="goal_id") + agent_id = _id(agent_id, field="agent_id") + turn_instance_id = _id(turn_instance_id, field="turn_instance_id") + operation_id = _id(operation_id, field="operation_id") + if isinstance(configured_limit, bool) or not isinstance(configured_limit, int) or configured_limit < 1: + raise ValueError("enabled multi_subagent configured_limit must be positive") + fields = _normalized_fields( + stage=stage, operation=operation, outcome=outcome, entrypoint_id=entrypoint_id, + reason_code=reason_code, evidence_ref=evidence_ref, validation_ref=validation_ref, + ) + guard = find_heartbeat_receipt( + runtime_root, goal_id=goal_id, agent_id=agent_id, + turn_instance_id=turn_instance_id, + ) + if not guard or not _details(guard).get("settlement_effect_id"): + raise ValueError("native child report requires an admitted, settlement-bound Turn guard") + log_path = rollout_event_log_path(runtime_root, goal_id) + events = load_rollout_events(log_path) + prior = _events_for_turn(events, agent_id=agent_id, turn_instance_id=turn_instance_id) + existing = next((event for event in prior + if event.get("case_id") == operation_id + and event.get("event_kind") == EVENT_KINDS[stage]), None) + if existing is not None and _details(existing) != fields: + raise ValueError("operation identity already has a conflicting native child report") + + def validate_transition(observed: Sequence[Mapping[str, Any]]) -> None: + current = _events_for_turn(observed, agent_id=agent_id, + turn_instance_id=turn_instance_id) + decisions = {str(item.get("case_id")): item for item in current + if item.get("event_kind") == EVENT_KINDS["decision"]} + if stage == "decision": + if str(guard.get("status") or "") not in {"normal_run", "turn_run_once"}: + raise ValueError("native child decision requires a runnable Turn guard") + if fields["operation"] in {"spawn", "followup"} and any( + _details(item).get("outcome") in {"capacity_rejected", "host_failed"} + for item in decisions.values() + ): + raise ValueError("host failure forbids same-Turn spawn/followup retry") + return + decision = decisions.get(operation_id) + if decision is None or _details(decision).get("outcome") != "started": + raise ValueError("result/review requires a started native child decision") + if stage == "review": + result = next((item for item in current if item.get("case_id") == operation_id + and item.get("event_kind") == EVENT_KINDS["result"]), None) + if result is None or _details(result).get("outcome") != "completed": + raise ValueError("parent review requires a completed native child result") + + if existing is None: + validate_transition(prior) + event = build_rollout_event( + goal_id=goal_id, event_kind=EVENT_KINDS[stage], agent_id=agent_id, + run_id=turn_instance_id, case_id=operation_id, status=fields["outcome"], + details=fields, recorded_at=(existing or {}).get("recorded_at"), + ) + appended = False + if execute: + stored, appended = append_rollout_event_once( + log_path, event, + identity_fields=("goal_id", "event_kind", "agent_id", "run_id", "case_id"), + precondition=lambda: validate_transition(load_rollout_events(log_path)), + ) + if _details(stored) != fields: + raise ValueError("operation identity already has a conflicting native child report") + events = load_rollout_events(log_path) + else: + stored = existing or event + return { + "ok": True, "dry_run": not execute, "appended": appended, + "receipt": {key: stored[key] for key in + ("event_id", "event_kind", "recorded_at", "run_id", "case_id", "status")}, + "native_child_activity": native_child_activity( + events if execute or existing else [*events, event], goal_id=goal_id, + agent_id=agent_id, turn_instance_id=turn_instance_id, + configured_limit=configured_limit, + ), + } diff --git a/loopx/cli.py b/loopx/cli.py index e1bc9700a4..f9ed9a709d 100644 --- a/loopx/cli.py +++ b/loopx/cli.py @@ -10,6 +10,9 @@ from .cli_commands.agent_capabilities import register_agent_capabilities, handle_agent_capabilities from .cli_commands.agent_directory import register_agent_directory, handle_agent_directory from .cli_commands.agent_context import register_agent_context, handle_agent_context +from .capabilities.multi_subagent.cli import ( + register_native_child_commands, handle_native_child_command, +) from .cli_commands.todo_continuation import register_todo_continuation, handle_todo_continuation from .cli_commands.manager_inbox import register_manager_inbox, handle_manager_inbox from .cli_commands.delegation import register_delegation, handle_delegation @@ -348,6 +351,7 @@ def build_parser() -> LoopXArgumentParser: register_delegation(sub, add_subcommand_format) register_agent_capabilities(sub, add_subcommand_format) register_agent_context(sub, add_subcommand_format) + register_native_child_commands(sub, add_subcommand_format) register_agent_directory(sub, add_subcommand_format) register_lark_inbox_commands(sub, add_subcommand_format) register_lark_kanban_commands(sub, add_subcommand_format) @@ -803,6 +807,13 @@ def main(argv: list[str] | None = None) -> int: output_format, ) + native_child_result = handle_native_child_command( + args, registry_path, effective_runtime_root(registry_path, args.runtime_root), + print_payload, output_format, + ) + if native_child_result is not None: + return native_child_result + if args.command == "agent-directory": return handle_agent_directory( args, registry_path, effective_runtime_root(registry_path, args.runtime_root), diff --git a/loopx/cli_commands/agent_context.py b/loopx/cli_commands/agent_context.py index 9ce1658ff4..c93a394874 100644 --- a/loopx/cli_commands/agent_context.py +++ b/loopx/cli_commands/agent_context.py @@ -1,7 +1,9 @@ """Read-only lifecycle context for hosts whose native tools bypass LoopX Turn.""" from ..agent_registry import load_goal_from_registry, registered_agent_ids_for_goal +from ..capabilities.multi_subagent.native_child_receipts import load_native_child_activity from ..control_plane.agent_context import project_goal_agent_context +from ..orchestration import compact_orchestration_policy def register_agent_context(subparsers, add_format): @@ -31,6 +33,10 @@ def register_agent_context(subparsers, add_format): type=int, help="Optional non-negative native child count observed by the host.", ) + parser.add_argument( + "--turn-instance-id", + help="Read durable native child activity for this exact admitted Turn.", + ) def handle_agent_context(args, registry_path, runtime_root, print_payload, output_format): @@ -81,6 +87,12 @@ def handle_agent_context(args, registry_path, runtime_root, print_payload, outpu render_agent_context, ) return 1 + if args.turn_instance_id and args.phase != "after_delegate_result": + print_payload( + {"ok": False, "error": "Turn child receipts require --phase after_delegate_result"}, + output_format(args), render_agent_context, + ) + return 1 observations = {} if operation: native_capacity = { @@ -91,6 +103,23 @@ def handle_agent_context(args, registry_path, runtime_root, print_payload, outpu if child_count is not None: native_capacity["child_count"] = child_count observations["native_host_capacity"] = native_capacity + native_activity = None + if args.turn_instance_id: + orchestration = compact_orchestration_policy(goal.get("spawn_policy")) + if (orchestration.get("mode") != "multi_subagent" + or orchestration.get("spawn_allowed") is not True + or int(orchestration.get("max_children") or 0) < 1): + print_payload( + {"ok": False, "error": "Turn child receipts require enabled multi_subagent policy"}, + output_format(args), render_agent_context, + ) + return 1 + native_activity = load_native_child_activity( + runtime_root, goal_id=args.goal_id, agent_id=args.agent_id, + turn_instance_id=args.turn_instance_id, + configured_limit=int(orchestration["max_children"]), + ) + observations["native_child_activity"] = native_activity context = project_goal_agent_context( phase=args.phase, scope={"goal_id": args.goal_id, "agent_id": args.agent_id, "todo_id": None}, @@ -124,6 +153,9 @@ def handle_agent_context(args, registry_path, runtime_root, print_payload, outpu "host_capacity_scope": "native_tool_input", } ) + if native_activity is not None: + payload["native_child_activity"] = native_activity + payload["host_receipts_scope"] = "turn_bound_coordinator_report" print_payload(payload, output_format(args), render_agent_context) return 0 diff --git a/loopx/control_plane/subagent_context.ts b/loopx/control_plane/subagent_context.ts index 31cc76b35e..c9587b8d98 100644 --- a/loopx/control_plane/subagent_context.ts +++ b/loopx/control_plane/subagent_context.ts @@ -4,21 +4,21 @@ import type { JsonObject } from "./effect_program.ts"; import { jsonObject, requireJsonObject } from "./runtime_decode.ts"; export const subagentContextProvider: AgentContextProvider = { - hookId: "multi_subagent.coordinator", capabilityId: "multi_subagent", revision: "v5", + hookId: "multi_subagent.coordinator", capabilityId: "multi_subagent", revision: "v6", phases: AGENT_CONTEXT_PHASES, produce(input, config) { const guidance = { before_plan: [ "Prefer bounded independent delegation; max_children is a configured ceiling, not live availability. Admit native children incrementally, avoid duplicate reads, and keep one parent question.", - "Native child tools can read loopx agent-context at before_delegate and after_delegate_result; these calls do not start Turns or spend quota.", + "Host-native child work uses loopx native-child record/read for bounded Turn receipts; these calls do not start Turns or spend quota.", "Choose independent work from current routes and user preferences. Runtime availability is not entrypoint admission; inspect bound delegation. Peer-activation blocks do not assess native children or delegation. Do not relaunch every route on every heartbeat.", ], before_delegate: [ "Give each child a bounded question, sources, read/write limits, expected evidence and stopping condition; identify dependencies and the coordinator's concurrent question.", - "For an authorized route, use its binding entrypoint and recheck the chosen runtime, execution profile and budget. Never silently substitute a runtime/model; record the selection reason and stable operation id. Preferences and readiness observations are not execution receipts.", + "For an authorized route, use its binding entrypoint and recheck the chosen runtime, execution profile and budget. Never silently substitute a runtime/model; record the selection reason and stable operation id. Report each native decision or bounded skip; missing reports stay unknown.", ], after_delegate_result: [ - "Check returned sources, omissions and contradictions against the question. Reconcile native child receipts and any freshly read bound delegation operation receipts; missing, unavailable or rejected receipts do not establish completed work.", + "Check returned sources, omissions and contradictions against the question. Record native child result and parent review separately, then reconcile fresh Turn receipts and bound delegation receipts; missing or rejected receipts do not establish completed work.", "On typed agent_thread_limit_reached, stop same-Turn spawn/followup retries, mark unlaunched work incomplete, and continue useful parent work.", "Verify decisive sources and record accept/defer/reject with reasons. Link accepted evidence to the deliverable and run parent validation before writeback; opinions are not independent evidence.", ], @@ -48,6 +48,11 @@ export const subagentContextProvider: AgentContextProvider = { contract.live_availability = nativeCapacity.outcome === "agent_thread_limit_reached" ? "capacity_exhausted" : "attempt_observed"; } + const nativeActivity = boundedNativeChildActivity(input.observations.native_child_activity); + if (nativeActivity) { + facts.native_child_activity = nativeActivity; + facts.native_receipt_observation = nativeActivity.observation; + } const counts = jsonObject(input.observations.reconciliation_counts); facts.receipt_observation = counts ? "host_reconciled" : "not_supplied"; if (counts) facts.reconciliation_counts = Object.fromEntries( @@ -61,7 +66,7 @@ export const subagentContextProvider: AgentContextProvider = { }; } return { guidance, facts, source_refs: [ - "goal_boundary.orchestration", "docs/integrations/codex-subagent-orchestration.md", + "goal_boundary.orchestration", "docs/integrations/host-native-child-receipts.md", ] }; }, }; @@ -98,6 +103,32 @@ function boundedNativeCapacityObservation(value: unknown): JsonObject | null { return result; } +function boundedNativeChildActivity(value: unknown): JsonObject | null { + const source = jsonObject(value); + if (!source || source.schema_version !== "native_subagent_activity_v0" + || source.entrypoint_scope !== "host_native_child_tools") return null; + const observation = String(source.observation ?? ""); + if (!["unknown", "coordinator_reported"].includes(observation)) return null; + const count = (key: string) => Number.isInteger(source[key]) && Number(source[key]) >= 0 + ? Math.min(Number(source[key]), 10_000) : 0; + const result: JsonObject = { + schema_version: "native_subagent_activity_v0", + entrypoint_scope: "host_native_child_tools", + observation, + host_attested: false, + configured_limit_kind: "upper_bound", + configured_limit: count("configured_limit"), + attempted_count: count("attempted_count"), + launched_count: count("launched_count"), + skipped_count: count("skipped_count"), + capacity_rejected_count: count("capacity_rejected_count"), + host_failed_count: count("host_failed_count"), + parent_accepted_count: count("parent_accepted_count"), + }; + if (source.retry_same_turn === false) result.retry_same_turn = false; + return result; +} + function boundedDelegationContext(value: unknown): JsonObject | null { const source = jsonObject(value); if (!source || source.schema_version !== "loopx_delegation_context_v0") return null; diff --git a/loopx/presentation/renderers/status_markdown.py b/loopx/presentation/renderers/status_markdown.py index c9f1395165..c715bd741e 100644 --- a/loopx/presentation/renderers/status_markdown.py +++ b/loopx/presentation/renderers/status_markdown.py @@ -1268,6 +1268,24 @@ def _append_project_asset_runtime_policy_markdown( f"{markdown_scalar(orchestration_policy_summary(asset_orchestration))}" ) + native_child_activity = ( + project_asset.get("native_child_activity") + if isinstance(project_asset.get("native_child_activity"), dict) + else {} + ) + if native_child_activity.get("observation") == "coordinator_reported": + lines.append( + " - native_child_activity: " + f"turn={markdown_scalar(native_child_activity.get('turn_instance_id'))} " + "source=coordinator_reported host_attested=false " + f"configured_max={native_child_activity.get('configured_limit')} " + f"starts={native_child_activity.get('launched_count')} " + f"skips={native_child_activity.get('skipped_count')} " + f"capacity_rejections={native_child_activity.get('capacity_rejected_count')} " + f"host_failures={native_child_activity.get('host_failed_count')} " + f"parent_accepted={native_child_activity.get('parent_accepted_count')}" + ) + subagent_activity = ( project_asset.get("subagent_activity") if isinstance(project_asset.get("subagent_activity"), dict) diff --git a/loopx/rollout_event_log.py b/loopx/rollout_event_log.py index 0e81dadf24..a0e4a6c33c 100644 --- a/loopx/rollout_event_log.py +++ b/loopx/rollout_event_log.py @@ -5,7 +5,7 @@ from collections import Counter, deque from datetime import datetime, timezone from pathlib import Path -from typing import Any, Iterable, Iterator, Mapping, Sequence +from typing import Any, Callable, Iterable, Iterator, Mapping, Sequence from .file_lock import exclusive_file_lock @@ -21,6 +21,9 @@ "compact_case_result", "evidence_log_read", "failure_attribution", + "native_child_decision", + "native_child_result", + "native_child_review", "pr_merge", "pr_review_ack", "quota_monitor_poll", @@ -441,8 +444,9 @@ def append_rollout_event_once( event: Mapping[str, Any], *, identity_fields: Sequence[str], + precondition: Callable[[], None] | None = None, ) -> tuple[dict[str, Any], bool]: - """Append once by a stable public identity, returning whether it was new.""" + """Append once by a stable identity; check a transition under the same lock.""" payload = dict(event) if payload.get("schema_version") != ROLLOUT_EVENT_SCHEMA_VERSION: @@ -473,6 +477,8 @@ def append_rollout_event_once( if _idempotency_body(existing) == _idempotency_body(payload): return existing, False raise ValueError(f"conflicting rollout event_id: {event_id}") + if precondition is not None: + precondition() _append_rollout_event_line(log_path, payload) return payload, True diff --git a/loopx/status.py b/loopx/status.py index 26ee24cf16..ffe3bb60c4 100644 --- a/loopx/status.py +++ b/loopx/status.py @@ -60,6 +60,10 @@ from .interface_budget import interface_budget_cadence_for_runs from .long_task_cadence import build_long_task_cadence_hint from .orchestration import compact_orchestration_policy +from .capabilities.multi_subagent.native_child_receipts import ( + latest_native_child_activity, + load_native_child_activity, +) from .paths import resolve_runtime_root from .control_plane.work_items.task_graph import ( build_task_graph_projection as _build_task_graph_projection_read_model, @@ -1176,6 +1180,31 @@ def request_active_state_todo_fields( ) if receipts: item["evidence_log_read_receipts"] = receipts + project_asset = item.get("project_asset") + if not isinstance(project_asset, dict): + continue + orchestration = project_asset.get("orchestration") + if not isinstance(orchestration, dict) or not ( + orchestration.get("mode") == "multi_subagent" + and orchestration.get("spawn_allowed") is True + and int(orchestration.get("max_children") or 0) > 0 + ): + continue + native_activity = latest_native_child_activity( + events, goal_id=goal_id, + configured_limit=int(orchestration["max_children"]), + ) + if native_activity: + # The shared status snapshot is bounded for Todo work. Once it + # reveals a native decision, read its exact Turn before showing + # counts, so older stages falling outside that window cannot + # silently undercount the activity. + project_asset["native_child_activity"] = load_native_child_activity( + runtime_root, goal_id=goal_id, + agent_id=native_activity["agent_id"], + turn_instance_id=native_activity["turn_instance_id"], + configured_limit=int(orchestration["max_children"]), + ) return queue From d64b3752e0e99ae636a474820a36b90551f7fe14 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:04:16 +0800 Subject: [PATCH 2/5] feat(dashboard): distinguish native child reports from host evidence Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- apps/presentation/dashboard/src/data/status.ts | 14 ++++++++++++++ .../features/personal-workspace/context-drawer.tsx | 12 ++++++++++++ .../src/features/personal-workspace/i18n.tsx | 4 ++++ .../personal-workspace/personal-workspace-model.ts | 10 ++++++++++ .../dashboard/src/views/dashboard-page.tsx | 11 +++++++++++ 5 files changed, 51 insertions(+) diff --git a/apps/presentation/dashboard/src/data/status.ts b/apps/presentation/dashboard/src/data/status.ts index 724dca9573..ce3f9a0804 100644 --- a/apps/presentation/dashboard/src/data/status.ts +++ b/apps/presentation/dashboard/src/data/status.ts @@ -341,6 +341,19 @@ export const projectAssetTodoProjectionGapSchema = z.object({ recommended_action: z.string().optional().nullable(), }); +export const nativeChildActivitySchema = z.object({ + schema_version: z.literal("native_subagent_activity_v0"), + observation: z.enum(["unknown", "coordinator_reported"]), + host_attested: z.literal(false), + configured_limit: z.number().int().nonnegative(), + launched_count: z.number().int().nonnegative(), + skipped_count: z.number().int().nonnegative(), + capacity_rejected_count: z.number().int().nonnegative(), + host_failed_count: z.number().int().nonnegative(), + parent_accepted_count: z.number().int().nonnegative(), + turn_instance_id: z.string(), +}); + export const projectAssetSchema = z.object({ owner: z.string(), gate: z.string(), @@ -351,6 +364,7 @@ export const projectAssetSchema = z.object({ quota: quotaSchema.optional().nullable(), control_plane: controlPlaneSchema.optional().nullable(), orchestration: orchestrationPolicySchema.optional().nullable(), + native_child_activity: nativeChildActivitySchema.optional().nullable(), latest_validation: projectAssetLatestValidationSchema.optional().nullable(), stale_latest_run_warning: staleLatestRunWarningSchema.optional().nullable(), todo_projection_gap: projectAssetTodoProjectionGapSchema.optional().nullable(), diff --git a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx index d6e34ac46a..86f76d19e6 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -736,6 +736,18 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention : null} + {selection.item.nativeChildActivity?.observation === "coordinator_reported" ? ( +
+

{t("drawer.subagentReportTitle")}

+

{t("drawer.subagentReportedActivity", { + started: selection.item.nativeChildActivity.launched_count, + skipped: selection.item.nativeChildActivity.skipped_count, + rejected: selection.item.nativeChildActivity.capacity_rejected_count, + failed: selection.item.nativeChildActivity.host_failed_count, + accepted: selection.item.nativeChildActivity.parent_accepted_count, + })}

+
+ ) : null} {selection.item.subagentExecution ?
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index d30f3ea61a..e4890536a5 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -272,6 +272,8 @@ const en = { "drawer.subagentConfirmEnable": "Confirm turning on sub-agent execution", "drawer.subagentCurrentBoundary": "Current task-domain restriction", "drawer.subagentDescription": "Allows the runtime to create temporary child agents for independent tasks only after Todo, quota, capability, and write-scope gates pass. It does not force parallel work or grant durable authority.", + "drawer.subagentReportTitle": "Child activity", + "drawer.subagentReportedActivity": "Latest coordinator report: {started} starts, {skipped} skips, {rejected} capacity rejections, {failed} host failures, {accepted} parent-accepted results. Host verification is unavailable.", "drawer.subagentDisable": "Preview turning off sub-agent execution", "drawer.subagentDisableSummary": "New child-agent execution will be disabled for this Goal. Existing Todo ownership and execution records stay unchanged.", "drawer.subagentDomainInvalid": "The selected task-domain restriction is invalid.", @@ -1386,6 +1388,8 @@ const zhCN: Record = { "drawer.subagentConfirmEnable": "确认开启子代理执行", "drawer.subagentCurrentBoundary": "当前任务领域限制", "drawer.subagentDescription": "仅在 Todo、配额、能力和写入范围门禁全部通过后,允许运行时为相互独立的任务临时创建子代理;不会强制并行,也不会授予持久权限。", + "drawer.subagentReportTitle": "子代理活动", + "drawer.subagentReportedActivity": "最近一轮主 Agent 回报:启动 {started} 次、跳过 {skipped} 次、容量拒绝 {rejected} 次、宿主失败 {failed} 次、主 Agent 验收 {accepted} 项;目前没有宿主核验。", "drawer.subagentDisable": "预览关闭子代理执行", "drawer.subagentDisableSummary": "这个 Goal 将不再创建新的子代理;现有 Todo 归属和执行记录不受影响。", "drawer.subagentDomainInvalid": "所选任务领域限制无效。", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index 1f006cfbcc..9c92342da0 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -110,6 +110,16 @@ export type WorkspaceGoal = { repository?: WorkspaceRepositoryContext; state: WorkspaceGoalState; subagentExecution?: WorkspaceGoalSubagentConfiguration; + nativeChildActivity?: { + turn_instance_id: string; + observation: "unknown" | "coordinator_reported"; + host_attested: false; + launched_count: number; + skipped_count: number; + capacity_rejected_count: number; + host_failed_count: number; + parent_accepted_count: number; + } | null; title: string; usage?: WorkspaceGoalUsage | null; }; diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 79876e845f..c5c5680554 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -459,6 +459,16 @@ type PersonalGoalItem = { needsYouTodoId?: string | null; nextSentence: string; hasRunObservation: boolean; + nativeChildActivity?: { + turn_instance_id: string; + observation: "unknown" | "coordinator_reported"; + host_attested: false; + launched_count: number; + skipped_count: number; + capacity_rejected_count: number; + host_failed_count: number; + parent_accepted_count: number; + } | null; state: PersonalGoalState; subagentExecution?: { allowedDomains: string[]; @@ -1208,6 +1218,7 @@ function buildPersonalHomeModel( hasRunObservation: Boolean(row.queueItem?.project_asset?.latest_validation || row.latestRun || payload.event_ledger_summary?.goals.some((item) => item.goal_id === goal.id)), + nativeChildActivity: row.queueItem?.project_asset?.native_child_activity, state, ...(goalSubagentConfigurationEnabled ? { subagentExecution: { From 85ef87ba0ebfaf8631a5bfa782cb4ee0b31d148d Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:04:33 +0800 Subject: [PATCH 3/5] test(multi-subagent): cover generic child receipt lifecycle Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../host-native-child-receipts.md | 88 +++++++ examples/dashboard-home-browser-smoke.mjs | 52 ++++ .../test_native_child_receipts.py | 245 ++++++++++++++++++ tests/control_plane_ts/agent_context.test.ts | 29 ++- 4 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 docs/integrations/host-native-child-receipts.md create mode 100644 tests/capabilities/test_native_child_receipts.py diff --git a/docs/integrations/host-native-child-receipts.md b/docs/integrations/host-native-child-receipts.md new file mode 100644 index 0000000000..dcf0b5969b --- /dev/null +++ b/docs/integrations/host-native-child-receipts.md @@ -0,0 +1,88 @@ +# Host-native child receipts / 宿主原生子代理回执 + +## Contract / 契约 + +When an enabled `multi_subagent` coordinator works in an admitted Turn, it can +write a bounded `decision → result → parent review` record for a child created +through any host's native tools. The record uses the existing Goal rollout event +log, the Turn instance ID, a stable operation ID, and an opaque `entrypoint_id`. +It does not launch children, schedule another Turn, grant write authority, or +spend quota. The configured `max_children` is an upper bound, never an observed +live capacity or a required launch count. + +启用 `multi_subagent` 的主 Agent 在已准入的 Turn 内,可以为任意宿主原生工具 +创建的子代理记录有界的“决策 → 结果 → 主 Agent 验收”回执。回执复用现有 +Goal 事件流,以 Turn ID、稳定操作 ID 和不限定宿主的 `entrypoint_id` 关联。 +记录动作不会启动子代理、调度新 Turn、授予写入权限或消耗配额。 +`max_children` 只是配置上限,不代表当前可用槽位,也不是必须启动的数量。 + +`native-child record` currently accepts the coordinator's typed report. Its +`observation` is `coordinator_reported` and `host_attested` is always `false`. +LoopX cannot intercept an arbitrary external host's native tool call. A future +host adapter can observe that call at its own boundary and use the same event +and read-model contract, but must declare its provenance instead of silently +upgrading a coordinator report into host attestation. Missing records remain +`unknown`; neither a missing record nor `max_children > 0` proves that a child +was created or deliberately skipped. + +目前 `native-child record` 接受主 Agent 的类型化上报,因此 `observation` 为 +`coordinator_reported`,`host_attested` 始终为 `false`。LoopX 无法拦截任意 +外部宿主的原生工具调用。后续宿主适配器可在自己的边界观察调用并沿用同一 +事件和读模型,但必须明确来源,不能把主 Agent 转述升级为宿主核验。缺少回执 +就是 `unknown`;没有回执或配置上限大于零,都不能证明已启动或主动跳过。 + +## Lifecycle / 生命周期 + +1. The admitted Turn guard must already have a settlement binding. `record` + rejects unregistered coordinators and disabled policy before writing. +2. Use `stage=decision` with a stable `operation-id` for `spawn`, `followup`, + or a bounded `skip` reason. A host capacity rejection maps to the generic + `host_capacity_exhausted` reason. Capacity rejection and typed host failure + stop same-Turn spawn/followup retries while parent work may continue. +3. A started operation may get a typed `result`. Only a completed result may + receive `parent review`; `accepted` requires public-safe evidence and + validation references. Raw prompts, host errors, transcripts, local paths, + and credentials are excluded. +4. Replay with the same identity and payload is idempotent; a conflicting + payload is rejected. `read` and `agent-context --phase after_delegate_result + --turn-instance-id ...` expose the same Turn read model. Goal status (JSON and + Markdown) exposes + the latest reported Turn only when the capability is enabled and a decision + exists. The dashboard uses that status projection, and omits the activity + line for unconfigured or unrelated Goals. + +1. Turn 须先有已提交的结算绑定;未注册主 Agent 或未启用策略不能写入。 +2. 用稳定 `operation-id` 写 `decision`,区分 `spawn`、`followup` 和有界理由的 + `skip`。宿主容量拒绝映射为通用 `host_capacity_exhausted`;容量拒绝和 + 类型化宿主失败都停止同一 Turn 的启动或跟进重试,主 Agent 仍可继续工作。 +3. 已启动操作可记录类型化 `result`;只有完成结果才能进入主 Agent `review`。 + `accepted` 必须有公开安全的证据与验证引用。原始提示、宿主错误、对话、 + 本地路径和凭据不进入回执。 +4. 同一身份和内容重放幂等,内容冲突会被拒绝。`read` 与带 Turn ID 的 + `agent-context` 读取同一模型。Goal 状态的 JSON 与 Markdown 只在能力启用且确有决策时投影 + 最近一轮;仪表板读取该投影,未配置或无关 Goal 不显示活动行。 + +Example / 示例: + +```sh +loopx --format json --registry REGISTRY native-child record \ + --goal-id GOAL --agent-id COORDINATOR --turn-instance-id TURN \ + --operation-id OPERATION --stage decision --operation spawn \ + --outcome started --entrypoint-id HOST_ENTRYPOINT --execute +loopx --format json --registry REGISTRY native-child record \ + --goal-id GOAL --agent-id COORDINATOR --turn-instance-id TURN \ + --operation-id OPERATION --stage result --outcome completed --execute +loopx --format json --registry REGISTRY native-child record \ + --goal-id GOAL --agent-id COORDINATOR --turn-instance-id TURN \ + --operation-id OPERATION --stage review --outcome accepted \ + --evidence-ref EVIDENCE_ID --validation-ref VALIDATION_ID --execute +loopx --format json --registry REGISTRY native-child read \ + --goal-id GOAL --agent-id COORDINATOR --turn-instance-id TURN +``` + +The source of truth for a bound LoopX delegation remains its delegation +operation receipt. A native child report never substitutes for that receipt or +for the parent validation of the underlying work. + +已绑定的 LoopX delegation 仍以自身操作回执为权威。原生子代理上报不能代替 +delegation 回执,也不能代替主 Agent 对工作结果的实际核验。 diff --git a/examples/dashboard-home-browser-smoke.mjs b/examples/dashboard-home-browser-smoke.mjs index 676d1496f0..9dc369d430 100644 --- a/examples/dashboard-home-browser-smoke.mjs +++ b/examples/dashboard-home-browser-smoke.mjs @@ -201,6 +201,18 @@ const goalSpecs = [ max_children: 2, allowed_domains: ["docs", "validation"], }, + nativeChildActivity: { + schema_version: "native_subagent_activity_v0", + turn_instance_id: "fixture-turn-1", + observation: "coordinator_reported", + host_attested: false, + configured_limit: 2, + launched_count: 1, + skipped_count: 0, + capacity_rejected_count: 1, + host_failed_count: 0, + parent_accepted_count: 0, + }, latest: { generated_at: "2026-01-01T00:03:00+00:00", classification: "dashboard_home_chinese_operator_copy_contract", @@ -224,6 +236,7 @@ function projectAssetFor(spec) { quota: spec.quota, control_plane: spec.controlPlane, orchestration: spec.orchestration, + native_child_activity: spec.nativeChildActivity, latest_validation: { generated_at: spec.latest.generated_at, classification: spec.latest.classification, @@ -1293,6 +1306,42 @@ async function main() { throw new Error(`Failed waiting for personal-goal-home: ${error.message}; body=${diagnostic.slice(0, 1000)}; pageErrors=${pageErrors.join(" | ")}`); } + if (process.env.LOOPX_NATIVE_CHILD_SMOKE_ONLY === "1") { + for (const [label, target] of [["desktop", page], + ["mobile", await browser.newPage({ isMobile: true, viewport: { width: 390, height: 900 } })]]) { + try { + await target.goto(`${baseUrl}/?goalId=loopx-meta&statusUrl=/${fixtureName}`, { waitUntil: "networkidle" }); + await target.waitForSelector('[data-testid="personal-goal-home"]', { timeout: 10_000 }); + await target.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: "概览", exact: true }).click(); + await target.locator(".goal-overview-heading button").click(); + const drawerText = await target.locator(".personal-context-drawer").innerText(); + if (!drawerText.includes("主 Agent 回报:启动 1 次、跳过 0 次、容量拒绝 1 次") + || !drawerText.includes("目前没有宿主核验")) { + throw new Error(`${label} native child report lost provenance: ${drawerText}`); + } + await target.screenshot({ + path: resolve(visualOutputDir, `${label}-native-child-activity.png`), + animations: "disabled", + }); + await target.locator(".personal-native-child-activity").screenshot({ + path: resolve(visualOutputDir, `${label}-native-child-card.png`), + animations: "disabled", + }); + } finally { + if (target !== page) await target.close(); + } + } + await page.goto(`${baseUrl}/?goalId=showcase-user-gate-safe-side-path&statusUrl=/${fixtureName}`, { waitUntil: "networkidle" }); + await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: "概览", exact: true }).click(); + await page.locator(".goal-overview-heading button").click(); + const unconfiguredDrawer = await page.locator(".personal-context-drawer").innerText(); + if (unconfiguredDrawer.includes("主 Agent 回报")) { + throw new Error("Unconfigured Goal displayed unrelated native child activity."); + } + console.log("dashboard-native-child-activity-smoke ok"); + return; + } + await page.locator(".personal-composer-tools > summary").click(); const body = await page.locator("body").innerText(); const required = [ @@ -1410,6 +1459,9 @@ async function main() { if (!drawerText.includes("Goal 详情") && !drawerText.includes("Repository")) { throw new Error(`Context drawer did not render expected details: ${drawerText}`); } + if (!drawerText.includes("主 Agent 回报:启动 1 次、跳过 0 次、容量拒绝 1 次")) { + throw new Error(`Native child report was not shown with its provenance: ${drawerText}`); + } } // Missing status URL error state & fallback diff --git a/tests/capabilities/test_native_child_receipts.py b/tests/capabilities/test_native_child_receipts.py new file mode 100644 index 0000000000..e7d52fe2b2 --- /dev/null +++ b/tests/capabilities/test_native_child_receipts.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from loopx.capabilities.multi_subagent.native_child_receipts import ( + latest_native_child_activity, + load_native_child_activity, + record_native_child, +) +from loopx.rollout_event_log import ( + append_rollout_event, + build_rollout_event, + load_rollout_events, + rollout_event_log_path, +) + + +GOAL = "native-child-fixture" +AGENT = "generic-coordinator" +TURN = "turn-native-1" + + +def _admit(runtime_root: Path) -> None: + append_rollout_event( + rollout_event_log_path(runtime_root, GOAL), + build_rollout_event( + goal_id=GOAL, event_kind="quota_should_run", agent_id=AGENT, + run_id=TURN, todo_id="todo-1", status="normal_run", + details={"todo_id": "todo-1", + "settlement_effect_id": f"{GOAL}:{AGENT}:todo-1:{TURN}"}, + ), + ) + + +def _record(runtime_root: Path, operation_id: str, *, stage: str, outcome: str, + **kwargs): + return record_native_child( + runtime_root=runtime_root, goal_id=GOAL, agent_id=AGENT, + turn_instance_id=TURN, operation_id=operation_id, configured_limit=6, + stage=stage, outcome=outcome, execute=True, **kwargs, + ) + + +def test_generic_report_adoption_is_idempotent_and_survives_restart(tmp_path: Path): + _admit(tmp_path) + unknown = load_native_child_activity( + tmp_path, goal_id=GOAL, agent_id=AGENT, turn_instance_id=TURN, + configured_limit=6, + ) + assert unknown["observation"] == "unknown" + assert unknown["configured_limit"] == 6 + assert unknown["launched_count"] == 0 + assert unknown["host_attested"] is False + + first = _record( + tmp_path, "op-1", stage="decision", operation="spawn", + outcome="started", entrypoint_id="generic_host", + ) + replay = _record( + tmp_path, "op-1", stage="decision", operation="spawn", + outcome="started", entrypoint_id="generic_host", + ) + assert first["appended"] is True + assert replay["appended"] is False + assert first["receipt"]["event_id"] == replay["receipt"]["event_id"] + assert first["native_child_activity"]["parent_accepted_count"] == 0 + + _record(tmp_path, "op-1", stage="result", outcome="completed") + reviewed = _record( + tmp_path, "op-1", stage="review", outcome="accepted", + evidence_ref="evidence-1", validation_ref="validation-1", + ) + activity = load_native_child_activity( + tmp_path, goal_id=GOAL, agent_id=AGENT, turn_instance_id=TURN, + configured_limit=6, + ) + assert activity == reviewed["native_child_activity"] + assert activity["observation"] == "coordinator_reported" + assert activity["launched_count"] == 1 + assert activity["parent_accepted_count"] == 1 + assert activity["operations"][0]["entrypoint_id"] == "generic_host" + assert latest_native_child_activity( + load_rollout_events(rollout_event_log_path(tmp_path, GOAL)), + goal_id=GOAL, configured_limit=6, + ) == activity + assert len(load_rollout_events(rollout_event_log_path(tmp_path, GOAL))) == 4 + + +def test_skip_capacity_rejection_and_no_same_turn_retry(tmp_path: Path): + _admit(tmp_path) + skipped = _record( + tmp_path, "skip-1", stage="decision", operation="skip", + outcome="skipped", entrypoint_id="another_host", + reason_code="no_independent_work", + )["native_child_activity"] + assert skipped["skipped_count"] == 1 + assert skipped["observed_capacity"] == "not_observed" + rejected = _record( + tmp_path, "op-1", stage="decision", operation="spawn", + outcome="capacity_rejected", entrypoint_id="another_host", + reason_code="host_capacity_exhausted", + )["native_child_activity"] + assert rejected["capacity_rejected_count"] == 1 + assert rejected["retry_same_turn"] is False + with pytest.raises(ValueError, match="same-Turn"): + _record( + tmp_path, "op-2", stage="decision", operation="followup", + outcome="started", entrypoint_id="another_host", + ) + assert len(load_rollout_events(rollout_event_log_path(tmp_path, GOAL))) == 3 + + +def test_typed_host_failure_stops_same_turn_retry_but_keeps_parent_reporting( + tmp_path: Path, +): + _admit(tmp_path) + failed = _record( + tmp_path, "op-1", stage="decision", operation="spawn", + outcome="host_failed", entrypoint_id="generic_host", + reason_code="host_unavailable", + )["native_child_activity"] + assert failed["host_failed_count"] == 1 + assert failed["retry_same_turn"] is False + with pytest.raises(ValueError, match="same-Turn"): + _record( + tmp_path, "op-2", stage="decision", operation="spawn", + outcome="started", entrypoint_id="generic_host", + ) + skipped = _record( + tmp_path, "skip-1", stage="decision", operation="skip", + outcome="skipped", entrypoint_id="generic_host", + reason_code="parent_work_priority", + )["native_child_activity"] + assert skipped["skipped_count"] == 1 + + +def test_no_unadmitted_or_conflicting_receipts(tmp_path: Path): + with pytest.raises(ValueError, match="admitted"): + _record( + tmp_path, "op-1", stage="decision", operation="spawn", + outcome="started", entrypoint_id="generic_host", + ) + _admit(tmp_path) + with pytest.raises(ValueError, match="started"): + _record(tmp_path, "op-1", stage="review", outcome="accepted", + evidence_ref="evidence-1", validation_ref="validation-1") + _record( + tmp_path, "op-1", stage="decision", operation="spawn", + outcome="started", entrypoint_id="generic_host", + ) + with pytest.raises(ValueError, match="conflicting"): + _record( + tmp_path, "op-1", stage="decision", operation="spawn", + outcome="started", entrypoint_id="other_host", + ) + with pytest.raises(ValueError, match="completed"): + _record(tmp_path, "op-1", stage="review", outcome="accepted", + evidence_ref="evidence-1", validation_ref="validation-1") + with pytest.raises(ValueError, match="compact opaque id"): + _record( + tmp_path, "op-2", stage="decision", operation="spawn", + outcome="started", entrypoint_id="/private/source", + ) + + +def test_cli_readback_and_disabled_policy_do_not_mutate(tmp_path: Path): + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"schema_version": 1, "goals": [{ + "id": GOAL, "repo": str(tmp_path), "status": "active", + "registered_agents": [AGENT], + "spawn_policy": {"mode": "multi_subagent", "allowed": True, "max_children": 6}, + }]})) + runtime_root = tmp_path / "runtime" + _admit(runtime_root) + base = [sys.executable, "-m", "loopx.cli", "--registry", str(registry), + "--runtime-root", str(runtime_root), "--format", "json", + "native-child", "--goal-id", GOAL, "--agent-id", AGENT, + "--turn-instance-id", TURN] + command = [*base, "record", "--operation-id", "op-1", "--stage", "decision", + "--operation", "spawn", "--outcome", "started", + "--entrypoint-id", "generic_host"] + preview = subprocess.run(command, text=True, capture_output=True) + assert preview.returncode == 0, preview.stderr + assert json.loads(preview.stdout)["dry_run"] is True + assert len(load_rollout_events(rollout_event_log_path(runtime_root, GOAL))) == 1 + recorded = subprocess.run([*command, "--execute"], text=True, capture_output=True) + assert recorded.returncode == 0, recorded.stderr + read = subprocess.run([*base, "read"], text=True, capture_output=True) + assert read.returncode == 0, read.stderr + assert json.loads(read.stdout)["native_child_activity"]["launched_count"] == 1 + context = subprocess.run( + [sys.executable, "-m", "loopx.cli", "--registry", str(registry), + "--runtime-root", str(runtime_root), "agent-context", "--format", "json", + "--goal-id", GOAL, "--agent-id", AGENT, "--phase", "after_delegate_result", + "--turn-instance-id", TURN], + text=True, capture_output=True, + ) + assert context.returncode == 0, context.stderr + context_payload = json.loads(context.stdout) + assert context_payload["host_receipts_observed"] is False + assert context_payload["native_child_activity"]["launched_count"] == 1 + [contribution] = context_payload["agent_context"]["contributions"] + assert contribution["facts"]["native_child_activity"]["launched_count"] == 1 + + disabled = json.loads(registry.read_text()) + disabled["goals"][0]["spawn_policy"]["allowed"] = False + registry.write_text(json.dumps(disabled)) + rejected = subprocess.run([*base, "read"], text=True, capture_output=True) + assert rejected.returncode == 1 + assert "enabled multi_subagent" in json.loads(rejected.stdout)["error"] + + +def test_goal_status_only_attaches_reported_activity_to_enabled_goal( + tmp_path: Path, monkeypatch, +): + import loopx.status as status + + _admit(tmp_path) + _record( + tmp_path, "op-1", stage="decision", operation="skip", + outcome="skipped", entrypoint_id="generic_host", + reason_code="no_independent_work", + ) + policy = {"mode": "multi_subagent", "spawn_allowed": True, "max_children": 6} + queue = {"items": [{"goal_id": GOAL, "project_asset": {"orchestration": policy}}]} + monkeypatch.setattr(status, "_build_attention_queue_read_model", lambda **_kwargs: queue) + projected = status.build_attention_queue( + contract={}, history={}, global_registry={}, runtime_root=tmp_path, + ) + activity = projected["items"][0]["project_asset"]["native_child_activity"] + assert activity["skipped_count"] == 1 + assert activity["observation"] == "coordinator_reported" + + queue["items"][0]["project_asset"] = { + "orchestration": {**policy, "spawn_allowed": False}, + } + disabled = status.build_attention_queue( + contract={}, history={}, global_registry={}, runtime_root=tmp_path, + ) + assert "native_child_activity" not in disabled["items"][0]["project_asset"] diff --git a/tests/control_plane_ts/agent_context.test.ts b/tests/control_plane_ts/agent_context.test.ts index cf7147244d..f9bbadf339 100644 --- a/tests/control_plane_ts/agent_context.test.ts +++ b/tests/control_plane_ts/agent_context.test.ts @@ -192,7 +192,7 @@ test("coordinator participation guidance survives all bounded lifecycle projecti } })!; assert.deepEqual(packet.failures, []); const [contribution] = packet.contributions as Record[]; - assert.equal(contribution.revision, "v5"); + assert.equal(contribution.revision, "v6"); assert.equal(packet.authority, "guidance_only"); assert.ok(Buffer.byteLength(JSON.stringify(contribution)) <= 2048); assert.ok(Buffer.byteLength(JSON.stringify(packet)) <= 3072); @@ -249,3 +249,30 @@ test("configured child limit stays distinct from typed native host capacity", () assert.equal(succeededFacts.capacity_contract.live_availability, "attempt_observed"); assert.equal(succeededFacts.native_host_capacity.retry_same_turn, undefined); }); + +test("durable native child report is bounded and does not claim host attestation", () => { + const packet = evaluateSubagentContext({ phase: "after_delegate_result", scope, + orchestration: { ...policy, max_children: 6 }, observations: { + native_child_activity: { + schema_version: "native_subagent_activity_v0", + entrypoint_scope: "host_native_child_tools", + observation: "coordinator_reported", + host_attested: true, + configured_limit: 6, + attempted_count: 2, + launched_count: 1, + skipped_count: 0, + capacity_rejected_count: 1, + host_failed_count: 0, + parent_accepted_count: 0, + retry_same_turn: false, + raw_host_result: "private result", + }, + } })!; + const facts = (packet.contributions as Record[])[0].facts; + assert.equal(facts.native_child_activity.host_attested, false); + assert.equal(facts.native_child_activity.launched_count, 1); + assert.equal(facts.native_child_activity.retry_same_turn, false); + assert.equal(facts.native_receipt_observation, "coordinator_reported"); + assert.ok(!JSON.stringify(packet).includes("private result")); +}); From e2a757133679a15b10c5b86110dabe291841a602 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:14:23 +0800 Subject: [PATCH 4/5] fix(multi-subagent): stream exact Turn activity in status Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../multi_subagent/native_child_receipts.py | 21 ++++++++++++------- loopx/status.py | 2 +- .../test_native_child_receipts.py | 15 +++++++++++++ 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/loopx/capabilities/multi_subagent/native_child_receipts.py b/loopx/capabilities/multi_subagent/native_child_receipts.py index ad9d3e57c5..e6b23fcdc4 100644 --- a/loopx/capabilities/multi_subagent/native_child_receipts.py +++ b/loopx/capabilities/multi_subagent/native_child_receipts.py @@ -18,6 +18,7 @@ from ...rollout_event_log import ( append_rollout_event_once, build_rollout_event, + iter_rollout_events, load_rollout_events, rollout_event_log_path, ) @@ -131,9 +132,13 @@ def native_child_activity( def load_native_child_activity( runtime_root: Path, *, goal_id: str, agent_id: str, - turn_instance_id: str, configured_limit: int, limit: int | None = None, + turn_instance_id: str, configured_limit: int, ) -> dict[str, Any]: - events = load_rollout_events(rollout_event_log_path(runtime_root, goal_id), limit=limit) + source = iter_rollout_events(rollout_event_log_path(runtime_root, goal_id)) + events = [event for event in source + if event.get("event_kind") in EVENT_KINDS.values() + and event.get("agent_id") == agent_id + and event.get("run_id") == turn_instance_id] return native_child_activity( events, goal_id=goal_id, agent_id=agent_id, turn_instance_id=turn_instance_id, configured_limit=configured_limit, @@ -144,13 +149,13 @@ def latest_native_child_activity( events: Sequence[Mapping[str, Any]], *, goal_id: str, configured_limit: int, ) -> dict[str, Any] | None: """Expose only the latest reported Turn in existing Goal status surfaces.""" - decisions = [event for event in events - if event.get("goal_id") == goal_id - and event.get("event_kind") == EVENT_KINDS["decision"] - and event.get("agent_id") and event.get("run_id")] - if not decisions: + observations = [event for event in events + if event.get("goal_id") == goal_id + and event.get("event_kind") in EVENT_KINDS.values() + and event.get("agent_id") and event.get("run_id")] + if not observations: return None - latest = max(decisions, key=lambda event: str(event.get("recorded_at") or "")) + latest = max(observations, key=lambda event: str(event.get("recorded_at") or "")) return native_child_activity( events, goal_id=goal_id, agent_id=str(latest["agent_id"]), turn_instance_id=str(latest["run_id"]), configured_limit=configured_limit, diff --git a/loopx/status.py b/loopx/status.py index ffe3bb60c4..ca15548960 100644 --- a/loopx/status.py +++ b/loopx/status.py @@ -1196,7 +1196,7 @@ def request_active_state_todo_fields( ) if native_activity: # The shared status snapshot is bounded for Todo work. Once it - # reveals a native decision, read its exact Turn before showing + # reveals a native event, read its exact Turn before showing # counts, so older stages falling outside that window cannot # silently undercount the activity. project_asset["native_child_activity"] = load_native_child_activity( diff --git a/tests/capabilities/test_native_child_receipts.py b/tests/capabilities/test_native_child_receipts.py index e7d52fe2b2..59ce71799b 100644 --- a/tests/capabilities/test_native_child_receipts.py +++ b/tests/capabilities/test_native_child_receipts.py @@ -236,6 +236,21 @@ def test_goal_status_only_attaches_reported_activity_to_enabled_goal( assert activity["skipped_count"] == 1 assert activity["observation"] == "coordinator_reported" + # A bounded status snapshot may retain a later result but not its decision. + # The status projection must recover the complete Turn from the event log. + _record(tmp_path, "op-2", stage="decision", operation="spawn", + outcome="started", entrypoint_id="generic_host") + _record(tmp_path, "op-2", stage="result", outcome="completed") + projected = status.build_attention_queue( + contract={}, history={}, global_registry={}, runtime_root=tmp_path, + events_for_goal=lambda _goal_id, *, limit: load_rollout_events( + rollout_event_log_path(tmp_path, GOAL), limit=limit, + )[-1:], + ) + activity = projected["items"][0]["project_asset"]["native_child_activity"] + assert activity["launched_count"] == 1 + assert activity["skipped_count"] == 1 + queue["items"][0]["project_asset"] = { "orchestration": {**policy, "spawn_allowed": False}, } From be333f929c47f3c44f2ddada869099f5da968750 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:16:45 +0800 Subject: [PATCH 5/5] docs(multi-subagent): distinguish future host attestation Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- docs/integrations/host-native-child-receipts.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/integrations/host-native-child-receipts.md b/docs/integrations/host-native-child-receipts.md index dcf0b5969b..7ccc04b921 100644 --- a/docs/integrations/host-native-child-receipts.md +++ b/docs/integrations/host-native-child-receipts.md @@ -19,16 +19,16 @@ Goal 事件流,以 Turn ID、稳定操作 ID 和不限定宿主的 `entrypoint `native-child record` currently accepts the coordinator's typed report. Its `observation` is `coordinator_reported` and `host_attested` is always `false`. LoopX cannot intercept an arbitrary external host's native tool call. A future -host adapter can observe that call at its own boundary and use the same event -and read-model contract, but must declare its provenance instead of silently -upgrading a coordinator report into host attestation. Missing records remain +host adapter must observe that call at its own boundary and extend this event +and read-model contract with a separately verified provenance variant; this +v0 recorder cannot claim host attestation. Missing records remain `unknown`; neither a missing record nor `max_children > 0` proves that a child was created or deliberately skipped. 目前 `native-child record` 接受主 Agent 的类型化上报,因此 `observation` 为 `coordinator_reported`,`host_attested` 始终为 `false`。LoopX 无法拦截任意 -外部宿主的原生工具调用。后续宿主适配器可在自己的边界观察调用并沿用同一 -事件和读模型,但必须明确来源,不能把主 Agent 转述升级为宿主核验。缺少回执 +外部宿主的原生工具调用。后续宿主适配器须在自己的边界观察调用,给事件与 +读模型扩展单独核验的来源类型;当前 v0 上报器不能声称宿主核验。缺少回执 就是 `unknown`;没有回执或配置上限大于零,都不能证明已启动或主动跳过。 ## Lifecycle / 生命周期