conduit/lib/features/hermes/services/hermes_json_guard.dart
cogwheel 4e73c9a0ea
Some checks are pending
L10n / l10n (push) Waiting to run
Fix ten reported issues and vendor Hermes Agent as an API reference (#725)
* Fix ten reported issues and vendor Hermes Agent as an API reference

Closes #693, #695, #699, #700, #701, #703, #708, #712, #716, #723.

- Hermes (#693, #699): load transcripts over REST even when session.resume
  fails, page at 200 messages and shrink on oversized responses, and show
  distinct errors for too-large, not-found, and RPC failures. Add the
  hermes-src submodule (NousResearch/hermes-agent v2026.9.11) as the RPC and
  REST contract reference.
- Server version banner (#695): dismissible, remembered per server and version.
- Model selector (#700): overflow fade and selection tint use the card surface.
- OpenRouter (#701): skip Batch API-only ":batch" model variants.
- Persisted chat content (#703): store plain output text instead of rendered
  <details> markup when structured output exists, matching the web client.
- Direct connections (#708): probes report their real failure instead of a
  generic message, the Open WebUI logout fence no longer blocks Direct setup,
  and 401/403 under the api-key header mode hints at Bearer auth.
- Reasoning effort (#712): per-server, per-model user picks override the
  model's configured default and are sent explicitly in the request.
- Voice calls (#716): stop the record plugin from clearing the Android
  communication device and verify the loudspeaker route before reporting it.
- Voice mode (#723): finalize speech on the responseDone marker.
- Add the five new strings to every locale.

* Address review: keep incomplete-clear block, pass effort on sends, keep all dismissals

- Direct profiles stay hidden and unusable after an incomplete full app-data
  clear, in memory and across restarts, until the logout fence is cleared by a
  completed cleanup or a new authenticated session. Ordinary failed sign-outs
  still do not block Direct setup.
- The primary Open WebUI send path now passes the resolved per-model reasoning
  effort like regeneration and queued completions already did.
- Server version warning dismissals are stored as a bounded set so dismissing
  one server no longer forgets another.

* Address review: release rejected speaker device, speak settled text on responseDone

- When Android accepts the built-in speaker but the read-back disagrees,
  clear that selection before the legacy setSpeakerphoneOn fallback, which is
  ignored while a communication device stays selected.
- Voice mode takes the final text from the message once responseDone is set;
  the streaming content provider can lag behind the flushed message.

* Address review: await the incomplete-clear restart marker before returning

The marker is written through the app-data-clear barrier with a checked write
and awaited by the clear coordinator, so a process death right after an
incomplete clear cannot reload the surviving Direct profiles on restart.

* Address review: arm the restart fence before the wipe and propagate marker failures

The incomplete-clear marker is now armed before any data is wiped, disarmed
only when the clear completes or yields ownership, re-asserted on an
incomplete outcome, and a failed checked write aborts the clear instead of
being swallowed. The in-memory block is unchanged.

* Do not raise an error banner for response.incomplete

Open WebUI forwards the provider's `response.incomplete` event over the
socket but treats it as a no-op: the output is kept and the turn still ends
with a normal `done`. Raising an error on that event flashed "The response
stopped before it was complete" under the answer until `done` cleared it.
`response.failed` still surfaces the provider error.
2026-09-15 00:48:03 +05:30

168 lines
4.8 KiB
Dart

/// Default structural ceilings for JSON received from a Hermes server.
const int kMaxHermesJsonDepth = 128;
const int kMaxHermesJsonNodes = 100000;
const int kMaxHermesJsonTokens = 400000;
enum HermesJsonLimit { depth, nodes, tokens }
/// A structural JSON limit exceeded before the source reached `jsonDecode`.
final class HermesJsonGuardException implements FormatException {
const HermesJsonGuardException(this.limit);
final HermesJsonLimit limit;
@override
String get message => switch (limit) {
HermesJsonLimit.depth => 'Hermes JSON exceeds the nesting limit.',
HermesJsonLimit.nodes => 'Hermes JSON exceeds the value limit.',
HermesJsonLimit.tokens => 'Hermes JSON exceeds the token limit.',
};
@override
int? get offset => null;
@override
Object? get source => null;
@override
String toString() => 'FormatException: $message';
}
/// A Hermes response or frame exceeded Conduit's transport size caps before
/// it was decoded. Callers can page smaller or tell the user the conversation
/// is too large; the payload itself is never retained.
final class HermesResponseTooLargeException implements FormatException {
const HermesResponseTooLargeException([
this.message = 'Hermes response is too large.',
]);
@override
final String message;
@override
int? get offset => null;
@override
Object? get source => null;
@override
String toString() => 'HermesResponseTooLargeException: $message';
}
/// Scans untrusted JSON without constructing its decoded object graph.
///
/// Strings are scanned in place so structural characters inside them do not
/// affect nesting. Allocation-bearing value tokens (including object keys)
/// count toward [maxNodes], while punctuation and values both count toward
/// [maxTokens]. JSON syntax remains the responsibility of `jsonDecode`; this
/// preflight only guarantees that malformed or valid input has finite lexical
/// and structural complexity before decoding begins.
void validateHermesJsonSource(
String source, {
int maxDepth = kMaxHermesJsonDepth,
int maxNodes = kMaxHermesJsonNodes,
int maxTokens = kMaxHermesJsonTokens,
}) {
if (maxDepth <= 0) throw RangeError.value(maxDepth, 'maxDepth');
if (maxNodes <= 0) throw RangeError.value(maxNodes, 'maxNodes');
if (maxTokens <= 0) throw RangeError.value(maxTokens, 'maxTokens');
var depth = 0;
var nodes = 0;
var tokens = 0;
var index = 0;
void addToken({required bool allocationBearing}) {
tokens++;
if (tokens > maxTokens) {
throw const HermesJsonGuardException(HermesJsonLimit.tokens);
}
if (!allocationBearing) return;
nodes++;
if (nodes > maxNodes) {
throw const HermesJsonGuardException(HermesJsonLimit.nodes);
}
}
while (index < source.length) {
final codeUnit = source.codeUnitAt(index);
if (_isJsonWhitespace(codeUnit)) {
index++;
continue;
}
if (codeUnit == _quote) {
addToken(allocationBearing: true);
index++;
var escaped = false;
while (index < source.length) {
final stringCodeUnit = source.codeUnitAt(index++);
if (escaped) {
escaped = false;
} else if (stringCodeUnit == _backslash) {
escaped = true;
} else if (stringCodeUnit == _quote) {
break;
}
}
continue;
}
if (codeUnit == _openBrace || codeUnit == _openBracket) {
addToken(allocationBearing: true);
depth++;
if (depth > maxDepth) {
throw const HermesJsonGuardException(HermesJsonLimit.depth);
}
index++;
continue;
}
if (codeUnit == _closeBrace || codeUnit == _closeBracket) {
addToken(allocationBearing: false);
if (depth > 0) depth--;
index++;
continue;
}
if (codeUnit == _colon || codeUnit == _comma) {
addToken(allocationBearing: false);
index++;
continue;
}
// Numbers, true, false, null, and malformed bare lexemes are each one
// allocation-bearing token. `jsonDecode` validates their exact spelling.
addToken(allocationBearing: true);
index++;
while (index < source.length) {
final next = source.codeUnitAt(index);
if (_isJsonWhitespace(next) || _isJsonDelimiter(next)) break;
index++;
}
}
}
bool _isJsonWhitespace(int codeUnit) =>
codeUnit == 0x20 ||
codeUnit == 0x09 ||
codeUnit == 0x0A ||
codeUnit == 0x0D;
bool _isJsonDelimiter(int codeUnit) =>
codeUnit == _openBrace ||
codeUnit == _closeBrace ||
codeUnit == _openBracket ||
codeUnit == _closeBracket ||
codeUnit == _colon ||
codeUnit == _comma ||
codeUnit == _quote;
const int _quote = 0x22;
const int _comma = 0x2C;
const int _colon = 0x3A;
const int _openBracket = 0x5B;
const int _backslash = 0x5C;
const int _closeBracket = 0x5D;
const int _openBrace = 0x7B;
const int _closeBrace = 0x7D;