Files
Maven/internal/tool/tool.go
T
kami d62ba093f5 smarthome: keep the devices under the cap, and stop asserting what the house did not do
States sorted every entity by id and cut at MaxEntities. Entity ids sort
by domain prefix, so binary_sensor came first and forty slots went to
connectivity and update-available rows: propose found nothing
controllable, and homeSummary, reading the same list, said everything
was off with the lights on. The cap stays, because a tool name the 1.7B
half-remembers is a wrong act. What changes is which forty. Controllable
domains are taken first and round-robin, so every switch and light is in
before any sensor.

CallService reported done for a call that changed nothing. Home
Assistant answers a service call with the states it changed, and a
removed entity or an offline integration gets 200 and an empty array.
That is the one place Maven asserts something about the physical world,
so an empty array is now ErrUnknownEntity.

The confirm turn on a house row was a column, not an invariant. The
proposal is destructive, but /tools writes the checkbox through on
enable, so unticking it once made an unlock row that ran on first
hearing. Exec now demands the second turn for any smarthome row whatever
the column says, and lock is out of the default domain set so a bare
block does not propose an unlock for every door.

wireSmartHome enumerated the house synchronously, inside wireVoice,
before the socket was serving and inside the unlock handler. A box that
black-holes the connection held the daemon's start for the per-call
timeout. The first propose moved onto the ticker goroutine.

Smaller: an unreachable lamp is counted apart from an off one, a
truncated on-list says how many it left out, refresh has a floor of a
minute, and the http url is documented as a deliberate wg-only choice.
Found in review of #80.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 14:06:38 +04:00

218 lines
8.4 KiB
Go

