Refine single-agent skills caching
This commit is contained in:
parent
44b326e7fe
commit
2cb75d5d81
@ -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<AssistantThreadSkillEntry> _singleAgentSharedImportedSkills =
|
||||
const <AssistantThreadSkillEntry>[];
|
||||
bool _singleAgentLocalSkillsHydrated = false;
|
||||
final Map<String, HttpClient> _aiGatewayStreamingClients =
|
||||
<String, HttpClient>{};
|
||||
final Set<String> _aiGatewayPendingSessionKeys = <String>{};
|
||||
@ -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<void> refreshSingleAgentLocalSkillsForSession(
|
||||
Future<void> 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 = <String>{
|
||||
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 <String>[];
|
||||
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 <AssistantThreadSkillEntry>[];
|
||||
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: <String, dynamic>{
|
||||
'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<void> refreshSingleAgentLocalSkillsForSession(
|
||||
String sessionKey,
|
||||
) async {
|
||||
await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: true);
|
||||
await refreshSingleAgentSkillsForSession(sessionKey);
|
||||
}
|
||||
|
||||
Future<void> 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 <AssistantThreadSkillEntry>[];
|
||||
_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<void> ensureSharedSingleAgentLocalSkillsLoaded() async {
|
||||
if (_singleAgentLocalSkillsHydrated) {
|
||||
return;
|
||||
}
|
||||
await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: false);
|
||||
}
|
||||
|
||||
Future<void> _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<bool> _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<String, dynamic>(),
|
||||
),
|
||||
)
|
||||
.where((item) => item.key.trim().isNotEmpty && item.label.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
_singleAgentSharedImportedSkills = skills;
|
||||
_singleAgentLocalSkillsHydrated = true;
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<void> _replaceSingleAgentThreadSkills(
|
||||
String sessionKey,
|
||||
List<AssistantThreadSkillEntry> importedSkills,
|
||||
) async {
|
||||
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
|
||||
final importedKeys = importedSkills.map((item) => item.key).toSet();
|
||||
final nextSelected =
|
||||
(_assistantThreadRecords[normalizedSessionKey]?.selectedSkillKeys ??
|
||||
const <String>[])
|
||||
.where(importedKeys.contains)
|
||||
.toList(growable: false);
|
||||
_upsertAssistantThreadRecord(
|
||||
normalizedSessionKey,
|
||||
importedSkills: importedSkills,
|
||||
selectedSkillKeys: nextSelected,
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
_notifyIfActive();
|
||||
}
|
||||
|
||||
AssistantThreadSkillEntry _singleAgentSkillEntryFromAcp(
|
||||
Map<String, dynamic> 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<GatewayChatMessage>? messages,
|
||||
|
||||
@ -264,6 +264,20 @@ class GatewayAcpClient {
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> request({
|
||||
required String method,
|
||||
required Map<String, dynamic> params,
|
||||
}) async {
|
||||
return _requestWithFallback(
|
||||
_GatewayAcpRpcRequest(
|
||||
id: _nextRequestId(method),
|
||||
method: method,
|
||||
params: params,
|
||||
),
|
||||
onNotification: (_) {},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {}
|
||||
|
||||
Future<Map<String, dynamic>> _requestWithFallback(
|
||||
|
||||
@ -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<void> initialize() async {
|
||||
await _secretStore.initialize();
|
||||
@ -74,6 +79,36 @@ class SecureConfigStore {
|
||||
return _settingsStore.appendAudit(entry);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> 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<String, dynamic> ? decoded : null;
|
||||
}
|
||||
|
||||
Future<void> saveSupportJson(
|
||||
String relativePath,
|
||||
Map<String, dynamic> payload,
|
||||
) async {
|
||||
final file = await supportFile(relativePath);
|
||||
if (file == null) {
|
||||
return;
|
||||
}
|
||||
await atomicWriteString(file, jsonEncode(payload), ownerOnly: true);
|
||||
}
|
||||
|
||||
Future<File?> 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<Map<String, String>> loadSecureRefs() {
|
||||
return _secretStore.loadSecureRefs();
|
||||
}
|
||||
|
||||
@ -349,6 +349,108 @@ void main() {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController persists shared local skills cache and restores it on restart',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
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>[
|
||||
SingleAgentProvider.codex,
|
||||
],
|
||||
singleAgentLocalSkillScanRoots: <String>[
|
||||
agentsRoot.path,
|
||||
codexRoot.path,
|
||||
],
|
||||
);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
expect(
|
||||
controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.map((item) => item.label),
|
||||
containsAll(const <String>['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>[
|
||||
SingleAgentProvider.codex,
|
||||
],
|
||||
singleAgentLocalSkillScanRoots: <String>[
|
||||
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 <String>['Browser', 'PPT']),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _writeSkill(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user