From 0e82cb442fbab100113a0c6b6101b3de6cdd51d1 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 20:06:25 +0400 Subject: [PATCH] the unplaceable word rides a typed error, not the message (V-634) Recovering it by cutting on quotes in err.Error() meant the reply depended on the wording of an error string. UnknownTargetError carries the word and errors.Is still holds. --- cmd/mavend/actions_act.go | 23 ++++++----------------- internal/tool/tool.go | 17 ++++++++++++++++- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/cmd/mavend/actions_act.go b/cmd/mavend/actions_act.go index 64dcf98..024f1b6 100644 --- a/cmd/mavend/actions_act.go +++ b/cmd/mavend/actions_act.go @@ -4,7 +4,6 @@ import ( "context" "errors" "log" - "strings" "github.com/kami/maven/internal/mcp" "github.com/kami/maven/internal/phraser" @@ -66,8 +65,12 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st // 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). - return phraser.A(phraser.ActUnknownTarget, - map[string]string{"name": unknownTargetOf(err, dec.Slots.Args)}) + 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, @@ -102,17 +105,3 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st return phraser.A(phraser.ActDone, nil) } -// unknownTargetOf pulls the word the executor could not place out of its error, -// falling back to the first arg. The word is what makes the reply usable, and a -// reply naming no word would send him to the log. -func unknownTargetOf(err error, args []string) string { - if _, rest, ok := strings.Cut(err.Error(), `"`); ok { - if word, _, ok := strings.Cut(rest, `"`); ok && word != "" { - return word - } - } - if len(args) > 0 { - return args[0] - } - return "" -} diff --git a/internal/tool/tool.go b/internal/tool/tool.go index 47e9963..cb7bf9e 100644 --- a/internal/tool/tool.go +++ b/internal/tool/tool.go @@ -84,6 +84,17 @@ var ( ErrUnknownTarget = errors.New("the act names a target the system cannot have") ) +// UnknownTargetError carries the word the executor could not place, because the +// reply names it: "«роутер» — не знаю такой цели" is actionable and "не +// получилось" sends him to the log. errors.Is(err, ErrUnknownTarget) holds. +type UnknownTargetError struct{ Target string } + +func (e *UnknownTargetError) Error() string { + return fmt.Sprintf("%s: %q", ErrUnknownTarget, e.Target) +} + +func (e *UnknownTargetError) Unwrap() error { return ErrUnknownTarget } + // MCPCaller is the seam for an act that is an MCP tool call rather than a // process (Vikunja #251). internal/mcp.Manager satisfies it via CallPositional. // nil ⇒ MCP is not configured, and an MCP row refuses to run rather than @@ -160,7 +171,7 @@ func (e *Executor) Exec(ctx context.Context, name string, args []string, confirm // since a task title is not a target, and a house row drops the spoken args. if !isMCPRow(t.Cmd) && !isHouseRow(t.Cmd) { if bad, ok := firstUnknownTarget(args); !ok { - return "", fmt.Errorf("%w: %q", ErrUnknownTarget, bad) + return "", &UnknownTargetError{Target: bad} } } // The tier decides, not the column (Vikunja #449). RiskOf reads the row and @@ -290,6 +301,10 @@ func (m *Matcher) Match(utterance string) (string, []string, bool) { // sentence. "перезагрузи роутер" is the case: restart is a real tool and // "роутер" is a real word, and `systemctl restart роутер` is neither. // +// Every process row this box enables takes a system identifier (systemctl, +// docker, journalctl, df). A process row that legitimately wanted Russian text +// would want a different dispatch, not a hole in this check. +// // It deliberately does not try to guess the right target. Identity is Nexus's // (CLAUDE.md § "The ecosystem"), and a target Nexus resolves reaches Hexis // through handleHexisAct before this executor is asked.