From e6437f4bb29be5cb69fa064be92ff87c91dc7ac5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 08:20:48 +0000 Subject: [PATCH] feat: effort de raisonnement Hermes complet sur les trois transports (v2.5.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Huit niveaux (none, minimal, low, medium, high, xhigh, max, ultra) au lieu de low / medium / high, avec libellés explicatifs dans Paramètres → Hermes. - model_options.reasoning_effort envoyé sur runs, sessions et chat completions ; il ne l'était qu'en chat completions. - Test des trois transports (effort présent, absent quand laissé au serveur). - Le sélecteur de modèle par fournisseur ayant été livré sur master en 2.5.2, cette branche ne garde que l'effort de raisonnement. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MMpgFriwxiBgurUVb21oCE --- README.md | 3 +- docs/ROADMAP.md | 1 + package.json | 4 +-- src/components/settings/SettingsDrawer.tsx | 9 +++-- src/lib/hermesReasoning.ts | 18 ++++++++++ src/services/hermes/client.ts | 11 ++++-- src/services/hermes/types.ts | 5 ++- tests/hermesReasoning.test.ts | 42 ++++++++++++++++++++++ 8 files changed, 81 insertions(+), 12 deletions(-) create mode 100644 src/lib/hermesReasoning.ts create mode 100644 tests/hermesReasoning.test.ts diff --git a/README.md b/README.md index 508e7a2..ddc07d4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # EveFlow 2 — Interface vocale JARVIS pour Hermes Agent [![Build](https://img.shields.io/github/actions/workflow/status/R0m1k3/EveFlow/windows-release.yml?style=flat-square)](https://github.com/R0m1k3/EveFlow/actions) -[![Version](https://img.shields.io/badge/version-2.5.0-brightgreen.svg?style=flat-square)](https://github.com/R0m1k3/EveFlow/releases) +[![Version](https://img.shields.io/badge/version-2.5.3-brightgreen.svg?style=flat-square)](https://github.com/R0m1k3/EveFlow/releases) [![License](https://img.shields.io/badge/license-MIT-lightgrey.svg?style=flat-square)](LICENSE) **EveFlow** est un compagnon de bureau Windows qui transforme [Hermes Agent](https://hermes-agent.nousresearch.com/) en assistant vocal à la JARVIS : un noyau holographique réactif au son, une conversation en streaming, les outils, sous-agents, approbations, crons, skills et sessions d'Hermes pilotés depuis un seul HUD. @@ -46,6 +46,7 @@ La version 2 est une réécriture complète : plus de robot 3D, un pipeline voca 1. **Runs API** (`POST /v1/runs` + `GET /v1/runs/{id}/events`) : deltas, outils, sous-agents, `approval.request`, `run.completed`, arrêt (`/stop`) et injection de consignes en cours de run (`/steer`). 2. **Sessions API** (`/api/sessions/{id}/chat/stream`) : mémoire côté serveur, fork, suppression, relecture de l'historique. 3. **Chat completions** OpenAI (`/v1/chat/completions`) avec `hermes.tool.progress`, continuité de session (`X-Hermes-Session-Id`) et outils EveFlow côté client (état du HUD, fichiers partagés, notifications). +* **Effort de raisonnement** : les huit niveaux d'Hermes (none, minimal, low, medium, high, xhigh, max, ultra) au choix dans Paramètres → Hermes, envoyés dans `model_options.reasoning_effort` sur les trois transports (runs, sessions, chat completions). * Mémoire longue durée via `X-Hermes-Session-Key`. * **Approbations** d'outils affichées dans le HUD : une fois, pour la session, toujours, refuser. * **Crons** : création en langage naturel (`every 1h`, `weekdays at 9am`, `in 30m`, expression cron), pause/reprise, exécution immédiate, édition, historique des résultats lus à voix haute. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9a94776..91074d9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -84,6 +84,7 @@ Sources : [jarvis-desktop-ai](https://github.com/ccarloshenri/jarvis-desktop-ai) | Correctifs 2.4.0.1 | réponse vide en chat completions désormais expliquée (JSON non streamé ou erreur HTTP 200), la liaison ne passe plus en « dégradé » quand seule l'API des crons échoue, transcriptions parasites (« (cliquant) », « *Claire* ») ignorées, préférence de voix masculine/féminine | | Parakeet v3 vs Whisper base sur trois phrases Piper (fr) (2.4.1) | Parakeet : 3/3 exactes avec ponctuation, 0,4 à 0,5 s à chaud (6,7 s au premier appel) ; Whisper base : erreurs sur « Jarvis », « Peux-tu », 0,7 à 0,9 s | | Worklets audio sous CSP stricte (2.4.1) | chargement des modules statiques OK dans l'application empaquetée | +| Effort de raisonnement Hermes (2.5.3) | huit niveaux (none → ultra) dans les réglages, transmis dans `model_options.reasoning_effort` sur runs, sessions et chat completions (auparavant chat completions seulement, trois niveaux) ; test des trois transports | | Voix françaises (2.5.0) | Edge Henri : MP3 reçu de bout en bout (32 ko pour 4 s). Supertonic 3 en français : 10 voix, RTF 0,14 sur 4 cœurs (8,7 s d'audio en 1,3 s), genres déterminés par mesure de la fréquence fondamentale (voix 0-4 : 170-210 Hz, voix 5-9 : 92-137 Hz) ; le worker compilé accepte `language` et bascule sur l'anglais pour une langue inconnue | | Reconnaissance française : Parakeet v3 vs Qwen3-ASR 0.6B int8 (2.5.0) | Six phrases Supertonic (3,8 s) : Parakeet WER 8,5 % (erreurs surtout de forme : « 14h30 »), 384 ms par phrase ; Qwen3-ASR WER 15,3 % (« mémoires vivres »), 1 477 ms, 940 Mo. Qwen3-ASR n'est pas ajouté au catalogue | | Serveur MCP (2.4.0) | initialize, tools/list (14 outils), tools/call côté principal (presse-papiers, capture image) et côté renderer (état, message dans le fil) | diff --git a/package.json b/package.json index a26b662..28e68d3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "eveflow", - "version": "2.5.2", - "releaseVersion": "2.5.2", + "version": "2.5.3", + "releaseVersion": "2.5.3", "description": "JARVIS-style desktop HUD for Hermes Agent: voice, streaming runs, scheduled jobs, skills and telemetry", "main": "dist-electron/main.js", "private": true, diff --git a/src/components/settings/SettingsDrawer.tsx b/src/components/settings/SettingsDrawer.tsx index 7ffcfe3..b6cd92a 100644 --- a/src/components/settings/SettingsDrawer.tsx +++ b/src/components/settings/SettingsDrawer.tsx @@ -16,6 +16,7 @@ import { useVoice } from '../../state/voice'; import { installedModels, useVoiceModels } from '../../state/voiceModels'; import { ModelsSection } from './ModelsSection'; import { ModelSelect } from './ModelSelect'; +import { REASONING_EFFORTS, isReasoningEffort } from '../../lib/hermesReasoning'; type Section = 'general' | 'hermes' | 'voice' | 'speech' | 'models' | 'webhook' | 'notifications' | 'ui'; @@ -244,12 +245,10 @@ export function SettingsDrawer({ onClose }: Props) {
- { if (isReasoningEffort(e.target.value)) update({ hermes: { reasoningEffort: e.target.value } }); }}> + {REASONING_EFFORTS.map((e) => )} + Envoyé à chaque requête (runs, sessions, chat completions) dans model_options.reasoning_effort. Plus c’est haut, plus la réponse est réfléchie et lente.
diff --git a/src/lib/hermesReasoning.ts b/src/lib/hermesReasoning.ts new file mode 100644 index 0000000..47ad8f3 --- /dev/null +++ b/src/lib/hermesReasoning.ts @@ -0,0 +1,18 @@ +/** Reasoning effort levels accepted by Hermes (`agent.reasoning_effort` / `model_options.reasoning_effort`). */ +import type { HermesReasoningEffort } from '../services/hermes/types'; + +export const REASONING_EFFORTS: Array<{ value: HermesReasoningEffort; label: string }> = [ + { value: '', label: 'Défaut du serveur (medium)' }, + { value: 'none', label: 'none : pas de réflexion' }, + { value: 'minimal', label: 'minimal' }, + { value: 'low', label: 'low : rapide' }, + { value: 'medium', label: 'medium : équilibré' }, + { value: 'high', label: 'high : réponses plus fouillées' }, + { value: 'xhigh', label: 'xhigh' }, + { value: 'max', label: 'max' }, + { value: 'ultra', label: 'ultra : le plus lent' } +]; + +export function isReasoningEffort(value: string): value is HermesReasoningEffort { + return REASONING_EFFORTS.some((e) => e.value === value); +} diff --git a/src/services/hermes/client.ts b/src/services/hermes/client.ts index 6e2b690..c8cfceb 100644 --- a/src/services/hermes/client.ts +++ b/src/services/hermes/client.ts @@ -160,6 +160,11 @@ export class HermesClient { return hermesBaseUrl(this.config.url); } + /** `model_options.reasoning_effort`, accepted by runs, session chat and chat completions alike. */ + private reasoningOptions(): Rec { + return this.config.reasoningEffort ? { model_options: { reasoning_effort: this.config.reasoningEffort } } : {}; + } + private headers(extra: Record = {}, json = true): Record { const h: Record = { ...extra }; if (json) h['Content-Type'] = 'application/json'; @@ -335,7 +340,7 @@ export class HermesClient { // ── Runs ────────────────────────────────────────────────────────────────── async startRun(body: { input: string; session_id?: string; instructions?: string; model?: string; provider?: string }): Promise<{ run_id: string; status: string }> { - const payload: Rec = { input: body.input }; + const payload: Rec = { input: body.input, ...this.reasoningOptions() }; if (body.session_id) payload.session_id = body.session_id; if (body.instructions) payload.instructions = body.instructions; if (body.model) payload.model = body.model; @@ -495,7 +500,7 @@ export class HermesClient { } if (isAborted()) return ''; const realId = sessionId.slice(3); - const body: Rec = { input: options.text }; + const body: Rec = { input: options.text, ...this.reasoningOptions() }; if (this.config.instructions) body.instructions = this.config.instructions; Object.assign(body, modelSelection(this.config.model)); @@ -539,7 +544,7 @@ export class HermesClient { payload.tools = options.localToolDefinitions; payload.tool_choice = 'auto'; } - if (this.config.reasoningEffort) payload.model_options = { reasoning_effort: this.config.reasoningEffort }; + Object.assign(payload, this.reasoningOptions()); const toolCalls = new Map(); let finishReason: string | null = null; diff --git a/src/services/hermes/types.ts b/src/services/hermes/types.ts index f4f648c..8fde54e 100644 --- a/src/services/hermes/types.ts +++ b/src/services/hermes/types.ts @@ -1,5 +1,8 @@ export type HermesTransport = 'auto' | 'runs' | 'sessions' | 'completions'; +/** Thinking depth accepted by Hermes (`model_options.reasoning_effort`); empty = server default (medium). */ +export type HermesReasoningEffort = '' | 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra'; + export interface HermesConfig { url: string; apiKey: string; @@ -7,7 +10,7 @@ export interface HermesConfig { /** Stable per-user key for long-term memory (X-Hermes-Session-Key). */ sessionKey: string; transport: HermesTransport; - reasoningEffort: '' | 'low' | 'medium' | 'high'; + reasoningEffort: HermesReasoningEffort; /** Extra instructions layered on top of the Hermes system prompt. */ instructions: string; /** Model used in "mission" mode (long tasks); empty = same as `model`. */ diff --git a/tests/hermesReasoning.test.ts b/tests/hermesReasoning.test.ts new file mode 100644 index 0000000..600b0e9 --- /dev/null +++ b/tests/hermesReasoning.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { HermesClient } from '../src/services/hermes/client'; +import { httpFetch, httpStream } from '../src/lib/transport'; +import { DEFAULT_SETTINGS } from '../src/state/settings'; +import { REASONING_EFFORTS, isReasoningEffort } from '../src/lib/hermesReasoning'; + +vi.mock('../src/lib/transport', async (original) => ({ ...await original(), httpFetch: vi.fn(), httpStream: vi.fn() })); +const config = { ...DEFAULT_SETTINGS.hermes, url: 'https://example.test', apiKey: 'k' }; +afterEach(() => vi.restoreAllMocks()); + +describe('reasoning effort levels', () => { + it('lists every level Hermes accepts, server default first', () => { + expect(REASONING_EFFORTS.map((e) => e.value)).toEqual(['', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']); + expect(isReasoningEffort('xhigh')).toBe(true); + expect(isReasoningEffort('')).toBe(true); + expect(isReasoningEffort('turbo')).toBe(false); + }); + + it.each(['runs', 'sessions', 'completions'] as const)('sends model_options.reasoning_effort over %s', async (transport) => { + const requests: Record[] = []; + const capture = async (req: { body?: string }) => { + requests.push(JSON.parse(req.body ?? '{}') as Record); + throw new Error('stop'); + }; + vi.mocked(httpFetch).mockImplementation(capture as never); + vi.mocked(httpStream).mockImplementation(capture as never); + const send = new HermesClient({ ...config, reasoningEffort: 'high' }).send({ text: 'Bonjour', sessionId: 'hs:test', history: [], onEvent: vi.fn() }, transport); + await expect(send.result).rejects.toThrow('stop'); + expect(requests[0]).toMatchObject({ model_options: { reasoning_effort: 'high' } }); + }); + + it('omits model_options when the effort is left to the server', async () => { + const requests: Record[] = []; + vi.mocked(httpFetch).mockImplementation((async (req: { body?: string }) => { + requests.push(JSON.parse(req.body ?? '{}') as Record); + throw new Error('stop'); + }) as never); + const send = new HermesClient({ ...config, reasoningEffort: '' }).send({ text: 'Bonjour', sessionId: 'test', history: [], onEvent: vi.fn() }, 'runs'); + await expect(send.result).rejects.toThrow('stop'); + expect(requests[0]).not.toHaveProperty('model_options'); + }); +});