E1: split monolithic files — cmd/nomos (main.go → server.go + mcp.go + workers.go),
internal/mcp/tools.go → entity_tools/ops_tools/knowledge_tools/analysis_tools,
internal/httpapi/impl.go → domain files (entities, events, signals, ontology,
fleet_health, client_context, client_lifecycle, entity_mutations, query_audit).
E2: migrate raw pool.Exec queries to sqlc (entities/relationships queries + generated).
E3: unify SSH — consolidate crypto/ssh dial into actuator/client.go (+client_test).
E4/E5: add tests — db/lifecycle, checkdefaults/build, ontology/preconditions, policy/risk.
152 lines
5.4 KiB
Go
152 lines
5.4 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// runChatTurn is the shared core of an operator-initiated turn: insert an
|
|
// assistant placeholder, run a.chat with incremental persistence (so whatever
|
|
// happened before an abort is never lost), finalize the row, and derive a
|
|
// title. It is agnostic to the transport: `sink` receives every agent event
|
|
// for delivery (SSE for a live handleChat, a no-op for a queued turn that has
|
|
// no client attached — the frontend learns about those via the poller + the
|
|
// status-driven "working" signal). The caller MUST already hold the session's
|
|
// turn-gate permit.
|
|
func (a *agent) runChatTurn(pctx, ctx context.Context, sessionID, message string, sink func(agentEvent)) {
|
|
toolCalls := []map[string]any{}
|
|
// P3: accumulate per-iteration reasoning instead of overwriting with the
|
|
// final `text` event (see the original inline comment in handleChat).
|
|
var textParts []string
|
|
var thinkingParts []string
|
|
var finalText string
|
|
var finalThinking string
|
|
|
|
placeholder, _ := json.Marshal(map[string]any{"role": "assistant", "text": ""})
|
|
msgID, err := a.store.insertMessageReturningID(pctx, sessionID, "assistant", placeholder)
|
|
if err != nil {
|
|
slog.Error("nomos: chat placeholder insert failed", "session", sessionID, "error", err)
|
|
}
|
|
persist := func() {
|
|
if msgID == uuid.Nil {
|
|
return
|
|
}
|
|
body, _ := json.Marshal(map[string]any{
|
|
"role": "assistant",
|
|
"text": finalText,
|
|
"thinking": finalThinking,
|
|
"tool_calls": toolCalls,
|
|
})
|
|
a.store.updateMessage(pctx, msgID, body)
|
|
}
|
|
|
|
a.chat(ctx, sessionID, message, func(ev agentEvent) {
|
|
if ev.Type == "tool_use" || ev.Type == "tool_result" {
|
|
if m, ok := ev.Data.(map[string]any); ok {
|
|
m["type"] = ev.Type
|
|
// One entry per tool call: tool_use creates it, tool_result
|
|
// merges the result into the same entry (matched by id).
|
|
id, _ := m["id"].(string)
|
|
if id != "" && ev.Type == "tool_result" {
|
|
for _, existing := range toolCalls {
|
|
if eID, _ := existing["id"].(string); eID == id {
|
|
for k, v := range m {
|
|
existing[k] = v
|
|
}
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
toolCalls = append(toolCalls, m)
|
|
}
|
|
}
|
|
persist() // live: survives even if the client disconnects right after
|
|
}
|
|
if ev.Type == "text" {
|
|
if t, ok := ev.Data.(string); ok && t != "" {
|
|
if ev.IsThinking {
|
|
thinkingParts = append(thinkingParts, t)
|
|
finalThinking = strings.Join(thinkingParts, "\n\n")
|
|
} else {
|
|
textParts = append(textParts, t)
|
|
finalText = strings.Join(textParts, "\n\n")
|
|
}
|
|
persist()
|
|
}
|
|
}
|
|
sink(ev)
|
|
})
|
|
|
|
// B.6: if the turn ended with no text and no tool calls (the model
|
|
// empty-response'd and all retries failed), delete the placeholder row
|
|
// instead of persisting an empty bubble.
|
|
if finalText == "" && len(toolCalls) == 0 && msgID != uuid.Nil {
|
|
a.store.deleteMessage(pctx, msgID)
|
|
} else {
|
|
persist() // final state — same row, updated one last time
|
|
}
|
|
|
|
// Title: prefer the goal once set; else the first assistant answer.
|
|
if finalText != "" && sessionID != "ephemeral" {
|
|
var goalTitle string
|
|
if sess, gerr := a.store.getSession(pctx, sessionID); gerr == nil && sess.Goal != "" {
|
|
goalTitle = truncate(sess.Goal, 120)
|
|
}
|
|
title := goalTitle
|
|
if title == "" {
|
|
title = truncate(finalText, 80)
|
|
}
|
|
if title != "" {
|
|
a.store.updateSessionTitle(pctx, sessionID, title)
|
|
}
|
|
}
|
|
}
|
|
|
|
// drainAcquireWait is how long drainQueued blocks for a busy gate before
|
|
// re-queuing and deferring to the holder's own release-drain. A package var so
|
|
// tests can shorten it; in production it just needs to outlast the brief
|
|
// release→drain handoff window.
|
|
var drainAcquireWait = 5 * time.Second
|
|
|
|
// drainQueued runs every queued operator message for a session as its own turn,
|
|
// one at a time, under the turn gate. Called (in a goroutine) whenever a turn
|
|
// releases the gate — from handleChat (live) and resumeSession (background) —
|
|
// so a message queued while the agent was busy is acted on as soon as it's
|
|
// free, without the operator re-sending. See messagequeue.go (plan 2026-08-03
|
|
// F2).
|
|
//
|
|
// Each queued turn is persisted incrementally and has no SSE client (the
|
|
// browser detached after receiving the `queued` event); the frontend sees the
|
|
// result via the 3s poller and the status-driven "working" indicator.
|
|
func (a *agent) drainQueued(ctx context.Context, sessionID string) {
|
|
for {
|
|
msg, ok := a.queue.dequeue(sessionID)
|
|
if !ok {
|
|
return
|
|
}
|
|
// Block briefly for the gate. If a live turn grabbed it first, put the
|
|
// message back — that turn's release will drain it again. Never stack.
|
|
if !a.gate.acquire(sessionID, drainAcquireWait) {
|
|
a.queue.requeueFront(sessionID, msg)
|
|
return
|
|
}
|
|
slog.Info("nomos: running queued operator message", "session", sessionID)
|
|
pctx := context.Background()
|
|
// Run the turn inside a per-iteration closure so the gate release is
|
|
// deferred to the end of THIS turn (and runs even if runChatTurn
|
|
// panics — safego recovers the panic at the goroutine boundary, so a
|
|
// non-deferred release would be skipped and the session's permit held
|
|
// forever, deadlocking all future turns). A bare `defer release` in
|
|
// the loop would be wrong too: Go defers run at function exit, not
|
|
// iteration exit, so the gate would stay held across iterations.
|
|
func() {
|
|
defer a.gate.release(sessionID)
|
|
a.runChatTurn(pctx, ctx, sessionID, msg, func(agentEvent) {})
|
|
}()
|
|
}
|
|
} |