refactor(desktop): route assistant execution through gateway ACP
This commit is contained in:
parent
659e187eb8
commit
ded87aa63f
File diff suppressed because it is too large
Load Diff
845
lib/runtime/gateway_acp_client.dart
Normal file
845
lib/runtime/gateway_acp_client.dart
Normal 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;
|
||||
}
|
||||
@ -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())
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
57
test/runtime/no_direct_cli_execution_guard_suite.dart
Normal file
57
test/runtime/no_direct_cli_execution_guard_suite.dart
Normal 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}',
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user