[03-Developer] #260 in-app tool calling (Gemma 4 multi-turn)
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
This commit is contained in:
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_gemma/flutter_gemma.dart';
|
||||
|
||||
import '../../ai/tools/tool_definition.dart' as tools;
|
||||
import 'llm_service.dart';
|
||||
|
||||
/// HuggingFace access token injected at build time via
|
||||
@@ -114,6 +115,93 @@ class GemmaLlmService implements LlmService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LlmChatSession> startChat({
|
||||
required List<tools.ToolDefinition> tools,
|
||||
}) async {
|
||||
if (!_loaded || _model == null) {
|
||||
throw StateError('LlmService not loaded');
|
||||
}
|
||||
final gemmaTools = tools
|
||||
.map((t) => Tool(
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: Map<String, dynamic>.from(t.parametersSchema),
|
||||
))
|
||||
.toList();
|
||||
final chat = await _model!.createChat(
|
||||
modelType: ModelType.gemma4,
|
||||
supportsFunctionCalls: true,
|
||||
// ToolChoice.auto = 모델이 자율 결정 (multi-tool + reply-only 모두 지원).
|
||||
toolChoice: ToolChoice.auto,
|
||||
tools: gemmaTools,
|
||||
);
|
||||
return _GemmaChatSession(chat);
|
||||
}
|
||||
}
|
||||
|
||||
class _GemmaChatSession implements LlmChatSession {
|
||||
final dynamic _chat;
|
||||
bool _closed = false;
|
||||
|
||||
_GemmaChatSession(this._chat);
|
||||
|
||||
@override
|
||||
Stream<LlmChatEvent> sendUser(String text) {
|
||||
if (_closed) {
|
||||
throw StateError('LlmChatSession is closed');
|
||||
}
|
||||
return _run(Message.text(text: text, isUser: true));
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<LlmChatEvent> sendToolResult({
|
||||
required String toolName,
|
||||
required Map<String, dynamic> result,
|
||||
}) {
|
||||
if (_closed) {
|
||||
throw StateError('LlmChatSession is closed');
|
||||
}
|
||||
return _run(Message.toolResponse(toolName: toolName, response: result));
|
||||
}
|
||||
|
||||
Stream<LlmChatEvent> _run(Message msg) async* {
|
||||
await _chat.addQueryChunk(msg);
|
||||
final Stream<ModelResponse> stream = _chat.generateChatResponseAsync();
|
||||
await for (final event in stream) {
|
||||
if (event is TextResponse) {
|
||||
yield LlmTextChunk(event.token);
|
||||
} else if (event is FunctionCallResponse) {
|
||||
yield LlmFunctionCall(
|
||||
event.name,
|
||||
Map<String, dynamic>.from(event.args),
|
||||
);
|
||||
return; // model hands control back to caller for tool exec
|
||||
} else if (event is ParallelFunctionCallResponse &&
|
||||
event.calls.isNotEmpty) {
|
||||
// ADR-0005: parallel calls collapsed to first — sequential dispatch.
|
||||
final first = event.calls.first;
|
||||
yield LlmFunctionCall(
|
||||
first.name,
|
||||
Map<String, dynamic>.from(first.args),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// ThinkingResponse / other: skip.
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
if (_closed) return;
|
||||
_closed = true;
|
||||
try {
|
||||
await _chat.close();
|
||||
} catch (_) {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts the first `FunctionCallResponse(name == expectedName)` from
|
||||
|
||||
Reference in New Issue
Block a user