[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:
2026-06-15 10:42:43 +09:00
parent eca097aa2c
commit b1bed4d5ca
21 changed files with 2313 additions and 0 deletions

View File

@@ -0,0 +1,251 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../ai/tools/tool_definition.dart';
import '../ai/tools/tool_dispatcher.dart';
import '../ai/tools/tool_envelope.dart';
import '../ai/tools/tool_registry.dart';
import '../core/constants.dart';
import '../data/ai/llm_service.dart';
import 'ai_providers.dart';
import 'catalog_providers.dart';
import 'providers.dart';
/// Multi-turn safety cap. ADR-0005 §C — guards against tool-call loops.
const int kChatMaxTurns = 4;
/// Soft warning threshold for chat history bloat (OQ-2).
const int kChatSoftHistoryLimit = 8;
sealed class ChatMessage {
const ChatMessage();
}
final class UserChatMessage extends ChatMessage {
final String text;
const UserChatMessage(this.text);
}
final class ModelChatMessage extends ChatMessage {
final String text;
const ModelChatMessage(this.text);
}
final class ToolCallChatMessage extends ChatMessage {
final String name;
final Map<String, dynamic> args;
final ToolResult result;
const ToolCallChatMessage(this.name, this.args, this.result);
}
final class SystemChatMessage extends ChatMessage {
final String text;
const SystemChatMessage(this.text);
}
class ChatSessionState {
final List<ChatMessage> messages;
final bool isStreaming;
final String? streamingText;
final String? error;
const ChatSessionState({
this.messages = const [],
this.isStreaming = false,
this.streamingText,
this.error,
});
ChatSessionState copyWith({
List<ChatMessage>? messages,
bool? isStreaming,
String? streamingText,
String? error,
bool clearStreamingText = false,
bool clearError = false,
}) {
return ChatSessionState(
messages: messages ?? this.messages,
isStreaming: isStreaming ?? this.isStreaming,
streamingText: clearStreamingText
? null
: (streamingText ?? this.streamingText),
error: clearError ? null : (error ?? this.error),
);
}
}
final toolRegistryProvider = Provider<ToolRegistry>((ref) {
return ToolRegistry.defaults();
});
final toolDepsProvider = FutureProvider<ToolDeps>((ref) async {
// bootstrap 가 끝나야 seed 가 채워진 framePatterns 를 신뢰할 수 있음.
await ref.watch(bootstrapProvider.future);
final framePatterns = await ref.watch(framePatternsProvider.future);
return ToolDeps(
habitDao: ref.watch(habitDaoProvider),
trackerDao: ref.watch(trackerDaoProvider),
catalog: ref.watch(catalogRepositoryProvider),
framePatterns: framePatterns,
userId: kLocalDefaultUserId,
);
});
final toolDispatcherProvider = Provider<ToolDispatcher>((ref) {
return ToolDispatcher(registry: ref.watch(toolRegistryProvider));
});
class ChatSessionController extends StateNotifier<ChatSessionState> {
ChatSessionController({
required this.llm,
required this.dispatcher,
required this.deps,
required this.tools,
}) : super(const ChatSessionState());
final LlmService llm;
final ToolDispatcher dispatcher;
final ToolDeps deps;
final List<ToolDefinition> tools;
LlmChatSession? _session;
Future<void> userTurn(String text, BuildContext context) async {
final trimmed = text.trim();
if (trimmed.isEmpty) return;
if (state.isStreaming) return;
state = state.copyWith(
messages: [...state.messages, UserChatMessage(trimmed)],
isStreaming: true,
streamingText: '',
clearError: true,
);
try {
// 1회 lazy load.
if (!llm.isLoaded) {
await llm.load();
}
_session ??= await llm.startChat(tools: tools);
String? pendingToolName;
Map<String, dynamic>? pendingToolResult;
Stream<LlmChatEvent> Function() nextStream = () =>
_session!.sendUser(trimmed);
for (var turn = 0; turn < kChatMaxTurns; turn++) {
var accumulated = '';
LlmFunctionCall? toolCall;
await for (final event in nextStream()) {
if (event is LlmTextChunk) {
accumulated += event.text;
if (!mounted) return;
state = state.copyWith(streamingText: accumulated);
} else if (event is LlmFunctionCall) {
toolCall = event;
break;
}
}
if (toolCall == null) {
// 자연어 응답으로 종료.
if (!mounted) return;
state = state.copyWith(
messages: [
...state.messages,
ModelChatMessage(accumulated),
],
isStreaming: false,
clearStreamingText: true,
);
_maybeWarnHistory();
return;
}
// Tool 처리.
if (!mounted) return;
final result = await dispatcher.dispatch(
toolName: toolCall.name,
rawArgs: toolCall.args,
confirmContext: context.mounted ? context : null,
deps: deps,
);
if (!mounted) return;
state = state.copyWith(
messages: [
...state.messages,
ToolCallChatMessage(toolCall.name, toolCall.args, result),
],
streamingText: '',
);
pendingToolName = toolCall.name;
pendingToolResult = result.toJson();
final capturedName = pendingToolName;
final capturedResult = pendingToolResult;
nextStream = () => _session!.sendToolResult(
toolName: capturedName,
result: capturedResult,
);
}
// MAX_TURNS 초과 안전 종료.
if (!mounted) return;
state = state.copyWith(
isStreaming: false,
clearStreamingText: true,
error: '도구 호출 루프가 너무 길어 중단했습니다.',
);
} catch (e) {
if (!mounted) return;
state = state.copyWith(
isStreaming: false,
clearStreamingText: true,
error: 'LLM 응답 실패: ${e.runtimeType}',
);
}
}
void clear() {
state = const ChatSessionState();
}
void _maybeWarnHistory() {
final turnCount = state.messages
.whereType<UserChatMessage>()
.length;
if (turnCount == kChatSoftHistoryLimit) {
state = state.copyWith(
messages: [
...state.messages,
const SystemChatMessage(
'대화가 길어졌어요. 다시 시작하면 모델이 더 빠르게 답할 수 있어요. (오른쪽 위 ↻ 버튼)',
),
],
);
}
}
@override
void dispose() {
_session?.close();
super.dispose();
}
}
final chatSessionControllerProvider = StateNotifierProvider.autoDispose<
ChatSessionController, ChatSessionState>((ref) {
final llm = ref.watch(llmServiceProvider);
final dispatcher = ref.watch(toolDispatcherProvider);
final deps = ref.watch(toolDepsProvider).requireValue;
final tools = ref.watch(toolRegistryProvider).all.toList(growable: false);
return ChatSessionController(
llm: llm,
dispatcher: dispatcher,
deps: deps,
tools: tools,
);
});