diff --git a/mobile/lib/shared/me/me_assistants.dart b/mobile/lib/shared/me/me_assistants.dart index ad36a994cf..b4eaf823fc 100644 --- a/mobile/lib/shared/me/me_assistants.dart +++ b/mobile/lib/shared/me/me_assistants.dart @@ -163,7 +163,7 @@ Future> _managedAgentPubkeys( } return [ for (final MapEntry(key: d, value: event) in heads.entries) - if (_agentPubkey.hasMatch(d) && _hasName(event)) d, + if (_agentPubkey.hasMatch(d) && _isSupportedPolicy(event)) d, ]; } @@ -171,14 +171,41 @@ bool _newer(NostrEvent a, NostrEvent b) => a.createdAt > b.createdAt || (a.createdAt == b.createdAt && a.id.compareTo(b.id) < 0); -/// Desktop omits managed-agent records without a name; match its inventory. -bool _hasName(NostrEvent event) { +/// Whether a managed-agent record holds a policy desktop can apply. This +/// mirrors desktop's `ManagedAgentEventContent` parse, plus the non-empty name +/// desktop's agent library requires. An unsupported latest record fails +/// closed: it excludes the agent rather than falling back to an older one. +bool _isSupportedPolicy(NostrEvent event) { + final Object? decoded; try { - final body = jsonDecode(event.content); - return body is Map && - body['name'] is String && - (body['name'] as String).trim().isNotEmpty; - } catch (_) { + decoded = jsonDecode(event.content); + } on FormatException { return false; } + if (decoded is! Map) return false; + final body = decoded; + final name = body['name']; + final parallelism = body['parallelism']; + final allowlist = body['respond_to_allowlist']; + return name is String && + name.trim().isNotEmpty && + parallelism is int && + parallelism >= 0 && + parallelism <= 0xFFFFFFFF && + _respondTo.contains(body['respond_to']) && + (!body.containsKey('respond_to_allowlist') || + (allowlist is List && allowlist.every((entry) => entry is String))) && + _optionalPolicyText.every((field) { + final value = body[field]; + return value == null || value is String; + }); } + +const _respondTo = {'owner-only', 'allowlist', 'anyone'}; +const _optionalPolicyText = [ + 'persona_id', + 'system_prompt', + 'model', + 'provider', + 'persona_source_version', +]; diff --git a/mobile/test/features/channels/me/solo_creation_tags_test.dart b/mobile/test/features/channels/me/solo_creation_tags_test.dart index 80b111398b..46a594bb4a 100644 --- a/mobile/test/features/channels/me/solo_creation_tags_test.dart +++ b/mobile/test/features/channels/me/solo_creation_tags_test.dart @@ -114,7 +114,11 @@ void main() { final inventory = NostrEvent.fromJson( nostr.Event.from( kind: 30177, - content: jsonEncode({'name': 'Owned agent'}), + content: jsonEncode({ + 'name': 'Owned agent', + 'parallelism': 1, + 'respond_to': 'owner-only', + }), secretKey: owner.secret, tags: [ ['d', agent.public], diff --git a/mobile/test/shared/me/me_assistants_test.dart b/mobile/test/shared/me/me_assistants_test.dart index 652e1f18fd..4b046769fb 100644 --- a/mobile/test/shared/me/me_assistants_test.dart +++ b/mobile/test/shared/me/me_assistants_test.dart @@ -84,12 +84,17 @@ void main() { var fakeAgents = 0; String fakeAgent() => (++fakeAgents).toRadixString(16).padLeft(64, 'a'); + const policy = { + 'name': 'Agent', + 'parallelism': 1, + 'respond_to': 'owner-only', + }; NostrEvent managed( String d, { nostr.Keys? by, int time = 1, int kind = 30177, - Object? content = const {'name': 'Agent'}, + Object? content = policy, }) => NostrEvent.fromJson( nostr.Event.from( kind: kind, @@ -233,10 +238,10 @@ void main() { final relay = _Relay( inventory: [ [ - managed(renamed.public, time: 3, content: {'name': ' '}), + managed(renamed.public, time: 3, content: {...policy, 'name': ' '}), managed(agent.public, time: 2), managed(renamed.public, time: 2), - managed(unnamed.public, content: {'name': 7}), + managed(unnamed.public, content: {...policy, 'name': 7}), managed(agent.public.toUpperCase()), managed('not-a-pubkey'), ], @@ -251,6 +256,74 @@ void main() { expect(choices.map((a) => a.pubkey), [agent.public]); expect(relay.profileAuthors.single, [agent.public]); }); + for (final (label, content) in [ + ('unsupported respond_to', {...policy, 'respond_to': 'nobody'}), + ('missing respond_to', {'name': 'Agent', 'parallelism': 1}), + ('missing parallelism', {'name': 'Agent', 'respond_to': 'anyone'}), + ('negative parallelism', {...policy, 'parallelism': -1}), + ('fractional parallelism', {...policy, 'parallelism': 1.5}), + ('oversized parallelism', {...policy, 'parallelism': 0x100000000}), + ('non-list allowlist', {...policy, 'respond_to_allowlist': 'x'}), + ( + 'non-string allowlist entry', + { + ...policy, + 'respond_to': 'allowlist', + 'respond_to_allowlist': [7], + }, + ), + ('null allowlist', {...policy, 'respond_to_allowlist': null}), + ('non-string model', {...policy, 'model': 7}), + ('non-object content', ['Agent']), + ]) { + test('an unsupported latest policy fails closed: $label', () async { + final relay = _Relay( + inventory: [ + [ + managed(agent.public, time: 2, content: content), + managed(agent.public), + ], + ], + profiles: [profile(agent, ownedBy: owner)], + ); + final container = setup(relay); + expect(await container.read(meAssistantsProvider.future), isEmpty); + expect( + await isOwnedMeAssistant(container.read(meSyncProvider), agent.public), + isFalse, + ); + }); + } + test('accepts every supported policy shape', () async { + final shapes = [ + policy, + {...policy, 'respond_to': 'anyone', 'parallelism': 0}, + { + ...policy, + 'respond_to': 'allowlist', + 'respond_to_allowlist': [other.public], + 'persona_id': 'scout', + 'model': null, + 'future_field': true, + }, + ]; + final agents = [for (final _ in shapes) nostr.Keys.generate()]; + final relay = _Relay( + inventory: [ + [ + for (var i = 0; i < shapes.length; i++) + managed(agents[i].public, content: shapes[i]), + ], + ], + profiles: [for (final key in agents) profile(key, ownedBy: owner)], + ); + expect( + (await setup( + relay, + ).read(meAssistantsProvider.future)).map((a) => a.pubkey), + unorderedEquals(agents.map((key) => key.public)), + ); + }); test('reads beyond the first inventory page with a cursor', () async { final first = fullPage; final relay = _Relay( @@ -320,7 +393,11 @@ void main() { }); test('same-second inventory records keep the lowest id', () async { final named = managed(agent.public, time: 5); - final unnamed = managed(agent.public, time: 5, content: {'name': ''}); + final unnamed = managed( + agent.public, + time: 5, + content: {...policy, 'name': ''}, + ); final relay = _Relay( inventory: [ [named, unnamed],