diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index d71fcb8c6a..6bca83d2d0 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -1129,6 +1129,12 @@ export const capabilityConfigurationCatalogSchema = z.object({ effective_revision: z.string(), }).optional(), documentation: z.record(z.string(), z.unknown()).optional(), + context_contribution: z.object({ + supported_phases: z.array(z.enum(["before_plan", "before_delegate", "after_delegate_result"])), + target: z.literal("coordinator"), + activation: z.literal("with_capability"), + receipt_required: z.literal(true), + }).optional(), configuration_editor: capabilityConfigurationEditorSchema, })), }); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/capability-workbench.tsx b/apps/presentation/dashboard/src/features/personal-workspace/capability-workbench.tsx index d8be5a222c..4564923385 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/capability-workbench.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/capability-workbench.tsx @@ -126,6 +126,23 @@ export function CapabilityDetailHeader({ capability, locale, source }: Readonly<
{locale === "zh-CN" + ? "随能力开启。支持以下阶段;此处展示能力范围,不能证明某次运行已读取或采纳。" + : "Enabled with this capability. These are supported phases, not proof that a run read or adopted the guidance."}
+{phase}{locale === "zh-CN" + ? "LoopX Turn 在请求与结果中提供上下文;原生工具入口由 LoopX skill 调用同一只读接口。执行与采纳需另看运行证据。" + : "LoopX Turn includes context in requests and results. For native tools, the LoopX skill reads the same interface. Execution and adoption require run evidence."}
+{localized.description}
@@ -139,3 +156,10 @@ export function CapabilityDetailHeader({ capability, locale, source }: Readonly< ); } + + +const contextPhaseCopy = { + before_plan: { zh: "规划前:识别独立问题并保留主 Agent 的核验与整合职责。", en: "Before planning: identify independent questions and retain coordinator validation and integration." }, + before_delegate: { zh: "委派前:明确子任务边界、模型偏好及预期证据。", en: "Before delegation: specify task boundaries, model preferences and expected evidence." }, + after_delegate_result: { zh: "回收后:核验结果,说明采纳决定并关联计划与成果。", en: "After results: validate evidence, explain acceptance and link plans and deliverables." }, +} as const; diff --git a/docs/integrations/codex-subagent-orchestration.md b/docs/integrations/codex-subagent-orchestration.md index 2818015cb0..65ff3281aa 100644 --- a/docs/integrations/codex-subagent-orchestration.md +++ b/docs/integrations/codex-subagent-orchestration.md @@ -367,6 +367,12 @@ model from response style. ### Frontend configuration +The capability detail page also lists **Coordinator workflow guidance** in +English / **主 Agent 协作指导** in Chinese. It shows the three supported phases +and their purpose, using the shared capability catalog. It is read-only metadata, +not a per-run delivery indicator. The existing capability switch controls this +guidance together with child capacity; there is no second enable switch. + Goal settings → Goal details exposes child model and reasoning effort next to the existing execution boundary. “Use Luna / max” fills the draft only; use “Preview configuration update” to save it, including while execution is off. @@ -379,3 +385,55 @@ preview/readback validation; a model change invalidates an older preview. The model field is a preference, not a discovery menu or an execution receipt. The CLI accepts an empty `--subagent-reasoning-effort ''` to clear effort while retaining the model; `--clear-subagent-model-config` clears both. + +### Capability context lifecycle + +An enabled `multi_subagent` capability contributes to the coordinator through +the generic bounded provider contract in `control_plane/agent_context.ts`. +The capability owns its guidance in `control_plane/subagent_context.ts`; other +capabilities can implement the same typed provider interface and register with +their runtime owner. This initial version registers this built-in provider, +not arbitrary manifest scripts or external plugins. + +| Phase | Managed LoopX Turn call site | Coordinator responsibility | +| --- | --- | --- | +| `before_plan` | Live quota decision → `interaction_contract.agent_context` → signed `turn_envelope.agent_context` | Prefer parallel delegation for read-heavy tasks with independent questions, within the configured child limit; keep useful validation and integration work with the parent. | +| `before_delegate` | Admitted child operations → plan and host request `delegation_context` | Bound briefs, expected evidence and model preferences before selecting/launching children. | +| `after_delegate_result` | Host receipt reconciliation → journal `host_result.agent_context` → executor result `agent_context` | Validate receipts and original evidence, then accept/defer/reject and link outcomes. | + +Planning guidance does not require two persistent Todos. Managed automatic +child-lane admission still requires its existing prerequisites; this change +does not grant spawning rights or force concurrency. Disabling the capability +omits the context. Model preferences alone do not enable it. + +For a native-tool host such as a Codex App session, the LoopX project skill reads +the same interface at delegation and result boundaries: + +```bash +loopx agent-context --goal-id example-peer-task-goal --agent-id coordinator \ + --phase before_delegate --format json +loopx agent-context --goal-id example-peer-task-goal --agent-id coordinator \ + --phase after_delegate_result --format json +``` + +The coordinator must be registered. The command reads current registry policy +without writing a Todo, starting a turn or spending quota. It has no native +execution receipt input; return-phase facts explicitly say `not_supplied`. +LoopX cannot transparently intercept arbitrary host tools. The managed return +packet is returned to the caller, not automatically sent as another model turn. + +Each packet binds Goal/Agent/optional Todo, phase, provider revision and a stable +content ID. When the envelope approaches its existing budget, it carries a +signed content hash and a read instruction pointing to the existing full-decision +route instead of duplicating all guidance. Providers return only guidance, bounded facts and source references; +they cannot replace action, permission or priority fields. Per-provider and +aggregate limits are 2,048 and 3,072 UTF-8 bytes. Failures are isolated and +diagnostic text excludes raw provider errors. These scoped context projections +are not a new progress store and should not be exported as public evidence. +`delivery: projected` means generated, not delivered/read/adopted. Read actual +host receipts and parent validation evidence for those conclusions. Replaying +a journal preserves the same packet; no new execution is inferred. + +中文:三个阶段已落到真实控制面接口,开启 capability 自动提供主 Agent +协作指导。原生工具通过 skill 在边界读取同一接口,不声称拦截宿主工具。 +关闭 capability 即停止提供;前端展示支持范围,执行、采纳仍须看实际证据。 diff --git a/examples/control_plane/cli-output-probe-runner.py b/examples/control_plane/cli-output-probe-runner.py index c74b55d840..8c020e7cf3 100644 --- a/examples/control_plane/cli-output-probe-runner.py +++ b/examples/control_plane/cli-output-probe-runner.py @@ -274,6 +274,42 @@ def _blocking_gate_rows( ] + +def _multi_subagent_rows(probe, semantics, fixture_root): + """Run the same enabled public fixture on base and head, not default-off only.""" + project, runtime, registry_path, state_file = probe._write_fixture( + fixture_root / "multi_subagent_enabled", probe.SCENARIOS[0] + ) + registry = json.loads(registry_path.read_text()) + registry["goals"][0]["spawn_policy"] = { + "mode": "multi_subagent", "spawn_allowed": True, "max_children": 4, + "model_config": {"model": "example-small", "reasoning_effort": "max"}, + } + registry_path.write_text(json.dumps(registry)) + variant_id = "quota_should_run_turn_envelope" + command = probe._mode_variant_commands( + project=project, runtime=runtime, registry_path=registry_path, + state_file=state_file, output_format="json", + )[variant_id] + exit_code, output = probe._invoke_cli(command) + if exit_code != 0: + raise AssertionError("enabled multi_subagent turn envelope failed") + measurement = probe.measure_cli_output(output, output_format="json") + variant = probe.CLI_OUTPUT_MODE_VARIANT_BY_ID[variant_id] + probe.assert_cli_output_mode_variant( + variant, output_format="json", text=output, measurement=measurement, + ) + return [_receipt_row( + semantics=semantics, + row_id="variant/quota_should_run_turn_envelope_multi_subagent/small/json", + surface_id=variant.parent_surface_id, + variant_id="quota_should_run_turn_envelope_multi_subagent", scenario="small", + output_format="json", qualification_policy="explicit_opt_in_cold_path", + semantic_json_keys=variant.semantic_json_keys, markdown_anchor=variant.markdown_anchor, + measurement=measurement, text=output, + )] + + def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--test-source", type=Path, required=True) @@ -292,6 +328,7 @@ def main() -> int: *_default_rows(probe, semantics, stable_root), *_variant_rows(probe, semantics, stable_root), *_blocking_gate_rows(probe, semantics, stable_root), + *_multi_subagent_rows(probe, semantics, stable_root), ] args.receipt.parent.mkdir(parents=True, exist_ok=True) args.receipt.write_text( diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index d84c3244d2..978a29fd0a 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -87,6 +87,10 @@ function multiSubagentCapability({ current } = {}) { const effective = current ?? fallback; return { capability_id: "multi_subagent", + context_contribution: { + supported_phases: ["before_plan", "before_delegate", "after_delegate_result"], + target: "coordinator", activation: "with_capability", receipt_required: true, + }, display_name: "Adaptive child capacity", description: "Bound child-agent capacity and eligible responsibility domains.", available_scopes: ["goal"], @@ -2262,6 +2266,20 @@ async function main() { await page.getByRole("button", { name: /自适应子 Agent 容量/u }).click(); await page.getByRole("heading", { level: 2, name: /^自适应子 Agent 容量/ }).waitFor({ state: "visible" }); + const contextHelp = page.getByTestId("capability-context-phases"); + await contextHelp.locator("summary").click(); + for (const phase of ["before_plan", "before_delegate", "after_delegate_result"]) { + await contextHelp.getByText(phase, { exact: true }).waitFor({ state: "visible" }); + } + await contextHelp.getByText(/不能证明某次运行已读取或采纳/u).waitFor({ state: "visible" }); + await page.screenshot({ path: resolve(outputDir, "capability-context-phases-desktop.png"), fullPage: false, animations: "disabled" }); + await page.setViewportSize({ width: 390, height: 844 }); + if (await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)) { + throw new Error("Capability lifecycle guidance overflows mobile viewport"); + } + await page.screenshot({ path: resolve(outputDir, "capability-context-phases-mobile.png"), fullPage: false, animations: "disabled" }); + await page.setViewportSize({ width: 1440, height: 1000 }); + await contextHelp.locator("summary").click(); const multiSubagentEnabled = page.getByLabel(/^启用$/u); const multiSubagentMaxChildren = page.getByLabel(/^最大子 Agent 数/u); const multiSubagentDomains = page.getByLabel(/^允许的职责域/u); diff --git a/loopx/capabilities/configuration_ui.py b/loopx/capabilities/configuration_ui.py index b23b13c4ad..6f2d51ad75 100644 --- a/loopx/capabilities/configuration_ui.py +++ b/loopx/capabilities/configuration_ui.py @@ -340,7 +340,7 @@ def _merge_goal_feature( entry["available_scopes"] = [*entry["available_scopes"], "goal"] entry["goal_feature_id"] = capability_id entry["availability"] = feature.get("availability") - for field in ("default", "current", "documentation"): + for field in ("default", "current", "documentation", "context_contribution"): if isinstance(feature.get(field), Mapping): entry[field] = deepcopy(feature[field]) entry["configuration_editor"] = capability_configuration_editor( diff --git a/loopx/cli.py b/loopx/cli.py index c530d2ab1c..c87cd37fe5 100644 --- a/loopx/cli.py +++ b/loopx/cli.py @@ -3,6 +3,7 @@ import argparse import sys +from .cli_commands.agent_context import register_agent_context, handle_agent_context from .cli_commands.manager_inbox import register_manager_inbox, handle_manager_inbox from .capabilities.content_ops.cli import ( @@ -325,6 +326,7 @@ def build_parser() -> LoopXArgumentParser: register_project_lifecycle_commands(sub, add_subcommand_format) register_goal_channel_commands(sub, add_subcommand_format) register_manager_inbox(sub, add_subcommand_format) + register_agent_context(sub, add_subcommand_format) register_lark_inbox_commands(sub, add_subcommand_format) register_lark_kanban_commands(sub, add_subcommand_format) @@ -754,6 +756,9 @@ def main(argv: list[str] | None = None) -> int: if lark_kanban_result is not None: return lark_kanban_result + if args.command == "agent-context": + return handle_agent_context(args, registry_path, print_payload, output_format) + if args.command == "manager-inbox": return handle_manager_inbox(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 new file mode 100644 index 0000000000..de503f9352 --- /dev/null +++ b/loopx/cli_commands/agent_context.py @@ -0,0 +1,66 @@ +"""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 ..control_plane.agent_context import project_agent_context +from ..orchestration import compact_orchestration_policy + + +def register_agent_context(subparsers, add_format): + parser = subparsers.add_parser( + "agent-context", help="Read enabled capability guidance for the coordinator." + ) + add_format(parser) + parser.add_argument("--goal-id", required=True) + parser.add_argument("--agent-id", required=True) + parser.add_argument( + "--phase", + required=True, + choices=("before_plan", "before_delegate", "after_delegate_result"), + ) + + +def handle_agent_context(args, registry_path, print_payload, output_format): + 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): + print_payload( + {"ok": False, "error": "coordinator is not registered for this Goal"}, + output_format(args), + render_agent_context, + ) + return 1 + context = project_agent_context( + phase=args.phase, + scope={"goal_id": args.goal_id, "agent_id": args.agent_id, "todo_id": None}, + orchestration=compact_orchestration_policy(goal.get("spawn_policy")), + ) + print_payload( + { + "ok": True, + "agent_context": context, + "source": "registry.spawn_policy", + "read_only": True, + "host_receipts_observed": False, + }, + output_format(args), + render_agent_context, + ) + return 0 + + +def render_agent_context(payload): + if not payload["ok"]: + return str(payload["error"]) + context = payload.get("agent_context") + if not context: + return "No enabled capability contributes coordinator context at this phase." + lines = [ + f"Coordinator context: {context['phase']} (projected guidance; not delivery or adoption)" + ] + for contribution in context["contributions"]: + lines.append(f"{contribution['capability_id']} / {contribution['revision']}") + lines.extend(f"- {text}" for text in contribution["guidance"]) + for key, value in contribution["facts"].items(): + lines.append(f"- {key}: {value}") + if context["failures"]: + lines.append("Some context providers failed; inspect JSON diagnostics.") + return "\n".join(lines) diff --git a/loopx/configuration_catalog.py b/loopx/configuration_catalog.py index 5b4ae29596..e7c39c1ae7 100644 --- a/loopx/configuration_catalog.py +++ b/loopx/configuration_catalog.py @@ -4,6 +4,7 @@ from collections.abc import Mapping, Sequence from typing import Any +from .control_plane.agent_context import agent_context_descriptor from .capabilities.configuration_ui import build_capability_configuration_catalog from .control_plane.goals.goal_vision_policy import completed_todo_replan_threshold @@ -220,6 +221,7 @@ def build_goal_configuration_catalog( }, { "feature_id": "multi_subagent", + "context_contribution": agent_context_descriptor(), "display_name": "Adaptive child capacity", "availability": "supported_opt_in", "default": { diff --git a/loopx/control_plane/agent_context.py b/loopx/control_plane/agent_context.py new file mode 100644 index 0000000000..4bcd9b345a --- /dev/null +++ b/loopx/control_plane/agent_context.py @@ -0,0 +1,47 @@ +"""Transport lifecycle inputs to the typed capability context owner.""" + +from collections.abc import Mapping +from typing import Any + +from .effect_runtime import effect_runtime_result + + +def project_agent_context( + *, + phase: str, + scope: Mapping[str, Any], + orchestration: Mapping[str, Any], + observations: Mapping[str, Any] | None = None, +): + return effect_runtime_result( + "capability_hook.agent_context.project", + { + "phase": phase, + "scope": dict(scope), + "orchestration": dict(orchestration), + "observations": dict(observations or {}), + }, + ) + + +def envelope_agent_context( + envelope: Mapping[str, Any], + *, + phase: str, + observations: Mapping[str, Any] | None = None, +): + """The signed planning contribution binds later phases to the coordinator.""" + context = envelope.get("agent_context") + if not isinstance(context, Mapping): + return None + boundary = envelope.get("boundary") or {} + return project_agent_context( + phase=phase, + scope=context["scope"], + orchestration=boundary.get("orchestration") or {}, + observations=observations, + ) + + +def agent_context_descriptor(): + return effect_runtime_result("capability_hook.agent_context.describe", {}) diff --git a/loopx/control_plane/agent_context.ts b/loopx/control_plane/agent_context.ts new file mode 100644 index 0000000000..853f496eeb --- /dev/null +++ b/loopx/control_plane/agent_context.ts @@ -0,0 +1,113 @@ +/** Bounded, read-only capability contributions to coordinator lifecycle context. */ +import { createHash } from "node:crypto"; +import type { JsonObject } from "./effect_program.ts"; +import { requireJsonObject, requireNonEmptyString } from "./runtime_decode.ts"; + +export const AGENT_CONTEXT_PHASES = [ + "before_plan", "before_delegate", "after_delegate_result", +] as const; +export type AgentContextPhase = typeof AGENT_CONTEXT_PHASES[number]; +export interface AgentContextInput { + phase: AgentContextPhase; + scope: JsonObject; + capabilities: JsonObject; + observations: JsonObject; +} +export interface AgentContextProvider { + hookId: string; + capabilityId: string; + revision: string; + phases: readonly AgentContextPhase[]; + produce: (input: AgentContextInput, config: JsonObject) => JsonObject; +} + +const MAX_BYTES = 3_072; +const CONTRIBUTION_BYTES = 2_048; +function identifier(value: unknown): string { + const text = requireNonEmptyString(value, "context identifier"); + if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/u.test(text)) { + throw new Error("invalid context identifier"); + } + return text; +} +function bytes(value: unknown): number { + return Buffer.byteLength(JSON.stringify(value), "utf8"); +} +function canonical(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonical); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => [key, canonical(item)])); + } + return value; +} + +/** Only in-process providers registered by capability owners run; no script DSL. */ +export function projectAgentContext( + value: unknown, providers: readonly AgentContextProvider[], +): JsonObject | null { + const input = requireJsonObject(value, "agent context input"); + const phase = input.phase as AgentContextPhase; + if (!AGENT_CONTEXT_PHASES.includes(phase)) throw new Error("unsupported context phase"); + const rawScope = requireJsonObject(input.scope, "agent context scope"); + const scope: JsonObject = { + goal_id: identifier(rawScope.goal_id), agent_id: identifier(rawScope.agent_id), + todo_id: rawScope.todo_id == null ? null : identifier(rawScope.todo_id), + }; + const capabilities = requireJsonObject(input.capabilities, "capabilities"); + const observations = requireJsonObject(input.observations ?? {}, "observations"); + const contributions: JsonObject[] = []; + const failures: JsonObject[] = []; + const packet: JsonObject = { + schema_version: "loopx_agent_context_v0", phase, scope, + target: "coordinator", authority: "guidance_only", delivery: "projected", + contributions, failures, + }; + const seen = new Set