mirror of
https://github.com/block/buzz.git
synced 2026-11-07 01:41:06 +00:00
## 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>
150 lines
5.2 KiB
Dart
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);
|
|
}
|