fix: chat session reliability, cost, and hygiene (empty-response guard, tool truncation, delete, titles)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

- Empty/refusal responses retried once, then surfaced as errors instead of silent blanks
- Chinese refusal boilerplate detected via denylist + non-ASCII heuristic
- Bulk-tool preference added to SOUL.md (list_lxcs over per-entity get_lxc_state)
- Tool results truncated to 4KB on persist; get_state_snapshot filters null-state entities
- Session delete (DELETE /sessions/{id} + confirm-on-second-click UI)
- Session titles auto-generated from assistant answer instead of raw user message
This commit is contained in:
2026-07-09 10:18:06 +02:00
parent 614c38ea7c
commit 49c37fe8b1
9 changed files with 335 additions and 60 deletions

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -15,6 +16,15 @@ import (
) )
const maxIterations = 15 const maxIterations = 15
const maxLLMRetries = 1
var refusalDenylist = []string{
"我没有相关信息",
"您可以尝试问我其它问题",
"我无法",
"抱歉,我无法",
"关于这个问题,我没有",
}
type agent struct { type agent struct {
client *mcpClient client *mcpClient
@@ -105,14 +115,6 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
return return
} }
// Rebuild conversation context from persisted history so sessions are
// multi-turn. The current user turn is saved by the HTTP handler before
// this runs, so it is already included in the history for real sessions.
// Prior tool_use/tool_result pairs are replayed as a tool-calling
// assistant message followed by matching tool-role results, so the agent
// starts each turn already knowing what it already checked instead of
// re-querying the same tools from scratch. Ephemeral sessions (no store)
// fall back to the single incoming message.
system := a.system system := a.system
if snapshot := a.fleetSnapshot(); snapshot != "" { if snapshot := a.fleetSnapshot(); snapshot != "" {
system += "\n\n" + snapshot system += "\n\n" + snapshot
@@ -147,30 +149,54 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
Tools: tools, Tools: tools,
} }
// Stream the completion, emitting token deltas as they arrive. The var msg openai.ChatCompletionMessage
// accumulator reassembles the full message (content + tool calls) for var acc openai.ChatCompletionAccumulator
// the loop's control flow.
stream := a.provider.Chat.Completions.NewStreaming(ctx, params, a.reqOpts...) for attempt := 0; attempt <= maxLLMRetries; attempt++ {
acc := openai.ChatCompletionAccumulator{} acc = openai.ChatCompletionAccumulator{}
for stream.Next() { stream := a.provider.Chat.Completions.NewStreaming(ctx, params, a.reqOpts...)
chunk := stream.Current() for stream.Next() {
acc.AddChunk(chunk) chunk := stream.Current()
if len(chunk.Choices) > 0 { acc.AddChunk(chunk)
if delta := chunk.Choices[0].Delta.Content; delta != "" { if len(chunk.Choices) > 0 {
emit(agentEvent{Type: "text_delta", Data: delta, SessionID: sessionID, Iteration: i + 1}) if delta := chunk.Choices[0].Delta.Content; delta != "" {
emit(agentEvent{Type: "text_delta", Data: delta, SessionID: sessionID, Iteration: i + 1})
}
} }
} }
} if err := stream.Err(); err != nil {
if err := stream.Err(); err != nil { if attempt < maxLLMRetries {
emit(agentEvent{Type: "error", Data: fmt.Sprintf("llm: %v", err), SessionID: sessionID}) slog.Warn("nomos: llm stream error, retrying", "error", err, "attempt", attempt+1, "session", sessionID)
return continue
} }
if len(acc.Choices) == 0 { emit(agentEvent{Type: "error", Data: fmt.Sprintf("llm: %v", err), SessionID: sessionID})
emit(agentEvent{Type: "error", Data: "no choices in response", SessionID: sessionID}) return
return }
} if len(acc.Choices) == 0 {
if attempt < maxLLMRetries {
slog.Warn("nomos: no choices in response, retrying", "attempt", attempt+1, "session", sessionID)
continue
}
emit(agentEvent{Type: "error", Data: "no choices in response", SessionID: sessionID})
return
}
msg := acc.Choices[0].Message msg = acc.Choices[0].Message
if len(msg.ToolCalls) == 0 {
if isRefusalOrEmpty(msg.Content) {
if attempt < maxLLMRetries {
slog.Warn("nomos: empty or refusal response, retrying",
"session", sessionID, "iter", i+1, "attempt", attempt+1,
"content_len", len(msg.Content))
continue
}
emit(agentEvent{Type: "error", Data: "Nomos returned an empty or unusable response — please retry.", SessionID: sessionID})
return
}
}
break
}
if len(msg.ToolCalls) == 0 { if len(msg.ToolCalls) == 0 {
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID}) emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID})
@@ -388,6 +414,33 @@ func (a *agent) fleetSnapshot() string {
return summary return summary
} }
// isRefusalOrEmpty returns true when the LLM response is blank or looks like a
// canned non-English refusal to an English-language conversation. Flash-tier
// models occasionally emit Chinese boilerplate deflection instead of a real
// answer; this catches it before it reaches the UI.
func isRefusalOrEmpty(text string) bool {
if strings.TrimSpace(text) == "" {
return true
}
ascii, nonASCII := 0, 0
for _, r := range text {
if r <= 127 {
ascii++
} else {
nonASCII++
}
}
if nonASCII > ascii {
return true
}
for _, pattern := range refusalDenylist {
if strings.Contains(text, pattern) {
return true
}
}
return false
}
func (a *agent) buildTools() ([]openai.ChatCompletionToolParam, error) { func (a *agent) buildTools() ([]openai.ChatCompletionToolParam, error) {
defs, err := a.client.listToolsFull() defs, err := a.client.listToolsFull()
if err != nil { if err != nil {

View File

@@ -186,6 +186,15 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
"tool_calls": toolCalls, "tool_calls": toolCalls,
}) })
st.saveMessage(ctx, sessionID, "assistant", assistantMsg) st.saveMessage(ctx, sessionID, "assistant", assistantMsg)
// Generate a meaningful title from the assistant's first answer
// instead of reusing the raw user message for every session.
if finalText != "" && sessionID != "ephemeral" {
title := truncate(finalText, 80)
if title != "" {
st.updateSessionTitle(ctx, sessionID, title)
}
}
} }
func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) { func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) {
@@ -220,13 +229,26 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store) {
return return
} }
messages, err := st.getMessages(r.Context(), id) switch r.Method {
if err != nil { case http.MethodDelete:
http.Error(w, err.Error(), 500) if err := st.deleteSession(r.Context(), id); err != nil {
return http.Error(w, err.Error(), 500)
return
}
w.WriteHeader(204)
case http.MethodGet:
messages, err := st.getMessages(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"session_id": id, "messages": messages})
default:
http.Error(w, "method not allowed", 405)
} }
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"session_id": id, "messages": messages})
} }
func handleQuery(w http.ResponseWriter, r *http.Request, client *mcpClient, agentSlug, mcpURL string) { func handleQuery(w http.ResponseWriter, r *http.Request, client *mcpClient, agentSlug, mcpURL string) {

View File

@@ -10,6 +10,8 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
const maxToolResultSize = 4096
type store struct { type store struct {
pool *pgxpool.Pool pool *pgxpool.Pool
} }
@@ -71,10 +73,45 @@ func (s *store) saveMessage(ctx context.Context, sessionID, role string, content
} }
_, err := s.pool.Exec(ctx, _, err := s.pool.Exec(ctx,
`INSERT INTO agent_messages (session_id, role, content) VALUES ($1, $2, $3)`, `INSERT INTO agent_messages (session_id, role, content) VALUES ($1, $2, $3)`,
sessionID, role, content) sessionID, role, truncateToolResults(content))
return err return err
} }
func truncateToolResults(content json.RawMessage) json.RawMessage {
var m map[string]any
if err := json.Unmarshal(content, &m); err != nil {
return content
}
toolCalls, ok := m["tool_calls"].([]any)
if !ok || len(toolCalls) == 0 {
return content
}
changed := false
for i, raw := range toolCalls {
tc, ok := raw.(map[string]any)
if !ok {
continue
}
if result, ok := tc["result"]; ok {
resultJSON, _ := json.Marshal(result)
if len(resultJSON) > maxToolResultSize {
tc["result"] = string(resultJSON[:maxToolResultSize]) + fmt.Sprintf("...truncated (%d bytes total)", len(resultJSON))
toolCalls[i] = tc
changed = true
}
}
}
if !changed {
return content
}
m["tool_calls"] = toolCalls
out, err := json.Marshal(m)
if err != nil {
return content
}
return out
}
func (s *store) touchSession(ctx context.Context, id string) { func (s *store) touchSession(ctx context.Context, id string) {
if s != nil { if s != nil {
s.pool.Exec(ctx, `UPDATE agent_sessions SET last_active_at=now() WHERE id=$1`, id) s.pool.Exec(ctx, `UPDATE agent_sessions SET last_active_at=now() WHERE id=$1`, id)
@@ -126,6 +163,26 @@ func (s *store) getMessages(ctx context.Context, sessionID string) ([]message, e
return out, rows.Err() return out, rows.Err()
} }
func (s *store) deleteSession(ctx context.Context, id string) error {
if s == nil {
return nil
}
_, err := s.pool.Exec(ctx, `DELETE FROM agent_messages WHERE session_id = $1`, id)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `DELETE FROM agent_sessions WHERE id = $1`, id)
return err
}
func (s *store) updateSessionTitle(ctx context.Context, id, title string) error {
if s == nil {
return nil
}
_, err := s.pool.Exec(ctx, `UPDATE agent_sessions SET title = $1 WHERE id = $2`, title, id)
return err
}
// resolveAgentID looks up the UUID of the agent entity (e.g. "agent:nomos"). // resolveAgentID looks up the UUID of the agent entity (e.g. "agent:nomos").
// Returns uuid.Nil if the store is absent or the slug is unknown. // Returns uuid.Nil if the store is absent or the slug is unknown.
func (s *store) resolveAgentID(ctx context.Context, slug string) uuid.UUID { func (s *store) resolveAgentID(ctx context.Context, slug string) uuid.UUID {

View File

@@ -698,6 +698,8 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
COALESCE(st.last_check_at::text, '') AS last_check COALESCE(st.last_check_at::text, '') AS last_check
FROM entities e FROM entities e
LEFT JOIN entity_status st ON st.entity_id = e.id LEFT JOIN entity_status st ON st.entity_id = e.id
WHERE e.state IS NOT NULL
OR st.health IS NOT NULL
ORDER BY st.health, e.slug ORDER BY st.health, e.slug
LIMIT 200 LIMIT 200
`), nil `), nil

View File

@@ -23,14 +23,30 @@ the actuator (a separate container with restricted SSH key) picks up.
## Key MCP tools ## Key MCP tools
- `get_entity`, `list_entities` — resolve slugs to state - `list_lxcs` — all LXC containers with host, IP, health (use for fleet-wide questions)
- `get_lxc_state` — per-container `pct status` (use only for a specific named container)
- `get_state_snapshot` — fleet health, disk, drift at a glance
- `get_health_summary` — fleet health counts
- `query_metrics` — time-series metrics (prefer over per-entity `get_trend` for fleet-wide)
- `list_entities` — resolve slugs to state (pass `type` filter when possible)
- `get_entity` — single-entity detail
- `get_blast_radius` — understand impact before requesting action - `get_blast_radius` — understand impact before requesting action
- `get_health_summary` — fleet status at a glance
- `get_signal_history` — open alerts - `get_signal_history` — open alerts
- `get_trend` — metric trends for decisions - `get_trend` — metric trends for a specific entity (single-entity only)
- `request_execution` — the ONLY mutation path - `request_execution` — the ONLY mutation path
- `get_agent_activity` — your own behavior log - `get_agent_activity` — your own behavior log
### Tool selection rules
- **Fleet-wide questions** (e.g. "which hosts are saturated?", "what needs updating?"):
prefer bulk tools: `list_lxcs`, `get_health_summary`, `get_state_snapshot`,
`query_metrics`. Only fall back to per-entity tools (`get_lxc_state`, `tail_log`,
`get_trend`) for a specific named entity the user asked about.
- **One call > many calls**: each `get_lxc_state` is a live SSH round-trip.
`list_lxcs` answers the same question in one call. Use it.
- When a bulk tool's summary isn't enough for a specific entity, call the
per-entity tool for that one entity — not for every entity in the fleet.
## Policy awareness ## Policy awareness
Before calling `request_execution`: Before calling `request_execution`:

View File

@@ -31,6 +31,11 @@ export async function fetchMessages(sessionId: string): Promise<Message[]> {
return data.messages ?? [] return data.messages ?? []
} }
export async function deleteSession(sessionId: string): Promise<boolean> {
const res = await fetch(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' })
return res.ok
}
export interface ChatEvent { export interface ChatEvent {
type: string type: string
data: any data: any

View File

@@ -1,20 +1,39 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { sessions, currentSession, loadSessions, loadSessionMessages, newChat } from '$lib/stores/chat' import { sessions, currentSession, loadSessions, loadSessionMessages, newChat, deleteSession } from '$lib/stores/chat'
import { relativeTime } from '$lib/utils' import { relativeTime } from '$lib/utils'
import { Button } from '$lib/components/ui/button' import { Button } from '$lib/components/ui/button'
import { ScrollArea } from '$lib/components/ui/scroll-area' import { ScrollArea } from '$lib/components/ui/scroll-area'
import PlusIcon from '@lucide/svelte/icons/plus' import PlusIcon from '@lucide/svelte/icons/plus'
import Trash2Icon from '@lucide/svelte/icons/trash2'
onMount(() => { onMount(() => {
loadSessions() loadSessions()
}) })
// Pick up sessions created/renamed elsewhere (e.g. after a turn completes).
$effect(() => { $effect(() => {
void $currentSession void $currentSession
loadSessions() loadSessions()
}) })
let confirmDelete = $state<string | null>(null)
function handleDelete(e: MouseEvent, id: string) {
e.stopPropagation()
if (confirmDelete === id) {
deleteSession(id)
confirmDelete = null
} else {
confirmDelete = id
// Hide confirmation after 3s
setTimeout(() => { if (confirmDelete === id) confirmDelete = null }, 3000)
}
}
function handleClick(sessionId: string) {
confirmDelete = null
loadSessionMessages(sessionId)
}
</script> </script>
<aside class="flex h-full w-56 shrink-0 flex-col gap-2 overflow-y-auto border-r bg-card/50 p-2"> <aside class="flex h-full w-56 shrink-0 flex-col gap-2 overflow-y-auto border-r bg-card/50 p-2">
@@ -27,10 +46,23 @@
{#each $sessions as session (session.id)} {#each $sessions as session (session.id)}
<button <button
type="button" type="button"
class="flex flex-col items-start gap-0.5 rounded-md border px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/60 {$currentSession === session.id ? 'border-primary bg-muted/50' : 'border-transparent'}" class="group flex flex-col items-start gap-0.5 rounded-md border px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/60 {$currentSession === session.id ? 'border-primary bg-muted/50' : 'border-transparent'}"
onclick={() => loadSessionMessages(session.id)} onclick={() => handleClick(session.id)}
> >
<span class="w-full truncate font-medium">{session.title || 'Untitled'}</span> <span class="flex w-full items-center justify-between gap-1">
<span class="min-w-0 truncate font-medium">{session.title || 'Untitled'}</span>
<span
class="shrink-0 rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-destructive/20 hover:text-destructive"
onclick={(e) => handleDelete(e, session.id)}
title={confirmDelete === session.id ? 'Click again to confirm delete' : 'Delete session'}
>
{#if confirmDelete === session.id}
<span class="text-[10px] font-semibold text-destructive">Sure?</span>
{:else}
<Trash2Icon class="size-3" />
{/if}
</span>
</span>
<span class="text-[11px] text-muted-foreground">{relativeTime(session.last_active_at)}</span> <span class="text-[11px] text-muted-foreground">{relativeTime(session.last_active_at)}</span>
</button> </button>
{:else} {:else}

View File

@@ -1,5 +1,5 @@
import { writable, get } from 'svelte/store' import { writable, get } from 'svelte/store'
import { streamChat, fetchSessions, fetchMessages } from '$lib/api' import { streamChat, fetchSessions, fetchMessages, deleteSession as apiDeleteSession } from '$lib/api'
import type { ChatEvent, Session, Message } from '$lib/api' import type { ChatEvent, Session, Message } from '$lib/api'
export interface ChatMessage { export interface ChatMessage {
@@ -177,3 +177,12 @@ export function cancelStream() {
streaming.set(false) streaming.set(false)
} }
} }
export async function deleteSession(sessionId: string) {
const ok = await apiDeleteSession(sessionId)
if (!ok) return
if (get(currentSession) === sessionId) {
newChat()
}
loadSessions()
}

View File

@@ -1,29 +1,59 @@
<script lang="ts"> <script lang="ts">
import { sessions, loadSessions, loadSessionMessages, messages, currentSession } from '$lib/stores/chat' import { sessions, loadSessions, loadSessionMessages, deleteSession, currentSession } from '$lib/stores/chat'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import Trash2Icon from '@lucide/svelte/icons/trash2'
onMount(() => { onMount(() => {
loadSessions() loadSessions()
}) })
let confirmDelete = $state<string | null>(null)
function handleDelete(e: MouseEvent, id: string) {
e.stopPropagation()
if (confirmDelete === id) {
deleteSession(id)
confirmDelete = null
} else {
confirmDelete = id
setTimeout(() => { if (confirmDelete === id) confirmDelete = null }, 3000)
}
}
</script> </script>
<div class="sessions-page"> <div class="sessions-page">
<h2>Sessions</h2> <h2>Sessions</h2>
<div class="session-list"> <div class="session-list">
{#each $sessions as session (session.id)} {#each $sessions as session (session.id)}
<button <div
class="session-card" class="session-card group"
class:active={$currentSession === session.id} class:active={$currentSession === session.id}
onclick={() => {
loadSessionMessages(session.id)
location.hash = '#/chat'
}}
> >
<div class="session-title">{session.title || 'Untitled'}</div> <button
<div class="session-meta"> class="session-content"
{new Date(session.last_active_at).toLocaleString()} onclick={() => {
</div> loadSessionMessages(session.id)
</button> location.hash = '#/chat'
}}
>
<div class="session-title">{session.title || 'Untitled'}</div>
<div class="session-meta">
{new Date(session.last_active_at).toLocaleString()}
</div>
</button>
<button
class="session-delete"
onclick={(e) => handleDelete(e, session.id)}
title={confirmDelete === session.id ? 'Click again to confirm delete' : 'Delete session'}
aria-label="Delete session"
>
{#if confirmDelete === session.id}
<span class="confirm-text">Delete?</span>
{:else}
<Trash2Icon class="icon" />
{/if}
</button>
</div>
{:else} {:else}
<div class="empty">No sessions yet. Start chatting with Nomos.</div> <div class="empty">No sessions yet. Start chatting with Nomos.</div>
{/each} {/each}
@@ -54,15 +84,12 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 0.75rem 1rem;
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
cursor: pointer;
color: var(--text); color: var(--text);
font-family: inherit; font-family: inherit;
font-size: 0.875rem; font-size: 0.875rem;
text-align: left;
transition: border-color 0.15s; transition: border-color 0.15s;
} }
@@ -75,6 +102,58 @@
background: var(--bg-hover); background: var(--bg-hover);
} }
.session-content {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: none;
border: none;
cursor: pointer;
color: inherit;
font-family: inherit;
font-size: inherit;
text-align: left;
}
.session-delete {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin-right: 4px;
padding: 0;
background: none;
border: none;
border-radius: 6px;
cursor: pointer;
color: var(--text-muted);
opacity: 0;
transition: opacity 0.15s, background 0.15s;
}
.group:hover .session-delete {
opacity: 1;
}
.session-delete:hover {
background: var(--destructive-bg-subtle, rgba(239, 68, 68, 0.1));
color: var(--destructive, #ef4444);
}
.confirm-text {
font-size: 0.65rem;
font-weight: 700;
color: var(--destructive, #ef4444);
}
.icon {
width: 16px;
height: 16px;
}
.session-title { .session-title {
font-weight: 500; font-weight: 500;
} }