fix(agent): fully silent assent — no system notes, no chat_assent events
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

The len(pending)>0 path still injected a brief note saying 'execution(s)
are now running' — the model saw this, thought work was being done for
it, and no-op'd (finish_reason=stop, content_len=0). Same confusion as
the len(pending)==0 case, just from the other branch.

Fix: both assent paths are now fully silent. No system note at all. The
model sees 'go ahead' in the replayed history and responds naturally.

Also removed chat_assent tool_use/tool_result emit events. These were
persisted in the transcript and confused the model on replay — it saw
its own 'tool calls' (chat_assent) and thought it had already acted.

VERSION 0.7.4 → 0.7.5
This commit is contained in:
2026-07-16 00:09:31 +02:00
parent e4e426de7d
commit df24cae507
2 changed files with 20 additions and 30 deletions

View File

@@ -296,17 +296,6 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
if ok {
granted = append(granted, p.execID)
slog.Info("nomos: chat-assent granted", "execution", p.execID, "status", status, "session", sessionID)
// Mark as continued so the continuation worker doesn't
// pick up this execution and call resumeSession while the
// chat handler is still processing "go ahead." Without this,
// two LLM calls run concurrently for the same session —
// the chat handler's chat() and the worker's resumeSession()
// — causing empty responses and race conditions.
if execUUID, perr := uuid.Parse(p.execID); perr == nil {
a.store.markContinued(ctx, execUUID)
}
emit(agentEvent{Type: "tool_use", Data: map[string]any{"name": "chat_assent", "args": map[string]any{"execution_id": p.execID}, "id": "assent-" + p.execID}, SessionID: sessionID})
emit(agentEvent{Type: "tool_result", Data: map[string]any{"name": "chat_assent", "result": fmt.Sprintf("Approved via chat assent (%q). Status: %s.", message, status), "id": "assent-" + p.execID}, SessionID: sessionID})
// An explicit typed confirmation for a destructive action
// opens a short, target-scoped window so the rest of a
@@ -324,15 +313,22 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
}
if len(granted) > 0 {
a.openAssentWindow(ctx, sessionID)
// Brief note: only tells the model WHICH specific executions
// were approved (so it doesn't re-request them). Does NOT say
// "continue the plan" — the model already sees "go ahead" in
// the replayed history and knows to continue. The old verbose
// note ("they are now running... you MUST continue...") made
// the model think work was being done for it, causing empty
// responses (finish_reason=stop, content_len=0).
note := fmt.Sprintf("[System: execution(s) %s were approved by the operator and are now running. Do not re-request those — check get_execution_status if you need the outcome.]", strings.Join(granted, ", "))
messages = append(messages, openai.SystemMessage(note))
// Mark approved executions as continued so the continuation
// worker doesn't call resumeSession while the chat handler is
// still processing "go ahead" — two concurrent LLM calls for the
// 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)
}
}
// No system note. The model already sees "go ahead" in the
// replayed history (the user message was saved to the DB before
// chat() was called). The old note said "they are now running"
// which made the model think work was being done for it —
// causing empty responses (finish_reason=stop, content_len=0).
// The approved executions are dispatched; the model will
// continue with the remaining plan steps naturally.
}
if len(blocked) > 0 {
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, ", "))
@@ -341,15 +337,9 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
} else if assent && 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. Do NOT
// inject a system note: the model already sees "go ahead" in the
// replayed history (the user message was saved to the DB before
// chat() was called, and getRecentMessages replays it). The old
// verbose system note ("The operator approved your proposed plan.
// Execute it now — call update_plan_step then run...") was redundant
// with the user's "go ahead" and caused the model to no-op
// (finish_reason=stop, content_len=0) — the model saw "approved" +
// "running" and concluded there was nothing to do.
// and asked "shall I?" Open the assent window silently. No system
// note: the model sees "go ahead" in the replayed history and
// responds naturally.
a.openAssentWindow(ctx, sessionID)
}