From 2cb75d5d81950de6126ecb34e2c391ce7e45e67d Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Wed, 25 Mar 2026 15:40:20 +0800 Subject: [PATCH] Refine single-agent skills caching --- lib/app/app_controller_desktop.dart | 240 +++++++++++++++--- lib/runtime/gateway_acp_client.dart | 14 + lib/runtime/secure_config_store.dart | 35 +++ .../app_controller_thread_skills_suite.dart | 102 ++++++++ 4 files changed, 351 insertions(+), 40 deletions(-) diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index 92b958f1..3a668f87 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -46,6 +46,9 @@ class _SingleAgentSkillScanRoot { final String scope; } +const String _singleAgentLocalSkillsCacheRelativePath = + 'cache/single-agent-local-skills.json'; + class AppController extends ChangeNotifier { static const List<_SingleAgentSkillScanRoot> _defaultGatewayOnlySkillScanRoots = <_SingleAgentSkillScanRoot>[ @@ -193,6 +196,7 @@ class AppController extends ChangeNotifier { DesktopThreadArtifactService(); List _singleAgentSharedImportedSkills = const []; + bool _singleAgentLocalSkillsHydrated = false; final Map _aiGatewayStreamingClients = {}; final Set _aiGatewayPendingSessionKeys = {}; @@ -481,7 +485,9 @@ class AppController extends ChangeNotifier { if (imported.isNotEmpty) { return imported; } - return _singleAgentSharedImportedSkills; + if (_singleAgentLocalSkillsHydrated) { + return _singleAgentSharedImportedSkills; + } } return imported; } @@ -1814,7 +1820,7 @@ class AppController extends ChangeNotifier { persistDefaultSelection: false, ); if (nextTarget == AssistantExecutionTarget.singleAgent) { - await refreshSingleAgentLocalSkillsForSession(nextSessionKey); + await refreshSingleAgentSkillsForSession(nextSessionKey); } _recomputeTasks(); } @@ -1921,7 +1927,7 @@ class AppController extends ChangeNotifier { persistDefaultSelection: true, ); if (resolvedTarget == AssistantExecutionTarget.singleAgent) { - await refreshSingleAgentLocalSkillsForSession( + await refreshSingleAgentSkillsForSession( _sessionsController.currentSessionKey, ); } @@ -1948,7 +1954,7 @@ class AppController extends ChangeNotifier { _notifyIfActive(); if (assistantExecutionTargetForSession(sessionKey) == AssistantExecutionTarget.singleAgent) { - await refreshSingleAgentLocalSkillsForSession(sessionKey); + await refreshSingleAgentSkillsForSession(sessionKey); } unawaited(refreshMultiAgentMounts(sync: settings.multiAgent.autoSync)); } @@ -2129,7 +2135,7 @@ class AppController extends ChangeNotifier { _notifyIfActive(); } - Future refreshSingleAgentLocalSkillsForSession( + Future refreshSingleAgentSkillsForSession( String sessionKey, ) async { final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); @@ -2137,35 +2143,71 @@ class AppController extends ChangeNotifier { AssistantExecutionTarget.singleAgent) { return; } - - final availableSkills = await _scanSingleAgentLocalSkillEntries(); - _singleAgentSharedImportedSkills = availableSkills; - final importedKeys = availableSkills.map((item) => item.key).toSet(); - final refreshTargets = { - normalizedSessionKey, - for (final entry in _assistantThreadRecords.entries) - if (assistantExecutionTargetForSession(entry.key) == - AssistantExecutionTarget.singleAgent) - entry.key, - }; - final refreshedAtMs = DateTime.now().millisecondsSinceEpoch.toDouble(); - for (final targetSessionKey in refreshTargets) { - final existingSelected = - _assistantThreadRecords[targetSessionKey]?.selectedSkillKeys ?? - const []; - final nextSelected = existingSelected - .where(importedKeys.contains) - .toList(growable: false); - _upsertAssistantThreadRecord( - targetSessionKey, - importedSkills: availableSkills, - selectedSkillKeys: nextSelected, - updatedAtMs: targetSessionKey == normalizedSessionKey - ? refreshedAtMs - : _assistantThreadRecords[targetSessionKey]?.updatedAtMs, + await ensureSharedSingleAgentLocalSkillsLoaded(); + final previousImported = + _assistantThreadRecords[normalizedSessionKey]?.importedSkills ?? + const []; + final provider = + singleAgentResolvedProviderForSession(normalizedSessionKey) ?? + currentSingleAgentResolvedProvider; + if (provider == null) { + await _replaceSingleAgentThreadSkills( + normalizedSessionKey, + _singleAgentSharedImportedSkills, ); + return; } - _notifyIfActive(); + try { + await _refreshAcpCapabilities(); + final response = await _gatewayAcpClient.request( + method: 'skills.status', + params: { + 'sessionId': normalizedSessionKey, + 'threadId': normalizedSessionKey, + 'mode': 'single-agent', + 'provider': provider.providerId, + }, + ); + final result = asMap(response['result']); + final payload = result.isNotEmpty ? result : response; + final skills = asList(payload['skills']) + .map(asMap) + .map((item) => _singleAgentSkillEntryFromAcp(item, provider)) + .where((item) => item.key.isNotEmpty && item.label.isNotEmpty) + .toList(growable: false); + await _replaceSingleAgentThreadSkills( + normalizedSessionKey, + skills.isNotEmpty ? skills : _singleAgentSharedImportedSkills, + ); + } on GatewayAcpException catch (error) { + if (_unsupportedAcpSkillsStatus(error)) { + await _replaceSingleAgentThreadSkills( + normalizedSessionKey, + _singleAgentSharedImportedSkills, + ); + return; + } + if (previousImported.isEmpty) { + await _replaceSingleAgentThreadSkills( + normalizedSessionKey, + _singleAgentSharedImportedSkills, + ); + } + } catch (_) { + if (previousImported.isEmpty) { + await _replaceSingleAgentThreadSkills( + normalizedSessionKey, + _singleAgentSharedImportedSkills, + ); + } + } + } + + Future refreshSingleAgentLocalSkillsForSession( + String sessionKey, + ) async { + await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: true); + await refreshSingleAgentSkillsForSession(sessionKey); } Future toggleAssistantSkillForSession( @@ -2776,6 +2818,7 @@ class AppController extends ChangeNotifier { return; } _disposed = true; + unawaited(_persistSharedSingleAgentLocalSkillsCache()); _runtimeEventsSubscription?.cancel(); _detachChildListeners(); _runtimeCoordinator.dispose(); @@ -2801,6 +2844,7 @@ class AppController extends ChangeNotifier { try { await _settingsController.initialize(); _restoreAssistantThreads(await _store.loadAssistantThreadRecords()); + await _restoreSharedSingleAgentLocalSkillsCache(); if (_disposed) { return; } @@ -2861,7 +2905,7 @@ class AppController extends ChangeNotifier { await _restoreInitialAssistantSessionSelection(); await _ensureActiveAssistantThread(); if (isSingleAgentMode) { - await refreshSingleAgentLocalSkillsForSession(currentSessionKey); + await refreshSingleAgentSkillsForSession(currentSessionKey); } _runtimeEventsSubscription = _runtimeCoordinator.gateway.events.listen( _handleRuntimeEvent, @@ -3078,7 +3122,7 @@ class AppController extends ChangeNotifier { persistDefaultSelection: false, ); if (target == AssistantExecutionTarget.singleAgent) { - await refreshSingleAgentLocalSkillsForSession(sessionKey); + await refreshSingleAgentSkillsForSession(sessionKey); } _recomputeTasks(); _notifyIfActive(); @@ -4210,6 +4254,7 @@ class AppController extends ChangeNotifier { _assistantThreadRecords.clear(); _assistantThreadMessages.clear(); _singleAgentSharedImportedSkills = const []; + _singleAgentLocalSkillsHydrated = false; final archivedKeys = settings.assistantArchivedTaskKeys .map(_normalizedAssistantSessionKey) .toSet(); @@ -4259,15 +4304,130 @@ class AppController extends ChangeNotifier { normalizedRecord.messages, ); } - if ((normalizedRecord.executionTarget ?? - settings.assistantExecutionTarget) == - AssistantExecutionTarget.singleAgent && - normalizedRecord.importedSkills.isNotEmpty) { - _singleAgentSharedImportedSkills = normalizedRecord.importedSkills; - } } } + Future ensureSharedSingleAgentLocalSkillsLoaded() async { + if (_singleAgentLocalSkillsHydrated) { + return; + } + await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: false); + } + + Future _refreshSharedSingleAgentLocalSkillsCache({ + required bool forceRescan, + }) async { + if (!forceRescan && _singleAgentLocalSkillsHydrated) { + return; + } + if (!forceRescan && await _restoreSharedSingleAgentLocalSkillsCache()) { + return; + } + final availableSkills = await _scanSingleAgentLocalSkillEntries(); + _singleAgentSharedImportedSkills = availableSkills; + _singleAgentLocalSkillsHydrated = true; + await _persistSharedSingleAgentLocalSkillsCache(); + } + + Future _restoreSharedSingleAgentLocalSkillsCache() async { + try { + final payload = await _store.loadSupportJson( + _singleAgentLocalSkillsCacheRelativePath, + ); + if (payload == null) { + return false; + } + final skills = asList(payload['skills']) + .map(asMap) + .map( + (item) => AssistantThreadSkillEntry.fromJson( + item.cast(), + ), + ) + .where((item) => item.key.trim().isNotEmpty && item.label.isNotEmpty) + .toList(growable: false); + _singleAgentSharedImportedSkills = skills; + _singleAgentLocalSkillsHydrated = true; + return true; + } catch (_) { + return false; + } + } + + Future _persistSharedSingleAgentLocalSkillsCache() async { + if (_singleAgentSharedImportedSkills.isEmpty) { + return; + } + try { + await _store.saveSupportJson(_singleAgentLocalSkillsCacheRelativePath, < + String, + dynamic + >{ + 'savedAtMs': DateTime.now().millisecondsSinceEpoch.toDouble(), + 'skills': _singleAgentSharedImportedSkills + .map((item) => item.toJson()) + .toList(growable: false), + }); + } catch (_) { + // Best effort only for local cache persistence. + } + } + + Future _replaceSingleAgentThreadSkills( + String sessionKey, + List importedSkills, + ) async { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + final importedKeys = importedSkills.map((item) => item.key).toSet(); + final nextSelected = + (_assistantThreadRecords[normalizedSessionKey]?.selectedSkillKeys ?? + const []) + .where(importedKeys.contains) + .toList(growable: false); + _upsertAssistantThreadRecord( + normalizedSessionKey, + importedSkills: importedSkills, + selectedSkillKeys: nextSelected, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + _notifyIfActive(); + } + + AssistantThreadSkillEntry _singleAgentSkillEntryFromAcp( + Map item, + SingleAgentProvider provider, + ) { + return AssistantThreadSkillEntry( + key: item['skillKey']?.toString().trim().isNotEmpty == true + ? item['skillKey'].toString().trim() + : (item['name']?.toString().trim() ?? ''), + label: item['name']?.toString().trim() ?? '', + description: item['description']?.toString().trim() ?? '', + source: item['source']?.toString().trim() ?? provider.providerId, + sourcePath: item['path']?.toString().trim() ?? '', + scope: item['scope']?.toString().trim().isNotEmpty == true + ? item['scope'].toString().trim() + : 'session', + sourceLabel: + item['sourceLabel']?.toString().trim().isNotEmpty == true + ? item['sourceLabel'].toString().trim() + : (item['source']?.toString().trim().isNotEmpty == true + ? item['source'].toString().trim() + : provider.label), + ); + } + + bool _unsupportedAcpSkillsStatus(GatewayAcpException error) { + final code = (error.code ?? '').trim(); + if (code == '-32601' || code == 'METHOD_NOT_FOUND') { + return true; + } + final message = error.toString().toLowerCase(); + return message.contains('unknown method') || + message.contains('method not found') || + message.contains('skills.status'); + } + void _upsertAssistantThreadRecord( String sessionKey, { List? messages, diff --git a/lib/runtime/gateway_acp_client.dart b/lib/runtime/gateway_acp_client.dart index 67ff2946..26ca5bb2 100644 --- a/lib/runtime/gateway_acp_client.dart +++ b/lib/runtime/gateway_acp_client.dart @@ -264,6 +264,20 @@ class GatewayAcpClient { ); } + Future> request({ + required String method, + required Map params, + }) async { + return _requestWithFallback( + _GatewayAcpRpcRequest( + id: _nextRequestId(method), + method: method, + params: params, + ), + onNotification: (_) {}, + ); + } + Future dispose() async {} Future> _requestWithFallback( diff --git a/lib/runtime/secure_config_store.dart b/lib/runtime/secure_config_store.dart index 0bb1d9be..28740a9a 100644 --- a/lib/runtime/secure_config_store.dart +++ b/lib/runtime/secure_config_store.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; +import 'dart:io'; + export 'file_store_support.dart'; export 'secret_store.dart'; export 'settings_store.dart'; @@ -21,6 +24,7 @@ class SecureConfigStore { secretRootPathResolver: fallbackDirectoryPathResolver, supportRootPathResolver: defaultSupportDirectoryPathResolver, ); + _layoutResolver = layoutResolver; _secretStore = SecretStore( fallbackDirectoryPathResolver: fallbackDirectoryPathResolver, databasePathResolver: databasePathResolver, @@ -40,6 +44,7 @@ class SecureConfigStore { late final SecretStore _secretStore; late final SettingsStore _settingsStore; + late final StoreLayoutResolver _layoutResolver; Future initialize() async { await _secretStore.initialize(); @@ -74,6 +79,36 @@ class SecureConfigStore { return _settingsStore.appendAudit(entry); } + Future?> loadSupportJson(String relativePath) async { + final file = await supportFile(relativePath); + if (file == null || !await file.exists()) { + return null; + } + final raw = await file.readAsString(); + final decoded = jsonDecode(raw); + return decoded is Map ? decoded : null; + } + + Future saveSupportJson( + String relativePath, + Map payload, + ) async { + final file = await supportFile(relativePath); + if (file == null) { + return; + } + await atomicWriteString(file, jsonEncode(payload), ownerOnly: true); + } + + Future supportFile(String relativePath) async { + final normalized = relativePath.trim(); + if (normalized.isEmpty) { + return null; + } + final layout = await _layoutResolver.resolve(); + return File('${layout.rootDirectory.path}/$normalized'); + } + Future> loadSecureRefs() { return _secretStore.loadSecureRefs(); } diff --git a/test/runtime/app_controller_thread_skills_suite.dart b/test/runtime/app_controller_thread_skills_suite.dart index 7295c690..40d3fde2 100644 --- a/test/runtime/app_controller_thread_skills_suite.dart +++ b/test/runtime/app_controller_thread_skills_suite.dart @@ -349,6 +349,108 @@ void main() { ); }, ); + + test( + 'AppController persists shared local skills cache and restores it on restart', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-single-agent-skills-cache-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + try { + await tempDirectory.delete(recursive: true); + } catch (_) {} + } + }); + final agentsRoot = Directory('${tempDirectory.path}/agents-skills'); + final codexRoot = Directory('${tempDirectory.path}/codex-skills'); + await _writeSkill( + agentsRoot, + 'browser', + skillName: 'Browser', + description: 'Browser tasks', + ); + await _writeSkill( + codexRoot, + 'ppt', + skillName: 'PPT', + description: 'Presentation tasks', + ); + + SecureConfigStore createStore() { + return SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => + '${tempDirectory.path}/settings.sqlite3', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + defaultSupportDirectoryPathResolver: () async => tempDirectory.path, + ); + } + + final firstStore = createStore(); + final controller = AppController( + store: firstStore, + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.codex, + ], + singleAgentLocalSkillScanRoots: [ + agentsRoot.path, + codexRoot.path, + ], + ); + await _waitFor(() => !controller.initializing); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.singleAgent, + ); + expect( + controller + .assistantImportedSkillsForSession(controller.currentSessionKey) + .map((item) => item.label), + containsAll(const ['Browser', 'PPT']), + ); + + final cacheFile = await firstStore.supportFile( + 'cache/single-agent-local-skills.json', + ); + expect(cacheFile, isNotNull); + await _waitFor(() => cacheFile != null && cacheFile.existsSync()); + controller.dispose(); + + if (await agentsRoot.exists()) { + await agentsRoot.delete(recursive: true); + } + if (await codexRoot.exists()) { + await codexRoot.delete(recursive: true); + } + + final restoredController = AppController( + store: createStore(), + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.codex, + ], + singleAgentLocalSkillScanRoots: [ + agentsRoot.path, + codexRoot.path, + ], + ); + addTearDown(restoredController.dispose); + await _waitFor(() => !restoredController.initializing); + await restoredController.setAssistantExecutionTarget( + AssistantExecutionTarget.singleAgent, + ); + + expect( + restoredController + .assistantImportedSkillsForSession( + restoredController.currentSessionKey, + ) + .map((item) => item.label), + containsAll(const ['Browser', 'PPT']), + ); + }, + ); } Future _writeSkill(