fix: chat session reliability, cost, and hygiene (empty-response guard, tool truncation, delete, titles)
- 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:
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -15,6 +16,15 @@ import (
|
||||
)
|
||||
|
||||
const maxIterations = 15
|
||||
const maxLLMRetries = 1
|
||||
|
||||
var refusalDenylist = []string{
|
||||
"我没有相关信息",
|
||||
"您可以尝试问我其它问题",
|
||||
"我无法",
|
||||
"抱歉,我无法",
|
||||
"关于这个问题,我没有",
|
||||
}
|
||||
|
||||
type agent struct {
|
||||
client *mcpClient
|
||||
@@ -105,14 +115,6 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
|
||||
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
|
||||
if snapshot := a.fleetSnapshot(); snapshot != "" {
|
||||
system += "\n\n" + snapshot
|
||||
@@ -147,30 +149,54 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
|
||||
Tools: tools,
|
||||
}
|
||||
|
||||
// Stream the completion, emitting token deltas as they arrive. The
|
||||
// accumulator reassembles the full message (content + tool calls) for
|
||||
// the loop's control flow.
|
||||
stream := a.provider.Chat.Completions.NewStreaming(ctx, params, a.reqOpts...)
|
||||
acc := openai.ChatCompletionAccumulator{}
|
||||
for stream.Next() {
|
||||
chunk := stream.Current()
|
||||
acc.AddChunk(chunk)
|
||||
if len(chunk.Choices) > 0 {
|
||||
if delta := chunk.Choices[0].Delta.Content; delta != "" {
|
||||
emit(agentEvent{Type: "text_delta", Data: delta, SessionID: sessionID, Iteration: i + 1})
|
||||
var msg openai.ChatCompletionMessage
|
||||
var acc openai.ChatCompletionAccumulator
|
||||
|
||||
for attempt := 0; attempt <= maxLLMRetries; attempt++ {
|
||||
acc = openai.ChatCompletionAccumulator{}
|
||||
stream := a.provider.Chat.Completions.NewStreaming(ctx, params, a.reqOpts...)
|
||||
for stream.Next() {
|
||||
chunk := stream.Current()
|
||||
acc.AddChunk(chunk)
|
||||
if len(chunk.Choices) > 0 {
|
||||
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 {
|
||||
emit(agentEvent{Type: "error", Data: fmt.Sprintf("llm: %v", err), SessionID: sessionID})
|
||||
return
|
||||
}
|
||||
if len(acc.Choices) == 0 {
|
||||
emit(agentEvent{Type: "error", Data: "no choices in response", SessionID: sessionID})
|
||||
return
|
||||
}
|
||||
if err := stream.Err(); err != nil {
|
||||
if attempt < maxLLMRetries {
|
||||
slog.Warn("nomos: llm stream error, retrying", "error", err, "attempt", attempt+1, "session", sessionID)
|
||||
continue
|
||||
}
|
||||
emit(agentEvent{Type: "error", Data: fmt.Sprintf("llm: %v", err), SessionID: sessionID})
|
||||
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 {
|
||||
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID})
|
||||
@@ -388,6 +414,33 @@ func (a *agent) fleetSnapshot() string {
|
||||
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) {
|
||||
defs, err := a.client.listToolsFull()
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user