mirror of
https://github.com/block/buzz.git
synced 2026-11-07 18:01:06 +00:00
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:
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')),
|
||||
|
||||
Reference in New Issue
Block a user