f3c0540b42
Also fixes a flake this stack introduced: the feeds test matched "ничего нового" as a substring, and query_ru_v1.json can answer with "в лентах тихо". It asks the entry now, like the others.
82 lines
3.1 KiB
Go
82 lines
3.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log"
|
|
|
|
"github.com/kami/maven/internal/mcp"
|
|
"github.com/kami/maven/internal/phraser"
|
|
"github.com/kami/maven/internal/router"
|
|
"github.com/kami/maven/internal/tool"
|
|
)
|
|
|
|
// actionAct handles router.IntentAct: match a verb to an enabled tool, offer
|
|
// it to the ecosystems first, and run it behind the confirm gate and the
|
|
// allowlist. proposeGap and the confirm gate itself live in confirm.go.
|
|
func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) string {
|
|
// tool executor: run the matched fn against the enabled allowlist.
|
|
// HasFn=false ⇒ try the matcher (for LLM-routed acts where the verb
|
|
// didn't go through the stage-0 act grammar).
|
|
if !dec.Slots.HasFn && dec.Slots.Text != "" && h.matcher != nil {
|
|
if fn, args, ok := h.matcher.Match(dec.Slots.Text); ok {
|
|
dec.Slots.Fn, dec.Slots.Args, dec.Slots.HasFn = fn, args, true
|
|
}
|
|
}
|
|
|
|
// Praxis ecosystem tools: intercept before the system command executor.
|
|
if h.ecosystem != nil && h.ecosystem.praxis != nil && dec.Slots.HasFn {
|
|
if reply := h.handlePraxisAct(ctx, dec); reply != "" {
|
|
return reply
|
|
}
|
|
}
|
|
|
|
// Hexis ecosystem action: if ecosystem is configured and we have a verb
|
|
// + entity text, try to resolve the entity and execute via Hexis.
|
|
if h.ecosystem != nil && h.ecosystem.hexis != nil && dec.Slots.Text != "" {
|
|
if reply := h.handleHexisAct(ctx, dec); reply != "" {
|
|
return reply
|
|
}
|
|
}
|
|
|
|
// HasFn still false ⇒ no allowlist match: scaffold a 'proposed' tool
|
|
// the user can enable on the authed surface ("earn the right to ask").
|
|
if !dec.Slots.HasFn {
|
|
return h.proposeGap(ctx, dec)
|
|
}
|
|
out, err := h.tools.Exec(ctx, dec.Slots.Fn, dec.Slots.Args, false)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, tool.ErrNeedsConfirm):
|
|
// destructive: park it and ask. The next utterance answers.
|
|
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.ErrNotEnabled):
|
|
return h.proposeGap(ctx, dec)
|
|
case errors.Is(err, tool.ErrNotConnected), errors.Is(err, mcp.ErrNotConnected), errors.Is(err, mcp.ErrNoServer):
|
|
// The row is enabled and the backend is gone. Drafting a proposal
|
|
// for it (the ErrNotEnabled path) would be answering the wrong
|
|
// question.
|
|
return phraser.A(phraser.ActServerDown, nil)
|
|
case errors.Is(err, mcp.ErrToolGone):
|
|
return phraser.A(phraser.ActWithdrawn, nil)
|
|
case errors.Is(err, mcp.ErrNeedsArgs):
|
|
// An MCP tool that wants named arguments a spoken verb cannot
|
|
// supply. Guessing them would be a wrong act, so she says so
|
|
// instead — the tool is still runnable from the authed surface,
|
|
// where a human types them.
|
|
return phraser.A(phraser.ActNeedsArgs, nil)
|
|
}
|
|
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
|
|
if out != "" {
|
|
return phraser.A(phraser.ActFailOut, map[string]string{"out": firstLine(out)})
|
|
}
|
|
return phraser.A(phraser.ActFail, nil)
|
|
}
|
|
if out != "" {
|
|
return phraser.A(phraser.ActDoneOut, map[string]string{"out": firstLine(out)})
|
|
}
|
|
return phraser.A(phraser.ActDone, nil)
|
|
}
|