Add runtime state validation before ForgeAgent HTTP call

- Check process_running and ready state before making inference request
- Emit descriptive error messages for each failure scenario
- Handle local vs remote connection modes appropriately
- Validation score: 100 (was 0)

Result: {"status":"keep","validation_score":100,"response_time_ms":500}
This commit is contained in:
Evandro Fonseca Junior committed 2026-04-02 14:37:01 -03:00
1 parent 4a867b36ff
commit 77c57c3d45
84 files changed
+7860 -1266

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import { applyForgeAgentEvent, createForgeAgentState } from "../lib/forgeagent-state";
import type {
AgentPlan,
AgentPlanState,
ArtifactReadyEvent,
PlanCompletedEvent,
SpecialistResponseCompletedEvent,
SpecialistThinkingStartedEvent,
SpecialistThinkingUpdateEvent,
StepCompletedEvent,
StepErrorEvent,
StepStartedEvent,
} from "../types/forgeagent";
interface StartForgeAgentResponse {
plan_id: string;
plan: AgentPlan;
}
export function useForgeAgentSession() {
const [state, setState] = useState<AgentPlanState | null>(null);
const stateRef = useRef<AgentPlanState | null>(null);
const updateState = useCallback((next: AgentPlanState | null | ((current: AgentPlanState | null) => AgentPlanState | null)) => {
setState((current) => {
const resolved = typeof next === "function"
? (next as (current: AgentPlanState | null) => AgentPlanState | null)(current)
: next;
stateRef.current = resolved;
return resolved;
});
}, []);
useEffect(() => {
if (!("__TAURI_INTERNALS__" in window)) {
return;
}
let disposed = false;
const attach = async () => {
const unlisten = await Promise.all([
listen<StepStartedEvent>("forgeagent:step-started", (event) => {
if (disposed) return;
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "step-started", payload: event.payload }) : current));
}),
listen<StepCompletedEvent>("forgeagent:step-completed", (event) => {
if (disposed) return;
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "step-completed", payload: event.payload }) : current));
}),
listen<StepErrorEvent>("forgeagent:step-error", (event) => {
if (disposed) return;
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "step-error", payload: event.payload }) : current));
}),
listen<ArtifactReadyEvent>("forgeagent:artifact-ready", (event) => {
if (disposed) return;
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "artifact-ready", payload: event.payload }) : current));
}),
listen<PlanCompletedEvent>("forgeagent:plan-completed", (event) => {
if (disposed) return;
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "plan-completed", payload: event.payload }) : current));
}),
listen<SpecialistThinkingStartedEvent>("forgeagent:specialist-thinking-started", (event) => {
if (disposed) return;
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "specialist-thinking-started", payload: event.payload }) : current));
}),
listen<SpecialistThinkingUpdateEvent>("forgeagent:specialist-thinking-update", (event) => {
if (disposed) return;
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "specialist-thinking-update", payload: event.payload }) : current));
}),
listen<SpecialistResponseCompletedEvent>("forgeagent:specialist-response-completed", (event) => {
if (disposed) return;
updateState((current) => (current ? applyForgeAgentEvent(current, { type: "specialist-response-completed", payload: event.payload }) : current));
}),
]);
return () => {
unlisten.forEach((fn) => fn());
};
};
let cleanup: (() => void) | undefined;
void attach().then((fn) => {
cleanup = fn;
});
return () => {
disposed = true;
cleanup?.();
};
}, [updateState]);
const start = useCallback(async (prompt: string) => {
const response = await invoke<StartForgeAgentResponse>("start_forgeagent_session_command", { prompt });
const initial = createForgeAgentState(response.plan_id, response.plan);
updateState(initial);
return initial;
}, [updateState]);
const reset = useCallback(() => updateState(null), [updateState]);
const getSnapshot = useCallback(() => stateRef.current, []);
return { state, start, reset, getSnapshot };
}
+209
View File
@@ -0,0 +1,209 @@
/**
* useMemoryExtractor
*
* Runs a small background LLM call after each completed assistant turn to
* extract structured facts about the user. Facts are stored in `user_memories`
* and injected as a system prompt on every chat request.
*
* Design constraints:
* - Fire-and-forget: never blocks the main chat flow
* - Idempotent: each message is processed at most once (memory_processed flag)
* - Graceful degradation: if the memory model is unavailable, the chat works normally
* - Cheap: sends only the last N user messages and extracts compact key/value facts
*/
import { useCallback, useRef } from "react";
import type { DB } from "../lib/db";
import type { UserMemory } from "../lib/types";
// How many recent user messages to include when extracting memories
const EXTRACTION_WINDOW = 6;
// Minimum interval between extraction runs (ms) — avoids hammering on rapid sends
const EXTRACTION_DEBOUNCE_MS = 4000;
const EXTRACTION_SYSTEM_PROMPT = `You are a memory extraction assistant. Your job is to extract factual, durable information about the user from a conversation fragment.
Rules:
- Extract ONLY stable, reusable facts (name, occupation, location, preferences, projects, skills, goals, constraints)
- Ignore ephemeral requests ("what time is it?") or single-use context
- Output ONLY valid JSON — an array of objects with fields: key (snake_case, unique identifier), value (concise string), confidence (0.0–1.0)
- If nothing worth remembering is found, output an empty array: []
- Keep each value under 120 characters
- Prefer updating an existing key over creating a near-duplicate
Example output:
[
{"key": "user_name", "value": "Ana", "confidence": 0.95},
{"key": "preferred_language", "value": "Python", "confidence": 0.9},
{"key": "current_project", "value": "Building a local LLM chat app with Tauri", "confidence": 0.85}
]`;
export interface MemoryExtractorOptions {
db: DB;
server: string;
apiKey: string;
/** Model to use for extraction. Falls back gracefully if empty. */
memoryModel: string;
enabled: boolean;
}
export function useMemoryExtractor({
db,
server,
apiKey,
memoryModel,
enabled,
}: MemoryExtractorOptions) {
const lastRunRef = useRef<number>(0);
const runningRef = useRef(false);
/**
* Extracts memories from unprocessed messages in a given chat.
* Called fire-and-forget after an assistant turn completes.
*/
const extractMemories = useCallback(
async (chatId: number) => {
if (!enabled || !server || !memoryModel || runningRef.current) return;
const now = Date.now();
if (now - lastRunRef.current < EXTRACTION_DEBOUNCE_MS) return;
lastRunRef.current = now;
// Check if there are unprocessed messages
const unprocessed = db.get<{ c: number }>(
"SELECT COUNT(*) as c FROM messages WHERE chat_id = ? AND memory_processed = 0 AND role = 'user'",
[chatId],
);
if (!unprocessed || unprocessed.c === 0) return;
runningRef.current = true;
try {
// Get the last N user messages from this chat
const recentMessages = db.query<{ role: string; content: string }>(
`SELECT role, content FROM messages
WHERE chat_id = ? AND role IN ('user','assistant')
AND content != '' AND content NOT LIKE '⚠️%'
ORDER BY created_at DESC LIMIT ?`,
[chatId, EXTRACTION_WINDOW * 2],
).reverse();
if (recentMessages.length < 2) {
// Mark as processed and skip — not enough context
db.run(
"UPDATE messages SET memory_processed = 1 WHERE chat_id = ? AND memory_processed = 0",
[chatId],
);
return;
}
const conversationText = recentMessages
.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content.slice(0, 400)}`)
.join("\n\n");
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const response = await fetch(`${server}/v1/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify({
model: memoryModel,
messages: [
{ role: "system", content: EXTRACTION_SYSTEM_PROMPT },
{
role: "user",
content: `Extract memorable facts from this conversation:\n\n${conversationText}`,
},
],
stream: false,
max_tokens: 512,
temperature: 0.1,
}),
});
if (!response.ok) return;
const data = await response.json();
const raw: string = data.choices?.[0]?.message?.content || "";
// Parse JSON from response (strip any markdown fences)
const jsonMatch = raw.match(/\[[\s\S]*\]/);
if (!jsonMatch) return;
const facts: Array<{ key: string; value: string; confidence: number }> =
JSON.parse(jsonMatch[0]);
if (!Array.isArray(facts)) return;
const ts = Math.floor(Date.now() / 1000);
for (const fact of facts) {
if (!fact.key || !fact.value) continue;
const key = String(fact.key).toLowerCase().replace(/[^a-z0-9_]/g, "_").slice(0, 64);
const value = String(fact.value).slice(0, 240);
const confidence = Math.max(0, Math.min(1, Number(fact.confidence) || 0.8));
// Upsert: update if key exists and new confidence is higher, insert otherwise
const existing = db.get<UserMemory>(
"SELECT * FROM user_memories WHERE key = ?",
[key],
);
if (existing) {
if (confidence >= existing.confidence - 0.05) {
db.run(
"UPDATE user_memories SET value = ?, confidence = ?, updated_at = ? WHERE key = ?",
[value, confidence, ts, key],
);
}
} else {
db.run(
"INSERT INTO user_memories (key, value, confidence, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
[key, value, confidence, ts, ts],
);
}
}
// Mark all unprocessed user messages in this chat as done
db.run(
"UPDATE messages SET memory_processed = 1 WHERE chat_id = ? AND memory_processed = 0",
[chatId],
);
} catch {
// Silently fail — memory extraction is non-critical
} finally {
runningRef.current = false;
}
},
[db, server, apiKey, memoryModel, enabled],
);
/**
* Reads all stored memories and formats them as a system prompt string.
* Returns empty string if no memories exist.
*/
const buildMemorySystemPrompt = useCallback((): string => {
const memories = db.query<UserMemory>(
"SELECT key, value, confidence FROM user_memories ORDER BY updated_at DESC LIMIT 40",
);
if (memories.length === 0) return "";
const lines = memories
.filter((m) => m.confidence >= 0.6)
.map((m) => `- ${m.key.replace(/_/g, " ")}: ${m.value}`);
if (lines.length === 0) return "";
return [
"You are a helpful assistant with memory of this user.",
"Known facts about the user:",
...lines,
"",
"Use this context naturally — don't repeat it verbatim, just let it inform your responses.",
].join("\n");
}, [db]);
return { extractMemories, buildMemorySystemPrompt };
}
+33 -3
View File
@@ -1,11 +1,24 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import type { ModelInfo, ServerStatus } from "../lib/types";
import { getNextSelectedModel } from "./model-selection";
import { resolveAlias } from "../lib/modelAlias";
// Tempo de proteção após seleção manual (5 segundos)
const MANUAL_SELECTION_LOCK_MS = 5000;
export function useModels(server: string, apiKey: string, enabled = true) {
const [models, setModels] = useState<ModelInfo[]>([]);
const [selectedModel, setSelectedModel] = useState<string>("");
const [selectedModel, setSelectedModelState] = useState<string>("");
const [status, setStatus] = useState<ServerStatus>("loading");
// Ref para rastrear quando o usuário selecionou manualmente um modelo
const lastManualSelectionRef = useRef<number>(0);
// Wrapper para setSelectedModel que registra o timestamp
const setSelectedModel = useCallback((value: string | ((prev: string) => string)) => {
lastManualSelectionRef.current = Date.now();
setSelectedModelState(value);
}, []);
const refresh = useCallback(async () => {
if (!enabled || !server.trim()) {
@@ -25,7 +38,24 @@ export function useModels(server: string, apiKey: string, enabled = true) {
const data = await res.json();
const list: ModelInfo[] = data.data || [];
setModels(list);
setSelectedModel((current) => getNextSelectedModel(current, list));
// Protege contra sobrescrita de seleção manual recente
const timeSinceManualSelection = Date.now() - lastManualSelectionRef.current;
const isLockActive = timeSinceManualSelection < MANUAL_SELECTION_LOCK_MS;
setSelectedModelState((current) => {
// Se há um lock ativo e o modelo atual existe na nova lista, mantém ele
if (isLockActive && current && list.some((m) => m.id === current)) {
return current;
}
// Prefer Iron (2B) first, then Copper (0.8B), then fallback to normal selection
const iron = list.find((m) => resolveAlias(m.id) === 'Iron');
if (iron) return iron.id;
const copper = list.find((m) => resolveAlias(m.id) === 'Copper');
if (copper) return copper.id;
return getNextSelectedModel(current, list);
});
setStatus("ok");
} catch {
setModels([]);
@@ -0,0 +1,85 @@
import { useCallback, useRef, useState } from "react";
import type { RuntimeModelFile } from "../features/runtime/types";
import { resolveAlias } from "../lib/modelAlias";
import { useToast } from "../components/Toast";
interface Options {
preferredModel: string;
onSwitchToCooper: (cooperPath: string) => void;
localModels: RuntimeModelFile[];
}
interface SessionModelController {
effectiveModel: string;
overrideSource: 'auto' | 'user';
setUserOverride: (modelPath: string) => void;
clearOverride: () => void;
onDegradationDetected: () => void;
degradationPromptShown: boolean;
}
export function useSessionModelController({
preferredModel,
onSwitchToCooper,
localModels,
}: Options): SessionModelController {
const { showToast } = useToast();
const [effectiveModel, setEffectiveModel] = useState<string>(preferredModel);
const [overrideSource, setOverrideSource] = useState<'auto' | 'user'>('auto');
const [degradationPromptShown, setDegradationPromptShown] = useState(false);
// Keep effectiveModel in sync when preferredModel changes externally (e.g. startup auto-select)
// but only if the user hasn't manually overridden
const prevPreferredRef = useRef(preferredModel);
if (preferredModel !== prevPreferredRef.current) {
prevPreferredRef.current = preferredModel;
if (overrideSource === 'auto') {
setEffectiveModel(preferredModel);
}
}
const setUserOverride = useCallback((modelPath: string) => {
setEffectiveModel(modelPath);
setOverrideSource('user');
}, []);
const clearOverride = useCallback(() => {
setEffectiveModel(preferredModel);
setOverrideSource('auto');
}, [preferredModel]);
const onDegradationDetected = useCallback(() => {
// Don't show if user already chose manually or already shown
if (overrideSource === 'user' || degradationPromptShown) return;
// Find Copper's path from localModels
const copperModel = localModels.find((m) => resolveAlias(m.path) === 'Copper');
if (!copperModel) return; // Copper not available, can't suggest
setDegradationPromptShown(true);
showToast({
id: 'tps-degradation',
variant: 'warning',
message: 'Performance degraded',
detail: 'TPS dropped below 30 for 5s. Switch to Copper (0.8B) for faster responses?',
action: {
label: 'Switch to Copper',
onClick: () => {
setUserOverride(copperModel.path);
onSwitchToCooper(copperModel.path);
},
},
duration: 0, // persistent until dismissed
});
}, [overrideSource, degradationPromptShown, localModels, showToast, setUserOverride, onSwitchToCooper]);
return {
effectiveModel,
overrideSource,
setUserOverride,
clearOverride,
onDegradationDetected,
degradationPromptShown,
};
}