refactor(desktop): route assistant execution through gateway ACP

This commit is contained in:
Haitao Pan 2026-03-23 14:17:09 +08:00
parent 659e187eb8
commit ded87aa63f
6 changed files with 1540 additions and 928 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,845 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'multi_agent_orchestrator.dart';
import 'runtime_models.dart';
class GatewayAcpException implements Exception {
const GatewayAcpException(this.message, {this.code, this.details});
final String message;
final String? code;
final Object? details;
@override
String toString() => code == null ? message : '$code: $message';
}
class GatewayAcpCapabilities {
const GatewayAcpCapabilities({
required this.singleAgent,
required this.multiAgent,
required this.providers,
required this.raw,
});
const GatewayAcpCapabilities.empty()
: singleAgent = false,
multiAgent = false,
providers = const <SingleAgentProvider>{},
raw = const <String, dynamic>{};
final bool singleAgent;
final bool multiAgent;
final Set<SingleAgentProvider> providers;
final Map<String, dynamic> raw;
}
class GatewayAcpSessionUpdate {
const GatewayAcpSessionUpdate({
required this.method,
required this.sessionId,
required this.threadId,
required this.turnId,
required this.type,
required this.textDelta,
required this.sequence,
required this.payload,
});
final String method;
final String sessionId;
final String threadId;
final String turnId;
final String type;
final String textDelta;
final int? sequence;
final Map<String, dynamic> payload;
}
class GatewayAcpSingleAgentRequest {
const GatewayAcpSingleAgentRequest({
required this.sessionId,
required this.threadId,
required this.provider,
required this.prompt,
required this.model,
required this.workingDirectory,
required this.attachments,
required this.selectedSkills,
required this.aiGatewayBaseUrl,
required this.aiGatewayApiKey,
required this.resumeSession,
});
final String sessionId;
final String threadId;
final SingleAgentProvider provider;
final String prompt;
final String model;
final String workingDirectory;
final List<CollaborationAttachment> attachments;
final List<String> selectedSkills;
final String aiGatewayBaseUrl;
final String aiGatewayApiKey;
final bool resumeSession;
}
class GatewayAcpSingleAgentResult {
const GatewayAcpSingleAgentResult({
required this.success,
required this.output,
required this.errorMessage,
required this.turnId,
required this.raw,
});
final bool success;
final String output;
final String errorMessage;
final String turnId;
final Map<String, dynamic> raw;
}
class GatewayAcpMultiAgentRequest {
const GatewayAcpMultiAgentRequest({
required this.sessionId,
required this.threadId,
required this.prompt,
required this.workingDirectory,
required this.attachments,
required this.selectedSkills,
required this.aiGatewayBaseUrl,
required this.aiGatewayApiKey,
required this.resumeSession,
});
final String sessionId;
final String threadId;
final String prompt;
final String workingDirectory;
final List<CollaborationAttachment> attachments;
final List<String> selectedSkills;
final String aiGatewayBaseUrl;
final String aiGatewayApiKey;
final bool resumeSession;
}
class GatewayAcpClient {
GatewayAcpClient({required this.endpointResolver});
final Uri? Function() endpointResolver;
int _requestCounter = 0;
GatewayAcpCapabilities _cachedCapabilities =
const GatewayAcpCapabilities.empty();
DateTime? _capabilitiesRefreshedAt;
Future<GatewayAcpCapabilities> loadCapabilities({
bool forceRefresh = false,
}) async {
if (!forceRefresh &&
_capabilitiesRefreshedAt != null &&
DateTime.now().difference(_capabilitiesRefreshedAt!) <
const Duration(seconds: 15)) {
return _cachedCapabilities;
}
final response = await _requestWithFallback(
_GatewayAcpRpcRequest(
id: _nextRequestId('capabilities'),
method: 'acp.capabilities',
params: const <String, dynamic>{},
),
onNotification: (_) {},
);
final result = asMap(response['result']);
final caps = asMap(result['capabilities']);
final providers = <SingleAgentProvider>{};
for (final raw in <Object?>[
...asList(result['providers']),
...asList(caps['providers']),
]) {
if (raw == null) {
continue;
}
final provider = SingleAgentProviderCopy.fromJsonValue(
raw.toString().trim().toLowerCase(),
);
if (provider != SingleAgentProvider.auto) {
providers.add(provider);
}
}
final singleAgent =
boolValue(result['singleAgent']) ??
boolValue(caps['single_agent']) ??
providers.isNotEmpty;
final multiAgent =
boolValue(result['multiAgent']) ??
boolValue(caps['multi_agent']) ??
true;
_cachedCapabilities = GatewayAcpCapabilities(
singleAgent: singleAgent,
multiAgent: multiAgent,
providers: providers,
raw: result,
);
_capabilitiesRefreshedAt = DateTime.now();
return _cachedCapabilities;
}
Future<GatewayAcpSingleAgentResult> runSingleAgent(
GatewayAcpSingleAgentRequest request, {
void Function(GatewayAcpSessionUpdate update)? onUpdate,
}) async {
final capabilities = await loadCapabilities();
if (!capabilities.singleAgent ||
!capabilities.providers.contains(request.provider)) {
throw GatewayAcpException(
'Single-agent provider ${request.provider.providerId} is unavailable from ACP capabilities',
code: 'ACP_SINGLE_AGENT_UNAVAILABLE',
);
}
final outputBuffer = StringBuffer();
var lastSequence = -1;
final rpcRequest = _GatewayAcpRpcRequest(
id: _nextRequestId('single-agent'),
method: request.resumeSession ? 'session.message' : 'session.start',
params: <String, dynamic>{
'sessionId': request.sessionId,
'threadId': request.threadId,
'mode': 'single-agent',
'provider': request.provider.providerId,
'taskPrompt': request.prompt,
'model': request.model,
'workingDirectory': request.workingDirectory,
'attachments': request.attachments
.map(
(item) => <String, dynamic>{
'name': item.name,
'description': item.description,
'path': item.path,
},
)
.toList(growable: false),
'selectedSkills': request.selectedSkills,
'aiGatewayBaseUrl': request.aiGatewayBaseUrl,
'aiGatewayApiKey': request.aiGatewayApiKey,
},
);
final response = await _requestWithFallback(
rpcRequest,
onNotification: (notification) {
final update = _sessionUpdateFromNotification(notification);
if (update == null) {
return;
}
if (update.sessionId != request.sessionId) {
return;
}
if (update.sequence != null && update.sequence! <= lastSequence) {
return;
}
if (update.sequence != null) {
lastSequence = update.sequence!;
}
if (update.textDelta.isNotEmpty) {
outputBuffer.write(update.textDelta);
}
onUpdate?.call(update);
},
);
final result = asMap(response['result']);
final explicitOutput = _extractOutput(result);
final output = explicitOutput.isNotEmpty
? explicitOutput
: outputBuffer.toString().trim();
final success = boolValue(result['success']) ?? output.isNotEmpty;
return GatewayAcpSingleAgentResult(
success: success,
output: output,
errorMessage: stringValue(result['error']) ?? '',
turnId: stringValue(result['turnId']) ?? '',
raw: result,
);
}
Stream<MultiAgentRunEvent> runMultiAgent(
GatewayAcpMultiAgentRequest request,
) {
final controller = StreamController<MultiAgentRunEvent>();
unawaited(() async {
final capabilities = await loadCapabilities();
if (!capabilities.multiAgent) {
throw const GatewayAcpException(
'Multi-agent capability is unavailable from ACP',
code: 'ACP_MULTI_AGENT_UNAVAILABLE',
);
}
final rpcRequest = _GatewayAcpRpcRequest(
id: _nextRequestId('multi-agent'),
method: request.resumeSession ? 'session.message' : 'session.start',
params: <String, dynamic>{
'sessionId': request.sessionId,
'threadId': request.threadId,
'mode': 'multi-agent',
'taskPrompt': request.prompt,
'workingDirectory': request.workingDirectory,
'attachments': request.attachments
.map(
(item) => <String, dynamic>{
'name': item.name,
'description': item.description,
'path': item.path,
},
)
.toList(growable: false),
'selectedSkills': request.selectedSkills,
'aiGatewayBaseUrl': request.aiGatewayBaseUrl,
'aiGatewayApiKey': request.aiGatewayApiKey,
},
);
var lastSequence = -1;
try {
final response = await _requestWithFallback(
rpcRequest,
onNotification: (notification) {
final event = _multiAgentEventFromNotification(notification);
if (event == null) {
return;
}
final seq =
(event.data['seq'] as num?)?.toInt() ??
(event.data['sequence'] as num?)?.toInt();
if (seq != null && seq <= lastSequence) {
return;
}
if (seq != null) {
lastSequence = seq;
}
if (!controller.isClosed) {
controller.add(event);
}
},
);
final result = asMap(response['result']);
if (!controller.isClosed) {
controller.add(
MultiAgentRunEvent(
type: 'result',
title: '',
message: stringValue(result['summary']) ?? '',
pending: false,
error: !(boolValue(result['success']) ?? false),
data: result,
),
);
}
} catch (error) {
if (!controller.isClosed) {
controller.add(
MultiAgentRunEvent(
type: 'result',
title: '',
message: error.toString(),
pending: false,
error: true,
data: <String, dynamic>{'error': error.toString()},
),
);
}
} finally {
await controller.close();
}
}());
return controller.stream;
}
Future<void> cancelSession({
required String sessionId,
required String threadId,
}) async {
await _requestWithFallback(
_GatewayAcpRpcRequest(
id: _nextRequestId('cancel'),
method: 'session.cancel',
params: <String, dynamic>{'sessionId': sessionId, 'threadId': threadId},
),
onNotification: (_) {},
);
}
Future<void> closeSession({
required String sessionId,
required String threadId,
}) async {
await _requestWithFallback(
_GatewayAcpRpcRequest(
id: _nextRequestId('close'),
method: 'session.close',
params: <String, dynamic>{'sessionId': sessionId, 'threadId': threadId},
),
onNotification: (_) {},
);
}
Future<void> dispose() async {}
Future<Map<String, dynamic>> _requestWithFallback(
_GatewayAcpRpcRequest request, {
required void Function(Map<String, dynamic>) onNotification,
}) async {
try {
return await _requestViaWebSocket(
request,
onNotification: onNotification,
);
} catch (_) {
return _requestViaHttp(request, onNotification: onNotification);
}
}
Future<Map<String, dynamic>> _requestViaWebSocket(
_GatewayAcpRpcRequest request, {
required void Function(Map<String, dynamic>) onNotification,
}) async {
final endpoint = _resolveWebSocketRpcEndpoint();
if (endpoint == null) {
throw const GatewayAcpException(
'Missing ACP endpoint',
code: 'ACP_ENDPOINT_MISSING',
);
}
final socket = await WebSocket.connect(endpoint.toString()).timeout(
const Duration(seconds: 6),
onTimeout: () => throw const GatewayAcpException(
'ACP websocket connect timeout',
code: 'ACP_WS_CONNECT_TIMEOUT',
),
);
final completer = Completer<Map<String, dynamic>>();
late final StreamSubscription<dynamic> subscription;
subscription = socket.listen(
(raw) {
final json = _decodeMap(raw);
final id = stringValue(json['id']);
final method = stringValue(json['method']) ?? '';
if (id == request.id &&
(json.containsKey('result') || json.containsKey('error'))) {
if (!completer.isCompleted) {
completer.complete(json);
}
return;
}
if (method.isNotEmpty) {
onNotification(json);
}
},
onError: (Object error, StackTrace stackTrace) {
if (!completer.isCompleted) {
completer.completeError(
GatewayAcpException(error.toString(), code: 'ACP_WS_RUNTIME_ERROR'),
);
}
},
onDone: () {
if (!completer.isCompleted) {
completer.completeError(
const GatewayAcpException(
'ACP websocket closed before response',
code: 'ACP_WS_EARLY_CLOSE',
),
);
}
},
cancelOnError: true,
);
socket.add(
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': request.id,
'method': request.method,
'params': request.params,
}),
);
try {
final response = await completer.future.timeout(
const Duration(seconds: 120),
);
_throwIfJsonRpcError(response);
return response;
} finally {
await subscription.cancel();
await socket.close();
}
}
Future<Map<String, dynamic>> _requestViaHttp(
_GatewayAcpRpcRequest request, {
required void Function(Map<String, dynamic>) onNotification,
}) async {
final endpoint = _resolveHttpRpcEndpoint();
if (endpoint == null) {
throw const GatewayAcpException(
'Missing ACP HTTP endpoint',
code: 'ACP_HTTP_ENDPOINT_MISSING',
);
}
final client = HttpClient()..connectionTimeout = const Duration(seconds: 8);
try {
final httpRequest = await client.postUrl(endpoint);
httpRequest.headers.set(
HttpHeaders.contentTypeHeader,
'application/json; charset=utf-8',
);
httpRequest.headers.set(
HttpHeaders.acceptHeader,
'text/event-stream, application/json',
);
httpRequest.add(
utf8.encode(
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': request.id,
'method': request.method,
'params': request.params,
}),
),
);
final response = await httpRequest.close().timeout(
const Duration(seconds: 120),
);
final contentType =
response.headers.contentType?.mimeType.toLowerCase() ??
response.headers
.value(HttpHeaders.contentTypeHeader)
?.toLowerCase() ??
'';
if (contentType.contains('text/event-stream')) {
return _consumeSseRpcResponse(
response: response,
requestId: request.id,
onNotification: onNotification,
);
}
final body = await response.transform(utf8.decoder).join();
final decoded = _decodeMap(body);
_throwIfJsonRpcError(decoded);
return decoded;
} finally {
client.close(force: true);
}
}
Future<Map<String, dynamic>> _consumeSseRpcResponse({
required HttpClientResponse response,
required String requestId,
required void Function(Map<String, dynamic>) onNotification,
}) async {
final completer = Completer<Map<String, dynamic>>();
final eventLines = <String>[];
void consumeEventPayload(String payload) {
final trimmed = payload.trim();
if (trimmed.isEmpty || trimmed == '[DONE]') {
return;
}
final json = _decodeMap(trimmed);
if (stringValue(json['id']) == requestId &&
(json.containsKey('result') || json.containsKey('error'))) {
if (!completer.isCompleted) {
completer.complete(json);
}
return;
}
if ((stringValue(json['method']) ?? '').isNotEmpty) {
onNotification(json);
}
}
await for (final line
in response.transform(utf8.decoder).transform(const LineSplitter())) {
if (line.isEmpty) {
if (eventLines.isNotEmpty) {
consumeEventPayload(eventLines.join('\n'));
eventLines.clear();
}
continue;
}
if (line.startsWith('data:')) {
eventLines.add(line.substring(5).trimLeft());
}
}
if (eventLines.isNotEmpty) {
consumeEventPayload(eventLines.join('\n'));
}
if (!completer.isCompleted) {
throw const GatewayAcpException(
'ACP SSE ended without JSON-RPC response',
code: 'ACP_SSE_NO_RESULT',
);
}
final resolved = await completer.future;
_throwIfJsonRpcError(resolved);
return resolved;
}
GatewayAcpSessionUpdate? _sessionUpdateFromNotification(
Map<String, dynamic> notification,
) {
final method = stringValue(notification['method']) ?? '';
if (method != 'session.update' && method != 'acp.session.update') {
return null;
}
final params = asMap(notification['params']);
return GatewayAcpSessionUpdate(
method: method,
sessionId: stringValue(params['sessionId']) ?? '',
threadId: stringValue(params['threadId']) ?? '',
turnId: stringValue(params['turnId']) ?? '',
type:
stringValue(params['type']) ??
stringValue(params['event']) ??
'status',
textDelta:
stringValue(params['delta']) ??
stringValue(params['text']) ??
stringValue(asMap(params['message'])['content']) ??
'',
sequence: intValue(params['seq']) ?? intValue(notification['seq']),
payload: params,
);
}
MultiAgentRunEvent? _multiAgentEventFromNotification(
Map<String, dynamic> notification,
) {
final method = stringValue(notification['method']) ?? '';
if (method == 'multi_agent.event' || method == 'acp.multi_agent.event') {
return MultiAgentRunEvent.fromJson(asMap(notification['params']));
}
final update = _sessionUpdateFromNotification(notification);
if (update == null || update.payload['mode'] != 'multi-agent') {
return null;
}
return MultiAgentRunEvent(
type: update.type,
title: stringValue(update.payload['title']) ?? '',
message: update.textDelta.isNotEmpty
? update.textDelta
: stringValue(update.payload['message']) ?? '',
pending: boolValue(update.payload['pending']) ?? false,
error: boolValue(update.payload['error']) ?? false,
role: stringValue(update.payload['role']),
iteration: intValue(update.payload['iteration']),
score: intValue(update.payload['score']),
data: update.payload,
);
}
String _extractOutput(Map<String, dynamic> result) {
final direct = stringValue(result['output']);
if ((direct ?? '').trim().isNotEmpty) {
return direct!.trim();
}
final text = stringValue(result['text']);
if ((text ?? '').trim().isNotEmpty) {
return text!.trim();
}
final summary = stringValue(result['summary']);
if ((summary ?? '').trim().isNotEmpty) {
return summary!.trim();
}
final message = asMap(result['message']);
final messageContent = stringValue(message['content']);
if ((messageContent ?? '').trim().isNotEmpty) {
return messageContent!.trim();
}
return '';
}
Map<String, dynamic> asMap(Object? raw) {
if (raw is Map<String, dynamic>) {
return raw;
}
if (raw is Map) {
return raw.cast<String, dynamic>();
}
return const <String, dynamic>{};
}
List<Object?> asList(Object? raw) {
if (raw is List<Object?>) {
return raw;
}
if (raw is List) {
return raw.cast<Object?>();
}
return const <Object?>[];
}
String? stringValue(Object? raw) {
if (raw == null) {
return null;
}
final value = raw.toString().trim();
return value.isEmpty ? null : value;
}
bool? boolValue(Object? raw) {
if (raw is bool) {
return raw;
}
if (raw is num) {
return raw != 0;
}
final text = raw?.toString().trim().toLowerCase();
if (text == null || text.isEmpty) {
return null;
}
if (text == 'true' || text == '1' || text == 'yes') {
return true;
}
if (text == 'false' || text == '0' || text == 'no') {
return false;
}
return null;
}
int? intValue(Object? raw) {
if (raw is int) {
return raw;
}
if (raw is num) {
return raw.toInt();
}
return int.tryParse(raw?.toString().trim() ?? '');
}
void _throwIfJsonRpcError(Map<String, dynamic> envelope) {
final error = asMap(envelope['error']);
if (error.isEmpty) {
return;
}
throw GatewayAcpException(
stringValue(error['message']) ?? 'ACP JSON-RPC request failed',
code: stringValue(error['code']),
details: error['data'],
);
}
Map<String, dynamic> _decodeMap(dynamic raw) {
if (raw is Map<String, dynamic>) {
return raw;
}
if (raw is Map) {
return raw.cast<String, dynamic>();
}
final text = raw is String ? raw : utf8.decode(raw as List<int>);
final decoded = jsonDecode(_extractFirstJsonDocument(text));
if (decoded is Map<String, dynamic>) {
return decoded;
}
if (decoded is Map) {
return decoded.cast<String, dynamic>();
}
return const <String, dynamic>{};
}
Uri? _resolveWebSocketRpcEndpoint() {
final base = endpointResolver();
if (base == null) {
return null;
}
final secure = base.scheme.toLowerCase() == 'https';
return base.replace(
scheme: secure ? 'wss' : 'ws',
path: '/acp',
query: null,
fragment: null,
);
}
Uri? _resolveHttpRpcEndpoint() {
final base = endpointResolver();
if (base == null) {
return null;
}
final scheme = base.scheme.toLowerCase();
if (scheme != 'http' && scheme != 'https') {
return null;
}
return base.replace(path: '/acp/rpc', query: null, fragment: null);
}
String _nextRequestId(String method) {
return '${DateTime.now().microsecondsSinceEpoch}-$method-${_requestCounter++}';
}
String _extractFirstJsonDocument(String text) {
final trimmed = text.trim();
if (trimmed.isEmpty) {
throw const FormatException('Empty response body');
}
final objectStart = trimmed.indexOf('{');
final arrayStart = trimmed.indexOf('[');
var start = -1;
if (objectStart >= 0 && arrayStart >= 0) {
start = objectStart < arrayStart ? objectStart : arrayStart;
} else if (objectStart >= 0) {
start = objectStart;
} else if (arrayStart >= 0) {
start = arrayStart;
}
if (start < 0) {
throw const FormatException('Missing JSON document');
}
var depth = 0;
var inString = false;
var escaped = false;
for (var index = start; index < trimmed.length; index++) {
final char = trimmed[index];
if (inString) {
if (escaped) {
escaped = false;
} else if (char == r'\') {
escaped = true;
} else if (char == '"') {
inString = false;
}
continue;
}
if (char == '"') {
inString = true;
continue;
}
if (char == '{' || char == '[') {
depth += 1;
} else if (char == '}' || char == ']') {
depth -= 1;
if (depth == 0) {
return trimmed.substring(start, index + 1);
}
}
}
throw const FormatException('Unterminated JSON document');
}
}
class _GatewayAcpRpcRequest {
const _GatewayAcpRpcRequest({
required this.id,
required this.method,
required this.params,
});
final String id;
final String method;
final Map<String, dynamic> params;
}

