Files
buzz/mobile/lib/features/agents/agent_model_picker.dart
Wes 1a30e51689 mobile: start new agents on the server-recommended model (#9151)
## Summary

Builderlab's model catalog can now recommend a model and thinking effort
for new agents (`recommended_selection` on
`/v3/beekeeper/get-model-catalog`). The mobile create-agent screen now
starts on that recommendation, so the default can change on the server
without an app release.

New agents start on the first of these that applies:

1. The model an agent link suggests, when the catalog offers it
(unchanged).
2. The server's recommended model and effort.
3. The first Sonnet model at medium effort, as added in #9034. Used
while a deployment sends no recommendation.
4. Blank.

A setup resumed after a failure still keeps its saved choice. A
recommendation that isn't in the returned catalog, or that is malformed,
is ignored. Existing agents' saved models are never changed.

The server only sends a recommendation when the deployment's catalog
config sets `recommended_selection`. Until then, the app keeps falling
back to Sonnet.

## Testing

- Unit tests cover the recommendation beating the Sonnet fallback, a
draft model beating the recommendation, and recommendations that should
be ignored (unsupported effort, unknown model, missing effort,
malformed, absent).
- A new widget test checks that the form opens on the recommended model
and effort instead of Sonnet.
- `just mobile-check` and the full mobile suite (`flutter test
--dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example`) pass locally.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:00:58 -07:00

150 lines
5.2 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../../shared/agents/remote_agents.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/sheet_action_section.dart';
import '../../shared/widgets/buzz_setting_menu.dart';
/// Uses the server catalog for both menus, including model-specific efforts.
class AgentModelPicker extends StatelessWidget {
const AgentModelPicker({
required this.controller,
required this.catalog,
required this.onRetry,
required this.onChanged,
this.modelId,
this.effort,
super.key,
});
final RemoteAgentController controller;
final AsyncSnapshot<List<RemoteAgentModel>> catalog;
final VoidCallback onRetry;
final String? modelId;
final String? effort;
final void Function(String?, String?) onChanged;
@override
Widget build(BuildContext context) {
// While loading, show the same rows disabled so nothing shifts.
final loading = catalog.connectionState != ConnectionState.done;
if (!loading && (catalog.hasError || catalog.data!.isEmpty)) {
return Column(
children: [
Text(
catalog.hasError
? 'Could not load available models.'
: 'No models are available.',
),
TextButton(onPressed: onRetry, child: const Text('Retry models')),
],
);
}
final models = loading ? const <RemoteAgentModel>[] : catalog.data!;
final selected = models.where((model) => model.id == modelId).firstOrNull;
final validEffort = selected?.efforts.contains(effort) == true
? effort
: null;
final enabled = !loading && !controller.busy;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SheetActionSection(
dividerIndent: Grid.xs,
children: [
BuzzSettingMenu<String>(
controlKey: const Key('agent-model'),
enabled: enabled,
label: 'Model',
grouped: true,
trailingIcon: Icons.unfold_more,
onSelected: (value) => onChanged(value, null),
options: [
for (final model in models)
BuzzSettingMenuOption(label: model.name, value: model.id),
],
value: selected?.id,
valueLabel: loading ? 'Loading…' : selected?.name ?? 'Choose',
),
BuzzSettingMenu<String>(
controlKey: const Key('agent-effort'),
enabled: enabled && selected != null,
label: 'Thinking',
grouped: true,
trailingIcon: Icons.unfold_more,
onSelected: (value) => onChanged(selected!.id, value),
options: [
for (final value in selected?.efforts ?? <String>[])
BuzzSettingMenuOption(label: value, value: value),
],
value: validEffort,
valueLabel: loading ? 'Loading…' : validEffort ?? 'Choose',
),
],
),
if (modelId != null && selected == null ||
effort != null && validEffort == null)
const Text(
'Choose an available model and supported reasoning effort.',
),
],
);
}
}
/// Shares catalog loading with the submit action so stale selections cannot save.
(AsyncSnapshot<List<RemoteAgentModel>>, VoidCallback) useAgentModelCatalog(
RemoteAgentController controller,
) {
final retry = useState(0);
final future = useMemoized(controller.modelCatalog, [
controller,
retry.value,
]);
return (useFuture(future, preserveState: false), () => retry.value++);
}
/// True only after the loaded catalog confirms this exact pair.
bool validAgentModelSelection(
AsyncSnapshot<List<RemoteAgentModel>> catalog,
String? model,
String? effort,
) =>
catalog.connectionState == ConnectionState.done &&
!catalog.hasError &&
catalog.data?.any(
(entry) => entry.id == model && entry.efforts.contains(effort),
) ==
true;
/// The model and effort a new agent starts with, so the easy path is a
/// capable but affordable choice rather than a blank (or the priciest) one.
/// A draft's own model wins when the catalog offers it; then the service's
/// recommendation; otherwise the first Sonnet model, at medium effort when
/// supported. Anything the catalog doesn't offer is left for the user.
(String?, String?) startingAgentModel(
List<RemoteAgentModel> catalog, {
String? draftModel,
String? draftEffort,
}) {
final drafted = catalog.where((entry) => entry.id == draftModel).firstOrNull;
if (drafted != null) {
return (
drafted.id,
drafted.efforts.contains(draftEffort) ? draftEffort : null,
);
}
final recommended = catalog
.where((entry) => entry.recommendedEffort != null)
.firstOrNull;
if (recommended != null) {
return (recommended.id, recommended.recommendedEffort);
}
final fallback = catalog
.where((entry) => entry.id.toLowerCase().contains('sonnet'))
.firstOrNull;
if (fallback == null) return (null, null);
return (fallback.id, fallback.efforts.contains('medium') ? 'medium' : null);
}