Skip to content

[per-component-executor] Design: per-component-executor-binding #1432

Description

@JoshuaRowePhantom

Design: per-component-executor-binding

Design document: docs/design/per-component-executor-binding.md (committed to the features branch)
https://github.com/JoshuaRowePhantom/Phantom.Workspaces/blob/features/docs/design/per-component-executor-binding.md

Key decision: reuse-first, NO new execution schema

An executor binding IS a transport connection-descriptor — the existing type-discriminated JSON already consumed by ITransportFactory.ConnectToAsync(JsonElement) (local, user-computer-profile, http, reverse-http, …). There is NO parallel ExecutorDescriptor type/record/schema anywhere in this design. An executor resource resolves to a connection-descriptor, which is fed straight into ITransportFactoryRegistry.ConnectToAsync. A trust profile's execution target already IS such a descriptor (TrustProfile.DefaultExecutionTarget), and Llm.Trust.ExecutionTargetResolver.ResolveDescriptor already produces the local/profile shapes.

Nesting is host-OUTER, target-INNER: {"type":"user-computer-profile","entity-id":"<host>","target":{ ...inner... }} ΓÇö the OUTER descriptor reaches the host, the INNER target runs there (the TargetedTransport seam in UserComputerProfileTransportFactory). The connection-descriptor strategy (a raw inline descriptor used verbatim) makes the model open-endedly extensible with no schema change.

Requirements

  1. Per-component binding. Each component of an agent session ΓÇö the model / Copilot-SDK chat client, and each individual MCP server/tool ΓÇö can be bound to a specific executor (the local orchestrator, or a named remote machine), instead of routing uniformly by tool-kind.
  2. Executors are named manifest resources. Executors are expressed as manifest resources[] entries of kind:"executor", referenced by name from the model (via model.options.executor, an executor name under the model options bag) and from tools (via an optional executor field).
  3. Unset inherits the session executor. An unset executor inherits the session's overall executor, which is the local orchestrator machine today ({"type":"local"} / "."). The system MUST NOT require or emit executor:"local".
  4. Executor id resolution strategies. An executor resource's id selects how it resolves to a transport connection-descriptor (JsonElement): local, parameter (bind to a launch parameter), user-computer-profile-entity (fixed profile entity-id), trust-profile, and connection-descriptor (a raw escape hatch carrying an inline connection-descriptor used verbatim). There is no parallel executor schema — an executor resolves to the same type-discriminated connection-descriptor the transport layer already consumes (local, user-computer-profile, http, reverse-http, …). The connection-descriptor strategy is what makes the model open-endedly extensible with no schema change.
  5. executor launch parameter. A new manifest parameter kind:"executor" lets the user pick, at launch, which executor a parameter-strategy executor resource resolves to. It offers a choice among two selectable option kinds: a trust-profile entity ("choose by trust policy") → that profile's DefaultExecutionTarget; and a user-computer-profile entity → choosing one synthesizes an implicit trust profile (in-memory TrustProfileDefinition whose DefaultExecutionTarget = {"type":"user-computer-profile","entity-id":<chosen uuid>} and whose HostingWorkspacesClientInstances = [<chosen uuid>]). Both paths converge on a trust profile whose DefaultExecutionTarget is the executor binding. The chosen selection is recorded in the typed parameter-selections map (string→JsonElement): {"trust-profile":"<name-or-id>"} or {"user-computer-profile":"<entity-id>"} — a sibling of the string→string parameter-values, which stays reserved for ${param} text templating (M7), lossless through PhantomAgentSchema.
  6. MCP servers must actually execute on their bound executor. McpToolContextProvider MUST connect through the transport router (ExecutorTargetRouter → ITransportFactoryRegistry) when its resolved connection-descriptor is non-local; today it always connects in-process and ExecutorTargetRouter has no production consumer.
  7. Remote MCP hosting. The remote host MUST host an arbitrary stdio/HTTP MCP server on request via a production openConnectionAsync handler registered on McpTransportListener.
  8. Entity resolution scoped to bound executor. mcp-server-entity resource resolution MUST resolve in the context of the tool's bound executor (search order: machine profile → ${USER}/mcp-servers → defaults/mcp-servers), not the resolving instance.
  9. Persist per-executor bindings. The session MUST persist per-executor bindings (executor-bindings: name → resolved connection-descriptor object) so the topology reconstructs correctly on resume — not just the single host-profile-entity-id.
  10. Explicit session executor. The "session overall executor" becomes an explicit concept (default local {"type":"local"}) that per-component executors override.
  11. Default split manifest. Ship a default manifest entity (defaults/agent-manifests/...) implementing this split: the Copilot-SDK chat client runs remotely; the chat router, the workspace tools (workspace-gui / workspace-entity), and the GitHub web MCP server run locally.
  12. No web-vs-non-web distinction. An MCP server with no executor simply runs on the local session executor. There is no special "web tools go remote" rule.
  13. OAuth interactivity rationale. MCP servers using interactive OAuth (authorization-code with a loopback/localhost redirect + a browser) MUST run on the machine that can open the user's browser and receive the loopback redirect ΓÇö i.e. the local executor. Therefore OAuth-interactive MCP servers MUST be pinned local; the default manifest pins the GitHub web MCP local for this reason (and because that is where the user authenticates). A key/PAT-authenticated web MCP does not strictly require local, but the default ships it local. Validation note: an MCP tool whose connection uses interactive OAuth combined with a non-local executor MUST be rejected or warned at load/validation time.
  14. Extensible to non-persistent runtimes (future). The model MUST be able to express non-persistent runtimes ΓÇö e.g. an ephemeral container ΓÇö without any manifest or session schema change, by authoring a host-outer / target-inner connection-descriptor and resolving it through the connection-descriptor escape hatch. Implementing such a runtime (a host-side container target handler driving ContainerEngine) is explicit future work, OUT OF SCOPE for the commits in this design ΓÇö there is no container implementation commit and no container child issue. See Extensibility below.

