ADR-0005 in-process tool runtime — 6 tools (catalog 2 + tracker 2 + habit 2), ToolDispatcher with JSON-schema validation + modal ConfirmGate for destructive ops, multi-turn LlmChatSession abstraction wired to flutter_gemma 0.16.5 (ToolChoice.auto), ChatSessionController with MAX_TURNS=4 safety + 8-turn history hint, ChatScreen entry behind AI opt-in. R3/R7/R8 enforced inside handlers. 41 new tests (envelope, catalog/tracker/habit tools, dispatcher, controller loop) — 151 total passing. Refs #260
194 lines
5.2 KiB
Dart
194 lines
5.2 KiB
Dart
import '../../ai/tools/tool_definition.dart';
|
|
|
|
/// Abstract LLM backend.
|
|
///
|
|
/// Concrete impls: `GemmaLlmService` (flutter_gemma) and `MockLlmService` (tests).
|
|
/// Contract:
|
|
/// - After [load], [isLoaded] is true.
|
|
/// - [generateStructured] throws [StateError] when not loaded.
|
|
/// - [generateStructured] returns a parsed JSON map matching the schema.
|
|
/// On schema/parse failure throws [FormatException].
|
|
/// - [unload] is idempotent.
|
|
/// - [startChat] opens a multi-turn chat session for tool calling (#260).
|
|
abstract class LlmService {
|
|
bool get isLoaded;
|
|
|
|
Future<void> load();
|
|
|
|
Future<void> unload();
|
|
|
|
/// Calls the model with a function-calling [schema] and returns the parsed
|
|
/// JSON arguments map. Caller is responsible for applying `.timeout(...)`.
|
|
Future<Map<String, dynamic>> generateStructured(
|
|
String prompt,
|
|
Map<String, dynamic> schema,
|
|
);
|
|
|
|
/// Opens a chat session that supports multi-turn user input + tool result
|
|
/// submission with the supplied [tools]. See ADR-0005.
|
|
Future<LlmChatSession> startChat({
|
|
required List<ToolDefinition> tools,
|
|
});
|
|
}
|
|
|
|
/// Streaming chat session for the tool-calling loop.
|
|
///
|
|
/// Lifecycle: created by [LlmService.startChat], lives for a single chat
|
|
/// screen, must be [close]d when the user dismisses the screen. Each
|
|
/// `send*` call returns a stream of [LlmChatEvent]s until the model yields
|
|
/// control (text done or a function call requested).
|
|
abstract class LlmChatSession {
|
|
Stream<LlmChatEvent> sendUser(String text);
|
|
|
|
Stream<LlmChatEvent> sendToolResult({
|
|
required String toolName,
|
|
required Map<String, dynamic> result,
|
|
});
|
|
|
|
Future<void> close();
|
|
}
|
|
|
|
/// Events emitted by [LlmChatSession]. See ADR-0005 §C.
|
|
sealed class LlmChatEvent {
|
|
const LlmChatEvent();
|
|
}
|
|
|
|
final class LlmTextChunk extends LlmChatEvent {
|
|
final String text;
|
|
const LlmTextChunk(this.text);
|
|
}
|
|
|
|
final class LlmFunctionCall extends LlmChatEvent {
|
|
final String name;
|
|
final Map<String, dynamic> args;
|
|
const LlmFunctionCall(this.name, this.args);
|
|
}
|
|
|
|
/// Programmable stub for tests. Use [enqueueResponse] / [enqueueError].
|
|
class MockLlmService implements LlmService {
|
|
final List<_Response> _queue = [];
|
|
bool _loaded = false;
|
|
int callCount = 0;
|
|
String? lastPrompt;
|
|
Map<String, dynamic>? lastSchema;
|
|
Duration responseDelay = Duration.zero;
|
|
|
|
/// Queues consumed by [startChat] in order. Each entry is the event list
|
|
/// returned for a single `send*` call.
|
|
final List<List<LlmChatEvent>> chatScript = [];
|
|
int chatStartCount = 0;
|
|
MockLlmChatSession? lastChat;
|
|
|
|
@override
|
|
bool get isLoaded => _loaded;
|
|
|
|
@override
|
|
Future<void> load() async {
|
|
_loaded = true;
|
|
}
|
|
|
|
@override
|
|
Future<void> unload() async {
|
|
_loaded = false;
|
|
}
|
|
|
|
void enqueueResponse(Map<String, dynamic> response) {
|
|
_queue.add(_Response.value(response));
|
|
}
|
|
|
|
void enqueueError(Object error) {
|
|
_queue.add(_Response.error(error));
|
|
}
|
|
|
|
/// Enqueue one batch of events that will be emitted on the next
|
|
/// `sendUser` or `sendToolResult` call. Items are streamed in order.
|
|
void enqueueChatEvents(List<LlmChatEvent> events) {
|
|
chatScript.add(events);
|
|
}
|
|
|
|
@override
|
|
Future<Map<String, dynamic>> generateStructured(
|
|
String prompt,
|
|
Map<String, dynamic> schema,
|
|
) async {
|
|
callCount += 1;
|
|
lastPrompt = prompt;
|
|
lastSchema = schema;
|
|
if (!_loaded) {
|
|
throw StateError('LlmService not loaded');
|
|
}
|
|
if (responseDelay > Duration.zero) {
|
|
await Future<void>.delayed(responseDelay);
|
|
}
|
|
if (_queue.isEmpty) {
|
|
throw StateError('MockLlmService: no queued response');
|
|
}
|
|
final r = _queue.removeAt(0);
|
|
if (r.error != null) throw r.error!;
|
|
return r.value!;
|
|
}
|
|
|
|
@override
|
|
Future<LlmChatSession> startChat({
|
|
required List<ToolDefinition> tools,
|
|
}) async {
|
|
if (!_loaded) {
|
|
throw StateError('LlmService not loaded');
|
|
}
|
|
chatStartCount += 1;
|
|
final session = MockLlmChatSession(chatScript);
|
|
lastChat = session;
|
|
return session;
|
|
}
|
|
}
|
|
|
|
/// Mock chat session that replays pre-queued events from [MockLlmService].
|
|
class MockLlmChatSession implements LlmChatSession {
|
|
final List<List<LlmChatEvent>> _script;
|
|
int sendCount = 0;
|
|
final List<String> userInputs = [];
|
|
final List<(String, Map<String, dynamic>)> toolResults = [];
|
|
bool closed = false;
|
|
|
|
MockLlmChatSession(this._script);
|
|
|
|
Stream<LlmChatEvent> _emitNext() async* {
|
|
sendCount += 1;
|
|
if (_script.isEmpty) {
|
|
throw StateError('MockLlmChatSession: no queued events');
|
|
}
|
|
final batch = _script.removeAt(0);
|
|
for (final ev in batch) {
|
|
yield ev;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Stream<LlmChatEvent> sendUser(String text) {
|
|
userInputs.add(text);
|
|
return _emitNext();
|
|
}
|
|
|
|
@override
|
|
Stream<LlmChatEvent> sendToolResult({
|
|
required String toolName,
|
|
required Map<String, dynamic> result,
|
|
}) {
|
|
toolResults.add((toolName, result));
|
|
return _emitNext();
|
|
}
|
|
|
|
@override
|
|
Future<void> close() async {
|
|
closed = true;
|
|
}
|
|
}
|
|
|
|
class _Response {
|
|
final Map<String, dynamic>? value;
|
|
final Object? error;
|
|
const _Response._(this.value, this.error);
|
|
factory _Response.value(Map<String, dynamic> v) => _Response._(v, null);
|
|
factory _Response.error(Object e) => _Response._(null, e);
|
|
}
|