refactor(runtime): move dispatch resolution into go-core

This commit is contained in:
Haitao Pan 2026-03-29 15:17:49 +08:00
parent 9d475123bb
commit 027a63d629
14 changed files with 940 additions and 71 deletions

View File

@ -14,6 +14,7 @@ import (
"github.com/gorilla/websocket"
"xworkmate/go_core/internal/dispatch"
"xworkmate/go_core/internal/shared"
)
@ -266,6 +267,8 @@ func (s *Server) handleRequest(
}
closed := s.closeSession(sessionID)
return map[string]any{"accepted": true, "closed": closed}, nil
case "xworkmate.dispatch.resolve":
return handleDispatchResolve(request.Params), nil
default:
return nil, &shared.RPCError{
Code: -32601,
@ -274,6 +277,107 @@ func (s *Server) handleRequest(
}
}
func handleDispatchResolve(params map[string]any) map[string]any {
providers := parseDispatchProviders(params["providers"])
requiredCapabilities := parseStringSlice(params["requiredCapabilities"])
preferredProviderID := strings.TrimSpace(
shared.StringArg(params, "preferredProviderId", ""),
)
request := dispatch.Request{
Providers: providers,
PreferredProviderID: preferredProviderID,
RequiredCapabilities: requiredCapabilities,
}
if nodeState := parseDispatchNodeState(params["nodeState"]); nodeState != nil {
request.NodeState = nodeState
}
if nodeInfo := parseDispatchNodeInfo(params["nodeInfo"]); nodeInfo != nil {
request.NodeInfo = nodeInfo
}
return dispatch.ResultMap(dispatch.Resolve(request))
}
func parseDispatchProviders(raw any) []dispatch.Provider {
list, ok := raw.([]any)
if !ok {
return nil
}
providers := make([]dispatch.Provider, 0, len(list))
for _, item := range list {
entry, ok := item.(map[string]any)
if !ok {
continue
}
id := strings.TrimSpace(shared.StringArg(entry, "id", ""))
if id == "" {
continue
}
providers = append(providers, dispatch.Provider{
ID: id,
Name: strings.TrimSpace(shared.StringArg(entry, "name", "")),
DefaultArgs: parseStringSlice(entry["defaultArgs"]),
Capabilities: parseStringSlice(entry["capabilities"]),
})
}
return providers
}
func parseDispatchNodeState(raw any) *dispatch.NodeState {
entry, ok := raw.(map[string]any)
if !ok {
return nil
}
return &dispatch.NodeState{
SelectedAgentID: strings.TrimSpace(
shared.StringArg(entry, "selectedAgentId", ""),
),
GatewayConnected: shared.BoolArg(
fmt.Sprint(entry["gatewayConnected"]),
false,
),
ExecutionTarget: strings.TrimSpace(
shared.StringArg(entry, "executionTarget", ""),
),
RuntimeMode: strings.TrimSpace(shared.StringArg(entry, "runtimeMode", "")),
BridgeEnabled: shared.BoolArg(fmt.Sprint(entry["bridgeEnabled"]), false),
BridgeState: strings.TrimSpace(shared.StringArg(entry, "bridgeState", "")),
ResolvedCodexCLIPath: strings.TrimSpace(
shared.StringArg(entry, "resolvedCodexCliPath", ""),
),
ConfiguredCodexCLIPath: strings.TrimSpace(
shared.StringArg(entry, "configuredCodexCliPath", ""),
),
}
}
func parseDispatchNodeInfo(raw any) *dispatch.NodeInfo {
entry, ok := raw.(map[string]any)
if !ok {
return nil
}
return &dispatch.NodeInfo{
ID: strings.TrimSpace(shared.StringArg(entry, "id", "")),
Name: strings.TrimSpace(shared.StringArg(entry, "name", "")),
Version: strings.TrimSpace(shared.StringArg(entry, "version", "")),
}
}
func parseStringSlice(raw any) []string {
list, ok := raw.([]any)
if !ok {
return nil
}
values := make([]string, 0, len(list))
for _, item := range list {
value := strings.TrimSpace(fmt.Sprint(item))
if value == "" {
continue
}
values = append(values, value)
}
return values
}
func (s *Server) enqueue(threadID string, task task) (map[string]any, *shared.RPCError) {
queue := s.ensureQueue(threadID)
queue <- task

View File

@ -0,0 +1,203 @@
package dispatch
import (
"maps"
"slices"
"strings"
)
type Provider struct {
ID string
Name string
DefaultArgs []string
Capabilities []string
}
type NodeState struct {
SelectedAgentID string
GatewayConnected bool
ExecutionTarget string
RuntimeMode string
BridgeEnabled bool
BridgeState string
ResolvedCodexCLIPath string
ConfiguredCodexCLIPath string
}
type NodeInfo struct {
ID string
Name string
Version string
}
type Request struct {
Providers []Provider
PreferredProviderID string
RequiredCapabilities []string
NodeState *NodeState
NodeInfo *NodeInfo
}
type Result struct {
Provider *Provider
AgentID string
Metadata map[string]any
}
func Resolve(request Request) Result {
provider := selectProvider(
request.Providers,
request.PreferredProviderID,
request.RequiredCapabilities,
)
if request.NodeState == nil {
return Result{Provider: provider, Metadata: map[string]any{}}
}
state := request.NodeState
nodeInfo := request.NodeInfo
nodeID := "xworkmate-app"
nodeName := "XWorkmate"
nodeVersion := ""
if nodeInfo != nil {
if strings.TrimSpace(nodeInfo.ID) != "" {
nodeID = strings.TrimSpace(nodeInfo.ID)
}
if strings.TrimSpace(nodeInfo.Name) != "" {
nodeName = strings.TrimSpace(nodeInfo.Name)
}
nodeVersion = strings.TrimSpace(nodeInfo.Version)
}
configuredPath := strings.TrimSpace(state.ConfiguredCodexCLIPath)
if strings.TrimSpace(state.ResolvedCodexCLIPath) != "" {
configuredPath = strings.TrimSpace(state.ResolvedCodexCLIPath)
}
localTransport := "stdio-jsonrpc"
if strings.TrimSpace(state.RuntimeMode) == "builtIn" {
localTransport = "ffi-runtime"
}
metadata := map[string]any{
"node": map[string]any{
"id": nodeID,
"name": nodeName,
"version": nodeVersion,
"kind": "app-mediated-cooperative-node",
"gatewayTransport": "websocket-rpc",
},
"dispatch": map[string]any{
"mode": dispatchMode(state.BridgeEnabled),
"executionTarget": strings.TrimSpace(state.ExecutionTarget),
},
"bridge": map[string]any{
"enabled": state.BridgeEnabled,
"state": strings.TrimSpace(state.BridgeState),
"gatewayConnected": state.GatewayConnected,
"runtimeMode": strings.TrimSpace(state.RuntimeMode),
"localTransport": localTransport,
},
}
if configuredPath != "" {
bridge := metadata["bridge"].(map[string]any)
bridge["binaryConfigured"] = true
}
if provider != nil {
metadata["provider"] = map[string]any{
"id": provider.ID,
"name": provider.Name,
"defaultArgs": provider.DefaultArgs,
"capabilities": provider.Capabilities,
}
}
return Result{
Provider: provider,
AgentID: strings.TrimSpace(state.SelectedAgentID),
Metadata: metadata,
}
}
func dispatchMode(bridgeEnabled bool) string {
if bridgeEnabled {
return "cooperative"
}
return "gateway-only"
}
func selectProvider(
providers []Provider,
preferredProviderID string,
requiredCapabilities []string,
) *Provider {
required := normalizeCapabilities(requiredCapabilities)
preferredID := strings.TrimSpace(preferredProviderID)
if preferredID != "" {
for _, provider := range providers {
if provider.ID == preferredID && supportsProvider(provider, required) {
candidate := provider
return &candidate
}
}
}
filtered := make([]Provider, 0, len(providers))
for _, provider := range providers {
if supportsProvider(provider, required) {
filtered = append(filtered, provider)
}
}
if len(filtered) == 0 {
return nil
}
slices.SortFunc(filtered, func(a, b Provider) int {
return strings.Compare(a.ID, b.ID)
})
candidate := filtered[0]
return &candidate
}
func supportsProvider(provider Provider, required map[string]struct{}) bool {
if len(required) == 0 {
return true
}
provided := normalizeCapabilities(provider.Capabilities)
for capability := range required {
if _, ok := provided[capability]; !ok {
return false
}
}
return true
}
func normalizeCapabilities(values []string) map[string]struct{} {
normalized := map[string]struct{}{}
for _, value := range values {
item := strings.TrimSpace(strings.ToLower(value))
if item == "" {
continue
}
normalized[item] = struct{}{}
}
return normalized
}
func ResultMap(result Result) map[string]any {
response := map[string]any{
"metadata": result.Metadata,
}
if result.Provider != nil {
provider := *result.Provider
response["providerId"] = provider.ID
response["provider"] = map[string]any{
"id": provider.ID,
"name": provider.Name,
"defaultArgs": slices.Clone(provider.DefaultArgs),
"capabilities": slices.Clone(provider.Capabilities),
}
}
if strings.TrimSpace(result.AgentID) != "" {
response["agentId"] = strings.TrimSpace(result.AgentID)
}
return maps.Clone(response)
}

View File

@ -0,0 +1,96 @@
package dispatch
import "testing"
func TestResolvePrefersRequestedProviderWhenCapabilitiesMatch(t *testing.T) {
result := Resolve(Request{
Providers: []Provider{
{
ID: "codex",
Name: "Codex",
Capabilities: []string{"chat", "gateway-bridge"},
},
{
ID: "qwen",
Name: "Qwen",
Capabilities: []string{"chat"},
},
},
PreferredProviderID: "codex",
RequiredCapabilities: []string{"gateway-bridge"},
})
if result.Provider == nil || result.Provider.ID != "codex" {
t.Fatalf("expected codex provider, got %#v", result.Provider)
}
}
func TestResolveFallsBackDeterministicallyByID(t *testing.T) {
result := Resolve(Request{
Providers: []Provider{
{
ID: "qwen",
Name: "Qwen",
Capabilities: []string{"chat"},
},
{
ID: "codex",
Name: "Codex",
Capabilities: []string{"chat"},
},
},
RequiredCapabilities: []string{"chat"},
})
if result.Provider == nil || result.Provider.ID != "codex" {
t.Fatalf("expected deterministic codex fallback, got %#v", result.Provider)
}
}
func TestResolveBuildsGatewayDispatchMetadata(t *testing.T) {
result := Resolve(Request{
Providers: []Provider{
{
ID: "codex",
Name: "Codex CLI",
DefaultArgs: []string{"app-server"},
Capabilities: []string{"chat", "gateway-bridge"},
},
},
PreferredProviderID: "codex",
RequiredCapabilities: []string{"gateway-bridge"},
NodeState: &NodeState{
SelectedAgentID: "main",
GatewayConnected: true,
ExecutionTarget: "local",
RuntimeMode: "externalCli",
BridgeEnabled: true,
BridgeState: "registered",
ResolvedCodexCLIPath: "/opt/homebrew/bin/codex",
},
NodeInfo: &NodeInfo{
ID: "xworkmate-app",
Name: "XWorkmate",
Version: "1.0.0",
},
})
if result.Provider == nil || result.Provider.ID != "codex" {
t.Fatalf("expected codex provider, got %#v", result.Provider)
}
if result.AgentID != "main" {
t.Fatalf("expected agent id main, got %q", result.AgentID)
}
dispatch, ok := result.Metadata["dispatch"].(map[string]any)
if !ok || dispatch["mode"] != "cooperative" {
t.Fatalf("expected cooperative dispatch, got %#v", result.Metadata["dispatch"])
}
bridge, ok := result.Metadata["bridge"].(map[string]any)
if !ok || bridge["localTransport"] != "stdio-jsonrpc" {
t.Fatalf("expected stdio-jsonrpc bridge transport, got %#v", result.Metadata["bridge"])
}
provider, ok := result.Metadata["provider"].(map[string]any)
if !ok || provider["id"] != "codex" {
t.Fatalf("expected provider metadata for codex, got %#v", result.Metadata["provider"])
}
}

View File

@ -30,6 +30,7 @@ import '../runtime/assistant_artifacts.dart';
import '../runtime/desktop_thread_artifact_service.dart';
import '../runtime/go_agent_core_client.dart';
import '../runtime/go_agent_core_desktop_transport.dart';
import '../runtime/go_runtime_dispatch_desktop_client.dart';
import '../runtime/mode_switcher.dart';
import '../runtime/agent_registry.dart';
import '../runtime/multi_agent_orchestrator.dart';
@ -191,6 +192,12 @@ class AppController extends ChangeNotifier {
arisBundleRepositoryInternal =
arisBundleRepository ?? ArisBundleRepository();
goCoreLocatorInternal = GoCoreLocator();
runtimeCoordinatorInternal.attachDispatchResolver(
GoRuntimeDispatchDesktopClient(
acpClient: gatewayAcpClientInternal,
goCoreLocator: goCoreLocatorInternal,
),
);
goAgentCoreClientInternal =
goAgentCoreClient ??
GoAgentCoreDesktopTransport(

View File

@ -296,6 +296,7 @@ Future<void> ensureCodexGatewayRegistrationRuntimeInternal(
.buildGatewayDispatch(
buildCodeAgentNodeStateRuntimeInternal(controller),
);
final resolvedDispatch = await dispatch;
await controller.codeAgentBridgeRegistryInternal.register(
agentType: 'code-agent-bridge',
name: 'XWorkmate Codex Bridge',
@ -316,7 +317,7 @@ Future<void> ensureCodexGatewayRegistrationRuntimeInternal(
),
],
metadata: <String, dynamic>{
...dispatch.metadata,
...resolvedDispatch.metadata,
'providerId': 'codex',
'runtimeMode': controller.effectiveCodeAgentRuntimeMode.name,
'gatewayMode': bridgeGatewayModeRuntimeInternal(controller).name,

View File

@ -282,7 +282,9 @@ extension AppControllerDesktopThreadActions on AppController {
final sessionKey = normalizedAssistantSessionKeyInternal(
currentSessionKey,
);
final userText = message.trim().isEmpty ? 'See attached.' : message.trim();
final userText = message.trim().isEmpty
? 'See attached.'
: message.trim();
appendLocalSessionMessageInternal(
sessionKey,
GatewayChatMessage(
@ -302,17 +304,17 @@ extension AppControllerDesktopThreadActions on AppController {
recomputeTasksInternal();
notifyIfActiveInternal();
try {
final dispatch = codeAgentNodeOrchestratorInternal.buildGatewayDispatch(
buildCodeAgentNodeStateInternal(),
);
final dispatch = await codeAgentNodeOrchestratorInternal
.buildGatewayDispatch(buildCodeAgentNodeStateInternal());
final result = await goAgentCoreClientInternal.executeSession(
GoAgentCoreSessionRequest(
sessionId: sessionKey,
threadId: sessionKey,
target: assistantExecutionTargetForSession(sessionKey),
prompt: message,
workingDirectory:
assistantWorkspacePathForSession(sessionKey).trim(),
workingDirectory: assistantWorkspacePathForSession(
sessionKey,
).trim(),
model: assistantModelForSession(sessionKey),
thinking: thinking,
selectedSkills: selectedSkillLabels,

View File

@ -42,9 +42,41 @@ class CodeAgentNodeOrchestrator {
final RuntimeCoordinator _runtimeCoordinator;
CodeAgentGatewayDispatch buildGatewayDispatch(CodeAgentNodeState state) {
Future<CodeAgentGatewayDispatch> buildGatewayDispatch(
CodeAgentNodeState state,
) async {
final resolver = _runtimeCoordinator.dispatchResolver;
if (resolver != null) {
final resolution = await resolver.resolveGatewayDispatch(
providers: _runtimeCoordinator.externalCodeAgents,
preferredProviderId: state.preferredProviderId,
requiredCapabilities: const <String>['gateway-bridge'],
nodeState: <String, dynamic>{
'selectedAgentId': state.selectedAgentId,
'gatewayConnected': state.gatewayConnected,
'executionTarget': state.executionTarget.promptValue,
'runtimeMode': state.runtimeMode.name,
'bridgeEnabled': state.bridgeEnabled,
'bridgeState': state.bridgeState,
'resolvedCodexCliPath': state.resolvedCodexCliPath?.trim() ?? '',
'configuredCodexCliPath': state.configuredCodexCliPath.trim(),
},
nodeInfo: const <String, dynamic>{
'id': 'xworkmate-app',
'name': kSystemAppName,
'version': kAppVersion,
},
);
if (resolution.metadata.isNotEmpty) {
return CodeAgentGatewayDispatch(
agentId: resolution.agentId,
metadata: resolution.metadata,
);
}
}
final provider = state.bridgeEnabled
? _runtimeCoordinator.selectExternalCodeAgent(
? await _runtimeCoordinator.selectExternalCodeAgent(
preferredProviderId: state.preferredProviderId,
requiredCapabilities: const <String>['gateway-bridge'],
)

View File

@ -0,0 +1,217 @@
import 'dart:async';
import 'dart:io';
import 'embedded_agent_launch_policy.dart';
import 'gateway_acp_client.dart';
import 'go_core.dart';
import 'runtime_dispatch_resolver.dart';
import 'runtime_external_code_agents.dart';
typedef GoRuntimeDispatchProcessStarter =
Future<Process> Function(
String executable,
List<String> arguments, {
Map<String, String>? environment,
String? workingDirectory,
});
class GoRuntimeDispatchDesktopClient implements RuntimeDispatchResolver {
GoRuntimeDispatchDesktopClient({
GatewayAcpClient? acpClient,
GoCoreLocator? goCoreLocator,
GoRuntimeDispatchProcessStarter? processStarter,
}) : _acpClient = acpClient ?? GatewayAcpClient(endpointResolver: () => null),
_goCoreLocator = goCoreLocator ?? GoCoreLocator(),
_processStarter =
processStarter ??
((executable, arguments, {environment, workingDirectory}) {
return Process.start(
executable,
arguments,
environment: environment,
workingDirectory: workingDirectory,
);
});
final GatewayAcpClient _acpClient;
final GoCoreLocator _goCoreLocator;
final GoRuntimeDispatchProcessStarter _processStarter;
Process? _localProcess;
Uri? _localEndpoint;
Future<Uri?>? _localEndpointFuture;
@override
Future<String?> selectProviderId({
required List<ExternalCodeAgentProvider> providers,
String preferredProviderId = '',
Iterable<String> requiredCapabilities = const <String>[],
}) async {
final endpoint = await _ensureLocalEndpoint();
if (endpoint == null) {
return null;
}
final response = await _acpClient.request(
method: 'xworkmate.dispatch.resolve',
params: <String, dynamic>{
'preferredProviderId': preferredProviderId.trim(),
'requiredCapabilities': requiredCapabilities
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.toList(growable: false),
'providers': providers.map(_providerToJson).toList(growable: false),
},
endpointOverride: endpoint,
);
final result = _castMap(response['result']);
return result['providerId']?.toString().trim().isNotEmpty == true
? result['providerId'].toString().trim()
: null;
}
@override
Future<RuntimeDispatchResolution> resolveGatewayDispatch({
required List<ExternalCodeAgentProvider> providers,
required String preferredProviderId,
required Iterable<String> requiredCapabilities,
required Map<String, dynamic> nodeState,
required Map<String, dynamic> nodeInfo,
}) async {
final endpoint = await _ensureLocalEndpoint();
if (endpoint == null) {
return const RuntimeDispatchResolution(metadata: <String, dynamic>{});
}
final response = await _acpClient.request(
method: 'xworkmate.dispatch.resolve',
params: <String, dynamic>{
'preferredProviderId': preferredProviderId.trim(),
'requiredCapabilities': requiredCapabilities
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.toList(growable: false),
'providers': providers.map(_providerToJson).toList(growable: false),
'nodeState': nodeState,
'nodeInfo': nodeInfo,
},
endpointOverride: endpoint,
);
final result = _castMap(response['result']);
return RuntimeDispatchResolution(
agentId: result['agentId']?.toString().trim().isNotEmpty == true
? result['agentId'].toString().trim()
: null,
providerId: result['providerId']?.toString().trim().isNotEmpty == true
? result['providerId'].toString().trim()
: null,
metadata: _castMap(result['metadata']),
raw: result,
);
}
@override
Future<void> dispose() async {
final process = _localProcess;
_localProcess = null;
_localEndpoint = null;
_localEndpointFuture = null;
if (process != null) {
try {
process.kill();
} catch (_) {
// Best effort only.
}
}
await _acpClient.dispose();
}
Map<String, dynamic> _providerToJson(ExternalCodeAgentProvider provider) {
return <String, dynamic>{
'id': provider.id,
'name': provider.name,
'defaultArgs': provider.defaultArgs,
'capabilities': provider.capabilities,
};
}
Future<Uri?> _ensureLocalEndpoint() async {
if (_localEndpoint != null) {
return _localEndpoint;
}
final inFlight = _localEndpointFuture;
if (inFlight != null) {
return inFlight;
}
final next = _startLocalProcess();
_localEndpointFuture = next;
try {
_localEndpoint = await next;
return _localEndpoint;
} finally {
_localEndpointFuture = null;
}
}
Future<Uri?> _startLocalProcess() async {
if (shouldBlockEmbeddedAgentLaunch(
isAppleHost: Platform.isIOS || Platform.isMacOS,
)) {
return null;
}
final launch = await _goCoreLocator.locate();
if (launch == null) {
return null;
}
final reservedSocket = await ServerSocket.bind(
InternetAddress.loopbackIPv4,
0,
);
final port = reservedSocket.port;
await reservedSocket.close();
final listenAddress = '127.0.0.1:$port';
final process = await _processStarter(
launch.executable,
<String>[...launch.arguments, 'serve', '--listen', listenAddress],
environment: Platform.environment,
workingDirectory: launch.workingDirectory,
);
_localProcess = process;
unawaited(process.stdout.drain<void>());
unawaited(process.stderr.drain<void>());
final endpoint = Uri(scheme: 'http', host: '127.0.0.1', port: port);
final deadline = DateTime.now().add(const Duration(seconds: 8));
while (DateTime.now().isBefore(deadline)) {
if (_localProcess != process) {
break;
}
final exitCode = await process.exitCode.timeout(
const Duration(milliseconds: 20),
onTimeout: () => -1,
);
if (exitCode != -1) {
break;
}
try {
await _acpClient.request(
method: 'acp.capabilities',
params: const <String, dynamic>{},
endpointOverride: endpoint,
);
return endpoint;
} catch (_) {
await Future<void>.delayed(const Duration(milliseconds: 120));
}
}
await dispose();
return null;
}
Map<String, dynamic> _castMap(Object? value) {
if (value is Map<String, dynamic>) {
return value;
}
if (value is Map) {
return value.cast<String, dynamic>();
}
return const <String, dynamic>{};
}
}

View File

@ -7,43 +7,15 @@ import 'codex_config_bridge.dart';
import 'codex_runtime.dart';
import 'gateway_runtime.dart';
import 'mode_switcher.dart';
import 'runtime_dispatch_resolver.dart';
import 'runtime_external_code_agents.dart';
import 'runtime_models.dart';
export 'runtime_external_code_agents.dart';
/// Coordination state for the runtime.
enum CoordinatorState { disconnected, connecting, connected, ready, error }
/// Descriptor for additional external Code Agent CLI integrations.
enum ExternalAgentTransport { subprocess, websocketJsonRpc }
extension ExternalAgentTransportCopy on ExternalAgentTransport {
static ExternalAgentTransport fromJsonValue(String? value) {
return ExternalAgentTransport.values.firstWhere(
(item) => item.name == value,
orElse: () => ExternalAgentTransport.subprocess,
);
}
}
class ExternalCodeAgentProvider {
const ExternalCodeAgentProvider({
required this.id,
required this.name,
required this.command,
this.transport = ExternalAgentTransport.subprocess,
this.endpoint = '',
this.defaultArgs = const <String>[],
this.capabilities = const <String>[],
});
final String id;
final String name;
final String command;
final ExternalAgentTransport transport;
final String endpoint;
final List<String> defaultArgs;
final List<String> capabilities;
}
/// Unified runtime coordinator for managing Gateway and Code Agent runtime.
///
/// This class coordinates:
@ -59,6 +31,7 @@ class RuntimeCoordinator extends ChangeNotifier {
final Map<String, ExternalCodeAgentProvider> _externalCodeAgents =
<String, ExternalCodeAgentProvider>{};
RuntimeDispatchResolver? _dispatchResolver;
CoordinatorState _state = CoordinatorState.disconnected;
String? _lastError;
@ -95,8 +68,10 @@ class RuntimeCoordinator extends ChangeNotifier {
required this.codex,
CodexConfigBridge? configBridge,
ModeSwitcher? modeSwitcher,
RuntimeDispatchResolver? dispatchResolver,
}) : configBridge = configBridge ?? CodexConfigBridge(),
modeSwitcher = modeSwitcher ?? ModeSwitcher(gateway);
modeSwitcher = modeSwitcher ?? ModeSwitcher(gateway),
_dispatchResolver = dispatchResolver;
/// Register an external Code Agent CLI provider descriptor.
///
@ -166,7 +141,38 @@ class RuntimeCoordinator extends ChangeNotifier {
/// Scheduling policy is intentionally simple for phase 1:
/// - honor preferred provider when it satisfies capability requirements
/// - otherwise pick the first discovered provider in deterministic id order
ExternalCodeAgentProvider? selectExternalCodeAgent({
Future<ExternalCodeAgentProvider?> selectExternalCodeAgent({
String? preferredProviderId,
Iterable<String> requiredCapabilities = const <String>[],
}) async {
final available = externalCodeAgents;
final resolver = _dispatchResolver;
if (resolver != null && available.isNotEmpty) {
final selectedProviderId = await resolver.selectProviderId(
providers: available,
preferredProviderId: preferredProviderId?.trim() ?? '',
requiredCapabilities: requiredCapabilities,
);
if (selectedProviderId != null) {
final selected = _externalCodeAgents[selectedProviderId];
if (selected != null) {
return selected;
}
}
}
return _selectExternalCodeAgentLocally(
preferredProviderId: preferredProviderId,
requiredCapabilities: requiredCapabilities,
);
}
RuntimeDispatchResolver? get dispatchResolver => _dispatchResolver;
void attachDispatchResolver(RuntimeDispatchResolver resolver) {
_dispatchResolver = resolver;
}
ExternalCodeAgentProvider? _selectExternalCodeAgentLocally({
String? preferredProviderId,
Iterable<String> requiredCapabilities = const <String>[],
}) {
@ -393,6 +399,11 @@ class RuntimeCoordinator extends ChangeNotifier {
@override
void dispose() {
final resolver = _dispatchResolver;
_dispatchResolver = null;
if (resolver != null) {
unawaited(resolver.dispose());
}
shutdown();
super.dispose();
}

View File

@ -0,0 +1,33 @@
import 'runtime_external_code_agents.dart';
class RuntimeDispatchResolution {
const RuntimeDispatchResolution({
required this.metadata,
this.agentId,
this.providerId,
this.raw = const <String, dynamic>{},
});
final String? agentId;
final String? providerId;
final Map<String, dynamic> metadata;
final Map<String, dynamic> raw;
}
abstract class RuntimeDispatchResolver {
Future<String?> selectProviderId({
required List<ExternalCodeAgentProvider> providers,
String preferredProviderId = '',
Iterable<String> requiredCapabilities = const <String>[],
});
Future<RuntimeDispatchResolution> resolveGatewayDispatch({
required List<ExternalCodeAgentProvider> providers,
required String preferredProviderId,
required Iterable<String> requiredCapabilities,
required Map<String, dynamic> nodeState,
required Map<String, dynamic> nodeInfo,
});
Future<void> dispose();
}

View File

@ -0,0 +1,30 @@
enum ExternalAgentTransport { subprocess, websocketJsonRpc }
extension ExternalAgentTransportCopy on ExternalAgentTransport {
static ExternalAgentTransport fromJsonValue(String? value) {
return ExternalAgentTransport.values.firstWhere(
(item) => item.name == value,
orElse: () => ExternalAgentTransport.subprocess,
);
}
}
class ExternalCodeAgentProvider {
const ExternalCodeAgentProvider({
required this.id,
required this.name,
required this.command,
this.transport = ExternalAgentTransport.subprocess,
this.endpoint = '',
this.defaultArgs = const <String>[],
this.capabilities = const <String>[],
});
final String id;
final String name;
final String command;
final ExternalAgentTransport transport;
final String endpoint;
final List<String> defaultArgs;
final List<String> capabilities;
}

View File

@ -6,6 +6,7 @@ import 'package:xworkmate/runtime/code_agent_node_orchestrator.dart';
import 'package:xworkmate/runtime/codex_runtime.dart';
import 'package:xworkmate/runtime/device_identity_store.dart';
import 'package:xworkmate/runtime/gateway_runtime.dart';
import 'package:xworkmate/runtime/runtime_dispatch_resolver.dart';
import 'package:xworkmate/runtime/runtime_coordinator.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
@ -43,6 +44,35 @@ class _FakeGatewayRuntime extends GatewayRuntime {
class _FakeCodexRuntime extends CodexRuntime {}
class _FakeDispatchResolver implements RuntimeDispatchResolver {
_FakeDispatchResolver(this.resolution);
final RuntimeDispatchResolution resolution;
@override
Future<String?> selectProviderId({
required List<ExternalCodeAgentProvider> providers,
String preferredProviderId = '',
Iterable<String> requiredCapabilities = const <String>[],
}) async {
return resolution.providerId;
}
@override
Future<RuntimeDispatchResolution> resolveGatewayDispatch({
required List<ExternalCodeAgentProvider> providers,
required String preferredProviderId,
required Iterable<String> requiredCapabilities,
required Map<String, dynamic> nodeState,
required Map<String, dynamic> nodeInfo,
}) async {
return resolution;
}
@override
Future<void> dispose() async {}
}
void main() {
group('CodeAgentNodeOrchestrator', () {
late RuntimeCoordinator coordinator;
@ -56,7 +86,7 @@ void main() {
orchestrator = CodeAgentNodeOrchestrator(coordinator);
});
test('builds cooperative node metadata for an external provider', () {
test('builds cooperative node metadata for an external provider', () async {
coordinator.registerExternalCodeAgent(
const ExternalCodeAgentProvider(
id: 'codex',
@ -67,7 +97,7 @@ void main() {
),
);
final dispatch = orchestrator.buildGatewayDispatch(
final dispatch = await orchestrator.buildGatewayDispatch(
const CodeAgentNodeState(
selectedAgentId: 'main',
gatewayConnected: true,
@ -102,7 +132,7 @@ void main() {
);
});
test('omits provider metadata when bridge is disabled', () {
test('omits provider metadata when bridge is disabled', () async {
coordinator.registerExternalCodeAgent(
const ExternalCodeAgentProvider(
id: 'codex',
@ -112,7 +142,7 @@ void main() {
),
);
final dispatch = orchestrator.buildGatewayDispatch(
final dispatch = await orchestrator.buildGatewayDispatch(
const CodeAgentNodeState(
selectedAgentId: '',
gatewayConnected: true,
@ -131,5 +161,42 @@ void main() {
);
expect(dispatch.metadata.containsKey('provider'), isFalse);
});
test('uses dispatch resolver metadata when available', () async {
coordinator.attachDispatchResolver(
_FakeDispatchResolver(
const RuntimeDispatchResolution(
agentId: 'main',
providerId: 'codex',
metadata: <String, dynamic>{
'dispatch': <String, dynamic>{
'mode': 'cooperative',
'executionTarget': 'local',
},
'provider': <String, dynamic>{'id': 'codex'},
},
),
),
);
final dispatch = await orchestrator.buildGatewayDispatch(
const CodeAgentNodeState(
selectedAgentId: 'main',
gatewayConnected: true,
executionTarget: AssistantExecutionTarget.local,
runtimeMode: CodeAgentRuntimeMode.externalCli,
bridgeEnabled: true,
bridgeState: 'registered',
preferredProviderId: 'codex',
),
);
expect(dispatch.agentId, 'main');
expect(
dispatch.metadata['dispatch'],
containsPair('mode', 'cooperative'),
);
expect(dispatch.metadata['provider'], containsPair('id', 'codex'));
});
});
}

View File

@ -221,7 +221,7 @@ void main() {
test(
'registerExternalCodeAgent supports capability-filtered discovery',
() {
() async {
coordinator.registerExternalCodeAgent(
const ExternalCodeAgentProvider(
id: 'opencode',
@ -248,12 +248,10 @@ void main() {
containsAll(<String>['gemini', 'opencode']),
);
expect(
coordinator
.selectExternalCodeAgent(
preferredProviderId: 'opencode',
requiredCapabilities: const <String>['review'],
)
?.id,
(await coordinator.selectExternalCodeAgent(
preferredProviderId: 'opencode',
requiredCapabilities: const <String>['review'],
))?.id,
equals('opencode'),
);
},

View File

@ -8,6 +8,7 @@ import 'package:xworkmate/runtime/codex_runtime.dart';
import 'package:xworkmate/runtime/device_identity_store.dart';
import 'package:xworkmate/runtime/gateway_runtime.dart';
import 'package:xworkmate/runtime/mode_switcher.dart';
import 'package:xworkmate/runtime/runtime_dispatch_resolver.dart';
import 'package:xworkmate/runtime/runtime_coordinator.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
@ -155,6 +156,38 @@ class _FakeModeSwitcher extends ModeSwitcher {
}
}
class _FakeDispatchResolver implements RuntimeDispatchResolver {
_FakeDispatchResolver({this.selectedProviderId});
final String? selectedProviderId;
int selectCalls = 0;
@override
Future<String?> selectProviderId({
required List<ExternalCodeAgentProvider> providers,
String preferredProviderId = '',
Iterable<String> requiredCapabilities = const <String>[],
}) async {
selectCalls += 1;
return selectedProviderId;
}
@override
Future<RuntimeDispatchResolution> resolveGatewayDispatch({
required List<ExternalCodeAgentProvider> providers,
required String preferredProviderId,
required Iterable<String> requiredCapabilities,
required Map<String, dynamic> nodeState,
required Map<String, dynamic> nodeInfo,
}) async {
return const RuntimeDispatchResolution(metadata: <String, dynamic>{});
}
@override
Future<void> dispose() async {}
}
void main() {
group('RuntimeCoordinator runtime modes', () {
late _FakeGatewayRuntime gateway;
@ -316,7 +349,7 @@ void main() {
test(
'selects provider by preferred id then falls back deterministically',
() {
() async {
coordinator.registerExternalCodeAgent(
const ExternalCodeAgentProvider(
id: 'codex',
@ -334,13 +367,13 @@ void main() {
),
);
final preferred = coordinator.selectExternalCodeAgent(
final preferred = await coordinator.selectExternalCodeAgent(
preferredProviderId: 'qwen-cli',
requiredCapabilities: const <String>['chat'],
);
expect(preferred?.id, 'qwen-cli');
final fallback = coordinator.selectExternalCodeAgent(
final fallback = await coordinator.selectExternalCodeAgent(
preferredProviderId: 'qwen-cli',
requiredCapabilities: const <String>['code-edit'],
);
@ -348,20 +381,55 @@ void main() {
},
);
test('returns null when no provider satisfies required capabilities', () {
coordinator.registerExternalCodeAgent(
const ExternalCodeAgentProvider(
id: 'qwen-cli',
name: 'Qwen CLI',
command: 'qwen',
capabilities: <String>['chat'],
),
);
test(
'returns null when no provider satisfies required capabilities',
() async {
coordinator.registerExternalCodeAgent(
const ExternalCodeAgentProvider(
id: 'qwen-cli',
name: 'Qwen CLI',
command: 'qwen',
capabilities: <String>['chat'],
),
);
final selected = coordinator.selectExternalCodeAgent(
requiredCapabilities: const <String>['memory-sync'],
);
expect(selected, isNull);
});
final selected = await coordinator.selectExternalCodeAgent(
requiredCapabilities: const <String>['memory-sync'],
);
expect(selected, isNull);
},
);
test(
'uses dispatch resolver when attached for provider selection',
() async {
final resolver = _FakeDispatchResolver(selectedProviderId: 'qwen-cli');
coordinator.attachDispatchResolver(resolver);
coordinator.registerExternalCodeAgent(
const ExternalCodeAgentProvider(
id: 'codex',
name: 'Codex CLI',
command: 'codex',
capabilities: <String>['chat', 'gateway-bridge'],
),
);
coordinator.registerExternalCodeAgent(
const ExternalCodeAgentProvider(
id: 'qwen-cli',
name: 'Qwen CLI',
command: 'qwen',
capabilities: <String>['chat', 'gateway-bridge'],
),
);
final selected = await coordinator.selectExternalCodeAgent(
preferredProviderId: 'codex',
requiredCapabilities: const <String>['gateway-bridge'],
);
expect(resolver.selectCalls, 1);
expect(selected?.id, 'qwen-cli');
},
);
});
}