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) }