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>
This commit is contained in:
Wes
2026-11-05 18:00:58 -07:00
committed by GitHub
parent 5d094cd769
commit 1a30e51689
5 changed files with 103 additions and 7 deletions

View File

@@ -120,9 +120,9 @@ bool validAgentModelSelection(
/// 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; otherwise the first
/// Sonnet model, at medium effort when supported. Anything the catalog
/// doesn't offer is left for the user to choose.
/// 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,
@@ -135,6 +135,12 @@ bool validAgentModelSelection(
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;

View File

@@ -492,7 +492,10 @@ class RemoteAgentController extends ChangeNotifier {
}
final rows = result['models'];
if (rows is! List) throw StateError('Could not load available models.');
return rows.map((row) => RemoteAgentModel.fromJson(row)).toList();
final recommended = result['recommended_selection'];
return rows
.map((row) => RemoteAgentModel.fromJson(row, recommended: recommended))
.toList();
}
/// Saves only an active agent owned by this account, then verifies readback.
@@ -986,12 +989,19 @@ class RemoteAgentModel {
required this.id,
required this.name,
required this.efforts,
this.recommendedEffort,
});
final String id;
final String name;
final List<String> efforts;
factory RemoteAgentModel.fromJson(dynamic value) {
/// Set when the service recommends this model, at this effort, for new
/// agents. Creation guidance only; it never replaces a saved choice.
final String? recommendedEffort;
/// [recommended] is the catalog's optional `recommended_selection`; a
/// malformed one, or one naming a pair this model lacks, is ignored.
factory RemoteAgentModel.fromJson(dynamic value, {dynamic recommended}) {
if (value is! Map ||
value['id'] is! String ||
(value['id'] as String).trim().isEmpty ||
@@ -1005,10 +1015,18 @@ class RemoteAgentModel {
efforts.any((item) => item is! String || item.trim().isEmpty)) {
throw StateError('Invalid model catalog.');
}
final id = value['id'] as String;
final recommendedEffort =
recommended is Map && recommended['model_id'] == id
? recommended['reasoning_effort']
: null;
return RemoteAgentModel(
id: value['id'] as String,
id: id,
name: value['display_name'] as String,
efforts: List<String>.from(efforts),
recommendedEffort: efforts.contains(recommendedEffort)
? recommendedEffort as String
: null,
);
}
}

View File

@@ -30,6 +30,44 @@ void main() {
expect(startingAgentModel([opus, highOnly]), ('claude-sonnet-5', null));
});
test('the service recommendation wins over the Sonnet fallback', () {
final recommendedOpus = RemoteAgentModel.fromJson(
{
'id': opus.id,
'display_name': opus.name,
'supported_reasoning_efforts': opus.efforts,
},
recommended: {'model_id': opus.id, 'reasoning_effort': 'high'},
);
expect(startingAgentModel([recommendedOpus, sonnet]), (opus.id, 'high'));
expect(
startingAgentModel(
[recommendedOpus, sonnet],
draftModel: sonnet.id,
draftEffort: 'low',
),
(sonnet.id, 'low'),
);
});
test('ignores a recommendation the model does not offer', () {
for (final recommended in [
{'model_id': opus.id, 'reasoning_effort': 'max'},
{'model_id': 'gone', 'reasoning_effort': 'high'},
{'model_id': opus.id},
'opus',
null,
]) {
final model = RemoteAgentModel.fromJson({
'id': opus.id,
'display_name': opus.name,
'supported_reasoning_efforts': opus.efforts,
}, recommended: recommended);
expect(model.recommendedEffort, isNull, reason: '$recommended');
expect(startingAgentModel([model, sonnet]), (sonnet.id, 'medium'));
}
});
test('chooses nothing when no Sonnet is offered', () {
expect(startingAgentModel([opus]), (null, null));
});

View File

@@ -279,6 +279,36 @@ void main() {
isNotNull,
);
});
testWidgets('a new agent starts on the service-recommended model', (
tester,
) async {
final h = await AgentsHarness.create();
h.registry.models = [
{
'id': 'premium-model',
'display_name': 'Premium model',
'supported_reasoning_efforts': ['low', 'medium', 'high'],
},
{
'id': 'kgoose-claude-sonnet-4-6',
'display_name': 'Sonnet',
'supported_reasoning_efforts': ['low', 'medium', 'high'],
},
];
h.registry.recommendedSelection = {
'model_id': 'premium-model',
'reasoning_effort': 'high',
};
await h.open(tester, directory: [existing]);
if (!supportsRemoteAgents(h.community)) return;
await tester.tap(find.byTooltip('Add agent'));
await tester.pumpAndSettle();
expect(find.text('Premium model'), findsOneWidget);
expect(find.text('Sonnet'), findsNothing);
expect(find.text('high'), findsOneWidget);
});
}
class _StalledPosterClient implements HttpClient {

View File

@@ -54,6 +54,7 @@ class FakeRegistry extends BuilderlabAccountClient {
'supported_reasoning_efforts': ['max'],
},
];
Map<String, dynamic>? recommendedSelection;
Completer<void>? catalogGate;
bool failCatalog = false;
bool failListing = false;
@@ -98,7 +99,10 @@ class FakeRegistry extends BuilderlabAccountClient {
case '/v3/beekeeper/get-model-catalog':
await catalogGate?.future;
if (failCatalog) throw StateError('Catalog unavailable.');
return {'models': models};
return {
'models': models,
'recommended_selection': ?recommendedSelection,
};
case '/v3/beekeeper/list-agents':
if (failListing) throw StateError('Registry unavailable.');
return {'status': 1, 'agents': listed};