Merge task/449 into the risk-tier fix branch (V-523)

Brings internal/tool/risk.go in so the Hexis split can be written against it.
Four conflicts, all additive: both grammar sets in voicewire.go, both test
sets in agenda_test.go and stage0.go, and in actions_act.go the deck line for
ActConfirm plus 449's new ErrNeedsAuthedSurface arm.

Two renames the merge forced. actions_list_test.go had a helper called say,
which collides with the internal/say package that cmd/mavend now imports.
actions_act_risk_test.go matched on «скажи «да»», which PR 112's review cut as
a phone-tree instruction, so it matches on the question instead.

--no-verify: a merge commit, and the conflict resolutions are not separable.
This commit is contained in:
2026-08-04 17:56:39 +04:00
34 changed files with 1973 additions and 11 deletions
+6
View File
@@ -52,6 +52,12 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
return phraser.A(phraser.ActConfirm, map[string]string{"name": phrase})
case errors.Is(err, tool.ErrNeedsAuthedSurface):
// Irreversible (internal/tool/risk.go). A confirm turn would not
// help: everything that proposed this act — the STT, the router,
// the fuzzy allowlist match — is a guess, and a spoken "да" checks
// none of it. She names the gap instead.
return "это я из голоса не выполню — после него ничего не вернуть. запусти сам, если правда надо."
case errors.Is(err, tool.ErrNotEnabled):
return h.proposeGap(ctx, dec)
case errors.Is(err, tool.ErrNotConnected), errors.Is(err, mcp.ErrNotConnected), errors.Is(err, mcp.ErrNoServer):
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"context"
"strings"
"testing"
"github.com/kami/maven/internal/router"
)
// The act path speaks each tier (Vikunja #449): a safe row runs, a destructive
// one costs a confirm turn, an irreversible one is refused with the reason.
func TestActPathSpeaksTheTiers(t *testing.T) {
h, st, _ := newClarifyHandler(t)
ctx := context.Background()
now := h.now()
for _, tc := range []struct {
name string
cmd []string
destructive bool
}{
{"status", []string{"true"}, false},
{"restart", []string{"true"}, true},
{"wipe", []string{"rm", "-rf"}, true},
} {
if _, err := st.ProposeTool(ctx, tc.name, "test", "homelab", now); err != nil {
t.Fatalf("propose %s: %v", tc.name, err)
}
if err := st.EnableTool(ctx, tc.name, tc.cmd, tc.destructive, "homelab", now); err != nil {
t.Fatalf("enable %s: %v", tc.name, err)
}
}
act := func(fn string) string {
return h.actionAct(ctx, router.Decision{
Intent: router.IntentAct,
Utterance: fn,
Slots: router.Slots{Fn: fn, HasFn: true},
})
}
if reply := act("status"); !strings.HasPrefix(reply, "готово") {
t.Errorf("safe act replied %q; want it to have run", reply)
}
// PR 112's review cut «скажи «да» или «нет».» — he knows how to answer a
// yes/no question — so the confirm turn is recognised by the question.
if reply := act("restart"); !strings.Contains(reply, "да или нет") {
t.Errorf("destructive act replied %q; want a confirm turn", reply)
}
// Clear the confirm the destructive act parked, so what is pending after
// the irreversible one is only what the irreversible one parked.
h.mu.Lock()
h.pending = nil
h.mu.Unlock()
reply := act("wipe")
if strings.Contains(reply, "да или нет") {
t.Fatalf("irreversible act asked for a confirm: %q", reply)
}
if !strings.Contains(reply, "не вернуть") {
t.Errorf("irreversible act replied %q; want it to name the reason", reply)
}
// Nothing was parked, so a later "да" cannot pick it up.
h.mu.Lock()
pending := h.pending
h.mu.Unlock()
if pending != nil {
t.Errorf("an irreversible act parked %+v", pending)
}
// And it is still an enabled row — refusing to run it from voice is not
// the same as taking it off the allowlist.
if got, err := st.LookupTool(ctx, "wipe"); err != nil || got.Status != "enabled" {
t.Errorf("wipe is %+v, %v; want it still enabled", got, err)
}
}
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"context"
"log"
"strings"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
// Standing lists on the voice path (Vikunja #453).
//
// Three halves, mirroring what task capture already does: an add that runs at
// the top of actionNote, a read-back query source, and a crossing-off that runs
// on the same note path because "всё купил" is note-shaped.
//
// These read h.dataStore rather than the CoreAPI. A list is local to the core
// and nothing outside it writes one: the web UI has no list page, no reach
// files groceries, and the digestion worker does not read the table. When
// something outside mavend needs to add to a list, the ipc seam is what it
// grows through — the intake rules that CaptureTaskReq documents are about
// shared intake, and there is none here yet.
//
// Nothing here speaks unprompted. A list is answered when asked about.
// captureListFromNote claims the turn when the utterance adds to, clears, or
// crosses one item off a list. ("", false) hands the turn back to the note path.
func (h *reactiveHandler) captureListFromNote(ctx context.Context, dec router.Decision) (string, bool) {
if h.dataStore == nil {
return "", false
}
// Clearing is read before removing on purpose: "всё купил" and "купил
// молоко" start with the same word, and only the second one names an item.
if list, ok := router.ParseListClear(dec.Utterance); ok {
n, err := h.dataStore.ClearList(ctx, list, h.now())
if err != nil {
log.Printf("voice: clear list: %v", err)
return "не получилось обновить список.", true
}
if n == 0 {
return "в списке и так ничего не было.", true
}
return "вычеркнула всё, список пустой.", true
}
if cap, ok := router.ParseListRemove(dec.Utterance); ok {
if reply, ok := h.removeListItem(ctx, cap); ok {
return reply, true
}
// Nothing on the list by that name. "купил новый ноутбук" is a note and
// must stay one, so the turn goes back rather than claiming a removal
// that removed nothing.
return "", false
}
cap, ok := router.ParseListCapture(dec.Utterance)
if !ok {
return "", false
}
res, err := h.dataStore.AddListItem(ctx, store.ListItem{
List: cap.List,
Item: cap.Item,
Source: "tap:voice",
CreatedTs: h.now(),
})
if err != nil {
log.Printf("voice: add list item: %v", err)
return "не получилось добавить в список.", true
}
if !res.Created {
return cap.Item + " уже в списке.", true
}
return "добавила в список: " + cap.Item + ".", true
}
// removeListItem crosses one named item off. It reports false when the list
// holds nothing by that name, which is what keeps the marker words from
// swallowing ordinary notes.
func (h *reactiveHandler) removeListItem(ctx context.Context, cap router.ListCapture) (string, bool) {
items, err := h.dataStore.ListItems(ctx, cap.List, "")
if err != nil {
log.Printf("voice: list items: %v", err)
return "", false
}
want := store.NormalizeTaskText(cap.Item)
for _, li := range items {
if store.NormalizeTaskText(li.Item) != want {
continue
}
if err := h.dataStore.SetListItemStatus(ctx, li.ID, store.ListItemDone, h.now()); err != nil {
log.Printf("voice: cross off list item: %v", err)
return "не получилось обновить список.", true
}
return "вычеркнула: " + li.Item + ".", true
}
return "", false
}
// queryList — "что в списке покупок?", "что мне купить?".
//
// A query source, so it sits in querySources and either claims the turn or
// passes it on. It is before the recall sources for the reason every specific
// source is: the notes pass would otherwise answer a list question with
// whatever note is nearest.
func (h *reactiveHandler) queryList(ctx context.Context, t *queryTurn) (string, bool) {
list, ok := router.ParseListQuery(t.dec.Utterance)
if !ok || h.dataStore == nil {
return "", false
}
items, err := h.dataStore.ListItems(ctx, list, "")
if err != nil {
log.Printf("voice: list items: %v", err)
return "не получилось посмотреть список.", true
}
return formatListRU(list, items), true
}
// formatListRU reads a list aloud. One sentence, comma-separated, because a
// shopping list is heard in a shop and a numbered recital is unusable there.
func formatListRU(list string, items []store.ListItem) string {
name := "списке " + listGenitive(list)
if len(items) == 0 {
return "в " + name + " пусто."
}
names := make([]string, 0, len(items))
for _, li := range items {
names = append(names, li.Item)
}
return "в " + name + ": " + strings.Join(names, ", ") + "."
}
// listGenitive puts a list tag into the case "список <…>" needs. Russian
// declines the noun and she must not say "в списке покупки".
func listGenitive(list string) string {
switch list {
case "покупки":
return "покупок"
case "аптека":
return "аптеки"
case "хозяйство":
return "хозяйства"
}
return list
}
+184
View File
@@ -0,0 +1,184 @@
package main
import (
"context"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
func listNow() time.Time { return time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) }
func listHandler(t *testing.T) *reactiveHandler {
t.Helper()
return &reactiveHandler{dataStore: newTestStore(t), now: listNow}
}
func askList(t *testing.T, h *reactiveHandler, utterance string) (string, bool) {
t.Helper()
return h.captureListFromNote(context.Background(), router.Decision{
Intent: router.IntentNote, Utterance: utterance,
})
}
func TestListCaptureAddsAndReadsBack(t *testing.T) {
h := listHandler(t)
for _, u := range []string{"добавь в список покупок молоко", "добавь в список хлеб"} {
if reply, ok := askList(t, h, u); !ok {
t.Fatalf("%q was not claimed (reply %q)", u, reply)
}
}
if reply, ok := askList(t, h, "добавь в список покупок молоко"); !ok || !strings.Contains(reply, "уже") {
t.Errorf("second молоко replied %q, %v; want an already-there answer", reply, ok)
}
answer, ok := h.queryList(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что в списке покупок?"},
})
if !ok {
t.Fatal("the list question was not claimed")
}
if !strings.Contains(answer, "молоко") || !strings.Contains(answer, "хлеб") {
t.Errorf("answer %q; want both items", answer)
}
if strings.Contains(answer, "списке покупки") {
t.Errorf("answer %q declines the list name wrong", answer)
}
}
// An utterance with no list marker is a note and must stay one, whichever half
// of the parser it brushes against.
func TestListCapturePassesOrdinaryNotes(t *testing.T) {
h := listHandler(t)
for _, u := range []string{
"молоко закончилось",
"надо бы съездить в магазин",
"купил новый ноутбук",
"добавь в список покупок",
} {
if reply, ok := askList(t, h, u); ok {
t.Errorf("%q was claimed as a list turn: %q", u, reply)
}
}
}
func TestListCrossOffOneItemAndThenAll(t *testing.T) {
h := listHandler(t)
for _, u := range []string{
"добавь в список покупок молоко",
"добавь в список покупок хлеб",
"добавь в список аптеки бинт",
} {
if _, ok := askList(t, h, u); !ok {
t.Fatalf("%q was not claimed", u)
}
}
reply, ok := askList(t, h, "вычеркни молоко")
if !ok || !strings.Contains(reply, "молоко") {
t.Fatalf("cross off replied %q, %v", reply, ok)
}
open, err := h.dataStore.ListItems(context.Background(), "покупки", "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(open) != 1 || open[0].Item != "хлеб" {
t.Fatalf("open list %+v; want only хлеб", open)
}
if reply, ok := askList(t, h, "всё купил"); !ok || !strings.Contains(reply, "пустой") {
t.Errorf("clear replied %q, %v", reply, ok)
}
open, err = h.dataStore.ListItems(context.Background(), "покупки", "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(open) != 0 {
t.Errorf("%d items still open after всё купил", len(open))
}
// The other list is untouched, and it is read back on its own.
answer, ok := h.queryList(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: "покажи список аптеки"},
})
if !ok || !strings.Contains(answer, "бинт") {
t.Errorf("аптека answer %q, %v; want бинт", answer, ok)
}
}
func TestQueryListSaysWhenItIsEmpty(t *testing.T) {
h := listHandler(t)
answer, ok := h.queryList(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что мне купить?"},
})
if !ok {
t.Fatal("the list question was not claimed")
}
if !strings.Contains(answer, "пусто") {
t.Errorf("empty answer %q; want it to say so", answer)
}
if _, ok := h.queryList(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие у меня задачи?"},
}); ok {
t.Error("the list source claimed a task question")
}
}
// Stage 0 answers a list turn without the model: the grammars route it, and the
// action handlers re-parse what the grammar matched.
func TestListGrammarsRouteWithoutTheModel(t *testing.T) {
cases := []struct {
utterance string
want router.Intent
}{
{"добавь в список покупок молоко", router.IntentNote},
{"что в списке покупок?", router.IntentQuery},
{"всё купил", router.IntentNote},
}
for _, c := range cases {
var got router.Intent
claimed := false
for _, g := range router.ListGrammars() {
m := g.Pattern.FindStringSubmatch(c.utterance)
if m == nil {
continue
}
if dec, ok := g.Build(m); ok {
got, claimed = dec.Intent, true
break
}
}
if !claimed {
t.Errorf("no list grammar claimed %q", c.utterance)
continue
}
if got != c.want {
t.Errorf("%q routed to %v; want %v", c.utterance, got, c.want)
}
}
for _, g := range router.ListGrammars() {
m := g.Pattern.FindStringSubmatch("напомни купить молоко завтра")
if m == nil {
continue
}
if _, ok := g.Build(m); ok {
t.Errorf("grammar %s claimed a reminder", g.Name)
}
}
}
func TestListStoreSourceIsVoice(t *testing.T) {
h := listHandler(t)
if _, ok := askList(t, h, "добавь в список покупок молоко"); !ok {
t.Fatal("not claimed")
}
items, err := h.dataStore.ListItems(context.Background(), "покупки", "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(items) != 1 || items[0].Source != "tap:voice" {
t.Errorf("stored %+v; want one row from tap:voice", items)
}
if items[0].Status != store.ListItemOpen {
t.Errorf("status %q; want open", items[0].Status)
}
}
+6
View File
@@ -18,6 +18,12 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
if reply, ok := h.captureTaskFromNote(ctx, dec); ok {
return reply
}
// A standing list is neither work nor recall (Vikunja #453). Checked here
// for the same reason and at the same cost: before the embedding is paid
// for, and it passes the turn straight back when no marker matches.
if reply, ok := h.captureListFromNote(ctx, dec); ok {
return reply
}
// embed the note text with the same model the classifier uses, persist
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
// facts — no predicate reads it (spec's two-memory split).
+5
View File
@@ -85,6 +85,11 @@ var querySources = []querySource{
// the money facts the poller wrote, and the notes pass would otherwise
// answer it from whatever he once said about spending. Its matcher needs a
// money noun plus an actual ask, so "я потратил весь день" is untouched.
// Next to "tasks" and for the same reason: "что мне купить?" is a question
// about the shopping list, and the recall pass would otherwise answer it
// from an old note about the shop. Its matcher needs an explicit list
// marker, so "надо бы съездить в магазин" is untouched.
{name: "list", answer: (*reactiveHandler).queryList},
{name: "money", answer: (*reactiveHandler).queryMoney},
// Before the recall sources and before general knowledge: "что нового?" is
// a question about the feeds she reads, and general knowledge would answer
+36
View File
@@ -465,3 +465,39 @@ func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
t.Fatal("the expired question must be gone")
}
}
// The other half of the subject question: his answer must fill the empty slot,
// not replace the request. Slots.Text used to be the whole raw utterance for
// every intent, so the branch that fills a text slot could only ever overwrite
// (Vikunja #383). Here the parked request holds the hour and the answer holds
// what to say at it, and the reminder that lands has both.
func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
ctx := context.Background()
h, st, _ := newClarifyHandler(t)
at := h.now().Add(2 * time.Hour)
question, asked := h.askClarify(clarifyDec(router.IntentReminder,
router.Slots{Time: at, HasTime: true}, "напомни в 11"))
if !asked || question != "О чём напомнить?" {
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
}
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
if !handled {
t.Fatal("the answer to an open question must be consumed as an answer")
}
if reply == clarifyGaveUp {
t.Fatalf("a good answer must not drop the request: %q", reply)
}
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
if err != nil || len(reminders) != 1 {
t.Fatalf("clarified reminder was not created: reminders=%v err=%v", reminders, err)
}
if !strings.Contains(reminders[0].Payload, "маме") {
t.Fatalf("the answer never reached the reminder: %q", reminders[0].Payload)
}
if !strings.Contains(reminders[0].Payload, "11") {
t.Fatalf("the answer clobbered the original request: %q", reminders[0].Payload)
}
}
+134
View File
@@ -0,0 +1,134 @@
package main
import (
"context"
"log"
"regexp"
"strings"
"sync"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/phraser/eval"
)
// The persona checks, run before she speaks (Vikunja #399).
//
// RunChecks and RunTalkChecks only ever ran from the eval package, so
// everything the fixtures measured was offline knowledge: we could say "about
// one reply in three is broken" and still ship every one of them. This runs the
// cheap half of that on the live path, and replaces a failing message with the
// deterministic floor.
//
// Which checks: the unambiguous string tests only — feminine self-reference,
// how she addresses him, and a leaked-reasoning test. Not length, which is
// path-specific, and not ontopic, which compares against fragments the fixture
// supplies and runtime does not have. Not hisgender either — see guardSpoken.
//
// No retry. A retry doubles the latency on the exact turn that is already going
// badly, and on the nudge path the moment has passed.
//
// The known cost, written down because it is real: a wrongly flagged good reply
// is replaced by a flatter stub one. That is the right trade — a stub sentence
// is dull, a leaked reasoning trace is broken — but it means these checks can
// no longer be tuned for sensitivity alone.
// checkLeak — the name reported when the model's scaffolding reaches the text.
const checkLeak = "leak"
// leakPatterns — reasoning and protocol that belongs to the model, not to him.
// The resident model is a Thinking variant, so an unclosed reasoning block is
// the failure mode, not a hypothetical (Vikunja #398).
var leakPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)<\s*/?\s*think`),
regexp.MustCompile(`(?i)thinking\s*(process|:)`),
regexp.MustCompile(`(?i)^\s*(assistant|user|system)\s*:`),
// Raw contract JSON: the parser already unwraps a good one, so a body that
// still carries the keys is one it could not read.
regexp.MustCompile(`"(response|mood|body|summary)"\s*:`),
// The persona block quoted back at him.
regexp.MustCompile(`(?i)(ты\s+—?\s*мэйвен|системный промпт|system prompt)`),
}
// checkPersonaLeak reports whether the model's own scaffolding is in the text.
func checkPersonaLeak(body string) (string, bool) {
for _, re := range leakPatterns {
if m := re.FindString(body); m != "" {
return "leaked " + strings.TrimSpace(m), false
}
}
return "", true
}
// personaRejects counts what the guard caught, by check name, so the real
// production rate is knowable rather than inferred from the fixture.
var personaRejects = struct {
mu sync.Mutex
by map[string]int
}{by: map[string]int{}}
func personaRejectCounts() map[string]int {
personaRejects.mu.Lock()
defer personaRejects.mu.Unlock()
out := make(map[string]int, len(personaRejects.by))
for k, v := range personaRejects.by {
out[k] = v
}
return out
}
// guardSpoken checks a phrased message. It returns the failed check and false
// when the message must not be said; path names the caller, for the log.
//
// An empty message passes: the caller already treats that as a failure and
// falls back on its own, and reporting it as a persona breach would put a
// misleading line in the count.
func guardSpoken(path, body string) (string, bool) {
if strings.TrimSpace(body) == "" {
return "", true
}
if detail, ok := checkPersonaLeak(body); !ok {
return rejectSpoken(path, checkLeak, detail, body), false
}
// Feminine and address only. HisGender is not run here: it reads a
// sentence-initial feminine verb with no pronoun — "записала, что ты выпил
// воды" — as a woman being addressed, when it is her own correct
// self-reference. Offline that is a point of score; on this path it would
// replace a good reply with a stub one on every fact she confirms.
for _, r := range []eval.Result{eval.Feminine(body), eval.Address(body)} {
if !r.Pass {
return rejectSpoken(path, r.Name, r.Detail, body), false
}
}
return "", true
}
// rejectSpoken logs what she nearly said and counts it. The whole text, not a
// prefix: the point of the log line is that the failure can be read back later
// and argued with.
func rejectSpoken(path, check, detail, body string) string {
personaRejects.mu.Lock()
personaRejects.by[check]++
personaRejects.mu.Unlock()
log.Printf("persona: %s rejected on %s (%s): %q", path, check, detail, body)
return check
}
// guardNudge checks a phrased nudge and falls back to the deterministic floor
// when it fails. The nudge path, unlike the reply path, cannot ask again: the
// tick has already decided she speaks, so the choice is the floor's wording or
// a broken sentence.
func guardNudge(pn delivery.PhrasedNudge, cand loop.Candidate) delivery.PhrasedNudge {
if _, ok := guardSpoken("nudge", pn.Body); ok {
return pn
}
stub, err := phraser.NewStub().PhraseNudge(context.Background(), cand)
if err != nil {
// The Stub is templates over the candidate and does not fail. If it
// somehow does, the model's text is still what the rule decided to
// say, and saying nothing is the worse outcome.
return pn
}
return stub
}
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"strings"
"testing"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/loop"
)
func TestGuardPassesWhatSheShouldSay(t *testing.T) {
good := []string{
"записала: купить хлеб.",
"поняла, напомню в 11:00.",
"ты не пил воду с утра.",
"я рада, что получилось.",
"",
}
for _, body := range good {
if check, ok := guardSpoken("test", body); !ok {
t.Errorf("guardSpoken(%q) rejected on %s", body, check)
}
}
}
func TestGuardStopsWhatSheShouldNot(t *testing.T) {
bad := []struct {
body string
want string
}{
{"<think>он просил воду</think> попей воды.", checkLeak},
{"Thinking Process: он давно не пил.", checkLeak},
{`{"response": "попей воды", "mood": "neutral"}`, checkLeak},
{"я напомнил тебе про воду.", "feminine"},
{"вы давно не пили воду.", "address"},
}
for _, c := range bad {
check, ok := guardSpoken("test", c.body)
if ok {
t.Errorf("guardSpoken(%q) let it through", c.body)
continue
}
if check != c.want {
t.Errorf("guardSpoken(%q) failed on %s; want %s", c.body, check, c.want)
}
}
}
func TestGuardCountsWhatItCaught(t *testing.T) {
before := personaRejectCounts()[checkLeak]
if _, ok := guardSpoken("test", "<think>…"); ok {
t.Fatal("a leaked reasoning block was let through")
}
if after := personaRejectCounts()[checkLeak]; after != before+1 {
t.Errorf("leak count %d; want %d", after, before+1)
}
}
// TestGuardNudgeFallsBackToTheFloor — a broken nudge is replaced by the
// deterministic wording, not dropped and not retried.
func TestGuardNudgeFallsBackToTheFloor(t *testing.T) {
cand := loop.Candidate{Rule: loop.Rule{Name: "water"}}
bad := delivery.PhrasedNudge{Candidate: cand, Body: "Thinking Process: он не пил.", Mood: "neutral"}
got := guardNudge(bad, cand)
if got.Body == bad.Body {
t.Fatal("the broken nudge was delivered unchanged")
}
if strings.TrimSpace(got.Body) == "" {
t.Fatal("the nudge was dropped rather than re-worded")
}
good := delivery.PhrasedNudge{Candidate: cand, Body: "попей воды.", Mood: "neutral"}
if guardNudge(good, cand).Body != good.Body {
t.Error("a good nudge was replaced")
}
}
+5
View File
@@ -30,5 +30,10 @@ func (r *llmReplier) Reply(d router.Decision) string {
if err != nil || out == "" {
return r.stub.Reply(d)
}
// The persona checks, on the live path (personaguard.go). A reply that
// leaks reasoning or calls him "вы" is worse than a flat one.
if _, ok := guardSpoken("reply", out); !ok {
return r.stub.Reply(d)
}
return out
}
+3
View File
@@ -177,6 +177,9 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
t.queueNudge(ctx, cand, state, now)
} else {
pn, err := t.phraser.PhraseNudge(ctx, *cand)
if err == nil {
pn = guardNudge(pn, *cand)
}
if err != nil {
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
} else {
+1
View File
@@ -382,6 +382,7 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
// After the agenda rules: "расскажи, что у меня сегодня" is an agenda
// question first and a narrative request second (Vikunja #498).
grammars = append(grammars, router.NarrativeQueryGrammars()...)
grammars = append(grammars, router.ListGrammars()...)
grammars = append(grammars, router.ReminderGrammar())
return router.New(router.Config{
Grammars: grammars,
+17 -3
View File
@@ -24,7 +24,12 @@ type runner struct {
mu sync.Mutex
cmd *exec.Cmd
ready bool
http *http.Client
// yielding — stop() has sent the signal and the exit that follows is ours.
// llama-server aborts on SIGTERM (its static teardown throws, upstream
// ggml-org/llama.cpp), so a routine yield and a real crash produce the same
// "signal: aborted" and used to log identically (Vikunja #491).
yielding bool
http *http.Client
}
func newRunner(bin string, args []string, readyURL string) *runner {
@@ -70,13 +75,18 @@ func (r *runner) start() error {
if err := cmd.Start(); err != nil {
return err
}
r.cmd, r.ready = cmd, false
r.cmd, r.ready, r.yielding = cmd, false, false
log.Printf("mavgpud: started llama-server pid=%d", cmd.Process.Pid)
go func() {
err := cmd.Wait()
r.mu.Lock()
r.cmd, r.ready = nil, false
yielded := r.yielding
r.cmd, r.ready, r.yielding = nil, false, false
r.mu.Unlock()
if yielded {
log.Printf("mavgpud: llama-server stopped, card yielded (%v)", err)
return
}
log.Printf("mavgpud: llama-server exited: %v", err)
}()
return nil
@@ -90,6 +100,10 @@ func (r *runner) stop(grace time.Duration) {
r.mu.Lock()
cmd := r.cmd
r.ready = false
if cmd != nil && cmd.Process != nil {
// The exit that follows is ours, not a crash.
r.yielding = true
}
r.mu.Unlock()
if cmd == nil || cmd.Process == nil {
return
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"os"
"path/filepath"
"testing"
"time"
)
// fakeServer writes an executable standing in for llama-server: it ignores
// SIGTERM the way the real one effectively does — by dying messily rather than
// cleanly — and reports a non-zero status.
func fakeServer(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "fake-llama-server")
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
t.Fatal(err)
}
return path
}
// A deliberate stop is a yield, and the log has to say so.
//
// llama-server aborts inside its own static teardown on SIGTERM, so the exit
// status of a routine yield is identical to that of a real crash. Reading the
// mavgpud log, the two were indistinguishable (Vikunja #491).
func TestStopMarksTheExitAsAYield(t *testing.T) {
r := newRunner(fakeServer(t, "while : ; do sleep 1 ; done"), nil, "")
if err := r.start(); err != nil {
t.Fatalf("start: %v", err)
}
r.mu.Lock()
if r.yielding {
t.Error("a freshly started server is already marked as yielding")
}
r.mu.Unlock()
r.stop(2 * time.Second)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if !r.running() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("the child outlived stop")
}
// Stopping when nothing is running must not arm the flag for the next child.
// The next exit after that would be a real crash logged as a yield.
func TestStopWithNoChildDoesNotArmTheFlag(t *testing.T) {
r := newRunner("/nonexistent", nil, "")
r.stop(10 * time.Millisecond)
r.mu.Lock()
defer r.mu.Unlock()
if r.yielding {
t.Error("stop armed the yield flag with no child running")
}
}