- docs/reference/260-in-app-tool-calling.md — 구현 후 동기화. 11 섹션 (모듈 지도 / 6 tool 표 / R 규칙 enforce / multi-turn 루프 / dispatcher / ConfirmGate / 2KB cap / LlmChatSession 두 구현 / UI 진입점 / 테스트 / known limitations). - docs/guides/ai-chat-using.md — 사용자 대상 how-to. 진입(🤖) / 대화 예시 / 확인 게이트 / 안전장치 / FAQ. - 후속 4건 Redmine 이슈 발행: #306 koreanLabel 필드 / #307 blank confirm skip / #308 category case-insensitive / #309 KGP deprecation. Refs #260
10 KiB
Reference: In-app tool calling (#260, v0.4.0)
상태: 구현 후 동기화 · 추적성 — Redmine #260 · 설계서: docs/design/260-gemma-tool-calling/ · ADR-0005 · 태그
v0.4.0(commit7037b9e)본 문서는 v0.4.0 의 실제 코드 사양이다. 설계 의도/대안은 설계서·ADR 을 참조. 본 기능은 v0.3.0 의 AI 프레임 제안 (215-ai-frame-suggest.md) 위에 multi-turn tool calling 을 얹은 것이다.
1. 모듈 지도
lib/
ai/tools/
tool_definition.dart — ToolDefinition / ToolHandler / ToolDeps
tool_envelope.dart — ToolResult sealed (Ok/Err/Cancelled) + encodeToolResult (2KB cap)
tool_registry.dart — ToolRegistry.defaults() + 6 tool 모음
tool_dispatcher.dart — 라우팅 + JSON-schema 검증 + ConfirmGate 통합
confirm_gate.dart — destructive 호출 시 AlertDialog
catalog_tools.dart — search_catalog, query_protocol (read-only)
habit_tools.dart — add_habit (destructive), list_active_habits
tracker_tools.dart — log_tracker_entry (destructive), get_streak
data/ai/
llm_service.dart — LlmChatSession 추상 + LlmChatEvent sealed + MockLlmChatSession
gemma_llm_service.dart — _GemmaChatSession (flutter_gemma 0.16.5 + ToolChoice.auto)
state/
chat_providers.dart — ChatSessionController + kChatMaxTurns / kChatSoftHistoryLimit
ui/screens/
chat_screen.dart — AI 코치 화면 + _kToolKoreanLabels
2. 도메인 모델
ToolDefinition (lib/ai/tools/tool_definition.dart)
class ToolDefinition {
final String name; // 'search_catalog'
final String description; // 모델이 보는 한국어 설명
final Map<String, dynamic> parametersSchema; // draft-07 JSON Schema Map 리터럴
final bool isDestructive; // true → ConfirmGate 의무
final ToolHandler handler; // Future<ToolResult> Function(args, deps)
final String Function(Map<String, dynamic>)? summarize; // confirm 모달 본문
}
ToolResult (sealed)
sealed class ToolResult { Map<String, dynamic> toJson(); }
final class ToolOk extends ToolResult { final Map<String, dynamic> data; }
final class ToolErr extends ToolResult { final String code; final String reason; }
final class ToolCancelled extends ToolResult {}
toJson() 디스크리미네이터:
- ok →
{'status': 'ok', 'data': {...}} - error →
{'status': 'error', 'code': '...', 'reason': '...'} - cancel →
{'status': 'cancelled', 'reason': 'user did not confirm'}
LlmChatEvent (sealed)
sealed class LlmChatEvent {}
final class LlmTextChunk extends LlmChatEvent { final String text; }
final class LlmFunctionCall extends LlmChatEvent {
final String name;
final Map<String, dynamic> args;
}
3. 등록된 도구 6개
| 이름 | 종류 | parametersSchema (required) | 반환 (ok) |
|---|---|---|---|
search_catalog |
read-only | category?, keyword?, limit? (1~10) |
{count, items:[{id,title,category,summary}]} |
query_protocol |
read-only | id |
kind 별 필드 (protocol / break / diet) |
list_active_habits |
read-only | — | {count, build_count, break_count, build_quota_remaining, break_quota_remaining, items[]} |
get_streak |
read-only | habit_id |
{current_streak, longest_streak, done_count_30d, done_count_phase42, tier, never_miss_twice_broken} |
add_habit |
destructive | protocol_id, frame_level, framed_text, anchor_when?, anchor_after_what?, dose_text? |
{habit_id, title, type, frame_level} |
log_tracker_entry |
destructive | habit_id, value, date?, note? |
{entry_id, habit_id, date, value} |
R 규칙 enforce (핸들러 책임, ADR-0005 §D-2)
| R 규칙 | 위치 | ToolErr code |
|---|---|---|
| R3 (build ≤3 / break ≤1) | _addHabitHandler → judgeActiveHabitQuota |
r3_quota |
| R5 (체크 (habit,date) 중복) | _logTrackerEntryHandler 핸들러 레벨 dedup |
duplicate |
| R7 (회피 키워드) | _addHabitHandler → detectAvoidanceKeywords(framePatterns) |
r7_avoidance |
| R8 (build XOR break protocol_id) | HabitDao.insertWithVariants assert → catch |
r8_xor |
R1/R2/R4/R6/R9/R10 는 현재 본 surface 의 직접 enforce 범위 아님. R9/R10 (minimum variant) 는 UI 흐름에서 별도 관리 — 후속 이슈.
4. 멀티턴 루프
ChatSessionController.userTurn(text, context) — lib/state/chat_providers.dart.
1. 사용자 메시지 append + isStreaming=true
2. (lazy) llm.load() + _session = llm.startChat(tools)
3. nextStream = sendUser(trimmed)
4. for turn in 0..kChatMaxTurns:
events = nextStream()
if events.last is LlmTextChunk only:
모델 자연어 응답 append → return
if events has LlmFunctionCall(toolCall):
result = dispatcher.dispatch(toolCall.name, toolCall.args, context, deps)
messages append ToolCallChatMessage(name, args, result)
capped = jsonDecode(encodeToolResult(result)) ← AC-9 2KB cap
nextStream = sendToolResult(name, capped)
5. else: error('도구 호출 루프가 너무 길어 중단했습니다.')
kChatMaxTurns = 4(안전 cap, ADR-0005 §C).kChatSoftHistoryLimit = 8— 사용자 turn 누적 8 회 도달 시 SystemChatMessage 한 줄 추가 ("대화가 길어졌어요").LlmChatSession.sendToolResult의result는 항상 capped Map (encodeToolResultround-trip 결과).
5. ToolDispatcher 게이트
lib/ai/tools/tool_dispatcher.dart — dispatch 는 절대 throw 하지 않는다.
1. registry.byName → null 이면 ToolErr('unknown_tool')
2. _validateArgs(schema, rawArgs) → 실패 시 ToolErr('validation', ...)
3. tool.isDestructive 라면:
confirmContext==null → ToolCancelled
ConfirmGate.show(ctx, tool, rawArgs) == false → ToolCancelled
4. try handler(rawArgs, deps) — 예외 → ToolErr('handler_error', ...)
_validateArgs 의 지원 타입: string / integer / number / boolean / object / array. 미선언 키는 허용 (모델 환각 허용 — 핸들러가 무시).
6. ConfirmGate UI
lib/ai/tools/confirm_gate.dart — showDialog<bool> 기반 AlertDialog.
- 제목:
이 작업을 수행할까요? - 본문:
tool.description+ summary box (tool.summarize?.call(args)또는 JSON fallback) - 액션:
취소(TextButton, false) /수행(FilledButton autofocus, true) barrierDismissible: true— 바깥 탭 = 취소- 좁은 화면 대응: content 를
SingleChildScrollView로 감쌈, summary boxwidth: double.infinity
7. 2KB result cap (ADR-0005 §OQ-2)
encodeToolResult(ToolResult, {int maxBytes = 2048}) — lib/ai/tools/tool_envelope.dart.
jsonEncode(result.toJson())후 길이가maxBytes이하면 그대로 반환.- 초과 +
ToolOk이면 payload 를{'_truncated': true, '_hint': '... query_protocol 같은 단건 조회 도구를 사용하세요.'}로 대체. - 초과 + 그 외 (방어용) →
substring(0, maxBytes)hard cut. - 호출 위치:
chat_providers.dartuserTurn안에서jsonDecode(encodeToolResult(result))로 round-trip → Map 형태로sendToolResult전달.
주의:
.length는 UTF-16 code units. 한글 멀티바이트 utf-8 환산 시 더 작은 바이트 → 2048 cap 은 보수적 (안전 방향).
8. LlmChatSession 두 구현
| 구현 | 위치 | 비고 |
|---|---|---|
_GemmaChatSession |
gemma_llm_service.dart |
flutter_gemma 0.16.5 _chat.addQueryChunk + generateChatResponseAsync. ParallelFunctionCallResponse 는 first 만 yield 후 return. ThinkingResponse skip. |
MockLlmChatSession |
llm_service.dart |
테스트용. chatScript (FIFO List<List<LlmChatEvent>>) + userInputs + toolResults 기록. lastChat 으로 마지막 세션 접근. |
Tool (flutter_gemma) ↔ ToolDefinition 매핑은 startChat 안에서 수행 (name, description, parameters).
9. UI 진입점
HabitListScreenAppBar 의 🤖 IconButton (Icons.smart_toy_outlined) —aiSettingsProvider == true일 때만 노출.- 탭 →
ChatScreenpush. - 새 대화: AppBar refresh 아이콘 (tooltip:
새 대화 (이전 기록 비우기)) →controller.clear(). - ToolCallChatMessage 라벨은
_kToolKoreanLabels맵 기반 (add_habit → 습관 추가등 6종, 미매핑은 raw name fallback).
10. 테스트
| 파일 | 케이스 수 |
|---|---|
test/ai/tools/tool_envelope_test.dart |
6 |
test/ai/tools/catalog_tools_test.dart |
7 |
test/ai/tools/habit_tools_test.dart |
8 |
test/ai/tools/tracker_tools_test.dart |
7 |
test/ai/tools/tool_dispatcher_test.dart |
6 |
test/state/chat_session_controller_test.dart |
8 (huge_dump cap 회귀 포함) |
test/ui/chat_screen_test.dart |
2 (E2E 수행/취소) |
전체 v0.4.0 기준 154 passed (1 skip).
11. Known limitations / 후속 이슈 권장
ToolDefinition.koreanLabel필드 도입 — 현재chat_screen.dart의_kToolKoreanLabelshardcoded const. 후속 tool 추가 시 두 위치 동기화 필요.log_tracker_entryblank 시 confirm skip — 현재isDestructive: true가 done/blank 무차별. R5 의도 "blank = 의도적 공란" 과 차이.search_catalogcategory case-insensitive — 현재DisplayCategory.name정확 일치. 모델 환각 대비.- R9/R10 minimum variant chat 노출 — Phase 1 dose variants UI 가 chat 진입점에 노출 안 됨. 핸들러는
isMinimum:false고정. - KGP deprecation 경고 (빌드 시) —
device_info_plus,flutter_gemma,large_file_handler. Future Flutter 빌드 실패 가능.