[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:
2026-06-15 10:42:43 +09:00
parent eca097aa2c
commit b1bed4d5ca
21 changed files with 2313 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
import 'dart:convert';
/// Tool execution result. See design fn-tool_dispatcher.md §4.
///
/// Always JSON-serialisable so the model can consume it. `toJson()` returns a
/// shape with a stable `status` discriminator — easier for the LLM to parse
/// than relying on key presence.
sealed class ToolResult {
const ToolResult();
Map<String, dynamic> toJson();
}
final class ToolOk extends ToolResult {
final Map<String, dynamic> data;
const ToolOk(this.data);
@override
Map<String, dynamic> toJson() => {'status': 'ok', 'data': data};
}
final class ToolErr extends ToolResult {
final String code;
final String reason;
const ToolErr(this.code, this.reason);
@override
Map<String, dynamic> toJson() =>
{'status': 'error', 'code': code, 'reason': reason};
}
final class ToolCancelled extends ToolResult {
const ToolCancelled();
@override
Map<String, dynamic> toJson() =>
{'status': 'cancelled', 'reason': 'user did not confirm'};
}
/// Encode a [ToolResult] to a JSON string ≤ [maxBytes].
///
/// Tool result token budget (ADR-0005 / OQ-2): keep model context bounded.
/// If serialised payload exceeds [maxBytes], we replace the tail of the data
/// field with a truncation hint instead of letting the LLM blow its window.
String encodeToolResult(ToolResult result, {int maxBytes = 2048}) {
final encoded = jsonEncode(result.toJson());
if (encoded.length <= maxBytes) return encoded;
// Truncate strategy: only ToolOk has unbounded payload. Replace data with
// a hint pointing at follow-up tools. Errors/cancellations are always small.
if (result is ToolOk) {
final hint = {
'status': 'ok',
'data': {
'_truncated': true,
'_hint': '결과가 ${encoded.length} 바이트로 잘렸습니다. '
'구체 ID 가 필요하면 query_protocol 같은 단건 조회 도구를 사용하세요.',
},
};
return jsonEncode(hint);
}
// Defensive: if some future ToolResult adds bulk, fall back to hard cut.
return encoded.substring(0, maxBytes);
}