From 9d1b6844557252bf1d78037d595cf9dd3083cbec Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sun, 1 Nov 2026 18:53:02 -0800 Subject: [PATCH] fix(mobile): show remote agent instructions from the registry (#8908) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After creating a remote (Builderbot) agent with instructions on mobile, its profile said "Instructions aren't available on mobile for this agent." Two causes: - Mobile called `list-agents` without `include_instructions: true`, so registry rows had no `agent_instructions`. Desktop already sends this flag. - The profile read instructions only from published desktop agent config, which remote agents don't have. Now mobile requests instructions in `list-agents` (both the listing and the post-create readback). For an agent in the active community owner's registry listing, the profile shows the row's `agent_instructions`. Like desktop, a missing field means "No instructions set." Desktop agents keep the published-config path. Because listings now carry instructions (up to 20,000 characters each), `list-agents` gets a 4 MB response bound instead of 64 KB. The delete-retry journal, which lives in plain preferences, drops instructions before it's written. ### Before / after iPhone SE, remote-agent-pilot community: profile of an agent just created on mobile with the instructions "Reply in one short sentence." | Before (main) | After (this PR) | | --- | --- | | | | ### Validation - Extended `agents_sheet_test.dart`: the remote-agent case asserts the `include_instructions` flag and that the profile shows the registry text. It fails without the fix. - New/extended tests for the larger `list-agents` bound and the retry journal; each fails with its fix reverted. - `just mobile-check` and the mobile test suite pass. - iPhone SE (release build, remote-agent-pilot community): created an agent with instructions on mobile, then opened its profile, which showed them. Deleted the test agent afterward. buzz-review-completed (manual testing waived by Tom) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: Claude Opus 5.5 --- .../agents/agent_identity_sections.dart | 16 +++++-- .../features/agents/agent_instructions.dart | 42 +++++++++++++++---- mobile/lib/shared/agents/remote_agents.dart | 17 ++++---- .../lib/shared/hosted/builderlab_account.dart | 12 +++++- .../features/agents/agents_sheet_test.dart | 10 +++++ .../shared/agents/remote_agents_test.dart | 12 +++++- .../hosted/builderlab_account_test.dart | 28 +++++++++++++ 7 files changed, 116 insertions(+), 21 deletions(-) diff --git a/mobile/lib/features/agents/agent_identity_sections.dart b/mobile/lib/features/agents/agent_identity_sections.dart index 2da6c5eb77..063fcc1c60 100644 --- a/mobile/lib/features/agents/agent_identity_sections.dart +++ b/mobile/lib/features/agents/agent_identity_sections.dart @@ -1,13 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../shared/community/community_provider.dart'; import '../../shared/agents/remote_agents.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/app_list_card.dart'; import 'agent_instructions.dart'; /// The agent's instructions and management information in shared Settings cards. -class AgentIdentitySections extends HookWidget { +class AgentIdentitySections extends HookConsumerWidget { const AgentIdentitySections({ required this.pubkey, required this.controller, @@ -18,11 +20,19 @@ class AgentIdentitySections extends HookWidget { final RemoteAgentController controller; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { useListenable(controller); final remote = controller.agents.any( (agent) => agent['agent_pubkey'] == pubkey, ); + // Remote instructions come from the registry row, listed for the active + // community's owner only (as the agents page decides). + final owner = ref.watch(activeCommunityProvider).asData?.value?.pubkey; + final row = owner != null && controller.owner == owner + ? controller.agents + .where((agent) => agent['agent_pubkey'] == pubkey) + .firstOrNull + : null; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -34,7 +44,7 @@ class AgentIdentitySections extends HookWidget { padding: const EdgeInsets.all(Grid.xs), child: SizedBox( width: double.infinity, - child: AgentInstructions(pubkey: pubkey), + child: AgentInstructions(pubkey: pubkey, registryRow: row), ), ), ], diff --git a/mobile/lib/features/agents/agent_instructions.dart b/mobile/lib/features/agents/agent_instructions.dart index 76256f75b8..44b9473569 100644 --- a/mobile/lib/features/agents/agent_instructions.dart +++ b/mobile/lib/features/agents/agent_instructions.dart @@ -103,12 +103,25 @@ Future loadAgentInstructions( } /// Read-only instructions, with explicit unavailable and retry states. +/// +/// A remote agent's instructions live in its Builderlab registry row, not in +/// published config, so [registryRow] (when set) is the only source. Like +/// desktop, an absent `agent_instructions` field means none are set, and a +/// non-string value is an invalid listing. class AgentInstructions extends ConsumerWidget { - const AgentInstructions({required this.pubkey, super.key}); + const AgentInstructions({required this.pubkey, this.registryRow, super.key}); final String pubkey; + final Map? registryRow; @override Widget build(BuildContext context, WidgetRef ref) { + final row = registryRow; + if (row != null) { + final text = row['agent_instructions'] ?? ''; + return text is String + ? _InstructionsText(text) + : const Text('Could not load instructions.'); + } final instructions = ref.watch(agentInstructionsProvider(pubkey)); return instructions.when( loading: () => const Center( @@ -130,14 +143,25 @@ class AgentInstructions extends ConsumerWidget { ), ], ), - data: (text) => SelectableText( - text == null - ? 'Instructions aren’t available on mobile for this agent. View them on desktop.' - : text.trim().isEmpty - ? 'No instructions set.' - : text, - style: context.textTheme.bodyMedium, - ), + data: _InstructionsText.new, + ); + } +} + +class _InstructionsText extends StatelessWidget { + const _InstructionsText(this.text); + final String? text; + + @override + Widget build(BuildContext context) { + final text = this.text; + return SelectableText( + text == null + ? 'Instructions aren’t available on mobile for this agent. View them on desktop.' + : text.trim().isEmpty + ? 'No instructions set.' + : text, + style: context.textTheme.bodyMedium, ); } } diff --git a/mobile/lib/shared/agents/remote_agents.dart b/mobile/lib/shared/agents/remote_agents.dart index 3ea5518a63..0abfd4ba81 100644 --- a/mobile/lib/shared/agents/remote_agents.dart +++ b/mobile/lib/shared/agents/remote_agents.dart @@ -290,7 +290,9 @@ class RemoteAgentController extends ChangeNotifier { return; } await ensureBinding(); - final result = await account.post('/v3/beekeeper/list-agents', {}); + final result = await account.post('/v3/beekeeper/list-agents', { + 'include_instructions': true, + }); requireStatus(result, 'SUCCESS', 1); final rows = result['agents'] ?? []; if (rows is! List) throw StateError('Invalid agent listing.'); @@ -443,11 +445,9 @@ class RemoteAgentController extends ChangeNotifier { }, expectedSubject: subject); check(); if (result['error'] != null) throw AccountFailure.rejected; - final readback = await account.post( - '/v3/beekeeper/list-agents', - {}, - expectedSubject: subject, - ); + final readback = await account.post('/v3/beekeeper/list-agents', { + 'include_instructions': true, + }, expectedSubject: subject); check(); requireStatus(readback, 'SUCCESS', 1); final rows = readback['agents']; @@ -777,7 +777,10 @@ class RemoteAgentController extends ChangeNotifier { final retries = Map.from( jsonDecode(prefs.getString('$scope:delete-retries') ?? '{}') as Map, ); - retries[pubkey] = agent; + // Instructions are private, and this retry journal is plain preferences; + // a retry needs only the row's identity. + retries[pubkey] = Map.from(agent) + ..remove('agent_instructions'); if (!await prefs.setString('$scope:delete-retries', jsonEncode(retries))) { throw StateError('Could not save deletion retry. Retry Delete.'); } diff --git a/mobile/lib/shared/hosted/builderlab_account.dart b/mobile/lib/shared/hosted/builderlab_account.dart index e7f8a7851c..32dec638d1 100644 --- a/mobile/lib/shared/hosted/builderlab_account.dart +++ b/mobile/lib/shared/hosted/builderlab_account.dart @@ -83,6 +83,12 @@ class BuilderlabAccountClient { '/v3/beekeeper/list-agents', }; + /// Agent listings carry each agent's instructions (up to 20,000 characters + /// apiece), so they get room for many full agents; every other route keeps + /// the default bound. + static int _responseLimit(String path) => + path == '/v3/beekeeper/list-agents' ? 4 * 1024 * 1024 : 65536; + /// Sessions are scoped per origin so staging never reads production state. late final _storageKey = 'buzz_hosted_session_${accountOrigin.host.replaceAll('.', '_')}_v1'; @@ -374,7 +380,11 @@ class BuilderlabAccountClient { ); } } - final response = await boundedHttpResponse(_http, request); + final response = await boundedHttpResponse( + _http, + request, + maxBytes: _responseLimit(path), + ); if (kDebugMode && response.statusCode >= 400) { debugPrint( 'Builderlab ${accountOrigin.host} ${request.method} $path: HTTP ${response.statusCode} contentType=${response.headers['content-type']?.split(';').first}', diff --git a/mobile/test/features/agents/agents_sheet_test.dart b/mobile/test/features/agents/agents_sheet_test.dart index 226f744e85..e6872b687f 100644 --- a/mobile/test/features/agents/agents_sheet_test.dart +++ b/mobile/test/features/agents/agents_sheet_test.dart @@ -203,6 +203,7 @@ void main() { 'agent_pubkey': fakeAgent, 'agent_id': 'remote-id', 'agent_name': 'Scout', + 'agent_instructions': 'Review carefully.', }, ]; await h.controller.run(h.controller.refresh); @@ -213,6 +214,15 @@ void main() { find.textContaining('This agent runs on Builderbot.'), findsOneWidget, ); + // Remote instructions come from the registry row, not published config. + expect(h.registry.bodies['/v3/beekeeper/list-agents'], { + 'include_instructions': true, + }); + expect(find.text('Review carefully.'), findsOneWidget); + expect( + find.textContaining('Instructions aren’t available'), + findsNothing, + ); await tester.pumpAndSettle(); await tester.ensureVisible(find.text('Delete agent')); expect( diff --git a/mobile/test/shared/agents/remote_agents_test.dart b/mobile/test/shared/agents/remote_agents_test.dart index cbae56a746..655740f5c7 100644 --- a/mobile/test/shared/agents/remote_agents_test.dart +++ b/mobile/test/shared/agents/remote_agents_test.dart @@ -496,7 +496,11 @@ void main() { final registry = FakeRegistry(bound: fakeOwner.public); final controller = await _controller(registry); registry.listed = [ - {'agent_pubkey': fakeAgent, 'agent_id': 'remote-id'}, + { + 'agent_pubkey': fakeAgent, + 'agent_id': 'remote-id', + 'agent_instructions': 'Private prompt.', + }, ]; await controller.run(controller.refresh); registry.calls.clear(); @@ -534,6 +538,12 @@ void main() { controller.rejectRemoval = true; expect(await remove(), isFalse); expect(registry.calls, ['remove-registration']); + // The retry journal is plain preferences, so it never holds the prompt. + final journal = (await SharedPreferences.getInstance()).getString( + '$_scope:delete-retries', + ); + expect(journal, contains('remote-id')); + expect(journal, isNot(contains('Private prompt.'))); registry.calls.clear(); controller.rejectRemoval = false; expect(await remove(), isFalse); diff --git a/mobile/test/shared/hosted/builderlab_account_test.dart b/mobile/test/shared/hosted/builderlab_account_test.dart index 0f66f75a51..e9a09f0912 100644 --- a/mobile/test/shared/hosted/builderlab_account_test.dart +++ b/mobile/test/shared/hosted/builderlab_account_test.dart @@ -310,6 +310,34 @@ void main() { expect(await stored(), isNull); }); + test('only agent listings may exceed the default response bound', () async { + // 100 KB: past the 64 KB default, as a few full-length instructions are. + final large = jsonEncode({'status': 1, 'padding': 'x' * 100000}); + final client = BuilderlabAccountClient( + httpClient: MockClient((request) async { + if (request.url.path.endsWith('/exchange')) { + return http.Response( + jsonEncode({'session_credential': 'secret', 'expires_at': expiry}), + 200, + ); + } + if (request.url.path.contains('/beekeeper/')) { + return http.Response(large, 200); + } + return http.Response( + jsonEncode({'subject': 'account-1', 'expires_at': expiry}), + 200, + ); + }), + ); + await client.exchange(code: 'one-use', verifier: verifier); + expect((await client.post('/v3/beekeeper/list-agents', {}))['status'], 1); + await expectLater( + client.post('/v3/beekeeper/get-model-catalog', {}), + throwsA(anything), + ); + }); + test('authenticated post accepts only Buzz account routes', () async { final client = BuilderlabAccountClient( httpClient: MockClient((_) async => fail('no request expected')),