Files
life-helper/app/lib/ai/tools/tool_dispatcher.dart
joungmin b1bed4d5ca [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
2026-06-15 10:42:43 +09:00

105 lines
3.1 KiB
Dart

import 'package:flutter/widgets.dart';
import 'confirm_gate.dart';
import 'tool_definition.dart';
import 'tool_envelope.dart';
import 'tool_registry.dart';
/// Routes a single `FunctionCallResponse` from the LLM to the matching
/// handler. See design `fn-tool_dispatcher.md`.
///
/// `dispatch` never throws — every failure path returns a `ToolResult`.
class ToolDispatcher {
final ToolRegistry registry;
final ConfirmGate confirmGate;
ToolDispatcher({
required this.registry,
ConfirmGate? confirmGate,
}) : confirmGate = confirmGate ?? const ConfirmGate();
Future<ToolResult> dispatch({
required String toolName,
required Map<String, dynamic> rawArgs,
required BuildContext? confirmContext,
required ToolDeps deps,
}) async {
// 1. Lookup.
final tool = registry.byName(toolName);
if (tool == null) {
return ToolErr('unknown_tool', '알 수 없는 도구: $toolName');
}
// 2. Validate against schema.
final validation = _validateArgs(tool.parametersSchema, rawArgs);
if (validation != null) {
return ToolErr('validation', '인자 오류: $validation');
}
// 3. Destructive → Confirm gate.
if (tool.isDestructive) {
if (confirmContext == null) {
return const ToolCancelled();
}
final ok = await confirmGate.show(confirmContext, tool, rawArgs);
if (!ok) return const ToolCancelled();
}
// 4. Run handler.
try {
return await tool.handler(rawArgs, deps);
} catch (e) {
return ToolErr('handler_error', '도구 실행 실패: ${e.runtimeType}');
}
}
}
/// Minimal JSON-schema-ish validator covering only what our tools use:
/// - object root with `properties` + optional `required`
/// - per-property `type` ∈ {string, integer, number, boolean, object, array}
///
/// Returns null on success, a short error message on failure. Extra keys are
/// allowed (model hallucination tolerated; logged at call site if needed).
String? _validateArgs(Map<String, dynamic> schema, Map<String, dynamic> args) {
final required = schema['required'];
if (required is List) {
for (final field in required) {
if (field is String && !args.containsKey(field)) {
return '필수 필드 \'$field\' 가 없습니다.';
}
}
}
final props = schema['properties'];
if (props is! Map) return null;
for (final entry in args.entries) {
final propSchema = props[entry.key];
if (propSchema is! Map) continue; // unknown key — tolerate
final expected = propSchema['type'];
if (expected is! String) continue;
final v = entry.value;
if (!_matchesType(expected, v)) {
return '\'${entry.key}\' 타입 불일치 (기대=$expected, 실제=${v.runtimeType})';
}
}
return null;
}
bool _matchesType(String expected, dynamic v) {
switch (expected) {
case 'string':
return v is String;
case 'integer':
return v is int;
case 'number':
return v is num;
case 'boolean':
return v is bool;
case 'object':
return v is Map;
case 'array':
return v is List;
default:
return true; // unknown type — passthrough
}
}