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 / 生命周期