diff --git a/docs/integrations/codex-subagent-orchestration.md b/docs/integrations/codex-subagent-orchestration.md index f7ab71dd8a..ef5eb3c5cc 100644 --- a/docs/integrations/codex-subagent-orchestration.md +++ b/docs/integrations/codex-subagent-orchestration.md @@ -479,7 +479,9 @@ for the current requester and projects as many as fit the existing context byte budget; `authorized_count` and `routes_truncated` make omissions explicit. Each route carries only binding/Agent/Todo/runtime identity, separate `runtime_readiness` and `readiness` observations, an optional public-safe execution profile and -one stable `loopx delegation` entrypoint. A separate, explicit +the existing host owner's `probe_scope` for a supplied non-null probe, and one stable +`loopx delegation` entrypoint. Full probe/remediation facts are disclosed by +inspecting that binding, not inlined into the planning budget. A separate, explicit `agent-context --phase after_delegate_result` read may include bounded operation-status and recovery-required counts. Automatic planning and managed return paths do not enumerate the operation journal. These reads do not launch, @@ -508,6 +510,10 @@ surfaces consume this same capability context, not another route configuration. `readiness=unknown`、`preflight=required`,通过现有 `loopx delegation inspect` 核验权威状态、任务验证与 Turn 准入。未知不等于禁用,ready 运行库也不等于可执行。 用户的异构偏好影响批次选择,不要求每次心跳重启所有路线,不绕过任一真实门禁。 +投影保留 host owner 非空 probe 的 `probe_scope`,完整 probe 与修复代码通过原 +绑定预检渐进式披露,不额外发起探测或决定准入。`probing_interpreter` 的模块缺失只针对本次检查的解释器,不能 +推断整机没有运行库;未探测的通用适配器仍为 `null/unknown`。修复原绑定环境后必须 +重新核验,不通过创建替代 worker、静默换模型或复用旧任务成功来伪造恢复。 中文:可在现有 `multi_subagent` 能力中配置 `.loopx/config/delegations.json` 指针,让当前请求 Agent 在规划前看到自己已获授权的 diff --git a/docs/reference/local-delegation.md b/docs/reference/local-delegation.md index 89bfcba06d..f1863b87be 100644 --- a/docs/reference/local-delegation.md +++ b/docs/reference/local-delegation.md @@ -261,6 +261,9 @@ filter used by `delegate list` considers at most six public-safe planning routes and byte-bounds the projected subset; `authorized_count` and `routes_truncated` make omissions explicit. Managed-host availability comes from the existing Turn host/profile owner; unprobed generic adapters are `unknown`, not optimistically ready. +Routes disclose that owner's `probe_scope` for a supplied non-null probe. Read full +`runtime_probe` and `unavailable_remediation` observations through +`delegation inspect` on the same binding; planning keeps its existing byte budget. This planning projection is read-only. It does not start, resume, accept, enumerate operations or periodically poll work. A ready observation is not an @@ -286,6 +289,9 @@ no legacy Session setting is copied back into the registry automatically. 启动、恢复、验收或周期轮询工作;需要时可显式读取 `after_delegate_result` 阶段的有界 operation 状态摘要。ready 和状态计数都不是执行或父级验收回执。清除指针不会撤销 授权;真正撤销仍须修改 operator binding 文件。 +路由对非空 probe 渐进式披露同一 host owner 的 `probe_scope`。按原绑定读取下述 +`delegation inspect`,可获得完整 `runtime_probe` 和 `unavailable_remediation`; +规划保持原有字节预算,不内联全部诊断。 ### Recover work without remembered operation ids @@ -354,6 +360,28 @@ Normal start still reads current admission and independently validates output. If the existing Turn rejects preflight, inspection reports that error rather than manufacturing a launchable result; no request is created. +`executor.runtime_probe` preserves the host's bounded probe scope: a DSH +`probing_interpreter` result concerns module availability in the interpreter +running this inspection, not every installation or remote host. A +`configured_runner` result concerns the explicit runner. A `null` probe means +this executor was not probed; older previews may omit the optional field. +`executor.unavailable_remediation` contains bounded operator-action codes from +the same host owner, not commands or permission to switch providers. For +`configure_dsh_runtime`, check the original launcher's interpreter and its +`deepseek-harness` optional dependency or original runner configuration before +requalification. A successful module probe alone does not prove credentials, +profile, task acceptance or remote capacity. Inspection exposes no interpreter +paths, credential/endpoint configuration, or provider payloads. + +中文:`executor.runtime_probe` 保留 host 的有界探测范围。DSH 的 +`probing_interpreter` 只说明执行本次检查的解释器是否能找到模块,不代表整机或远端 +所有安装;`configured_runner` 针对已显式配置的 runner。`null` 表示未探测,旧预览 +可以缺省此兼容字段。`executor.unavailable_remediation` 是同一 host owner 提供的 +有界操作代码,不是命令,也不授予切换 provider 的权限。遇到 +`configure_dsh_runtime`,先核对原启动器绑定的解释器及其 `deepseek-harness` 可选依赖 +或原 runner 配置,再重新核验。模块可用不证明凭据、profile、任务验收或远端容量; +此检查不暴露解释器路径、凭据/endpoint 配置或 provider 原始数据。 + Enabled MCP exposes `inspect_execution_binding`; newly enrolled Goal Chat tools accept `action=inspect` with `binding_id`. Existing native thread schemas remain unchanged. Owners can use **Team execution** directly below the Goal conversation diff --git a/loopx/control_plane/collaboration/delegation.ts b/loopx/control_plane/collaboration/delegation.ts index d0f8a326ee..f38451962e 100644 --- a/loopx/control_plane/collaboration/delegation.ts +++ b/loopx/control_plane/collaboration/delegation.ts @@ -60,6 +60,34 @@ export function delegationTurnPlanDecision(params: JsonObject): JsonObject { }; } +/** Preserve the host owner's public diagnosis, not its private configuration. + * Optional fields keep older host previews compatible; null means unprobed. */ +export function delegationRuntimeFacts(executor: JsonObject): JsonObject { + const facts: JsonObject = {}; + if (Object.hasOwn(executor, "runtime_probe")) { + if (executor.runtime_probe === null) facts.runtime_probe = null; + else { + const probe = requireJsonObject(executor.runtime_probe, "runtime probe"); + requireThat(probe.schema_version === "managed_runtime_probe_v0" + && typeof probe.scope === "string" + && ["probing_interpreter", "configured_runner"].includes(probe.scope) + && (probe.module === null || (typeof probe.module === "string" + && probe.module.length <= 128 && /^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*$/.test(probe.module))) + && typeof probe.available === "boolean", "invalid runtime probe observation"); + facts.runtime_probe = {schema_version: probe.schema_version, scope: probe.scope, + module: probe.module, available: probe.available}; + } + } + if (Object.hasOwn(executor, "unavailable_remediation")) { + const remedies = executor.unavailable_remediation; + requireThat(Array.isArray(remedies) && remedies.length <= 8 + && remedies.every(code => typeof code === "string" && /^[a-z][a-z0-9_]{0,79}$/.test(code)), + "invalid runtime remediation codes"); + facts.unavailable_remediation = [...remedies]; + } + return facts; +} + /** Read the actual dry-run route/profile, never infer readiness from assignment. */ export function delegationPreflight(params: JsonObject): JsonObject { const binding = requireJsonObject(params.binding, "binding identity"); @@ -115,7 +143,8 @@ export function delegationPreflight(params: JsonObject): JsonObject { authority_state: "promoted", authority_next_action: "none", promotion_from_surface_allowed: false, executor: {host: executor.executor, available: executor.available, - reason: executor.unavailable_reason, profile: executor.execution_profile}, + reason: executor.unavailable_reason, profile: executor.execution_profile, + ...delegationRuntimeFacts(executor)}, effects, note: "Point-in-time preflight, not an execution permit or evidence of running work. " + "Start rechecks admission; inspect original operations before dispatching replacements. " diff --git a/loopx/control_plane/collaboration/delegation_context.py b/loopx/control_plane/collaboration/delegation_context.py index 4d8231ad9e..1886379374 100644 --- a/loopx/control_plane/collaboration/delegation_context.py +++ b/loopx/control_plane/collaboration/delegation_context.py @@ -65,6 +65,11 @@ def _route(binding: dict[str, Any]) -> dict[str, Any]: reason = str(executor.get("unavailable_reason") or "").strip() if reason: row["reason_code"] = reason + # Transport the existing host owner's public observations unchanged. The + # Python adapter neither reprobes nor derives another admission decision. + for key in ("runtime_probe", "unavailable_remediation"): + if key in executor: + row[key] = executor[key] return row diff --git a/loopx/control_plane/subagent_context.ts b/loopx/control_plane/subagent_context.ts index 31cc76b35e..4a25608ee1 100644 --- a/loopx/control_plane/subagent_context.ts +++ b/loopx/control_plane/subagent_context.ts @@ -2,6 +2,7 @@ import { AGENT_CONTEXT_PHASES, projectAgentContext, type AgentContextProvider } from "./agent_context.ts"; import type { JsonObject } from "./effect_program.ts"; import { jsonObject, requireJsonObject } from "./runtime_decode.ts"; +import { delegationRuntimeFacts } from "./collaboration/delegation.ts"; export const subagentContextProvider: AgentContextProvider = { hookId: "multi_subagent.coordinator", capabilityId: "multi_subagent", revision: "v5", @@ -131,6 +132,11 @@ function boundedDelegationContext(value: unknown): JsonObject | null { if (executorKind) compact.executor_kind = executorKind; if (profile) compact.execution_profile = profile; if (reason) compact.reason_code = reason; + // Planning discloses scope only; inspect the same binding for full probe + // and remedies. Keep the existing route/contribution budgets unchanged. + const runtimeFacts = delegationRuntimeFacts(route); + const probe = jsonObject(runtimeFacts.runtime_probe); + if (probe) compact.probe_scope = probe.scope; return [compact]; }) : []; const rawReceipts = jsonObject(source.operation_receipts); diff --git a/tests/control_plane/test_delegation_context.py b/tests/control_plane/test_delegation_context.py index f91dbc7fc5..f78012f2e3 100644 --- a/tests/control_plane/test_delegation_context.py +++ b/tests/control_plane/test_delegation_context.py @@ -3,6 +3,8 @@ import json from pathlib import Path +import pytest + from loopx.control_plane.agent_context import project_goal_agent_context from loopx.control_plane.collaboration import delegation_context from loopx.control_plane.quota.live_decision import build_live_quota_should_run_decision @@ -148,6 +150,52 @@ def operations(_self, *, limit, cursor=None): } +@pytest.mark.parametrize("runner_configured", [False, True]) +def test_planning_retains_original_probe_scope_and_remediation_without_admission( + tmp_path: Path, monkeypatch, runner_configured: bool +) -> None: + from loopx.control_plane.turn_driver.host_binding import managed_executor_binding + + project, registry, runtime = _fixture(tmp_path) + calls = [] + executor = managed_executor_binding( + "dsh", environ={}, module_probe=lambda _: False, + dsh_runner_configured=runner_configured, + ) + + def binding(*args, **_kwargs): + calls.append(args) + return {**executor, "credential_env": "PRIVATE_CREDENTIAL", + "endpoint_env": "PRIVATE_ENDPOINT"} + + monkeypatch.setattr(delegation_context, "managed_executor_binding_from_host_args", binding) + packet = delegation_context.project_delegation_context( + runtime_root=runtime, registry_path=registry, goal_id="goal-a", + agent_id="coordinator", project=project, execution_config=".loopx/config/delegations.json", + ) + route = packet["routes"][0] + assert len(calls) == 1 + assert route["runtime_probe"] == executor["runtime_probe"] + assert route["unavailable_remediation"] == executor["unavailable_remediation"] + assert route["runtime_readiness"] == ("ready" if runner_configured else "blocked") + assert route["readiness"] == ("unknown" if runner_configured else "blocked") + assert packet["preflight"] == "required" + assert "operation_receipts" not in packet + assert "PRIVATE_" not in json.dumps(packet) + + +def test_unprobed_generic_route_keeps_explicit_null_probe(tmp_path: Path) -> None: + project, registry, runtime = _fixture(tmp_path) + packet = delegation_context.project_delegation_context( + runtime_root=runtime, registry_path=registry, goal_id="goal-a", + agent_id="coordinator", project=project, execution_config=".loopx/config/delegations.json", + ) + route = packet["routes"][0] + assert route["runtime_probe"] is None + assert route["unavailable_remediation"] == [] + assert route["runtime_readiness"] == route["readiness"] == "unknown" + + def test_repeated_live_quota_planning_never_reads_operation_inventory( tmp_path: Path, monkeypatch ) -> None: diff --git a/tests/control_plane_ts/agent_context.test.ts b/tests/control_plane_ts/agent_context.test.ts index cf7147244d..2160f80acb 100644 --- a/tests/control_plane_ts/agent_context.test.ts +++ b/tests/control_plane_ts/agent_context.test.ts @@ -182,6 +182,40 @@ test("maximum delegation directory stays within provider budget", () => { assert.ok(Buffer.byteLength(JSON.stringify(packet.contributions[0])) <= 2048); }); +test("planning progressively discloses probe scope without crowding out a second route", () => { + const probe = {schema_version: "managed_runtime_probe_v0", scope: "probing_interpreter", + module: "deepseek_harness", available: false}; + const routes = [ + {binding_id: "managed-evidence/" + "x".repeat(34), + agent_id: "managed-worker-" + "x".repeat(12), todo_id: "todo_" + "a".repeat(12), runtime_id: "dsh", + readiness: "blocked", runtime_readiness: "blocked", executor_kind: "managed", + execution_profile: "deepseek-v4-flash@high", reason_code: "dsh_runtime_unavailable", + runtime_probe: {...probe, private_path: "/private/interpreter"}, + unavailable_remediation: ["configure_dsh_runtime", "select_individual_host"]}, + {binding_id: "generic-verifier/" + "x".repeat(34), + agent_id: "generic-worker-" + "x".repeat(12), todo_id: "todo_" + "b".repeat(12), runtime_id: "generic-cli", + readiness: "unknown", runtime_readiness: "unknown", executor_kind: "generic", + runtime_probe: null, unavailable_remediation: []}, + ]; + for (const phase of ["before_plan", "before_delegate"]) { + const packet = evaluateSubagentContext({phase, scope, orchestration: policy, observations: { + delegation_context: {schema_version: "loopx_delegation_context_v0", configuration_state: "ready", + observed_at: "2026-09-19T04:20:00+00:00", authorized_count: 2, projected_count: 2, routes}, + }})!; + assert.deepEqual(packet.failures, []); + const [contribution] = packet.contributions as Record[]; + const context = contribution.facts.delegation_context; + assert.equal(context.projected_count, 2); + assert.equal(context.preflight, "required"); + assert.deepEqual(context.routes.map((route: any) => route.probe_scope), ["probing_interpreter", undefined]); + assert.deepEqual(context.routes.map((route: any) => route.readiness), ["blocked", "unknown"]); + assert.ok(Buffer.byteLength(JSON.stringify(context)) <= 900); + assert.ok(Buffer.byteLength(JSON.stringify(contribution)) <= 2048); + assert.ok(!JSON.stringify(packet).includes("/private/interpreter")); + assert.ok(!JSON.stringify(packet).includes("configure_dsh_runtime")); + } +}); + // Rich model identifiers and the complete participation guidance must survive // the actual provider budget, not disappear as an isolated provider failure. test("coordinator participation guidance survives all bounded lifecycle projections", () => { diff --git a/tests/control_plane_ts/delegation.test.ts b/tests/control_plane_ts/delegation.test.ts index 866689a5b6..c95006ea4d 100644 --- a/tests/control_plane_ts/delegation.test.ts +++ b/tests/control_plane_ts/delegation.test.ts @@ -96,6 +96,70 @@ test("preflight separates task admission, acceptance binding and runtime availab assert.throws(() => delegationPreflight({...params, preview: {...preview, effects: {...effects, host_invoked: true}}})); }); +const runtimePreflight = (executor: Record) => delegationPreflight({ + binding, validation_files_current: true, acceptance: {todo_id: binding.todo_id, state: "ready"}, + preview: {dry_run: true, status: "preview", + effects: {host_invoked: false, state_written: false, quota_spent: false, scheduler_acknowledged: false}, + route: {kind: "ready_for_host", would_invoke_host: true, selected_todo_id: binding.todo_id}, + managed_executor: executor}, +}); + +test("preflight retains scoped runtime facts without leaking host configuration", () => { + const probe = {schema_version: "managed_runtime_probe_v0", scope: "probing_interpreter", + module: "deepseek_harness", available: false}; + const remedies = ["configure_dsh_runtime", "select_individual_host"]; + const result = runtimePreflight({executor: "dsh", available: false, + unavailable_reason: "dsh_runtime_unavailable", execution_profile: "explicit-profile", + runtime_probe: {...probe, private_path: "/private/interpreter"}, unavailable_remediation: remedies, + credential_env: "PRIVATE_CREDENTIAL", endpoint_env: "PRIVATE_ENDPOINT"}); + assert.equal(result.state, "runtime_unavailable"); + assert.deepEqual(result.executor, {host: "dsh", available: false, + reason: "dsh_runtime_unavailable", profile: "explicit-profile", + runtime_probe: probe, unavailable_remediation: remedies}); + assert.equal(JSON.stringify(result).includes("PRIVATE_"), false); + assert.equal(JSON.stringify(result).includes("/private/interpreter"), false); + assert.equal(Object.values(result.effects as Record).some(Boolean), false); +}); + +test("runtime probes cannot override credential failure or unprobed generic readiness", () => { + const result = runtimePreflight({executor: "dsh", available: false, + unavailable_reason: "operator_credential_unconfigured", execution_profile: "explicit-profile", + runtime_probe: {schema_version: "managed_runtime_probe_v0", scope: "configured_runner", + module: null, available: true}, + unavailable_remediation: ["configure_operator_credential", "select_individual_host"]}); + assert.equal(result.state, "runtime_unavailable"); + assert.equal((result.executor as Record).available, false); + assert.deepEqual((result.executor as Record).runtime_probe, + {schema_version: "managed_runtime_probe_v0", scope: "configured_runner", module: null, available: true}); + const generic = runtimePreflight({executor: "generic-cli", available: null, + unavailable_reason: null, execution_profile: null, runtime_probe: null, unavailable_remediation: []}); + assert.equal(generic.state, "runtime_unverified"); + assert.deepEqual(generic.executor, {host: "generic-cli", available: null, reason: null, + profile: null, runtime_probe: null, unavailable_remediation: []}); +}); + +test("malformed scoped runtime facts fail closed; legacy omissions remain compatible", () => { + const probe = {schema_version: "managed_runtime_probe_v0", scope: "probing_interpreter", + module: "deepseek_harness", available: false}; + const executor = {executor: "dsh", available: false, unavailable_reason: "dsh_runtime_unavailable", + execution_profile: "explicit-profile", runtime_probe: probe, unavailable_remediation: ["configure_dsh_runtime"]}; + for (const runtime_probe of [false, {}, {...probe, schema_version: "other"}, + {...probe, scope: "whole_machine"}, {...probe, scope: ["probing_interpreter"]}, + {...probe, module: "/private/path"}, + {...probe, available: "false"}]) { + assert.throws(() => runtimePreflight({...executor, runtime_probe}), /runtime probe/); + } + for (const unavailable_remediation of [null, "configure_dsh_runtime", ["/private/path"], + ["x".repeat(81)], Array(9).fill("configure_dsh_runtime")]) { + assert.throws(() => runtimePreflight({...executor, unavailable_remediation}), /runtime remediation/); + } + const legacy = runtimePreflight({executor: "dsh", available: true, unavailable_reason: null, + execution_profile: "explicit-profile"}); + assert.equal(legacy.state, "launchable"); + assert.deepEqual(legacy.executor, + {host: "dsh", available: true, reason: null, profile: "explicit-profile"}); +}); + test("requester adoption needs accepted downstream use, not reading, revision or prose", () => { const artifact = {ref: "result.json", sha256: "a".repeat(64)}; const source = {operation_id: "source", status: "accepted", artifacts: [artifact]}; diff --git a/tests/test_delegation_preflight.py b/tests/test_delegation_preflight.py index ac5c09e2fa..f99b8f3c47 100644 --- a/tests/test_delegation_preflight.py +++ b/tests/test_delegation_preflight.py @@ -28,6 +28,34 @@ def test_real_cli_preflight_preserves_unknown_runtime_and_state(service): assert result["turn_eligible"] and result["acceptance_ready"] assert result["executor"]["host"] == "generic-cli" assert result["executor"]["available"] is None + assert result["executor"]["runtime_probe"] is None + assert result["executor"]["unavailable_remediation"] == [] + assert not any(result["effects"].values()) + assert runner.registry.read_bytes() == before + assert not (root / "host-started").exists() + assert not list(runner.path("inventory").parent.glob("*.json")) + assert not list((root / "runtime" / "goals").glob("*/turns/*.json")) + + +def test_real_cli_dsh_preflight_preserves_interpreter_probe_without_launch(service): + root, runner = service + config = json.loads(runner.config.read_text()) + config["bindings"][0]["host_args"] = ["--host", "dsh"] + runner.config.write_text(json.dumps(config)) + before = runner.registry.read_bytes() + status, result = cli(runner, "inspect", "--binding-id", "analysis") + assert status == 0, result + executor = result["executor"] + probe = executor["runtime_probe"] + assert probe["schema_version"] == "managed_runtime_probe_v0" + assert probe["scope"] == "probing_interpreter" + assert probe["module"] == "deepseek_harness" + assert isinstance(probe["available"], bool) + if not probe["available"]: + assert executor["available"] is False + assert executor["reason"] == "dsh_runtime_unavailable" + assert executor["unavailable_remediation"] == ["configure_dsh_runtime", "select_individual_host"] + assert "credential_env" not in executor and "endpoint_env" not in executor assert not any(result["effects"].values()) assert runner.registry.read_bytes() == before assert not (root / "host-started").exists() @@ -348,6 +376,8 @@ def test_selected_codex_managed_agent_profile_is_projected_exactly(service): "available": None, "reason": None, "profile": "gpt-5.6-sol@xhigh", + "runtime_probe": None, + "unavailable_remediation": [], } assert result["state"] == "runtime_unverified" assert not any(result["effects"].values())