fix(mobile): show remote agent instructions from the registry (#8908)

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) |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/5d7333ea429e6c8c8aae93846d23c3a2bb9bda25/pr-8908--before-instructions.png"
width="300"> | <img
src="https://raw.githubusercontent.com/block/buzz/5d7333ea429e6c8c8aae93846d23c3a2bb9bda25/pr-8908--after-instructions.png"
width="300"> |

### 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 <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tom Brow
2026-11-01 18:53:02 -08:00
committed by GitHub
parent 985de41f14
commit 9d1b684455
7 changed files with 116 additions and 21 deletions

View File

@@ -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),
),
),
],

View File

@@ -103,12 +103,25 @@ Future<String?> 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<String, dynamic>? 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,
);
}
}

View File

@@ -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<String, dynamic>.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<String, dynamic>.from(agent)
..remove('agent_instructions');
if (!await prefs.setString('$scope:delete-retries', jsonEncode(retries))) {
throw StateError('Could not save deletion retry. Retry Delete.');
}

View File

@@ -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}',

View File

@@ -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(

View File

@@ -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);

View File

@@ -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')),