No deadline survives the turn path, from mavweb down to llama-server (#188)

Co-authored-by: claude <no-reply@agents.claude.kvmx.ru>
Co-committed-by: claude <no-reply@agents.claude.kvmx.ru>
This commit was merged in pull request #188.
This commit is contained in:
2026-08-06 21:11:42 +02:00
committed by kami
parent 661b5c1099
commit 69d0f5ee78
10 changed files with 363 additions and 32 deletions
+1 -1
View File
@@ -571,7 +571,7 @@ func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decisi
}
reply := h.applyAction(ctx, dec)
if reply == "" {
reply = h.replier.Reply(dec)
reply = h.replier.Reply(ctx, dec)
}
if reply == "" {
// Belt: an empty reply here would be a silent drop.
+4 -4
View File
@@ -22,7 +22,7 @@ func newLLMReplier(c phraser.Completer, block func() string) *llmReplier {
// Reply never fails: a clarify, a model error and an unusable generation all
// answer from the stub, which is what keeps a turn from breaking on the model.
func (r *llmReplier) Reply(d router.Decision) string {
func (r *llmReplier) Reply(ctx context.Context, d router.Decision) string {
if d.Clarify {
// The deck, not the stub's single sentence: a clarify she cannot turn
// into a question is the line he hears most often when she misses him,
@@ -39,14 +39,14 @@ func (r *llmReplier) Reply(d router.Decision) string {
// что ты выпел стакан воды" for "я выпил воды".
return phraser.FactAck(d.Utterance)
}
out, err := r.p.PhraseReply(context.Background(), d)
out, err := r.p.PhraseReply(ctx, d)
if err != nil || out == "" {
return r.stub.Reply(d)
return r.stub.Reply(ctx, 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 r.stub.Reply(ctx, d)
}
return out
}
+5 -5
View File
@@ -22,7 +22,7 @@ func (s stubCompleter) Complete(_ context.Context, _ llm.Req) (string, error) {
func TestLLMReplierPassesTheModelReplyThrough(t *testing.T) {
r := newLLMReplier(stubCompleter{out: `{"response":"записала, кофе закончился","mood":"neutral"}`}, nil)
got := r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}})
got := r.Reply(context.Background(), router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}})
if got != "записала, кофе закончился" {
t.Errorf("got %q, want %q", got, "записала, кофе закончился")
}
@@ -42,7 +42,7 @@ func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
// the clarify deck rather than the stub's single sentence.
func TestLLMReplierClarifyReadsTheDeck(t *testing.T) {
r := newLLMReplier(stubCompleter{out: "я всё поняла"}, nil)
got := r.Reply(router.Decision{Clarify: true, Utterance: "мгм"})
got := r.Reply(context.Background(), router.Decision{Clarify: true, Utterance: "мгм"})
if got == "я всё поняла" {
t.Fatal("a clarify must not be phrased by the model")
}
@@ -50,7 +50,7 @@ func TestLLMReplierClarifyReadsTheDeck(t *testing.T) {
t.Errorf("on clarify: got %q, want %q", got, want)
}
// Two different misses do not sound identical.
if same := r.Reply(router.Decision{Clarify: true, Utterance: "а"}); same == got {
if same := r.Reply(context.Background(), router.Decision{Clarify: true, Utterance: "а"}); same == got {
t.Log("two utterances hashed to the same line, which is allowed but should be rare")
}
}
@@ -60,14 +60,14 @@ func TestLLMReplierClarifyReadsTheDeck(t *testing.T) {
// produce, which is the same claim without pinning one wording.
func assertAck(t *testing.T, r *llmReplier, d router.Decision, key, what string) {
t.Helper()
if got := r.Reply(d); !phraser.IsAck(key, nil, got) {
if got := r.Reply(context.Background(), d); !phraser.IsAck(key, nil, got) {
t.Errorf("on %s: got %q, want a %q line", what, got, key)
}
}
func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) {
t.Helper()
got, want := r.Reply(d), voice.NewStubReplier().Reply(d)
got, want := r.Reply(context.Background(), d), voice.NewStubReplier().Reply(context.Background(), d)
if got != want {
t.Errorf("on %s: got %q, want stub %q", what, got, want)
}
+1 -1
View File
@@ -458,7 +458,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// 9. replier — phrase the reply across the router decision.
if replyText == "" {
replyText = h.replier.Reply(dec)
replyText = h.replier.Reply(ctx, dec)
}
return withNotice(expiredNotice, replyText)
}
+19 -1
View File
@@ -58,6 +58,12 @@ func main() {
// mutex, so sharing the connection would freeze every other page for the
// length of the load. See handleModels.
var swapConn modelController
// turnConn — a third connection, for POST /api/chat and nothing else, for
// the same reason /models has one (V-638). A chat turn routes, phrases and
// may act, bounded only by phraser.timeout at 60s, and every other handler
// on this server queues behind it on the shared client's one mutex. Nil ⇒
// chat shares the main connection, which is how it behaved before.
var turnConn ipc.CoreAPI
if *coreSock != "" {
c, err := ipc.DialWait(*coreSock, 60*time.Second)
if err != nil {
@@ -71,6 +77,12 @@ func main() {
defer sc.Close()
swapConn = sc
}
if tc, err := ipc.Dial(*coreSock); err != nil {
log.Printf("chat: third core connection failed (%v) — /api/chat will share the main one and a turn will block the other pages", err)
} else {
defer tc.Close()
turnConn = tc
}
}
// stepUpSession stays nil unless the passkey endpoints are wired below — it
@@ -208,7 +220,13 @@ func main() {
// decides how every utterance is routed and how every reply is worded.
mux.HandleFunc("/tools", gatedPage(handleTools))
mux.HandleFunc("/routines", gatedPage(handleRoutines))
mux.HandleFunc("/api/chat", gatedPage(handleChatAPI))
mux.HandleFunc("/api/chat", func(w http.ResponseWriter, r *http.Request) {
c := turnConn
if c == nil {
c = core
}
handleChatAPI(w, r, c, stepUpSession, *requireStepUp)
})
mux.HandleFunc("/api/revert", gatedPage(handleRevert))
mux.HandleFunc("/api/correct", gatedPage(handleCorrectAPI))
mux.HandleFunc("/models", func(w http.ResponseWriter, r *http.Request) {