Files
buzz/mobile/lib/features/agents/agent_instructions.dart
Tom Brow 9d1b684455 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>
2026-11-01 18:53:02 -08:00

168 lines
5.6 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
);
}
}