Files
oikos/internal/nomos/assent/assent.go
dtoro 7a7d390718 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.
2026-08-16 10:31:45 +02:00

144 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package assent
import (
"regexp"
"strings"
)
// Chat-assent approval: the operator authorizes a proposed action by
// replying normally in chat ("go ahead", "yes", "do it") instead of clicking
// a separate Approve button. This is deterministic (not LLM-judged) so it
// can't be talked around by a model that misreads intent, and it only ever
// looks at the assistant turn immediately preceding the operator's reply —
// an old "yes" from three messages ago can never retroactively approve
// something new. Destructive-risk actions are excluded: they always need the
// explicit typed-confirmation flow, never loose assent.
// PendingApproval is one gated action proposed in the immediately-preceding
// assistant turn, extracted from its tool_result text.
type PendingApproval struct {
ExecID string
Destructive bool
}
// executionQueuedRE matches the "execution <uuid> queued" phrasing shared by
// the run and request_execution/pct_create tool result messages.
var executionQueuedRE = regexp.MustCompile(`(?i)execution\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\s+queued`)
// extractPendingApprovals scans the tool results of one assistant turn for
// gated actions that are still awaiting a decision.
func ExtractPendingApprovals(resultTexts []string) []PendingApproval {
var out []PendingApproval
for _, text := range resultTexts {
m := executionQueuedRE.FindStringSubmatch(text)
if m == nil {
continue
}
out = append(out, PendingApproval{
ExecID: m[1],
Destructive: strings.Contains(strings.ToUpper(text), "DESTRUCTIVE"),
})
}
return out
}
// negationWords, checked first: any of these anywhere in the message means
// the reply is NOT assent, even if a positive word also appears (e.g. "no,
// don't restart it yet" contains neither "yes" nor "go ahead", but "wait"
// alone should also block a stray "yes" a sentence later — checking negation
// first and returning false errs toward re-confirming rather than assuming
// consent, per "when in doubt, escalate"). Includes contracted negatives
// ("haven't", "isn't", ...) alongside "don't"/"do not" — found live: "I
// haven't confirmed anything yet" was reading as an explicit confirmation
// because none of the contracted forms were covered, only "don't"/"do not".
// Deliberately does NOT include a bare "not": that's broad enough to false-
// negative ordinary assent ("go ahead, this is not risky") — the specific
// contracted-verb forms below are unambiguous negation on their own.
var negationWords = []string{
"no", "nope", "don't", "do not", "stop", "wait", "hold on", "hold off",
"not yet", "cancel", "nevermind", "never mind", "actually don't", "skip that",
"haven't", "hasn't", "isn't", "wasn't", "aren't", "can't", "cannot",
"won't", "wouldn't", "shouldn't", "didn't", "doesn't",
}
// assentWords, checked only if no negation matched.
var assentWords = []string{
"go ahead", "goahead", "yes", "yep", "yeah", "yup", "do it", "proceed",
"approve", "approved", "confirm", "confirmed", "ship it", "sounds good",
"lgtm", "run it", "execute", "ok go", "okay go", "please do",
}
// wordTokenRe splits a message into lowercase word tokens. Apostrophes
// (straight ' and curly ) stay attached to their word so "don't"/"haven't"
// tokenize as one token, not two.
var wordTokenRe = regexp.MustCompile(`[a-z0-9']+`)
func tokenize(msg string) []string {
return wordTokenRe.FindAllString(strings.ToLower(strings.ReplaceAll(msg, "", "'")), -1)
}
// containsPhrase reports whether phrase (one or more words) appears as a
// consecutive run of WHOLE tokens in tokens — never a mid-word substring
// match. This is the fix for a real false positive found live: the old
// substring check (`strings.Contains(m, "yes")`) matched "yes" inside
// "yesterday", and "confirm" inside "confirmed"/"unconfirmed" without regard
// for word boundaries. Negation already used a word-boundary check
// (space-padded); assent/confirm words didn't — this brings both onto the
// same, more robust tokenized comparison instead of ad-hoc string padding.
func containsPhrase(tokens []string, phrase string) bool {
words := strings.Fields(phrase)
if len(words) == 0 || len(words) > len(tokens) {
return false
}
for i := 0; i+len(words) <= len(tokens); i++ {
match := true
for j, w := range words {
if tokens[i+j] != w {
match = false
break
}
}
if match {
return true
}
}
return false
}
// isAssent reports whether msg is a plain-language authorization of a
// pending proposal. Deliberately simple and auditable: a fixed word list,
// not a model judgment call, so behavior is predictable and can't be
// prompt-injected via the pending action's own content.
func IsAssent(msg string) bool {
tokens := tokenize(msg)
for _, w := range negationWords {
if containsPhrase(tokens, w) {
return false
}
}
for _, w := range assentWords {
if containsPhrase(tokens, w) {
return true
}
}
return false
}
// isTypedConfirmation reports whether msg is an explicit confirmation strong
// enough to grant a DESTRUCTIVE pending action. Deliberately a separate,
// stricter check from isAssent: a bare "yes"/"go ahead"/"proceed" must never
// grant something destructive, only an explicit "confirm" statement does —
// this is the typed-confirmation phrase SOUL.md tells the operator to use
// ("I confirm destroy 135"). Still negation-aware for the same reason as
// isAssent: "don't confirm yet" must not accidentally match.
func IsTypedConfirmation(msg string) bool {
tokens := tokenize(msg)
for _, w := range negationWords {
if containsPhrase(tokens, w) {
return false
}
}
return containsPhrase(tokens, "confirm") || containsPhrase(tokens, "confirmed")
}