[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:
104
app/lib/ai/tools/tool_dispatcher.dart
Normal file
104
app/lib/ai/tools/tool_dispatcher.dart
Normal file
@@ -0,0 +1,104 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user