Files
Maven/cmd/mavend/actions_act.go
T
claude 025f81e961 mavend: wire resolveAction into actionAct
Daemon half of the action-resolution boundary:

- Add resolveAction wrapper: delegates to ResolveActionCandidate,
  records outcome in the decision trace (action-resolve:route/matcher)
- Refactor actionAct: remove matcher call, consume candidate, write
  resolved values back into Slots for downstream branches
- Add 8 integration tests pinning all required scenarios:
  route-sourced, matcher-sourced, matcher miss, destructive confirm,
  task-status intercept, stage-0, learned-router, alias match

All existing tests pass. Execution/risk/confirmation unchanged.
2026-09-05 21:50:33 +04:00

126 lines
5.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: 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)
// Write the candidate's resolved values back into Slots so the existing
// branches (task-status, Praxis, Hexis, proposeGap, tool.Executor) work
// unchanged. This is the mechanical adjustment that preserves all existing
// behavior without redesigning those branches.
if candidate.ActionResolved() {
dec.Slots.Fn = candidate.Fn
dec.Slots.Args = candidate.Args
dec.Slots.HasFn = 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 && router.ActHasEntityTarget(dec) {
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.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", 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)
}