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
64 lines
2.0 KiB
Dart
64 lines
2.0 KiB
Dart
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);
|
|
}
|