// Package tool is maven's act executor: it runs the ENABLED tools from the
// store's allowlist, and drafts 'proposed' scaffolds for acts that aren't on
// it yet.
//
// Boundary discipline (DESIGN.md § "Tool registration — drafting is suggest,
// enabling is act"):
//
// - The store is the allowlist. Only status='enabled' rows run. A verb not
// on it → refuse ("not on the list → refuse, don't improvise") and draft a
// 'proposed' scaffold instead. Enabling a proposal is a human act on an
// authed surface (mavweb), gated at AuthStepUp — never the voice path, so
// a compromised router can't grant itself a capability.
// - Args are passed as argv, NEVER through a shell. STT text lands as
// positional arguments to Cmd; there is no `sh -c`, so "restart nginx;
// rm -rf" can't inject — the tail is one argv element to the named binary.
// - An enabled row whose cmd is ["smarthome", "<entity_id>", "<service>"] is
// a Home Assistant service call instead of a process (Vikunja #256), by
// exactly the same trick and under exactly the same rules. Control rows are
// always destructive, so flipping something in his flat always costs a
// confirm turn.
// - An enabled row whose cmd is ["mcp", "<server>", "<tool>"] is a call to a
// configured MCP server instead of a process (Vikunja #251). It goes
// through every rule above unchanged — enabled, and confirmed if it
// mutates — because the store is still the allowlist; only the dispatch at
// the bottom of Exec differs.
// - Destructive tools don't run on first hearing: Exec returns ErrNeedsConfirm
// and the handler runs a confirm turn ("выполнить X? да/нет"); only a
// confirmed re-Exec runs them. A gate assumes a fully-formed action, which
// an enabled+matched act is (DESIGN.md § "Confirmation is not one
// mechanism").
package tool
import (
"bytes"
"context"
"errors"
"fmt"
"log"
"os/exec"
"strings"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/mcp"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/smarthome"
)
// API — the narrow slice of ipc.CoreAPI the executor and matcher need. Backed
// in-process by the daemon's store adapter (a direct sqlite query per call —
// acts are rare, personal-scale; no cache).
type API interface {
LookupTool(ctx context.Context, name string) (ipc.Tool, error)
ListTools(ctx context.Context, status string) ([]ipc.Tool, error)
ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error)
}
var (
// ErrNotEnabled — the fn isn't an enabled tool (absent, or still proposed).
ErrNotEnabled = errors.New("tool not on the enabled allowlist")
// ErrNeedsConfirm — the fn is enabled but destructive; needs a confirm turn.
ErrNeedsConfirm = errors.New("destructive tool needs confirmation")
)
// 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
// silently doing nothing.
type MCPCaller interface {
CallPositional(ctx context.Context, server, tool string, args []string) (string, error)
}
// HomeCaller is the seam for an act that is a Home Assistant service call
// rather than a process (Vikunja #256). internal/smarthome.Client satisfies it.
// nil ⇒ the house is not configured, and a house row refuses to run rather than
// silently doing nothing.
type HomeCaller interface {
CallService(ctx context.Context, entityID, service string) (string, error)
}
// Executor runs enabled tools. run is the exec seam (default: real process);
// tests swap it. timeout bounds each invocation.
type Executor struct {
api API
timeout time.Duration
run func(ctx context.Context, argv []string) (string, error)
mcp MCPCaller
home HomeCaller
}
// NewExecutor builds the executor. timeout<=0 defaults to 30s.
func NewExecutor(api API, timeout time.Duration) *Executor {
if timeout <= 0 {
timeout = 30 * time.Second
}
return &Executor{api: api, timeout: timeout, run: runProcess}
}
// WithMCP attaches the MCP caller. Called once at wiring time when the mcp
// config block is present; without it, a row whose cmd is ["mcp", …] refuses.
func (e *Executor) WithMCP(m MCPCaller) *Executor {
e.mcp = m
return e
}
// WithHome attaches the Home Assistant caller. Called once at wiring time when
// the smarthome block is enabled; without it, a row whose cmd is
// ["smarthome", …] refuses.
func (e *Executor) WithHome(h HomeCaller) *Executor {
e.home = h
return e
}
// Exec looks up name in the store and runs Cmd+args as argv (no shell).
// confirmed=true is the second turn of a destructive act (the user said "да");
// it bypasses the ErrNeedsConfirm gate. Non-enabled ⇒ ErrNotEnabled; a
// destructive tool with confirmed=false ⇒ ErrNeedsConfirm.
func (e *Executor) Exec(ctx context.Context, name string, args []string, confirmed bool) (string, error) {
t, err := e.api.LookupTool(ctx, name)
if errors.Is(err, ipc.ErrToolNotFound) {
return "", ErrNotEnabled
}
if err != nil {
return "", err
}
if t.Status != "enabled" {
return "", ErrNotEnabled
}
if t.Destructive && !confirmed {
return "", ErrNeedsConfirm
}
// An MCP row is a call to a configured server, not a process. Everything
// above still applied: it had to be enabled, and a mutating one had to be
// confirmed. Only the dispatch differs.
if server, remote, ok := mcp.ParseCmd(t.Cmd); ok {
if e.mcp == nil {
return "", ErrNotEnabled
}
ctx, cancel := context.WithTimeout(ctx, e.timeout)
defer cancel()
return e.mcp.CallPositional(ctx, server, remote, args)
}
// A house row is a Home Assistant service call, not a process (Vikunja
// #256). Same story: enabled, and confirmed — every control row is
// destructive, because there is no read-only way to turn the heating off.
// The spoken args are dropped on purpose: the entity and the service come
// from the row Kami enabled, so a router that misheard can pick the wrong
// row but can never compose a target of its own.
if entityID, service, ok := smarthome.ParseCmd(t.Cmd); ok {
if e.home == nil {
return "", ErrNotEnabled
}
// The confirm turn on a house row is structural, not a column. The
// proposal is written destructive=true, but /tools writes the checkbox
// straight through on enable (destructive=excluded.destructive), so
// unticking it once turned home_lock_front_door_unlock into a row that
// ran on first hearing. Nothing any surface writes can remove the
// second turn from a physical device.
if !confirmed {
return "", ErrNeedsConfirm
}
ctx, cancel := context.WithTimeout(ctx, e.timeout)
defer cancel()
return e.home.CallService(ctx, entityID, service)
}
argv := append(append([]string(nil), t.Cmd...), args...)
if len(argv) == 0 {
return "", ErrNotEnabled
}
ctx, cancel := context.WithTimeout(ctx, e.timeout)
defer cancel()
return e.run(ctx, argv)
}
func runProcess(ctx context.Context, argv []string) (string, error) {
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
err := cmd.Run()
out := strings.TrimSpace(buf.String())
if err != nil {
return out, fmt.Errorf("run %v: %w", argv, err)
}
return out, nil
}
// Matcher — a router.ActMatcher whose allowlist is the live set of enabled
// tool names (one source of truth with the executor). Match delegates to the
// router's default prefix logic over the current names. The interface's Match
// has no ctx, so it queries with a background context — an in-process sqlite
// read on the daemon.
type Matcher struct{ api API }
// NewMatcher builds a store-backed act matcher.
func NewMatcher(api API) *Matcher { return &Matcher{api: api} }
func (m *Matcher) names() []string {
ts, err := m.api.ListTools(context.Background(), "enabled")
if err != nil {
log.Printf("tool: list enabled tools: %v", err)
return nil
}
names := make([]string, len(ts))
for i, t := range ts {
names[i] = t.Name
}
return names
}
// Allowlist — the enabled verbs (for stage-0 grammar wiring / introspection).
func (m *Matcher) Allowlist() []string { return m.names() }
// Match — longest-verb-first prefix match over the live enabled allowlist.
func (m *Matcher) Match(utterance string) (string, []string, bool) {
return router.DefaultActMatcher{Fns: m.names()}.Match(utterance)
}