feat(runtime): add built-in/external codex modes and external agent provider registry

This commit is contained in:
Haitao Pan 2026-03-14 09:31:23 +08:00
parent c062ed1661
commit 6c12439168
3 changed files with 389 additions and 74 deletions

View File

@ -1,6 +1,18 @@
# Codex CLI 集成任务计划 - 已完成
> 目标:将 Codex CLI 集成到 XWorkmate 作为内置 Code Agent支持 AI Gateway 模型桥接和 OpenClaw Gateway 在线/离线模式。
## 目标(产品层)
1. 将 Codex CLI 集成到 XWorkmate 作为内置 Code Agent支持 AI Gateway 模型桥接和 OpenClaw Gateway 在线/离线模式。
2. 提供可选设置:将 Codex CLI 以**外部依赖**方式接入 XWorkmate支持同一套网关桥接与模式切换能力。
3. 预留其他外部 Code Agent CLI 的接入能力(统一注册、能力发现与调度)。
## 当前实现状态(对齐当前代码)
- 当前落地形态为**外部进程接入**:由 `CodexRuntime.startStdio()` 启动外部 `codex` 进程。
- Codex 可执行文件通过 `findCodexBinary()``CODEX_PATH`、常见安装目录与 `PATH` 中查找。
- 用户需预先安装 Codex CLI例如 `npm i -g @openai/codex`)。
- 运行时由 Dart `Process``_process: Process?`)进行生命周期管理。
- 通过 `AgentRegistry`、`RuntimeCoordinator` 已具备多 Agent 扩展的基础结构,可继续接入其他外部 CLI。
## 架构概览
@ -33,8 +45,8 @@
┌─────────────────────────────────────────────────────────────────────┐
│ OpenClaw Gateway │
│ .env: AI-Gateway-Url = https://api.svc.plus/v1
│ .env: AI-Gateway-apiKey = ***REMOVED-CREDENTIAL***
│ .env: AI-Gateway-Url = <development/test only>
│ .env: AI-Gateway-apiKey = <do not commit real secrets>
│ │
│ 模式切换: │
│ - Local: 127.0.0.1:18789 (本地代理) │

View File