View File

@ -211,11 +211,6 @@ class RuntimeCoordinator extends ChangeNotifier {
throw StateError('Failed to connect: ${result.error}');
}
// Step 2: Start code-agent runtime according to selected mode.
if (preferredMode != GatewayMode.offline) {
await _ensureCodeAgentRuntime();
}
_state = CoordinatorState.ready;
notifyListeners();
} catch (e) {
@ -248,10 +243,6 @@ class RuntimeCoordinator extends ChangeNotifier {
throw StateError('No available connection mode: ${result.error}');
}
if (result.mode != GatewayMode.offline) {
await _ensureCodeAgentRuntime();
}
_state = CoordinatorState.ready;
notifyListeners();
} catch (e) {
@ -283,8 +274,7 @@ class RuntimeCoordinator extends ChangeNotifier {
}
return null;
}
return codex.findCodexBinary();
return null;
}
/// Start the code-agent runtime without changing the Gateway connection state.
@ -297,49 +287,14 @@ class RuntimeCoordinator extends ChangeNotifier {
_codexPath = codexPath?.trim();
_cwd = workingDirectory ?? _cwd ?? Directory.current.path;
_lastError = null;
if (runtimeMode == CodeAgentRuntimeMode.builtIn) {
if (codex.isConnected) {
await codex.stop();
}
_state = CoordinatorState.ready;
notifyListeners();
return;
}
final resolvedCodexPath = await resolveCodexPath(codexPath: _codexPath);
if (resolvedCodexPath == null) {
_state = CoordinatorState.error;
_lastError = 'Codex CLI not found';
notifyListeners();
throw StateError('Codex CLI not found');
}
_codexPath = resolvedCodexPath;
if (codex.isConnected) {
_state = CoordinatorState.ready;
notifyListeners();
return;
}
_state = CoordinatorState.connecting;
_state = CoordinatorState.ready;
notifyListeners();
try {
await codex.startStdio(codexPath: resolvedCodexPath, cwd: _cwd);
_state = CoordinatorState.ready;
notifyListeners();
} catch (error) {
_state = CoordinatorState.error;
_lastError = error.toString();
notifyListeners();
rethrow;
}
}
Future<void> stopCodeAgentRuntime() async {
await codex.stop();
_state = CoordinatorState.disconnected;
_state = gateway.isConnected
? CoordinatorState.ready
: CoordinatorState.disconnected;
notifyListeners();
}
@ -404,7 +359,7 @@ class RuntimeCoordinator extends ChangeNotifier {
_state = CoordinatorState.disconnected;
notifyListeners();
await Future.wait([codex.stop(), gateway.disconnect()]);
await gateway.disconnect();
}
Future<ModeSwitchResult> _switchMode(GatewayMode mode) {
@ -418,28 +373,6 @@ class RuntimeCoordinator extends ChangeNotifier {
}
}
Future<void> _ensureCodeAgentRuntime() async {
if (_runtimeMode == CodeAgentRuntimeMode.builtIn) {
// Built-in mode: runtime is assumed internal, no external process needed.
return;
}
final resolvedCodexPath = await resolveCodexPath(codexPath: _codexPath);
if (resolvedCodexPath == null) {
// Fall back to offline mode if external Codex CLI is unavailable.
await modeSwitcher.switchToOffline();
return;
}
_codexPath = resolvedCodexPath;
try {
await codex.startStdio(codexPath: resolvedCodexPath, cwd: _cwd);
} catch (_) {
// Continue without external code agent in offline mode.
await modeSwitcher.switchToOffline();
}
}
static Set<String> _normalizeCapabilitySet(Iterable<String> capabilities) {
return capabilities
.map((item) => item.trim().toLowerCase())

View File

@ -1,6 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'gateway_acp_client.dart';
import 'multi_agent_orchestrator.dart';
import 'runtime_models.dart';
@ -78,20 +76,8 @@ abstract class SingleAgentRunner {
}
class DefaultSingleAgentRunner implements SingleAgentRunner {
DefaultSingleAgentRunner({
Future<bool> Function(String command)? binaryExistsResolver,
CliProcessStarter? processStarter,
}) : _binaryExistsResolver = binaryExistsResolver,
_processStarter =
processStarter ??
((executable, arguments, {environment, workingDirectory}) {
return Process.start(
executable,
arguments,
environment: environment,
workingDirectory: workingDirectory,
);
});
DefaultSingleAgentRunner({required GatewayAcpClient acpClient})
: _acpClient = acpClient;
static const List<SingleAgentProvider> _autoOrder = <SingleAgentProvider>[
SingleAgentProvider.codex,
@ -100,180 +86,111 @@ class DefaultSingleAgentRunner implements SingleAgentRunner {
SingleAgentProvider.gemini,
];
final Future<bool> Function(String command)? _binaryExistsResolver;
final CliProcessStarter _processStarter;
final Map<String, Process> _activeProcesses = <String, Process>{};
final Set<String> _abortedSessionIds = <String>{};
final GatewayAcpClient _acpClient;
@override
Future<SingleAgentProviderResolution> resolveProvider({
required SingleAgentProvider selection,
required String configuredCodexCliPath,
}) async {
if (selection != SingleAgentProvider.auto) {
final available = await _isProviderAvailable(
selection,
configuredCodexCliPath: configuredCodexCliPath,
);
return SingleAgentProviderResolution(
selection: selection,
resolvedProvider: available ? selection : null,
fallbackReason: available
? null
: '${selection.label} CLI is unavailable on this device.',
);
}
for (final provider in _autoOrder) {
if (await _isProviderAvailable(
provider,
configuredCodexCliPath: configuredCodexCliPath,
)) {
try {
final capabilities = await _acpClient.loadCapabilities();
if (!capabilities.singleAgent) {
return SingleAgentProviderResolution(
selection: selection,
resolvedProvider: provider,
fallbackReason: null,
resolvedProvider: null,
fallbackReason: 'ACP single-agent capability is unavailable.',
);
}
if (selection != SingleAgentProvider.auto) {
final available = capabilities.providers.contains(selection);
return SingleAgentProviderResolution(
selection: selection,
resolvedProvider: available ? selection : null,
fallbackReason: available
? null
: '${selection.label} provider is unavailable from ACP adapter.',
);
}
}
return const SingleAgentProviderResolution(
selection: SingleAgentProvider.auto,
resolvedProvider: null,
fallbackReason: 'No supported external CLI provider is available.',
);
for (final provider in _autoOrder) {
if (capabilities.providers.contains(provider)) {
return SingleAgentProviderResolution(
selection: selection,
resolvedProvider: provider,
fallbackReason: null,
);
}
}
return const SingleAgentProviderResolution(
selection: SingleAgentProvider.auto,
resolvedProvider: null,
fallbackReason: 'No ACP single-agent provider is currently available.',
);
} catch (error) {
return SingleAgentProviderResolution(
selection: selection,
resolvedProvider: null,
fallbackReason: 'ACP capability negotiation failed: $error',
);
}
}
@override
Future<SingleAgentRunResult> run(SingleAgentRunRequest request) async {
final command = _resolveCommand(
request.provider,
configuredCodexCliPath: request.configuredCodexCliPath,
model: request.model,
);
final args = _buildArgs(
provider: request.provider,
command: command,
model: request.model,
prompt: _augmentPrompt(request),
cwd: request.workingDirectory,
);
final env = _buildEnvVars(
provider: request.provider,
aiGatewayBaseUrl: request.aiGatewayBaseUrl,
aiGatewayApiKey: request.aiGatewayApiKey,
config: request.config,
);
try {
final process = await _processStarter(
command,
args,
environment: env,
workingDirectory: request.workingDirectory.trim().isEmpty
? null
: request.workingDirectory,
final result = await _acpClient.runSingleAgent(
GatewayAcpSingleAgentRequest(
sessionId: request.sessionId,
threadId: request.sessionId,
provider: request.provider,
prompt: _augmentPrompt(request),
model: request.model,
workingDirectory: request.workingDirectory,
attachments: request.attachments,
selectedSkills: request.selectedSkills,
aiGatewayBaseUrl: request.aiGatewayBaseUrl,
aiGatewayApiKey: request.aiGatewayApiKey,
resumeSession: true,
),
onUpdate: (update) {
if (update.textDelta.isNotEmpty) {
request.onOutput?.call(update.textDelta);
}
},
);
_activeProcesses[request.sessionId] = process;
await process.stdin.close();
final timeout = Duration(seconds: request.config.timeoutSeconds);
final stdoutBuffer = StringBuffer();
final stderrBuffer = StringBuffer();
final stdoutFuture = process.stdout
.transform(utf8.decoder)
.listen((chunk) {
if (chunk.isEmpty) {
return;
}
stdoutBuffer.write(chunk);
request.onOutput?.call(stdoutBuffer.toString());
})
.asFuture<void>();
final stderrFuture = process.stderr
.transform(utf8.decoder)
.listen((chunk) {
if (chunk.isEmpty) {
return;
}
stderrBuffer.write(chunk);
})
.asFuture<void>();
final exitCode = await process.exitCode.timeout(timeout, onTimeout: () {
try {
process.kill(ProcessSignal.sigkill);
} catch (_) {
// Best effort only.
}
return -1;
});
await Future.wait<void>(<Future<void>>[
stdoutFuture.timeout(timeout, onTimeout: () {}),
stderrFuture.timeout(timeout, onTimeout: () {}),
]);
final output = stdoutBuffer.toString().trim().isNotEmpty
? stdoutBuffer.toString().trim()
: stderrBuffer.toString().trim();
if (_abortedSessionIds.remove(request.sessionId)) {
return SingleAgentRunResult(
provider: request.provider,
output: output,
success: false,
errorMessage: 'aborted',
shouldFallbackToAiChat: false,
aborted: true,
);
}
if (exitCode == 0 && output.isNotEmpty) {
return SingleAgentRunResult(
provider: request.provider,
output: output,
success: true,
errorMessage: '',
shouldFallbackToAiChat: false,
);
}
final fallbackReason = _isLaunchFailureExit(
exitCode,
stderrBuffer.toString(),
)
? '${request.provider.label} CLI could not be launched.'
: null;
return SingleAgentRunResult(
provider: request.provider,
output: output,
success: false,
errorMessage: stderrBuffer.toString().trim().isNotEmpty
? stderrBuffer.toString().trim()
: 'CLI exited with code $exitCode',
shouldFallbackToAiChat: fallbackReason != null,
fallbackReason: fallbackReason,
output: result.output,
success: result.success,
errorMessage: result.errorMessage,
shouldFallbackToAiChat: !result.success && result.output.isEmpty,
fallbackReason: !result.success
? 'ACP single-agent run failed: ${result.errorMessage}'
: null,
);
} catch (error) {
if (_abortedSessionIds.remove(request.sessionId)) {
return SingleAgentRunResult(
provider: request.provider,
output: '',
success: false,
errorMessage: 'aborted',
shouldFallbackToAiChat: false,
aborted: true,
);
}
final fallbackReason = _isLaunchFailureError(error)
? '${request.provider.label} CLI could not be launched.'
: null;
} on GatewayAcpException catch (error) {
final shouldFallback = _shouldFallbackToAiChat(error.code, error.message);
return SingleAgentRunResult(
provider: request.provider,
output: '',
success: false,
errorMessage: error.toString(),
shouldFallbackToAiChat: fallbackReason != null,
fallbackReason: fallbackReason,
shouldFallbackToAiChat: shouldFallback,
fallbackReason: shouldFallback
? '${request.provider.label} provider is unavailable from ACP adapter.'
: null,
);
} catch (error) {
return SingleAgentRunResult(
provider: request.provider,
output: '',
success: false,
errorMessage: error.toString(),
shouldFallbackToAiChat: true,
fallbackReason:
'${request.provider.label} provider run failed before completion.',
);
} finally {
_activeProcesses.remove(request.sessionId);
}
}
@ -283,227 +200,29 @@ class DefaultSingleAgentRunner implements SingleAgentRunner {
if (normalized.isEmpty) {
return;
}
_abortedSessionIds.add(normalized);
final process = _activeProcesses[normalized];
if (process == null) {
return;
}
try {
process.kill(ProcessSignal.sigterm);
await _acpClient.cancelSession(
sessionId: normalized,
threadId: normalized,
);
} catch (_) {
// Best effort only.
}
}
Future<bool> _isProviderAvailable(
SingleAgentProvider provider, {
required String configuredCodexCliPath,
}) async {
if (provider == SingleAgentProvider.auto) {
return false;
bool _shouldFallbackToAiChat(String? code, String message) {
final normalizedCode = code?.trim().toUpperCase() ?? '';
if (normalizedCode == 'ACP_ENDPOINT_MISSING' ||
normalizedCode == 'ACP_HTTP_ENDPOINT_MISSING' ||
normalizedCode == 'ACP_WS_CONNECT_TIMEOUT' ||
normalizedCode == 'ACP_WS_RUNTIME_ERROR' ||
normalizedCode == 'ACP_WS_EARLY_CLOSE') {
return true;
}
if (provider == SingleAgentProvider.codex &&
configuredCodexCliPath.trim().isNotEmpty) {
return File(configuredCodexCliPath.trim()).existsSync();
}
return _binaryExists(_binaryName(provider));
}
Future<bool> _binaryExists(String command) async {
if (_binaryExistsResolver != null) {
return _binaryExistsResolver(command);
}
final check = await Process.run(
Platform.isWindows ? 'where' : 'which',
<String>[command],
runInShell: true,
);
return check.exitCode == 0 && '${check.stdout}'.trim().isNotEmpty;
}
String _binaryName(SingleAgentProvider provider) {
return switch (provider) {
SingleAgentProvider.auto => 'auto',
SingleAgentProvider.codex => 'codex',
SingleAgentProvider.opencode => 'opencode',
SingleAgentProvider.claude => 'claude',
SingleAgentProvider.gemini => 'gemini',
};
}
String _resolveCommand(
SingleAgentProvider provider, {
required String configuredCodexCliPath,
required String model,
}) {
final useOllamaLaunch = _prefersOllamaLaunch(
provider: provider,
model: model,
);
if (useOllamaLaunch) {
return 'ollama';
}
if (provider == SingleAgentProvider.codex &&
configuredCodexCliPath.trim().isNotEmpty) {
return configuredCodexCliPath.trim();
}
return _binaryName(provider);
}
List<String> _buildArgs({
required SingleAgentProvider provider,
required String command,
required String model,
required String prompt,
required String cwd,
}) {
final useOllamaLaunch = command == 'ollama';
switch (provider) {
case SingleAgentProvider.claude:
if (useOllamaLaunch) {
return _buildOllamaLaunchArgs(
provider: provider,
model: model,
prompt: prompt,
cwd: cwd,
);
}
return model.trim().isEmpty
? <String>['-p', prompt]
: <String>['--model', model.trim(), '-p', prompt];
case SingleAgentProvider.codex:
if (useOllamaLaunch) {
return _buildOllamaLaunchArgs(
provider: provider,
model: model,
prompt: prompt,
cwd: cwd,
);
}
return <String>[
'exec',
'--skip-git-repo-check',
'--color',
'never',
if (cwd.trim().isNotEmpty) ...<String>['-C', cwd.trim()],
if (model.trim().isNotEmpty) ...<String>['-m', model.trim()],
prompt,
];
case SingleAgentProvider.gemini:
return model.trim().isEmpty
? <String>['-p', prompt]
: <String>['--model', model.trim(), '-p', prompt];
case SingleAgentProvider.opencode:
if (useOllamaLaunch) {
return _buildOllamaLaunchArgs(
provider: provider,
model: model,
prompt: prompt,
cwd: cwd,
);
}
return <String>[
'run',
'--format',
'default',
if (cwd.trim().isNotEmpty) ...<String>['--dir', cwd.trim()],
if (model.trim().isNotEmpty) ...<String>['-m', model.trim()],
prompt,
];
case SingleAgentProvider.auto:
return const <String>[];
}
}
bool _prefersOllamaLaunch({
required SingleAgentProvider provider,
required String model,
}) {
if (model.trim().isEmpty) {
return false;
}
return provider == SingleAgentProvider.codex ||
provider == SingleAgentProvider.opencode ||
provider == SingleAgentProvider.claude;
}
List<String> _buildOllamaLaunchArgs({
required SingleAgentProvider provider,
required String model,
required String prompt,
required String cwd,
}) {
final tool = provider.providerId;
final args = <String>['launch', tool, '--model', model.trim()];
if (provider == SingleAgentProvider.claude) {
args.add('--yes');
args.addAll(<String>['--', '-p', prompt]);
return args;
}
if (provider == SingleAgentProvider.codex) {
args.addAll(<String>[
'--',
'exec',
'--skip-git-repo-check',
'--color',
'never',
if (cwd.trim().isNotEmpty) ...<String>['-C', cwd.trim()],
prompt,
]);
return args;
}
if (provider == SingleAgentProvider.opencode) {
args.addAll(<String>[
'--',
'run',
'--format',
'default',
if (cwd.trim().isNotEmpty) ...<String>['--dir', cwd.trim()],
prompt,
]);
return args;
}
args.addAll(<String>['--', '-p', prompt]);
return args;
}
Map<String, String> _buildEnvVars({
required SingleAgentProvider provider,
required String aiGatewayBaseUrl,
required String aiGatewayApiKey,
required MultiAgentConfig config,
}) {
final baseEnv = <String, String>{...Platform.environment};
if (config.aiGatewayInjectionPolicy != AiGatewayInjectionPolicy.disabled &&
aiGatewayBaseUrl.trim().isNotEmpty &&
aiGatewayApiKey.trim().isNotEmpty) {
baseEnv['OPENAI_BASE_URL'] = aiGatewayBaseUrl.trim();
baseEnv['OPENAI_API_KEY'] = aiGatewayApiKey.trim();
baseEnv['OLLAMA_BASE_URL'] = aiGatewayBaseUrl.trim();
baseEnv['OLLAMA_HOST'] = aiGatewayBaseUrl.trim();
if (provider == SingleAgentProvider.claude) {
baseEnv['ANTHROPIC_BASE_URL'] = aiGatewayBaseUrl.trim();
baseEnv['ANTHROPIC_AUTH_TOKEN'] = aiGatewayApiKey.trim();
baseEnv['ANTHROPIC_API_KEY'] = aiGatewayApiKey.trim();
}
return baseEnv;
}
final ollamaEndpoint = config.ollamaEndpoint.trim();
if (ollamaEndpoint.isNotEmpty) {
baseEnv['OLLAMA_BASE_URL'] = ollamaEndpoint;
baseEnv['OLLAMA_HOST'] = ollamaEndpoint;
baseEnv['OPENAI_API_KEY'] = 'ollama';
baseEnv['OPENAI_BASE_URL'] = ollamaEndpoint.endsWith('/v1')
? ollamaEndpoint
: '$ollamaEndpoint/v1';
}
if (provider == SingleAgentProvider.claude ||
provider == SingleAgentProvider.codex) {
baseEnv['ANTHROPIC_AUTH_TOKEN'] = 'ollama';
baseEnv['ANTHROPIC_API_KEY'] = '';
baseEnv['ANTHROPIC_BASE_URL'] = ollamaEndpoint;
}
return baseEnv;
final normalizedMessage = message.toLowerCase();
return normalizedMessage.contains('timeout') ||
normalizedMessage.contains('unavailable') ||
normalizedMessage.contains('missing');
}
String _augmentPrompt(SingleAgentRunRequest request) {
@ -515,24 +234,4 @@ class DefaultSingleAgentRunner implements SingleAgentRunner {
.join('\n');
return 'User-selected local attachments:\n$attachmentLines\n\n${request.prompt}';
}
bool _isLaunchFailureExit(int exitCode, String stderr) {
if (exitCode == 127 || exitCode == 9009 || exitCode == -1) {
return true;
}
final normalized = stderr.toLowerCase();
return normalized.contains('not found') ||
normalized.contains('no such file') ||
normalized.contains('is not recognized');
}
bool _isLaunchFailureError(Object error) {
if (error is ProcessException) {
return true;
}
final normalized = error.toString().toLowerCase();
return normalized.contains('not found') ||
normalized.contains('no such file') ||
normalized.contains('cannot find');
}
}

View File

@ -0,0 +1,57 @@
@TestOn('vm')
library;
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('Desktop ACP guard', () {
test(
'critical runtime client files must not execute external CLI directly',
() {
final blockedStartPattern = RegExp(r'\bProcess\.start\s*\(');
final blockedRunPattern = RegExp(r'\bProcess\.run\s*\(');
final allowedRunPatterns = <RegExp>[
RegExp(r"Process\.run\(\s*'open'"),
RegExp(r"Process\.run\(\s*'cmd'"),
RegExp(r"Process\.run\(\s*'xdg-open'"),
];
const guardedFiles = <String>[
'lib/app/app_controller_desktop.dart',
'lib/runtime/single_agent_runner.dart',
'lib/runtime/runtime_coordinator.dart',
'lib/runtime/gateway_acp_client.dart',
];
for (final relativePath in guardedFiles) {
final file = File(relativePath);
expect(
file.existsSync(),
isTrue,
reason: '$relativePath should exist',
);
final content = file.readAsStringSync();
expect(
blockedStartPattern.hasMatch(content),
isFalse,
reason:
'$relativePath contains forbidden local CLI execution: ${blockedStartPattern.pattern}',
);
for (final match in blockedRunPattern.allMatches(content)) {
final start = (match.start - 48).clamp(0, content.length);
final end = (match.end + 72).clamp(0, content.length);
final snippet = content.substring(start, end);
expect(
allowedRunPatterns.any((pattern) => pattern.hasMatch(snippet)),
isTrue,
reason:
'$relativePath contains non-whitelisted Process.run at offset ${match.start}',
);
}
}
},
);
});
}

View File

@ -187,7 +187,7 @@ void main() {
);
test(
'external mode resolves and starts codex process when binary exists',
'external mode keeps gateway ready without starting local codex process',
() async {
codex.findResult = '/usr/local/bin/codex';
@ -197,14 +197,14 @@ void main() {
);
expect(coordinator.runtimeMode, CodeAgentRuntimeMode.externalCli);
expect(codex.findCalled, isTrue);
expect(codex.startCalled, isTrue);
expect(codex.findCalled, isFalse);
expect(codex.startCalled, isFalse);
expect(modeSwitcher.currentMode, GatewayMode.remote);
},
);
test(
'external mode falls back to offline when codex binary missing',
'external mode no longer forces offline when codex binary is missing',
() async {
codex.findResult = null;
@ -213,10 +213,10 @@ void main() {
runtimeMode: CodeAgentRuntimeMode.externalCli,
);
expect(codex.findCalled, isTrue);
expect(codex.findCalled, isFalse);
expect(codex.startCalled, isFalse);
expect(modeSwitcher.offlineSwitchCalled, isTrue);
expect(modeSwitcher.currentMode, GatewayMode.offline);
expect(modeSwitcher.offlineSwitchCalled, isFalse);
expect(modeSwitcher.currentMode, GatewayMode.remote);
},
);
});