Gaps addressed

Gap Description Fixed by
G1 No per-tool / per-MCP executor binding; routing is per-kind static (ExecutorTargetResolver.ForKind). Commits 1, 6
G2 No executor field on the MCP tool type (PhantomMcpTool carries only Transport). Commit 3
G3 No kind:"executor" manifest resource (resources[] is anyOf:[toolResource, modelResource]). Commit 1
G4 No executor parameter kind / picker (Launchpad infers kind by name only, and there is no way to pick an executor by trust profile or implicit-from-user-computer-profile at launch). Commits 2, 8
G5 MCP mcp-server-entity resolution is not scoped to the bound executor's profile→user→defaults context. Commit 7
G6 Session persists only one remote (host-profile-entity-id), not per-executor bindings. Commit 5
G7 "Session overall executor" is implicit (no explicit default-local concept). Commit 5
G8 ExecutorTargetRouter has no production consumer; McpToolContextProvider always connects in-process. Commit 6
G9 No remote production handler to host an arbitrary stdio/HTTP MCP server (McpTransportListener is never registered in production). Commit 6
G10 The executor model must be structured/extensible so non-persistent runtimes (e.g. ephemeral containers) can be added later WITHOUT a manifest/session schema change. Satisfied by the reuse-first design: executors resolve to connection-descriptors and the raw connection-descriptor strategy + type-dispatched transport factories admit new types with no caller/schema change. Commits 4, 5 + Extensibility (documentation only ΓÇö no implementation commit)

Cohesion findings (M1ΓÇôM7)

A review of the three integration seams (manifest→build, MCP tool→transport, session persistence) produced seven concrete findings that the design doc's Cohesion / integration seams section now enumerates with exact class/method/line names. Summary:

Related / non-blocking

Chosen design

Approach: Option A ΓÇö Per-component executor binding via manifest kind:"executor" resources.

