cd6549fa51
The other half of the stage-0 rule. actionAct intercepts task_status ahead of both ecosystem clients, because the board is Maven's own store and reaching a capability registry would answer a question about his task list with a gap. Three answers besides the move, and none of them guesses. No match says so. More than one match asks which, since closing the wrong task marks work he never finished as done. No task named asks which too, because the router claims the turn without the referent and the list lives here. Matching is normalised containment either direction, over the same store.NormalizeTaskText key capture dedupes on — he shortens what he said as often as he pads it. Deliberately not fuzzy: a ranked best guess always returns exactly one answer, and the one thing this has to be able to say is that it is not sure. A candidate he says is done takes both legal store moves. The store refuses candidate → done, and saying it out loud IS the confirmation the candidate was waiting for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
96 lines
3.8 KiB
Go
96 lines
3.8 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
|
|
}
|
|
}
|
|
|
|
// The board is Maven's own store, so a spoken status change is answered here
|
|
// and never offered to an ecosystem client (Vikunja #512). First, because
|
|
// task_status is on no allowlist and no capability registry: reaching either
|
|
// of them would answer a turn about his own task list with a gap.
|
|
if dec.Slots.Fn == router.TaskStatusFn {
|
|
return h.resolveTaskStatus(ctx, dec)
|
|
}
|
|
|
|
// 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.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 phraser.A(phraser.ActNeedsAuthedSurface, nil)
|
|
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)
|
|
}
|