feat: Phase 8 — nomos packages extracted into internal/nomos/{turngate,retrycap,messagequeue,assent,session}
Mechanical extraction of nomos internal components into plain-Go subpackages
per the hexagonal plan (ADR 0016 §3.1 rule 3):
turngate/ — per-session turn serialization (plan 2026-08-03 F1)
retrycap/ — per-turn run retry cap (maxRunRetries=3)
messagequeue/ — operator-message queue for busy-turn re-entry (F2)
assent/ — chat-assent detection (isAssent, isTypedConfirmation,
ExtractPendingApprovals), decoupled from agent via
[]string input instead of persistedCall
session/ — store (chat sessions, plan execution, DB persistence),
migration runner + local emitEvent to break adapter
dependency
internal/migrate/ — shared migration runner extracted from postgres pool,
used by both the oikos postgres adapter and session tests.
session package export-rename finishing touches remain; the four smaller
packages compile with passing tests. Depguard rules and ADR-0016 leaf-note
update deferred to a followup. VERSION 0.35.1.
This commit is contained in:
@@ -10,6 +10,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dtoro/oikos/internal/nomos/assent"
|
||||
"github.com/dtoro/oikos/internal/nomos/messagequeue"
|
||||
"github.com/dtoro/oikos/internal/nomos/retrycap"
|
||||
"github.com/dtoro/oikos/internal/nomos/session"
|
||||
"github.com/dtoro/oikos/internal/nomos/turngate"
|
||||
"github.com/google/uuid"
|
||||
"github.com/openai/openai-go"
|
||||
"github.com/openai/openai-go/option"
|
||||
@@ -51,7 +56,7 @@ type agent struct {
|
||||
system string
|
||||
provider *openai.Client
|
||||
model string
|
||||
store *store
|
||||
store *session.Store
|
||||
agentID uuid.UUID
|
||||
reqOpts []option.RequestOption
|
||||
apiBase string // oikos HTTP API base, derived from NOMOS_MCP_URL, for chat-assent approvals
|
||||
@@ -59,14 +64,14 @@ type agent struct {
|
||||
httpClient *http.Client
|
||||
// gate serializes turns per session (at most one in-flight turn per
|
||||
// sessionID). See turngate.go and plan 2026-08-03 F1.
|
||||
gate *turnGate
|
||||
gate *turngate.TurnGate
|
||||
// queue holds operator messages that arrived while a turn was already
|
||||
// running; they are auto-run when the gate frees (plan 2026-08-03 F2).
|
||||
// See messagequeue.go.
|
||||
queue *messageQueue
|
||||
queue *messagequeue.MessageQueue
|
||||
}
|
||||
|
||||
func newAgent(ctx context.Context, clients *mcpClientPool, st *store, agentSlug string, openrouterAPIKey string) (*agent, error) {
|
||||
func newAgent(ctx context.Context, clients *mcpClientPool, st *session.Store, agentSlug string, openrouterAPIKey string) (*agent, error) {
|
||||
system := loadSoul()
|
||||
apiKey := openrouterAPIKey
|
||||
model := os.Getenv("NOMOS_MODEL")
|
||||
@@ -82,7 +87,7 @@ func newAgent(ctx context.Context, clients *mcpClientPool, st *store, agentSlug
|
||||
option.WithAPIKey(apiKey),
|
||||
)
|
||||
|
||||
agentID := st.resolveAgentID(ctx, agentSlug)
|
||||
agentID := st.ResolveAgentID(ctx, agentSlug)
|
||||
if agentID == uuid.Nil {
|
||||
slog.Warn("nomos: agent entity not found; tool-call activity will not be logged", "slug", agentSlug)
|
||||
}
|
||||
@@ -124,8 +129,8 @@ func newAgent(ctx context.Context, clients *mcpClientPool, st *store, agentSlug
|
||||
apiBase: apiBase,
|
||||
apiToken: os.Getenv("OIKOS_MCP_BEARER_TOKEN"),
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
gate: newTurnGate(),
|
||||
queue: newMessageQueue(),
|
||||
gate: turngate.New(),
|
||||
queue: messagequeue.New(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -155,12 +160,12 @@ const assentWindowDuration = 30 * time.Minute
|
||||
// session dimension, approving one task's plan would silently auto-run
|
||||
// unapproved actions in any other concurrently-running task.
|
||||
func (a *agent) openAssentWindow(ctx context.Context, sessionID string) {
|
||||
if a.store == nil || a.store.pool == nil || a.agentID == uuid.Nil || sessionID == "" {
|
||||
if a.store == nil || a.agentID == uuid.Nil || sessionID == "" {
|
||||
return
|
||||
}
|
||||
key := assentWindowKey(a.agentID, sessionID)
|
||||
key := session.AssentWindowKey(a.agentID, sessionID)
|
||||
expires := time.Now().Add(assentWindowDuration).UTC().Format(time.RFC3339)
|
||||
_, err := a.store.pool.Exec(ctx,
|
||||
_, err := a.store.Exec(ctx,
|
||||
`INSERT INTO autonomy_settings (key, value) VALUES ($1, $2)
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2`, key, expires)
|
||||
if err != nil {
|
||||
@@ -233,7 +238,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
system += "\n\n" + snapshot
|
||||
}
|
||||
messages := []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(system)}
|
||||
history, truncatedHistory, _ := a.store.getRecentMessages(ctx, sessionID, historyWindowSize)
|
||||
history, truncatedHistory, _ := a.store.GetRecentMessages(ctx, sessionID, historyWindowSize)
|
||||
if truncatedHistory {
|
||||
// Tell the model explicitly rather than silently dropping older
|
||||
// turns — otherwise it might assume something wasn't done just
|
||||
@@ -289,36 +294,42 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
// actions are never granted by loose assent — they need the stricter
|
||||
// isTypedConfirmation ("I confirm ...", per SOUL.md's guidance for what
|
||||
// to ask the operator to type).
|
||||
pending := extractPendingApprovals(lastAssistantCalls)
|
||||
assent := isAssent(message)
|
||||
typedConfirm := isTypedConfirmation(message)
|
||||
if len(pending) > 0 && (assent || typedConfirm) {
|
||||
pending := assent.ExtractPendingApprovals(func() []string {
|
||||
texts := make([]string, len(lastAssistantCalls))
|
||||
for i, c := range lastAssistantCalls {
|
||||
texts[i] = c.resultText()
|
||||
}
|
||||
return texts
|
||||
}())
|
||||
operatorAssented := assent.IsAssent(message)
|
||||
typedConfirm := assent.IsTypedConfirmation(message)
|
||||
if len(pending) > 0 && (operatorAssented || typedConfirm) {
|
||||
var granted, blocked []string
|
||||
for _, p := range pending {
|
||||
if p.destructive && !typedConfirm {
|
||||
blocked = append(blocked, p.execID)
|
||||
if p.Destructive && !typedConfirm {
|
||||
blocked = append(blocked, p.ExecID)
|
||||
continue
|
||||
}
|
||||
if !p.destructive && !assent {
|
||||
if !p.Destructive && !operatorAssented {
|
||||
continue // typed-confirm alone doesn't grant a non-destructive item without also reading as assent
|
||||
}
|
||||
ok, status, aerr := a.approveExecution(ctx, p.execID)
|
||||
ok, status, aerr := a.approveExecution(ctx, p.ExecID)
|
||||
if aerr != nil {
|
||||
slog.Error("nomos: chat-assent approve", "execution", p.execID, "error", aerr)
|
||||
slog.Error("nomos: chat-assent approve", "execution", p.ExecID, "error", aerr)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
granted = append(granted, p.execID)
|
||||
slog.Info("nomos: chat-assent granted", "execution", p.execID, "status", status, "session", sessionID)
|
||||
granted = append(granted, p.ExecID)
|
||||
slog.Info("nomos: chat-assent granted", "execution", p.ExecID, "status", status, "session", sessionID)
|
||||
|
||||
// An explicit typed confirmation for a destructive action
|
||||
// opens a short, target-scoped window so the rest of a
|
||||
// destructive recovery sequence on the SAME target (e.g.
|
||||
// stop -> destroy) doesn't need a second typed confirmation.
|
||||
if p.destructive && typedConfirm {
|
||||
if execUUID, perr := uuid.Parse(p.execID); perr == nil {
|
||||
if target := a.store.executionTarget(ctx, execUUID); target != "" {
|
||||
a.store.openDestructiveWindow(ctx, a.agentID, target, sessionID)
|
||||
if p.Destructive && typedConfirm {
|
||||
if execUUID, perr := uuid.Parse(p.ExecID); perr == nil {
|
||||
if target := a.store.ExecutionTarget(ctx, execUUID); target != "" {
|
||||
a.store.OpenDestructiveWindow(ctx, a.agentID, target, sessionID)
|
||||
slog.Info("nomos: destructive window opened", "agent", a.agentID, "target", target, "session", sessionID)
|
||||
}
|
||||
}
|
||||
@@ -333,7 +344,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
// same session cause empty responses and race conditions.
|
||||
for _, execID := range granted {
|
||||
if execUUID, perr := uuid.Parse(execID); perr == nil {
|
||||
a.store.markContinued(ctx, execUUID)
|
||||
a.store.MarkContinued(ctx, execUUID)
|
||||
}
|
||||
}
|
||||
// No system note. The model already sees "go ahead" in the
|
||||
@@ -348,7 +359,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
note := fmt.Sprintf("[System: execution(s) %s are classified DESTRUCTIVE and were NOT approved by loose assent — you must ask the operator for an explicit typed confirmation before they can run. Once they do confirm, further destructive steps on that SAME target (e.g. finishing a stop-then-destroy sequence) will auto-run for 15 minutes without asking again — but a different target always needs its own confirmation.]", strings.Join(blocked, ", "))
|
||||
messages = append(messages, openai.SystemMessage(note))
|
||||
}
|
||||
} else if assent && len(pending) == 0 {
|
||||
} else if operatorAssented && len(pending) == 0 {
|
||||
// The operator said "proceed"/"go ahead"/"yes" but there are no
|
||||
// pending approvals — the agent proposed a plan (via propose_plan)
|
||||
// and asked "shall I?" Open the assent window silently. No system
|
||||
@@ -365,12 +376,12 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
|
||||
// Retry cap (P0.1 from plans/2026-07-18-session-review-three-sessions.md):
|
||||
// track failing `run` calls within this turn so an identical command that
|
||||
// keeps failing is refused after maxRunRetries attempts. Without this,
|
||||
// keeps failing is refused after retrycap.MaxRunRetries attempts. Without this,
|
||||
// session 1e9c7691 retried the same `chown` ~20 times, each retry piling
|
||||
// up a zombie process on the target (knfsd was holding a kernel lock).
|
||||
// The tracker is per-turn — a fresh turn after the operator responds can
|
||||
// retry once more, so this doesn't permanently block recovery.
|
||||
retries := newRunRetryTracker()
|
||||
retries := retrycap.New()
|
||||
|
||||
for i := 0; i < maxIterations; i++ {
|
||||
params := openai.ChatCompletionNewParams{
|
||||
@@ -501,7 +512,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
}
|
||||
|
||||
// Retry cap: if this `run` call has already failed
|
||||
// maxRunRetries times this turn with the same (target,
|
||||
// retrycap.MaxRunRetries times this turn with the same (target,
|
||||
// command), refuse to dispatch it again. Return a synthetic
|
||||
// tool result directing the agent to investigate *why* the
|
||||
// command hangs instead of retrying. See retrycap.go and
|
||||
@@ -509,12 +520,12 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
if tc.Function.Name == "run" {
|
||||
t, _ := args["target"].(string)
|
||||
c, _ := args["command"].(string)
|
||||
key := runFailureKey(t, c)
|
||||
if n := retries.failures(key); n >= maxRunRetries {
|
||||
directive := runRetryDirective(t, c, n)
|
||||
key := retrycap.RunFailureKey(t, c)
|
||||
if n := retries.Failures(key); n >= retrycap.MaxRunRetries {
|
||||
directive := retrycap.RunRetryDirective(t, c, n)
|
||||
slog.Warn("nomos: run retry cap hit — refusing dispatch",
|
||||
"target", t, "failures", n, "session", sessionID)
|
||||
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args,
|
||||
a.store.LogActivity(ctx, a.agentID, sessionID, tc.Function.Name, args,
|
||||
tc.Function.Arguments, directive, 0, false, correlationID, totalTokens)
|
||||
emit(agentEvent{
|
||||
Type: "tool_result",
|
||||
@@ -566,7 +577,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
inputStr := string(inputJSON)
|
||||
|
||||
if callErr != nil {
|
||||
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, callErr.Error(), elapsed, false, correlationID, totalTokens)
|
||||
a.store.LogActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, callErr.Error(), elapsed, false, correlationID, totalTokens)
|
||||
|
||||
// Retry cap: dispatch errors (e.g. MCP client timeout)
|
||||
// count toward the cap too. A command that keeps timing
|
||||
@@ -576,9 +587,9 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
if tc.Function.Name == "run" {
|
||||
t, _ := args["target"].(string)
|
||||
c, _ := args["command"].(string)
|
||||
key := runFailureKey(t, c)
|
||||
n := retries.recordFailure(key)
|
||||
if n >= maxRunRetries {
|
||||
key := retrycap.RunFailureKey(t, c)
|
||||
n := retries.RecordFailure(key)
|
||||
if n >= retrycap.MaxRunRetries {
|
||||
slog.Warn("nomos: run failure cap reached — next identical call will be refused",
|
||||
"target", t, "failures", n, "session", sessionID)
|
||||
}
|
||||
@@ -596,7 +607,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
}
|
||||
|
||||
resultJSON, _ := json.Marshal(result)
|
||||
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, string(resultJSON), elapsed, true, correlationID, totalTokens)
|
||||
a.store.LogActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, string(resultJSON), elapsed, true, correlationID, totalTokens)
|
||||
|
||||
// Link any execution this tool queued/started back to this
|
||||
// session, so the auto-continuation worker can feed its result
|
||||
@@ -604,18 +615,18 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
// executions (pct_create, apt_upgrade) are the ones that matter —
|
||||
// their result lands after this turn ends.
|
||||
for _, execID := range extractExecutionIDs(string(resultJSON)) {
|
||||
a.store.linkExecution(ctx, execID, sessionID)
|
||||
a.store.LinkExecution(ctx, execID, sessionID)
|
||||
}
|
||||
|
||||
// Record which entities this task touched (task —involves→ entity)
|
||||
// and pulse them on the live context panel. Args only — never
|
||||
// results — so a bulk query doesn't drag the whole fleet in.
|
||||
a.store.recordTouched(ctx, sessionID, tc.Function.Name, args)
|
||||
a.store.RecordTouched(ctx, sessionID, tc.Function.Name, args)
|
||||
|
||||
// When the agent records knowledge, link that note to this task so
|
||||
// the task's outcome view shows what it learned (and pulse it live).
|
||||
if tc.Function.Name == "upsert_knowledge" {
|
||||
a.store.linkKnowledgeToTask(ctx, sessionID, string(resultJSON))
|
||||
a.store.LinkKnowledgeToTask(ctx, sessionID, string(resultJSON))
|
||||
}
|
||||
|
||||
emit(agentEvent{
|
||||
@@ -635,12 +646,12 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
// queued for approval (pending approvals are not failures).
|
||||
// Pass the RAW result text (not JSON-encoded) so the helper's
|
||||
// HasPrefix check sees "run on …" not "\"run on …\"".
|
||||
if isRunFailure(tc.Function.Name, runResultText(result), callErr) {
|
||||
if retrycap.IsRunFailure(tc.Function.Name, retrycap.RunResultText(result), callErr) {
|
||||
t, _ := args["target"].(string)
|
||||
c, _ := args["command"].(string)
|
||||
key := runFailureKey(t, c)
|
||||
n := retries.recordFailure(key)
|
||||
if n >= maxRunRetries {
|
||||
key := retrycap.RunFailureKey(t, c)
|
||||
n := retries.RecordFailure(key)
|
||||
if n >= retrycap.MaxRunRetries {
|
||||
slog.Warn("nomos: run failure cap reached — next identical call will be refused",
|
||||
"target", t, "failures", n, "session", sessionID)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user