Rationale: Option A gives true per-server granularity (addressing B's fatal con) by making the existing but dormant ExecutorTargetRouter a production consumer and giving each MCP provider a resolved connection-descriptor. It avoids C's sub-session sprawl by keeping ONE AgentChat whose components are individually routed over transport. Its own cons are contained: the remote MCP host handler is a small McpTransportListener registration (the listener primitive already exists and is exercised by tests), the descriptor threading is an additive constructor parameter on McpToolContextProvider, and the parameter-kind work is localised to the manifest parameter model and the Launchpad picker. Because an unset executor and a single-machine topology both resolve to {"type":"local"}, the change is behaviour-preserving for every existing manifest. Reusing the transport connection-descriptor (rather than inventing a parallel executor schema) also means non-persistent runtimes ΓÇö e.g. ephemeral containers ΓÇö can be added later purely as a new connection-descriptor type behind the same ITransportFactory dispatch, with no manifest or session schema change (G10; see Extensibility).

Implementation plan (11 commits)

Eleven commits total: Commits 1ΓÇô9, plus Commit 6B (model/chat-client binding) and Commit 10 (LLM authoring guide). Each commit leaves the build green and all tests passing.

Commit 1 ΓÇö Executor resource schema + model ΓÇö #1433

Scope: Add the executorResource $def to agent-manifest.json and its parsed model ExecutorResource (convenience strategies local/parameter/user-computer-profile-entity/trust-profile via a simple Options string map, PLUS an optional inline ConnectionDescriptor JsonElement for the raw connection-descriptor escape hatch). There is NO bespoke ExecutorDescriptor schema ΓÇö an executor resolves to the transport connection-descriptor. Extend resources.items.anyOf to include executorResource. Add the optional executor string to the toolResource $def (the model authors its executor under model.options.executor, round-tripped by ModelOptions.AdditionalProperties, so no modelResource schema change is needed). Parse kind:"executor" resources into ExecutorResource during manifest load (via the M5 executor pre-pass, distinct from IToolResourceFactory).
Files: Phantom.Workspaces.Llm.Core/JsonSchemas/agent-manifest.json; Phantom.Workspaces.Llm.Core/Manifest/ExecutorResource.cs (new); the manifest loader that enumerates resources[].
Tests: AgentManifestExecutorResourceTests (Load_ManifestWithExecutorResource_ParsesResource, RoundTrip_ExecutorResourceAndRefs_Lossless, and a connection-descriptor-strategy resource parse/round-trip).
Dependencies: none.

Commit 2 ΓÇö executor parameter kind ΓÇö #1434

Scope: Add an executor parameter kind to the manifest parameter model and its documentation, plus value recording/substitution. The parameter offers two selectable option kinds — a trust-profile entity and a user-computer-profile entity (the latter synthesizing an implicit trust profile) — and records a disambiguated selection in the typed parameter-selections map (string→JsonElement) ({"trust-profile":"<name-or-id>"} or {"user-computer-profile":"<entity-id>"}), a sibling of the unchanged string→string parameter-values (M7), lossless through PhantomAgentSchema. Make parameter kind read from the manifest parameter kind field rather than being inferred purely by name (see Contradictions).
Files: the AgentManifest parameter model / substitutor (Phantom.Workspaces.Llm.Core/AgentDefinitionParameterSubstitutor.cs and the parameter property model); Phantom.Workspaces.Data.Core/JsonEntities/documentation/agent-options-parameters.md.
Tests: AgentManifestExecutorResourceTests.Load_ExecutorParameter_Recognised; a recording test for the new kind (both disambiguated selection shapes, stored as typed JsonElement entries in parameter-selections).
Dependencies: none.

Commit 3 ΓÇö PhantomMcpTool.Executor field ΓÇö #1435

Scope: Add Executor (nullable string) to PhantomMcpTool using the established recipe: read in PhantomAgentSchema PostProcess (ReadExecutor), copy in From(), emit in Save(). Keep the source-scan guard intact.
Files: Phantom.Workspaces.Llm.Interfaces/PhantomMcpTool.cs; Phantom.Workspaces.Llm.Interfaces/PhantomAgentSchema.cs.
Tests: PhantomMcpToolExecutorTests (Save_WithExecutor_EmitsExecutorField, From_CopiesExecutor, RoundTrip_ExecutorField_Preserved, guard test).
Dependencies: none.

Commit 4 ΓÇö Executor-resource resolver (returns a connection-descriptor) ΓÇö #1436

Scope: Add ExecutorResourceResolver mapping an ExecutorResource (+ resolved parameter-values + trust context) to a transport connection-descriptor (JsonElement) for all five id strategies (local, parameter, user-computer-profile-entity, trust-profile, connection-descriptor), with clear errors for unknown/unresolved. This is the reuse-first fix: the resolver returns the connection-descriptor that ITransportFactoryRegistry.ConnectToAsync already dispatches on (NOT a flat string, NOT an ExecutorDescriptor), DELEGATING to the existing Llm.Trust.ExecutionTargetResolver.ResolveDescriptor for the local/profile shapes. The parameter strategy reads the typed executor selection (M7) from parameter-selections (not parameter-values). Runs from a distinct pre-pass, not IToolResourceFactory (M5). Its ResolveComponent output populates RuntimeContextProviderRegistration.ConnectionDescriptor (M1). Add ExecutorBindings (session executor default {"type":"local"} + name→connection-descriptor + ResolveComponent + ToTopology + ToPersistableMap).
Files: Phantom.Workspaces.Llm.Core/Manifest/ExecutorResourceResolver.cs, Phantom.Workspaces.Llm.Core/Manifest/ExecutorBindings.cs (new).
Tests: ExecutorResourceResolverTests (incl. Resolve_ConnectionDescriptorId_ReturnsInlineDescriptorVerbatim, Resolve_ParameterValue_JsonEncodedString_IsParsed), ExecutorBindingsTests, ExecutorResourcePrePassTests.
Dependencies: Commit 1 (ExecutorResource); Commit 2 (for the parameter strategy / typed parameter-selections value).

Commit 5 ΓÇö Explicit session executor + executor-bindings persistence + resume ΓÇö #1437

Scope: Make the session's overall executor explicit (default {"type":"local"}). Add executor-bindings (root shape { "session": <descriptor>, "components": { name→connection-descriptor } }) and a new typed parameter-selections root key (string→JsonElement) to agent-session.json. On build, write bindings alongside the existing parameter-values / host-profile-entity-id (AgentSessionEntityFactory.cs:39-93); on resume, rebuild ExecutorTopology and set the deferred selector's topology from the bindings (deriving the client-instance strings for local/user-computer-profile shapes). M6 back-compat: if executor-bindings.session is absent but host-profile-entity-id is present, derive SessionExecutor = {"type":"user-computer-profile","entity-id":<id>}; bindings are the source of truth. The executor selection persists in a new typed parameter-selections root key (string→JsonElement), a sibling of the unchanged string→string parameter-values (M7 — no dictionary widening).
Files: Phantom.Workspaces.Data.Core/JsonSchemas/agent-session.json; Phantom.Workspaces.Data.Core/AgentSessionEntityFactory.cs; the session build/resume path that constructs ExecutorTopology and calls DeferredTrustedExecutorSelector.SetTopology.
Tests: AgentSessionExecutorBindingsTests (incl. Persist_DescriptorObjects_RoundTrips, Persist_SessionAndComponentsKeys_RoundTrip, Resume_RebuildsTopologyFromBindings, Resume_LegacyHostProfileOnly_DerivesSessionExecutor).
Dependencies: Commit 4.

Commit 6 ΓÇö Per-tool MCP execution over transport + production remote MCP host ΓÇö #1438

Scope: Thread the resolved connection-descriptor + a production ExecutorTargetRouter into each McpToolContextProvider (constructed in AgentChat). When the bound descriptor is non-local, feed it straight into the router → ITransportFactoryRegistry.ConnectToAsync (no string hop), opening an McpClientOverTransport — bridged to the MCP SDK client through the NEW McpChannelClientTransport (IClientTransport over IMessageChannel, M2); when local, keep the in-process path (no round-trip). Nesting is host-outer/target-inner. Add the production RemoteMcpHostHandler (openConnectionAsync, steps a–e) and register it on a production McpTransportListener in WorkspacesTransportComposition (M3). This makes ExecutorTargetRouter a production consumer (G8) and provides the arbitrary stdio/HTTP MCP host (G9). The remote host handler CONSUMES #1439's scoped resolver (machine prefix first) — a two-way touchpoint documented in both issues; dependency direction unchanged.
Files: Phantom.Workspaces.Llm.Core/McpToolContextProvider.cs, Phantom.Workspaces.Llm.Core/AgentChat.cs, Phantom.Workspaces.Transport.Mcp/McpChannelClientTransport.cs (new), Phantom.Workspaces.Transport.Mcp/RemoteMcpHostHandler.cs (new), Phantom.Workspaces/Services/WorkspacesTransportComposition.cs.
Tests: McpToolContextProviderRoutingTests, McpChannelClientTransportTests, RemoteMcpHostHandlerTests, WorkspacesTransportCompositionTests, Scenario3_PerMcpServerRoutingTests.
Dependencies: Commits 3, 4, 5. Touchpoint: #1439 (scoped resolver consumed by the host handler).

Commit 6B ΓÇö Model / chat-client executor binding (remote client, local router) ΓÇö #1443

Scope: (M4.) Honour a model.options.executor binding by transporting ONLY CopilotSdkChatClient's inner SDK session while the router (IChatClient decorators) and the AIContextProviders stay local — the structural inverse of TransportTrustedExecutor.CreateAgentChatAsync (Transport/TransportTrustedExecutor.cs:43-67, :50/:54), which remotes the whole AgentChat. When the model's resolved descriptor is {"type":"local"}, behaviour is unchanged (in-process CLI). When non-local, CopilotSdkChatClient obtains its SDK session over a transport (EnsureSessionAsync :1230 → copilotClientFactory.Create :1277 → CreateOrResumeSessionAsync :1324-1357) instead of in-process. A production client-only host path builds a local CopilotSdkChatClient on the remote (distinct from ChatClientTransportListener's whole-AgentChat build). Inserted as 6B so Commits 7/8/9 do NOT renumber.
Files: Phantom.Workspaces.Llm.Core/CopilotSdkChatClient.cs; Phantom.Workspaces.Llm.Core/AgentFactory.cs; new CopilotSessionOverTransport under Phantom.Workspaces.Llm.Core/Transport/Chat/; Phantom.Workspaces/Services/WorkspacesTransportComposition.cs.
Tests: CopilotSdkChatClientExecutorTests, AgentChatExecutorBindingTests, WorkspacesTransportCompositionTests.
Dependencies: Commit 4 (#1436), Commit 5 (#1437). Required by: Commit 9 (#1441).

Commit 7 ΓÇö MCP mcp-server-entity resolution scoped to the bound executor ΓÇö #1439

Scope: When a tool id is mcp-server-entity, evaluate the search prefixes (machine profile → ${USER}/mcp-servers → defaults/mcp-servers) against the bound executor's profile/user context instead of the resolving instance's, constructing the McpServerEntityToolResourceFactory searchPrefixes with the bound machine first. The remote MCP host handler from #1438 CONSUMES this scoped resolver (two-way touchpoint).
Files: the mcp-server-entity resolution code (Phantom.Workspaces/McpServerEntityToolResourceFactory.cs:21-113).
Tests: McpServerEntityBoundExecutorResolutionTests.
Dependencies: Commit 6. Touchpoint: #1438 (its host handler consumes this resolver).

Commit 8 ΓÇö Launchpad executor picker UI ΓÇö #1440

Scope: Rename the AgentManifestParameterKind enum value UserComputerProfile → Executor and add a combined picker in the Launchpad that lists both trust-profile entities and user-computer-profile entities; the selection records the disambiguated value ({"trust-profile":...} or {"user-computer-profile":...}) as a typed JsonElement in parameter-selections (not parameter-values). It is no longer a user-computer-profile-only picker. Honour the manifest parameter kind field.
Files: Phantom.Workspaces/ViewModels/AgentManifestParameterKind.cs, Phantom.Workspaces/ViewModels/AgentManifestParameterRowViewModel.cs, Phantom.Workspaces/ViewModels/AgentManifestLaunchpadViewModel.cs, Phantom.Workspaces/Templates/AgentManifestLaunchpadView.axaml(.cs).
Tests: AgentManifestLaunchpadViewModelTests.
Dependencies: Commit 2.

Commit 9 ΓÇö Default split-executor Copilot manifest + OAuth-local validation ΓÇö #1441

Scope: Add defaults/agent-manifests/copilot-split-executor with: one kind:"executor" resource worker (id parameter → worker-profile); a worker-profile parameter (kind executor) + a working-directory parameter; a model with executor:"worker"; workspace-gui / workspace-entity tools with no executor (inherit local); a GitHub web MCP tool with no executor (local, for OAuth). Add load-time validation that rejects/warns an OAuth-interactive MCP whose executor is non-local. Cross-check the exact tool/model/connection JSON shapes against features/docs/examples/github-copilot-remote-chat.json. The model-remote behaviour depends on Commit 6B (#1443).
Files: Phantom.Workspaces.Data.Core/JsonEntities/agent-manifests/copilot-split-executor.json (new); the manifest/validation code that enforces the OAuth-local rule.
Tests: CopilotSplitExecutorManifestTests, SplitExecutorIntegrationTests.
Dependencies: Commits 1ΓÇô6, 6B.

Commit 10 ΓÇö LLM-readable executor authoring guide ΓÇö #1442

Scope: Add a single, self-contained, machine-consumable authoring reference (Phantom.Workspaces.Data.Core/JsonEntities/documentation/agent-manifest-executors.md) so an LLM can reliably author a valid executor-bound manifest or reason about a session: the kind:"executor" resource + five id strategies, the executor reference on tools and the model.options.executor reference on the model, the kind:"executor" launch parameter + disambiguated recorded value, the reused connection-descriptor types + host-outer/target-inner nesting, the persisted executor-bindings session shape, two worked end-to-end examples (split-executor topology from #1441; trivial all-local baseline), and OAuth-local guidance. Every fenced JSON block is a real, parseable example guarded by tests. Complements #1441 by documenting the GENERAL authoring rules.
Files: Phantom.Workspaces.Data.Core/JsonEntities/documentation/agent-manifest-executors.md (new); cross-links in agent-options-parameters.md; schema-comment alignment in agent-manifest.json.
Tests: ExecutorAuthoringGuideTests (AuthoringGuide_EmbeddedManifestExamples_ParseAndRoundTrip, AuthoringGuide_EmbeddedSessionExamples_ResolveToDescriptors, AuthoringGuide_DocumentsAllFiveIdStrategies, AuthoringGuide_ExecutorParameterValueShapes_Documented).
Dependencies: Commits 1 (#1433), 2 (#1434), 3 (#1435), 5 (#1437); complements Commit 9 (#1441).

Extensibility ΓÇö non-persistent executors

Explicitly OUT OF SCOPE and FUTURE WORK ΓÇö there is no container implementation commit and no container child issue in this design. This is documentation only. It records how the reuse-first connection-descriptor model extends to non-persistent runtimes (an ephemeral container is the worked example) without any manifest or session schema change, because an ExecutorResource already supports the raw connection-descriptor strategy and the transport layer already dispatches by type.

  • Reuse the existing containers subsystem. Phantom.Workspaces.Containers already defines ContainerDefinition + container-definition.json and ContainerEngine (CreateAsync/PullAsync/StartAsync/StopAsync/DestroyAsync/UsableAsync) with Docker Desktop / containerd implementations (already used in production by MongoDbConnectionBroker). A future container executor would drive this subsystem ΓÇö it does not invent a new container abstraction, and no container manifest/session schema is added.
  • Authoring shape (host-outer, target-inner). A container executor would be authored via the raw connection-descriptor strategy as a host-outer descriptor with an inner container target: {"type":"user-computer-profile","entity-id":"<worker>","target":{"type":"container","container-definition":<ContainerDefinition>}}. The OUTER descriptor reaches the host; the INNER target runs there (threaded through the existing TargetedTransport seam). Because ExecutorResource already carries an inline connection-descriptor, no new manifest field is needed.
  • Extension work (future). Add a host-side container target handler ΓÇö analogous to the MCP host handler seam ΓÇö driving ContainerEngine create/start/attach and DestroyAsync on teardown, registered as an ITransportFactory/listener. Trust inherits the resolved host executor. Open question (future): what runs INSIDE the container (a full agent-executor endpoint vs. just the pinned MCP server) ΓÇö the design does not answer this; it only guarantees the connection-descriptor model can extend here with no manifest/session schema change.

Sub-issues (the 11 children ΓÇö dependency order)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions