Refactor single-agent execution chain

This commit is contained in:
Haitao Pan 2026-04-06 16:01:27 +08:00
parent 078bb841c5
commit 1a4c00a1eb
15 changed files with 1116 additions and 992 deletions

View File

@ -75,4 +75,100 @@ extension AppControllerDesktopExternalAcpRouting on AppController {
);
await goTaskServiceClientInternal.syncExternalProviders(providers);
}
ExternalCodeAgentAcpRoutingConfig buildExternalAcpRoutingForSessionInternal(
String sessionKey, {
String? explicitExecutionTarget,
}) {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
);
final thread = assistantThreadRecordsInternal[normalizedSessionKey];
final sessionTarget = assistantExecutionTargetForSession(
normalizedSessionKey,
);
final preferredGatewayTarget = switch (sessionTarget) {
AssistantExecutionTarget.auto => 'local',
AssistantExecutionTarget.local => 'local',
AssistantExecutionTarget.remote => 'remote',
AssistantExecutionTarget.singleAgent =>
settings.assistantExecutionTarget == AssistantExecutionTarget.remote
? 'remote'
: 'local',
};
final availableSkills =
assistantImportedSkillsForSession(normalizedSessionKey)
.map((item) {
return ExternalCodeAgentAcpAvailableSkill(
id: item.key,
label: item.label,
description: item.description,
);
})
.toList(growable: false);
final selectedSkills =
assistantSelectedSkillsForSession(normalizedSessionKey)
.map((item) {
return item.label.trim().isNotEmpty ? item.label : item.key;
})
.where((item) => item.trim().isNotEmpty)
.toList(growable: false);
final resolvedExplicitExecutionTarget =
sessionTarget == AssistantExecutionTarget.auto
? ''
: explicitExecutionTarget?.trim().isNotEmpty == true
? explicitExecutionTarget!.trim()
: (thread?.hasExplicitExecutionTargetSelection ?? false)
? _routingExecutionTargetValueInternal(
assistantExecutionTargetForSession(normalizedSessionKey),
)
: '';
final resolvedExplicitProviderId =
sessionTarget == AssistantExecutionTarget.auto
? ''
: thread?.hasExplicitProviderSelection ?? false
? singleAgentProviderForSession(normalizedSessionKey).providerId
: '';
final resolvedExplicitModel = thread?.hasExplicitModelSelection ?? false
? (sessionTarget == AssistantExecutionTarget.auto
? ''
: assistantModelForSession(normalizedSessionKey))
: '';
final resolvedExplicitSkills = thread?.hasExplicitSkillSelection ?? false
? selectedSkills
: const <String>[];
final hasExplicitSelection =
resolvedExplicitExecutionTarget.isNotEmpty ||
resolvedExplicitProviderId.isNotEmpty ||
resolvedExplicitModel.trim().isNotEmpty ||
resolvedExplicitSkills.isNotEmpty;
if (!hasExplicitSelection) {
return ExternalCodeAgentAcpRoutingConfig.auto(
preferredGatewayTarget: preferredGatewayTarget,
availableSkills: availableSkills,
);
}
return ExternalCodeAgentAcpRoutingConfig(
mode: ExternalCodeAgentAcpRoutingMode.explicit,
preferredGatewayTarget: preferredGatewayTarget,
explicitExecutionTarget: resolvedExplicitExecutionTarget,
explicitProviderId: resolvedExplicitProviderId,
explicitModel: resolvedExplicitModel,
explicitSkills: resolvedExplicitSkills,
allowSkillInstall: false,
availableSkills: availableSkills,
);
}
String _routingExecutionTargetValueInternal(AssistantExecutionTarget target) {
return switch (target) {
AssistantExecutionTarget.auto => 'singleAgent',
AssistantExecutionTarget.singleAgent => 'singleAgent',
AssistantExecutionTarget.local => 'local',
AssistantExecutionTarget.remote => 'remote',
};
}
}

View File

@ -532,7 +532,7 @@ extension AppControllerDesktopSettingsRuntime on AppController {
if (disposedInternal) {
return;
}
final startupTarget = sanitizeExecutionTargetInternal(
final startupTarget = sanitizePersistedExecutionTargetInternal(
settings.assistantExecutionTarget,
);
agentsControllerInternal.restoreSelection(
@ -796,7 +796,7 @@ extension AppControllerDesktopSettingsRuntime on AppController {
Future<void> applyPersistedGatewaySettingsInternal(
SettingsSnapshot snapshot,
) async {
final target = sanitizeExecutionTargetInternal(
final target = sanitizePersistedExecutionTargetInternal(
snapshot.assistantExecutionTarget,
);
final sessionKey = normalizedAssistantSessionKeyInternal(
@ -805,6 +805,8 @@ extension AppControllerDesktopSettingsRuntime on AppController {
upsertTaskThreadInternal(
sessionKey,
executionTarget: target,
gatewayEntryState: gatewayEntryStateForTargetInternal(target),
latestResolvedRuntimeModel: '',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
recomputeTasksInternal();

View File

@ -1,52 +1,7 @@
// ignore_for_file: unused_import, unnecessary_import
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'app_metadata.dart';
import 'app_capabilities.dart';
import 'app_store_policy.dart';
import 'ui_feature_manifest.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/aris_bundle.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';
import '../runtime/gateway_runtime.dart';
import '../runtime/runtime_controllers.dart';
import '../runtime/runtime_models.dart';
import '../runtime/secure_config_store.dart';
import '../runtime/embedded_agent_launch_policy.dart';
import '../runtime/runtime_coordinator.dart';
import '../runtime/direct_single_agent_app_server_client.dart';
import '../runtime/gateway_acp_client.dart';
import '../runtime/codex_runtime.dart';
import '../runtime/codex_config_bridge.dart';
import '../runtime/code_agent_node_orchestrator.dart';
import '../runtime/assistant_artifacts.dart';
import '../runtime/desktop_thread_artifact_service.dart';
import '../runtime/go_task_service_client.dart';
import '../runtime/mode_switcher.dart';
import '../runtime/agent_registry.dart';
import '../runtime/multi_agent_orchestrator.dart';
import '../runtime/platform_environment.dart';
import '../runtime/single_agent_runner.dart';
import '../runtime/skill_directory_access.dart';
import 'app_controller_desktop_core.dart';
import 'app_controller_desktop_navigation.dart';
import 'app_controller_desktop_gateway.dart';
import 'app_controller_desktop_settings.dart';
import 'app_controller_desktop_thread_sessions.dart';
import 'app_controller_desktop_thread_actions.dart';
import 'app_controller_desktop_workspace_execution.dart';
import 'app_controller_desktop_settings_runtime.dart';
import 'app_controller_desktop_thread_storage.dart';
import 'app_controller_desktop_skill_permissions.dart';
import 'app_controller_desktop_external_acp_routing.dart';
import 'app_controller_desktop_runtime_helpers.dart';
import 'app_controller_desktop_single_agent_ai_gateway.dart';
import 'app_controller_desktop_single_agent_go_task_flow.dart';
import '../runtime/runtime_models.dart';
extension AppControllerDesktopSingleAgent on AppController {
Future<void> sendSingleAgentMessageInternal(
@ -54,919 +9,21 @@ extension AppControllerDesktopSingleAgent on AppController {
required String thinking,
required List<GatewayChatAttachmentPayload> attachments,
required List<CollaborationAttachment> localAttachments,
}) async {
final sessionKey = normalizedAssistantSessionKeyInternal(
sessionsControllerInternal.currentSessionKey,
}) {
return sendSingleAgentMessageDesktopGoTaskFlowInternal(
this,
message,
thinking: thinking,
attachments: attachments,
localAttachments: localAttachments,
);
final trimmed = message.trim();
if (trimmed.isEmpty && attachments.isEmpty) {
return;
}
await enqueueThreadTurnInternal<void>(sessionKey, () async {
final sessionTarget = assistantExecutionTargetForSession(sessionKey);
final userText = trimmed.isEmpty ? 'See attached.' : trimmed;
appendAssistantThreadMessageInternal(
sessionKey,
GatewayChatMessage(
id: nextLocalMessageIdInternal(),
role: 'user',
text: userText,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
);
aiGatewayPendingSessionKeysInternal.add(sessionKey);
recomputeTasksInternal();
notifyIfActiveInternal();
try {
final routing = buildExternalAcpRoutingForSessionInternal(sessionKey);
final selection = singleAgentProviderForSession(sessionKey);
await syncExternalAcpProvidersInternal();
final capabilities = await goTaskServiceClientInternal
.loadExternalAcpCapabilities(
target: AssistantExecutionTarget.singleAgent,
forceRefresh: true,
);
final availableProviders = configuredSingleAgentProviders
.where(capabilities.providers.contains)
.toList(growable: false);
final provider = selection == SingleAgentProvider.auto
? (availableProviders.isEmpty ? null : availableProviders.first)
: (capabilities.providers.contains(selection) ? selection : null);
final fallbackReason = provider == null
? (selection == SingleAgentProvider.auto
? appText(
'当前没有可用的 GoTaskService Provider。',
'No GoTaskService provider is currently available.',
)
: appText(
'当前 GoTaskService 不支持 ${selection.label}',
'GoTaskService does not currently support ${selection.label}.',
))
: null;
if (provider == null && !routing.isAuto) {
if (singleAgentUsesAiChatFallbackForSession(sessionKey)) {
appendSingleAgentFallbackStatusMessageInternal(
sessionKey,
fallbackReason,
);
await sendAiGatewayMessageInternal(
message,
thinking: thinking,
attachments: attachments,
sessionKeyOverride: sessionKey,
appendUserMessage: false,
managePendingState: false,
);
} else {
appendAssistantThreadMessageInternal(
sessionKey,
GatewayChatMessage(
id: nextLocalMessageIdInternal(),
role: 'assistant',
text: singleAgentUnavailableLabelInternal(
sessionKey,
fallbackReason,
),
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: singleAgentRuntimeDebugToolNameInternal(
provider?.label ?? selection.label,
),
stopReason: null,
pending: false,
error: false,
),
);
}
return;
}
final effectiveProvider = sessionTarget == AssistantExecutionTarget.auto
? SingleAgentProvider.auto
: (provider ?? SingleAgentProvider.auto);
appendSingleAgentRuntimeStatusMessageInternal(
sessionKey,
effectiveProvider,
);
final workingDirectory =
resolveSingleAgentWorkingDirectoryForSessionInternal(
sessionKey,
provider: provider,
);
if (workingDirectory == null || workingDirectory.trim().isEmpty) {
final error = StateError(
appText(
'当前线程缺少可运行的工作路径,无法启动单机智能体。',
'This thread does not have a runnable workspace path, so Single Agent cannot start.',
),
);
appendAssistantThreadMessageInternal(
sessionKey,
assistantErrorMessageInternal(error.message),
);
throw error;
}
final selectedSkills = assistantSelectedSkillsForSession(sessionKey)
.map((item) => item.label.trim().isNotEmpty ? item.label : item.key)
.where((item) => item.trim().isNotEmpty)
.toList(growable: false);
final result = await goTaskServiceClientInternal.executeTask(
GoTaskServiceRequest(
sessionId: sessionKey,
threadId: sessionKey,
target: AssistantExecutionTarget.singleAgent,
prompt: message,
workingDirectory: workingDirectory,
model: sessionTarget == AssistantExecutionTarget.auto
? ''
: assistantModelForSession(sessionKey),
thinking: thinking,
selectedSkills: selectedSkills,
inlineAttachments: attachments,
localAttachments: localAttachments,
aiGatewayBaseUrl: aiGatewayUrl,
aiGatewayApiKey: await loadAiGatewayApiKey(),
agentId: '',
metadata: const <String, dynamic>{},
routing: routing,
provider: effectiveProvider,
),
onUpdate: (update) {
if (update.isDelta) {
appendAiGatewayStreamingTextInternal(sessionKey, update.text);
notifyIfActiveInternal();
}
},
);
final resolvedRuntimeModel = result.resolvedModel.trim();
final resolvedGatewayEntryState = goTaskServiceGatewayEntryState(
requestedTarget: sessionTarget,
result: result,
);
upsertTaskThreadInternal(
sessionKey,
gatewayEntryState: resolvedGatewayEntryState,
latestResolvedRuntimeModel: resolvedRuntimeModel,
lifecycleStatus: 'ready',
lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastResultCode: result.success ? 'success' : 'error',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
final resolvedWorkspaceKind = result.resolvedWorkspaceRefKind;
final resolvedWorkingDirectory = result.resolvedWorkingDirectory.trim();
if (resolvedWorkspaceKind != null &&
resolvedWorkingDirectory.isNotEmpty) {
final existingThread = requireTaskThreadForSessionInternal(
sessionKey,
);
upsertTaskThreadInternal(
sessionKey,
workspaceBinding: WorkspaceBinding(
workspaceId: existingThread.workspaceBinding.workspaceId,
workspaceKind:
resolvedWorkspaceKind == WorkspaceRefKind.remotePath
? WorkspaceKind.remoteFs
: WorkspaceKind.localFs,
workspacePath: resolvedWorkingDirectory,
displayPath: resolvedWorkingDirectory,
writable: existingThread.workspaceBinding.writable,
),
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
}
clearAiGatewayStreamingTextInternal(sessionKey);
if (!result.success &&
singleAgentUsesAiChatFallbackForSession(sessionKey)) {
if (singleAgentUsesAiChatFallbackForSession(sessionKey)) {
appendSingleAgentFallbackStatusMessageInternal(
sessionKey,
result.errorMessage,
);
upsertTaskThreadInternal(
sessionKey,
gatewayEntryState: 'only-chat',
latestResolvedRuntimeModel: resolvedAiGatewayModel,
lifecycleStatus: 'ready',
lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastResultCode: 'fallback',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
await sendAiGatewayMessageInternal(
message,
thinking: thinking,
attachments: attachments,
sessionKeyOverride: sessionKey,
appendUserMessage: false,
managePendingState: false,
);
} else {
appendAssistantThreadMessageInternal(
sessionKey,
GatewayChatMessage(
id: nextLocalMessageIdInternal(),
role: 'assistant',
text: singleAgentUnavailableLabelInternal(
sessionKey,
result.errorMessage,
),
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: singleAgentRuntimeDebugToolNameInternal(
effectiveProvider.label,
),
stopReason: null,
pending: false,
error: false,
),
);
}
return;
}
if (!result.success) {
appendAssistantThreadMessageInternal(
sessionKey,
assistantErrorMessageInternal(
appText(
'GoTaskService 执行失败:${result.errorMessage}',
'GoTaskService execution failed: ${result.errorMessage}',
),
),
);
return;
}
if (result.message.trim().isEmpty) {
appendAssistantThreadMessageInternal(
sessionKey,
assistantErrorMessageInternal(
appText(
'GoTaskService 没有返回可显示的输出。',
'GoTaskService returned no displayable output.',
),
),
);
return;
}
appendAssistantThreadMessageInternal(
sessionKey,
GatewayChatMessage(
id: nextLocalMessageIdInternal(),
role: 'assistant',
text: result.message,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
);
} catch (error) {
clearAiGatewayStreamingTextInternal(sessionKey);
upsertTaskThreadInternal(
sessionKey,
lifecycleStatus: 'ready',
lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastResultCode: 'error',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
appendAssistantThreadMessageInternal(
sessionKey,
assistantErrorMessageInternal(error.toString()),
);
} finally {
clearAiGatewayStreamingTextInternal(sessionKey);
aiGatewayPendingSessionKeysInternal.remove(sessionKey);
recomputeTasksInternal();
notifyIfActiveInternal();
}
});
}
Future<void> sendAiGatewayMessageInternal(
String message, {
required String thinking,
required List<GatewayChatAttachmentPayload> attachments,
String? sessionKeyOverride,
bool appendUserMessage = true,
bool managePendingState = true,
}) async {
final sessionKey = normalizedAssistantSessionKeyInternal(
sessionKeyOverride ?? sessionsControllerInternal.currentSessionKey,
);
final trimmed = message.trim();
if (trimmed.isEmpty && attachments.isEmpty) {
return;
}
final baseUrl = normalizeAiGatewayBaseUrlInternal(aiGatewayUrl);
if (baseUrl == null) {
appendAssistantThreadMessageInternal(
sessionKey,
assistantErrorMessageInternal(
appText(
'LLM API Endpoint 未配置,无法发送对话。',
'LLM API Endpoint is not configured, so the conversation could not be sent.',
),
),
);
return;
}
final apiKey = await loadAiGatewayApiKey();
final allowsAnonymous =
isLoopbackHostInternal(baseUrl.host) &&
(baseUrl.host.trim().toLowerCase() == '127.0.0.1' ||
baseUrl.host.trim().toLowerCase() == 'localhost');
if (apiKey.isEmpty && !allowsAnonymous) {
appendAssistantThreadMessageInternal(
sessionKey,
assistantErrorMessageInternal(
appText(
'LLM API Token 未配置,无法发送对话。',
'LLM API Token is not configured, so the conversation could not be sent.',
),
),
);
return;
}
final model = resolvedAiGatewayModel;
if (model.isEmpty) {
appendAssistantThreadMessageInternal(
sessionKey,
assistantErrorMessageInternal(
appText(
'当前没有可用的 LLM API 对话模型。请先在 设置 -> 集成 中同步并选择可用模型。',
'No LLM API chat model is available yet. Sync and select a supported model in Settings -> Integrations first.',
),
),
);
return;
}
if (appendUserMessage) {
final userText = trimmed.isEmpty ? 'See attached.' : trimmed;
appendAssistantThreadMessageInternal(
sessionKey,
GatewayChatMessage(
id: nextLocalMessageIdInternal(),
role: 'user',
text: userText,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
);
}
if (managePendingState) {
aiGatewayPendingSessionKeysInternal.add(sessionKey);
recomputeTasksInternal();
notifyIfActiveInternal();
}
try {
final assistantText = await requestAiGatewayCompletionInternal(
baseUrl: baseUrl,
apiKey: apiKey,
model: model,
thinking: thinking,
sessionKey: sessionKey,
);
appendAssistantThreadMessageInternal(
sessionKey,
GatewayChatMessage(
id: nextLocalMessageIdInternal(),
role: 'assistant',
text: assistantText,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
);
upsertTaskThreadInternal(
sessionKey,
gatewayEntryState: 'only-chat',
latestResolvedRuntimeModel: model,
lifecycleStatus: 'ready',
lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastResultCode: 'success',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
} on AiGatewayAbortExceptionInternal catch (error) {
final partial = error.partialText.trim();
if (partial.isNotEmpty) {
appendAssistantThreadMessageInternal(
sessionKey,
GatewayChatMessage(
id: nextLocalMessageIdInternal(),
role: 'assistant',
text: partial,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: 'aborted',
pending: false,
error: false,
),
);
}
upsertTaskThreadInternal(
sessionKey,
lifecycleStatus: 'ready',
lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastResultCode: 'aborted',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
} catch (error) {
upsertTaskThreadInternal(
sessionKey,
lifecycleStatus: 'ready',
lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastResultCode: 'error',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
appendAssistantThreadMessageInternal(
sessionKey,
assistantErrorMessageInternal(aiGatewayErrorLabelInternal(error)),
);
} finally {
aiGatewayStreamingClientsInternal.remove(sessionKey);
clearAiGatewayStreamingTextInternal(sessionKey);
if (managePendingState) {
aiGatewayPendingSessionKeysInternal.remove(sessionKey);
recomputeTasksInternal();
notifyIfActiveInternal();
}
}
}
Future<String> requestAiGatewayCompletionInternal({
required Uri baseUrl,
required String apiKey,
required String model,
required String thinking,
required String sessionKey,
}) async {
final uri = aiGatewayChatUriInternal(baseUrl);
final client = HttpClient()
..connectionTimeout = const Duration(seconds: 20);
aiGatewayStreamingClientsInternal[sessionKey] = client;
try {
final request = await client
.postUrl(uri)
.timeout(const Duration(seconds: 20));
request.headers.set(
HttpHeaders.acceptHeader,
'text/event-stream, application/json',
);
request.headers.set(
HttpHeaders.contentTypeHeader,
'application/json; charset=utf-8',
);
final trimmedApiKey = apiKey.trim();
if (trimmedApiKey.isNotEmpty) {
request.headers.set(
HttpHeaders.authorizationHeader,
'Bearer $trimmedApiKey',
);
request.headers.set('x-api-key', trimmedApiKey);
}
final payload = <String, dynamic>{
'model': model,
'stream': true,
'messages': buildAiGatewayRequestMessagesInternal(sessionKey),
};
final normalizedThinking = thinking.trim().toLowerCase();
if (normalizedThinking.isNotEmpty && normalizedThinking != 'off') {
payload['reasoning_effort'] = normalizedThinking;
}
request.add(utf8.encode(jsonEncode(payload)));
final response = await request.close().timeout(
const Duration(seconds: 60),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
final body = await response.transform(utf8.decoder).join();
throw AiGatewayChatExceptionInternal(
formatAiGatewayHttpErrorInternal(
response.statusCode,
extractAiGatewayErrorDetailInternal(body),
),
);
}
final contentType =
response.headers.contentType?.mimeType.toLowerCase() ??
response.headers
.value(HttpHeaders.contentTypeHeader)
?.toLowerCase() ??
'';
if (contentType.contains('text/event-stream')) {
final streamed = await readAiGatewayStreamingResponseInternal(
response: response,
sessionKey: sessionKey,
);
if (streamed.trim().isEmpty) {
throw const FormatException('Missing assistant content');
}
return streamed.trim();
}
return await readAiGatewayJsonCompletionInternal(response);
} catch (error) {
if (consumeAiGatewayAbortInternal(sessionKey)) {
throw AiGatewayAbortExceptionInternal(
aiGatewayStreamingTextBySessionInternal[sessionKey] ?? '',
);
}
rethrow;
} finally {
aiGatewayStreamingClientsInternal.remove(sessionKey);
client.close(force: true);
}
}
List<Map<String, String>> buildAiGatewayRequestMessagesInternal(
String sessionKey,
) {
final history = <GatewayChatMessage>[
...(gatewayHistoryCacheInternal[sessionKey] ??
const <GatewayChatMessage>[]),
...(assistantThreadMessagesInternal[sessionKey] ??
const <GatewayChatMessage>[]),
];
return history
.where((message) {
final role = message.role.trim().toLowerCase();
return (role == 'user' || role == 'assistant') &&
(message.toolName ?? '').trim().isEmpty &&
message.text.trim().isNotEmpty;
})
.map(
(message) => <String, String>{
'role': message.role.trim().toLowerCase() == 'assistant'
? 'assistant'
: 'user',
'content': message.text.trim(),
},
)
.toList(growable: false);
}
Future<String> readAiGatewayJsonCompletionInternal(
HttpClientResponse response,
) async {
final body = await response.transform(utf8.decoder).join();
final decoded = jsonDecode(extractFirstJsonDocumentInternal(body));
final assistantText = extractAiGatewayAssistantTextInternal(decoded);
if (assistantText.trim().isEmpty) {
throw const FormatException('Missing assistant content');
}
return assistantText.trim();
}
Future<String> readAiGatewayStreamingResponseInternal({
required HttpClientResponse response,
required String sessionKey,
}) async {
final buffer = StringBuffer();
final eventLines = <String>[];
void processEvent(String payload) {
final trimmed = payload.trim();
if (trimmed.isEmpty) {
return;
}
if (trimmed == '[DONE]') {
return;
}
final deltaText = extractAiGatewayStreamTextInternal(trimmed);
if (deltaText.isEmpty) {
return;
}
final current = buffer.toString();
if (current.isEmpty || deltaText == current) {
buffer
..clear()
..write(deltaText);
} else if (deltaText.startsWith(current)) {
buffer
..clear()
..write(deltaText);
} else {
buffer.write(deltaText);
}
setAiGatewayStreamingTextInternal(sessionKey, buffer.toString());
}
await for (final line
in response.transform(utf8.decoder).transform(const LineSplitter())) {
if (consumeAiGatewayAbortInternal(sessionKey)) {
throw AiGatewayAbortExceptionInternal(buffer.toString());
}
if (line.isEmpty) {
if (eventLines.isNotEmpty) {
processEvent(eventLines.join('\n'));
eventLines.clear();
}
continue;
}
if (line.startsWith('data:')) {
eventLines.add(line.substring(5).trimLeft());
}
}
if (eventLines.isNotEmpty) {
processEvent(eventLines.join('\n'));
}
return buffer.toString();
}
String extractAiGatewayStreamTextInternal(String payload) {
final decoded = jsonDecode(extractFirstJsonDocumentInternal(payload));
final map = asMap(decoded);
final choices = asList(map['choices']);
if (choices.isNotEmpty) {
final firstChoice = asMap(choices.first);
final delta = asMap(firstChoice['delta']);
final deltaContent = extractAiGatewayContentInternal(delta['content']);
if (deltaContent.isNotEmpty) {
return deltaContent;
}
}
return extractAiGatewayAssistantTextInternal(decoded);
}
Future<void> abortAiGatewayRunInternal(String sessionKey) async {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
);
aiGatewayAbortedSessionKeysInternal.add(normalizedSessionKey);
final client = aiGatewayStreamingClientsInternal.remove(
normalizedSessionKey,
);
if (client != null) {
try {
client.close(force: true);
} catch (_) {
// Best effort only.
}
}
aiGatewayPendingSessionKeysInternal.remove(normalizedSessionKey);
clearAiGatewayStreamingTextInternal(normalizedSessionKey);
upsertTaskThreadInternal(
normalizedSessionKey,
lifecycleStatus: 'ready',
lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastResultCode: 'aborted',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
recomputeTasksInternal();
notifyIfActiveInternal();
}
bool consumeAiGatewayAbortInternal(String sessionKey) {
return aiGatewayAbortedSessionKeysInternal.remove(
normalizedAssistantSessionKeyInternal(sessionKey),
);
Future<void> abortAiGatewayRunInternal(String sessionKey) {
return abortAiGatewaySingleAgentRunDesktopInternal(this, sessionKey);
}
GatewayChatMessage assistantErrorMessageInternal(String text) {
return GatewayChatMessage(
id: nextLocalMessageIdInternal(),
role: 'assistant',
text: text,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: true,
);
}
String? singleAgentRuntimeDebugToolNameInternal(String label) {
if (!showsSingleAgentRuntimeDebugMessagesInternal) {
return null;
}
final trimmed = label.trim();
if (trimmed.isEmpty) {
return null;
}
return trimmed;
}
void appendSingleAgentRuntimeStatusMessageInternal(
String sessionKey,
SingleAgentProvider provider,
) {
if (!showsSingleAgentRuntimeDebugMessagesInternal) {
return;
}
appendAssistantThreadMessageInternal(
sessionKey,
GatewayChatMessage(
id: nextLocalMessageIdInternal(),
role: 'assistant',
text: appText(
'单机智能体已切换到 ${provider.label} 执行当前任务。',
'Single Agent is using ${provider.label} for this task.',
),
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: provider.label,
stopReason: null,
pending: false,
error: false,
),
);
}
void appendSingleAgentFallbackStatusMessageInternal(
String sessionKey,
String? reason,
) {
if (!showsSingleAgentRuntimeDebugMessagesInternal) {
return;
}
appendAssistantThreadMessageInternal(
sessionKey,
GatewayChatMessage(
id: nextLocalMessageIdInternal(),
role: 'assistant',
text: singleAgentFallbackLabelInternal(reason),
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: 'AI Chat fallback',
stopReason: null,
pending: false,
error: false,
),
);
}
String singleAgentFallbackLabelInternal(String? reason) {
final detail = reason?.trim() ?? '';
return detail.isEmpty
? appText(
'未发现可用的外部 Agent ACP 端点,已回退到 AI Chat。',
'No external Agent ACP endpoint is available. Falling back to AI Chat.',
)
: appText(
'外部 Agent ACP 连接不可用,已回退到 AI Chat$detail',
'External Agent ACP connection is unavailable. Falling back to AI Chat: $detail',
);
}
String singleAgentUnavailableLabelInternal(
String sessionKey,
String? reason,
) {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
);
final detail = reason?.trim() ?? '';
final selection = singleAgentProviderForSession(normalizedSessionKey);
if (singleAgentShouldSuggestAutoSwitchForSession(normalizedSessionKey)) {
return detail.isEmpty
? appText(
'当前线程固定为 ${selection.label},但它在这台设备上不可用。检测到其他外部 Agent ACP 端点时不会自动改线,可切到 Auto。',
'This thread is pinned to ${selection.label}, but it is unavailable on this device. XWorkmate will not reroute to another external Agent ACP endpoint automatically. Switch to Auto instead.',
)
: appText(
'当前线程固定为 ${selection.label}$detail 检测到其他外部 Agent ACP 端点时不会自动改线,可切到 Auto。',
'This thread is pinned to ${selection.label}: $detail XWorkmate will not reroute to another external Agent ACP endpoint automatically. Switch to Auto instead.',
);
}
if (singleAgentNeedsAiGatewayConfigurationForSession(
normalizedSessionKey,
)) {
return detail.isEmpty
? appText(
'当前没有可用的外部 Agent ACP 端点,也没有可用的 AI Chat fallback。请先配置外部 Agent 连接,或配置 LLM API。',
'No external Agent ACP endpoint is available, and AI Chat fallback is not configured. Configure an external Agent connection or configure LLM API first.',
)
: appText(
'$detail 当前没有可用的外部 Agent ACP 端点,也没有可用的 AI Chat fallback。请先配置外部 Agent 连接,或配置 LLM API。',
'$detail No external Agent ACP endpoint is available, and AI Chat fallback is not configured. Configure an external Agent connection or configure LLM API first.',
);
}
return detail.isEmpty
? appText(
'当前线程的外部 Agent ACP 连接尚未就绪。',
'The external Agent ACP connection for this thread is not ready yet.',
)
: appText(
'当前线程的外部 Agent ACP 连接尚未就绪:$detail',
'The external Agent ACP connection for this thread is not ready yet: $detail',
);
}
ExternalCodeAgentAcpRoutingConfig buildExternalAcpRoutingForSessionInternal(
String sessionKey, {
String? explicitExecutionTarget,
}) {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
);
final thread = assistantThreadRecordsInternal[normalizedSessionKey];
final sessionTarget = assistantExecutionTargetForSession(
normalizedSessionKey,
);
final preferredGatewayTarget = switch (sessionTarget) {
AssistantExecutionTarget.auto => 'local',
AssistantExecutionTarget.local => 'local',
AssistantExecutionTarget.remote => 'remote',
AssistantExecutionTarget.singleAgent =>
settings.assistantExecutionTarget == AssistantExecutionTarget.remote
? 'remote'
: 'local',
};
final availableSkills =
assistantImportedSkillsForSession(normalizedSessionKey)
.map(
(item) => ExternalCodeAgentAcpAvailableSkill(
id: item.key,
label: item.label,
description: item.description,
),
)
.toList(growable: false);
final selectedSkills =
assistantSelectedSkillsForSession(normalizedSessionKey)
.map((item) => item.label.trim().isNotEmpty ? item.label : item.key)
.where((item) => item.trim().isNotEmpty)
.toList(growable: false);
final resolvedExplicitExecutionTarget =
sessionTarget == AssistantExecutionTarget.auto
? ''
: explicitExecutionTarget?.trim().isNotEmpty == true
? explicitExecutionTarget!.trim()
: (thread?.hasExplicitExecutionTargetSelection ?? false)
? _routingExecutionTargetValue(
assistantExecutionTargetForSession(normalizedSessionKey),
)
: '';
final resolvedExplicitProviderId =
sessionTarget == AssistantExecutionTarget.auto
? ''
: thread?.hasExplicitProviderSelection ?? false
? singleAgentProviderForSession(normalizedSessionKey).providerId
: '';
final resolvedExplicitModel = thread?.hasExplicitModelSelection ?? false
? (sessionTarget == AssistantExecutionTarget.auto
? ''
: assistantModelForSession(normalizedSessionKey))
: '';
final resolvedExplicitSkills = thread?.hasExplicitSkillSelection ?? false
? selectedSkills
: const <String>[];
final hasExplicitSelection =
resolvedExplicitExecutionTarget.isNotEmpty ||
resolvedExplicitProviderId.isNotEmpty ||
resolvedExplicitModel.trim().isNotEmpty ||
resolvedExplicitSkills.isNotEmpty;
if (!hasExplicitSelection) {
return ExternalCodeAgentAcpRoutingConfig.auto(
preferredGatewayTarget: preferredGatewayTarget,
availableSkills: availableSkills,
);
}
return ExternalCodeAgentAcpRoutingConfig(
mode: ExternalCodeAgentAcpRoutingMode.explicit,
preferredGatewayTarget: preferredGatewayTarget,
explicitExecutionTarget: resolvedExplicitExecutionTarget,
explicitProviderId: resolvedExplicitProviderId,
explicitModel: resolvedExplicitModel,
explicitSkills: resolvedExplicitSkills,
allowSkillInstall: false,
availableSkills: availableSkills,
);
}
String _routingExecutionTargetValue(AssistantExecutionTarget target) {
return switch (target) {
AssistantExecutionTarget.auto => 'singleAgent',
AssistantExecutionTarget.singleAgent => 'singleAgent',
AssistantExecutionTarget.local => 'local',
AssistantExecutionTarget.remote => 'remote',
};
return assistantErrorMessageSingleAgentDesktopInternal(this, text);
}
}

View File

@ -0,0 +1,465 @@
// ignore_for_file: unused_import, unnecessary_import
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/gateway_runtime_helpers.dart';
import '../runtime/runtime_models.dart';
import 'app_controller_desktop_core.dart';
import 'app_controller_desktop_runtime_helpers.dart';
import 'app_controller_desktop_skill_permissions.dart';
import 'app_controller_desktop_thread_sessions.dart';
import 'app_controller_desktop_thread_storage.dart';
GatewayChatMessage assistantErrorMessageSingleAgentDesktopInternal(
AppController controller,
String text,
) {
return GatewayChatMessage(
id: controller.nextLocalMessageIdInternal(),
role: 'assistant',
text: text,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: true,
);
}
Future<void> sendAiGatewaySingleAgentMessageDesktopInternal(
AppController controller,
String message, {
required String thinking,
required List<GatewayChatAttachmentPayload> attachments,
String? sessionKeyOverride,
bool appendUserMessage = true,
bool managePendingState = true,
}) async {
final sessionKey = controller.normalizedAssistantSessionKeyInternal(
sessionKeyOverride ??
controller.sessionsControllerInternal.currentSessionKey,
);
final trimmed = message.trim();
if (trimmed.isEmpty && attachments.isEmpty) {
return;
}
final baseUrl = controller.normalizeAiGatewayBaseUrlInternal(
controller.aiGatewayUrl,
);
if (baseUrl == null) {
controller.appendAssistantThreadMessageInternal(
sessionKey,
assistantErrorMessageSingleAgentDesktopInternal(
controller,
appText(
'LLM API Endpoint 未配置,无法发送对话。',
'LLM API Endpoint is not configured, so the conversation could not be sent.',
),
),
);
return;
}
final apiKey = await controller.loadAiGatewayApiKey();
final allowsAnonymous =
controller.isLoopbackHostInternal(baseUrl.host) &&
(baseUrl.host.trim().toLowerCase() == '127.0.0.1' ||
baseUrl.host.trim().toLowerCase() == 'localhost');
if (apiKey.isEmpty && !allowsAnonymous) {
controller.appendAssistantThreadMessageInternal(
sessionKey,
assistantErrorMessageSingleAgentDesktopInternal(
controller,
appText(
'LLM API Token 未配置,无法发送对话。',
'LLM API Token is not configured, so the conversation could not be sent.',
),
),
);
return;
}
final model = controller.resolvedAiGatewayModel;
if (model.isEmpty) {
controller.appendAssistantThreadMessageInternal(
sessionKey,
assistantErrorMessageSingleAgentDesktopInternal(
controller,
appText(
'当前没有可用的 LLM API 对话模型。请先在 设置 -> 集成 中同步并选择可用模型。',
'No LLM API chat model is available yet. Sync and select a supported model in Settings -> Integrations first.',
),
),
);
return;
}
if (appendUserMessage) {
final userText = trimmed.isEmpty ? 'See attached.' : trimmed;
controller.appendAssistantThreadMessageInternal(
sessionKey,
GatewayChatMessage(
id: controller.nextLocalMessageIdInternal(),
role: 'user',
text: userText,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
);
}
if (managePendingState) {
controller.aiGatewayPendingSessionKeysInternal.add(sessionKey);
controller.recomputeTasksInternal();
controller.notifyIfActiveInternal();
}
try {
final assistantText =
await requestAiGatewaySingleAgentCompletionDesktopInternal(
controller,
baseUrl: baseUrl,
apiKey: apiKey,
model: model,
thinking: thinking,
sessionKey: sessionKey,
);
controller.appendAssistantThreadMessageInternal(
sessionKey,
GatewayChatMessage(
id: controller.nextLocalMessageIdInternal(),
role: 'assistant',
text: assistantText,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
);
controller.upsertTaskThreadInternal(
sessionKey,
gatewayEntryState: 'only-chat',
latestResolvedRuntimeModel: model,
lifecycleStatus: 'ready',
lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastResultCode: 'success',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
} on AiGatewayAbortExceptionInternal catch (error) {
final partial = error.partialText.trim();
if (partial.isNotEmpty) {
controller.appendAssistantThreadMessageInternal(
sessionKey,
GatewayChatMessage(
id: controller.nextLocalMessageIdInternal(),
role: 'assistant',
text: partial,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: 'aborted',
pending: false,
error: false,
),
);
}
controller.upsertTaskThreadInternal(
sessionKey,
lifecycleStatus: 'ready',
lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastResultCode: 'aborted',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
} catch (error) {
controller.upsertTaskThreadInternal(
sessionKey,
lifecycleStatus: 'ready',
lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastResultCode: 'error',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
controller.appendAssistantThreadMessageInternal(
sessionKey,
assistantErrorMessageSingleAgentDesktopInternal(
controller,
controller.aiGatewayErrorLabelInternal(error),
),
);
} finally {
controller.aiGatewayStreamingClientsInternal.remove(sessionKey);
controller.clearAiGatewayStreamingTextInternal(sessionKey);
if (managePendingState) {
controller.aiGatewayPendingSessionKeysInternal.remove(sessionKey);
controller.recomputeTasksInternal();
controller.notifyIfActiveInternal();
}
}
}
Future<String> requestAiGatewaySingleAgentCompletionDesktopInternal(
AppController controller, {
required Uri baseUrl,
required String apiKey,
required String model,
required String thinking,
required String sessionKey,
}) async {
final uri = controller.aiGatewayChatUriInternal(baseUrl);
final client = HttpClient()..connectionTimeout = const Duration(seconds: 20);
controller.aiGatewayStreamingClientsInternal[sessionKey] = client;
try {
final request = await client
.postUrl(uri)
.timeout(const Duration(seconds: 20));
request.headers.set(
HttpHeaders.acceptHeader,
'text/event-stream, application/json',
);
request.headers.set(
HttpHeaders.contentTypeHeader,
'application/json; charset=utf-8',
);
final trimmedApiKey = apiKey.trim();
if (trimmedApiKey.isNotEmpty) {
request.headers.set(
HttpHeaders.authorizationHeader,
'Bearer $trimmedApiKey',
);
request.headers.set('x-api-key', trimmedApiKey);
}
final payload = <String, dynamic>{
'model': model,
'stream': true,
'messages': buildAiGatewaySingleAgentRequestMessagesDesktopInternal(
controller,
sessionKey,
),
};
final normalizedThinking = thinking.trim().toLowerCase();
if (normalizedThinking.isNotEmpty && normalizedThinking != 'off') {
payload['reasoning_effort'] = normalizedThinking;
}
request.add(utf8.encode(jsonEncode(payload)));
final response = await request.close().timeout(const Duration(seconds: 60));
if (response.statusCode < 200 || response.statusCode >= 300) {
final body = await response.transform(utf8.decoder).join();
throw AiGatewayChatExceptionInternal(
controller.formatAiGatewayHttpErrorInternal(
response.statusCode,
controller.extractAiGatewayErrorDetailInternal(body),
),
);
}
final contentType =
response.headers.contentType?.mimeType.toLowerCase() ??
response.headers.value(HttpHeaders.contentTypeHeader)?.toLowerCase() ??
'';
if (contentType.contains('text/event-stream')) {
final streamed = await readAiGatewayStreamingResponseDesktopInternal(
controller,
response: response,
sessionKey: sessionKey,
);
if (streamed.trim().isEmpty) {
throw const FormatException('Missing assistant content');
}
return streamed.trim();
}
return await readAiGatewayJsonCompletionDesktopInternal(
controller,
response,
);
} catch (error) {
if (consumeAiGatewaySingleAgentAbortDesktopInternal(
controller,
sessionKey,
)) {
throw AiGatewayAbortExceptionInternal(
controller.aiGatewayStreamingTextBySessionInternal[sessionKey] ?? '',
);
}
rethrow;
} finally {
controller.aiGatewayStreamingClientsInternal.remove(sessionKey);
client.close(force: true);
}
}
List<Map<String, String>>
buildAiGatewaySingleAgentRequestMessagesDesktopInternal(
AppController controller,
String sessionKey,
) {
final history = <GatewayChatMessage>[
...(controller.gatewayHistoryCacheInternal[sessionKey] ??
const <GatewayChatMessage>[]),
...(controller.assistantThreadMessagesInternal[sessionKey] ??
const <GatewayChatMessage>[]),
];
return history
.where((message) {
final role = message.role.trim().toLowerCase();
return (role == 'user' || role == 'assistant') &&
(message.toolName ?? '').trim().isEmpty &&
message.text.trim().isNotEmpty;
})
.map(
(message) => <String, String>{
'role': message.role.trim().toLowerCase() == 'assistant'
? 'assistant'
: 'user',
'content': message.text.trim(),
},
)
.toList(growable: false);
}
Future<String> readAiGatewayJsonCompletionDesktopInternal(
AppController controller,
HttpClientResponse response,
) async {
final body = await response.transform(utf8.decoder).join();
final decoded = jsonDecode(controller.extractFirstJsonDocumentInternal(body));
final assistantText = controller.extractAiGatewayAssistantTextInternal(
decoded,
);
if (assistantText.trim().isEmpty) {
throw const FormatException('Missing assistant content');
}
return assistantText.trim();
}
Future<String> readAiGatewayStreamingResponseDesktopInternal(
AppController controller, {
required HttpClientResponse response,
required String sessionKey,
}) async {
final buffer = StringBuffer();
final eventLines = <String>[];
void processEvent(String payload) {
final trimmed = payload.trim();
if (trimmed.isEmpty || trimmed == '[DONE]') {
return;
}
final deltaText = extractAiGatewayStreamTextDesktopInternal(
controller,
trimmed,
);
if (deltaText.isEmpty) {
return;
}
final current = buffer.toString();
if (current.isEmpty || deltaText == current) {
buffer
..clear()
..write(deltaText);
} else if (deltaText.startsWith(current)) {
buffer
..clear()
..write(deltaText);
} else {
buffer.write(deltaText);
}
controller.setAiGatewayStreamingTextInternal(sessionKey, buffer.toString());
}
await for (final line
in response.transform(utf8.decoder).transform(const LineSplitter())) {
if (consumeAiGatewaySingleAgentAbortDesktopInternal(
controller,
sessionKey,
)) {
throw AiGatewayAbortExceptionInternal(buffer.toString());
}
if (line.isEmpty) {
if (eventLines.isNotEmpty) {
processEvent(eventLines.join('\n'));
eventLines.clear();
}
continue;
}
if (line.startsWith('data:')) {
eventLines.add(line.substring(5).trimLeft());
}
}
if (eventLines.isNotEmpty) {
processEvent(eventLines.join('\n'));
}
return buffer.toString();
}
String extractAiGatewayStreamTextDesktopInternal(
AppController controller,
String payload,
) {
final decoded = jsonDecode(
controller.extractFirstJsonDocumentInternal(payload),
);
final map = asMap(decoded);
final choices = asList(map['choices']);
if (choices.isNotEmpty) {
final firstChoice = asMap(choices.first);
final delta = asMap(firstChoice['delta']);
final deltaContent = controller.extractAiGatewayContentInternal(
delta['content'],
);
if (deltaContent.isNotEmpty) {
return deltaContent;
}
}
return controller.extractAiGatewayAssistantTextInternal(decoded);
}
Future<void> abortAiGatewaySingleAgentRunDesktopInternal(
AppController controller,
String sessionKey,
) async {
final normalizedSessionKey = controller.normalizedAssistantSessionKeyInternal(
sessionKey,
);
controller.aiGatewayAbortedSessionKeysInternal.add(normalizedSessionKey);
final client = controller.aiGatewayStreamingClientsInternal.remove(
normalizedSessionKey,
);
if (client != null) {
try {
client.close(force: true);
} catch (_) {
// Best effort only.
}
}
controller.aiGatewayPendingSessionKeysInternal.remove(normalizedSessionKey);
controller.clearAiGatewayStreamingTextInternal(normalizedSessionKey);
controller.upsertTaskThreadInternal(
normalizedSessionKey,
lifecycleStatus: 'ready',
lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastResultCode: 'aborted',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
controller.recomputeTasksInternal();
controller.notifyIfActiveInternal();
}
bool consumeAiGatewaySingleAgentAbortDesktopInternal(
AppController controller,
String sessionKey,
) {
return controller.aiGatewayAbortedSessionKeysInternal.remove(
controller.normalizedAssistantSessionKeyInternal(sessionKey),
);
}

View File

@ -0,0 +1,354 @@
// ignore_for_file: unused_import, unnecessary_import
import 'dart:async';
import 'package:flutter/material.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/go_task_service_client.dart';
import '../runtime/runtime_models.dart';
import 'app_controller_desktop_core.dart';
import 'app_controller_desktop_external_acp_routing.dart';
import 'app_controller_desktop_runtime_helpers.dart';
import 'app_controller_desktop_single_agent_ai_gateway.dart';
import 'app_controller_desktop_single_agent_status_messages.dart';
import 'app_controller_desktop_thread_sessions.dart';
import 'app_controller_desktop_thread_storage.dart';
import 'app_controller_desktop_skill_permissions.dart';
Future<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
AppController controller,
String message, {
required String thinking,
required List<GatewayChatAttachmentPayload> attachments,
required List<CollaborationAttachment> localAttachments,
}) async {
final sessionKey = controller.normalizedAssistantSessionKeyInternal(
controller.sessionsControllerInternal.currentSessionKey,
);
final trimmed = message.trim();
if (trimmed.isEmpty && attachments.isEmpty) {
return;
}
await controller.enqueueThreadTurnInternal<void>(sessionKey, () async {
final sessionTarget = controller.assistantExecutionTargetForSession(
sessionKey,
);
final userText = trimmed.isEmpty ? 'See attached.' : trimmed;
controller.appendAssistantThreadMessageInternal(
sessionKey,
GatewayChatMessage(
id: controller.nextLocalMessageIdInternal(),
role: 'user',
text: userText,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
);
controller.aiGatewayPendingSessionKeysInternal.add(sessionKey);
controller.recomputeTasksInternal();
controller.notifyIfActiveInternal();
try {
final routing = controller.buildExternalAcpRoutingForSessionInternal(
sessionKey,
);
final selection = controller.singleAgentProviderForSession(sessionKey);
await controller.syncExternalAcpProvidersInternal();
final capabilities = await controller.goTaskServiceClientInternal
.loadExternalAcpCapabilities(
target: AssistantExecutionTarget.singleAgent,
forceRefresh: true,
);
final availableProviders = controller.configuredSingleAgentProviders
.where(capabilities.providers.contains)
.toList(growable: false);
final provider = selection == SingleAgentProvider.auto
? (availableProviders.isEmpty ? null : availableProviders.first)
: (capabilities.providers.contains(selection) ? selection : null);
final fallbackReason = provider == null
? (selection == SingleAgentProvider.auto
? appText(
'当前没有可用的 GoTaskService Provider。',
'No GoTaskService provider is currently available.',
)
: appText(
'当前 GoTaskService 不支持 ${selection.label}',
'GoTaskService does not currently support ${selection.label}.',
))
: null;
if (provider == null && !routing.isAuto) {
if (controller.singleAgentUsesAiChatFallbackForSession(sessionKey)) {
appendSingleAgentFallbackStatusDesktopInternal(
controller,
sessionKey,
fallbackReason,
);
await sendAiGatewaySingleAgentMessageDesktopInternal(
controller,
message,
thinking: thinking,
attachments: attachments,
sessionKeyOverride: sessionKey,
appendUserMessage: false,
managePendingState: false,
);
} else {
controller.appendAssistantThreadMessageInternal(
sessionKey,
GatewayChatMessage(
id: controller.nextLocalMessageIdInternal(),
role: 'assistant',
text: singleAgentUnavailableLabelDesktopInternal(
controller,
sessionKey,
fallbackReason,
),
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: singleAgentRuntimeDebugToolNameDesktopInternal(
controller,
provider?.label ?? selection.label,
),
stopReason: null,
pending: false,
error: false,
),
);
}
return;
}
final effectiveProvider = sessionTarget == AssistantExecutionTarget.auto
? SingleAgentProvider.auto
: (provider ?? SingleAgentProvider.auto);
appendSingleAgentRuntimeStatusDesktopInternal(
controller,
sessionKey,
effectiveProvider,
);
final workingDirectory = controller
.resolveSingleAgentWorkingDirectoryForSessionInternal(
sessionKey,
provider: provider,
);
if (workingDirectory == null || workingDirectory.trim().isEmpty) {
final error = StateError(
appText(
'当前线程缺少可运行的工作路径,无法启动单机智能体。',
'This thread does not have a runnable workspace path, so Single Agent cannot start.',
),
);
controller.appendAssistantThreadMessageInternal(
sessionKey,
assistantErrorMessageSingleAgentDesktopInternal(
controller,
error.message,
),
);
throw error;
}
final selectedSkills = controller
.assistantSelectedSkillsForSession(sessionKey)
.map((item) => item.label.trim().isNotEmpty ? item.label : item.key)
.where((item) => item.trim().isNotEmpty)
.toList(growable: false);
final result = await controller.goTaskServiceClientInternal.executeTask(
GoTaskServiceRequest(
sessionId: sessionKey,
threadId: sessionKey,
target: AssistantExecutionTarget.singleAgent,
prompt: message,
workingDirectory: workingDirectory,
model: sessionTarget == AssistantExecutionTarget.auto
? ''
: controller.assistantModelForSession(sessionKey),
thinking: thinking,
selectedSkills: selectedSkills,
inlineAttachments: attachments,
localAttachments: localAttachments,
aiGatewayBaseUrl: controller.aiGatewayUrl,
aiGatewayApiKey: await controller.loadAiGatewayApiKey(),
agentId: '',
metadata: const <String, dynamic>{},
routing: routing,
provider: effectiveProvider,
),
onUpdate: (update) {
if (update.isDelta) {
controller.appendAiGatewayStreamingTextInternal(
sessionKey,
update.text,
);
controller.notifyIfActiveInternal();
}
},
);
_applySingleAgentGoTaskResultDesktopInternal(
controller,
sessionKey: sessionKey,
sessionTarget: sessionTarget,
message: message,
thinking: thinking,
attachments: attachments,
result: result,
);
} catch (error) {
controller.clearAiGatewayStreamingTextInternal(sessionKey);
controller.upsertTaskThreadInternal(
sessionKey,
lifecycleStatus: 'ready',
lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastResultCode: 'error',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
controller.appendAssistantThreadMessageInternal(
sessionKey,
assistantErrorMessageSingleAgentDesktopInternal(
controller,
error.toString(),
),
);
} finally {
controller.clearAiGatewayStreamingTextInternal(sessionKey);
controller.aiGatewayPendingSessionKeysInternal.remove(sessionKey);
controller.recomputeTasksInternal();
controller.notifyIfActiveInternal();
}
});
}
void _applySingleAgentGoTaskResultDesktopInternal(
AppController controller, {
required String sessionKey,
required AssistantExecutionTarget sessionTarget,
required String message,
required String thinking,
required List<GatewayChatAttachmentPayload> attachments,
required GoTaskServiceResult result,
}) {
final resolvedRuntimeModel = result.resolvedModel.trim();
final resolvedGatewayEntryState = goTaskServiceGatewayEntryState(
requestedTarget: sessionTarget,
result: result,
);
controller.upsertTaskThreadInternal(
sessionKey,
gatewayEntryState: resolvedGatewayEntryState,
latestResolvedRuntimeModel: resolvedRuntimeModel,
lifecycleStatus: 'ready',
lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastResultCode: result.success ? 'success' : 'error',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
_updateSingleAgentWorkspaceBindingFromResultDesktopInternal(
controller,
sessionKey,
result,
);
controller.clearAiGatewayStreamingTextInternal(sessionKey);
if (!result.success &&
controller.singleAgentUsesAiChatFallbackForSession(sessionKey)) {
appendSingleAgentFallbackStatusDesktopInternal(
controller,
sessionKey,
result.errorMessage,
);
controller.upsertTaskThreadInternal(
sessionKey,
gatewayEntryState: 'only-chat',
latestResolvedRuntimeModel: controller.resolvedAiGatewayModel,
lifecycleStatus: 'ready',
lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastResultCode: 'fallback',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
unawaited(
sendAiGatewaySingleAgentMessageDesktopInternal(
controller,
message,
thinking: thinking,
attachments: attachments,
sessionKeyOverride: sessionKey,
appendUserMessage: false,
managePendingState: false,
),
);
return;
}
if (!result.success) {
controller.appendAssistantThreadMessageInternal(
sessionKey,
assistantErrorMessageSingleAgentDesktopInternal(
controller,
appText(
'GoTaskService 执行失败:${result.errorMessage}',
'GoTaskService execution failed: ${result.errorMessage}',
),
),
);
return;
}
if (result.message.trim().isEmpty) {
controller.appendAssistantThreadMessageInternal(
sessionKey,
assistantErrorMessageSingleAgentDesktopInternal(
controller,
appText(
'GoTaskService 没有返回可显示的输出。',
'GoTaskService returned no displayable output.',
),
),
);
return;
}
controller.appendAssistantThreadMessageInternal(
sessionKey,
GatewayChatMessage(
id: controller.nextLocalMessageIdInternal(),
role: 'assistant',
text: result.message,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
);
}
void _updateSingleAgentWorkspaceBindingFromResultDesktopInternal(
AppController controller,
String sessionKey,
GoTaskServiceResult result,
) {
final resolvedWorkspaceKind = result.resolvedWorkspaceRefKind;
final resolvedWorkingDirectory = result.resolvedWorkingDirectory.trim();
if (resolvedWorkspaceKind == null || resolvedWorkingDirectory.isEmpty) {
return;
}
final existingThread = controller.requireTaskThreadForSessionInternal(
sessionKey,
);
controller.upsertTaskThreadInternal(
sessionKey,
workspaceBinding: WorkspaceBinding(
workspaceId: existingThread.workspaceBinding.workspaceId,
workspaceKind: resolvedWorkspaceKind == WorkspaceRefKind.remotePath
? WorkspaceKind.remoteFs
: WorkspaceKind.localFs,
workspacePath: resolvedWorkingDirectory,
displayPath: resolvedWorkingDirectory,
writable: existingThread.workspaceBinding.writable,
),
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
}

View File

@ -0,0 +1,136 @@
// ignore_for_file: unused_import, unnecessary_import
import 'package:flutter/material.dart';
import '../i18n/app_language.dart';
import '../runtime/runtime_models.dart';
import 'app_controller_desktop_core.dart';
import 'app_controller_desktop_runtime_helpers.dart';
import 'app_controller_desktop_thread_sessions.dart';
import 'app_controller_desktop_thread_storage.dart';
String? singleAgentRuntimeDebugToolNameDesktopInternal(
AppController controller,
String label,
) {
if (!controller.showsSingleAgentRuntimeDebugMessagesInternal) {
return null;
}
final trimmed = label.trim();
if (trimmed.isEmpty) {
return null;
}
return trimmed;
}
void appendSingleAgentRuntimeStatusDesktopInternal(
AppController controller,
String sessionKey,
SingleAgentProvider provider,
) {
if (!controller.showsSingleAgentRuntimeDebugMessagesInternal) {
return;
}
controller.appendAssistantThreadMessageInternal(
sessionKey,
GatewayChatMessage(
id: controller.nextLocalMessageIdInternal(),
role: 'assistant',
text: appText(
'单机智能体已切换到 ${provider.label} 执行当前任务。',
'Single Agent is using ${provider.label} for this task.',
),
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: provider.label,
stopReason: null,
pending: false,
error: false,
),
);
}
void appendSingleAgentFallbackStatusDesktopInternal(
AppController controller,
String sessionKey,
String? reason,
) {
if (!controller.showsSingleAgentRuntimeDebugMessagesInternal) {
return;
}
controller.appendAssistantThreadMessageInternal(
sessionKey,
GatewayChatMessage(
id: controller.nextLocalMessageIdInternal(),
role: 'assistant',
text: singleAgentFallbackLabelDesktopInternal(reason),
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: 'AI Chat fallback',
stopReason: null,
pending: false,
error: false,
),
);
}
String singleAgentFallbackLabelDesktopInternal(String? reason) {
final detail = reason?.trim() ?? '';
return detail.isEmpty
? appText(
'未发现可用的外部 Agent ACP 端点,已回退到 AI Chat。',
'No external Agent ACP endpoint is available. Falling back to AI Chat.',
)
: appText(
'外部 Agent ACP 连接不可用,已回退到 AI Chat$detail',
'External Agent ACP connection is unavailable. Falling back to AI Chat: $detail',
);
}
String singleAgentUnavailableLabelDesktopInternal(
AppController controller,
String sessionKey,
String? reason,
) {
final normalizedSessionKey = controller.normalizedAssistantSessionKeyInternal(
sessionKey,
);
final detail = reason?.trim() ?? '';
final selection = controller.singleAgentProviderForSession(
normalizedSessionKey,
);
if (controller.singleAgentShouldSuggestAutoSwitchForSession(
normalizedSessionKey,
)) {
return detail.isEmpty
? appText(
'当前线程固定为 ${selection.label},但它在这台设备上不可用。检测到其他外部 Agent ACP 端点时不会自动改线,可切到 Auto。',
'This thread is pinned to ${selection.label}, but it is unavailable on this device. XWorkmate will not reroute to another external Agent ACP endpoint automatically. Switch to Auto instead.',
)
: appText(
'当前线程固定为 ${selection.label}$detail 检测到其他外部 Agent ACP 端点时不会自动改线,可切到 Auto。',
'This thread is pinned to ${selection.label}: $detail XWorkmate will not reroute to another external Agent ACP endpoint automatically. Switch to Auto instead.',
);
}
if (controller.singleAgentNeedsAiGatewayConfigurationForSession(
normalizedSessionKey,
)) {
return detail.isEmpty
? appText(
'当前没有可用的外部 Agent ACP 端点,也没有可用的 AI Chat fallback。请先配置外部 Agent 连接,或配置 LLM API。',
'No external Agent ACP endpoint is available, and AI Chat fallback is not configured. Configure an external Agent connection or configure LLM API first.',
)
: appText(
'$detail 当前没有可用的外部 Agent ACP 端点,也没有可用的 AI Chat fallback。请先配置外部 Agent 连接,或配置 LLM API。',
'$detail No external Agent ACP endpoint is available, and AI Chat fallback is not configured. Configure an external Agent connection or configure LLM API first.',
);
}
return detail.isEmpty
? appText(
'当前线程的外部 Agent ACP 连接尚未就绪。',
'The external Agent ACP connection for this thread is not ready yet.',
)
: appText(
'当前线程的外部 Agent ACP 连接尚未就绪:$detail',
'The external Agent ACP connection for this thread is not ready yet: $detail',
);
}

View File

@ -39,6 +39,7 @@ import 'app_controller_desktop_core.dart';
import 'app_controller_desktop_navigation.dart';
import 'app_controller_desktop_gateway.dart';
import 'app_controller_desktop_settings.dart';
import 'app_controller_desktop_external_acp_routing.dart';
import 'app_controller_desktop_single_agent.dart';
import 'app_controller_desktop_thread_binding.dart';
import 'app_controller_desktop_thread_sessions.dart';
@ -479,10 +480,11 @@ extension AppControllerDesktopThreadActions on AppController {
);
if (aiGatewayPendingSessionKeysInternal.contains(sessionKey)) {
await goTaskServiceClientInternal.cancelTask(
route: assistantExecutionTargetForSession(sessionKey) ==
AssistantExecutionTarget.singleAgent ||
route:
assistantExecutionTargetForSession(sessionKey) ==
AssistantExecutionTarget.auto
AssistantExecutionTarget.singleAgent ||
assistantExecutionTargetForSession(sessionKey) ==
AssistantExecutionTarget.auto
? GoTaskServiceRoute.externalAcpSingle
: GoTaskServiceRoute.openClawTask,
target: assistantExecutionTargetForSession(sessionKey),

View File

@ -76,6 +76,11 @@ extension AppControllerDesktopThreadBinding on AppController {
return '/owners/$realm/$subjectType/$subjectId/threads/$normalizedSessionKey';
}
bool isOwnerScopedRemoteWorkspacePathInternal(String path) {
final normalizedPath = path.trim();
return normalizedPath.startsWith('/owners/');
}
String threadWorkspaceDirectoryNameInternal(String sessionKey) {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
@ -139,9 +144,14 @@ extension AppControllerDesktopThreadBinding on AppController {
required ThreadOwnerScope ownerScope,
WorkspaceBinding? existingBinding,
}) {
if (existingBinding != null &&
final preservesRemoteSingleAgentBinding =
existingBinding != null &&
existingBinding.workspaceKind == WorkspaceKind.remoteFs &&
existingBinding.workspacePath.trim().isNotEmpty) {
existingBinding.workspacePath.trim().isNotEmpty &&
!isOwnerScopedRemoteWorkspacePathInternal(
existingBinding.workspacePath,
);
if (preservesRemoteSingleAgentBinding) {
return existingBinding.copyWith(
displayPath: existingBinding.displayPath.trim().isEmpty
? existingBinding.workspacePath

View File

@ -661,7 +661,7 @@ extension AppControllerDesktopThreadSessions on AppController {
sessionKey,
);
final record = taskThreadForSessionInternal(normalizedSessionKey);
return sanitizeExecutionTargetInternal(
return sanitizePersistedExecutionTargetInternal(
record == null
? settings.assistantExecutionTarget
: assistantExecutionTargetFromExecutionMode(

View File

@ -314,7 +314,7 @@ List<String> assistantModelChoicesForSessionThreadSessionInternal(
final normalizedSessionKey = normalizeAssistantSessionKeyThreadInternal(
sessionKey,
);
final target = controller.sanitizeExecutionTargetInternal(
final target = controller.sanitizePersistedExecutionTargetInternal(
controller.taskThreadForSessionInternal(normalizedSessionKey) == null
? controller.settings.assistantExecutionTarget
: assistantExecutionTargetFromExecutionMode(

View File

@ -223,6 +223,15 @@ extension AppControllerDesktopThreadStorage on AppController {
).sanitizeExecutionTarget(target);
}
AssistantExecutionTarget sanitizePersistedExecutionTargetInternal(
AssistantExecutionTarget? target,
) {
if (target == AssistantExecutionTarget.auto) {
return AssistantExecutionTarget.auto;
}
return sanitizeExecutionTargetInternal(target);
}
MultiAgentConfig resolveMultiAgentConfigInternal(SettingsSnapshot snapshot) {
final defaults = MultiAgentConfig.defaults();
final current = snapshot.multiAgent;

View File

@ -52,7 +52,7 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
Future<void> setAssistantExecutionTarget(
AssistantExecutionTarget target,
) async {
final resolvedTarget = sanitizeExecutionTargetInternal(target);
final resolvedTarget = sanitizePersistedExecutionTargetInternal(target);
final currentTarget = assistantExecutionTargetForSession(
sessionsControllerInternal.currentSessionKey,
);
@ -78,6 +78,8 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
sessionsControllerInternal.currentSessionKey,
executionTarget: resolvedTarget,
executionTargetSource: ThreadSelectionSource.explicit,
gatewayEntryState: gatewayEntryStateForTargetInternal(resolvedTarget),
latestResolvedRuntimeModel: '',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
recomputeTasksInternal();
@ -177,7 +179,7 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
required String sessionKey,
required bool persistDefaultSelection,
}) async {
final resolvedTarget = sanitizeExecutionTargetInternal(target);
final resolvedTarget = sanitizePersistedExecutionTargetInternal(target);
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
);
@ -356,12 +358,10 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
singleAgentProviderForSession(currentSessionKey),
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
unawaited(
ensureDesktopTaskThreadBindingInternal(
normalizedSessionKey,
executionTarget: resolvedTarget,
),
);
// Re-read the current thread target when the async binding sync runs so a
// just-created thread cannot be rebound back to a stale target if the user
// switches execution mode immediately afterwards.
unawaited(ensureDesktopTaskThreadBindingInternal(normalizedSessionKey));
unawaited(persistAssistantLastSessionKeyInternal(normalizedSessionKey));
notifyIfActiveInternal();
}

View File

@ -206,8 +206,6 @@ class SingleAgentProvider {
final SingleAgentProviderSource source;
bool get isAuto => providerId == auto.providerId;
bool get isBuiltInReserved =>
source == SingleAgentProviderSource.builtInReserved;
bool get isExternalExtension =>
source == SingleAgentProviderSource.externalExtension;
@ -278,11 +276,7 @@ extension SingleAgentProviderCopy on SingleAgentProvider {
}) => SingleAgentProvider.fromJsonValue(value, label: label, badge: badge);
}
enum SingleAgentProviderSource { externalExtension, builtInReserved }
SingleAgentProvider normalizeSingleAgentProviderSelection(
SingleAgentProvider provider,
) => provider;
enum SingleAgentProviderSource { externalExtension }
List<SingleAgentProvider> normalizeSingleAgentProviderList(
Iterable<SingleAgentProvider> providers,
@ -290,9 +284,8 @@ List<SingleAgentProvider> normalizeSingleAgentProviderList(
final normalized = <SingleAgentProvider>[];
final seen = <String>{};
for (final provider in providers) {
final resolved = normalizeSingleAgentProviderSelection(provider);
if (seen.add(resolved.providerId)) {
normalized.add(resolved);
if (seen.add(provider.providerId)) {
normalized.add(provider);
}
}
return normalized;

View File

@ -526,17 +526,14 @@ class SettingsSnapshot {
}
SingleAgentProvider resolveSingleAgentProvider(SingleAgentProvider provider) {
final normalizedSelection = normalizeSingleAgentProviderSelection(provider);
if (normalizedSelection.isAuto) {
if (provider.isAuto) {
return SingleAgentProvider.auto;
}
final profile = externalAcpEndpointForProviderId(
normalizedSelection.providerId,
);
final profile = externalAcpEndpointForProviderId(provider.providerId);
if (profile != null) {
return profile.toProvider();
}
return normalizedSelection;
return provider;
}
SingleAgentProvider singleAgentProviderForId(String providerId) {
@ -544,9 +541,7 @@ class SettingsSnapshot {
if (resolved.isEmpty || resolved == SingleAgentProvider.auto.providerId) {
return SingleAgentProvider.auto;
}
final normalizedSelection = normalizeSingleAgentProviderSelection(
SingleAgentProvider.fromJsonValue(resolved),
);
final normalizedSelection = SingleAgentProvider.fromJsonValue(resolved);
final profile = externalAcpEndpointForProviderId(
normalizedSelection.providerId,
);

View File

@ -26,6 +26,11 @@ void main() {
// Tightened in T2/T3 after assistant + app/runtime closure split.
'lib/features/assistant/assistant_page_main.dart': 1000,
'lib/app/app_controller_desktop_runtime_helpers.dart': 800,
'lib/app/app_controller_desktop_single_agent.dart': 200,
'lib/app/app_controller_desktop_single_agent_ai_gateway.dart': 800,
'lib/app/app_controller_desktop_single_agent_go_task_flow.dart': 800,
'lib/app/app_controller_desktop_single_agent_status_messages.dart': 400,
'lib/app/app_controller_desktop_external_acp_routing.dart': 400,
'lib/app/app_controller_desktop_thread_sessions.dart': 800,
'lib/app/app_controller_desktop_runtime_coordination_impl.dart': 800,
'lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart':