- design/260-gemma-tool-calling/README.md — overall (12 AC + 7 OQ + 모듈 구조) - fn-tool_dispatcher.md — multi-tool router (validate → confirm gate → handler → envelope) - fn-add_habit_handler.md — destructive 대표 (R3/R7/R8 enforce) - fn-confirm_gate.md — 모달 AlertDialog 흐름 (OQ-3 = 모달 확정) - fn-chat_session_controller.md — multi-turn loop 상태 머신 (MAX_TURNS=4) - ADR-0005 — in-app tool runtime + R 규칙 = 핸들러 책임 + schema SoT=Dart + 모달 OQ-1/2/4 = README §11 결정. OQ-3 = 모달 (사용자 결정). 신규 OQ-5/6/7 = Developer 가 구현 중 검증. Refs #260
6.1 KiB
6.1 KiB
함수 설계서: ChatSessionController.userTurn (#260)
부모 설계서: ./README.md · 상태: Draft 작성: [AI] Architect · 구현:
lib/state/chat_providers.dart:ChatSessionController.userTurn· 테스트:test/state/chat_session_controller_test.dart
1. 시그니처
class ChatSessionController extends StateNotifier<ChatSessionState> {
Future<void> userTurn(String text, BuildContext context);
}
class ChatSessionState {
final List<ChatMessage> messages; // append-only in-memory
final bool isStreaming; // 모델 응답 중 → 입력 disabled
final String? streamingText; // 부분 텍스트 누적
final String? error; // 마지막 에러 (null = OK)
}
2. 책임 (단일 책임, 1줄)
사용자 메시지를 받아 LLM ↔ Tool Dispatcher 의 multi-turn loop 을 돌리고, 메시지 상태를 갱신한다.
3. 입력
| 파라미터 | 타입 | 제약 | 설명 |
|---|---|---|---|
text |
String | non-empty after trim | 사용자가 입력한 자연어 |
context |
BuildContext | mounted | ConfirmGate 가 사용 |
4. 출력
- 반환:
Future<void>. 결과는 state.messages 와 streamingText 에 반영. - 부수효과:
- state 다중 갱신 (StateNotifier.state = ...)
- LLM 호출 (I/O)
- Tool dispatcher 호출 (DB write 가능)
- ConfirmGate 모달 표시 가능
5. 동작 / 알고리즘
1. 입력 validate:
if text.trim().isEmpty: return
if state.isStreaming: return // 중복 호출 차단
2. user 메시지 append:
state = state.copyWith(
messages: [...state.messages, UserChatMessage(text)],
isStreaming: true,
streamingText: '',
error: null,
)
3. tool registry 와 deps 준비:
tools = ToolRegistry.allDefinitions()
deps = ref.read(toolDepsProvider)
llm = ref.read(llmServiceProvider)
4. multi-turn loop (최대 MAX_TURNS=4 — tool 호출 chain 보호):
for (var turn = 0; turn < MAX_TURNS; turn++) {
stream = llm.sendChatTurn(
userInput: turn == 0 ? text : null, // 0 turn 만 user text, 이후는 tool result
toolResultToSubmit: turn == 0 ? null : pendingToolResult,
tools: tools,
)
toolCallToHandle = null
accumulatedText = ''
await for (event in stream) {
switch event:
case TextResponse(text):
accumulatedText += text
state = state.copyWith(streamingText: accumulatedText)
case FunctionCallResponse(name, args):
toolCallToHandle = (name, args)
break // 스트림 stop, tool 처리로 분기
case ThinkingResponse: skip
}
if toolCallToHandle == null:
// 모델이 자연어 응답으로 마무리
state = state.copyWith(
messages: [...state.messages, ModelChatMessage(accumulatedText)],
streamingText: null,
isStreaming: false,
)
return
// tool 처리
result = await ToolDispatcher.dispatch(
toolName: toolCallToHandle.name,
rawArgs: toolCallToHandle.args,
confirmContext: context,
deps: deps,
)
state = state.copyWith(
messages: [...state.messages,
ToolCallChatMessage(toolCallToHandle.name, toolCallToHandle.args, result)],
streamingText: '',
)
pendingToolResult = (toolCallToHandle.name, result.toJson())
}
// MAX_TURNS 초과 → 안전 종료
state = state.copyWith(
error: '도구 호출 루프가 너무 길어 중단했습니다.',
isStreaming: false,
streamingText: null,
)
6. 에러 & 실패 모드
| 조건 | 처리 | state |
|---|---|---|
| 빈 입력 | early return | unchanged |
| 동시 호출 (isStreaming) | early return | unchanged |
| LLM stream 예외 | catch | error: 'LLM 응답 실패: ${e.type}', isStreaming:false |
| MAX_TURNS 초과 | safety break | error: '...너무 길어...' |
| tool result 직렬화 실패 (이론상 없음) | catch | tool ToolErr 대체 |
7. 엣지케이스
- 사용자가 stream 중 chat 화면 dismiss: StateNotifier 가 dispose 되어도 진행 중인
await는 계속됨. side effect (DB write) 가 이미 시작됐을 수 있으니 graceful 하게 무시 —mounted체크로 state 갱신만 skip. - 연속 tool 호출: LLM 이 search_catalog → query_protocol → add_habit 같이 3 turn 돌 수 있음. MAX_TURNS=4 가 안전망. AC 시나리오 대부분 1~2 turn 종료.
- tool 호출 후 LLM 이 또 같은 tool 호출 (loop): MAX_TURNS 가 차단. 추가로 핸들러는 idempotent 결과 반환하지만 R3 quota 등이 2번째 호출에서 차단.
- chat history 8 turn 초과: 현재 turn 끝나면 "지난 대화를 정리할까요?" 안내 메시지 (
SystemChatMessage) 자동 append. clear 는 사용자 액션. - 모달 confirm 대기 중 사용자가 화면 dismiss: ConfirmGate 내부 mounted 가드가 false 반환 → ToolCancelled → loop 계속 (또는 LLM 이 마무리).
8. 복잡도 / 성능
- per-turn: LLM round trip (
25초 E2B) + handler (<100ms). - 총 latency: 2
3 turn 으로 끝나는 시나리오 평균 510 초. - 메모리: messages 리스트가 메모리 누적. clear 안 하면 무한 — 단 chat history persist X 이므로 앱 종료 시 GC.
9. 테스트 케이스 (필수)
Mock LLM 으로 시뮬레이션. 실 모델 호출 안 함.
| 케이스 | LLM mock 시퀀스 | 기대 state |
|---|---|---|
| 자연어 응답만 | [TextResponse('안녕!')] |
messages = [user, model], isStreaming=false |
| 1 tool call + 응답 | [FunctionCallResponse('search_catalog', {category:'sleep'})] → tool result → [TextResponse('카페인 protocol...')] |
messages = [user, toolCall, model] |
| destructive cancel | add_habit call → ConfirmGate mock false |
toolCall message 의 result = ToolCancelled |
| MAX_TURNS 초과 | LLM 이 매번 tool call | error 세팅, 안전 종료 |
| 중복 호출 차단 | isStreaming=true 일 때 userTurn 재호출 | early return, state unchanged |
10. 의존
LlmService.sendChatTurn(...)(확장 인터페이스)ToolDispatcher.dispatch(...)ToolRegistry.allDefinitions()ToolDeps(toolDepsProvider)- Flutter
BuildContext.mounted