[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:
251
app/lib/state/chat_providers.dart
Normal file
251
app/lib/state/chat_providers.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user