66c578a6f4
Introduce ActionValidationStatus enum (valid, unresolved, missing_argument, invalid_argument, ambiguous_target) as the typed classification of validation outcomes. ActionValidationResult now carries Status instead of boolean flags. Backward-compatible: Unresolved() and Valid() methods preserved on the result. Existing validation behavior unchanged: only blank Fn produces invalid_argument. All downstream behavior (proposeGap, confirmation, task_status, praxis, hexis) unchanged. Tests added for all five status values, backward compatibility, and the full validation → execution boundary.
127 lines
5.2 KiB
Go
127 lines
5.2 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: resolve the action, 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.
|
|
//
|
|
// Action resolution happens in resolveAction (actionresolve.go) — a single
|
|
// boundary that produces an ActionCandidate before execution. This function
|
|
// consumes the candidate; it no longer decides which function/tool the user
|
|
// meant.
|
|
func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) string {
|
|
// An allowlist or a model route is evidence about WHAT could run, never
|
|
// authority to run it. Keep the user's negative command at the execution
|
|
// boundary too: actionAct is also reached by rebuilt decisions outside the
|
|
// ordinary pre-route ladder.
|
|
if refusesCommand(dec) {
|
|
return commandProhibitionReply
|
|
}
|
|
|
|
// Resolve the action: produce an ActionCandidate from the routing
|
|
// decision. The candidate carries the resolved function, its arguments,
|
|
// and where the resolution came from (route or matcher).
|
|
candidate := h.resolveAction(ctx, dec)
|
|
|
|
// Structural validation: is this candidate complete enough to proceed?
|
|
// Unresolved (Fn empty) flows to proposeGap; invalid (Fn present but
|
|
// malformed) is refused; valid proceeds to execution.
|
|
validation := router.ValidateActionCandidate(candidate)
|
|
noteActionValidation(ctx, validation)
|
|
|
|
if !validation.Unresolved() && !validation.Valid() {
|
|
// Resolved but structurally malformed: refuse execution.
|
|
return phraser.A(phraser.ActFail, nil)
|
|
}
|
|
|
|
// 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 candidate.Fn == router.TaskStatusFn {
|
|
return h.resolveTaskStatus(ctx, dec, candidate)
|
|
}
|
|
|
|
// Praxis ecosystem tools: intercept before the system command executor.
|
|
if h.ecosystem != nil && h.ecosystem.praxis != nil && candidate.ActionResolved() {
|
|
if reply := h.handlePraxisAct(ctx, dec, candidate); 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 && router.ActHasEntityTarget(dec) {
|
|
if reply := h.handleHexisAct(ctx, dec, candidate); reply != "" {
|
|
return reply
|
|
}
|
|
}
|
|
|
|
// Unresolved candidate ⇒ no allowlist match: scaffold a 'proposed' tool
|
|
// the user can enable on the authed surface ("earn the right to ask").
|
|
if !candidate.ActionResolved() {
|
|
return h.proposeGap(ctx, dec)
|
|
}
|
|
out, err := h.tools.Exec(ctx, candidate.Fn, candidate.Args, false)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, tool.ErrNeedsConfirm):
|
|
// destructive: park it and ask. The next utterance answers.
|
|
phrase := actPhrase(candidate.Fn, candidate.Args)
|
|
h.park(candidate.Fn, candidate.Args, phrase)
|
|
return phraser.A(phraser.ActConfirm, map[string]string{"name": phrase})
|
|
case errors.Is(err, tool.ErrUnknownTarget):
|
|
// The verb reached a tool and the tail did not reach a target, so
|
|
// nothing ran. Saying which word she could not place is the whole
|
|
// answer: he either renames it or gives the row an alias that
|
|
// carries the target, and both are one turn away (V-634).
|
|
word := ""
|
|
var unknown *tool.UnknownTargetError
|
|
if errors.As(err, &unknown) {
|
|
word = unknown.Target
|
|
}
|
|
return phraser.A(phraser.ActUnknownTarget, map[string]string{"name": word})
|
|
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", candidate.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)
|
|
}
|