@ -3,11 +3,11 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'gateway_runtime.dart';
import 'runtime_models.dart';
import 'codex_runtime.dart';
import 'codex_config_bridge.dart';
import 'codex_runtime.dart';
import 'gateway_runtime.dart';
import 'mode_switcher.dart';
import 'runtime_models.dart';
/// Coordination state for the runtime.
enum CoordinatorState {
@ -18,56 +18,129 @@ enum CoordinatorState {
error,
}
/// Unified runtime coordinator for managing Gateway and Codex.
///
/// Code agent runtime mode for Codex integration.
///
/// - [builtIn]: XWorkmate internal runtime path (no external codex process).
/// - [externalCli]: Launch external `codex` executable via stdio bridge.
enum CodeAgentRuntimeMode {
builtIn,
externalCli,
}
/// Descriptor for additional external Code Agent CLI integrations.
class ExternalCodeAgentProvider {
const ExternalCodeAgentProvider({
required this.id,
required this.name,
required this.command,
this.defaultArgs = const <String>[],
this.capabilities = const <String>[],
});
final String id;
final String name;
final String command;
final List<String> defaultArgs;
final List<String> capabilities;
}
/// Unified runtime coordinator for managing Gateway and Code Agent runtime.
///
/// This class coordinates:
/// - GatewayRuntime: Connection to OpenClaw Gateway
/// - CodexRuntime: Local Codex CLI process
/// - CodexRuntime: Code agent runtime (external CLI or built-in runtime mode)
/// - ModeSwitcher: Local/Remote/Offline mode switching
/// - Agent communication and message routing
/// - Extensible external code-agent provider descriptors for future CLIs
class RuntimeCoordinator extends ChangeNotifier {
final GatewayRuntime gateway;
final CodexRuntime codex;
final CodexConfigBridge configBridge;
final ModeSwitcher modeSwitcher;
final Map<String, ExternalCodeAgentProvider> _externalCodeAgents =
<String, ExternalCodeAgentProvider>{};
CoordinatorState _state = CoordinatorState.disconnected;
String? _lastError;
String? _codexPath;
String? _cwd;
CodeAgentRuntimeMode _runtimeMode = CodeAgentRuntimeMode.externalCli;
CoordinatorState get state => _state;
String? get lastError => _lastError;
bool get isReady => _state == CoordinatorState.ready;
/// Current code-agent runtime mode.
CodeAgentRuntimeMode get runtimeMode => _runtimeMode;
/// Current gateway mode.
GatewayMode get currentMode => modeSwitcher.currentMode;
/// Current capabilities based on mode.
ModeCapabilities get capabilities => modeSwitcher.capabilities;
/// Whether cloud memory is available.
bool get hasCloudMemory => modeSwitcher.capabilities.hasCloudMemory;
/// Whether task queue is available.
bool get hasTaskQueue => modeSwitcher.capabilities.hasTaskQueue;
/// Registered external code agent providers (future extension point).
List<ExternalCodeAgentProvider> get externalCodeAgents =>
List<ExternalCodeAgentProvider>.unmodifiable(_externalCodeAgents.values);
RuntimeCoordinator({
required this.gateway,
required this.codex,
CodexConfigBridge? configBridge,
ModeSwitcher? modeSwitcher,
}) : configBridge = configBridge ?? CodexConfigBridge(),
}) : configBridge = configBridge ?? CodexConfigBridge(),
modeSwitcher = modeSwitcher ?? ModeSwitcher(gateway);
/// Register an external Code Agent CLI provider descriptor.
///
/// This reserves integration slots for additional CLI-based agents while
/// keeping invocation, capability discovery, and scheduling metadata unified.
void registerExternalCodeAgent(ExternalCodeAgentProvider provider) {
final normalizedId = provider.id.trim();
if (normalizedId.isEmpty) {
throw ArgumentError.value(provider.id, 'provider.id', 'Cannot be empty');
}
_externalCodeAgents[normalizedId] = ExternalCodeAgentProvider(
id: normalizedId,
name: provider.name,
command: provider.command,
defaultArgs: provider.defaultArgs,
capabilities: provider.capabilities,
);
notifyListeners();
}
/// Remove an external Code Agent CLI provider descriptor.
bool unregisterExternalCodeAgent(String providerId) {
final removed = _externalCodeAgents.remove(providerId.trim()) != null;
if (removed) {
notifyListeners();
}
return removed;
}
/// Check whether an external provider is known.
bool hasExternalCodeAgent(String providerId) {
return _externalCodeAgents.containsKey(providerId.trim());
}
/// Initialize the coordinator with Gateway profile and Codex.
Future<void> initialize({
GatewayConnectionProfile? profile,
String? codexPath,
String? workingDirectory,
GatewayMode preferredMode = GatewayMode.remote,
CodeAgentRuntimeMode runtimeMode = CodeAgentRuntimeMode.externalCli,
}) async {
_state = CoordinatorState.connecting;
_runtimeMode = runtimeMode;
_codexPath = codexPath;
_cwd = workingDirectory ?? Directory.current.path;
_lastError = null;
@ -75,41 +148,15 @@ class RuntimeCoordinator extends ChangeNotifier {
try {
// Step 1: Connect to Gateway based on preferred mode
ModeSwitchResult result;
switch (preferredMode) {
case GatewayMode.local:
result = await modeSwitcher.switchToLocal();
break;
case GatewayMode.remote:
result = await modeSwitcher.switchToRemote();
break;
case GatewayMode.offline:
result = await modeSwitcher.switchToOffline();
break;
}
final result = await _switchMode(preferredMode);
if (!result.success) {
throw StateError('Failed to connect: ${result.error}');
}
// Step 2: Find and start Codex (if not in offline mode)
// Step 2: Start code-agent runtime according to selected mode.
if (preferredMode != GatewayMode.offline) {
final resolvedCodexPath = codexPath ?? await codex.findCodexBinary();
if (resolvedCodexPath == null) {
// Fall back to offline mode if Codex not found
await modeSwitcher.switchToOffline();
} else {
try {
await codex.startStdio(
codexPath: resolvedCodexPath,
cwd: _cwd,
);
} catch (e) {
// Continue without Codex in offline mode
await modeSwitcher.switchToOffline();
}
}
await _ensureCodeAgentRuntime();
}
_state = CoordinatorState.ready;
@ -127,8 +174,10 @@ class RuntimeCoordinator extends ChangeNotifier {
String? codexPath,
String? workingDirectory,
bool preferRemote = true,
CodeAgentRuntimeMode runtimeMode = CodeAgentRuntimeMode.externalCli,
}) async {
_state = CoordinatorState.connecting;
_runtimeMode = runtimeMode;
_codexPath = codexPath;
_cwd = workingDirectory ?? Directory.current.path;
_lastError = null;
@ -142,20 +191,8 @@ class RuntimeCoordinator extends ChangeNotifier {
throw StateError('No available connection mode: ${result.error}');
}
// Start Codex if available
if (result.mode != GatewayMode.offline) {
final resolvedCodexPath = codexPath ?? await codex.findCodexBinary();
if (resolvedCodexPath != null) {
try {
await codex.startStdio(
codexPath: resolvedCodexPath,
cwd: _cwd,
);
} catch (e) {
// Continue in offline mode
await modeSwitcher.switchToOffline();
}
}
await _ensureCodeAgentRuntime();
}
_state = CoordinatorState.ready;
@ -181,19 +218,7 @@ class RuntimeCoordinator extends ChangeNotifier {
/// Switch to a different mode.
Future<void> switchMode(GatewayMode newMode) async {
ModeSwitchResult result;
switch (newMode) {
case GatewayMode.local:
result = await modeSwitcher.switchToLocal();
break;
case GatewayMode.remote:
result = await modeSwitcher.switchToRemote();
break;
case GatewayMode.offline:
result = await modeSwitcher.switchToOffline();
break;
}
final result = await _switchMode(newMode);
if (!result.success) {
throw StateError('Failed to switch mode: ${result.error}');
@ -223,16 +248,16 @@ class RuntimeCoordinator extends ChangeNotifier {
/// Get available modes based on current state.
List<GatewayMode> getAvailableModes() {
final modes = <GatewayMode>[];
// Always can try local mode
modes.add(GatewayMode.local);
// Remote mode requires network
modes.add(GatewayMode.remote);
// Offline mode is always available
modes.add(GatewayMode.offline);
return modes;
}
@ -258,6 +283,41 @@ class RuntimeCoordinator extends ChangeNotifier {
]);
}
Future<ModeSwitchResult> _switchMode(GatewayMode mode) {
switch (mode) {
case GatewayMode.local:
return modeSwitcher.switchToLocal();
case GatewayMode.remote:
return modeSwitcher.switchToRemote();
case GatewayMode.offline:
return modeSwitcher.switchToOffline();
}
}
Future<void> _ensureCodeAgentRuntime() async {
if (_runtimeMode == CodeAgentRuntimeMode.builtIn) {
// Built-in mode: runtime is assumed internal, no external process needed.
return;
}
final resolvedCodexPath = _codexPath ?? await codex.findCodexBinary();
if (resolvedCodexPath == null) {
// Fall back to offline mode if external Codex CLI is unavailable.
await modeSwitcher.switchToOffline();
return;
}
try {
await codex.startStdio(
codexPath: resolvedCodexPath,
cwd: _cwd,
);
} catch (_) {
// Continue without external code agent in offline mode.
await modeSwitcher.switchToOffline();
}
}
@override
void dispose() {
shutdown();

View File

@ -0,0 +1,243 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/runtime/codex_runtime.dart';
import 'package:xworkmate/runtime/gateway_runtime.dart';
import 'package:xworkmate/runtime/mode_switcher.dart';
import 'package:xworkmate/runtime/runtime_coordinator.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
class _FakeGatewayRuntime extends ChangeNotifier implements GatewayRuntime {
GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial();
final StreamController<GatewayPushEvent> _events =
StreamController<GatewayPushEvent>.broadcast();
@override
bool get isConnected => _snapshot.status == RuntimeConnectionStatus.connected;
@override
GatewayConnectionSnapshot get snapshot => _snapshot;
@override
Stream<GatewayPushEvent> get events => _events.stream;
@override
Future<void> initialize() async {}
@override
Future<void> connectProfile(
GatewayConnectionProfile profile, {
String authTokenOverride = '',
String authPasswordOverride = '',
}) async {
_snapshot = GatewayConnectionSnapshot(
profile: profile,
status: RuntimeConnectionStatus.connected,
);
}
@override
Future<void> disconnect() async {
_snapshot = GatewayConnectionSnapshot(
profile: _snapshot.profile,
status: RuntimeConnectionStatus.offline,
);
}
@override
Future<Map<String, dynamic>> request(
String method, {
Map<String, dynamic> params = const {},
Duration timeout = const Duration(seconds: 30),
}) async {
return <String, dynamic>{};
}
@override
void clearLogs() {}
@override
List<RuntimeLogEntry> get logs => const <RuntimeLogEntry>[];
@override
List<RuntimeLogEntry> get logsForTest => const <RuntimeLogEntry>[];
@override
void addRuntimeLogForTest({
required String level,
required String category,
required String message,
}) {}
}
class _FakeCodexRuntime extends CodexRuntime {
bool findCalled = false;
bool startCalled = false;
String? findResult;
@override
Future<String?> findCodexBinary() async {
findCalled = true;
return findResult;
}
@override
Future<void> startStdio({
required String codexPath,
String? cwd,
CodexSandboxMode sandbox = CodexSandboxMode.workspaceWrite,
CodexApprovalPolicy approval = CodexApprovalPolicy.suggest,
List<String> extraArgs = const <String>[],
}) async {
startCalled = true;
}
@override
Future<void> stop() async {}
}
class _FakeModeSwitcher extends ModeSwitcher {
_FakeModeSwitcher(super.gateway);
GatewayMode mode = GatewayMode.offline;
ModeCapabilities modeCapabilities = ModeCapabilities.offline;
bool offlineSwitchCalled = false;
@override
GatewayMode get currentMode => mode;
@override
ModeCapabilities get capabilities => modeCapabilities;
@override
Future<ModeSwitchResult> switchToLocal({
String host = '127.0.0.1',
int port = 18789,
String? token,
}) async {
mode = GatewayMode.local;
modeCapabilities = ModeCapabilities.local;
return ModeSwitchResult(success: true, mode: GatewayMode.local);
}
@override
Future<ModeSwitchResult> switchToRemote({
String host = 'openclaw.svc.plus',
int port = 443,
bool tls = true,
String? token,
}) async {
mode = GatewayMode.remote;
modeCapabilities = ModeCapabilities.remote;
return ModeSwitchResult(success: true, mode: GatewayMode.remote);
}
@override
Future<ModeSwitchResult> switchToOffline() async {
offlineSwitchCalled = true;
mode = GatewayMode.offline;
modeCapabilities = ModeCapabilities.offline;
return ModeSwitchResult(success: true, mode: GatewayMode.offline);
}
@override
Future<ModeSwitchResult> autoSelect({bool preferRemote = true}) async {
return preferRemote ? switchToRemote() : switchToLocal();
}
}
void main() {
group('RuntimeCoordinator runtime modes', () {
late _FakeGatewayRuntime gateway;
late _FakeCodexRuntime codex;
late _FakeModeSwitcher modeSwitcher;
late RuntimeCoordinator coordinator;
setUp(() {
gateway = _FakeGatewayRuntime();
codex = _FakeCodexRuntime();
modeSwitcher = _FakeModeSwitcher(gateway);
coordinator = RuntimeCoordinator(
gateway: gateway,
codex: codex,
modeSwitcher: modeSwitcher,
);
});
test('built-in mode does not resolve or start external codex process', () async {
codex.findResult = '/usr/local/bin/codex';
await coordinator.initialize(
preferredMode: GatewayMode.remote,
runtimeMode: CodeAgentRuntimeMode.builtIn,
);
expect(coordinator.runtimeMode, CodeAgentRuntimeMode.builtIn);
expect(codex.findCalled, isFalse);
expect(codex.startCalled, isFalse);
expect(coordinator.isReady, isTrue);
});
test('external mode resolves and starts codex process when binary exists', () async {
codex.findResult = '/usr/local/bin/codex';
await coordinator.initialize(
preferredMode: GatewayMode.remote,
runtimeMode: CodeAgentRuntimeMode.externalCli,
);
expect(coordinator.runtimeMode, CodeAgentRuntimeMode.externalCli);
expect(codex.findCalled, isTrue);
expect(codex.startCalled, isTrue);
expect(modeSwitcher.currentMode, GatewayMode.remote);
});
test('external mode falls back to offline when codex binary missing', () async {
codex.findResult = null;
await coordinator.initialize(
preferredMode: GatewayMode.remote,
runtimeMode: CodeAgentRuntimeMode.externalCli,
);
expect(codex.findCalled, isTrue);
expect(codex.startCalled, isFalse);
expect(modeSwitcher.offlineSwitchCalled, isTrue);
expect(modeSwitcher.currentMode, GatewayMode.offline);
});
});
group('RuntimeCoordinator external provider registry', () {
late RuntimeCoordinator coordinator;
setUp(() {
final gateway = _FakeGatewayRuntime();
final codex = _FakeCodexRuntime();
coordinator = RuntimeCoordinator(
gateway: gateway,
codex: codex,
modeSwitcher: _FakeModeSwitcher(gateway),
);
});
test('registers and unregisters external code agent providers', () {
const provider = ExternalCodeAgentProvider(
id: 'qwen-cli',
name: 'Qwen CLI',
command: 'qwen',
defaultArgs: <String>['serve'],
capabilities: <String>['chat', 'code-edit'],
);
coordinator.registerExternalCodeAgent(provider);
expect(coordinator.hasExternalCodeAgent('qwen-cli'), isTrue);
expect(coordinator.externalCodeAgents, hasLength(1));
final removed = coordinator.unregisterExternalCodeAgent('qwen-cli');
expect(removed, isTrue);
expect(coordinator.externalCodeAgents, isEmpty);
});
});
}