mirror of
https://github.com/block/buzz.git
synced 2026-11-06 17:31:19 +00:00
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>
168 lines
5.6 KiB
Dart
168 lines
5.6 KiB
Dart
import 'dart:convert';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||
import 'package:uuid/uuid.dart';
|
||
|
||
import '../../shared/community/community_provider.dart';
|
||
import '../../shared/relay/relay.dart';
|
||
import '../../shared/relay/relay_author_provider.dart';
|
||
import '../../shared/theme/theme.dart';
|
||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||
|
||
/// Reads only the selected owner's community-scoped agent configuration.
|
||
final agentInstructionsProvider = FutureProvider.autoDispose
|
||
.family<String?, String>((ref, pubkey) async {
|
||
final community = ref.watch(activeCommunityProvider).asData?.value;
|
||
final owner = community?.pubkey;
|
||
if (owner == null) return null;
|
||
final relay = ref.watch(relaySessionProvider.notifier);
|
||
Future<List<NostrEvent>> query(NostrFilter filter) =>
|
||
relay.queryRelay([filter]);
|
||
final relayAuthor = await ref.watch(
|
||
relayAuthorProvider(community!.relayUrl).future,
|
||
);
|
||
if (relayAuthor == null) return null;
|
||
return loadAgentInstructions(pubkey, owner, relayAuthor, query);
|
||
});
|
||
|
||
/// Only published config is authoritative; local desktop overrides cannot be
|
||
/// inferred from legacy registration or definition prompts.
|
||
Future<String?> loadAgentInstructions(
|
||
String pubkey,
|
||
String owner,
|
||
String relayAuthor,
|
||
Future<List<NostrEvent>> Function(NostrFilter) query,
|
||
) async {
|
||
final channels = await query(
|
||
NostrFilter(
|
||
kinds: const [39000],
|
||
authors: [relayAuthor],
|
||
tags: {
|
||
'#P': [owner],
|
||
'#t': [pubkey],
|
||
},
|
||
),
|
||
);
|
||
final configs = channels.where((event) {
|
||
final channel = ChannelData.fromEvent(event);
|
||
return event.kind == 39000 &&
|
||
event.pubkey == relayAuthor &&
|
||
channel.creator == owner &&
|
||
channel.channelType == 'system' &&
|
||
channel.labels.contains('agent-config') &&
|
||
channel.labels.contains(pubkey);
|
||
}).toList();
|
||
if (channels.length >= 100) {
|
||
throw const FormatException('Too many config channels');
|
||
}
|
||
configs.sort((a, b) {
|
||
final left = int.tryParse(a.getTagValue('created_at') ?? '');
|
||
final right = int.tryParse(b.getTagValue('created_at') ?? '');
|
||
final order = left == null
|
||
? (right == null ? 0 : 1)
|
||
: (right == null ? -1 : left.compareTo(right));
|
||
return order != 0
|
||
? order
|
||
: (a.getTagValue('d') ?? '').compareTo(b.getTagValue('d') ?? '');
|
||
});
|
||
if (configs.isNotEmpty) {
|
||
final channel = ChannelData.fromEvent(configs.first);
|
||
if (channel.isArchived) return null;
|
||
final id = const Uuid().v5(channel.id, 'buzz.agent.instructions');
|
||
final artifacts = await query(
|
||
NostrFilter(
|
||
kinds: const [45010],
|
||
extensions: const {'artifact': 'current'},
|
||
tags: {
|
||
'#d': [id],
|
||
'#h': [channel.id],
|
||
},
|
||
),
|
||
);
|
||
if (artifacts.length != 1) return null;
|
||
final event = artifacts.single;
|
||
if (event.kind != 45010 ||
|
||
event.getTagValue('d') != id ||
|
||
event.getTagValue('h') != channel.id ||
|
||
event.getTagValue('type') != 'buzz.agent.instructions' ||
|
||
event.getTagValue('ar') != '1' ||
|
||
!['create', 'update', 'restore'].contains(event.getTagValue('op'))) {
|
||
return null;
|
||
}
|
||
final data = jsonDecode(event.content);
|
||
if (data is! Map ||
|
||
data['version'] != 1 ||
|
||
data['text'] is! String ||
|
||
(data['text'] as String).contains('\u0000')) {
|
||
throw const FormatException('Invalid instructions');
|
||
}
|
||
return data['text'] as String;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// 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, 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(
|
||
child: BuzzLoadingIndicator(semanticLabel: 'Loading instructions'),
|
||
),
|
||
error: (_, _) => Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text('Could not load instructions.'),
|
||
TextButton(
|
||
onPressed: () {
|
||
final community = ref.read(activeCommunityProvider).asData?.value;
|
||
if (community != null) {
|
||
ref.invalidate(relayAuthorProvider(community.relayUrl));
|
||
}
|
||
ref.invalidate(agentInstructionsProvider(pubkey));
|
||
},
|
||
child: const Text('Retry'),
|
||
),
|
||
],
|
||
),
|
||
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,
|
||
);
|
||
}
|
||
}
|