From eca097aa2c5d1e4f042b77ec3b2b1909e959b28e Mon Sep 17 00:00:00 2001 From: joungmin Date: Mon, 15 Jun 2026 10:15:44 +0900 Subject: [PATCH] [02-Architect] #260 design spec + ADR-0005 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../0005-in-app-tool-calling-architecture.md | 67 +++++ docs/design/260-gemma-tool-calling/README.md | 284 ++++++++++++++++++ .../fn-add_habit_handler.md | 141 +++++++++ .../fn-chat_session_controller.md | 146 +++++++++ .../260-gemma-tool-calling/fn-confirm_gate.md | 106 +++++++ .../fn-tool_dispatcher.md | 100 ++++++ 6 files changed, 844 insertions(+) create mode 100644 docs/adr/0005-in-app-tool-calling-architecture.md create mode 100644 docs/design/260-gemma-tool-calling/README.md create mode 100644 docs/design/260-gemma-tool-calling/fn-add_habit_handler.md create mode 100644 docs/design/260-gemma-tool-calling/fn-chat_session_controller.md create mode 100644 docs/design/260-gemma-tool-calling/fn-confirm_gate.md create mode 100644 docs/design/260-gemma-tool-calling/fn-tool_dispatcher.md diff --git a/docs/adr/0005-in-app-tool-calling-architecture.md b/docs/adr/0005-in-app-tool-calling-architecture.md new file mode 100644 index 0000000..5645451 --- /dev/null +++ b/docs/adr/0005-in-app-tool-calling-architecture.md @@ -0,0 +1,67 @@ +# ADR-0005: In-app tool calling architecture (MCP-equivalent) + +> **상태**: Accepted +> **작성**: [AI] Architect · **일자**: 2026-06-15 +> **추적성** — Redmine #260, 설계서 `docs/design/260-gemma-tool-calling/README.md`, ADR-0003 (on-device Gemma 채택) + +## 컨텍스트 +#218 로 on-device Gemma 4 추론이 동작하고 #226 으로 카탈로그 47 항목이 노출됐다. 사용자가 "이거 내 습관으로 추가해줘" 같은 자연어 요청으로 DB mutation 까지 도달하는 경로가 필요하다. + +선택지: +1. **별도 MCP 서버 띄우기** — 표준 프로토콜, 외부 process. 모바일 환경에서 IPC + 추가 메모리 부담 + 모델 컨텍스트 비용 동일. +2. **In-process Dart 함수 직접 호출** — 같은 프로세스 안에서 tool 정의 + 핸들러. flutter_gemma 0.16.5 의 `tools` 파라미터 위에 얇은 라우터. +3. **Prompt engineering 으로 mutation 안내** — 모델이 "다음과 같이 하세요" 텍스트로만 응답, 실제 액션은 사용자가 수동 — UX 후퇴, R 규칙 enforce 불가. + +추가로 결정해야 할 부수 항목: +- R1~R10 운영 규칙을 **어디서** enforce 할 것인가 +- tool schema 의 source-of-truth (Dart 코드 vs yaml/json 파일) +- destructive tool 의 사용자 확인 게이트 (모달 vs inline 카드 vs 무게이트) + +## 결정 + +### 결정 1: in-process Dart tool runtime +- 별도 process 띄우지 않는다. `lib/ai/tools/` 하위에 `ToolDefinition` + `ToolDispatcher` + 핸들러를 Dart 로 작성. +- flutter_gemma 의 `createChat(tools: [...], toolChoice: ToolChoice.auto)` 가 모델 ↔ Dart 사이의 protocol layer 역할. +- `FunctionCallResponse` 를 받으면 `ToolDispatcher.dispatch(name, args, deps)` 로 라우팅 → `chat.addToolResult(...)` 로 회신. + +### 결정 2: R 규칙 enforce 는 tool 핸들러 책임 +- 모델 prompt 에 "R3 quota 는 build ≤ 3" 식 안내를 넣지 않는다 (학습 신뢰성 불충분). +- 모든 mutation 핸들러는 호출 직전 도메인 함수 (`judgeActiveHabitQuota`, `detectAvoidanceKeywords`, `assertXorProtocol`, `validateTrackerValue` 등) 를 직접 호출. +- 위반 시 `ToolErr(code: 'r3_quota' | 'r7_avoidance' | ..., reason: 한국어)` 반환 → 모델이 사용자에게 안내. + +### 결정 3: tool schema source-of-truth = Dart 코드 +- 각 `ToolDefinition.parametersSchema` 는 Dart `Map` 리터럴 (draft-07 JSON Schema 형태). +- yaml/json 별도 파일 두지 않는다. +- 이유: + 1. 핸들러 시그니처와 schema 가 같은 파일에 있어 drift 방지 + 2. yaml 추가 시 codegen + 버전 동기화 부담 + 3. IDE 자동완성 / rename / find-usages 활용 + +### 결정 4: destructive tool = 모달 Confirm 게이트 의무 +- `add_habit`, `log_tracker_entry(value=done)` 등 mutation tool 은 `isDestructive=true` 플래그. +- Dispatcher 가 호출 전 `ConfirmGate.show(context, tool, args)` 로 AlertDialog 표시 → 사용자 OK 시에만 핸들러 실행. +- inline 카드 (chat 메시지 안에) 대신 모달 채택 — 시각적 안전성과 모달 API 단순성을 위해. +- 사용자 결정 (2026-06-15). + +## 결과 +- 새 디렉토리 `lib/ai/tools/` 와 `chat_screen.dart`. +- `LlmService` 인터페이스에 `sendChatTurn(...)` 추가 — `MockLlmService` 도 갱신. +- 별도 server / process 추가 없음. 의존성 증가 없음. +- 향후 외부 서비스 (예: 클라우드 카탈로그 sync) 도입 시 핸들러 내부에서 fetch 하는 것으로 충분 — MCP 도입 부담 없음. + +## 영향 / 후속 +- (+) tool latency in-process Dart 호출이라 < 100ms. MCP IPC 오버헤드 없음. +- (+) R 규칙 단일 SoT — Repository 가 검증하므로 UI/CLI/Chat 모두 동일 동작. +- (-) MCP 표준 호환 X — 외부 tool 가 MCP 서버로 연동하려면 별도 어댑터 필요. Phase 1 범위 아님. +- (-) Dart schema 가 수십 개 넘으면 가독성 부담 — ≥ 20 tool 시 ADR 후속 (yaml/codegen 도입 재검토). + +## 대안 (기각) +- **A. MCP 서버 별도 띄우기**: 모바일에서 native process 띄우려면 platform channel + lifecycle 관리. 메모리 +N MB. 표준 호환 외 이득 없음 — Phase 1 부적합. +- **B. Prompt-only 안내**: 모델이 R 규칙을 학습 못 한다는 게 이미 검증됨 (#218 OQ-1 시점). 안전한 mutation 불가. +- **C. inline 확인 카드**: chat 메시지 흐름에 자연스럽지만 사용자가 다른 메시지에 묻혀 무심코 진행 위험. 모달이 더 안전. +- **D. yaml schema**: codegen 부담. Dart 단일 SoT 가 단순. + +## 참고 +- 설계서: `docs/design/260-gemma-tool-calling/README.md` +- 관련 ADR: ADR-0003 (on-device Gemma 채택) +- 관련 Redmine: #260 diff --git a/docs/design/260-gemma-tool-calling/README.md b/docs/design/260-gemma-tool-calling/README.md new file mode 100644 index 0000000..9c668e5 --- /dev/null +++ b/docs/design/260-gemma-tool-calling/README.md @@ -0,0 +1,284 @@ +# 설계서: On-device Gemma tool calling (#260) + +> **상태**: Draft +> **작성**: [AI] Architect · **최종수정**: 2026-06-15 +> **추적성** — Redmine: #260 · 관련 ADR: ADR-0005 (신규) +> · 구현 파일: `app/lib/ai/tools/`, `app/lib/ui/screens/chat_screen.dart`, `app/lib/data/ai/gemma_llm_service.dart` 일부 확장 · 테스트: `app/test/ai/tools/`, `app/test/ui/chat_screen_test.dart` + +## 1. 목적 (Why) +on-device Gemma 4 와의 대화만으로 **카탈로그 검색 → 습관 추가 → 체크 → 스트릭 조회**를 한 흐름에 끝낸다. MCP 와 동일한 capability 추상화를 in-process Dart 함수로 구현해 latency 를 거의 0 으로 만든다. + +## 2. 범위 (Scope) +- **포함**: + - 6 개 tool 정의 + 핸들러 (`search_catalog`, `query_protocol`, `add_habit`, `list_active_habits`, `log_tracker_entry`, `get_streak`) + - Multi-tool 대화 루프 (LLM 이 동적으로 tool 선택 → 핸들러 실행 → 결과 회신 → LLM 자연어 응답) + - Destructive tool 의 모달 Confirm UI 게이트 + - Tool 응답 사이즈 가드 (≤ 2KB) + - Tool 핸들러에서 R1~R10 enforce (모델 prompt 가 아닌 코드) + - 신규 ChatScreen + AppBar entry + - 6 핸들러 unit + 1 widget E2E 테스트 +- **제외 (out of scope)**: + - 음성 인터페이스 + - 다국어 (한국어만) + - 대화 history persistence (in-memory only, 앱 종료 시 휘발) + - Streaming animation 의 고급 UI + - 모델 prompt engineering 으로 R 규칙 학습 + - 자동 phase 전환 / reward 발급 tool + +## 3. 인수조건 (Acceptance Criteria) +Planner #260 의 AC-1~12 그대로: +- [ ] AC-1: `search_catalog(category?, keyword?, limit≤10)` — summary (60자 truncate) 반환 +- [ ] AC-2: `query_protocol(id)` — Protocol/Break/Diet 전체 필드 +- [ ] AC-3: `add_habit(protocol_id, frame_level, framed_text, anchor?, dose?)` — Confirm UI 거침. 거부 시 `{cancelled: true}` 반환 +- [ ] AC-4: `list_active_habits()` — 활성 습관 id/title/type/protocol_id +- [ ] AC-5: `log_tracker_entry(habit_id, value, date?)` — value ∈ {done, blank} +- [ ] AC-6: `get_streak(habit_id)` — missing vs blank 구분 결과 +- [ ] AC-7: R1~R10 enforce 는 tool 핸들러 책임. 위반 시 `{error, reason}` 반환 +- [ ] AC-8: destructive tool 은 ConfirmDialog 의무. read-only 는 직접 실행 +- [ ] AC-9: tool result ≤ 2KB 가드. 초과 시 truncate + hint +- [ ] AC-10: 기존 110 + 신규 (≥ 12 unit + 1 widget E2E) 통과 +- [ ] AC-11: tool 정의는 `lib/ai/tools/` 도메인별 분리 +- [ ] AC-12: 잘못된 args (스키마 위배) → validation error 반환, crash 금지 + +## 4. 컨텍스트 & 제약 + +### 의존성 +- **완료**: #218 (Gemma 4 통합 / `GemmaLlmService`), #226 (`CatalogRepository`, `DisplayCategory`) +- **활용 surface**: + - `HabitDao.insertWithVariants(HabitDraft)` — R8 + R9/R10 트랜잭션 + - `HabitDao.countActive({userId, type})` — R1/R2 + - `HabitDao.activeHabitsForUser(userId)` — list + - `TrackerDao.recordCheckIn(TrackerEntryDraft)` — R5 + - `CatalogRepository.all()` / `byId()` — 카탈로그 + - `validateFrameLevel()` / `detectAvoidanceKeywords()` — R3/R7 + - `judgeActiveHabitQuota()` — R1/R2 + - `computeStreak(...)` — streak 계산 +- **라이브러리**: `flutter_gemma 0.16.5` — `Tool`, `ToolChoice.auto`, `FunctionCallResponse`, `ParallelFunctionCallResponse`, `TextResponse` + +### 제약 +- **단일 사용자**: Phase 1 `kLocalDefaultUserId` 하드코딩. tool 인자에 user_id 없음. +- **메모리**: Gemma 4 E2B 가 RAM 4GB+ 요구. tool args/result 는 추가로 모델 context 채움 → token budget 정책 필수. +- **응답 시간**: in-process 호출이라 핸들러 자체는 < 50ms. 하지만 LLM round trip 2회 (tool call decision + 최종 응답) 가 user-perceived latency 의 95%. +- **모달 race**: Confirm UI 가 떠 있는 동안 사용자가 chat 화면 dismiss 가능 — graceful cancellation 필요. + +### 가정 +- `flutter_gemma 0.16.5` 의 `ToolChoice.auto` 가 multi-tool 동적 선택 지원 (← Developer 가 1차 검증). +- 사용자는 한 turn 에 1 mutation 만 수행한다고 가정 (병렬 mutation tool 호출 시 첫 번째만 confirm, 나머지는 reject). + +## 5. 아키텍처 개요 + +### 모듈 / 파일 구조 (신규) +``` +app/lib/ai/tools/ +├── tool_registry.dart # 모든 ToolDefinition 모음 + Dispatcher 입구 +├── tool_dispatcher.dart # FunctionCallResponse → handler 라우팅 + result envelope +├── tool_envelope.dart # ToolResult sealed (Ok, Err, Cancelled) + JSON 직렬화 + 2KB 가드 +├── confirm_gate.dart # destructive 호출 시 모달 표시 → bool 반환 +├── catalog_tools.dart # search_catalog, query_protocol 정의 + 핸들러 +├── habit_tools.dart # add_habit, list_active_habits 정의 + 핸들러 +└── tracker_tools.dart # log_tracker_entry, get_streak 정의 + 핸들러 + +app/lib/ui/screens/ +└── chat_screen.dart # ConsumerStatefulWidget. 메시지 리스트 + 입력 + tool call 표시 + +app/lib/state/ +└── chat_providers.dart # chatSessionProvider (StateNotifier), 등록 도구 리스트 provider + +app/lib/data/ai/ +└── gemma_llm_service.dart # (확장) sendChatTurn(...) 새 메서드 — multi-tool loop 지원 +``` + +### 데이터 흐름 +``` +ChatScreen.send("아침 햇빛 프로토콜 알려줘") + │ + ▼ +ChatSessionController.userTurn(text) + │ 1. 사용자 메시지 append + │ 2. llm.sendChatTurn(history, tools) 호출 → stream + ▼ +GemmaLlmService.sendChatTurn(...) ← 신규 메서드 + │ 1. createChat(tools: registry.all(), toolChoice: auto) [세션 캐싱] + │ 2. chat.add(userMessage) + │ 3. stream = chat.generateResponseAsync() + ▼ + ◇ ModelResponse 분기 + ├─ TextResponse → controller.appendModelChunk(text) + ├─ ThinkingResponse → drop (memory: isThinking:false) + └─ FunctionCallResponse(name, args) + │ + ▼ + ToolDispatcher.dispatch(name, args, context) + │ + ▼ + ◇ ToolDefinition.isDestructive ? + │ ├─ Y → ConfirmGate.show(context, tool, args) → bool + │ │ └─ false → return ToolResult.cancelled() + │ └─ N → 계속 + │ + ▼ + handler(args, deps) async + │ 1. args schema validate (try/catch) + │ 2. R 규칙 enforce (R3/R5/R7/R8 등 호출) + │ 3. Repository 호출 + │ 4. ToolResult.ok(payload) or ToolResult.err(code, reason) + │ + ▼ + ToolEnvelope.encode(result) → JSON String ≤ 2KB + │ size > 2KB → truncate + hint + ▼ + chat.addToolResult(name, jsonString) + │ + ▼ + 다시 generateResponseAsync() → TextResponse 로 마무리 +``` + +### I/O ↔ 순수 분리 +- **I/O**: `GemmaLlmService.sendChatTurn`, `ToolDispatcher.dispatch` (Repository 호출), `ConfirmGate.show` (UI) +- **순수**: + - `ToolEnvelope.encode/truncate` — JSON 직렬화 + 사이즈 가드 + - `ToolArgsValidator.validate(tool, args)` — schema 매칭 + - 각 도메인 R 규칙 함수 (기존) +- **테스트 전략**: 핸들러 unit 테스트는 in-memory DB + 직접 호출. ConfirmGate 는 `WidgetTester` 로 모달 검증. Multi-tool loop 는 Mock LLM 으로 시뮬레이션 (모델 호출 안 함). + +## 6. 데이터 모델 + +### ToolDefinition +```dart +class ToolDefinition { + final String name; // 'search_catalog' + final String description; // 모델이 보는 한국어 설명 + final Map parametersSchema; // JSON Schema (draft-07) + final bool isDestructive; // true → ConfirmGate 거침 + final ToolHandler handler; // Future Function(args, deps) +} +``` + +### ToolResult (sealed) +```dart +sealed class ToolResult { + Map toJson(); +} +final class ToolOk extends ToolResult { + final Map data; +} +final class ToolErr extends ToolResult { + final String code; // 'validation' | 'r3_quota' | 'r7_avoidance' | 'r8_xor' | ... + final String reason; // 모델이 사용자에게 안내할 한국어 +} +final class ToolCancelled extends ToolResult { + // user dismissed confirm modal +} +``` + +### ChatMessage +```dart +sealed class ChatMessage { + final DateTime ts; +} +final class UserChatMessage extends ChatMessage { final String text; } +final class ModelChatMessage extends ChatMessage { final String text; } +final class ToolCallChatMessage extends ChatMessage { + final String toolName; + final Map args; + final ToolResult result; + // UI 표시: "📦 search_catalog 호출 → 3개 결과" +} +``` + +### 입력 검증 규칙 +| Tool | 인자 | 제약 | +|---|---|---| +| search_catalog | category, keyword, limit | category ∈ DisplayCategory.values \| null, keyword ≤ 50자, limit ∈ [1,10] | +| query_protocol | id | non-empty String | +| add_habit | protocol_id, frame_level, framed_text, anchor?, dose? | protocol_id ∈ existing catalog ids, frame_level ∈ {L2, L3}, framed_text ≤ 200자 | +| list_active_habits | (없음) | — | +| log_tracker_entry | habit_id, value, date? | habit_id ∈ existing habits, value ∈ {done, blank}, date YYYY-MM-DD 또는 null(=today) | +| get_streak | habit_id | habit_id ∈ existing habits | + +## 7. 함수 명세 (Function Specs) +복잡 함수는 별도 파일: +- `fn-tool_dispatcher.md` — multi-tool 라우팅 + envelope + Confirm gate 통합 +- `fn-add_habit_handler.md` — destructive 핸들러 대표. R3/R7/R8 enforce + HabitDraft 빌드 +- `fn-confirm_gate.md` — 모달 UI 흐름 + race 조건 +- `fn-chat_session_controller.md` — Multi-tool loop 의 상태 머신 + +단순 함수 (직접 구현): +- `search_catalog_handler` / `query_protocol_handler` / `list_active_habits_handler` / `get_streak_handler` — Repository 호출 후 envelope 만 씌움 +- `ToolEnvelope.encode` / `truncate` — JSON encode + 2KB cap + 말미 hint + +## 8. 변경 영향 / 기존 코드 수정 +| 파일 | 변경 | +|---|---| +| `lib/data/ai/gemma_llm_service.dart` | `sendChatTurn(history, tools)` 신규 메서드. 기존 `generateStructured` 는 유지 (frame suggest 가 사용 중). | +| `lib/data/ai/llm_service.dart` | `LlmService` 인터페이스에 `sendChatTurn(...)` 추가. `MockLlmService` 도 갱신. | +| `lib/state/ai_providers.dart` | 변경 없음. | +| `lib/ui/screens/habit_list_screen.dart` | AppBar 에 chat icon → ChatScreen 진입 (검색 옆). | +| `lib/state/providers.dart` | 변경 없음. | +| `lib/domain/rules/active_habit_quota.dart` | 기존 함수 그대로 활용. 호출 위치만 핸들러로 확장. | + +## 9. 비기능 요구 +- **Latency**: tool 1 회당 LLM round trip 2 회. 모델 응답 평균 2~5 초 (E2B), 핸들러 자체 < 100ms. +- **메모리**: tool args/result 가 모델 context 에 누적. 8 turn 후 reset 권장 (OQ-2 정책). +- **A11y**: ConfirmDialog 는 Semantics + autofocus 첫 액션 버튼. +- **i18n**: 한국어만. tool description 도 한국어. +- **로깅**: tool call 이벤트 (name, success/err code) 만 — args/result payload 는 로깅 금지 (PII 누출 차단). + +## 10. 테스트 전략 +### Unit (≥ 12) +- `tool_envelope_test.dart` — encode/decode round-trip, 2KB truncate, error 직렬화 (3) +- `catalog_tools_test.dart` — search_catalog (category 필터/keyword/limit) + query_protocol (정상/미존재) (4) +- `habit_tools_test.dart` — add_habit 성공 + R3 차단 + R7 차단 + R8 차단, list_active_habits (5) +- `tracker_tools_test.dart` — log_tracker_entry 성공 + R5 차단 + date 기본값, get_streak (4) +- `tool_dispatcher_test.dart` — unknown tool, validation fail, ok 경로 (3) + +### Widget (≥ 1) +- `chat_screen_test.dart` — 시드 후 "아침 햇빛 추가해줘" → mock LLM 이 add_habit tool call → ConfirmDialog 노출 → 확인 → habit row 1개 증가 검증 + +### Integration (선택) +- 실 단말 manual : 카탈로그 검색 + 습관 추가 + 체크 흐름 1회 (QA 단계). + +## 11. Open Questions (구현 중 답) + +### Planner 의 OQ +- **OQ-1 (idempotency)**: 동일 mutation 연속 호출 처리 + - **결정**: tool 핸들러는 (구분 키, time window) 기반 dedup 없음. 대신 **응답에 의미 있는 ID 를 항상 포함** → 모델이 후속 turn 에서 "이미 추가됨" 인지. Repository 의 unique constraint 가 최종 안전망 (TrackerEntries (habit_id, date) UNIQUE 가정 — Developer 확인 필요). + - 이유: 시간 기반 dedup 은 사용자가 의도적으로 "다시 시도" 한 경우와 구분 못함. 명시적 confirm UI 가 이미 안전 게이트. + +- **OQ-2 (token budget)**: + - **결정**: tool result 직렬화 후 **2048 bytes hard cap**. 초과 시 마지막 1KB 를 `"... (잘림) 더 보려면 query_protocol 사용"` 으로 대체. 카탈로그 list 응답은 항상 summary(60자) + id 만, 상세는 별도 tool 호출. + - 추가: chat history 가 8 turn 초과 시 controller 가 "지난 대화를 정리할까요?" 안내 — 자동 reset 은 안 함 (사용자가 선택). + +- **OQ-3 (Confirm UI)**: 모달 확정 (사용자 결정 2026-06-15) + - `ConfirmGate.show(context, ToolDefinition, args)` 가 `showDialog` 으로 AlertDialog 표시. 제목 = "이 작업을 수행할까요?", 본문 = tool description + args 의 사람 친화 요약 (예: "프로토콜 '아침 햇빛'을 L2 프레임으로 새 습관 추가"), 액션 = "취소" / "수행". 모달이 뜨는 동안 chat 입력은 disable. + +- **OQ-4 (schema SoT)**: + - **결정**: **Dart 코드** 가 SoT. 각 `ToolDefinition` 의 `parametersSchema` 는 Dart 리터럴 Map. 이유: + 1. yaml 추가 시 codegen + 버전 동기화 부담 + 2. 핸들러 시그니처 와 schema 가 같은 파일에 있어야 drift 방지 + 3. 자동완성 + 리팩터링 도구 활용 (rename, find-usages) + - 추후 schema 가 수십 개 이상이면 ADR 후속에서 재논의. + +### 신규 OQ (Developer 가 구현 중 답) +- **OQ-5**: `flutter_gemma 0.16.5` 의 `ToolChoice.auto` 가 multi-tool 동적 선택 + tool 호출 안 함 (TextResponse) 모두 지원하는가? — Developer 가 small probe 작성. 미지원 시 fallback = `ToolChoice.required` + meta-tool ("any_action"/"reply_only"). +- **OQ-6**: `chat.addToolResult(...)` API 명 + 시그니처 정확히 (`Tool result message`, `addFunctionResult` 등 변형 가능) — flutter_gemma changelog 확인. +- **OQ-7**: `TrackerEntries(habit_id, date)` UNIQUE 제약 존재 여부 — 없으면 마이그레이션 추가 vs 핸들러 levelup 결정. + +## 12. ADR 후보 +- **ADR-0005**: "In-app tool calling architecture (MCP-equivalent)" — 발행 예정. + - 결정 1: tool runtime 은 in-process Dart (MCP 서버 별도 띄우지 않음) + - 결정 2: R 규칙 enforce 는 tool 핸들러 책임 (모델 prompt 아님) + - 결정 3: schema SoT = Dart 코드 + - 결정 4: destructive tool = 모달 Confirm 게이트 의무 + +## 13. 작업 분할 (Developer 가 참고) +1. ADR-0005 발행 (Architect 발행 — 본 작업과 동시) +2. `ToolDefinition` + `ToolResult` + `ToolEnvelope` 골격 +3. `catalog_tools` 2 핸들러 (read-only) + 테스트 +4. `tracker_tools` 2 핸들러 + 테스트 +5. `habit_tools` 2 핸들러 + R 규칙 enforce + 테스트 +6. `ToolDispatcher` + `ConfirmGate` + 테스트 +7. `GemmaLlmService.sendChatTurn` + `LlmService` 확장 + `MockLlmService` 갱신 +8. `ChatScreen` + `ChatSessionController` + 위젯 테스트 +9. `HabitListScreen` AppBar 진입점 +10. 회귀 (전체 110 + 신규) diff --git a/docs/design/260-gemma-tool-calling/fn-add_habit_handler.md b/docs/design/260-gemma-tool-calling/fn-add_habit_handler.md new file mode 100644 index 0000000..7bea47e --- /dev/null +++ b/docs/design/260-gemma-tool-calling/fn-add_habit_handler.md @@ -0,0 +1,141 @@ +# 함수 설계서: `addHabitHandler` (#260) + +> **부모 설계서**: ./README.md · **상태**: Draft +> **작성**: [AI] Architect · **구현**: `lib/ai/tools/habit_tools.dart:addHabitHandler` · **테스트**: `test/ai/tools/habit_tools_test.dart` + +## 1. 시그니처 +```dart +Future addHabitHandler( + Map args, + ToolDeps deps, +); +``` + +대표 destructive 핸들러. 다른 destructive (`log_tracker_entry`) 도 동일 패턴을 따름. + +## 2. 책임 (단일 책임, 1줄) +LLM 이 제안한 새 습관 args 를 검증 → R1~R10 enforce → `HabitDao.insertWithVariants` 호출 → 결과 envelope 반환. + +## 3. 입력 +| 파라미터 | 타입 | 제약 | 설명 | +|---|---|---|---| +| `args.protocol_id` | String | non-empty, catalog 에 존재 | 사용자가 선택한 카탈로그 ID | +| `args.frame_level` | String | ∈ {"L2", "L3"} | R3: L0/L1 reject | +| `args.framed_text` | String | 1~200자 | 사용자가 보는 문장 | +| `args.anchor_when` | String? | ≤ 50자 | 선택 — 예: "기상 후" | +| `args.anchor_after_what` | String? | ≤ 50자 | 선택 — 예: "세수" | +| `args.dose_text` | String? | ≤ 100자 | 선택 — variant 단일 생성 (없으면 시드의 default_dose) | +| `deps` | ToolDeps | non-null | userId / HabitDao / CatalogRepository | + +**`type` 인자 없음** — catalog item 의 종류 (Protocol/Break) 에 따라 자동 결정. 핸들러가 catalog lookup 으로 판정. + +## 4. 출력 +- **반환**: `Future`. + - `ToolOk(data: {habit_id, title, type, frame_level})` 성공 + - `ToolErr(code: 'validation' | 'not_found' | 'r3_quota' | 'r7_avoidance' | 'r8_xor', reason: 한국어)` +- **부수효과**: 성공 시 DB write (Habits + HabitDoseVariants 트랜잭션). 실패 시 무변화. + +## 5. 동작 / 알고리즘 +``` +1. validate args (schema 는 dispatch 가 이미 했지만, 의미 검증 추가): + - protocol_id = args['protocol_id'] (String) + - frameLevel = FrameLevel.fromString(args['frame_level']) + if frameLevel == null || frameLevel in {l0, l1}: + return ToolErr('validation', 'frame_level 은 L2 또는 L3 이어야 합니다.') + - framedText = args['framed_text'] + if framedText.trim().isEmpty: + return ToolErr('validation', 'framed_text 가 비어있습니다.') + +2. catalog lookup (1차 효과로 type 결정): + item = await deps.catalog.byId(protocol_id) + if item == null: + return ToolErr('not_found', '카탈로그에서 \'$protocol_id\' 를 찾을 수 없습니다.') + if item is ProtocolCatalogItem: habitType = build + else if item is BreakCatalogItem: habitType = breakHabit + else if item is DietCatalogItem: habitType = build // diet 는 build 로 매핑 + else: return ToolErr('validation', '지원되지 않는 카탈로그 타입') + +3. R7 avoidance keyword 검증: + patterns = await framePatterns() // ref 로 캐시된 것 활용 권장 (deps 에 주입) + hits = detectAvoidanceKeywords(framedText, patterns) + if hits.isNotEmpty: + return ToolErr('r7_avoidance', + 'L${frameLevel} 프레임에 회피 키워드 "${hits.first.keyword}" 가 감지됐어요. ' + '"${hits.first.suggestion}" 같은 표현으로 다시 시도해주세요.') + +4. R3 active habit quota 검증: + count = await deps.habitDao.countActive(userId: deps.userId, type: habitType) + quota = judgeActiveHabitQuota(type: habitType, currentActiveCount: count) + if !quota.allowed: + return ToolErr('r3_quota', quota.reason) + +5. HabitDraft 빌드 (R8 XOR enforce 는 HabitDao 내부 assertion): + draft = HabitDraft( + userId: deps.userId, + type: habitType, + title: item.title, + protocolId: habitType == HabitType.build ? protocol_id : null, + breakProtocolId: habitType == HabitType.breakHabit ? protocol_id : null, + frameLevel: frameLevel, + frameFramedText: framedText, + anchorWhen: args['anchor_when'], + anchorAfterWhat: args['anchor_after_what'], + startedAt: today_kst_yyyymmdd(), + variants: dose_text != null + ? [VariantDraft(label: '기본', doseText: dose_text, isMinimum: false, sortOrder: 0)] + : [], // 빈 variants 면 HabitDao 가 시드 default 로 채우거나 그대로 빈 채로 저장 + ) + +6. insert: + habitId = await deps.habitDao.insertWithVariants(draft) + // R8 위배 시 assertXorProtocol 가 throw → catch → return ToolErr('r8_xor', ...) + +7. return ToolOk(data: { + 'habit_id': habitId, + 'title': item.title, + 'type': habitType.name, + 'frame_level': frameLevel.name, + }) +``` + +## 6. 에러 & 실패 모드 +| 조건 | 처리 | 반환 | +|---|---|---| +| frame_level L0/L1 | 사용자 안내 | `ToolErr('validation', '...')` | +| protocol_id 미존재 | 사용자 안내 | `ToolErr('not_found', '...')` | +| 회피 키워드 감지 | 사용자 안내 + 대안 제시 | `ToolErr('r7_avoidance', '...')` | +| R3 quota 초과 | 사용자 안내 | `ToolErr('r3_quota', '...')` | +| R8 XOR 위배 (이론상 불가, 안전망) | 로깅 + 안내 | `ToolErr('r8_xor', '...')` | +| HabitDao 예외 | dispatch 가 catch → `ToolErr('handler_error', ...)` | | + +**불변식**: 모든 error path 는 한국어 reason 을 포함. 모델이 이를 받아 사용자에게 안내. + +## 7. 엣지케이스 +- **공백 anchor**: trim 후 empty → null 로 변환. +- **catalog 이 DietCatalogItem 인데 frame_level 이 L3**: 통과. (diet 도 정체성 프레임 가능) +- **frame_level "l2" 소문자**: `FrameLevel.fromString` 이 정규화. 미지원 시 validation error. +- **중복 추가**: 같은 protocol_id + framed_text 로 재호출 — 별도 dedup 없음. R3 quota 가 3 째에서 차단. (OQ-1) +- **anchor_when 만 있고 anchor_after_what 없음**: 모두 허용 (어느 하나만 있어도 됨). + +## 8. 복잡도 / 성능 +- DB query 2회 (catalog byId + countActive) + 1회 write 트랜잭션. +- `framePatterns` 매번 로드 시 N (≈ 30) 패턴 keyword 매칭 — O(text_len × pattern_count). text ≤ 200자, patterns ≤ 30 → 무시 가능. +- 평균 < 100ms. + +## 9. 테스트 케이스 (필수) +| 케이스 | 사전 | 입력 | 기대 | +|---|---|---|---| +| 정상 build | seed + 0 active | `{protocol_id: 'morning_sunlight', frame_level: 'L2', framed_text: '아침에 햇빛 보기'}` | ToolOk, habits row +1 | +| 정상 break | seed + 0 active | `{protocol_id: 'alcohol', ...}` | ToolOk, type=breakHabit | +| L0 reject | seed | `frame_level: 'L0'` | ToolErr('validation') | +| 미존재 protocol | seed | `protocol_id: 'no_such'` | ToolErr('not_found') | +| 회피 키워드 | seed | `framed_text: '술 끊기'` | ToolErr('r7_avoidance') | +| R3 quota (build 3개) | seed + build 3개 | 새 build 추가 | ToolErr('r3_quota') | + +## 10. 의존 +- `CatalogRepository.byId(id)` +- `HabitDao.countActive(...)`, `HabitDao.insertWithVariants(draft)` +- `judgeActiveHabitQuota(...)` +- `detectAvoidanceKeywords(text, patterns)` +- `framePatterns` (ToolDeps 에 주입) +- `FrameLevel`, `HabitType`, `HabitDraft`, `VariantDraft` diff --git a/docs/design/260-gemma-tool-calling/fn-chat_session_controller.md b/docs/design/260-gemma-tool-calling/fn-chat_session_controller.md new file mode 100644 index 0000000..0f3de69 --- /dev/null +++ b/docs/design/260-gemma-tool-calling/fn-chat_session_controller.md @@ -0,0 +1,146 @@ +# 함수 설계서: `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 { + Future userTurn(String text, BuildContext context); +} + +class ChatSessionState { + final List 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`. 결과는 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` diff --git a/docs/design/260-gemma-tool-calling/fn-confirm_gate.md b/docs/design/260-gemma-tool-calling/fn-confirm_gate.md new file mode 100644 index 0000000..c078a9a --- /dev/null +++ b/docs/design/260-gemma-tool-calling/fn-confirm_gate.md @@ -0,0 +1,106 @@ +# 함수 설계서: `ConfirmGate.show` (#260) + +> **부모 설계서**: ./README.md · **상태**: Draft +> **작성**: [AI] Architect · **구현**: `lib/ai/tools/confirm_gate.dart:show` · **테스트**: `test/ui/confirm_gate_test.dart` + +## 1. 시그니처 +```dart +class ConfirmGate { + static Future show( + BuildContext context, + ToolDefinition tool, + Map args, + ); +} +``` + +## 2. 책임 (단일 책임, 1줄) +destructive tool 실행 직전 모달 AlertDialog 을 띄워 사용자 confirm 여부를 `Future` 로 반환한다. + +## 3. 입력 +| 파라미터 | 타입 | 제약 | 설명 | +|---|---|---|---| +| `context` | BuildContext | `context.mounted == true` | chat screen 의 context | +| `tool` | ToolDefinition | isDestructive=true | 어떤 도구인가 | +| `args` | Map | 이미 validate 통과 | 사용자에게 보여줄 인자 | + +## 4. 출력 +- **반환**: `Future` + - `true` = 사용자가 "수행" 탭 + - `false` = 사용자가 "취소" 또는 outside-tap dismiss 또는 `context.mounted == false` +- **부수효과**: 모달 표시 (UI). DB 변경 없음. + +## 5. 동작 / 알고리즘 +``` +1. if !context.mounted: + return false + +2. summary = _summarize(tool.name, args) + // tool 별 사람 친화 요약 함수 (per-tool overridable) + +3. result = await showDialog( + context: context, + barrierDismissible: true, // outside-tap = 취소 + builder: (ctx) => AlertDialog( + title: Text('이 작업을 수행할까요?'), + content: Column(mainAxisSize: min, crossAxisAlignment: start, children: [ + Text(tool.description, style: bodyMedium), + SizedBox(height: 12), + Container( + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.surfaceVariant, + borderRadius: BorderRadius.circular(8), + ), + child: Text(summary), + ), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('취소')), + FilledButton( + autofocus: true, + onPressed: () => Navigator.pop(ctx, true), + child: Text('수행'), + ), + ], + ), + ) + +4. return result ?? false // dismiss 시 null → false +``` + +### `_summarize` 규칙 (tool 별) +- `add_habit` → "프로토콜 '$title'을 ${frame_level} 프레임으로 새 습관으로 추가합니다.\n • 문장: \"$framed_text\"\n • 앵커: ${anchor_when ?? '-'} / ${anchor_after_what ?? '-'}" +- `log_tracker_entry` → "$habit_title 의 ${date ?? '오늘'} 기록을 '${value == 'done' ? '완료' : '공란'}' 으로 저장합니다." +- 기타 → JSON pretty (fallback) + +`title` 은 핸들러가 호출 직전 lookup 해서 args 에 채워줄 수 있지만, 단순화를 위해 ConfirmGate 가 직접 catalog/habit 조회는 안 함 — args 에 이미 있는 값만 사용. (안 채워졌으면 protocol_id 그대로 노출 — 트레이드오프 수용.) + +## 6. 에러 & 실패 모드 +| 조건 | 처리 | 반환 | +|---|---|---| +| context dispose 후 호출 | guard 즉시 | `false` | +| showDialog 자체 예외 (이론상 없음) | rethrow X — catch 후 false 반환 | `false` | +| args 가 _summarize 가 기대 안 한 형태 | toString fallback | 정상 동작 (dialog 노출) | + +## 7. 엣지케이스 +- **chat 모달 위에 chat 모달**: 동시에 호출되지 않도록 ChatSessionController 가 직렬화 (한 turn = 한 tool call 만). 다중 destructive tool 병렬 호출 시 첫 confirm 만 처리, 나머지는 `ToolCancelled` 자동 반환 (OQ-1 영향). +- **시스템 back press**: Android 뒤로가기 → dialog dismiss → false. 의도된 cancel. +- **autofocus + 키보드 enter**: 수행 버튼 기본 포커스. 의도치 않은 enter 누름 위험 — 사용자 결정으로 수용 (단축키 활용성 ↑). + +## 8. 복잡도 / 성능 +- O(1). 사용자 대기 시간 = 무한 (사용자 입력 대기). +- 호출 빈도: 사용자 대화 turn 당 0 또는 1. + +## 9. 테스트 케이스 (필수) +| 케이스 | 셋업 | 입력 | 기대 | +|---|---|---|---| +| confirm | MaterialApp + 모달 진입 → "수행" tap | add_habit args | true | +| cancel | "취소" tap | 동일 | false | +| outside dismiss | barrier tap | 동일 | false | +| unmounted context | context dispose 후 호출 | — | false 즉시 | + +## 10. 의존 +- Flutter `showDialog`, `AlertDialog` +- `ToolDefinition` (description 출처) +- per-tool summary 규칙은 별도 함수로 분리 (`_summarize(toolName, args)`) — 단위 테스트 용이. diff --git a/docs/design/260-gemma-tool-calling/fn-tool_dispatcher.md b/docs/design/260-gemma-tool-calling/fn-tool_dispatcher.md new file mode 100644 index 0000000..9318954 --- /dev/null +++ b/docs/design/260-gemma-tool-calling/fn-tool_dispatcher.md @@ -0,0 +1,100 @@ +# 함수 설계서: `ToolDispatcher.dispatch` (#260) + +> **부모 설계서**: ./README.md · **상태**: Draft +> **작성**: [AI] Architect · **구현**: `lib/ai/tools/tool_dispatcher.dart:dispatch` · **테스트**: `test/ai/tools/tool_dispatcher_test.dart` + +## 1. 시그니처 +```dart +Future dispatch({ + required String toolName, + required Map rawArgs, + required BuildContext? confirmContext, // null 이면 destructive tool 자동 cancel + required ToolDeps deps, +}); +``` + +`ToolDeps` = `{ HabitDao habitDao, TrackerDao trackerDao, CatalogRepository catalog, String userId }`. + +## 2. 책임 (단일 책임, 1줄) +`toolName` 으로 핸들러를 찾아, args 검증 → Confirm gate → 핸들러 호출 → 결과를 envelope 으로 감싸 반환한다. + +## 3. 입력 +| 파라미터 | 타입 | 제약/검증 | 설명 | +|---|---|---|---| +| `toolName` | String | non-empty, registry 에 등록된 이름 | LLM 의 `FunctionCallResponse.name` | +| `rawArgs` | Map | 어떤 타입이든 — 검증은 내부에서 | LLM 의 `FunctionCallResponse.args` | +| `confirmContext` | BuildContext? | 살아있는 widget context | destructive 가 아니면 무관. null + destructive = 자동 cancel | +| `deps` | ToolDeps | non-null | 핸들러가 호출할 Repository 묶음 | + +## 4. 출력 +- **반환**: `Future` — `ToolOk` / `ToolErr` / `ToolCancelled`. 절대 throw 하지 않음. +- **부수효과**: + - Confirm gate 호출 시 모달 표시 (UI side effect) + - 핸들러 내부에서 DB write 가능 (destructive 인 경우만) + +## 5. 동작 / 알고리즘 +``` +1. tool = ToolRegistry.byName(toolName) + if tool == null: + return ToolErr(code: 'unknown_tool', reason: '알 수 없는 도구: $toolName') + +2. validatedArgs = ToolArgsValidator.validate(tool.parametersSchema, rawArgs) + if validatedArgs is ValidationError: + return ToolErr(code: 'validation', reason: '인자 오류: ${err.message}') + +3. if tool.isDestructive: + if confirmContext == null: + return ToolCancelled() + ok = await ConfirmGate.show(confirmContext, tool, validatedArgs) + if !ok: + return ToolCancelled() + +4. try: + payload = await tool.handler(validatedArgs, deps) + // handler 가 이미 ToolResult 를 반환하는 형태이므로 passthrough + return payload + catch (e, st): + log('tool_error', tool=$toolName, err=$e) + return ToolErr(code: 'handler_error', reason: '도구 실행 실패: ${e.runtimeType}') +``` + +## 6. 에러 & 실패 모드 +| 조건 | 처리 | 반환 | +|---|---|---| +| `toolName` 미등록 | log warn | `ToolErr('unknown_tool', ...)` | +| `rawArgs` schema 위배 | log info | `ToolErr('validation', ...)` | +| destructive + confirmContext null | 조용히 | `ToolCancelled()` | +| 사용자 모달 거부 | 조용히 | `ToolCancelled()` | +| 핸들러 예외 | log error + stacktrace | `ToolErr('handler_error', ...)` — 사용자 메시지엔 타입만 | +| 핸들러가 R 규칙 위배 detect | 핸들러 자체에서 반환 | passthrough `ToolErr('r3_quota', ...)` 등 | + +**불변식**: dispatch 는 throw 하지 않는다. 모든 실패 경로는 ToolResult 로 환원. + +## 7. 엣지케이스 +- **빈 args**: `{}` 가 들어와도 schema 가 required 필드 검증으로 잡음. +- **redundant args** (스키마에 없는 키): 무시 — 모델이 환각해도 통과시키되 로깅. +- **모달 race**: confirmContext 가 dispatch 호출 후 dispose 되는 경우 → `ConfirmGate` 내부에서 `context.mounted` 체크 후 false 반환. +- **dispatch 중 사용자가 chat 화면 dismiss**: 핸들러는 계속 실행됨 (취소 안 함). 결과는 폐기되지만 DB write 는 commit 된 채 남음. ChatSessionController 가 lifecycle 책임짐 (Architect 결정: side effect 보존 = 사용자가 의도적으로 chat 닫았다고 가정). + +## 8. 복잡도 / 성능 +- O(1) registry lookup (`Map`). +- args validate ≤ 50자 keyword 등 small payload — O(n) JSON schema 매칭. +- 호출 빈도: 사용자 대화 turn 당 0~N (보통 0 또는 1). 폴링 루프 아님. +- 메모리: stateless — instance 변수 없음. + +## 9. 테스트 케이스 (필수) +| 케이스 | 입력 | 기대 | +|---|---|---| +| unknown tool | `dispatch('foo', {}, ...)` | `ToolErr('unknown_tool', ...)` | +| validation fail | `dispatch('add_habit', {'protocol_id': 123}, ...)` | `ToolErr('validation', ...)` (123 is int not string) | +| destructive + null context | `dispatch('add_habit', validArgs, null, ...)` | `ToolCancelled()` | +| destructive + user accept | mock ConfirmGate → true | handler 결과 그대로 | +| destructive + user reject | mock ConfirmGate → false | `ToolCancelled()` | +| handler throw | mock handler throws | `ToolErr('handler_error', ...)` | +| read-only normal | `dispatch('search_catalog', validArgs, null, ...)` | `ToolOk(data:...)` | + +## 10. 의존 +- `ToolRegistry` (정적 lookup) +- `ToolArgsValidator` (JSON schema validator — 간단 자체 구현 권장) +- `ConfirmGate.show` (UI) +- `ToolDeps` 내부의 Repository 들 (각 핸들러가 사용)