refactor runtime cores into focused parts
This commit is contained in:
parent
f211883c2c
commit
3c80aa52a8
@ -4,4 +4,7 @@ import 'dart:io';
|
||||
|
||||
import 'runtime_models.dart';
|
||||
|
||||
part 'direct_single_agent_app_server_client_protocol.part.dart';
|
||||
part 'direct_single_agent_app_server_client_transport.part.dart';
|
||||
part 'direct_single_agent_app_server_client_helpers.part.dart';
|
||||
part 'direct_single_agent_app_server_client_core.part.dart';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,183 @@
|
||||
part of 'direct_single_agent_app_server_client.dart';
|
||||
|
||||
Uri _buildRestUri(
|
||||
Uri base,
|
||||
String path, {
|
||||
Map<String, String>? queryParameters,
|
||||
}) {
|
||||
final normalizedPath = path.startsWith('/') ? path : '/$path';
|
||||
return base.replace(
|
||||
path: normalizedPath,
|
||||
queryParameters: queryParameters,
|
||||
fragment: null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _fetchJson(
|
||||
Uri uri, {
|
||||
required String gatewayToken,
|
||||
}) async {
|
||||
final client = HttpClient()..connectionTimeout = const Duration(seconds: 8);
|
||||
try {
|
||||
final request = await client.getUrl(uri);
|
||||
final normalizedToken = gatewayToken.trim();
|
||||
if (normalizedToken.isNotEmpty) {
|
||||
request.headers.set(
|
||||
HttpHeaders.authorizationHeader,
|
||||
'Bearer $normalizedToken',
|
||||
);
|
||||
}
|
||||
final response = await request.close();
|
||||
final body = await response.transform(utf8.decoder).join();
|
||||
return _decodeMap(body);
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _postJson(
|
||||
Uri uri, {
|
||||
required Object? body,
|
||||
required String gatewayToken,
|
||||
}) async {
|
||||
final client = HttpClient()..connectionTimeout = const Duration(seconds: 8);
|
||||
try {
|
||||
final request = await client.postUrl(uri);
|
||||
request.headers.set(
|
||||
HttpHeaders.contentTypeHeader,
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
final normalizedToken = gatewayToken.trim();
|
||||
if (normalizedToken.isNotEmpty) {
|
||||
request.headers.set(
|
||||
HttpHeaders.authorizationHeader,
|
||||
'Bearer $normalizedToken',
|
||||
);
|
||||
}
|
||||
if (body != null) {
|
||||
request.add(utf8.encode(jsonEncode(body)));
|
||||
}
|
||||
final response = await request.close();
|
||||
final text = await response.transform(utf8.decoder).join();
|
||||
if (text.trim().isEmpty) {
|
||||
return const <String, dynamic>{};
|
||||
}
|
||||
return _decodeMap(text);
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Object?>> _fetchJsonList(
|
||||
Uri uri, {
|
||||
required String gatewayToken,
|
||||
}) async {
|
||||
final client = HttpClient()..connectionTimeout = const Duration(seconds: 8);
|
||||
try {
|
||||
final request = await client.getUrl(uri);
|
||||
final normalizedToken = gatewayToken.trim();
|
||||
if (normalizedToken.isNotEmpty) {
|
||||
request.headers.set(
|
||||
HttpHeaders.authorizationHeader,
|
||||
'Bearer $normalizedToken',
|
||||
);
|
||||
}
|
||||
final response = await request.close();
|
||||
final body = await response.transform(utf8.decoder).join();
|
||||
final decoded = jsonDecode(body);
|
||||
if (decoded is List<Object?>) {
|
||||
return decoded;
|
||||
}
|
||||
if (decoded is List) {
|
||||
return decoded.cast<Object?>();
|
||||
}
|
||||
return const <Object?>[];
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
String? _extractThreadId(Map<String, dynamic> payload) {
|
||||
final topLevelId = payload['id']?.toString().trim() ?? '';
|
||||
if (topLevelId.isNotEmpty) {
|
||||
return topLevelId;
|
||||
}
|
||||
final thread = _asMap(payload['thread']);
|
||||
final nestedId = thread['id']?.toString().trim() ?? '';
|
||||
if (nestedId.isNotEmpty) {
|
||||
return nestedId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _extractModel(Map<String, dynamic> payload) {
|
||||
final model = payload['model']?.toString().trim() ?? '';
|
||||
if (model.isNotEmpty) {
|
||||
return model;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _extractThreadPath(Map<String, dynamic> payload) {
|
||||
final directPath = payload['path']?.toString().trim() ?? '';
|
||||
if (directPath.isNotEmpty) {
|
||||
return directPath;
|
||||
}
|
||||
final thread = _asMap(payload['thread']);
|
||||
final nestedPath = thread['path']?.toString().trim() ?? '';
|
||||
if (nestedPath.isNotEmpty) {
|
||||
return nestedPath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _decodeMap(Object raw) {
|
||||
if (raw is Map<String, dynamic>) {
|
||||
return raw;
|
||||
}
|
||||
if (raw is Map) {
|
||||
return raw.cast<String, dynamic>();
|
||||
}
|
||||
final decoded = jsonDecode(raw.toString());
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return decoded;
|
||||
}
|
||||
if (decoded is Map) {
|
||||
return decoded.cast<String, dynamic>();
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _asMap(Object? value) {
|
||||
if (value is Map<String, dynamic>) {
|
||||
return value;
|
||||
}
|
||||
if (value is Map) {
|
||||
return value.cast<String, dynamic>();
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
}
|
||||
|
||||
bool _isLocalHost(String host) {
|
||||
final normalized = host.trim().toLowerCase();
|
||||
if (normalized.isEmpty ||
|
||||
normalized == 'localhost' ||
|
||||
normalized == '127.0.0.1' ||
|
||||
normalized == '::1') {
|
||||
return true;
|
||||
}
|
||||
final address = InternetAddress.tryParse(normalized);
|
||||
return address?.isLoopback ?? false;
|
||||
}
|
||||
|
||||
WorkspaceRefKind _workspaceRefKindForEndpointMode(
|
||||
DirectSingleAgentEndpointMode mode,
|
||||
) {
|
||||
return switch (mode) {
|
||||
DirectSingleAgentEndpointMode.wsLocal ||
|
||||
DirectSingleAgentEndpointMode.httpLocal => WorkspaceRefKind.localPath,
|
||||
DirectSingleAgentEndpointMode.wss ||
|
||||
DirectSingleAgentEndpointMode.https => WorkspaceRefKind.remotePath,
|
||||
DirectSingleAgentEndpointMode.unsupported => WorkspaceRefKind.localPath,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,149 @@
|
||||
part of 'direct_single_agent_app_server_client.dart';
|
||||
|
||||
class DirectSingleAgentCapabilities {
|
||||
const DirectSingleAgentCapabilities({
|
||||
required this.available,
|
||||
required this.supportedProviders,
|
||||
required this.endpoint,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
const DirectSingleAgentCapabilities.unavailable({
|
||||
required this.endpoint,
|
||||
this.errorMessage,
|
||||
}) : available = false,
|
||||
supportedProviders = const <SingleAgentProvider>[];
|
||||
|
||||
final bool available;
|
||||
final List<SingleAgentProvider> supportedProviders;
|
||||
final String endpoint;
|
||||
final String? errorMessage;
|
||||
|
||||
bool get supportsCodex => supportsProvider(SingleAgentProvider.codex);
|
||||
|
||||
bool supportsProvider(SingleAgentProvider provider) =>
|
||||
supportedProviders.contains(provider);
|
||||
}
|
||||
|
||||
class DirectSingleAgentRunResult {
|
||||
const DirectSingleAgentRunResult({
|
||||
required this.success,
|
||||
required this.output,
|
||||
required this.errorMessage,
|
||||
this.aborted = false,
|
||||
this.resolvedModel = '',
|
||||
this.resolvedWorkingDirectory = '',
|
||||
this.resolvedWorkspaceRefKind,
|
||||
});
|
||||
|
||||
final bool success;
|
||||
final String output;
|
||||
final String errorMessage;
|
||||
final bool aborted;
|
||||
final String resolvedModel;
|
||||
final String resolvedWorkingDirectory;
|
||||
final WorkspaceRefKind? resolvedWorkspaceRefKind;
|
||||
}
|
||||
|
||||
class DirectSingleAgentRunRequest {
|
||||
const DirectSingleAgentRunRequest({
|
||||
required this.sessionId,
|
||||
required this.provider,
|
||||
required this.prompt,
|
||||
required this.model,
|
||||
required this.workingDirectory,
|
||||
required this.gatewayToken,
|
||||
this.selectedSkills = const <AssistantThreadSkillEntry>[],
|
||||
this.onOutput,
|
||||
});
|
||||
|
||||
final String sessionId;
|
||||
final SingleAgentProvider provider;
|
||||
final String prompt;
|
||||
final String model;
|
||||
final String workingDirectory;
|
||||
final String gatewayToken;
|
||||
final List<AssistantThreadSkillEntry> selectedSkills;
|
||||
final void Function(String text)? onOutput;
|
||||
}
|
||||
|
||||
enum DirectSingleAgentEndpointMode {
|
||||
wsLocal,
|
||||
wss,
|
||||
httpLocal,
|
||||
https,
|
||||
unsupported,
|
||||
}
|
||||
|
||||
enum _DirectSingleAgentTransportKind { websocketAppServer, restSessionApi }
|
||||
|
||||
class DirectSingleAgentEndpointDescriptor {
|
||||
const DirectSingleAgentEndpointDescriptor({
|
||||
required this.mode,
|
||||
required this.baseUri,
|
||||
this.websocketUri,
|
||||
});
|
||||
|
||||
final DirectSingleAgentEndpointMode mode;
|
||||
final Uri? baseUri;
|
||||
final Uri? websocketUri;
|
||||
|
||||
bool get isSupported => mode != DirectSingleAgentEndpointMode.unsupported;
|
||||
|
||||
bool get prefersWebSocket =>
|
||||
mode == DirectSingleAgentEndpointMode.wsLocal ||
|
||||
mode == DirectSingleAgentEndpointMode.wss;
|
||||
|
||||
bool get allowsRest =>
|
||||
mode == DirectSingleAgentEndpointMode.httpLocal ||
|
||||
mode == DirectSingleAgentEndpointMode.https;
|
||||
|
||||
static DirectSingleAgentEndpointDescriptor describe(Uri? endpoint) {
|
||||
if (endpoint == null) {
|
||||
return const DirectSingleAgentEndpointDescriptor(
|
||||
mode: DirectSingleAgentEndpointMode.unsupported,
|
||||
baseUri: null,
|
||||
);
|
||||
}
|
||||
final scheme = endpoint.scheme.toLowerCase();
|
||||
final normalizedBase = endpoint.replace(
|
||||
path: '',
|
||||
query: null,
|
||||
fragment: null,
|
||||
);
|
||||
final isLocal = _isLocalHost(endpoint.host);
|
||||
if (scheme == 'ws' && isLocal) {
|
||||
return DirectSingleAgentEndpointDescriptor(
|
||||
mode: DirectSingleAgentEndpointMode.wsLocal,
|
||||
baseUri: normalizedBase,
|
||||
websocketUri: normalizedBase,
|
||||
);
|
||||
}
|
||||
if (scheme == 'wss') {
|
||||
return DirectSingleAgentEndpointDescriptor(
|
||||
mode: DirectSingleAgentEndpointMode.wss,
|
||||
baseUri: normalizedBase,
|
||||
websocketUri: normalizedBase,
|
||||
);
|
||||
}
|
||||
if (scheme == 'http' && isLocal) {
|
||||
return DirectSingleAgentEndpointDescriptor(
|
||||
mode: DirectSingleAgentEndpointMode.httpLocal,
|
||||
baseUri: normalizedBase,
|
||||
websocketUri: normalizedBase.replace(scheme: 'ws'),
|
||||
);
|
||||
}
|
||||
if (scheme == 'https') {
|
||||
return DirectSingleAgentEndpointDescriptor(
|
||||
mode: DirectSingleAgentEndpointMode.https,
|
||||
baseUri: normalizedBase,
|
||||
websocketUri: normalizedBase.replace(scheme: 'wss'),
|
||||
);
|
||||
}
|
||||
return DirectSingleAgentEndpointDescriptor(
|
||||
mode: DirectSingleAgentEndpointMode.unsupported,
|
||||
baseUri: normalizedBase,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,881 @@
|
||||
part of 'direct_single_agent_app_server_client.dart';
|
||||
|
||||
class _ResolvedSingleAgentTransport {
|
||||
const _ResolvedSingleAgentTransport({
|
||||
required this.kind,
|
||||
required this.endpoint,
|
||||
required this.workspaceRefKind,
|
||||
this.websocket,
|
||||
this.rest,
|
||||
});
|
||||
|
||||
final _DirectSingleAgentTransportKind kind;
|
||||
final Uri endpoint;
|
||||
final WorkspaceRefKind workspaceRefKind;
|
||||
final _DirectSingleAgentWebSocketTransport? websocket;
|
||||
final _DirectSingleAgentRestTransport? rest;
|
||||
}
|
||||
|
||||
class _ResolvedDirectThread {
|
||||
const _ResolvedDirectThread({
|
||||
required this.threadId,
|
||||
this.workingDirectory = '',
|
||||
});
|
||||
|
||||
final String threadId;
|
||||
final String workingDirectory;
|
||||
}
|
||||
|
||||
class _DirectSingleAgentWebSocketTransport {
|
||||
final Map<String, _DirectAppServerConnection> _activeConnections =
|
||||
<String, _DirectAppServerConnection>{};
|
||||
final Map<String, String> _threadIds = <String, String>{};
|
||||
final Map<String, String> _threadWorkingDirectories = <String, String>{};
|
||||
final Set<String> _abortedSessions = <String>{};
|
||||
|
||||
Future<void> probe(Uri endpoint, {required String gatewayToken}) async {
|
||||
_DirectAppServerConnection? connection;
|
||||
try {
|
||||
connection = await _DirectAppServerConnection.connect(
|
||||
endpoint,
|
||||
gatewayToken: gatewayToken,
|
||||
);
|
||||
await connection.initialize();
|
||||
} finally {
|
||||
await connection?.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<DirectSingleAgentRunResult> run(
|
||||
DirectSingleAgentRunRequest request, {
|
||||
required Uri endpoint,
|
||||
required WorkspaceRefKind workspaceRefKind,
|
||||
}) async {
|
||||
final normalizedSessionId = request.sessionId.trim();
|
||||
if (normalizedSessionId.isEmpty) {
|
||||
return const DirectSingleAgentRunResult(
|
||||
success: false,
|
||||
output: '',
|
||||
errorMessage: 'Single-agent session id is missing.',
|
||||
);
|
||||
}
|
||||
|
||||
_abortedSessions.remove(normalizedSessionId);
|
||||
final connection = await _DirectAppServerConnection.connect(
|
||||
endpoint,
|
||||
gatewayToken: request.gatewayToken,
|
||||
);
|
||||
_activeConnections[normalizedSessionId] = connection;
|
||||
|
||||
try {
|
||||
await connection.initialize();
|
||||
final resolvedThread = await _ensureThread(
|
||||
connection,
|
||||
sessionId: normalizedSessionId,
|
||||
workingDirectory: request.workingDirectory,
|
||||
model: request.model,
|
||||
);
|
||||
final threadId = resolvedThread.threadId;
|
||||
final resolvedWorkingDirectory = resolvedThread.workingDirectory.trim();
|
||||
|
||||
final output = StringBuffer();
|
||||
String resolvedModel = '';
|
||||
final completion = Completer<DirectSingleAgentRunResult>();
|
||||
late final StreamSubscription<Map<String, dynamic>> subscription;
|
||||
subscription = connection.notifications.listen(
|
||||
(notification) {
|
||||
final method = notification['method']?.toString().trim() ?? '';
|
||||
final params = _asMap(notification['params']);
|
||||
if (params['threadId']?.toString() != threadId) {
|
||||
return;
|
||||
}
|
||||
if (method == 'item/agentMessage/delta') {
|
||||
final delta = params['delta']?.toString() ?? '';
|
||||
if (delta.isNotEmpty) {
|
||||
output.write(delta);
|
||||
request.onOutput?.call(delta);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (method == 'turn/completed' && !completion.isCompleted) {
|
||||
completion.complete(
|
||||
DirectSingleAgentRunResult(
|
||||
success: true,
|
||||
output: output.toString(),
|
||||
errorMessage: '',
|
||||
resolvedModel: resolvedModel,
|
||||
resolvedWorkingDirectory: resolvedWorkingDirectory,
|
||||
resolvedWorkspaceRefKind: workspaceRefKind,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if ((method == 'turn/failed' || method == 'turn/error') &&
|
||||
!completion.isCompleted) {
|
||||
final aborted =
|
||||
_abortedSessions.contains(normalizedSessionId) ||
|
||||
(params['message']?.toString().toLowerCase().contains(
|
||||
'abort',
|
||||
) ??
|
||||
false);
|
||||
completion.complete(
|
||||
DirectSingleAgentRunResult(
|
||||
success: false,
|
||||
output: output.toString(),
|
||||
aborted: aborted,
|
||||
resolvedModel: resolvedModel,
|
||||
resolvedWorkingDirectory: resolvedWorkingDirectory,
|
||||
resolvedWorkspaceRefKind: workspaceRefKind,
|
||||
errorMessage:
|
||||
params['message']?.toString() ??
|
||||
params['error']?.toString() ??
|
||||
'Single-agent app-server turn failed.',
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
if (!completion.isCompleted) {
|
||||
completion.complete(
|
||||
DirectSingleAgentRunResult(
|
||||
success: false,
|
||||
output: output.toString(),
|
||||
errorMessage: error.toString(),
|
||||
aborted: _abortedSessions.contains(normalizedSessionId),
|
||||
resolvedModel: resolvedModel,
|
||||
resolvedWorkingDirectory: resolvedWorkingDirectory,
|
||||
resolvedWorkspaceRefKind: workspaceRefKind,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
if (!completion.isCompleted) {
|
||||
completion.complete(
|
||||
DirectSingleAgentRunResult(
|
||||
success: false,
|
||||
output: output.toString(),
|
||||
errorMessage: _abortedSessions.contains(normalizedSessionId)
|
||||
? 'Single-agent app-server run aborted.'
|
||||
: 'Single-agent app-server connection closed before completion.',
|
||||
aborted: _abortedSessions.contains(normalizedSessionId),
|
||||
resolvedModel: resolvedModel,
|
||||
resolvedWorkingDirectory: resolvedWorkingDirectory,
|
||||
resolvedWorkspaceRefKind: workspaceRefKind,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
final input = <Map<String, dynamic>>[
|
||||
<String, dynamic>{'type': 'text', 'text': request.prompt},
|
||||
for (final skill in request.selectedSkills)
|
||||
if (skill.label.trim().isNotEmpty &&
|
||||
skill.sourcePath.trim().isNotEmpty)
|
||||
<String, dynamic>{
|
||||
'type': 'skill',
|
||||
'name': skill.label.trim(),
|
||||
'path': skill.sourcePath.trim(),
|
||||
},
|
||||
];
|
||||
final started = await connection.request(
|
||||
'turn/start',
|
||||
params: <String, dynamic>{'threadId': threadId, 'input': input},
|
||||
);
|
||||
resolvedModel = _extractModel(started) ?? resolvedModel;
|
||||
return await completion.future.timeout(
|
||||
const Duration(minutes: 10),
|
||||
onTimeout: () => DirectSingleAgentRunResult(
|
||||
success: false,
|
||||
output: output.toString(),
|
||||
errorMessage: 'Single-agent app-server request timed out.',
|
||||
aborted: _abortedSessions.contains(normalizedSessionId),
|
||||
resolvedModel: resolvedModel,
|
||||
resolvedWorkingDirectory: resolvedWorkingDirectory,
|
||||
resolvedWorkspaceRefKind: workspaceRefKind,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await subscription.cancel();
|
||||
}
|
||||
} catch (error) {
|
||||
return DirectSingleAgentRunResult(
|
||||
success: false,
|
||||
output: '',
|
||||
errorMessage: error.toString(),
|
||||
aborted: _abortedSessions.contains(normalizedSessionId),
|
||||
resolvedModel: '',
|
||||
resolvedWorkingDirectory: request.workingDirectory,
|
||||
resolvedWorkspaceRefKind: workspaceRefKind,
|
||||
);
|
||||
} finally {
|
||||
_activeConnections.remove(normalizedSessionId);
|
||||
await connection.close();
|
||||
_abortedSessions.remove(normalizedSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> abort(String sessionId) async {
|
||||
final normalizedSessionId = sessionId.trim();
|
||||
if (normalizedSessionId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_abortedSessions.add(normalizedSessionId);
|
||||
final connection = _activeConnections[normalizedSessionId];
|
||||
final threadId = _threadIds[normalizedSessionId];
|
||||
if (connection == null || threadId == null || threadId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await connection.request(
|
||||
'turn/interrupt',
|
||||
params: <String, dynamic>{'threadId': threadId},
|
||||
);
|
||||
} catch (_) {
|
||||
// Best effort only.
|
||||
}
|
||||
await connection.close();
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
final connections = _activeConnections.values.toList(growable: false);
|
||||
_activeConnections.clear();
|
||||
for (final connection in connections) {
|
||||
await connection.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<_ResolvedDirectThread> _ensureThread(
|
||||
_DirectAppServerConnection connection, {
|
||||
required String sessionId,
|
||||
required String workingDirectory,
|
||||
required String model,
|
||||
}) async {
|
||||
final normalizedWorkingDirectory = workingDirectory.trim();
|
||||
final existingThreadId = _threadIds[sessionId]?.trim() ?? '';
|
||||
final existingWorkingDirectory =
|
||||
_threadWorkingDirectories[sessionId]?.trim() ?? '';
|
||||
final canReuseExistingThread =
|
||||
existingThreadId.isNotEmpty &&
|
||||
(normalizedWorkingDirectory.isEmpty ||
|
||||
(existingWorkingDirectory.isNotEmpty &&
|
||||
existingWorkingDirectory == normalizedWorkingDirectory));
|
||||
if (existingThreadId.isNotEmpty) {
|
||||
if (!canReuseExistingThread) {
|
||||
_threadIds.remove(sessionId);
|
||||
_threadWorkingDirectories.remove(sessionId);
|
||||
}
|
||||
}
|
||||
if (canReuseExistingThread) {
|
||||
try {
|
||||
final resumed = await connection.request(
|
||||
'thread/resume',
|
||||
params: <String, dynamic>{
|
||||
'threadId': existingThreadId,
|
||||
if (normalizedWorkingDirectory.isNotEmpty)
|
||||
'cwd': normalizedWorkingDirectory,
|
||||
},
|
||||
);
|
||||
final resumedId = _extractThreadId(resumed) ?? existingThreadId;
|
||||
final resumedWorkingDirectory =
|
||||
_extractThreadPath(resumed)?.trim() ?? normalizedWorkingDirectory;
|
||||
_threadIds[sessionId] = resumedId;
|
||||
if (resumedWorkingDirectory.isNotEmpty) {
|
||||
_threadWorkingDirectories[sessionId] = resumedWorkingDirectory;
|
||||
}
|
||||
return _ResolvedDirectThread(
|
||||
threadId: resumedId,
|
||||
workingDirectory: resumedWorkingDirectory,
|
||||
);
|
||||
} catch (_) {
|
||||
_threadIds.remove(sessionId);
|
||||
_threadWorkingDirectories.remove(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
final created = await connection.request(
|
||||
'thread/start',
|
||||
params: <String, dynamic>{
|
||||
if (normalizedWorkingDirectory.isNotEmpty)
|
||||
'cwd': normalizedWorkingDirectory,
|
||||
if (model.trim().isNotEmpty) 'model': model.trim(),
|
||||
},
|
||||
);
|
||||
final threadId = _extractThreadId(created) ?? '';
|
||||
if (threadId.isEmpty) {
|
||||
throw StateError('Single-agent app-server returned an empty thread id.');
|
||||
}
|
||||
final createdWorkingDirectory =
|
||||
_extractThreadPath(created)?.trim() ?? normalizedWorkingDirectory;
|
||||
_threadIds[sessionId] = threadId;
|
||||
if (createdWorkingDirectory.isNotEmpty) {
|
||||
_threadWorkingDirectories[sessionId] = createdWorkingDirectory;
|
||||
}
|
||||
return _ResolvedDirectThread(
|
||||
threadId: threadId,
|
||||
workingDirectory: createdWorkingDirectory,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DirectSingleAgentRestTransport {
|
||||
final Map<String, String> _restSessionIds = <String, String>{};
|
||||
final Map<String, String> _restSessionWorkingDirectories = <String, String>{};
|
||||
final Set<String> _abortedSessions = <String>{};
|
||||
|
||||
Future<void> probe(Uri base, {required String gatewayToken}) async {
|
||||
await _fetchJson(
|
||||
_buildRestUri(base, '/global/health'),
|
||||
gatewayToken: gatewayToken,
|
||||
);
|
||||
}
|
||||
|
||||
Future<DirectSingleAgentRunResult> run(
|
||||
DirectSingleAgentRunRequest request, {
|
||||
required Uri base,
|
||||
required WorkspaceRefKind workspaceRefKind,
|
||||
}) async {
|
||||
final normalizedSessionId = request.sessionId.trim();
|
||||
if (normalizedSessionId.isEmpty) {
|
||||
return const DirectSingleAgentRunResult(
|
||||
success: false,
|
||||
output: '',
|
||||
errorMessage: 'Single-agent session id is missing.',
|
||||
);
|
||||
}
|
||||
|
||||
_abortedSessions.remove(normalizedSessionId);
|
||||
final remoteSessionId = await _ensureRestSession(
|
||||
base,
|
||||
sessionId: normalizedSessionId,
|
||||
workingDirectory: request.workingDirectory,
|
||||
gatewayToken: request.gatewayToken,
|
||||
);
|
||||
|
||||
final output = StringBuffer();
|
||||
final completion = Completer<DirectSingleAgentRunResult>();
|
||||
String? activeAssistantMessageId;
|
||||
String? lastAssistantText;
|
||||
var busySeen = false;
|
||||
|
||||
void completeFailure(String message) {
|
||||
if (completion.isCompleted) {
|
||||
return;
|
||||
}
|
||||
completion.complete(
|
||||
DirectSingleAgentRunResult(
|
||||
success: false,
|
||||
output: output.toString(),
|
||||
errorMessage: message,
|
||||
aborted: _abortedSessions.contains(normalizedSessionId),
|
||||
resolvedModel: request.model,
|
||||
resolvedWorkingDirectory: request.workingDirectory,
|
||||
resolvedWorkspaceRefKind: workspaceRefKind,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final eventClient = HttpClient()
|
||||
..connectionTimeout = const Duration(seconds: 8);
|
||||
late final HttpClientRequest eventRequest;
|
||||
late final HttpClientResponse eventResponse;
|
||||
StreamSubscription<String>? lineSubscription;
|
||||
|
||||
void completeSuccess() {
|
||||
if (completion.isCompleted) {
|
||||
return;
|
||||
}
|
||||
final resolvedOutput = output.toString().trim().isNotEmpty
|
||||
? output.toString()
|
||||
: (lastAssistantText ?? '');
|
||||
if (resolvedOutput.trim().isEmpty) {
|
||||
completeFailure(
|
||||
'OpenCode REST session completed without assistant content.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
completion.complete(
|
||||
DirectSingleAgentRunResult(
|
||||
success: true,
|
||||
output: resolvedOutput,
|
||||
errorMessage: '',
|
||||
resolvedModel: request.model,
|
||||
resolvedWorkingDirectory: request.workingDirectory,
|
||||
resolvedWorkspaceRefKind: workspaceRefKind,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final eventUri = _buildRestUri(base, '/global/event');
|
||||
eventRequest = await eventClient.getUrl(eventUri);
|
||||
eventRequest.headers.set(HttpHeaders.acceptHeader, 'text/event-stream');
|
||||
final normalizedToken = request.gatewayToken.trim();
|
||||
if (normalizedToken.isNotEmpty) {
|
||||
eventRequest.headers.set(
|
||||
HttpHeaders.authorizationHeader,
|
||||
'Bearer $normalizedToken',
|
||||
);
|
||||
}
|
||||
eventResponse = await eventRequest.close();
|
||||
lineSubscription = eventResponse
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.listen(
|
||||
(line) {
|
||||
if (!line.startsWith('data: ')) {
|
||||
return;
|
||||
}
|
||||
final event = _decodeMap(line.substring(6));
|
||||
final payload = _asMap(event['payload']);
|
||||
final type = payload['type']?.toString().trim() ?? '';
|
||||
final properties = _asMap(payload['properties']);
|
||||
if (properties['sessionID']?.toString().trim() !=
|
||||
remoteSessionId) {
|
||||
return;
|
||||
}
|
||||
if (type == 'session.status') {
|
||||
final status = _asMap(properties['status']);
|
||||
final statusType = status['type']?.toString().trim() ?? '';
|
||||
if (statusType == 'busy') {
|
||||
busySeen = true;
|
||||
}
|
||||
if (statusType == 'idle' && busySeen) {
|
||||
completeSuccess();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (type == 'session.idle' && busySeen) {
|
||||
completeSuccess();
|
||||
return;
|
||||
}
|
||||
if (type == 'session.error' && !completion.isCompleted) {
|
||||
final error = _asMap(properties['error']);
|
||||
completeFailure(
|
||||
error['message']?.toString() ??
|
||||
error['name']?.toString() ??
|
||||
'OpenCode session failed.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (type == 'message.updated') {
|
||||
final info = _asMap(properties['info']);
|
||||
if (info['role']?.toString().trim() == 'assistant') {
|
||||
activeAssistantMessageId = info['id']?.toString().trim();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (type == 'message.part.delta') {
|
||||
final part = _asMap(properties['part']);
|
||||
if (activeAssistantMessageId != null &&
|
||||
part['messageID']?.toString().trim() ==
|
||||
activeAssistantMessageId) {
|
||||
final delta =
|
||||
properties['text']?.toString() ??
|
||||
properties['delta']?.toString() ??
|
||||
'';
|
||||
if (delta.isNotEmpty) {
|
||||
output.write(delta);
|
||||
request.onOutput?.call(delta);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (type == 'message.part.updated') {
|
||||
final part = _asMap(properties['part']);
|
||||
if (activeAssistantMessageId != null &&
|
||||
part['messageID']?.toString().trim() ==
|
||||
activeAssistantMessageId &&
|
||||
part['type']?.toString().trim() == 'text') {
|
||||
lastAssistantText = part['text']?.toString();
|
||||
if ((lastAssistantText?.trim().isNotEmpty ?? false)) {
|
||||
completeSuccess();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {},
|
||||
onDone: () {},
|
||||
cancelOnError: true,
|
||||
);
|
||||
|
||||
await _postJson(
|
||||
_buildRestUri(
|
||||
base,
|
||||
'/session/$remoteSessionId/message',
|
||||
queryParameters: <String, String>{
|
||||
'directory': request.workingDirectory,
|
||||
},
|
||||
),
|
||||
body: <String, dynamic>{
|
||||
'agent': 'build',
|
||||
'parts': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'type': 'text', 'text': request.prompt},
|
||||
],
|
||||
},
|
||||
gatewayToken: request.gatewayToken,
|
||||
);
|
||||
unawaited(
|
||||
_pollRestAssistantMessage(
|
||||
base,
|
||||
remoteSessionId: remoteSessionId,
|
||||
workingDirectory: request.workingDirectory,
|
||||
gatewayToken: request.gatewayToken,
|
||||
onResolved: (text) {
|
||||
if (text.trim().isNotEmpty) {
|
||||
lastAssistantText = text;
|
||||
if (output.toString().trim().isEmpty) {
|
||||
output.write(text);
|
||||
request.onOutput?.call(text);
|
||||
}
|
||||
completeSuccess();
|
||||
}
|
||||
},
|
||||
onError: completeFailure,
|
||||
),
|
||||
);
|
||||
|
||||
return await completion.future.timeout(
|
||||
const Duration(minutes: 10),
|
||||
onTimeout: () => DirectSingleAgentRunResult(
|
||||
success: false,
|
||||
output: output.toString(),
|
||||
errorMessage: 'OpenCode REST request timed out.',
|
||||
aborted: _abortedSessions.contains(normalizedSessionId),
|
||||
resolvedModel: request.model,
|
||||
resolvedWorkingDirectory: request.workingDirectory,
|
||||
resolvedWorkspaceRefKind: workspaceRefKind,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
return DirectSingleAgentRunResult(
|
||||
success: false,
|
||||
output: output.toString(),
|
||||
errorMessage: error.toString(),
|
||||
aborted: _abortedSessions.contains(normalizedSessionId),
|
||||
resolvedModel: request.model,
|
||||
resolvedWorkingDirectory: request.workingDirectory,
|
||||
resolvedWorkspaceRefKind: workspaceRefKind,
|
||||
);
|
||||
} finally {
|
||||
unawaited(lineSubscription?.cancel());
|
||||
eventClient.close(force: true);
|
||||
_abortedSessions.remove(normalizedSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> abort(
|
||||
String sessionId, {
|
||||
required List<Uri> candidateBases,
|
||||
}) async {
|
||||
final normalizedSessionId = sessionId.trim();
|
||||
if (normalizedSessionId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_abortedSessions.add(normalizedSessionId);
|
||||
final restSessionId = _restSessionIds[normalizedSessionId]?.trim() ?? '';
|
||||
if (restSessionId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
for (final base in candidateBases) {
|
||||
try {
|
||||
await _postJson(
|
||||
_buildRestUri(base, '/session/$restSessionId/abort'),
|
||||
body: null,
|
||||
gatewayToken: '',
|
||||
);
|
||||
} catch (_) {
|
||||
// Best effort only.
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _ensureRestSession(
|
||||
Uri base, {
|
||||
required String sessionId,
|
||||
required String workingDirectory,
|
||||
required String gatewayToken,
|
||||
}) async {
|
||||
final normalizedWorkingDirectory = workingDirectory.trim();
|
||||
final existing = _restSessionIds[sessionId]?.trim() ?? '';
|
||||
if (existing.isNotEmpty) {
|
||||
final existingWorkingDirectory =
|
||||
_restSessionWorkingDirectories[sessionId]?.trim() ?? '';
|
||||
final canReuseExistingSession =
|
||||
normalizedWorkingDirectory.isEmpty ||
|
||||
(existingWorkingDirectory.isNotEmpty &&
|
||||
existingWorkingDirectory == normalizedWorkingDirectory);
|
||||
if (canReuseExistingSession) {
|
||||
return existing;
|
||||
}
|
||||
_restSessionIds.remove(sessionId);
|
||||
_restSessionWorkingDirectories.remove(sessionId);
|
||||
}
|
||||
final created = await _postJson(
|
||||
_buildRestUri(
|
||||
base,
|
||||
'/session',
|
||||
queryParameters: <String, String>{
|
||||
'directory': normalizedWorkingDirectory,
|
||||
},
|
||||
),
|
||||
body: <String, dynamic>{'title': sessionId},
|
||||
gatewayToken: gatewayToken,
|
||||
);
|
||||
final createdId = created['id']?.toString().trim() ?? '';
|
||||
if (createdId.isEmpty) {
|
||||
throw StateError('OpenCode REST endpoint returned an empty session id.');
|
||||
}
|
||||
_restSessionIds[sessionId] = createdId;
|
||||
if (normalizedWorkingDirectory.isNotEmpty) {
|
||||
_restSessionWorkingDirectories[sessionId] = normalizedWorkingDirectory;
|
||||
}
|
||||
return createdId;
|
||||
}
|
||||
|
||||
Future<void> _pollRestAssistantMessage(
|
||||
Uri base, {
|
||||
required String remoteSessionId,
|
||||
required String workingDirectory,
|
||||
required String gatewayToken,
|
||||
required void Function(String text) onResolved,
|
||||
required void Function(String message) onError,
|
||||
}) async {
|
||||
String? previousText;
|
||||
var stableCount = 0;
|
||||
for (var attempt = 0; attempt < 100; attempt++) {
|
||||
try {
|
||||
final items = await _fetchJsonList(
|
||||
_buildRestUri(
|
||||
base,
|
||||
'/session/$remoteSessionId/message',
|
||||
queryParameters: <String, String>{
|
||||
'directory': workingDirectory,
|
||||
'limit': '20',
|
||||
},
|
||||
),
|
||||
gatewayToken: gatewayToken,
|
||||
);
|
||||
final text = _latestAssistantTextFromRestMessages(items);
|
||||
if (text.trim().isNotEmpty) {
|
||||
if (text == previousText) {
|
||||
stableCount += 1;
|
||||
} else {
|
||||
previousText = text;
|
||||
stableCount = 1;
|
||||
}
|
||||
if (stableCount >= 2) {
|
||||
onResolved(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
onError(error.toString());
|
||||
return;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
}
|
||||
onError('OpenCode REST session completed without assistant content.');
|
||||
}
|
||||
|
||||
String _latestAssistantTextFromRestMessages(List<Object?> items) {
|
||||
for (final raw in items.reversed) {
|
||||
final item = _asMap(raw);
|
||||
final info = _asMap(item['info']);
|
||||
if (info['role']?.toString().trim() != 'assistant') {
|
||||
continue;
|
||||
}
|
||||
final parts = item['parts'];
|
||||
if (parts is! List) {
|
||||
continue;
|
||||
}
|
||||
for (final rawPart in parts) {
|
||||
final part = _asMap(rawPart);
|
||||
if (part['type']?.toString().trim() == 'text') {
|
||||
final text = part['text']?.toString() ?? '';
|
||||
if (text.trim().isNotEmpty) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
class _DirectAppServerConnection {
|
||||
_DirectAppServerConnection(this._socket);
|
||||
|
||||
final WebSocket _socket;
|
||||
final StreamController<Map<String, dynamic>> _notifications =
|
||||
StreamController<Map<String, dynamic>>.broadcast();
|
||||
final Map<String, Completer<Map<String, dynamic>>> _pendingRequests =
|
||||
<String, Completer<Map<String, dynamic>>>{};
|
||||
int _requestCounter = 0;
|
||||
bool _initialized = false;
|
||||
StreamSubscription<dynamic>? _subscription;
|
||||
|
||||
Stream<Map<String, dynamic>> get notifications => _notifications.stream;
|
||||
|
||||
static Future<_DirectAppServerConnection> connect(
|
||||
Uri endpoint, {
|
||||
String gatewayToken = '',
|
||||
}) async {
|
||||
final headers = <String, dynamic>{};
|
||||
final normalizedToken = gatewayToken.trim();
|
||||
if (normalizedToken.isNotEmpty) {
|
||||
headers[HttpHeaders.authorizationHeader] = 'Bearer $normalizedToken';
|
||||
}
|
||||
final socket =
|
||||
await WebSocket.connect(
|
||||
endpoint.toString(),
|
||||
headers: headers.isEmpty ? null : headers,
|
||||
).timeout(
|
||||
const Duration(seconds: 8),
|
||||
onTimeout: () => throw TimeoutException(
|
||||
'Single-agent app-server websocket connect timed out.',
|
||||
),
|
||||
);
|
||||
final connection = _DirectAppServerConnection(socket);
|
||||
connection._attach();
|
||||
return connection;
|
||||
}
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_initialized) {
|
||||
return;
|
||||
}
|
||||
await request(
|
||||
'initialize',
|
||||
params: const <String, dynamic>{
|
||||
'clientInfo': <String, dynamic>{'name': 'xworkmate', 'version': '0'},
|
||||
'capabilities': <String, dynamic>{
|
||||
'optOutNotificationMethods': <String>[],
|
||||
},
|
||||
},
|
||||
);
|
||||
await notify('initialized', params: const <String, dynamic>{});
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> request(
|
||||
String method, {
|
||||
Map<String, dynamic> params = const <String, dynamic>{},
|
||||
Duration timeout = const Duration(seconds: 60),
|
||||
}) async {
|
||||
final id = '${DateTime.now().microsecondsSinceEpoch}-${_requestCounter++}';
|
||||
final completer = Completer<Map<String, dynamic>>();
|
||||
_pendingRequests[id] = completer;
|
||||
_socket.add(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'id': id,
|
||||
'method': method,
|
||||
'params': params,
|
||||
}),
|
||||
);
|
||||
return completer.future.timeout(
|
||||
timeout,
|
||||
onTimeout: () {
|
||||
_pendingRequests.remove(id);
|
||||
throw TimeoutException(
|
||||
'Single-agent app-server request $method timed out.',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> notify(
|
||||
String method, {
|
||||
required Map<String, dynamic> params,
|
||||
}) async {
|
||||
_socket.add(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'method': method,
|
||||
'params': params,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void _attach() {
|
||||
_subscription = _socket.listen(
|
||||
(dynamic raw) {
|
||||
final message = _decodeMap(raw);
|
||||
final id = message['id']?.toString();
|
||||
if (id != null && message.containsKey('result')) {
|
||||
final completer = _pendingRequests.remove(id);
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.complete(_asMap(message['result']));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (id != null && message.containsKey('error')) {
|
||||
final completer = _pendingRequests.remove(id);
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
final error = _asMap(message['error']);
|
||||
completer.completeError(
|
||||
StateError(
|
||||
error['message']?.toString() ??
|
||||
'Single-agent app-server request failed.',
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.containsKey('method')) {
|
||||
_notifications.add(message);
|
||||
}
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
for (final completer in _pendingRequests.values) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(error);
|
||||
}
|
||||
}
|
||||
_pendingRequests.clear();
|
||||
_notifications.addError(error, stackTrace);
|
||||
},
|
||||
onDone: () {
|
||||
final error = StateError(
|
||||
'Single-agent app-server websocket closed unexpectedly.',
|
||||
);
|
||||
for (final completer in _pendingRequests.values) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(error);
|
||||
}
|
||||
}
|
||||
_pendingRequests.clear();
|
||||
if (!_notifications.isClosed) {
|
||||
unawaited(_notifications.close());
|
||||
}
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
for (final completer in _pendingRequests.values) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(
|
||||
StateError('Single-agent app-server connection closed.'),
|
||||
);
|
||||
}
|
||||
}
|
||||
_pendingRequests.clear();
|
||||
if (!_notifications.isClosed) {
|
||||
await _notifications.close();
|
||||
}
|
||||
try {
|
||||
await _socket.close();
|
||||
} catch (_) {
|
||||
// Best effort only.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,4 +11,7 @@ import 'aris_llm_chat_client.dart';
|
||||
import 'multi_agent_frameworks.dart';
|
||||
import 'runtime_models.dart';
|
||||
|
||||
part 'multi_agent_orchestrator_protocol.part.dart';
|
||||
part 'multi_agent_orchestrator_workflow.part.dart';
|
||||
part 'multi_agent_orchestrator_support.part.dart';
|
||||
part 'multi_agent_orchestrator_core.part.dart';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
186
lib/runtime/multi_agent_orchestrator_protocol.part.dart
Normal file
186
lib/runtime/multi_agent_orchestrator_protocol.part.dart
Normal file
@ -0,0 +1,186 @@
|
||||
part of 'multi_agent_orchestrator.dart';
|
||||
|
||||
typedef CliProcessStarter =
|
||||
Future<Process> Function(
|
||||
String executable,
|
||||
List<String> arguments, {
|
||||
Map<String, String>? environment,
|
||||
String? workingDirectory,
|
||||
});
|
||||
|
||||
/// 协作日志条目
|
||||
class CollaborationLogEntry {
|
||||
const CollaborationLogEntry({
|
||||
required this.timestamp,
|
||||
required this.level,
|
||||
required this.emoji,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
final DateTime timestamp;
|
||||
final CollaborationLogLevel level;
|
||||
final String emoji;
|
||||
final String message;
|
||||
|
||||
String get formattedTime {
|
||||
final h = timestamp.hour.toString().padLeft(2, '0');
|
||||
final m = timestamp.minute.toString().padLeft(2, '0');
|
||||
final s = timestamp.second.toString().padLeft(2, '0');
|
||||
return '$h:$m:$s';
|
||||
}
|
||||
}
|
||||
|
||||
enum CollaborationLogLevel { debug, info, warning, error, success }
|
||||
|
||||
/// CLI 执行结果
|
||||
class CliResult {
|
||||
const CliResult({
|
||||
required this.output,
|
||||
required this.error,
|
||||
required this.exitCode,
|
||||
});
|
||||
|
||||
final String output;
|
||||
final String error;
|
||||
final int exitCode;
|
||||
|
||||
bool get success => exitCode == 0 && error.isEmpty;
|
||||
}
|
||||
|
||||
/// Architect 执行结果
|
||||
class ArchitectResult {
|
||||
ArchitectResult({
|
||||
required this.output,
|
||||
required this.decomposedTasks,
|
||||
required this.duration,
|
||||
});
|
||||
|
||||
final String output;
|
||||
final List<SubTask> decomposedTasks;
|
||||
final Duration duration;
|
||||
}
|
||||
|
||||
/// Engineer 执行结果
|
||||
class EngineerResult {
|
||||
EngineerResult({
|
||||
required this.output,
|
||||
required this.codeOutput,
|
||||
required this.completedTasks,
|
||||
required this.duration,
|
||||
});
|
||||
|
||||
final String output;
|
||||
String codeOutput;
|
||||
final List<SubTask> completedTasks;
|
||||
final Duration duration;
|
||||
}
|
||||
|
||||
/// Tester 执行结果
|
||||
class TesterResult {
|
||||
TesterResult({
|
||||
required this.output,
|
||||
required this.score,
|
||||
required this.feedback,
|
||||
required this.duration,
|
||||
});
|
||||
|
||||
final String output;
|
||||
final int score;
|
||||
final String feedback;
|
||||
final Duration duration;
|
||||
}
|
||||
|
||||
/// 协作步骤
|
||||
class CollaborationStep {
|
||||
const CollaborationStep({
|
||||
required this.role,
|
||||
required this.status,
|
||||
required this.output,
|
||||
required this.duration,
|
||||
this.iteration,
|
||||
this.score,
|
||||
});
|
||||
|
||||
final String role;
|
||||
final StepStatus status;
|
||||
final String output;
|
||||
final Duration duration;
|
||||
final int? iteration;
|
||||
final int? score;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'role': role,
|
||||
'status': status.name,
|
||||
'output': output,
|
||||
'durationMs': duration.inMilliseconds,
|
||||
if (iteration != null) 'iteration': iteration,
|
||||
if (score != null) 'score': score,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
enum StepStatus { pending, running, completed, failed }
|
||||
|
||||
/// 子任务
|
||||
class SubTask {
|
||||
const SubTask({
|
||||
required this.id,
|
||||
required this.description,
|
||||
required this.order,
|
||||
required this.type,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String description;
|
||||
final int order;
|
||||
final SubTaskType type;
|
||||
}
|
||||
|
||||
enum SubTaskType { design, implementation, testing, documentation, deployment }
|
||||
|
||||
/// 附件
|
||||
class CollaborationAttachment {
|
||||
const CollaborationAttachment({
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.path,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String description;
|
||||
final String path;
|
||||
}
|
||||
|
||||
/// 协作最终结果
|
||||
class CollaborationResult {
|
||||
const CollaborationResult({
|
||||
required this.success,
|
||||
required this.steps,
|
||||
required this.finalCode,
|
||||
required this.finalScore,
|
||||
required this.duration,
|
||||
required this.iterations,
|
||||
this.error,
|
||||
});
|
||||
|
||||
final bool success;
|
||||
final List<CollaborationStep> steps;
|
||||
final String finalCode;
|
||||
final int finalScore;
|
||||
final Duration duration;
|
||||
final int iterations;
|
||||
final String? error;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'success': success,
|
||||
'steps': steps.map((item) => item.toJson()).toList(growable: false),
|
||||
'finalCode': finalCode,
|
||||
'finalScore': finalScore,
|
||||
'durationMs': duration.inMilliseconds,
|
||||
'iterations': iterations,
|
||||
if (error != null) 'error': error,
|
||||
};
|
||||
}
|
||||
}
|
||||
280
lib/runtime/multi_agent_orchestrator_support.part.dart
Normal file
280
lib/runtime/multi_agent_orchestrator_support.part.dart
Normal file
@ -0,0 +1,280 @@
|
||||
part of 'multi_agent_orchestrator.dart';
|
||||
|
||||
extension _MultiAgentOrchestratorSupport on MultiAgentOrchestrator {
|
||||
String _openAiCompatibleBaseUrl({required String aiGatewayBaseUrl}) {
|
||||
if (_config.aiGatewayInjectionPolicy != AiGatewayInjectionPolicy.disabled &&
|
||||
aiGatewayBaseUrl.trim().isNotEmpty) {
|
||||
final normalized = aiGatewayBaseUrl.trim();
|
||||
return normalized.endsWith('/v1') ? normalized : '$normalized/v1';
|
||||
}
|
||||
final normalized = _config.ollamaEndpoint.trim();
|
||||
return normalized.endsWith('/v1') ? normalized : '$normalized/v1';
|
||||
}
|
||||
|
||||
String _openAiCompatibleApiKey({required String aiGatewayApiKey}) {
|
||||
if (_config.aiGatewayInjectionPolicy != AiGatewayInjectionPolicy.disabled &&
|
||||
aiGatewayApiKey.trim().isNotEmpty) {
|
||||
return aiGatewayApiKey.trim();
|
||||
}
|
||||
return 'ollama';
|
||||
}
|
||||
|
||||
String _systemPromptForRole(MultiAgentRole role) {
|
||||
return switch (role) {
|
||||
MultiAgentRole.architect =>
|
||||
'You are the architecture and documentation lane in a multi-agent coding workflow. Focus on requirements, acceptance evidence, task slicing, and milestones.',
|
||||
MultiAgentRole.engineer =>
|
||||
'You are the lead engineer in a multi-agent coding workflow. Produce implementation-oriented output for the critical path.',
|
||||
MultiAgentRole.testerDoc =>
|
||||
'You are the worker-review lane in a multi-agent coding workflow. Review, score, and suggest follow-up fixes and worker follow-ups.',
|
||||
};
|
||||
}
|
||||
|
||||
String _roleLabel(MultiAgentRole role) {
|
||||
return switch (role) {
|
||||
MultiAgentRole.architect => 'Architect',
|
||||
MultiAgentRole.engineer => 'Lead Engineer',
|
||||
MultiAgentRole.testerDoc => 'Worker/Review',
|
||||
};
|
||||
}
|
||||
|
||||
String _modelForRole(MultiAgentRole role) {
|
||||
return switch (role) {
|
||||
MultiAgentRole.architect => _config.architect.model,
|
||||
MultiAgentRole.engineer => _config.engineer.model,
|
||||
MultiAgentRole.testerDoc => _config.tester.model,
|
||||
};
|
||||
}
|
||||
|
||||
bool _prefersOllamaLaunch({required String tool, required String model}) {
|
||||
final normalizedTool = tool.trim().toLowerCase();
|
||||
final normalizedModel = model.trim();
|
||||
if (normalizedModel.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedTool != 'claude' &&
|
||||
normalizedTool != 'codex' &&
|
||||
normalizedTool != 'opencode') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
List<String> _buildOllamaLaunchArgs({
|
||||
required String tool,
|
||||
required String model,
|
||||
required String prompt,
|
||||
required String cwd,
|
||||
}) {
|
||||
final args = <String>['launch', tool, '--model', model];
|
||||
if (tool == 'claude') {
|
||||
args.add('--yes');
|
||||
args.addAll(<String>['--', '-p', prompt]);
|
||||
return args;
|
||||
}
|
||||
if (tool == 'codex') {
|
||||
args.addAll(<String>[
|
||||
'--',
|
||||
'exec',
|
||||
'--skip-git-repo-check',
|
||||
'--color',
|
||||
'never',
|
||||
if (cwd.isNotEmpty) ...<String>['-C', cwd],
|
||||
prompt,
|
||||
]);
|
||||
return args;
|
||||
}
|
||||
if (tool == 'opencode') {
|
||||
args.addAll(<String>[
|
||||
'--',
|
||||
'run',
|
||||
'--format',
|
||||
'default',
|
||||
if (cwd.isNotEmpty) ...<String>['--dir', cwd],
|
||||
prompt,
|
||||
]);
|
||||
return args;
|
||||
}
|
||||
args.addAll(<String>['--', '-p', prompt]);
|
||||
return args;
|
||||
}
|
||||
|
||||
void _throwIfAborted() {
|
||||
if (_abortRequested) {
|
||||
throw StateError('Multi-agent collaboration aborted.');
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 Architect 分解的任务
|
||||
List<SubTask> _parseDecomposedTasks(String architectOutput) {
|
||||
final tasks = <SubTask>[];
|
||||
final lines = architectOutput.split('\n');
|
||||
|
||||
var order = 1;
|
||||
for (final line in lines) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.isEmpty) continue;
|
||||
|
||||
// 匹配 "- 描述" 或 "1. 描述" 格式
|
||||
final dashMatch = RegExp(r'^[-*]\s+(.+)').firstMatch(trimmed);
|
||||
final numMatch = RegExp(r'^\d+[.、)]\s*(.+)').firstMatch(trimmed);
|
||||
|
||||
String? description;
|
||||
if (dashMatch != null) {
|
||||
description = dashMatch.group(1);
|
||||
} else if (numMatch != null) {
|
||||
description = numMatch.group(1);
|
||||
}
|
||||
|
||||
if (description != null && description.isNotEmpty) {
|
||||
// 去除复杂度等技术注释
|
||||
description = description.replaceAll(RegExp(r'\s*\|.*'), '').trim();
|
||||
|
||||
// 判断任务类型
|
||||
SubTaskType type = SubTaskType.implementation;
|
||||
final lower = description.toLowerCase();
|
||||
if (lower.contains('测试') || lower.contains('test')) {
|
||||
type = SubTaskType.testing;
|
||||
} else if (lower.contains('文档') || lower.contains('doc')) {
|
||||
type = SubTaskType.documentation;
|
||||
} else if (lower.contains('设计') || lower.contains('design')) {
|
||||
type = SubTaskType.design;
|
||||
} else if (lower.contains('部署') || lower.contains('deploy')) {
|
||||
type = SubTaskType.deployment;
|
||||
}
|
||||
|
||||
tasks.add(
|
||||
SubTask(
|
||||
id: order.toString(),
|
||||
description: description,
|
||||
order: order,
|
||||
type: type,
|
||||
),
|
||||
);
|
||||
order++;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果解析失败,至少返回一个包含完整需求的子任务
|
||||
if (tasks.isEmpty) {
|
||||
tasks.add(
|
||||
SubTask(
|
||||
id: '1',
|
||||
description: architectOutput.length > 200
|
||||
? '${architectOutput.substring(0, 200)}...'
|
||||
: architectOutput,
|
||||
order: 1,
|
||||
type: SubTaskType.implementation,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/// 解析审阅评分
|
||||
int _parseReviewScore(String output) {
|
||||
// 尝试匹配 "评分 (1-10)" 模式
|
||||
final patterns = [
|
||||
RegExp(r'评分\s*\(?[1100]\)?\s*[::]?\s*(\d+)'),
|
||||
RegExp(r'score\s*[::]?\s*(\d+)', caseSensitive: false),
|
||||
RegExp(r'评分[::\s]*(\d+)'),
|
||||
RegExp(r'\*\*(\d+)\s*/\s*10\*\*'),
|
||||
RegExp(r'(\d+)\s*/\s*10'),
|
||||
];
|
||||
|
||||
for (final pattern in patterns) {
|
||||
final match = pattern.firstMatch(output);
|
||||
if (match != null) {
|
||||
final scoreStr = match.group(1)!;
|
||||
final score = int.tryParse(
|
||||
scoreStr.replaceAll('1', '1').replaceAll('0', '0'),
|
||||
);
|
||||
if (score != null && score >= 1 && score <= 10) {
|
||||
return score;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 默认中等评分
|
||||
return 5;
|
||||
}
|
||||
|
||||
/// 提取审阅反馈
|
||||
String _extractFeedback(String output) {
|
||||
final feedbackIndex = output.indexOf(RegExp(r'##?\s*问题|##?\s*改进|##?\s*建议'));
|
||||
if (feedbackIndex >= 0) {
|
||||
final endIndex = output.indexOf(
|
||||
RegExp(r'##?\s*测试|##?\s*文档'),
|
||||
feedbackIndex + 1,
|
||||
);
|
||||
if (endIndex > feedbackIndex) {
|
||||
return output.substring(feedbackIndex, endIndex).trim();
|
||||
}
|
||||
return output.substring(feedbackIndex).trim();
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/// 构建 Ollama 环境变量
|
||||
Map<String, String> _buildCliEnvVars({
|
||||
required String tool,
|
||||
required String aiGatewayBaseUrl,
|
||||
required String aiGatewayApiKey,
|
||||
}) {
|
||||
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 (tool == '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 (tool == 'claude' || tool == 'codex') {
|
||||
baseEnv['ANTHROPIC_AUTH_TOKEN'] = 'ollama';
|
||||
baseEnv['ANTHROPIC_API_KEY'] = '';
|
||||
baseEnv['ANTHROPIC_BASE_URL'] = ollamaEndpoint;
|
||||
}
|
||||
return baseEnv;
|
||||
}
|
||||
|
||||
/// 解析 CLI 工具路径
|
||||
String _resolveCliPath(String tool) {
|
||||
switch (tool) {
|
||||
case 'claude':
|
||||
return 'claude';
|
||||
case 'codex':
|
||||
return 'codex';
|
||||
case 'gemini':
|
||||
return 'gemini';
|
||||
case 'opencode':
|
||||
return 'opencode';
|
||||
default:
|
||||
return tool;
|
||||
}
|
||||
}
|
||||
|
||||
void _emitEvent(
|
||||
void Function(MultiAgentRunEvent event)? onEvent,
|
||||
MultiAgentRunEvent event,
|
||||
) {
|
||||
onEvent?.call(event);
|
||||
}
|
||||
|
||||
}
|
||||
738
lib/runtime/multi_agent_orchestrator_workflow.part.dart
Normal file
738
lib/runtime/multi_agent_orchestrator_workflow.part.dart
Normal file
@ -0,0 +1,738 @@
|
||||
part of 'multi_agent_orchestrator.dart';
|
||||
|
||||
extension _MultiAgentOrchestratorWorkflow on MultiAgentOrchestrator {
|
||||
/// 运行 Architect(调度/文档分析)
|
||||
Future<ArchitectResult> _runArchitect(
|
||||
String task, {
|
||||
required FrameworkPreset preset,
|
||||
required List<String> selectedSkills,
|
||||
required String aiGatewayBaseUrl,
|
||||
required String aiGatewayApiKey,
|
||||
}) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
||||
try {
|
||||
// 根据配置选择 Architect 工具
|
||||
if (_config.architectEnabled) {
|
||||
final tool = await _resolveToolForRole(
|
||||
MultiAgentRole.architect,
|
||||
_config.architectTool,
|
||||
);
|
||||
final instructionBlock = await preset.roleInstructionBlock(
|
||||
role: MultiAgentRole.architect,
|
||||
tool: tool,
|
||||
selectedSkills: selectedSkills,
|
||||
);
|
||||
final result = await _runCliPrompt(
|
||||
role: MultiAgentRole.architect,
|
||||
tool: tool,
|
||||
model: _resolvedModelForRole(
|
||||
MultiAgentRole.architect,
|
||||
configuredModel: _config.architectModel,
|
||||
),
|
||||
prompt: _buildArchitectPrompt(task, selectedSkills, instructionBlock),
|
||||
cwd: '',
|
||||
aiGatewayBaseUrl: aiGatewayBaseUrl,
|
||||
aiGatewayApiKey: aiGatewayApiKey,
|
||||
);
|
||||
stopwatch.stop();
|
||||
|
||||
// 解析分解后的任务
|
||||
final tasks = _parseDecomposedTasks(result.output);
|
||||
return ArchitectResult(
|
||||
output: result.output,
|
||||
decomposedTasks: tasks,
|
||||
duration: stopwatch.elapsed,
|
||||
);
|
||||
} else {
|
||||
// Architect 被禁用,直接返回原任务作为单一子任务
|
||||
stopwatch.stop();
|
||||
return ArchitectResult(
|
||||
output: task,
|
||||
decomposedTasks: [
|
||||
SubTask(
|
||||
id: '1',
|
||||
description: task,
|
||||
order: 1,
|
||||
type: SubTaskType.implementation,
|
||||
),
|
||||
],
|
||||
duration: stopwatch.elapsed,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
stopwatch.stop();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 运行 Lead Engineer(主实现)
|
||||
Future<EngineerResult> _runEngineer(
|
||||
List<SubTask> tasks,
|
||||
String workingDirectory,
|
||||
List<CollaborationAttachment> attachments, {
|
||||
required FrameworkPreset preset,
|
||||
required List<String> selectedSkills,
|
||||
required String aiGatewayBaseUrl,
|
||||
required String aiGatewayApiKey,
|
||||
}) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final tool = await _resolveToolForRole(
|
||||
MultiAgentRole.engineer,
|
||||
_config.engineerTool,
|
||||
);
|
||||
final instructionBlock = await preset.roleInstructionBlock(
|
||||
role: MultiAgentRole.engineer,
|
||||
tool: tool,
|
||||
selectedSkills: selectedSkills,
|
||||
);
|
||||
|
||||
final taskList = tasks
|
||||
.map((t) => '## ${t.order}. ${t.description}')
|
||||
.join('\n\n');
|
||||
|
||||
final prompt =
|
||||
'''
|
||||
$instructionBlock
|
||||
|
||||
你是一个资深工程师,负责完成以下编码任务:
|
||||
|
||||
### 任务列表
|
||||
$taskList
|
||||
|
||||
### 工作目录
|
||||
$workingDirectory
|
||||
|
||||
### 附件信息
|
||||
${attachments.map((a) => '- ${a.name}: ${a.description}').join('\n')}
|
||||
|
||||
### 优先技能
|
||||
${selectedSkills.isEmpty ? '- 无' : selectedSkills.map((item) => '- $item').join('\n')}
|
||||
|
||||
请完成这些任务,输出完整的代码实现。
|
||||
''';
|
||||
|
||||
final result = await _runCliPrompt(
|
||||
role: MultiAgentRole.engineer,
|
||||
tool: tool,
|
||||
model: _resolvedModelForRole(
|
||||
MultiAgentRole.engineer,
|
||||
configuredModel: _config.engineerModel,
|
||||
),
|
||||
prompt: prompt,
|
||||
cwd: workingDirectory,
|
||||
aiGatewayBaseUrl: aiGatewayBaseUrl,
|
||||
aiGatewayApiKey: aiGatewayApiKey,
|
||||
);
|
||||
stopwatch.stop();
|
||||
|
||||
return EngineerResult(
|
||||
output: result.output,
|
||||
codeOutput: result.output,
|
||||
completedTasks: tasks,
|
||||
duration: stopwatch.elapsed,
|
||||
);
|
||||
}
|
||||
|
||||
/// 运行 Worker/Review(代码审阅)
|
||||
Future<TesterResult> _runTester(
|
||||
String codeOutput, {
|
||||
required FrameworkPreset preset,
|
||||
required String aiGatewayBaseUrl,
|
||||
required String aiGatewayApiKey,
|
||||
}) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final tool = await _resolveToolForRole(
|
||||
MultiAgentRole.testerDoc,
|
||||
_config.testerTool,
|
||||
);
|
||||
final instructionBlock = await preset.roleInstructionBlock(
|
||||
role: MultiAgentRole.testerDoc,
|
||||
tool: tool,
|
||||
selectedSkills: const <String>[],
|
||||
);
|
||||
|
||||
final prompt =
|
||||
'''
|
||||
$instructionBlock
|
||||
|
||||
请审阅以下代码,并按以下格式输出:
|
||||
|
||||
## 评分 (1-10)
|
||||
[1-10 的分数,10 最高]
|
||||
|
||||
## 问题列表
|
||||
[发现的问题,格式:- 问题描述 (严重程度: 高/中/低)]
|
||||
|
||||
## 改进建议
|
||||
[具体的改进建议]
|
||||
|
||||
## 测试用例
|
||||
```[语言]
|
||||
[生成的测试用例代码]
|
||||
```
|
||||
|
||||
## 文档建议
|
||||
[如有需要补充的文档说明]
|
||||
|
||||
### 待审阅代码
|
||||
${codeOutput.length > 4000 ? '${codeOutput.substring(0, 4000)}\n...[代码已截断]' : codeOutput}
|
||||
''';
|
||||
|
||||
final testerModel = _resolvedModelForRole(
|
||||
MultiAgentRole.testerDoc,
|
||||
configuredModel: _config.testerModel,
|
||||
);
|
||||
final result = _config.usesAris && tool == 'claude'
|
||||
? await _runArisTesterViaClaudeReview(
|
||||
model: testerModel,
|
||||
prompt: prompt,
|
||||
)
|
||||
: await _runCliPrompt(
|
||||
role: MultiAgentRole.testerDoc,
|
||||
tool: tool,
|
||||
model: testerModel,
|
||||
prompt: prompt,
|
||||
cwd: '',
|
||||
aiGatewayBaseUrl: aiGatewayBaseUrl,
|
||||
aiGatewayApiKey: aiGatewayApiKey,
|
||||
);
|
||||
stopwatch.stop();
|
||||
|
||||
final score = _parseReviewScore(result.output);
|
||||
final feedback = _extractFeedback(result.output);
|
||||
|
||||
return TesterResult(
|
||||
output: result.output,
|
||||
score: score,
|
||||
feedback: feedback,
|
||||
duration: stopwatch.elapsed,
|
||||
);
|
||||
}
|
||||
|
||||
/// 运行修复(迭代循环中)
|
||||
Future<EngineerResult> _runFix(
|
||||
String originalCode,
|
||||
String feedback,
|
||||
String workingDirectory, {
|
||||
required FrameworkPreset preset,
|
||||
required String aiGatewayBaseUrl,
|
||||
required String aiGatewayApiKey,
|
||||
}) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final tool = await _resolveToolForRole(
|
||||
MultiAgentRole.engineer,
|
||||
_config.engineerTool,
|
||||
);
|
||||
final instructionBlock = await preset.roleInstructionBlock(
|
||||
role: MultiAgentRole.engineer,
|
||||
tool: tool,
|
||||
selectedSkills: const <String>[],
|
||||
);
|
||||
|
||||
final prompt =
|
||||
'''
|
||||
$instructionBlock
|
||||
|
||||
你是一个资深工程师。请根据审阅反馈修复代码。
|
||||
|
||||
## 审阅反馈
|
||||
$feedback
|
||||
|
||||
## 原始代码
|
||||
$originalCode
|
||||
|
||||
请完成修复,输出修复后的完整代码。
|
||||
''';
|
||||
|
||||
final result = await _runCliPrompt(
|
||||
role: MultiAgentRole.engineer,
|
||||
tool: tool,
|
||||
model: _resolvedModelForRole(
|
||||
MultiAgentRole.engineer,
|
||||
configuredModel: _config.engineerModel,
|
||||
),
|
||||
prompt: prompt,
|
||||
cwd: workingDirectory,
|
||||
aiGatewayBaseUrl: aiGatewayBaseUrl,
|
||||
aiGatewayApiKey: aiGatewayApiKey,
|
||||
);
|
||||
stopwatch.stop();
|
||||
|
||||
return EngineerResult(
|
||||
output: result.output,
|
||||
codeOutput: result.output,
|
||||
completedTasks: [],
|
||||
duration: stopwatch.elapsed,
|
||||
);
|
||||
}
|
||||
|
||||
/// 通用的 CLI 进程执行方法
|
||||
Future<CliResult> _runCliPrompt({
|
||||
required MultiAgentRole role,
|
||||
required String tool,
|
||||
required String model,
|
||||
required String prompt,
|
||||
required String cwd,
|
||||
required String aiGatewayBaseUrl,
|
||||
required String aiGatewayApiKey,
|
||||
}) async {
|
||||
late final List<String> args;
|
||||
late final String command;
|
||||
late final Map<String, String> envVars;
|
||||
final useOllamaLaunch = _prefersOllamaLaunch(tool: tool, model: model);
|
||||
|
||||
switch (tool) {
|
||||
case 'claude':
|
||||
command = useOllamaLaunch ? 'ollama' : _resolveCliPath('claude');
|
||||
envVars = _buildCliEnvVars(
|
||||
tool: tool,
|
||||
aiGatewayBaseUrl: aiGatewayBaseUrl,
|
||||
aiGatewayApiKey: aiGatewayApiKey,
|
||||
);
|
||||
if (useOllamaLaunch) {
|
||||
args = _buildOllamaLaunchArgs(
|
||||
tool: tool,
|
||||
model: model,
|
||||
prompt: prompt,
|
||||
cwd: cwd,
|
||||
);
|
||||
} else if (model.isNotEmpty) {
|
||||
args = ['--model', model, '-p', prompt];
|
||||
} else {
|
||||
args = ['-p', prompt];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'codex':
|
||||
command = useOllamaLaunch ? 'ollama' : _resolveCliPath('codex');
|
||||
envVars = _buildCliEnvVars(
|
||||
tool: tool,
|
||||
aiGatewayBaseUrl: aiGatewayBaseUrl,
|
||||
aiGatewayApiKey: aiGatewayApiKey,
|
||||
);
|
||||
if (useOllamaLaunch) {
|
||||
args = _buildOllamaLaunchArgs(
|
||||
tool: tool,
|
||||
model: model,
|
||||
prompt: prompt,
|
||||
cwd: cwd,
|
||||
);
|
||||
} else if (model.isNotEmpty) {
|
||||
args = [
|
||||
'exec',
|
||||
'--skip-git-repo-check',
|
||||
'--color',
|
||||
'never',
|
||||
if (cwd.isNotEmpty) ...['-C', cwd],
|
||||
'-m',
|
||||
model,
|
||||
prompt,
|
||||
];
|
||||
} else {
|
||||
args = [
|
||||
'exec',
|
||||
'--skip-git-repo-check',
|
||||
'--color',
|
||||
'never',
|
||||
if (cwd.isNotEmpty) ...['-C', cwd],
|
||||
prompt,
|
||||
];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'gemini':
|
||||
command = _resolveCliPath('gemini');
|
||||
envVars = _buildCliEnvVars(
|
||||
tool: tool,
|
||||
aiGatewayBaseUrl: aiGatewayBaseUrl,
|
||||
aiGatewayApiKey: aiGatewayApiKey,
|
||||
);
|
||||
if (model.isNotEmpty) {
|
||||
args = ['--model', model, '-p', prompt];
|
||||
} else {
|
||||
args = ['-p', prompt];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'opencode':
|
||||
command = useOllamaLaunch ? 'ollama' : _resolveCliPath('opencode');
|
||||
envVars = _buildCliEnvVars(
|
||||
tool: tool,
|
||||
aiGatewayBaseUrl: aiGatewayBaseUrl,
|
||||
aiGatewayApiKey: aiGatewayApiKey,
|
||||
);
|
||||
args = useOllamaLaunch
|
||||
? _buildOllamaLaunchArgs(
|
||||
tool: tool,
|
||||
model: model,
|
||||
prompt: prompt,
|
||||
cwd: cwd,
|
||||
)
|
||||
: [
|
||||
'run',
|
||||
'--format',
|
||||
'default',
|
||||
if (cwd.isNotEmpty) ...['--dir', cwd],
|
||||
if (model.isNotEmpty) ...['-m', model],
|
||||
prompt,
|
||||
];
|
||||
break;
|
||||
|
||||
default:
|
||||
throw ArgumentError('Unknown tool: $tool');
|
||||
}
|
||||
|
||||
final cliAvailable = await _binaryExists(command);
|
||||
if (_config.usesAris && !cliAvailable) {
|
||||
return _runArisFallback(
|
||||
role: role,
|
||||
model: model,
|
||||
prompt: prompt,
|
||||
aiGatewayBaseUrl: aiGatewayBaseUrl,
|
||||
aiGatewayApiKey: aiGatewayApiKey,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final process = await _processStarter(
|
||||
command,
|
||||
args,
|
||||
environment: envVars,
|
||||
workingDirectory: cwd.isNotEmpty ? cwd : null,
|
||||
);
|
||||
_activeCliProcess = process;
|
||||
|
||||
await process.stdin.close();
|
||||
|
||||
// 超时控制
|
||||
final timeout = Duration(seconds: _config.timeoutSeconds);
|
||||
|
||||
final stdoutFuture = process.stdout
|
||||
.transform(utf8.decoder)
|
||||
.join()
|
||||
.timeout(
|
||||
timeout,
|
||||
onTimeout: () {
|
||||
process.kill();
|
||||
return '[超时或进程已终止]';
|
||||
},
|
||||
);
|
||||
|
||||
final stderrFuture = process.stderr
|
||||
.transform(utf8.decoder)
|
||||
.join()
|
||||
.timeout(timeout, onTimeout: () => '');
|
||||
|
||||
final results = await Future.wait([stdoutFuture, stderrFuture]);
|
||||
final exitCode = await process.exitCode.timeout(
|
||||
timeout,
|
||||
onTimeout: () => -1,
|
||||
);
|
||||
_activeCliProcess = null;
|
||||
|
||||
final cliResult = CliResult(
|
||||
output: results[0],
|
||||
error: results[1],
|
||||
exitCode: exitCode,
|
||||
);
|
||||
if (_config.usesAris && !cliResult.success) {
|
||||
return _runArisFallback(
|
||||
role: role,
|
||||
model: model,
|
||||
prompt: prompt,
|
||||
aiGatewayBaseUrl: aiGatewayBaseUrl,
|
||||
aiGatewayApiKey: aiGatewayApiKey,
|
||||
);
|
||||
}
|
||||
return cliResult;
|
||||
} catch (e) {
|
||||
_activeCliProcess = null;
|
||||
if (_config.usesAris) {
|
||||
return _runArisFallback(
|
||||
role: role,
|
||||
model: model,
|
||||
prompt: prompt,
|
||||
aiGatewayBaseUrl: aiGatewayBaseUrl,
|
||||
aiGatewayApiKey: aiGatewayApiKey,
|
||||
);
|
||||
}
|
||||
return CliResult(output: '', error: e.toString(), exitCode: -1);
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建 Architect 的 Prompt
|
||||
String _buildArchitectPrompt(
|
||||
String task,
|
||||
List<String> selectedSkills,
|
||||
String instructionBlock,
|
||||
) {
|
||||
return '''
|
||||
$instructionBlock
|
||||
|
||||
你是一个多 Agent 协作调度者。请先收敛 requirements -> acceptance evidence,再输出可执行的主程/worker分工。
|
||||
|
||||
## 用户需求
|
||||
$task
|
||||
|
||||
## 优先技能
|
||||
${selectedSkills.isEmpty ? '- 无' : selectedSkills.map((item) => '- $item').join('\n')}
|
||||
|
||||
请输出:
|
||||
1. 任务概述(2-3 句话)
|
||||
2. 子任务列表(3-5 个),每个子任务包含:
|
||||
- 任务编号和描述
|
||||
- 负责角色(文档/主程/worker)
|
||||
- 接受标准
|
||||
- 关键技术点
|
||||
3. 推荐的执行顺序与关键里程碑
|
||||
|
||||
请严格按以下格式输出:
|
||||
## 概述
|
||||
[你的概述]
|
||||
|
||||
## 子任务
|
||||
1. [任务描述] | 角色:[文档/主程/worker] | 接受标准:[可验证结果] | 关键技术:[技术点]
|
||||
2. [任务描述] | 角色:[文档/主程/worker] | 接受标准:[可验证结果] | 关键技术:[技术点]
|
||||
...
|
||||
''';
|
||||
}
|
||||
|
||||
Future<String> _resolveToolForRole(
|
||||
MultiAgentRole role,
|
||||
String configuredTool,
|
||||
) async {
|
||||
if (!_config.usesAris) {
|
||||
return configuredTool;
|
||||
}
|
||||
final configuredModel = _resolvedModelForRole(
|
||||
role,
|
||||
configuredModel: _modelForRole(role).trim(),
|
||||
);
|
||||
final candidates = switch (role) {
|
||||
MultiAgentRole.architect => <String>[
|
||||
configuredTool,
|
||||
'claude',
|
||||
'codex',
|
||||
'opencode',
|
||||
'gemini',
|
||||
],
|
||||
MultiAgentRole.engineer => <String>[
|
||||
configuredTool,
|
||||
'codex',
|
||||
'opencode',
|
||||
'claude',
|
||||
'gemini',
|
||||
],
|
||||
MultiAgentRole.testerDoc => <String>[
|
||||
configuredTool,
|
||||
'opencode',
|
||||
'codex',
|
||||
'claude',
|
||||
'gemini',
|
||||
],
|
||||
};
|
||||
for (final candidate in candidates) {
|
||||
final trimmed = candidate.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
if (_prefersOllamaLaunch(tool: trimmed, model: configuredModel)) {
|
||||
if (await _binaryExists('ollama')) {
|
||||
return trimmed;
|
||||
}
|
||||
} else if (await _binaryExists(_resolveCliPath(trimmed))) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return configuredTool;
|
||||
}
|
||||
|
||||
String _resolvedModelForRole(
|
||||
MultiAgentRole role, {
|
||||
required String configuredModel,
|
||||
}) {
|
||||
final trimmed = configuredModel.trim();
|
||||
if (trimmed.isNotEmpty) {
|
||||
return trimmed;
|
||||
}
|
||||
switch (role) {
|
||||
case MultiAgentRole.architect:
|
||||
return 'kimi-k2.5:cloud';
|
||||
case MultiAgentRole.engineer:
|
||||
return 'minimax-m2.7:cloud';
|
||||
case MultiAgentRole.testerDoc:
|
||||
return 'glm-5:cloud';
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _binaryExists(String command) async {
|
||||
final resolver = _binaryExistsResolver;
|
||||
if (resolver != null) {
|
||||
return resolver(command);
|
||||
}
|
||||
final check = await Process.run(
|
||||
Platform.isWindows ? 'where' : 'which',
|
||||
<String>[command],
|
||||
runInShell: true,
|
||||
);
|
||||
return check.exitCode == 0 && '${check.stdout}'.trim().isNotEmpty;
|
||||
}
|
||||
|
||||
Future<CliResult> _runArisFallback({
|
||||
required MultiAgentRole role,
|
||||
required String model,
|
||||
required String prompt,
|
||||
required String aiGatewayBaseUrl,
|
||||
required String aiGatewayApiKey,
|
||||
}) async {
|
||||
if (role == MultiAgentRole.testerDoc) {
|
||||
final viaLlmChat = await _runArisTesterViaLlmChat(
|
||||
model: model,
|
||||
prompt: prompt,
|
||||
aiGatewayBaseUrl: aiGatewayBaseUrl,
|
||||
aiGatewayApiKey: aiGatewayApiKey,
|
||||
);
|
||||
if (viaLlmChat.success) {
|
||||
return viaLlmChat;
|
||||
}
|
||||
}
|
||||
return _runOpenAiCompatiblePrompt(
|
||||
role: role,
|
||||
model: model,
|
||||
prompt: prompt,
|
||||
aiGatewayBaseUrl: aiGatewayBaseUrl,
|
||||
aiGatewayApiKey: aiGatewayApiKey,
|
||||
);
|
||||
}
|
||||
|
||||
Future<CliResult> _runArisTesterViaLlmChat({
|
||||
required String model,
|
||||
required String prompt,
|
||||
required String aiGatewayBaseUrl,
|
||||
required String aiGatewayApiKey,
|
||||
}) async {
|
||||
try {
|
||||
if (!await _goCoreLocator.isAvailable()) {
|
||||
return const CliResult(
|
||||
output: '',
|
||||
error: 'Go core is unavailable for llm-chat',
|
||||
exitCode: -1,
|
||||
);
|
||||
}
|
||||
final endpoint = _openAiCompatibleBaseUrl(
|
||||
aiGatewayBaseUrl: aiGatewayBaseUrl,
|
||||
);
|
||||
final apiKey = _openAiCompatibleApiKey(aiGatewayApiKey: aiGatewayApiKey);
|
||||
final output = await _arisLlmChatClient.chat(
|
||||
endpoint: endpoint,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
prompt: prompt,
|
||||
systemPrompt:
|
||||
'You are the ARIS reviewer. Review the provided implementation and return actionable feedback.',
|
||||
);
|
||||
return CliResult(output: output, error: '', exitCode: 0);
|
||||
} catch (error) {
|
||||
return CliResult(output: '', error: error.toString(), exitCode: -1);
|
||||
}
|
||||
}
|
||||
|
||||
Future<CliResult> _runArisTesterViaClaudeReview({
|
||||
required String model,
|
||||
required String prompt,
|
||||
}) async {
|
||||
try {
|
||||
if (!await _goCoreLocator.isAvailable()) {
|
||||
return const CliResult(
|
||||
output: '',
|
||||
error: 'Go core is unavailable for claude-review',
|
||||
exitCode: -1,
|
||||
);
|
||||
}
|
||||
if (!await _binaryExists(_resolveCliPath('claude'))) {
|
||||
return const CliResult(
|
||||
output: '',
|
||||
error: 'Claude CLI is unavailable for claude-review',
|
||||
exitCode: -1,
|
||||
);
|
||||
}
|
||||
final output = await _arisLlmChatClient.claudeReview(
|
||||
prompt: prompt,
|
||||
model: model,
|
||||
systemPrompt:
|
||||
'You are the ARIS reviewer. Review the provided implementation and return actionable feedback.',
|
||||
);
|
||||
return CliResult(output: output, error: '', exitCode: 0);
|
||||
} catch (error) {
|
||||
return CliResult(output: '', error: error.toString(), exitCode: -1);
|
||||
}
|
||||
}
|
||||
|
||||
Future<CliResult> _runOpenAiCompatiblePrompt({
|
||||
required MultiAgentRole role,
|
||||
required String model,
|
||||
required String prompt,
|
||||
required String aiGatewayBaseUrl,
|
||||
required String aiGatewayApiKey,
|
||||
}) async {
|
||||
final client = _httpClientFactory();
|
||||
_activeHttpClient = client;
|
||||
try {
|
||||
final request = await client.postUrl(
|
||||
Uri.parse(
|
||||
'${_openAiCompatibleBaseUrl(aiGatewayBaseUrl: aiGatewayBaseUrl).replaceAll(RegExp(r'/$'), '')}/chat/completions',
|
||||
),
|
||||
);
|
||||
request.headers.set(HttpHeaders.contentTypeHeader, 'application/json');
|
||||
request.headers.set(
|
||||
HttpHeaders.authorizationHeader,
|
||||
'Bearer ${_openAiCompatibleApiKey(aiGatewayApiKey: aiGatewayApiKey)}',
|
||||
);
|
||||
request.add(
|
||||
utf8.encode(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'model': model,
|
||||
'stream': false,
|
||||
'messages': <Map<String, String>>[
|
||||
<String, String>{
|
||||
'role': 'system',
|
||||
'content': _systemPromptForRole(role),
|
||||
},
|
||||
<String, String>{'role': 'user', 'content': prompt},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
final response = await request.close().timeout(
|
||||
Duration(seconds: _config.timeoutSeconds),
|
||||
);
|
||||
final body = await utf8.decodeStream(response);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
return CliResult(
|
||||
output: '',
|
||||
error: body,
|
||||
exitCode: response.statusCode,
|
||||
);
|
||||
}
|
||||
final decoded = jsonDecode(body) as Map<String, dynamic>;
|
||||
final choices = decoded['choices'] as List? ?? const <Object>[];
|
||||
final firstChoice = choices.isNotEmpty ? choices.first : null;
|
||||
final output =
|
||||
((firstChoice as Map?)?['message'] as Map?)?['content']?.toString() ??
|
||||
'';
|
||||
return CliResult(output: output, error: '', exitCode: 0);
|
||||
} catch (error) {
|
||||
return CliResult(output: '', error: error.toString(), exitCode: -1);
|
||||
} finally {
|
||||
_activeHttpClient = null;
|
||||
try {
|
||||
client.close(force: true);
|
||||
} catch (_) {
|
||||
// Best effort only.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -11,6 +11,18 @@ import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
|
||||
void main() {
|
||||
group('DirectSingleAgentAppServerClient', () {
|
||||
test('direct single-agent app-server core file stays split into focused parts', () {
|
||||
final lines = File(
|
||||
'lib/runtime/direct_single_agent_app_server_client_core.part.dart',
|
||||
).readAsLinesSync();
|
||||
|
||||
expect(
|
||||
lines.length,
|
||||
lessThanOrEqualTo(1000),
|
||||
reason: 'The core file should stay under the target line budget.',
|
||||
);
|
||||
});
|
||||
|
||||
test('classifies the four endpoint modes', () {
|
||||
expect(
|
||||
DirectSingleAgentEndpointDescriptor.describe(
|
||||
|
||||
@ -12,6 +12,18 @@ import 'package:xworkmate/runtime/multi_agent_orchestrator.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
|
||||
void main() {
|
||||
test('multi-agent orchestrator core file stays split into focused parts', () {
|
||||
final lines = File(
|
||||
'lib/runtime/multi_agent_orchestrator_core.part.dart',
|
||||
).readAsLinesSync();
|
||||
|
||||
expect(
|
||||
lines.length,
|
||||
lessThanOrEqualTo(1000),
|
||||
reason: 'The core file should stay under the target line budget.',
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'MultiAgentOrchestrator falls back to local Ollama + ARIS Go core chat runtime',
|
||||
() async {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user