From c0355894b4ecdf2eec63c00f02925542a3824b86 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:37:42 +0800 Subject: [PATCH 1/2] fix(agents): distinguish native child tools from bound delegation Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/control_plane/subagent_context.ts | 12 +++---- tests/control_plane_ts/agent_context.test.ts | 36 ++++++++++++++++++-- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/loopx/control_plane/subagent_context.ts b/loopx/control_plane/subagent_context.ts index 324aa2a678..6f9b190b16 100644 --- a/loopx/control_plane/subagent_context.ts +++ b/loopx/control_plane/subagent_context.ts @@ -6,21 +6,21 @@ import { isAbsolute } from "node:path"; import { delegationRuntimeFacts, selectDelegationBinding } from "./collaboration/delegation.ts"; export const subagentContextProvider: AgentContextProvider = { - hookId: "multi_subagent.coordinator", capabilityId: "multi_subagent", revision: "v6", + hookId: "multi_subagent.coordinator", capabilityId: "multi_subagent", revision: "v7", 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.", + "Delegate coherent independent outcomes, not isolated calls; leave method choices to the child. max_children is a ceiling, not a target or live availability. Admit incrementally, avoid duplicate reads, and keep useful parent work.", "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.", + "Choose authorized routes under user preferences. Host-native tools need no delegation binding. For bound delegation, inspect the selected binding; runtime availability is not admission. Peer-activation blocks assess neither route. Do not relaunch all routes each 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. Report each native decision or bounded skip; missing reports stay unknown.", + "Give each child a coherent question, acceptance, sources, read/write limits and stopping condition; leave room to investigate and challenge the hypothesis. Identify dependencies and concurrent parent work.", + "Use authorized host-native tools directly; only bound delegation needs its binding entrypoint and runtime/profile/budget preflight. Preserve user model preferences; never silently substitute runtime/model. Record selection reason and stable operation id; report native decisions or skips, leaving missing reports unknown.", ], after_delegate_result: [ - "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.", + "Check returned sources, omissions and contradictions against acceptance. Record native result and parent review separately; reconcile fresh receipts for the route actually used. 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.", ], diff --git a/tests/control_plane_ts/agent_context.test.ts b/tests/control_plane_ts/agent_context.test.ts index fa9d25b7fa..f8d78a4fd1 100644 --- a/tests/control_plane_ts/agent_context.test.ts +++ b/tests/control_plane_ts/agent_context.test.ts @@ -46,6 +46,11 @@ test("stable context ids bind phase, scope, content and policy revision", () => assert.notEqual(id(original), id(projectAgentContext({ ...input, ...patch }, [provider]))); } assert.notEqual(id(original), id(projectAgentContext(input, [{ ...provider, revision: "v2" }]))); + for (const patch of [{ guidance: ["Inspect contradictory evidence."] }, { facts: { observed: 1 } }]) { + assert.notEqual(id(original), id(projectAgentContext(input, [{ ...provider, + produce: () => ({ ...provider.produce({} as any, {}), ...patch }), + }]))); + } }); test("provider failures, forged control fields and oversize context are isolated", () => { @@ -228,7 +233,7 @@ test("coordinator participation guidance survives all bounded lifecycle projecti } })!; assert.deepEqual(packet.failures, []); const [contribution] = packet.contributions as Record[]; - assert.equal(contribution.revision, "v6"); + assert.equal(contribution.revision, "v7"); assert.equal(packet.authority, "guidance_only"); assert.ok(Buffer.byteLength(JSON.stringify(contribution)) <= 2048); assert.ok(Buffer.byteLength(JSON.stringify(packet)) <= 3072); @@ -358,16 +363,41 @@ const blocked = (phase = "before_plan") => ({ reason_code: "delegation_context_unavailable", }); -test("delegation facts reproduce the pinned Python projection byte for byte, context_id included", (t) => { +test("delegation facts preserve the pinned migration contract across guidance revisions", (t) => { for (const [name, scenario] of Object.entries(fixture.scenarios)) { t.mock.timers.enable({ apis: ["Date"], now: Date.parse(scenario.observed_at) }); const packet = evaluateGoalAgentContext({ phase: scenario.phase, scope: fixture.scope, orchestration: scenario.orchestration, observations: {}, delegation_facts: scenario.delegation_facts }); - assert.equal(JSON.stringify(packet), JSON.stringify(scenario.expected_context), name); + // The fixture preserves the pre-migration observation, not a permanent + // wording freeze. Guidance/revision are deliberately versioned; context id + // content binding is covered separately above. + const factsOnly = (value: any) => ({ ...value, contributions: value.contributions.map( + ({ guidance, revision, context_id, ...observation }: any) => observation, + ) }); + assert.deepEqual(factsOnly(packet), factsOnly(scenario.expected_context), name); t.mock.timers.reset(); } }); +test("native guidance keeps binding preflight scoped even when delegation is unavailable", () => { + for (const delegation_facts of [undefined, { config_error: "unavailable" }, delegationFacts([])]) { + const packet = evaluateGoalAgentContext({ phase: "before_delegate", scope: fixture.scope, + orchestration: { ...policy, model_config: null }, observations: {}, delegation_facts })!; + assert.equal(packet.authority, "guidance_only"); + assert.deepEqual(packet.failures, []); + const [{ guidance, facts }] = packet.contributions as Record[]; + assert.match(guidance.join(" "), /authorized host-native tools directly/); + assert.match(guidance.join(" "), /only bound delegation needs its binding entrypoint/); + assert.match(guidance.join(" "), /never silently substitute runtime\/model/); + assert.equal(facts.capacity_contract.live_availability, "not_observed"); + if (facts.delegation_context) { + assert.equal(facts.delegation_context.execution_scope, "bound_delegation"); + assert.equal(facts.delegation_context.preflight, "required"); + assert.deepEqual(facts.delegation_context.routes, []); + } + } +}); + test("ready routes derive readiness once from the executor fact and join one probe per host argv", () => { const managed = { ...genericExecutor, executor: "dsh", executor_kind: "managed", execution_profile: "model-a@high", available: false, unavailable_reason: "dsh_runtime_unavailable", From e70a9ccdcca1e216b0482612395c3da1f481b816 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:01:21 +0800 Subject: [PATCH 2/2] fix(agents): keep composed subagent context within its budget Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/control_plane/subagent_context.ts | 32 ++++---- tests/control_plane/test_agent_context.py | 84 ++++++++++++++++++++ tests/control_plane_ts/agent_context.test.ts | 81 +++++++++++++++---- 3 files changed, 166 insertions(+), 31 deletions(-) diff --git a/loopx/control_plane/subagent_context.ts b/loopx/control_plane/subagent_context.ts index 6f9b190b16..b83ae954ff 100644 --- a/loopx/control_plane/subagent_context.ts +++ b/loopx/control_plane/subagent_context.ts @@ -6,23 +6,31 @@ import { isAbsolute } from "node:path"; import { delegationRuntimeFacts, selectDelegationBinding } from "./collaboration/delegation.ts"; export const subagentContextProvider: AgentContextProvider = { - hookId: "multi_subagent.coordinator", capabilityId: "multi_subagent", revision: "v7", + hookId: "multi_subagent.coordinator", capabilityId: "multi_subagent", revision: "v8", phases: AGENT_CONTEXT_PHASES, produce(input, config) { + const nativeCapacity = input.phase === "after_delegate_result" + ? boundedNativeCapacityObservation(input.observations.native_host_capacity) + : null; + const capacityExhausted = nativeCapacity?.outcome === "agent_thread_limit_reached"; + // Leave room for the complete model preference and bounded route or receipt facts. const guidance = { before_plan: [ - "Delegate coherent independent outcomes, not isolated calls; leave method choices to the child. max_children is a ceiling, not a target or live availability. Admit incrementally, avoid duplicate reads, and keep useful parent work.", - "Host-native child work uses loopx native-child record/read for bounded Turn receipts; these calls do not start Turns or spend quota.", - "Choose authorized routes under user preferences. Host-native tools need no delegation binding. For bound delegation, inspect the selected binding; runtime availability is not admission. Peer-activation blocks assess neither route. Do not relaunch all routes each heartbeat.", + "Delegate coherent independent outcomes and leave methods to children. Add them incrementally within max_children. Avoid duplicate reads and keep useful parent work.", + "Honor user preferences. Use authorized host-native tools directly. Inspect the chosen binding for admission. Peer activation gates apply only to peers. Don't relaunch all routes each heartbeat.", + "Use loopx native-child record/read for Turn receipts without starting Turns or spending quota.", ], before_delegate: [ - "Give each child a coherent question, acceptance, sources, read/write limits and stopping condition; leave room to investigate and challenge the hypothesis. Identify dependencies and concurrent parent work.", - "Use authorized host-native tools directly; only bound delegation needs its binding entrypoint and runtime/profile/budget preflight. Preserve user model preferences; never silently substitute runtime/model. Record selection reason and stable operation id; report native decisions or skips, leaving missing reports unknown.", + "Give each child a question, acceptance, sources, read/write limits and stop condition. Invite inquiry and challenge. Name dependencies and parallel parent work.", + "Use authorized host-native tools directly. Only bound delegation needs its entrypoint and runtime/profile/budget preflight. Honor model preferences. Never silently substitute runtime/model. Record why and a stable operation ID. Report native decisions/skips. Missing reports stay unknown.", ], after_delegate_result: [ - "Check returned sources, omissions and contradictions against acceptance. Record native result and parent review separately; reconcile fresh receipts for the route actually used. 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.", + "Reconcile fresh receipts for the route used. Missing/rejected receipts can't prove completion. Separate native result and parent review.", + // The exhaustion observation already carries the stop and recovery actions. + ...(capacityExhausted ? [] : [ + "On typed agent_thread_limit_reached, stop same-Turn spawn/followup retries. Mark unlaunched work incomplete and continue useful parent work.", + ]), + "Check originals, gaps and conflicts against acceptance. Opinions aren't independent evidence. Log accept/defer/reject reasons. Link evidence. Validate before writeback.", ], }[input.phase]; const facts: JsonObject = { @@ -46,14 +54,10 @@ export const subagentContextProvider: AgentContextProvider = { facts.delegation_context = delegation; } if (input.phase === "after_delegate_result") { - const nativeCapacity = boundedNativeCapacityObservation( - input.observations.native_host_capacity, - ); if (nativeCapacity) { facts.native_host_capacity = nativeCapacity; const contract = facts.capacity_contract as JsonObject; - contract.live_availability = nativeCapacity.outcome === "agent_thread_limit_reached" - ? "capacity_exhausted" : "attempt_observed"; + contract.live_availability = capacityExhausted ? "capacity_exhausted" : "attempt_observed"; } const nativeActivity = boundedNativeChildActivity(input.observations.native_child_activity); if (nativeActivity) { diff --git a/tests/control_plane/test_agent_context.py b/tests/control_plane/test_agent_context.py index 27336e3933..a72ff9e204 100644 --- a/tests/control_plane/test_agent_context.py +++ b/tests/control_plane/test_agent_context.py @@ -332,6 +332,90 @@ def test_native_cli_is_read_only_and_does_not_claim_native_receipts(tmp_path, ph assert json.loads(rejected.stdout)["ok"] is False +@pytest.mark.parametrize( + ("binding_length", "model"), + [(94, "gpt-6.1-terra"), (95, "gpt-6.1-terra"), (95, "m" * 160)], +) +def test_native_cli_keeps_complete_context_with_large_bound_routes( + tmp_path, binding_length, model, +): + config_ref = ".loopx/config/delegations.json" + config = tmp_path / config_ref + config.parent.mkdir(parents=True) + bindings = [] + for index in range(3): + prefix = f"binding-{index}-" + bindings.append({ + "id": prefix + "a" * (binding_length - len(prefix)), + "agent_id": f"worker-{index}-" + "a" * (40 if index else 41), + "todo_id": f"todo_review000{index}", + "requesters": [SCOPE["agent_id"]], + "workspace": str(tmp_path / f"worker-{index}"), + "host_args": ["--host", "codex-cli"], + "timeout_seconds": 60, + "output_refs": ["result.json"], + }) + config.write_text(json.dumps({ + "schema_version": "loopx_local_delegation_v0", "bindings": bindings, + })) + model_config = {"model": model, "reasoning_effort": "max"} + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({ + "schema_version": 1, + "goals": [{ + "id": SCOPE["goal_id"], "repo": str(tmp_path), "status": "active", + "registered_agents": [SCOPE["agent_id"], *(b["agent_id"] for b in bindings)], + "spawn_policy": { + **POLICY, "max_children": 3, "model_config": model_config, + "execution_config": config_ref, + }, + }], + })) + command = [ + sys.executable, "-m", "loopx.cli", + "--registry", str(registry), "--runtime-root", str(tmp_path / "runtime"), + "agent-context", "--goal-id", SCOPE["goal_id"], "--agent-id", SCOPE["agent_id"], + "--format", "json", + ] + for phase in ("before_plan", "before_delegate", "after_delegate_result"): + args = [*command, "--phase", phase] + if phase == "after_delegate_result": + args += [ + "--native-child-operation", "followup", + "--native-child-outcome", "agent_thread_limit_reached", + ] + result = subprocess.run(args, capture_output=True, text=True, check=True) + payload = json.loads(result.stdout) + assert payload["ok"] is True + context = payload["agent_context"] + assert context["phase"] == phase + assert context["authority"] == "guidance_only" + assert context["failures"] == [] + [contribution] = context["contributions"] + assert contribution["guidance"] + facts = contribution["facts"] + assert facts["model_preference"] == model_config + assert len(json.dumps(contribution, separators=(",", ":")).encode()) <= 2048 + assert len(json.dumps(context, separators=(",", ":")).encode()) <= 3072 + if phase == "after_delegate_result": + assert "delegation_context" not in facts + assert facts["delegation_receipts"]["operation_receipts"] == {"observed": 0} + assert facts["native_host_capacity"]["retry_same_turn"] is False + assert facts["receipt_observation"] == "not_supplied" + else: + routes = facts["delegation_context"] + assert routes["execution_scope"] == "bound_delegation" + assert routes["preflight"] == "required" + assert routes["authorized_count"] == 3 + assert routes["projected_count"] == 2 + assert routes["routes_truncated"] is True + assert [route["binding_id"] for route in routes["routes"]] == [ + b["id"] for b in bindings[:2] + ] + assert all(route["readiness"] == "unknown" for route in routes["routes"]) + assert str(tmp_path) not in result.stdout + + def test_native_cli_projects_typed_capacity_exhaustion_without_raw_error(tmp_path): registry = tmp_path / "registry.json" registry.write_text( diff --git a/tests/control_plane_ts/agent_context.test.ts b/tests/control_plane_ts/agent_context.test.ts index f8d78a4fd1..bcd6e3cc2f 100644 --- a/tests/control_plane_ts/agent_context.test.ts +++ b/tests/control_plane_ts/agent_context.test.ts @@ -173,20 +173,27 @@ test("maximum delegation directory stays within provider budget", () => { executor_kind: "managed", readiness: "ready", execution_profile: "fixture-provider/fixture-model@max", })); - const packet = evaluateSubagentContext({ phase: "before_plan", scope, - orchestration: { ...policy, max_children: 6 }, observations: { - delegation_context: { - schema_version: "loopx_delegation_context_v0", configuration_state: "ready", - observed_at: "2026-09-19T04:20:00+00:00", authorized_count: 6, - projected_count: 6, routes, - }, - } })!; - assert.deepEqual(packet.failures, []); - const delegation = (packet.contributions as any[])[0].facts.delegation_context; - assert.ok(delegation.projected_count >= 1 && delegation.projected_count <= 6); - assert.equal(delegation.routes.length, delegation.projected_count); - assert.equal(delegation.routes_truncated, delegation.projected_count < 6 || undefined); - assert.ok(Buffer.byteLength(JSON.stringify(packet.contributions[0])) <= 2048); + const model_config = { model: "m".repeat(160), reasoning_effort: "medium" }; + for (const phase of ["before_plan", "before_delegate"]) { + const packet = evaluateSubagentContext({ phase, scope, + orchestration: { ...policy, max_children: 6, model_config }, observations: { + ...(phase === "before_delegate" ? { child_count: 6 } : {}), + delegation_context: { + schema_version: "loopx_delegation_context_v0", configuration_state: "ready", + observed_at: "2026-09-19T04:20:00+00:00", authorized_count: 6, + projected_count: 6, routes, + }, + } })!; + assert.deepEqual(packet.failures, []); + const [contribution] = packet.contributions as Record[]; + const delegation = contribution.facts.delegation_context; + assert.deepEqual(contribution.facts.model_preference, model_config); + assert.ok(delegation.projected_count >= 2 && delegation.projected_count <= 6); + assert.equal(delegation.routes.length, delegation.projected_count); + assert.equal(delegation.routes_truncated, delegation.projected_count < 6 || undefined); + assert.ok(Buffer.byteLength(JSON.stringify(contribution)) <= 2048); + assert.ok(Buffer.byteLength(JSON.stringify(packet)) <= 3072); + } }); test("planning progressively discloses probe scope without crowding out a second route", () => { @@ -233,8 +240,11 @@ test("coordinator participation guidance survives all bounded lifecycle projecti } })!; assert.deepEqual(packet.failures, []); const [contribution] = packet.contributions as Record[]; - assert.equal(contribution.revision, "v7"); + assert.equal(contribution.revision, "v8"); assert.equal(packet.authority, "guidance_only"); + if (phase === "after_delegate_result") { + assert.match(contribution.guidance.join(" "), /stop same-Turn spawn\/followup retries/); + } assert.ok(Buffer.byteLength(JSON.stringify(contribution)) <= 2048); assert.ok(Buffer.byteLength(JSON.stringify(packet)) <= 3072); } @@ -387,8 +397,8 @@ test("native guidance keeps binding preflight scoped even when delegation is una assert.deepEqual(packet.failures, []); const [{ guidance, facts }] = packet.contributions as Record[]; assert.match(guidance.join(" "), /authorized host-native tools directly/); - assert.match(guidance.join(" "), /only bound delegation needs its binding entrypoint/); - assert.match(guidance.join(" "), /never silently substitute runtime\/model/); + assert.match(guidance.join(" "), /Only bound delegation needs its entrypoint and runtime\/profile\/budget preflight/); + assert.match(guidance.join(" "), /never silently substitute runtime\/model/i); assert.equal(facts.capacity_contract.live_availability, "not_observed"); if (facts.delegation_context) { assert.equal(facts.delegation_context.execution_scope, "bound_delegation"); @@ -398,6 +408,43 @@ test("native guidance keeps binding preflight scoped even when delegation is una } }); +test("return context keeps native capacity stops and receipt sources with a long model preference", () => { + const model_config = { model: "m".repeat(160), reasoning_effort: "medium" }; + const items = ["prepared", "running", "turn_returned", "accepted", "rejected", "stopped", "unavailable"] + .map(status => ({ status, recovery_required: status === "stopped", private_result: "raw child material" })); + const packet = evaluateGoalAgentContext({ phase: "after_delegate_result", scope: fixture.scope, + orchestration: { ...policy, model_config }, delegation_facts: delegationFacts([binding(0)], { + operation_inventory: { items, has_more: true }, + }), observations: { + native_host_capacity: { + schema_version: "native_subagent_capacity_observation_v0", operation: "followup", + outcome: "agent_thread_limit_reached", child_count: 2, + }, + native_child_activity: { + schema_version: "native_subagent_activity_v0", entrypoint_scope: "host_native_child_tools", + observation: "coordinator_reported", configured_limit: 2, attempted_count: 3, + launched_count: 2, capacity_rejected_count: 1, parent_accepted_count: 1, + retry_same_turn: false, + }, + } })!; + assert.deepEqual(packet.failures, []); + const [contribution] = packet.contributions as Record[]; + const guidance = contribution.guidance.join(" "); + assert.match(guidance, /Missing\/rejected receipts can't prove completion/); + assert.match(guidance, /Opinions aren't independent evidence/); + assert.deepEqual(contribution.facts.model_preference, model_config); + assert.equal(contribution.facts.native_host_capacity.retry_same_turn, false); + assert.equal(contribution.facts.native_child_activity.host_attested, false); + assert.equal(contribution.facts.native_child_activity.parent_accepted_count, 1); + assert.equal(contribution.facts.receipt_observation, "not_supplied"); + assert.equal(contribution.facts.delegation_receipts.operation_receipts.observed, 7); + assert.equal(contribution.facts.delegation_receipts.operation_receipts.accepted, 1); + assert.equal(contribution.facts.delegation_context, undefined); + assert.ok(!JSON.stringify(packet).includes("raw child material")); + assert.ok(Buffer.byteLength(JSON.stringify(contribution)) <= 2048); + assert.ok(Buffer.byteLength(JSON.stringify(packet)) <= 3072); +}); + test("ready routes derive readiness once from the executor fact and join one probe per host argv", () => { const managed = { ...genericExecutor, executor: "dsh", executor_kind: "managed", execution_profile: "model-a@high", available: false, unavailable_reason: "dsh_runtime_unavailable",