tandem 0.2.0
tandem: ^0.2.0 copied to clipboard
Makes an existing Flutter screen agent-aware over AG-UI. The agent sees what its state class lists and calls its methods; important actions wait for approval.
A shopping list the agent can read and change. It can empty the list only after the user approves, and only when there is something on it.
import 'package:example/agent_chat.dart';
import 'package:example/agent_workspace.dart';
import 'package:example/item_input.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:tandem/tandem.dart';
part 'shopping_list.g.dart';
class const ShoppingListState({final List<String> items = const []});
@TandemAgent()
class ShoppingListCubit()
extends Cubit<ShoppingListState>
with _$ShoppingListCubit {
this : super(const .new());
@override
List<AgentCapability> get agentCapabilities => [
.sees(items),
.calls(add),
.calls(remove),
if (items.isNotEmpty) .callsWithApproval(clear),
];
@override
Stream<Object?> get changes => stream;
/// What is on the shopping list now.
List<String> get items => state.items;
/// Adds [item] to the list.
void add(String item) => emit(.new(items: [...items, item]));
/// Removes [item] from the list. Refuses names absent from the current list.
void remove(String item) {
if (items.contains(item)) {
emit(.new(items: [...items]..remove(item)));
} else {
throw ToolCallRefused(
'"$item" is not on the shopping list. '
'Use an exact item name from the current list.',
);
}
}
/// Empties the list.
void clear() => emit(const .new());
}
void main() => runApp(
MaterialApp(
home: ShoppingListScreen(
connection: HttpAgentConnection(Uri.parse('http://localhost:8000/agent')),
),
),
);
final class const ShoppingListScreen({
super.key,
required final AgentConnection connection,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => BlocProvider(
create: (context) => ShoppingListCubit(),
child: TandemScope.connected(
surface: (context) => context.read<ShoppingListCubit>(),
connection: connection,
approvals: const .fromState(),
child: const _ShoppingListView(),
),
);
}
final class const _ShoppingListView() extends StatelessWidget {
@override
Widget build(BuildContext context) => const Scaffold(
body: AgentWorkspace(
title: 'Shopping list',
chat: AgentChat(suggestion: 'Add milk and bread, then empty the list.'),
child: _Items(),
),
);
}
agent_chat.dart is the chat of the example: copy it into your app, make its texts and look your own, and import it from there. AgentWorkspace puts the list and the chat side by side, or one above the other on a phone, and _Items is the list itself, with buttons to remove an item and a field to add one (the whole file). approvals: const .fromState() lets the chat draw the card for clear. Run dart run build_runner build to generate shopping_list.g.dart, then start an AG-UI agent on localhost:8000 that puts context into the model's prompt.
No agent yet? ScriptedAgent plays a script in its place, with no backend and no API key. Run this file with flutter run -t and its path:
import 'package:example/shopping_list/shopping_list.dart';
import 'package:flutter/material.dart';
import 'package:tandem/testing.dart';
// Plays these turns whatever the user writes. A run past the script fails.
final _agent = ScriptedAgent([
(_) => const [
AgentStep.callTool('add', {'item': 'Milk'}),
AgentStep.callTool('add', {'item': 'Bread'}),
],
(_) => const [
AgentStep.say('Added milk and bread. Shall I empty the list?'),
AgentStep.callTool('clear'),
],
(_) => const [AgentStep.say('Done.')],
]);
void main() =>
runApp(MaterialApp(home: ShoppingListScreen(connection: _agent)));
The example gallery runs this screen and five more with scripted agents. More screens to copy, with Riverpod, provider, a dialog for approvals and a button instead of a chat, are in doc/examples.md.
The same screen can use Apple's on-device model instead of a backend:
void main() => runApp(
MaterialApp(home: ShoppingListScreen(connection: OnDeviceAgent.apple())),
);