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
105 lines
3.1 KiB
Dart
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
|
|
}
|
|
}
|