- 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
147 lines
6.1 KiB
Markdown
147 lines
6.1 KiB
Markdown
# 함수 설계서: `ChatSessionController.userTurn` (#260)
|
|
|
|
> **부모 설계서**: ./README.md · **상태**: Draft
|
|
> **작성**: [AI] Architect · **구현**: `lib/state/chat_providers.dart:ChatSessionController.userTurn` · **테스트**: `test/state/chat_session_controller_test.dart`
|
|
|
|
## 1. 시그니처
|
|
```dart
|
|
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 (~2~5초 E2B) + handler (<100ms).
|
|
- 총 latency: 2~3 turn 으로 끝나는 시나리오 평균 5~10 초.
|
|
- 메모리: 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`
|