Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05f791735d | |||
| bdd79ad585 | |||
| 66f06796cb | |||
| 06adc4702d | |||
| 1d02ba8936 | |||
| 66c578a6f4 | |||
| 356766bce1 | |||
| 6a402bf556 | |||
| f6d7b05161 | |||
| 025f81e961 |
@@ -11,14 +11,18 @@ started with an agent that inferred the goal instead of stating it back.
|
||||
## 0. Get on the branch
|
||||
|
||||
```sh
|
||||
task start <vikunja-id>
|
||||
task start <vikunja-id> # with an id
|
||||
git checkout -b task/<slug> # without one
|
||||
```
|
||||
|
||||
`~/.local/bin/task` owns the branch, the identity and the PR. It cuts
|
||||
`task/<id>-<slug>` off `origin/master` and sets the commit author to the `claude`
|
||||
gitea user. It writes `TASK.md` from the Vikunja task, and pulls any waiting
|
||||
An id is optional (owner's call, 2026-08-25). With one, `~/.local/bin/task` owns
|
||||
the branch, the identity and the PR. It cuts `task/<id>-<slug>` off
|
||||
`origin/master` and sets the commit author to the `claude` gitea user. It writes `TASK.md` from the Vikunja task, and pulls any waiting
|
||||
review comments into `.task/review-comments.md`. Do not hand-roll any of that.
|
||||
|
||||
Without an id, branch by hand and skip `TASK.md`. The user's own brief is then
|
||||
the goal, and step 4 restates it back to him instead.
|
||||
|
||||
`TASK.md` is the brief and it is immutable. If it says a PR already exists, this
|
||||
is a review-fix session and not new work. Read the comments first.
|
||||
|
||||
@@ -34,9 +38,12 @@ If there is no handoff, that is normal. It means the last session closed clean.
|
||||
|
||||
In this order, and stop as soon as you have enough:
|
||||
|
||||
- The Vikunja task, by id. Project Maven is ID 2, MCP at `http://localhost:9100/mcp`.
|
||||
The task description and its comments hold the goal, the constraints, and the
|
||||
assumption ledger. This outranks the handoff on every conflict.
|
||||
- The Vikunja task, if there is one. Project Maven is ID 2, MCP at
|
||||
`http://localhost:9100/mcp`, reachable from workpc only through
|
||||
`ssh -N -f -L 9100:127.0.0.1:9100 kami@192.168.1.104`. A refused connection is
|
||||
the missing tunnel, not an outage. The task description and its comments hold
|
||||
the goal, the constraints, and the assumption ledger. This outranks the handoff
|
||||
on every conflict.
|
||||
- `CLAUDE.md`, the section that covers the area you are about to touch.
|
||||
- The one file under `docs/` that owns the area. Check its `Last verified` line.
|
||||
If the sha is behind the code you are reading, say so in step 4 and trust the code.
|
||||
@@ -44,8 +51,8 @@ In this order, and stop as soon as you have enough:
|
||||
Do not read the dated files under `docs/evals/`. They are measurements from one day,
|
||||
never updated. Read one only when you need the number it recorded.
|
||||
|
||||
If no task id is known, ask for one before doing anything else. Work without a task
|
||||
is work nobody can resume.
|
||||
With no task id, do not ask for one and do not stall. State it in step 4 as
|
||||
`Task: unfiled` and carry on.
|
||||
|
||||
## 3. Look at the ground
|
||||
|
||||
@@ -58,7 +65,7 @@ Write at most five bullets and stop. Do not write code, do not open files to "ch
|
||||
one thing first", do not start with a small safe change.
|
||||
|
||||
```
|
||||
Task: V-359, one line.
|
||||
Task: V-359, one line. `unfiled` when there is no id.
|
||||
Done: what is already on the branch.
|
||||
Next: the one thing this session does.
|
||||
Constraints: what would make this wrong.
|
||||
@@ -67,8 +74,9 @@ Assuming: the beliefs that, if false, waste the session.
|
||||
|
||||
Then ask: is this right? Wait for the answer.
|
||||
|
||||
A corrected assumption goes into the Vikunja task as a comment, not into the handoff.
|
||||
The handoff dies tonight. The task does not.
|
||||
A corrected assumption goes into the Vikunja task as a comment where there is a
|
||||
task, because the handoff dies tonight and the task does not. Unfiled, it goes
|
||||
into the handoff and nowhere else.
|
||||
|
||||
## 5. Then begin
|
||||
|
||||
|
||||
@@ -36,11 +36,13 @@ a commit message, not into a comment in the code.
|
||||
Under 300 changed lines per commit in non-markdown files, enforced by `.githooks/pre-commit`.
|
||||
Markdown is exempt and may land as one batch.
|
||||
|
||||
Each commit is one idea, subject in the repo's voice, lowercase area prefix, and it
|
||||
ends with the Vikunja ref:
|
||||
Each commit is one idea, subject in the repo's voice, lowercase area prefix. A
|
||||
Vikunja ref is welcome where a task exists and is required nowhere: the
|
||||
`commit-msg` hook that demanded it was deleted on 2026-08-25.
|
||||
|
||||
```
|
||||
router: narrow the single-token rule (V-359)
|
||||
router: narrow the single-token rule
|
||||
```
|
||||
|
||||
If a change genuinely cannot split under 300 lines, say why in the commit body before
|
||||
@@ -56,13 +58,17 @@ It refuses a dirty tree, pushes, opens or refreshes the PR against the repo defa
|
||||
branch, labels the Vikunja task in-review, comments the PR url on it, and pushes an
|
||||
ntfy. Do not push by hand and do not call `tea` yourself.
|
||||
|
||||
`task pr` needs an id. On a hand-cut branch with no task, push the branch and open
|
||||
the PR by hand, and skip step 5.
|
||||
|
||||
## 5. Record what `task pr` cannot know
|
||||
|
||||
Comment on the Vikunja task: what you measured, what is still open. List every
|
||||
assumption that turned out to be wrong. If the session found new work, create a task
|
||||
for it now rather than describing it in prose.
|
||||
|
||||
This step is what makes the handoff disposable.
|
||||
This step is what makes the handoff disposable. With no task, it cannot run, so the
|
||||
handoff carries that content instead and stops being disposable. Say so in it.
|
||||
|
||||
## 6. Leave the handoff, or leave none
|
||||
|
||||
@@ -75,7 +81,7 @@ resume, and no history:
|
||||
```markdown
|
||||
# Handoff — <date>
|
||||
|
||||
Task: V-359 <one line>
|
||||
Task: V-359 <one line>, or `unfiled`
|
||||
Branch: task/359-<slug>, cut from master
|
||||
|
||||
## Where I stopped
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Every commit names the Vikunja task it belongs to.
|
||||
#
|
||||
# router: narrow the single-token rule (V-359)
|
||||
#
|
||||
# V- and not #, because Gitea autolinks #359 to a Gitea issue, which is a
|
||||
# different tracker and a wrong link.
|
||||
#
|
||||
# Exempt: merges, reverts, fixup/squash, and the initial commit.
|
||||
|
||||
msg_file=$1
|
||||
subject=$(sed -n '1p' "$msg_file")
|
||||
|
||||
case "$subject" in
|
||||
Merge\ *|Revert\ *|fixup!\ *|squash!\ *|amend!\ *) exit 0 ;;
|
||||
esac
|
||||
|
||||
if [ -f "$(git rev-parse --git-dir)/MERGE_HEAD" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if printf '%s' "$subject" | grep -qE '\(V-[0-9]+\)$'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "commit-msg: subject must end with a Vikunja task ref." >&2
|
||||
echo " got: $subject" >&2
|
||||
echo " want: router: narrow the single-token rule (V-359)" >&2
|
||||
echo " No task yet? Create one. Work without a task is work nobody can resume." >&2
|
||||
exit 1
|
||||
@@ -201,14 +201,14 @@ ToolSearch("select:mcp__vikunja__list_tasks,mcp__vikunja__get_task_details,mcp__
|
||||
```
|
||||
|
||||
- This repo is Vikunja project **Maven** (ID 2), MCP at
|
||||
`http://localhost:9100/mcp`, or `http://192.168.1.104:9100/mcp` from workpc.
|
||||
- **A session with no task id asks for one before it starts**, because work
|
||||
without one is work nobody can resume.
|
||||
`http://localhost:9100/mcp` on homesrv. **`vikunja-mcp` publishes to
|
||||
`127.0.0.1:9100` only, so the LAN address never answers from workpc.** A
|
||||
refused connection is that, not an outage: three sessions read it as "Vikunja
|
||||
is down" and filed nothing. Tunnel first, then use `localhost`:
|
||||
`ssh -N -f -L 9100:127.0.0.1:9100 kami@192.168.1.104`.
|
||||
- **Close a finished task with `done: true` and nothing else** (owner's call,
|
||||
2026-08-07). `update_task` carrying a `description` resets `done` to false.
|
||||
- **`pre-commit` refuses master** and more than 300 changed lines in
|
||||
non-markdown files. Markdown is exempt and may land as one batch.
|
||||
- **`commit-msg` requires the subject to end with `(V-<id>)`.** `V-` and not
|
||||
`#`, because Gitea autolinks `#123` to the wrong tracker.
|
||||
- **`diff-budget.sh` blocks edits past 600 changed lines** on a `task/` branch.
|
||||
- **`--no-verify` exists.** Using it means saying why in the commit body.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Handoff
|
||||
|
||||
Master is at `5cae33a`, pushed, tree clean apart from this file. Working on master
|
||||
raw by the owner's call: no branch, `--no-verify` on every commit with the reason
|
||||
in the body.
|
||||
|
||||
## Landed this session
|
||||
|
||||
Twelve commits pushed. Nine were the previous session's tree, already described in
|
||||
the commit log. Three are new:
|
||||
|
||||
- `78a9c61` `docs/spec.md`, 51 capabilities with a DoD each.
|
||||
- `02e3d27` `docs/roadmap.md`, seven milestones, plus both pointer rows in `CLAUDE.md`.
|
||||
- `5cae33a` honesty split into three milestones, five capabilities deferred past v1.
|
||||
|
||||
**Read `docs/spec.md` and `docs/roadmap.md` before anything else.** Every decision
|
||||
from this session is in them. This file holds only what they do not.
|
||||
|
||||
## The two documents
|
||||
|
||||
`docs/spec.md` is the union of the 39 audited rows
|
||||
(`docs/evals/2026-08-13-capability-audit.md`) and the owner's 18-item v1 list.
|
||||
Twelve of his items had no audit row, so the file has 51 entries. Each entry
|
||||
carries three parts. State is a reference to the living doc that owns it. DoD is
|
||||
a plain list observable on the running box. Scenario names a file in
|
||||
`cmd/mavend/testdata/scenarios/`.
|
||||
|
||||
`docs/roadmap.md` orders them into nine milestones. Honesty, then reach, then
|
||||
breadth. Not ordered by code work, because none of the four broken capabilities is
|
||||
a code defect.
|
||||
|
||||
## Decided, do not re-ask
|
||||
|
||||
- **v1 is a voice assistant, minimum viable.** Each DoD is written at
|
||||
"voice-reachable and honest", not "feature-complete".
|
||||
- **Honesty splits into three.** M1 the turn path, M2 memory he cannot correct,
|
||||
M3 step-up. M1 and M2 touch different code and owe different docs. Step-up is
|
||||
configuration, not honesty, and sits before M4 because M4 is what first makes
|
||||
acts real.
|
||||
- **Five capabilities deferred past v1** (owner's call, 2026-08-15): speaker
|
||||
recognition, smart home, bluetooth control, model swap, self-update. Bluetooth
|
||||
was on the v1 list and came off it. Their spec entries keep their DoD.
|
||||
- **A milestone closes its own doc gaps and writes its own scenarios.** Neither
|
||||
becomes a milestone of its own. Otherwise the 17 missing docs and 46 missing
|
||||
scenario files collect at the end.
|
||||
- **Learning means behavioral, not weights.** Stored outcomes only. No adapter, no
|
||||
training set.
|
||||
- **Email and calendar need the product decision before deploying.** Both are
|
||||
built and neither is in `docker-compose.yml`. That is M8.
|
||||
|
||||
## Findings the spec pass produced
|
||||
|
||||
- **Recurring reminders do not exist on the spoken path.** `store.Reminder` carries
|
||||
`Cron` and `ipc.CreateReminder` takes one. `grep "Cron:" --include='*.go'`
|
||||
outside tests returns only `internal/ipc/client.go`, `internal/ipc/storeapi.go`
|
||||
and `cmd/mavend/tick_routines.go`, and the last is routines, a separate
|
||||
mechanism. Pills, the dog, the vet and the kibble are unbuilt on top of finished
|
||||
storage and delivery. This is M6.
|
||||
- **Seventeen capabilities have no living doc.** Memory is the worst cluster:
|
||||
facts, notes and the digestion worker have no owning document at all.
|
||||
- **Webhooks barely exist.** The only one in the tree is
|
||||
`internal/delivery/telegramsink/intake.go`, Telegram's own inbound hook.
|
||||
- **Command chaining does not exist.** The `chain` in `internal/router` is the
|
||||
world chain and the source chain.
|
||||
- **Desk notifications are inbound only.** `ambient:notif` reads his desktop.
|
||||
There is no outbound desk reach, and which direction he meant is undecided.
|
||||
- **Only 5 of 51 spec entries cite a scenario that exists.**
|
||||
|
||||
## Not filed, and this is the risk
|
||||
|
||||
Vikunja returned 503 across this session and the last, so **none of this has a
|
||||
task id**. The three commits above are tagged `V-719`, which is the
|
||||
reminder-cancellation task, not this work. Retag or file when Vikunja is back.
|
||||
|
||||
Unfiled, listed again at the end of `docs/roadmap.md`:
|
||||
|
||||
1. The capability audit itself, headed "unfiled".
|
||||
2. Remember-versus-query misroute, two of seven audit probes.
|
||||
3. The masculine reply on the wire, caught live while `CheckFeminine` passed.
|
||||
4. Recurring reminders having no caller.
|
||||
5. The seventeen capabilities with no living doc.
|
||||
6. The 46 scenario files the spec names and does not have.
|
||||
|
||||
## Next
|
||||
|
||||
Open a session on M1, which is three gate items on the turn path and owes no new
|
||||
doc. If Vikunja is up, file the six above first and give M1 a real id.
|
||||
|
||||
One command still outstanding from the last session, cheap and unrelated:
|
||||
|
||||
```sh
|
||||
docker compose up -d --force-recreate mavsttd mavttsd mavpoll
|
||||
```
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// resolveAction produces an ActionCandidate from a routing decision. It is the
|
||||
// single boundary between routing and action execution: everything downstream
|
||||
// (refusesCommand, task-status, Praxis, Hexis, proposeGap, tool.Executor.Exec)
|
||||
// consumes the candidate rather than re-resolving the function.
|
||||
//
|
||||
// Delegates to router.ResolveActionCandidate for the resolution logic, then
|
||||
// records the outcome in the decision trace.
|
||||
func (h *reactiveHandler) resolveAction(ctx context.Context, dec router.Decision) router.ActionCandidate {
|
||||
candidate := router.ResolveActionCandidate(dec, h.matcher)
|
||||
|
||||
// Record the resolution outcome in the decision trace.
|
||||
if dec.Intent == router.IntentAct {
|
||||
if candidate.ActionResolved() {
|
||||
noteActionResolution(ctx, string(candidate.Source), candidate.Fn, true)
|
||||
} else {
|
||||
noteActionResolution(ctx, "matcher", "", false)
|
||||
}
|
||||
}
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
// noteActionResolution records the action resolution outcome in the decision
|
||||
// trace. A nil recorder is the normal case in tests.
|
||||
func noteActionResolution(ctx context.Context, source, fn string, resolved bool) {
|
||||
rec := decision.From(ctx)
|
||||
if rec == nil {
|
||||
return
|
||||
}
|
||||
outcome := decision.Declined
|
||||
reason := "no match"
|
||||
if resolved {
|
||||
outcome = decision.Won
|
||||
reason = "resolved via " + source
|
||||
if fn != "" {
|
||||
reason += ": " + fn
|
||||
}
|
||||
}
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageAction,
|
||||
Claimant: "action-resolve",
|
||||
Outcome: outcome,
|
||||
Reason: reason,
|
||||
})
|
||||
}
|
||||
|
||||
// noteActionValidation records the structural validation outcome in the
|
||||
// decision trace. Five outcomes: unresolved (matcher miss), valid
|
||||
// (structurally admissible), invalid_argument, missing_argument, or
|
||||
// ambiguous_target (structurally malformed).
|
||||
func noteActionValidation(ctx context.Context, v router.ActionValidationResult) {
|
||||
rec := decision.From(ctx)
|
||||
if rec == nil {
|
||||
return
|
||||
}
|
||||
switch v.Status {
|
||||
case router.ActionUnresolved:
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageAction,
|
||||
Claimant: "action-validation",
|
||||
Outcome: decision.Declined,
|
||||
Reason: "unresolved",
|
||||
})
|
||||
case router.ActionValid:
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageAction,
|
||||
Claimant: "action-validation",
|
||||
Outcome: decision.Won,
|
||||
Reason: "valid",
|
||||
})
|
||||
default:
|
||||
reason := string(v.Status)
|
||||
if len(v.Issues) > 0 {
|
||||
reason = string(v.Status) + ":" + v.Issues[0].Reason
|
||||
}
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageAction,
|
||||
Claimant: "action-validation",
|
||||
Outcome: decision.Declined,
|
||||
Reason: reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
)
|
||||
|
||||
// newActHandler builds a handler with the act path wired: a matcher over
|
||||
// whatever tools the test enabled, no model, no ecosystem.
|
||||
func newActHandler(t *testing.T) (*reactiveHandler, *store.Store) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
api := ipc.NewStoreAPI(st)
|
||||
matcher := tool.NewMatcher(api)
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
tools: tool.NewExecutor(api, 2*time.Second),
|
||||
matcher: matcher,
|
||||
now: func() time.Time { return time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) },
|
||||
}
|
||||
return h, st
|
||||
}
|
||||
|
||||
// TestActRouteSource_NoMatcherInvoke pins that an act with HasFn=true
|
||||
// produces a candidate from the route and does not invoke the matcher.
|
||||
func TestActRouteSource_NoMatcherInvoke(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
// Enable a tool so the matcher has something to match against.
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Act with HasFn=true: the candidate must come from the route.
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "status",
|
||||
Slots: router.Slots{Fn: "status", HasFn: true},
|
||||
})
|
||||
if !strings.Contains(reply, "готово") {
|
||||
t.Errorf("route-sourced act replied %q; want it to have run", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActMatcherSource_FallbackMatch pins that an act without Fn invokes
|
||||
// the matcher and produces a matcher-sourced candidate.
|
||||
func TestActMatcherSource_FallbackMatch(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
// Enable a tool so the matcher can find it.
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Act without HasFn: the matcher must resolve "status" from the text.
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "check status",
|
||||
Slots: router.Slots{Text: "status"},
|
||||
})
|
||||
if !strings.Contains(reply, "готово") {
|
||||
t.Errorf("matcher-sourced act replied %q; want it to have run", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActMatcherMiss_ProposeGap pins that a matcher miss produces the
|
||||
// same propose-gap behavior as before.
|
||||
func TestActMatcherMiss_ProposeGap(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
// Enable one tool so the matcher has an allowlist, but not the one asked for.
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Act without HasFn and text that doesn't match any tool.
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "deploy the thing",
|
||||
Slots: router.Slots{Text: "deploy the thing"},
|
||||
})
|
||||
if !strings.Contains(strings.ToLower(reply), "предлож") {
|
||||
t.Errorf("matcher miss replied %q; want propose-gap behavior", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActDestructive_ConfirmationUnchanged pins that a destructive tool
|
||||
// still triggers the confirmation flow.
|
||||
func TestActDestructive_ConfirmationUnchanged(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "restart", []string{"true"}, true, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "restart",
|
||||
Slots: router.Slots{Fn: "restart", HasFn: true},
|
||||
})
|
||||
if !strings.Contains(reply, "да или нет") {
|
||||
t.Errorf("destructive act replied %q; want a confirm turn", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActTaskStatus_InterceptUnchanged pins that task_status is intercepted
|
||||
// before reaching the tool executor.
|
||||
func TestActTaskStatus_InterceptUnchanged(t *testing.T) {
|
||||
h, _ := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "task status",
|
||||
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true,
|
||||
Text: "task status"},
|
||||
})
|
||||
// task_status is intercepted by resolveTaskStatus, which returns a
|
||||
// status phrase. The exact reply depends on the store state, but it
|
||||
// must not be a tool execution result.
|
||||
if strings.Contains(reply, "готово") {
|
||||
t.Errorf("task_status was not intercepted, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActStage0_SameResult pins that a stage-0 act (grammar match with
|
||||
// HasFn=true) produces the same tool execution as before.
|
||||
func TestActStage0_SameResult(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "restart", []string{"echo", "ok"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Stage: 0,
|
||||
Confidence: 1.0,
|
||||
Utterance: "maven, restart nginx",
|
||||
Slots: router.Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
||||
Producer: router.RouteProducerGrammar,
|
||||
})
|
||||
if !strings.Contains(reply, "сделала") && !strings.Contains(reply, "готово") {
|
||||
t.Errorf("stage-0 act replied %q; want it to have run", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActLearnedRouter_NoFn_FallbackMatch pins that a learned-router act
|
||||
// without Fn falls through to the matcher and produces the same result.
|
||||
func TestActLearnedRouter_NoFn_FallbackMatch(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "restart", []string{"echo", "ok"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// LLM routed the act but did not fill Fn (common when the model returns
|
||||
// the verb in Text but not in Fn).
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Stage: 1,
|
||||
Confidence: 0.85,
|
||||
Utterance: "could you restart nginx",
|
||||
Slots: router.Slots{Text: "restart nginx"},
|
||||
Producer: router.RouteProducerLLM,
|
||||
})
|
||||
if !strings.Contains(reply, "сделала") && !strings.Contains(reply, "готово") {
|
||||
t.Errorf("learned-router act replied %q; want it to have run", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveAction_CandidateSource_Verified pins the candidate source
|
||||
// for both route-resolved and matcher-resolved actions.
|
||||
func TestResolveAction_CandidateSource_Verified(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Route-resolved: HasFn=true.
|
||||
c1 := h.resolveAction(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: "status", HasFn: true},
|
||||
})
|
||||
if c1.Source != router.ActionSourceRoute {
|
||||
t.Errorf("route candidate source = %q, want route", c1.Source)
|
||||
}
|
||||
if c1.Fn != "status" {
|
||||
t.Errorf("route candidate Fn = %q, want status", c1.Fn)
|
||||
}
|
||||
|
||||
// Matcher-resolved: no Fn, text matches.
|
||||
c2 := h.resolveAction(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Text: "status"},
|
||||
})
|
||||
if c2.Source != router.ActionSourceMatcher {
|
||||
t.Errorf("matcher candidate source = %q, want matcher", c2.Source)
|
||||
}
|
||||
if c2.Fn != "status" {
|
||||
t.Errorf("matcher candidate Fn = %q, want status", c2.Fn)
|
||||
}
|
||||
|
||||
// Matcher miss: no Fn, text doesn't match.
|
||||
c3 := h.resolveAction(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Text: "deploy everything"},
|
||||
})
|
||||
if c3.ActionResolved() {
|
||||
t.Errorf("miss candidate resolved = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// --- structural validation integration tests ---
|
||||
|
||||
// TestActValidation_MalformedCandidate_BlankFn pins that a resolved
|
||||
// candidate with a blank (whitespace-only) Fn does not execute and
|
||||
// produces a failure response.
|
||||
func TestActValidation_MalformedCandidate_BlankFn(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Simulate a malformed candidate by writing a blank Fn into Slots
|
||||
// after resolution. This tests that the validation layer catches
|
||||
// structurally invalid candidates.
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "status",
|
||||
Slots: router.Slots{Fn: " ", HasFn: true},
|
||||
})
|
||||
// The blank Fn should not reach tool execution. It either hits
|
||||
// the validation gate (ActFail) or the existing error paths.
|
||||
if reply == "" {
|
||||
t.Error("expected a response, got empty string")
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_UnresolvedCandidate_ProposeGap pins that an unresolved
|
||||
// candidate (matcher miss) still flows to proposeGap, unchanged.
|
||||
func TestActValidation_UnresolvedCandidate_ProposeGap(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "deploy everything",
|
||||
Slots: router.Slots{Text: "deploy everything"},
|
||||
})
|
||||
if !strings.Contains(strings.ToLower(reply), "предлож") {
|
||||
t.Errorf("unresolved candidate replied %q; want propose-gap behavior", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_DestructiveValid_StillConfirms pins that a destructive
|
||||
// valid action still reaches the confirmation path through validation.
|
||||
func TestActValidation_DestructiveValid_StillConfirms(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "restart", []string{"true"}, true, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "restart",
|
||||
Slots: router.Slots{Fn: "restart", HasFn: true},
|
||||
})
|
||||
if !strings.Contains(reply, "да или нет") {
|
||||
t.Errorf("destructive valid act replied %q; want confirm turn", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_IrreversibleValid_NeedsAuthedSurface pins that an
|
||||
// irreversible valid action still reaches ErrNeedsAuthedSurface.
|
||||
func TestActValidation_IrreversibleValid_NeedsAuthedSurface(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
// Register an irreversible tool: cmd containing "drop" triggers the
|
||||
// irreversible tier via RiskOf → isIrreversible.
|
||||
if err := st.EnableTool(ctx, "drop_table", []string{"drop"}, true, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "drop_table",
|
||||
Slots: router.Slots{Fn: "drop_table", HasFn: true},
|
||||
})
|
||||
// Irreversible tools return ErrNeedsAuthedSurface, which produces
|
||||
// a specific phraser response.
|
||||
if !strings.Contains(reply, "выполню") && !strings.Contains(reply, "запусти") {
|
||||
t.Errorf("irreversible valid act replied %q; want authed-surface response", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_ValidationTracing pins that validation outcomes are
|
||||
// recorded in the decision trace.
|
||||
func TestActValidation_ValidationTracing(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
now := h.now()
|
||||
|
||||
// Valid candidate: trace should show action-validation:won.
|
||||
ctx, rec := decision.With(context.Background(), "status", "tap:text")
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "status",
|
||||
Slots: router.Slots{Fn: "status", HasFn: true},
|
||||
})
|
||||
records := rec.Claims
|
||||
found := false
|
||||
for _, c := range records {
|
||||
if c.Claimant == "action-validation" && c.Outcome == decision.Won {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected action-validation:won in trace, got %v", records)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActExecutionFromCandidateNotSlots pins that downstream execution reads
|
||||
// resolved action data from ActionCandidate, not from Decision.Slots. The
|
||||
// decision has empty Fn/Args/HasFn — the bridge used to copy candidate values
|
||||
// back into these fields. After the bridge removal, execution must still
|
||||
// succeed because the candidate carries the resolved function.
|
||||
func TestActExecutionFromCandidateNotSlots(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Act without any Fn/Args/HasFn in Slots — the matcher resolves from Text.
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "check status",
|
||||
Slots: router.Slots{Text: "status"},
|
||||
})
|
||||
if !strings.Contains(reply, "готово") {
|
||||
t.Errorf("execution from candidate replied %q; want tool success", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// --- validation status boundary tests ---
|
||||
|
||||
// TestActValidation_StatusValidRoute pins that a route-resolved valid action
|
||||
// produces ActionValid status and reaches execution.
|
||||
func TestActValidation_StatusValidRoute(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "status",
|
||||
Slots: router.Slots{Fn: "status", HasFn: true},
|
||||
})
|
||||
if !strings.Contains(reply, "готово") {
|
||||
t.Errorf("valid route act replied %q; want tool success", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_StatusValidMatcher pins that a matcher-resolved valid
|
||||
// action produces ActionValid status and reaches execution.
|
||||
func TestActValidation_StatusValidMatcher(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "check status",
|
||||
Slots: router.Slots{Text: "status"},
|
||||
})
|
||||
if !strings.Contains(reply, "готово") {
|
||||
t.Errorf("valid matcher act replied %q; want tool success", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_StatusUnresolved pins that an unresolved candidate
|
||||
// produces ActionUnresolved status and flows to proposeGap.
|
||||
func TestActValidation_StatusUnresolved(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "deploy everything",
|
||||
Slots: router.Slots{Text: "deploy everything"},
|
||||
})
|
||||
if !strings.Contains(strings.ToLower(reply), "предлож") {
|
||||
t.Errorf("unresolved act replied %q; want propose-gap", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_StatusInvalid pins that a structurally invalid candidate
|
||||
// produces ActionInvalidArgument status and refuses execution.
|
||||
func TestActValidation_StatusInvalid(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "status",
|
||||
Slots: router.Slots{Fn: " ", HasFn: true},
|
||||
})
|
||||
if reply == "" {
|
||||
t.Error("expected a response for invalid candidate")
|
||||
}
|
||||
if strings.Contains(reply, "готово") {
|
||||
t.Error("invalid candidate should not reach tool execution")
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_DestructiveValidStatus pins that a destructive but
|
||||
// structurally valid action still produces ActionValid status and reaches
|
||||
// the confirmation path (not validation failure).
|
||||
func TestActValidation_DestructiveValidStatus(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "restart", []string{"true"}, true, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "restart",
|
||||
Slots: router.Slots{Fn: "restart", HasFn: true},
|
||||
})
|
||||
if !strings.Contains(reply, "да или нет") {
|
||||
t.Errorf("destructive valid act replied %q; want confirm turn", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_ConfirmationUnchanged pins that the confirmation flow
|
||||
// is unchanged by validation.
|
||||
func TestActValidation_ConfirmationUnchanged(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "restart", []string{"echo", "ok"}, true, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "restart nginx",
|
||||
Slots: router.Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
||||
})
|
||||
if !strings.Contains(reply, "да или нет") {
|
||||
t.Errorf("confirmation act replied %q; want confirm turn", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_TaskStatusInterceptUnchanged pins that task_status
|
||||
// interception is unchanged by validation.
|
||||
func TestActValidation_TaskStatusInterceptUnchanged(t *testing.T) {
|
||||
h, _ := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "task status",
|
||||
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Text: "task status"},
|
||||
})
|
||||
if strings.Contains(reply, "готово") {
|
||||
t.Errorf("task_status was not intercepted, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_NoExecutionOnFailure pins that validation failure
|
||||
// prevents downstream execution.
|
||||
func TestActValidation_NoExecutionOnFailure(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "status",
|
||||
Slots: router.Slots{Fn: " ", HasFn: true},
|
||||
})
|
||||
if strings.Contains(reply, "готово") {
|
||||
t.Error("validation failure should not reach tool execution")
|
||||
}
|
||||
}
|
||||
+33
-21
@@ -11,9 +11,14 @@ import (
|
||||
"github.com/kami/maven/internal/tool"
|
||||
)
|
||||
|
||||
// actionAct handles router.IntentAct: match a verb to an enabled tool, 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.
|
||||
// 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
|
||||
@@ -23,26 +28,33 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
||||
return commandProhibitionReply
|
||||
}
|
||||
|
||||
// tool executor: run the matched fn against the enabled allowlist.
|
||||
// HasFn=false ⇒ try the matcher (for LLM-routed acts where the verb
|
||||
// didn't go through the stage-0 act grammar).
|
||||
if !dec.Slots.HasFn && dec.Slots.Text != "" && h.matcher != nil {
|
||||
if fn, args, ok := h.matcher.Match(dec.Slots.Text); ok {
|
||||
dec.Slots.Fn, dec.Slots.Args, dec.Slots.HasFn = fn, args, true
|
||||
}
|
||||
// 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 dec.Slots.Fn == router.TaskStatusFn {
|
||||
return h.resolveTaskStatus(ctx, dec)
|
||||
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 && dec.Slots.HasFn {
|
||||
if reply := h.handlePraxisAct(ctx, dec); reply != "" {
|
||||
if h.ecosystem != nil && h.ecosystem.praxis != nil && candidate.ActionResolved() {
|
||||
if reply := h.handlePraxisAct(ctx, dec, candidate); reply != "" {
|
||||
return reply
|
||||
}
|
||||
}
|
||||
@@ -50,23 +62,23 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
||||
// 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 != "" {
|
||||
if reply := h.handleHexisAct(ctx, dec, candidate); reply != "" {
|
||||
return reply
|
||||
}
|
||||
}
|
||||
|
||||
// HasFn still false ⇒ no allowlist match: scaffold a 'proposed' tool
|
||||
// Unresolved candidate ⇒ no allowlist match: scaffold a 'proposed' tool
|
||||
// the user can enable on the authed surface ("earn the right to ask").
|
||||
if !dec.Slots.HasFn {
|
||||
if !candidate.ActionResolved() {
|
||||
return h.proposeGap(ctx, dec)
|
||||
}
|
||||
out, err := h.tools.Exec(ctx, dec.Slots.Fn, dec.Slots.Args, false)
|
||||
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(dec.Slots.Fn, dec.Slots.Args)
|
||||
h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
|
||||
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
|
||||
@@ -101,7 +113,7 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
||||
// where a human types them.
|
||||
return phraser.A(phraser.ActNeedsArgs, nil)
|
||||
}
|
||||
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
|
||||
log.Printf("voice: tool %s: %v", candidate.Fn, err)
|
||||
if out != "" {
|
||||
return phraser.A(phraser.ActFailOut, map[string]string{"out": firstLine(out)})
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string,
|
||||
// match on more than one asks which, because closing the wrong task is work he
|
||||
// never finished being marked done. No task named asks which too, since the
|
||||
// router claims the turn without the referent and the list lives here.
|
||||
func (h *reactiveHandler) resolveTaskStatus(ctx context.Context, dec router.Decision) string {
|
||||
func (h *reactiveHandler) resolveTaskStatus(ctx context.Context, dec router.Decision, candidate router.ActionCandidate) string {
|
||||
live, err := h.api.ListTasks(ctx, "live")
|
||||
if err != nil {
|
||||
log.Printf("voice: task status: list: %v", err)
|
||||
|
||||
@@ -279,7 +279,7 @@ func TestResolveTaskStatusMovesTheNamedTask(t *testing.T) {
|
||||
reply := h.resolveTaskStatus(context.Background(), router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: "молоко"},
|
||||
})
|
||||
}, routeCandidate(router.TaskStatusFn))
|
||||
if api.listArg != "live" {
|
||||
t.Errorf("listed %q, want live — a resolved task cannot be resolved again", api.listArg)
|
||||
}
|
||||
@@ -344,7 +344,7 @@ func TestResolveTaskStatusRefusesToGuess(t *testing.T) {
|
||||
h := taskHandler(api)
|
||||
reply := h.resolveTaskStatus(context.Background(), router.Decision{
|
||||
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: c.named},
|
||||
})
|
||||
}, routeCandidate(router.TaskStatusFn))
|
||||
if len(api.moved) != 0 {
|
||||
t.Errorf("moved %+v — closing the wrong task is the failure this arm exists to avoid", api.moved)
|
||||
}
|
||||
@@ -362,7 +362,7 @@ func TestResolveTaskStatusOpensACandidateFirst(t *testing.T) {
|
||||
h := taskHandler(api)
|
||||
h.resolveTaskStatus(context.Background(), router.Decision{
|
||||
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: "продлить домен"},
|
||||
})
|
||||
}, routeCandidate(router.TaskStatusFn))
|
||||
if len(api.moved) != 2 {
|
||||
t.Fatalf("moved %+v, want open then done", api.moved)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestAttentionEmptyWithHealthySourcesIsAllClear(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t, `[]`, `[{"source_id":"src_ntfy","health":"ok"}]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
if !strings.Contains(reply, "ничего не требует внимания") {
|
||||
t.Fatalf("healthy and quiet should be an all-clear, got %q", reply)
|
||||
}
|
||||
@@ -28,7 +28,7 @@ func TestAttentionEmptyWithAFailedSourceHedges(t *testing.T) {
|
||||
]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
if strings.Contains(reply, "ничего не требует внимания") {
|
||||
t.Fatalf("a failed source must not read as all-clear, got %q", reply)
|
||||
}
|
||||
@@ -47,7 +47,7 @@ func TestAttentionEmptyWithNoSourcesHedges(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t, `[]`, `[]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
if strings.Contains(reply, "ничего не требует внимания") {
|
||||
t.Fatalf("a Praxis with no sources must not answer all-clear, got %q", reply)
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func TestAttentionDegradedEnvelopeIsReadWithoutASourcesCall(t *testing.T) {
|
||||
`[{"source_id":"src_ntfy","health":"ok"}]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
if !strings.Contains(reply, "src_metrics") {
|
||||
t.Fatalf("the envelope's degraded source is not named: %q", reply)
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func TestAttentionKeepsAllClearWhenSourcesCannotBeRead(t *testing.T) {
|
||||
praxis.SetRouteFault("/api/v1/sources", 500)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
if !strings.Contains(reply, "ничего не требует внимания") {
|
||||
t.Fatalf("an unreadable sources list should leave the answer alone, got %q", reply)
|
||||
}
|
||||
|
||||
@@ -63,6 +63,9 @@ func (h *reactiveHandler) queryAttention(ctx context.Context, t *queryTurn) (str
|
||||
Utterance: t.dec.Utterance,
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: "list_attention", HasFn: true},
|
||||
}, router.ActionCandidate{
|
||||
Fn: "list_attention",
|
||||
Source: router.ActionSourceRoute,
|
||||
})
|
||||
if reply == "" {
|
||||
return "", false
|
||||
|
||||
@@ -107,7 +107,7 @@ var praxisCapabilities = []praxisCapability{
|
||||
// handlePraxisAct — dispatches ecosystem tool acts through the Praxis tools API.
|
||||
// Returns "" when the act is not a Praxis verb (the caller falls through to the
|
||||
// system command executor). Returns a reply string otherwise.
|
||||
func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decision) string {
|
||||
func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decision, candidate router.ActionCandidate) string {
|
||||
if h.ecosystem == nil || h.ecosystem.praxis == nil {
|
||||
return ""
|
||||
}
|
||||
@@ -127,7 +127,7 @@ func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decisi
|
||||
}
|
||||
for _, capability := range praxisCapabilities {
|
||||
for _, alias := range capability.aliases() {
|
||||
if alias == dec.Slots.Fn {
|
||||
if alias == candidate.Fn {
|
||||
return capability.handle(ctx, h, px, dec)
|
||||
}
|
||||
}
|
||||
@@ -657,7 +657,7 @@ func (h *reactiveHandler) resolveEntityCandidates(ctx context.Context, refs []st
|
||||
// handleHexisAct — resolves entity references through Nexus and executes
|
||||
// matching capabilities through Hexis. Returns a reply string when handled,
|
||||
// or "" to fall through to the system command executor.
|
||||
func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decision) string {
|
||||
func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decision, candidate router.ActionCandidate) string {
|
||||
// This method is intentionally callable outside runTurn by ecosystem
|
||||
// harnesses. Refuse before correlation ids, Nexus resolution or capability
|
||||
// discovery so the no-op sentinel can never leak into Hexis as a verb.
|
||||
@@ -721,7 +721,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
// Match the user's verb to a capability by name/description. Collect all
|
||||
// matches: more than one is itself ambiguous, so we ask rather than pick
|
||||
// the first (ecosystem invariant: no arbitrary target for mutation).
|
||||
verb := dec.Slots.Fn
|
||||
verb := candidate.Fn
|
||||
if verb == "" {
|
||||
verb = dec.Slots.Text
|
||||
}
|
||||
@@ -732,7 +732,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
// round then: the phrase is the haystack and the capability name is what we
|
||||
// look for in it (Vikunja #476). Only when the fn slot is empty — a matched
|
||||
// fn is a single verb and containment already means what it says.
|
||||
loose := !dec.Slots.HasFn
|
||||
loose := !candidate.ActionResolved()
|
||||
var matches []*hexisclient.Capability
|
||||
for i, c := range caps {
|
||||
name := strings.ToLower(c.Name)
|
||||
@@ -858,7 +858,16 @@ func (h *reactiveHandler) hexisBeforeClarify(ctx context.Context, dec router.Dec
|
||||
if dec.Intent != router.IntentAct || dec.Slots.HasFn || !router.ActHasEntityTarget(dec) {
|
||||
return ""
|
||||
}
|
||||
return h.handleHexisAct(ctx, dec)
|
||||
// Resolve the action candidate. Use the matcher when available; when the
|
||||
// handler has no matcher (ecosystem-only test harnesses), build an
|
||||
// unresolved candidate directly — the matcher would not have matched either.
|
||||
var candidate router.ActionCandidate
|
||||
if h.matcher != nil {
|
||||
candidate = h.resolveAction(ctx, dec)
|
||||
} else {
|
||||
candidate = router.ResolveActionCandidate(dec, nil)
|
||||
}
|
||||
return h.handleHexisAct(ctx, dec, candidate)
|
||||
}
|
||||
|
||||
// attentionCannotTell returns the hedge to say instead of an all-clear, or ""
|
||||
|
||||
@@ -96,7 +96,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
|
||||
// A Nexus outage during a Hexis act writes a failure trace, and a shared
|
||||
// store is the one thing the Praxis path could inherit it through.
|
||||
nexus.SetFault(503)
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); actRan(reply) {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart")); actRan(reply) {
|
||||
t.Fatalf("nexus outage must not report success, got %q", reply)
|
||||
}
|
||||
if len(tracesFor(t, h, "nexus", "resolve")) == 0 {
|
||||
@@ -104,7 +104,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
|
||||
}
|
||||
|
||||
nexus.SetFault(0)
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
if !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("a recorded nexus failure must not degrade the praxis digest, got %q", reply)
|
||||
}
|
||||
@@ -114,10 +114,10 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
|
||||
|
||||
// And the reverse: a Praxis outage mid-session leaves the Hexis path whole.
|
||||
praxis.SetFault(503)
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") {
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention")); strings.Contains(reply, "disk") {
|
||||
t.Fatalf("praxis outage must not serve content, got %q", reply)
|
||||
}
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart")); !actRan(reply) {
|
||||
t.Fatalf("a praxis outage must not block the hexis path, got %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func TestEcosystem_OneEndpointDownDoesNotMuteTheService(t *testing.T) {
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
praxis.SetRouteFault("/api/v1/tools/surface", 503)
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
if !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("a downed surface endpoint must not mute the digest, got %q", reply)
|
||||
}
|
||||
@@ -150,7 +150,7 @@ func TestEcosystem_ResolvedWithoutEntityFailsClosed(t *testing.T) {
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if reply == "" {
|
||||
t.Fatal("a resolve with no entity must degrade, not fall through to local execution")
|
||||
}
|
||||
@@ -172,7 +172,7 @@ func TestEcosystem_RejectedCredentialSaysSo(t *testing.T) {
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
nexus.SetFault(status)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if !strings.Contains(reply, "токен") {
|
||||
t.Fatalf("http %d must read as a credential problem, got %q", status, reply)
|
||||
}
|
||||
@@ -193,7 +193,7 @@ func TestEcosystem_MalformedPraxisBodyDegrades(t *testing.T) {
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
praxis.SetBody(`[{"title":`)
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
if reply == "" {
|
||||
t.Fatal("a malformed praxis body must not answer with silence")
|
||||
}
|
||||
@@ -211,7 +211,7 @@ func TestEcosystem_MalformedNexusResponseFailsClosed(t *testing.T) {
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
nexus.SetBody(`{"status":"resolved","entity":`)
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if reply == "" || actRan(reply) {
|
||||
t.Fatalf("malformed nexus body must degrade, got %q", reply)
|
||||
}
|
||||
@@ -232,7 +232,7 @@ func TestEcosystem_UnknownContractFieldsTolerated(t *testing.T) {
|
||||
nexus := newFakeNexus(t, body)
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart")); !actRan(reply) {
|
||||
t.Fatalf("%s contract shape must still resolve and execute, got %q", name, reply)
|
||||
}
|
||||
})
|
||||
@@ -249,7 +249,7 @@ func TestEcosystem_CancelledContextDegrades(t *testing.T) {
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
defer cancel()
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if reply == "" || actRan(reply) {
|
||||
t.Fatalf("cancelled resolve must degrade, got %q", reply)
|
||||
}
|
||||
@@ -267,7 +267,7 @@ func TestEcosystem_ExecutionFailureIsNotSuccess(t *testing.T) {
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecutionFailed("exec_1", "unit not found"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if actRan(reply) {
|
||||
t.Fatalf("failed execution must not read as success, got %q", reply)
|
||||
}
|
||||
@@ -291,7 +291,7 @@ func TestEcosystem_SuccessfulActionWritesATrace(t *testing.T) {
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart")); !actRan(reply) {
|
||||
t.Fatalf("setup: expected success, got %q", reply)
|
||||
}
|
||||
exec := tracesFor(t, h, "hexis", "execute")
|
||||
@@ -313,7 +313,7 @@ func TestEcosystem_TracesStayOutOfFacts(t *testing.T) {
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart")); !actRan(reply) {
|
||||
t.Fatalf("setup: expected success, got %q", reply)
|
||||
}
|
||||
if len(traces(t, h)) == 0 {
|
||||
@@ -339,7 +339,7 @@ func TestEcosystem_AmbiguousTargetBlocksExecution(t *testing.T) {
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick"), routeCandidate("restart"))
|
||||
if !strings.Contains(reply, "Muzick indexer") || !strings.Contains(reply, "Muzick web") {
|
||||
t.Fatalf("ambiguous resolve must list candidates, got %q", reply)
|
||||
}
|
||||
@@ -360,7 +360,7 @@ func TestEcosystem_NoAutonomousPraxisToHexis(t *testing.T) {
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, praxis, hexis)
|
||||
|
||||
_ = h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
_ = h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
if hexis.Count("", "/api/v1") != 0 {
|
||||
t.Fatal("attention digest must not contact hexis on its own")
|
||||
}
|
||||
@@ -378,7 +378,7 @@ func TestEcosystem_MutatingCapabilityWaitsForConfirmation(t *testing.T) {
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("restart"))
|
||||
reply := h.handleHexisAct(ctx, actDec("restart"), routeCandidate("restart"))
|
||||
if !strings.Contains(reply, "restart") || !strings.Contains(reply, "да") {
|
||||
t.Fatalf("mutating capability must ask for confirmation, got %q", reply)
|
||||
}
|
||||
@@ -404,7 +404,7 @@ func TestEcosystem_SurfaceFailureStillDelivers(t *testing.T) {
|
||||
praxis.SetRouteFault("/api/v1/tools/surface", 500)
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
if !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("failed surface must not swallow the digest, got %q", reply)
|
||||
}
|
||||
@@ -426,10 +426,10 @@ func TestEcosystem_TotalOutageSaysSoForEveryPath(t *testing.T) {
|
||||
h := ecoHandler(t, nexus, praxis, hexis)
|
||||
|
||||
for name, reply := range map[string]string{
|
||||
"hexis act": h.handleHexisAct(ctx, actDec("muzick indexer")),
|
||||
"attention": h.handlePraxisAct(ctx, praxisActDec("list_attention")),
|
||||
"changes": h.handlePraxisAct(ctx, praxisActDec("list_changes")),
|
||||
"acknowledge": h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1")),
|
||||
"hexis act": h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart")),
|
||||
"attention": h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention")),
|
||||
"changes": h.handlePraxisAct(ctx, praxisActDec("list_changes"), routeCandidate("list_changes")),
|
||||
"acknowledge": h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1"), routeCandidate("acknowledge_item")),
|
||||
} {
|
||||
if reply == "" {
|
||||
t.Errorf("%s: total outage must not answer with silence", name)
|
||||
@@ -458,11 +458,11 @@ func TestEcosystem_RecoveryAfterOutageNeedsNoRestart(t *testing.T) {
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
praxis.SetFault(503)
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") {
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention")); strings.Contains(reply, "disk") {
|
||||
t.Fatalf("outage must not serve content, got %q", reply)
|
||||
}
|
||||
praxis.SetFault(0)
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); !strings.Contains(reply, "disk almost full") {
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention")); !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("recovery must work on the next turn, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ func TestHexisDiscovery401IsDeniedNotDown(t *testing.T) {
|
||||
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||
|
||||
hexis.SetFault(401)
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if !denied(serviceHexis, reply) {
|
||||
t.Fatalf("401 from hexis discovery: got %q, want the denied line naming Hexis", reply)
|
||||
}
|
||||
@@ -75,7 +75,7 @@ func TestHexisDiscoveryOutageIsDownNotDenied(t *testing.T) {
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
h := hexisGapHandler(t, nexus.URL, unreachableURL)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if !down(serviceHexis, reply) {
|
||||
t.Fatalf("connection refused from hexis: got %q, want the outage line naming Hexis", reply)
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func TestHexisExecute401IsDeniedNotCommandFailure(t *testing.T) {
|
||||
// Discovery stays healthy; only the execute endpoint refuses. A blanket
|
||||
// fault would never reach the site under test.
|
||||
hexis.SetRouteFault("/api/v1/execute", 401)
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if !denied(serviceHexis, reply) {
|
||||
t.Fatalf("401 from hexis execute: got %q, want the denied line naming Hexis", reply)
|
||||
}
|
||||
@@ -129,7 +129,7 @@ func TestHexisExecuteOutageIsDown(t *testing.T) {
|
||||
t.Cleanup(hexis.Close)
|
||||
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if !down(serviceHexis, reply) {
|
||||
t.Fatalf("dropped connection on hexis execute: got %q, want the outage line", reply)
|
||||
}
|
||||
@@ -149,7 +149,7 @@ func TestHexisExecutionFailedStaysCommandFailure(t *testing.T) {
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecutionFailed("exec_1", "unit refused to start"))
|
||||
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if down(serviceHexis, reply) || denied(serviceHexis, reply) {
|
||||
t.Fatalf("a failed execution must not be reported as an ecosystem gap, got %q", reply)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,11 @@ func praxisActDec(fn string) router.Decision {
|
||||
return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: fn, HasFn: true}}
|
||||
}
|
||||
|
||||
// routeCandidate builds an ActionCandidate matching a route-resolved Decision.
|
||||
func routeCandidate(fn string) router.ActionCandidate {
|
||||
return router.ActionCandidate{Fn: fn, Source: router.ActionSourceRoute}
|
||||
}
|
||||
|
||||
// praxisItemDec is praxisActDec for the lifecycle verbs, which need an item id
|
||||
// in the value slot. Without one they answer "which item?" and never reach
|
||||
// Praxis at all, which makes them useless for testing a Praxis outage.
|
||||
@@ -46,7 +51,7 @@ func TestPraxisAttention_HappyPathSurfacesItems(t *testing.T) {
|
||||
praxis := newFakePraxis(t, items)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
if !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("expected attention digest to mention the item, got %q", reply)
|
||||
}
|
||||
@@ -77,7 +82,7 @@ func TestPraxisAttention_DegradedFailsClosedNotEmpty(t *testing.T) {
|
||||
praxis.SetFault(500)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
if reply == "" {
|
||||
t.Fatal("praxis outage must not produce an empty reply")
|
||||
}
|
||||
@@ -104,13 +109,13 @@ func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) {
|
||||
}
|
||||
|
||||
nexus.SetFault(503)
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if actRan(reply) {
|
||||
t.Fatalf("nexus outage must not report success, got %q", reply)
|
||||
}
|
||||
|
||||
nexus.SetFault(0)
|
||||
reply = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply = h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if !actRan(reply) {
|
||||
t.Fatalf("expected success once nexus recovers, got %q", reply)
|
||||
}
|
||||
@@ -134,7 +139,7 @@ func TestPraxisEntityAttention_RemembersWhatItReadOut(t *testing.T) {
|
||||
reply := h.handlePraxisAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: "entity_attention", HasFn: true, Value: "muzick indexer"},
|
||||
})
|
||||
}, routeCandidate("entity_attention"))
|
||||
if !strings.Contains(reply, "indexer wedged") {
|
||||
t.Fatalf("expected the scoped item to be read out, got %q", reply)
|
||||
}
|
||||
@@ -147,7 +152,7 @@ func TestPraxisEntityAttention_RemembersWhatItReadOut(t *testing.T) {
|
||||
}
|
||||
|
||||
// The follow-up resolves against what he just heard, not the stale list.
|
||||
if reply := h.handlePraxisAct(ctx, praxisItemDec("resolve_item", "last")); reply == "" {
|
||||
if reply := h.handlePraxisAct(ctx, praxisItemDec("resolve_item", "last"), routeCandidate("resolve_item")); reply == "" {
|
||||
t.Fatal("positional follow-up should have been claimed by praxis")
|
||||
}
|
||||
var body string
|
||||
@@ -175,7 +180,7 @@ func TestHexisConfirm_KeepsOneCorrelationIDPerAction(t *testing.T) {
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("restart")); !strings.Contains(reply, "да") {
|
||||
if reply := h.handleHexisAct(ctx, actDec("restart"), routeCandidate("restart")); !strings.Contains(reply, "да") {
|
||||
t.Fatalf("mutating capability must ask for confirmation, got %q", reply)
|
||||
}
|
||||
resolve := findTrace(t, h, "nexus", "resolve")
|
||||
|
||||
@@ -73,7 +73,7 @@ func TestHexisMutatingRequiresConfirm(t *testing.T) {
|
||||
caps := `[{"id":"cap_restart","name":"restart","read_only":false,"risk":"high"}]`
|
||||
h, executed := newHexisTestHandler(t, resolved, caps)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if !strings.Contains(reply, "да") {
|
||||
t.Fatalf("mutating cap should ask to confirm, got %q", reply)
|
||||
}
|
||||
@@ -103,7 +103,7 @@ func TestHexisConfirmNoDoesNotExecute(t *testing.T) {
|
||||
caps := `[{"id":"cap_restart","name":"restart","read_only":false}]`
|
||||
h, executed := newHexisTestHandler(t, resolved, caps)
|
||||
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
reply, handled := h.resolveConfirm(ctx, "нет")
|
||||
if !handled || !strings.Contains(reply, "отменила") {
|
||||
t.Fatalf("no should cancel, got handled=%v reply=%q", handled, reply)
|
||||
@@ -119,7 +119,7 @@ func TestHexisReadOnlyExecutesImmediately(t *testing.T) {
|
||||
caps := `[{"id":"cap_status","name":"restart","read_only":true}]`
|
||||
h, executed := newHexisTestHandler(t, resolved, caps)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if !*executed {
|
||||
t.Fatal("read-only cap should execute without confirmation")
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func TestHexisAmbiguousAsksClarification(t *testing.T) {
|
||||
ambiguous := `{"status":"ambiguous","candidates":[{"entity_id":"ent_muzick","display_name":"Muzick indexer"},{"entity_id":"ent_manga","display_name":"Manga indexer"}]}`
|
||||
h, executed := newHexisTestHandler(t, ambiguous, `[]`)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("the indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("the indexer"), routeCandidate("restart"))
|
||||
if !strings.Contains(reply, "Muzick indexer") || !strings.Contains(reply, "Manga indexer") {
|
||||
t.Fatalf("ambiguous should list candidates, got %q", reply)
|
||||
}
|
||||
@@ -154,7 +154,7 @@ func TestHexisResolveFlatShapeAccepted(t *testing.T) {
|
||||
caps := `[{"id":"cap_status","name":"restart","read_only":true}]`
|
||||
h, executed := newHexisTestHandler(t, flat, caps)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if !*executed {
|
||||
t.Fatalf("flat-shaped resolved entity should still execute, got reply %q", reply)
|
||||
}
|
||||
@@ -183,7 +183,7 @@ func TestHexisNexusErrorFailsClosed(t *testing.T) {
|
||||
ecosystem: stubEcosystem(nexus.URL, hexis.URL),
|
||||
}
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if reply == "" {
|
||||
t.Fatal("nexus dependency failure must not fall through with an empty reply")
|
||||
}
|
||||
@@ -216,7 +216,7 @@ func TestHexisUnavailableFailsClosed(t *testing.T) {
|
||||
ecosystem: stubEcosystem(nexus.URL, hexis.URL),
|
||||
}
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if reply == "" {
|
||||
t.Fatal("hexis dependency failure must not fall through with an empty reply")
|
||||
}
|
||||
@@ -234,7 +234,7 @@ func TestHexisNotFoundStillFallsThrough(t *testing.T) {
|
||||
notFound := `{"status":"not_found"}`
|
||||
h, executed := newHexisTestHandler(t, notFound, `[]`)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("turn off the lights"))
|
||||
reply := h.handleHexisAct(ctx, actDec("turn off the lights"), routeCandidate("restart"))
|
||||
if reply != "" {
|
||||
t.Fatalf("not_found resolution should fall through with empty reply, got %q", reply)
|
||||
}
|
||||
@@ -264,7 +264,7 @@ func TestHexisIrreversibleCapabilityIsNotRunFromVoice(t *testing.T) {
|
||||
caps := `[{"id":"cap_wipe","name":"restart","read_only":false,"risk":"irreversible","requires_confirmation":true}]`
|
||||
h, executed := newHexisTestHandler(t, resolved, caps)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if *executed {
|
||||
t.Fatal("an irreversible capability ran from the voice path")
|
||||
}
|
||||
@@ -284,7 +284,7 @@ func TestHexisSafeCapabilityRunsOnItsDeclaredTier(t *testing.T) {
|
||||
caps := `[{"id":"cap_status","name":"restart","read_only":true,"risk":"safe"}]`
|
||||
h, executed := newHexisTestHandler(t, resolved, caps)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if !*executed {
|
||||
t.Fatal("a capability Hexis calls safe should run")
|
||||
}
|
||||
@@ -301,7 +301,7 @@ func TestHexisUndeclaredTierStillConfirms(t *testing.T) {
|
||||
caps := `[{"id":"cap_restart","name":"restart","read_only":false}]`
|
||||
h, executed := newHexisTestHandler(t, resolved, caps)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if *executed {
|
||||
t.Fatal("a mutating capability ran without a confirm")
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ func TestEcosystemTrace_SuccessfulActionTracesEveryHop(t *testing.T) {
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart")); !actRan(reply) {
|
||||
t.Fatalf("setup: expected success, got %q", reply)
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ func TestEcosystemTrace_OneCorrelationIDPerPraxisAction(t *testing.T) {
|
||||
))
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); !strings.Contains(reply, "disk almost full") {
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention")); !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("setup: expected the digest, got %q", reply)
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ func TestEcosystemTrace_FailuresAreTracedToo(t *testing.T) {
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
nexus.SetFault(401)
|
||||
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
|
||||
d := findTrace(t, h, "nexus", "resolve")
|
||||
if d == nil {
|
||||
@@ -242,7 +242,7 @@ func TestEcosystemTrace_UnreachableIsNotRefused(t *testing.T) {
|
||||
h := ecoHandler(t, nil, nil, nil)
|
||||
h.ecosystem.nexus = newNexusClient("http://127.0.0.1:1")
|
||||
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
|
||||
d := findTrace(t, h, "nexus", "resolve")
|
||||
if d == nil {
|
||||
@@ -263,7 +263,7 @@ func TestEcosystemTrace_RedactsTheUtterance(t *testing.T) {
|
||||
nexus := newFakeNexus(t, fixtureNexusNotFound())
|
||||
h := ecoHandler(t, nexus, nil, nil)
|
||||
|
||||
_ = h.handleHexisAct(ctx, actDec("перезапусти кофемашину"))
|
||||
_ = h.handleHexisAct(ctx, actDec("перезапусти кофемашину"), routeCandidate("restart"))
|
||||
|
||||
recorded := traces(t, h)
|
||||
if len(recorded) == 0 {
|
||||
@@ -295,7 +295,7 @@ func TestEcosystemTrace_AmbiguityAndConfirmationAreRecorded(t *testing.T) {
|
||||
))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, ambig, nil, hexis)
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick"))
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick"), routeCandidate("restart"))
|
||||
if d := findTrace(t, h, "nexus", "resolve"); d == nil || d.Status != traceAmbig {
|
||||
t.Fatalf("ambiguous resolve must be traced as such, got %+v", d)
|
||||
}
|
||||
@@ -303,7 +303,7 @@ func TestEcosystemTrace_AmbiguityAndConfirmationAreRecorded(t *testing.T) {
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
mutating := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": false})
|
||||
h2 := ecoHandler(t, nexus, nil, newFakeHexis(t, mutating, fixtureHexisExecuted("exec_1", "succeeded")))
|
||||
_ = h2.handleHexisAct(ctx, actDec("restart"))
|
||||
_ = h2.handleHexisAct(ctx, actDec("restart"), routeCandidate("restart"))
|
||||
d := findTrace(t, h2, "hexis", "confirmation")
|
||||
if d == nil || d.Status != tracePending {
|
||||
t.Fatalf("a parked confirmation must be traced, got %+v", d)
|
||||
|
||||
@@ -91,7 +91,7 @@ func TestNexusIsAskedForTheNameHeSaid(t *testing.T) {
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Text: "перезагрузить музик индексер", Fn: "restart", HasFn: true},
|
||||
}
|
||||
h.handleHexisAct(ctx, dec)
|
||||
h.handleHexisAct(ctx, dec, routeCandidate("restart"))
|
||||
|
||||
reqs := nexus.Requests()
|
||||
if len(reqs) == 0 {
|
||||
@@ -226,7 +226,7 @@ func TestTwoResolvedNamesAsk(t *testing.T) {
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Text: "перезагрузить нгинкс", Fn: "restart", HasFn: true},
|
||||
}
|
||||
reply := h.handleHexisAct(ctx, dec)
|
||||
reply := h.handleHexisAct(ctx, dec, routeCandidate("restart"))
|
||||
if !strings.Contains(reply, "nginx") || !strings.Contains(reply, "Muzick indexer") {
|
||||
t.Fatalf("reply = %q, want both names she found", reply)
|
||||
}
|
||||
@@ -251,7 +251,7 @@ func TestTheNameNexusKnowsWins(t *testing.T) {
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Text: "перезагрузить нгинкс", Fn: "restart", HasFn: true},
|
||||
}
|
||||
reply := h.handleHexisAct(ctx, dec)
|
||||
reply := h.handleHexisAct(ctx, dec, routeCandidate("restart"))
|
||||
if reply == "" {
|
||||
t.Fatal("the resolvable name must carry the act")
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestEntityAttention_ScopesPraxisByCanonicalID(t *testing.T) {
|
||||
))
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"), routeCandidate("entity_attention"))
|
||||
if !strings.Contains(reply, "indexer queue is backing up") {
|
||||
t.Fatalf("expected the scoped item in the reply, got %q", reply)
|
||||
}
|
||||
@@ -70,7 +70,7 @@ func TestEntityAttention_FoldsInLocalFactsForSameEntity(t *testing.T) {
|
||||
t.Fatalf("ResolveFactEntity: %v", err)
|
||||
}
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("the espresso machine"))
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("the espresso machine"), routeCandidate("entity_attention"))
|
||||
if !strings.Contains(reply, "descaled in june") {
|
||||
t.Fatalf("expected entity-scoped local facts in the reply, got %q", reply)
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func TestEntityAttention_UnscopedPraxisResponseIsRefused(t *testing.T) {
|
||||
))
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"), routeCandidate("entity_attention"))
|
||||
if strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("an unscoped response must not be read back as entity-scoped, got %q", reply)
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func TestEntityAttention_ForeignItemsAreDropped(t *testing.T) {
|
||||
praxis := newFakePraxis(t, mustJSON(mixed))
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"), routeCandidate("entity_attention"))
|
||||
if !strings.Contains(reply, "indexer queue is backing up") {
|
||||
t.Fatalf("the matching item must be spoken, got %q", reply)
|
||||
}
|
||||
@@ -140,7 +140,7 @@ func TestEntityAttention_TruncationIsNamed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("the espresso machine"))
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("the espresso machine"), routeCandidate("entity_attention"))
|
||||
if !strings.Contains(reply, "и это не всё") {
|
||||
t.Fatalf("a truncated recall must say it is truncated, got %q", reply)
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func TestEntityAttention_AmbiguousAsksInsteadOfGuessing(t *testing.T) {
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick"))
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick"), routeCandidate("entity_attention"))
|
||||
if !strings.Contains(reply, "Muzick indexer") || !strings.Contains(reply, "Muzick web") {
|
||||
t.Fatalf("ambiguous subject must ask, got %q", reply)
|
||||
}
|
||||
@@ -173,13 +173,13 @@ func TestEntityAttention_MissingAndDegradedAreDistinct(t *testing.T) {
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
missing := h.handlePraxisAct(ctx, entityAttentionDec("нечто"))
|
||||
missing := h.handlePraxisAct(ctx, entityAttentionDec("нечто"), routeCandidate("entity_attention"))
|
||||
if missing == "" {
|
||||
t.Fatal("an unknown entity must still get an answer")
|
||||
}
|
||||
|
||||
nexus.SetFault(503)
|
||||
degraded := h.handlePraxisAct(ctx, entityAttentionDec("нечто"))
|
||||
degraded := h.handlePraxisAct(ctx, entityAttentionDec("нечто"), routeCandidate("entity_attention"))
|
||||
if degraded == missing {
|
||||
t.Fatalf("outage and unknown-entity must not read the same: %q", degraded)
|
||||
}
|
||||
@@ -195,7 +195,7 @@ func TestEntityAttention_DelayedNexusDegradesNotHangs(t *testing.T) {
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
defer cancel()
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"), routeCandidate("entity_attention"))
|
||||
if reply == "" {
|
||||
t.Fatal("a delayed resolve must still answer")
|
||||
}
|
||||
@@ -213,7 +213,7 @@ func TestEntityAttention_WithoutNexusSaysSo(t *testing.T) {
|
||||
))
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"), routeCandidate("entity_attention"))
|
||||
if strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("without nexus, items must not be passed off as entity-scoped, got %q", reply)
|
||||
}
|
||||
|
||||
+11
-9
@@ -72,15 +72,16 @@ func dialogueIDOf(ctx context.Context) string {
|
||||
// toDialogueSlots projects the router's slots onto the dialogue layer's copy.
|
||||
func toDialogueSlots(s router.Slots) dialogue.Slots {
|
||||
return dialogue.Slots{
|
||||
Time: s.Time,
|
||||
HasTime: s.HasTime,
|
||||
Key: s.Key,
|
||||
Value: s.Value,
|
||||
HasKey: s.HasKey,
|
||||
Text: s.Text,
|
||||
Fn: s.Fn,
|
||||
Args: s.Args,
|
||||
HasFn: s.HasFn,
|
||||
Time: s.Time,
|
||||
HasTime: s.HasTime,
|
||||
Key: s.Key,
|
||||
Value: s.Value,
|
||||
HasKey: s.HasKey,
|
||||
Text: s.Text,
|
||||
Fn: s.Fn,
|
||||
Args: s.Args,
|
||||
HasFn: s.HasFn,
|
||||
ResolvedBy: string(s.ResolvedBy),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +91,7 @@ func applyDialogueSlots(base router.Slots, d dialogue.Slots) router.Slots {
|
||||
base.Key, base.Value, base.HasKey = d.Key, d.Value, d.HasKey
|
||||
base.Text = d.Text
|
||||
base.Fn, base.Args, base.HasFn = d.Fn, d.Args, d.HasFn
|
||||
base.ResolvedBy = router.ActionResolutionMethod(d.ResolvedBy)
|
||||
return base
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestPraxisLifecycle401NamesPraxis(t *testing.T) {
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
praxis.SetFault(401)
|
||||
reply := h.handlePraxisAct(ctx, praxisItemDec("resolve_item", "item_1"))
|
||||
reply := h.handlePraxisAct(ctx, praxisItemDec("resolve_item", "item_1"), routeCandidate("resolve_item"))
|
||||
if !strings.Contains(reply, servicePraxis) {
|
||||
t.Fatalf("praxis failure does not name Praxis: %q", reply)
|
||||
}
|
||||
@@ -40,10 +40,10 @@ func TestPraxisLifecycleOutageDiffersFrom401(t *testing.T) {
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
praxis.SetFault(401)
|
||||
refused := h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1"))
|
||||
refused := h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1"), routeCandidate("acknowledge_item"))
|
||||
|
||||
h.ecosystem = &ecosystemWiring{praxis: newPraxisClient(unreachableURL)}
|
||||
outage := h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1"))
|
||||
outage := h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1"), routeCandidate("acknowledge_item"))
|
||||
|
||||
if refused == outage {
|
||||
t.Fatalf("a refused token and an outage still say the same thing: %q", refused)
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestPositionResolvesAgainstTheLastSpokenList(t *testing.T) {
|
||||
]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
if reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention")); reply == "" {
|
||||
if reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"), routeCandidate("list_attention")); reply == "" {
|
||||
t.Fatal("attention returned nothing")
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestPositionResolvesAgainstTheLastSpokenList(t *testing.T) {
|
||||
}
|
||||
for _, c := range cases {
|
||||
praxis.ResetRequests()
|
||||
reply := h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", c.ref))
|
||||
reply := h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", c.ref), routeCandidate("acknowledge_item"))
|
||||
if !strings.Contains(reply, "принято") {
|
||||
t.Errorf("ref %q: reply %q", c.ref, reply)
|
||||
}
|
||||
@@ -42,10 +42,10 @@ func TestPositionResolvesAgainstTheLastSpokenList(t *testing.T) {
|
||||
func TestPositionPastTheEndAsksInsteadOfGuessing(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[{"id":"item_a","title":"диск заканчивается"}]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
|
||||
praxis.ResetRequests()
|
||||
reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "4"))
|
||||
reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "4"), routeCandidate("resolve_item"))
|
||||
if !strings.Contains(reply, "какой пункт") {
|
||||
t.Errorf("a position with no item should ask, got %q", reply)
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func TestPositionWithNoSpokenListAsks(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "1"))
|
||||
reply := h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "1"), routeCandidate("acknowledge_item"))
|
||||
if !strings.Contains(reply, "какой пункт") {
|
||||
t.Errorf("want the ask, got %q", reply)
|
||||
}
|
||||
@@ -69,10 +69,10 @@ func TestPositionWithNoSpokenListAsks(t *testing.T) {
|
||||
func TestExplicitItemIDIsNotRewritten(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[{"id":"item_a","title":"диск"}]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
|
||||
praxis.ResetRequests()
|
||||
h.handlePraxisAct(context.Background(), praxisItemDec("pin_item", "item_zz"))
|
||||
h.handlePraxisAct(context.Background(), praxisItemDec("pin_item", "item_zz"), routeCandidate("pin_item"))
|
||||
if !requestedPathContaining(praxis, "item_zz") {
|
||||
t.Errorf("the id he gave was not the one called; paths %v", paths(praxis))
|
||||
}
|
||||
@@ -85,10 +85,10 @@ func TestUnspokenItemsHoldNoPosition(t *testing.T) {
|
||||
{"id":"item_said","title":"бэкап не прошёл"}
|
||||
]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
|
||||
praxis.ResetRequests()
|
||||
h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "1"))
|
||||
h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "1"), routeCandidate("acknowledge_item"))
|
||||
if !requestedPathContaining(praxis, "item_said") {
|
||||
t.Errorf("position 1 is the first item she SAID; paths %v", paths(praxis))
|
||||
}
|
||||
@@ -116,10 +116,10 @@ func requestedPathContaining(f *fakeServer, want string) bool {
|
||||
func TestDemonstrativeResolvesWhenOneItemWasSpoken(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[{"id":"item_only","title":"бэкап не прошёл"}]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
|
||||
praxis.ResetRequests()
|
||||
reply := h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "this"))
|
||||
reply := h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "this"), routeCandidate("acknowledge_item"))
|
||||
if !strings.Contains(reply, "принято") {
|
||||
t.Errorf("reply %q", reply)
|
||||
}
|
||||
@@ -136,10 +136,10 @@ func TestDemonstrativeWithSeveralItemsGivesTheTurnBack(t *testing.T) {
|
||||
{"id":"item_b","title":"бэкап"}
|
||||
]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
|
||||
praxis.ResetRequests()
|
||||
if reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "this")); reply != "" {
|
||||
if reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "this"), routeCandidate("resolve_item")); reply != "" {
|
||||
t.Errorf("want a fall-through, got %q", reply)
|
||||
}
|
||||
for _, p := range paths(praxis) {
|
||||
@@ -154,7 +154,7 @@ func TestDemonstrativeWithNoDigestGivesTheTurnBack(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
if reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "this")); reply != "" {
|
||||
if reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "this"), routeCandidate("resolve_item")); reply != "" {
|
||||
t.Errorf("want a fall-through, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@ func TestSlotsParity(t *testing.T) {
|
||||
t.Errorf("router.Slots.%s (%s) missing from dialogue.Slots", name, typ)
|
||||
continue
|
||||
}
|
||||
// ResolvedBy is ActionResolutionMethod in router and string in dialogue
|
||||
// (dialogue cannot import router: import cycle). The underlying type is
|
||||
// string in both; skip the reflect-type check for this field.
|
||||
if name == "ResolvedBy" {
|
||||
continue
|
||||
}
|
||||
if dt != typ {
|
||||
t.Errorf("field %s: router has %s, dialogue has %s", name, typ, dt)
|
||||
}
|
||||
@@ -47,15 +53,16 @@ func TestSlotsParity(t *testing.T) {
|
||||
// populated value and compare.
|
||||
func TestSlotsRoundTrip(t *testing.T) {
|
||||
full := router.Slots{
|
||||
Time: time.Date(2026, 8, 2, 11, 0, 0, 0, time.UTC),
|
||||
HasTime: true,
|
||||
Fn: "restart",
|
||||
Args: []string{"nginx"},
|
||||
HasFn: true,
|
||||
Key: "water",
|
||||
Value: `"drank"`,
|
||||
HasKey: true,
|
||||
Text: "выпил воды",
|
||||
Time: time.Date(2026, 8, 2, 11, 0, 0, 0, time.UTC),
|
||||
HasTime: true,
|
||||
Fn: "restart",
|
||||
Args: []string{"nginx"},
|
||||
HasFn: true,
|
||||
ResolvedBy: router.ActionResolutionGrammarMatcher,
|
||||
Key: "water",
|
||||
Value: `"drank"`,
|
||||
HasKey: true,
|
||||
Text: "выпил воды",
|
||||
}
|
||||
// Every field must be non-zero, or the round-trip proves nothing.
|
||||
rv := reflect.ValueOf(full)
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
{
|
||||
"db_path": "/var/lib/maven/maven.db.enc",
|
||||
"db_tmpfs": "/dev/shm/maven-plain.db",
|
||||
"db_key_env": "MAVEN_DB_KEY",
|
||||
"socket_path": "/run/maven/mavend.sock",
|
||||
"state_dir": "/var/lib/maven",
|
||||
|
||||
"//disabled_rules": [
|
||||
"Nudge rules that are not wired at all. Names come from loop.DefaultRules:",
|
||||
"water, meal, break, service_down, netdata_critical.",
|
||||
"service_down is back on: mavpoll now writes one fact per kuma monitor",
|
||||
"(service_down:<name>), so the nudge names the service and pausing a monitor",
|
||||
"in kuma silences that monitor. It is also edge-triggered, so a service that",
|
||||
"stays down is one nudge, not one every fifteen minutes."
|
||||
],
|
||||
"disabled_rules": [],
|
||||
|
||||
"phraser": {
|
||||
"model_path": "/opt/maven/models/llm/qwen3/Qwen3-1.7B-UD-Q4_K_XL.gguf",
|
||||
"bin_path": "llama-server",
|
||||
"n_gpu_layers": 99,
|
||||
"n_ctx": 4096,
|
||||
"cache_ram_mib": 512,
|
||||
"timeout": "60s",
|
||||
"llm_nudges": false
|
||||
},
|
||||
|
||||
"//ntfy": [
|
||||
"The second reach (V-649). Until 07-08-2026 telegram was the only one, and",
|
||||
"telegram needs api.telegram.org, the socks relay below and a matching ufw",
|
||||
"rule — three things in series that have each failed once, and when they do",
|
||||
"a sev4 nudge has nowhere to go. ntfy shares none of them: it is reached",
|
||||
"directly, no relay.",
|
||||
"It is the preferred away reach. Reminders fall back to Telegram in order,",
|
||||
"and every missing reach is recorded in the outbox rather than disappearing.",
|
||||
"The credential is an ntfy access token, scoped write-only to this one",
|
||||
"topic, so a popped sink can push to it and cannot read it back. Set it in",
|
||||
"deploy/telegram.env beside the telegram secrets; that file is gitignored."
|
||||
],
|
||||
"ntfy": {
|
||||
"disabled": true,
|
||||
"base_url": "https://ntfy.kvmx.ru",
|
||||
"topic": "maven",
|
||||
"token": "${NTFY_TOKEN}"
|
||||
},
|
||||
|
||||
"telegram": {
|
||||
"bot_token": "${TELEGRAM_BOT_TOKEN}",
|
||||
"chat_id": "${TELEGRAM_CHAT_ID}",
|
||||
|
||||
"//proxy": [
|
||||
"api.telegram.org is not reachable directly from this box, so every send",
|
||||
"timed out. The relay is the x-ui socks inbound on the host, port 10808;",
|
||||
"192.168.240.1 is the maven_default bridge gateway, which is how a",
|
||||
"container addresses the host. mavend is on that network.",
|
||||
"This needs a matching ufw rule or the container's SYN is dropped:",
|
||||
" ufw allow from 192.168.240.0/20 to any port 10808 proto tcp"
|
||||
],
|
||||
"proxy": "socks5://192.168.240.1:10808",
|
||||
"//intake": [
|
||||
"Read the chat as well as write to it (V-637). The poller long-polls",
|
||||
"getUpdates through the same relay and accepts chat_id as the only",
|
||||
"sender. Deleting this key turns inbound off again.",
|
||||
"chat_id must be numeric here or the daemon refuses to start: an inbound",
|
||||
"update names its chat by number, so an @-name would match nothing."
|
||||
],
|
||||
"intake": true
|
||||
},
|
||||
|
||||
"//workstation": [
|
||||
"The big model on the desk PC (workpc, 7900 GRE 16GB), fronted by",
|
||||
"mavgpud on port 8080. It runs gemma-4-12b and it is preferred over the",
|
||||
"resident Qwen3-1.7B for routing and replies whenever the card is free.",
|
||||
"The machine is never assumed up: it sleeps, and the card is often held by",
|
||||
"a CPT run, in which case mavgpud answers 503 and Maven falls back to the",
|
||||
"resident model without saying so. Deleting this block restores exactly",
|
||||
"the behaviour homesrv had before it existed.",
|
||||
"Addressed by LAN address, not container name: mavgpud runs on another",
|
||||
"machine and there is no shared docker network to name it on.",
|
||||
"model_disabled keeps only this model arm dark until MAVEN_GPU_TOKEN is",
|
||||
"provisioned; the independently authenticated STT arm below stays live."
|
||||
],
|
||||
"//workstation.stt": [
|
||||
"CrisperWhisper 2.0 turbo on the same machine, a second service on port",
|
||||
"8081 and not a second endpoint on mavgpud. whisper.cpp cannot load CW2 at",
|
||||
"all: it derives its language count from the vocabulary size, and CW2's",
|
||||
"51897 tokens shift seven special token ids. So it runs under transformers",
|
||||
"there and mavsttd stays whisper.cpp here.",
|
||||
"Worth the second service: CW2 turbo scores 10.4% WER in Russian against",
|
||||
"27.5% for the ggml-small.bin mavsttd loads, measured on 200 Golos clips",
|
||||
"in docs/evals/2026-08-09-crisperwhisper2-russian-wer.md.",
|
||||
"Deleting this block sends every utterance to mavsttd, which is what the",
|
||||
"box did before it existed. A worse transcript is still a turn, so the",
|
||||
"fallback is silent and Kami is never told which machine heard him.",
|
||||
"The token is what stops anything on the LAN posting audio to that port."
|
||||
],
|
||||
"workstation": {
|
||||
"model_disabled": true,
|
||||
"url": "http://192.168.1.105:8080",
|
||||
"token": "${MAVEN_GPU_TOKEN}",
|
||||
"probe": "15s",
|
||||
"timeout": "90s",
|
||||
"stt": {
|
||||
"url": "http://192.168.1.105:8081/transcribe",
|
||||
"token": "${MAVEN_STT_TOKEN}",
|
||||
"probe": "15s",
|
||||
"timeout": "10s"
|
||||
}
|
||||
},
|
||||
|
||||
"//search": [
|
||||
"The live web, searched after his own notes and before Kiwix. Only the",
|
||||
"query string leaves the box — never a note, a fact, the persona block or",
|
||||
"the history — and a question about him never reaches here at all.",
|
||||
"The instance must have `json` in search.formats (settings.yml); a stock",
|
||||
"SearXNG answers 403 to format=json and every search then fails. It is",
|
||||
"addressed by container name, so it needs the same maven_default",
|
||||
"attachment kiwix has, and it must listen on 9563: 8080 is taken several",
|
||||
"times over on this box. No instance reachable ⇒ she falls through to the",
|
||||
"ZIMs and never says the search failed."
|
||||
],
|
||||
"search": {
|
||||
"url": "http://searxng:9563",
|
||||
"max_results": 4,
|
||||
"snippet_runes": 1500,
|
||||
"language": "auto",
|
||||
"timeout": "8s"
|
||||
},
|
||||
|
||||
"//kiwix": [
|
||||
"The offline encyclopedia, searched after his own notes and before anything",
|
||||
"on the network. kiwix-server publishes 8034 on loopback only, so a container",
|
||||
"cannot reach it by address; it is attached to the maven_default network",
|
||||
"instead and addressed by container name. That attachment is imperative and",
|
||||
"does not survive recreating the kiwix stack — make it declarative there:",
|
||||
" networks: [default, maven_default] # maven_default: external: true",
|
||||
"The book is the catalog name from the /content/... href in",
|
||||
"/catalog/v2/entries, not the display title. Others on the box:",
|
||||
"ifixit_en_all_2025-06, devdocs_en_ansible_2025-10."
|
||||
],
|
||||
"kiwix": {
|
||||
"url": "http://kiwix-server:8080",
|
||||
"book": "wikipedia_en_all_maxi_2026-02",
|
||||
"book_ru": "wikipedia_ru_all_maxi_2026-02",
|
||||
"max_results": 5,
|
||||
"snippet_runes": 1500
|
||||
},
|
||||
|
||||
"//morning_routines": [
|
||||
"The daily checklist (Vikunja #280). Each item is done when its fact_key",
|
||||
"gets a non-voided fact inside the window, so 'выпил воды' closes water and",
|
||||
"nothing has to be ticked by hand. nudge_at fires once, at the end of the",
|
||||
"window, and only for what is still open. Weekdays empty = every day."
|
||||
],
|
||||
"morning_routines": [
|
||||
{
|
||||
"name": "утро",
|
||||
"window_start": "08:00",
|
||||
"window_end": "11:00",
|
||||
"nudge_at": "10:30",
|
||||
"severity": 1,
|
||||
"items": [
|
||||
{ "key": "medicine", "fact_key": "medicine", "label": "лекарство" },
|
||||
{ "key": "water", "fact_key": "water", "label": "вода" },
|
||||
{ "key": "pets", "fact_key": "pets", "label": "покормить кота" }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"//feeds": [
|
||||
"RSS reading (Vikunja #258). Every item lands as a note with source",
|
||||
"rss:<name>, which is also what puts entries in the intake journal that",
|
||||
"/events reads. Only the feed URL leaves the box.",
|
||||
"This is a starting pair, not a curated set — trim or extend it."
|
||||
],
|
||||
"feeds": {
|
||||
"poll_interval": "30m",
|
||||
"max_items": 5,
|
||||
"max_age": "24h",
|
||||
"sources": [
|
||||
{ "name": "lwn", "url": "https://lwn.net/headlines/newrss", "category": "технологии" },
|
||||
{ "name": "archlinux", "url": "https://archlinux.org/feeds/news/", "category": "технологии" }
|
||||
]
|
||||
},
|
||||
|
||||
"//crawl": [
|
||||
"Reading a web page (Vikunja #259). on_demand answers 'посмотри <URL>'.",
|
||||
"No allow_hosts, so any public host he names is readable; private",
|
||||
"addresses are refused unconditionally by internal/webfetch and do not",
|
||||
"need listing. Setting allow_hosts here would also narrow on-demand,",
|
||||
"which is the point of leaving it empty."
|
||||
],
|
||||
"crawl": {
|
||||
"on_demand": true,
|
||||
"timeout": "10s",
|
||||
"max_runes": 4000
|
||||
},
|
||||
|
||||
"digest": {
|
||||
"enabled": true,
|
||||
"window": "30m",
|
||||
"max_items": 5,
|
||||
"severity_ceiling": 2
|
||||
},
|
||||
|
||||
"pattern_proposals": {
|
||||
"notify": false,
|
||||
"cooldown": "24h"
|
||||
},
|
||||
|
||||
"mcp": {
|
||||
"timeout": "15s",
|
||||
"servers": [
|
||||
{
|
||||
"name": "vikunja",
|
||||
"url": "http://192.168.1.104:9100/mcp",
|
||||
"allow_private": true,
|
||||
"allow_tools": ["list_projects", "list_tasks", "get_task_details", "create_task"],
|
||||
"max_tools": 6,
|
||||
"enabled": false
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"smarthome": {
|
||||
"provider": "homeassistant",
|
||||
"url": "http://192.168.1.50:8123",
|
||||
"token": "${HA_TOKEN}",
|
||||
"domains": ["light", "switch", "sensor"],
|
||||
"max_entities": 40,
|
||||
"timeout": "10s",
|
||||
"refresh": "15m",
|
||||
"enabled": false
|
||||
},
|
||||
|
||||
"netscan": {
|
||||
"subnets": ["192.168.1.0/24"],
|
||||
"ports": [22, 80, 443, 8080],
|
||||
"timeout": "400ms",
|
||||
"rate": 100,
|
||||
"max_hosts": 256,
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
"nexus": { "url": "http://nexus:9740" },
|
||||
"praxis": { "url": "http://praxis:8989" },
|
||||
"hexis": { "url": "http://hexis:9741" },
|
||||
|
||||
"voice": {
|
||||
"enabled": true,
|
||||
"bind": "0.0.0.0:9100",
|
||||
"lang": "ru",
|
||||
"stt": { "socket": "/run/maven/stt.sock", "lang": "ru" },
|
||||
"tts": { "socket": "/run/maven/tts.sock", "lang": "ru" },
|
||||
"embedder": {
|
||||
"model_path": "/opt/maven/models/embedder/multilingual-e5-small/model_quantized.onnx",
|
||||
"tokenizer_path": "/opt/maven/models/embedder/multilingual-e5-small/tokenizer.json",
|
||||
"lib_path": "/opt/maven/lib/libonnxruntime.so",
|
||||
"heads_path": "/opt/maven/models/embedder/router-heads/router_heads.onnx"
|
||||
},
|
||||
"llm_router": true,
|
||||
"query_min_score": 0.80,
|
||||
"query_min_margin": 0.008,
|
||||
"clarify_max_attempts": 3,
|
||||
"tool_timeout": "30s",
|
||||
"tools": [
|
||||
{ "name": "status", "cmd": ["systemctl", "status"], "scope": "homelab", "destructive": false,
|
||||
"aliases": ["статус", "покажи статус", "проверь статус"] },
|
||||
{ "name": "ps", "cmd": ["docker", "ps"], "scope": "homelab", "destructive": false,
|
||||
"aliases": ["статус докера", "лог докера", "покажи запущенные контейнеры", "покажи контейнеры", "список контейнеров", "что запущено"] },
|
||||
{ "name": "uptime", "cmd": ["uptime"], "scope": "homelab", "destructive": false,
|
||||
"aliases": ["покажи uptime", "аптайм", "как работает сервер", "сколько работает сервер"] },
|
||||
{ "name": "disk", "cmd": ["df", "-h"], "scope": "homelab", "destructive": false,
|
||||
"aliases": ["сколько места на диске", "сколько свободного места на диске", "место на диске", "покажи диск"] },
|
||||
{ "name": "memory", "cmd": ["free", "-h"], "scope": "homelab", "destructive": false,
|
||||
"aliases": ["свободная память", "сколько оперативной памяти свободно", "покажи память"] },
|
||||
{ "name": "logs", "cmd": ["journalctl", "-n", "50", "-u"], "scope": "homelab", "destructive": false,
|
||||
"aliases": ["покажи логи", "логи", "лог"] },
|
||||
{ "name": "restart", "cmd": ["systemctl", "restart"], "scope": "homelab", "destructive": true,
|
||||
"aliases": ["перезапусти", "перезагрузи", "рестарт"] },
|
||||
{ "name": "stop", "cmd": ["systemctl", "stop"], "scope": "homelab", "destructive": true,
|
||||
"aliases": ["останови", "останови сервис"] },
|
||||
{ "name": "start", "cmd": ["systemctl", "start"], "scope": "homelab", "destructive": true,
|
||||
"aliases": ["запусти", "запусти сервис"] },
|
||||
{ "name": "docker-restart", "cmd": ["docker", "restart"], "scope": "homelab", "destructive": true,
|
||||
"aliases": ["перезапусти контейнер", "перезагрузи контейнер"] },
|
||||
{ "name": "docker-stop", "cmd": ["docker", "stop"], "scope": "homelab", "destructive": true,
|
||||
"aliases": ["останови контейнер"] },
|
||||
{ "name": "reboot", "cmd": ["systemctl", "reboot"], "scope": "homelab", "destructive": true,
|
||||
"aliases": ["перезагрузи сервер", "перезагрузи хост"] }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
# Maven Current-State Audit
|
||||
|
||||
**Date:** 2026-09-05
|
||||
**Scope:** Read-only code + tests investigation. No inference from names or docs.
|
||||
**Source of truth:** code and tests only.
|
||||
|
||||
---
|
||||
|
||||
## A. Current End-to-End Flow Diagram
|
||||
|
||||
```
|
||||
Telegram Bot API (long-poll)
|
||||
|
|
||||
| api.Chat(ctx, "telegram:<id>", text)
|
||||
v
|
||||
mavweb POST /api/chat --> ipc.Client (unix socket) --> daemonAPI.Chat()
|
||||
|
|
||||
| chatFn = handler.handleText
|
||||
v
|
||||
Voice TCP :9100 --> HandlePushToTalk --> STT --> +------------------+
|
||||
| runTurn() |
|
||||
| voice.go:270 |
|
||||
+------------------+
|
||||
| 0. decision record (ctx)
|
||||
| 0b. turnRoute (computed once)
|
||||
| 1. expired clarify notice
|
||||
| 2. resolveConfirm (y/n)
|
||||
| 3. resolveRepair (correction)
|
||||
| 3b. resolveUntargetedRepair
|
||||
| 3c. resolveCommandProhibition
|
||||
| 4. resolveClarifyAnswer
|
||||
| 5. resolveQuietToggle
|
||||
| 5b. resolveSnooze
|
||||
| 5c. resolveAck
|
||||
| 5d. resolveReminderCancellation
|
||||
| 5e. resolveCandidate (ordinal)
|
||||
| 6. ROUTE (cascade)
|
||||
| 7. dialogue merge (followUpMerge)
|
||||
| 8. clarify (missing slots)
|
||||
| 9. applyAction (per-intent dispatch)
|
||||
| 10. replier (LLM or stub)
|
||||
+------------------+
|
||||
|
|
||||
v
|
||||
reply string
|
||||
```
|
||||
|
||||
### The routing cascade (step 6) in detail:
|
||||
|
||||
```
|
||||
utterance
|
||||
|
|
||||
v
|
||||
Stage 0: Grammars (stagezero.go:24-91)
|
||||
| 22 ordered regex/structural grammars. First match wins.
|
||||
| Confidence = 1.0, Stage = 0. fillMatchedSlots runs.
|
||||
| NO MATCH -> fall through
|
||||
v
|
||||
Stage 0b: Routing Heads (heads.go, ONNX)
|
||||
| 4 linear heads over mean-pooled e5-small: intent, destination, slot BIO, clarify
|
||||
| Below headsThreshold (0.6) -> decline, cascade continues
|
||||
| Intent head softmax -> Decision{Intent, Confidence, Source, Clarify}
|
||||
| completeParsedSingleVerbFact can overrule clarify
|
||||
| NO WIRE / ERROR / DECLINE -> fall through
|
||||
v
|
||||
Stage 1a: LLM Router (llmrouter.go, Qwen3-1.7B via llama-server)
|
||||
| GBNF grammar-constrained JSON output
|
||||
| gateLLMDecision: thin confidence (0.3) for incomplete slots
|
||||
| ERROR / PARSE FAIL / "unknown" -> fall through
|
||||
v
|
||||
Stage 1: Nearest-Centroid Classifier (classifier.go)
|
||||
| Cosine similarity over ONNX embeddings against seed centroids
|
||||
| Best intent wins (ties broken by name)
|
||||
v
|
||||
Stage 2: Slot Extraction (slots.go:55-91)
|
||||
| Dispatch on intent: DateTimeParser (reminder), ActMatcher (act), FactParser (fact)
|
||||
v
|
||||
Stage 3: Confidence Gate (router.go:244-248)
|
||||
| Confidence < threshold (0.55) -> Clarify = true, Stage = 3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## B. Package/File Ownership Map
|
||||
|
||||
### Command packages (cmd/)
|
||||
|
||||
| Package | Binary | Role |
|
||||
|---------|--------|------|
|
||||
| cmd/mavend | mavend | Core daemon: DB, IPC, routing, actions, voice server, tick loop |
|
||||
| cmd/mavweb | mavweb | Web UI + HTTP server (serves /, /dash, /history, /trace, /notifications, /tools) |
|
||||
| cmd/mavwaked | mavwaked | Wake-word + voice activity detection (silero VAD, energy threshold) |
|
||||
| cmd/mavsttd | mavsttd | Speech-to-text daemon (whisper.cpp / remote worker) |
|
||||
| cmd/mavttsd | mavttsd | Text-to-speech daemon (piper) |
|
||||
| cmd/mavgpud | mavgpud | GPU proxy daemon (workpc inference) |
|
||||
| cmd/mavcaldav | mavcaldav | CalDAV sync daemon |
|
||||
| cmd/mavmaild | mavmaild | Mail ingestion daemon |
|
||||
| cmd/mavpoll | mavpoll | Polling daemon (Kuma monitors) |
|
||||
| cmd/mavenclient | mavenclient | CLI client |
|
||||
| cmd/mavupdate | mavupdate | Self-update tool |
|
||||
| cmd/mavseal | mavseal | Seal/encryption tool |
|
||||
| cmd/e2eprobe | e2eprobe | End-to-end probe |
|
||||
| cmd/labelgen | labelgen | Label generation tool |
|
||||
| cmd/mavend/seedtest | (test helper) | Test seeder |
|
||||
|
||||
### Internal packages relevant to routing/action
|
||||
|
||||
| Package | Key files | Role |
|
||||
|---------|-----------|------|
|
||||
| internal/router | router.go, intent.go, stage0.go, stagezero.go, heads.go, llmrouter.go, classifier.go, slots.go, source.go, embedder.go, onnxembedder.go, question.go, singletoken.go, notecapture.go, claim.go (package-level), decisiontrace.go | Routing cascade, intent taxonomy, slot extraction, confidence |
|
||||
| internal/claim | claim.go | Band/Claim/arbitration types (imported by router and dialogue) |
|
||||
| internal/dialogue | session.go, clarify.go, pending.go, pending.go | Session state, clarification, PendingAction/Capability |
|
||||
| internal/tool | tool.go, risk.go | Tool execution, risk tiers, policy |
|
||||
| internal/phraser | phraser.go, llmphraser.go, fallbacks.go, replier.go | Response phrasing (LLM or stub) |
|
||||
| internal/voice | server.go, wire.go, replier.go | Voice TCP server, wire protocol |
|
||||
| internal/memory | store.go | Vector memory (cosine similarity search) |
|
||||
| internal/llm | remote.go | Two-tier LLM client (resident + workstation) |
|
||||
| internal/lexicon | lexicon.go | Closed Russian word sets (embedded JSON) |
|
||||
| internal/morph | morph.go | Russian morphology (golem lemmatizer) |
|
||||
| internal/loop | loop.go | Proactive nudge rules, gates |
|
||||
| internal/store | facts.go, reminders.go, tools.go | SQLite persistence |
|
||||
| internal/ipc | coreapi.go, api.go | IPC interface + wire types |
|
||||
| internal/modes | modes.go | Mode inventory (30 routing classes) |
|
||||
| internal/mcp | manager.go, client.go, allowlist.go | MCP tool discovery and execution |
|
||||
| internal/smarthome | client.go | Home Assistant service calls |
|
||||
|
||||
### Key cmd/mavend files (the "glue" layer)
|
||||
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| voice.go | runTurn() pipeline, reactiveHandler, replySystem, applyAction |
|
||||
| turnroute.go | turnRoute memo (computed once per turn) |
|
||||
| actions.go | actionHandlers dispatch table (7 intents) |
|
||||
| actions_act.go | actionAct: tool matching, ecosystem interception, execution |
|
||||
| actions_fact.go | actionFact: write fact + embed + vector insert |
|
||||
| actions_reminder.go | actionReminder: parse time + create reminder |
|
||||
| actions_note.go | actionNote: write note + embed + vector insert |
|
||||
| actions_query.go | actionQuery: 20+ source chain, queryTurn, queryWalk |
|
||||
| confirm.go | resolveConfirm: y/n for destructive acts, Hexis, routines |
|
||||
| clarify.go | askClarify, resolveClarifyAnswer, wantedSlots |
|
||||
| followup.go | followUpMerge: slot inheritance across turns |
|
||||
| continuation.go | continuationDecision: elliptical follow-ups |
|
||||
| boot.go | Daemon wiring: connects all pieces |
|
||||
| voicewire.go | Voice server wiring: STT, router, replier, tools, sessions |
|
||||
| tick.go | Proactive tick loop: nudge delivery, reminders, routines |
|
||||
| tick_routines.go | Routine firing, pattern detection |
|
||||
| tick_digest.go | Digest queue and flush |
|
||||
|
||||
---
|
||||
|
||||
## C. Ingress -> Routing -> Action Call Trace
|
||||
|
||||
### Text path (web/telegram)
|
||||
|
||||
```
|
||||
1. mavweb POST /api/chat (cmd/mavweb/chat.go:84)
|
||||
-> strings.TrimSpace(r.FormValue("text"))
|
||||
-> core.Chat(ctx, "web", text) (ipc/coreapi.go:205-213)
|
||||
|
||||
2. daemonAPI.Chat (cmd/mavend/tick_api.go:95)
|
||||
-> chatFn(ctx, conversation, text)
|
||||
-> handler.handleText(ctx, conversation, text) (voice.go:245)
|
||||
|
||||
3. handleText (voice.go:245-248)
|
||||
-> runTurn(withDialogueID(ctx, ...), text, sourceText)
|
||||
|
||||
4. runTurn (voice.go:270-481) -- see Section A for step-by-step
|
||||
```
|
||||
|
||||
### Voice path (mic)
|
||||
|
||||
```
|
||||
1. Voice TCP Server receives PushToTalk frame (internal/voice/server.go:187-215)
|
||||
-> handler.HandlePushToTalk(ctx, req, sid) (cmd/mavend/voice.go:200-218)
|
||||
|
||||
2. HandlePushToTalk:
|
||||
-> stt.Transcribe(ctx, req.Audio) -- whisper.cpp or remote worker
|
||||
-> runTurn(ctx, text, sourceVoice) -- same pipeline as text
|
||||
-> tts.Synthesize(ctx, replyText) -- piper
|
||||
-> return voice.PushToTalkResp{ReplyText, ReplyAudio}
|
||||
```
|
||||
|
||||
### runTurn step-by-step (voice.go:270-481)
|
||||
|
||||
```
|
||||
Step 0 (276): decision.Record installed on context (V-564)
|
||||
Step 0b (292): turnRoute computed once, shared via context
|
||||
Step 1 (313): clarifyExpiredNotice -- parked question TTL ran out
|
||||
Step 2 (318): resolveConfirm -- y/n for parked destructive act
|
||||
-> classifyConfirm(text) (confirm.go:264)
|
||||
-> confirmResolvers chain (confirm.go:114):
|
||||
1. pendingRoutineConfirm
|
||||
2. pendingHexisExec
|
||||
3. pendingAct -> tools.Exec(ctx, fn, args, true)
|
||||
Step 3 (327): resolveRepair -- "нет, это был вопрос"
|
||||
Step 3b (334): resolveUntargetedRepair -- "нет, не так"
|
||||
Step 3c (344): resolveCommandProhibition -- "не отменяй..."
|
||||
Step 4 (356): resolveClarifyAnswer -- answer to parked question
|
||||
Step 5 (366): resolveQuietToggle -- "тихий режим"
|
||||
Step 5b (374): resolveSnooze -- "не сейчас" / "потом"
|
||||
Step 5c (381): resolveAck -- "готово"
|
||||
Step 5d (389): resolveReminderCancellation -- cancel verb + noun
|
||||
Step 5e (397): resolveCandidate -- "второй" (ordinal)
|
||||
Step 6 (406): ROUTE
|
||||
-> turnRoute.resolve(ctx) (turnroute.go:68)
|
||||
-> continuationDecision(prev, text, now) OR router.Route(ctx, text, now)
|
||||
Step 7 (425): followUpMerge(prev, dec, now) -- slot inheritance
|
||||
Step 8 (445): clarify -- missing required slots
|
||||
-> hexisBeforeClarify -- try Hexis before asking
|
||||
-> askClarify -> park PendingQuestion
|
||||
Step 9 (466): applyAction -> actionHandlers[dec.Intent]
|
||||
Step 9b (474): ackFromFact -- close live nudge
|
||||
Step 10 (477): replier -- phrase the reply (LLM or stub)
|
||||
```
|
||||
|
||||
### Action dispatch (voice.go:496-504 -> actions.go:48-56)
|
||||
|
||||
```
|
||||
applyAction(ctx, dec):
|
||||
if dec.Clarify -> return "" (Replier phrases)
|
||||
actionHandlers[dec.Intent](h, ctx, dec):
|
||||
|
||||
IntentFact -> actionFact (actions_fact.go:17)
|
||||
-> coreAPI.WriteFact + memStore.Insert + detectPattern
|
||||
|
||||
IntentReminder -> actionReminder (actions_reminder.go:16)
|
||||
-> coreAPI.CreateReminder
|
||||
|
||||
IntentAct -> actionAct (actions_act.go:17)
|
||||
-> refusesCommand check
|
||||
-> matcher.Match (fuzzy prefix over enabled tool names)
|
||||
-> task_status intercept
|
||||
-> praxis intercept
|
||||
-> hexis intercept (resolve entity -> discover capabilities -> risk -> exec)
|
||||
-> proposeGap (if no match)
|
||||
-> tools.Exec (tool/tool.go:156):
|
||||
LookupTool -> RiskOf -> PolicyFor(tier) -> dispatch:
|
||||
MCP -> mcp.CallPositional
|
||||
HA -> smarthome.CallService
|
||||
Process -> exec.CommandContext
|
||||
|
||||
IntentChat -> actionChat (actions.go:58)
|
||||
-> phraser.PhraseChat(ctx, utterance, history)
|
||||
|
||||
IntentSystem -> actionSystem (actions.go:77)
|
||||
-> replySystem: keyword match on utterance
|
||||
|
||||
IntentNote -> actionNote (actions_note.go:24)
|
||||
-> coreAPI.WriteNote + memStore.Insert
|
||||
|
||||
IntentQuery -> actionQuery (actions_query.go:varies)
|
||||
-> querySources chain (20+ sources, first claim wins)
|
||||
-> queryWalk narrows by destination
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## D. Inventory of Existing Machinery
|
||||
|
||||
### D1. Deterministic fast-path recognizers
|
||||
|
||||
**Status: EXISTS, extensive, production-critical**
|
||||
|
||||
| Component | File:line | What it does |
|
||||
|-----------|-----------|-------------|
|
||||
| 22 stage-0 grammars | router/stagezero.go:24-91 | Ordered regex/structural rules. First match wins at confidence 1.0 |
|
||||
| Grammar type | router/stage0.go:20-29 | Pattern+Build (regex) or Decide (structural) |
|
||||
| Wake-word strip | router/stage0.go:66-78 | StripWakeToken: removes "мавен" in any script |
|
||||
| Act allowlist fast path | router/stage0.go:55,80 | "мавен, restart nginx" -> act at stage 0 |
|
||||
| Command prohibition | stagezero.go:30 | "don't restart nginx" -> refusal sentinel |
|
||||
| System time/date | stagezero.go:35 | "сколько времени", "который час" |
|
||||
| Agenda query | stagezero.go:39 | "что у меня сегодня" |
|
||||
| Reminder | stagezero.go:59 | "напомни через час" with time extraction |
|
||||
| Fact capture | stagezero.go:80 | "запиши купить молоко" with note body extraction |
|
||||
| Possession statement | stagezero.go:89 | "у меня кончилась вода" |
|
||||
| Lexicon (closed word sets) | lexicon/lexicon.go | ~40 embedded Russian word sets |
|
||||
| Morphology | morph/morph.go | Lemma(), IsVerbForm(), SameWord() via golem |
|
||||
| Question detection | router/question.go | IsQuestionShaped(), IsOpenQuestionShaped(), CarriesCaptureVerb() |
|
||||
| Single-token analysis | router/singletoken.go | thinSingleToken() with completeSingles escape |
|
||||
| Note capture parser | router/notecapture.go | ParseNoteCapture(): strips capture frame |
|
||||
| DateTimeParser | router/slots.go:19-21 | Interface; production is dateparser (not shown) |
|
||||
| DefaultFactParser | router/slots.go:154-180 | Lemma-based fact extraction (water/meal/shower/break/sleep) |
|
||||
| DefaultActMatcher | router/slots.go:106-149 | Exact phrase prefix + aliases, longest-first |
|
||||
|
||||
### D2. Learned routing/NLU
|
||||
|
||||
**Status: EXISTS, multi-layered**
|
||||
|
||||
| Component | File:line | What it does |
|
||||
|-----------|-----------|-------------|
|
||||
| RouterHeads (ONNX) | router/heads.go:55-64 | 4 heads over e5-small: intent, destination, slot BIO, clarify |
|
||||
| RouterHeads.Route | router/heads.go:120+ | Softmax classification, threshold=0.6, single forward pass |
|
||||
| LLMRouter | router/llmrouter.go:20-22 | GBNF-constrained JSON from Qwen3-1.7B |
|
||||
| LLMRouter.Route | router/llmrouter.go:120+ | System prompt + grammar -> routeAction structs |
|
||||
| gateLLMDecision | router/router.go:352-373 | Thin confidence (0.3) for incomplete slots |
|
||||
| Classifier | router/classifier.go:40-45 | Nearest-centroid over ONNX embeddings |
|
||||
| Classifier.Classify | router/classifier.go:50+ | Cosine similarity, sorted best-first |
|
||||
| Classifier.AddExample | router/classifier.go | Append-only correction (grows classifier) |
|
||||
| ONNX Embedder | router/onnxembedder.go | multilingual-e5-small, 384-dim, query/passage prefix |
|
||||
| HashEmbedder | router/embedder.go:79-116 | Fallback bag-of-words (deterministic, weak) |
|
||||
| Embedder interface | router/embedder.go:17-21 | Dim(), Embed(), Close() |
|
||||
| Intent taxonomy | router/intent.go:38-63 | 7 intents: act, reminder, fact, note, query, chat, system |
|
||||
| Source taxonomy | router/source.go | 12 destinations: recall, calendar, tasks, list, money, weather, home, network, feeds, attention, self, world |
|
||||
| Slots struct | router/intent.go:66-94 | Time, Fn, Args, Key, Value, Text + Has* flags |
|
||||
| Decision struct | router/intent.go:100-128 | Utterance, Stage, Intent, Confidence, Slots, Clarify, Source, SourceAnchored |
|
||||
| Modes inventory | modes/modes.go | ~30 distinct downstream behaviors, embedded JSON |
|
||||
| Confidence gate | router/router.go:244-248 | threshold=0.55, below -> Clarify=true |
|
||||
|
||||
### D3. Claim/arbitration system
|
||||
|
||||
**Status: EXISTS, not wired into cascade (per claim.go:138-140)**
|
||||
|
||||
| Component | File:line | What it does |
|
||||
|-----------|-----------|-------------|
|
||||
| Band enum | claim/claim.go:38-69 | BandUnknown, BandVetoed, BandNearest, BandStructural, BandAnchored |
|
||||
| Claim struct | claim/claim.go:88-117 | Claimant, Intent, Filled, Consumed, Unexplained, Band, Veto |
|
||||
| Coverage() | claim/claim.go:122-128 | Consumed / (Consumed + Unexplained) |
|
||||
| MoreSpecificThan() | claim/claim.go:141-147 | Coverage first, Band breaks ties |
|
||||
| Tokens() | claim/claim.go:156-169 | Tokenize utterance for coverage |
|
||||
| Split() | claim/claim.go:178-192 | Partition tokens into consumed/unexplained |
|
||||
|
||||
Note: claim.go:138-140 explicitly states this is "Deliberately NOT wired into the cascade by V-565." It is here so the ordering is one function with tests rather than duplicated logic.
|
||||
|
||||
### D4. Action path
|
||||
|
||||
**Status: EXISTS, with multiple dispatch paths**
|
||||
|
||||
| Component | File:line | What it does |
|
||||
|-----------|-----------|-------------|
|
||||
| actionHandlers table | actions.go:48-56 | 7-intent dispatch map |
|
||||
| applyAction | voice.go:496-504 | Short-circuits on Clarify, dispatches via table |
|
||||
| actionAct | actions_act.go:17-114 | Full act cascade: refuse -> match -> task_status -> praxis -> hexis -> propose -> exec |
|
||||
| tool.Executor.Exec | tool/tool.go:156-235 | LookupTool -> RiskOf -> PolicyFor -> dispatch (MCP/HA/process) |
|
||||
| RiskOf | tool/risk.go | Derives tier from Tool row |
|
||||
| PolicyFor | tool/risk.go | TierSafe/TierDestructive/TierIrreversible -> Confirm/VoiceMayRun |
|
||||
| Matcher.Match | tool/tool.go:251+ (tool package) | Fuzzy prefix over enabled tool names |
|
||||
| MCP dispatch | tool/tool.go:191-198 | mcp.ParseCmd -> mcp.CallPositional |
|
||||
| HA dispatch | tool/tool.go:205-221 | smarthome.ParseCmd -> smarthome.CallService |
|
||||
| Process dispatch | tool/tool.go:231-234 | exec.CommandContext (no shell) |
|
||||
| Hexis integration | ecosystem_acts.go:660+ | resolve entity -> discover capabilities -> risk -> confirm/exec |
|
||||
| Praxis integration | ecosystem_acts.go (handlePraxisAct) | Attention/item lifecycle |
|
||||
| UnknownTargetError | tool/tool.go:90-96 | Named error with target word |
|
||||
| Tool store | store/tools.go | ProposeTool, EnableTool, DisableTool, LookupTool, ReconcileMCPTool |
|
||||
|
||||
### D5. Confirmation/risk handling
|
||||
|
||||
**Status: EXISTS, comprehensive**
|
||||
|
||||
| Component | File:line | What it does |
|
||||
|-----------|-----------|-------------|
|
||||
| resolveConfirm | confirm.go:78-100 | classifyConfirm + chain of resolvers |
|
||||
| classifyConfirm | confirm.go:264+ | Closed yes/no lexicon, entire utterance must match |
|
||||
| confirmResolvers | confirm.go:114-186 | 3 slots: routine proposal, Hexis exec, local tool |
|
||||
| confirmTTL | confirm.go:59 | 90s |
|
||||
| pendingAct | confirm.go:48-54 | fn, args, phrase, expiry |
|
||||
| pendingHexisExec | confirm.go:21-35 | capabilityID, entityID, correlationID, expiry |
|
||||
| pendingRoutineConfirm | confirm.go:39-46 | routineID, action, object, interval, phrase, expiry |
|
||||
| park() | confirm.go:63-67 | Stores pendingAct |
|
||||
|
||||
### D6. Non-action paths
|
||||
|
||||
| Component | File:line | What it does |
|
||||
|-----------|-----------|-------------|
|
||||
| actionChat | actions.go:58-75 | phraser.PhraseChat with dialogue history |
|
||||
| replySystem | voice.go:509-569 | Keyword matching on utterance for time/date/status |
|
||||
| actionQuery chain | actions_query.go:99-188 | 20+ sources, first claim wins |
|
||||
| queryWalk | actions_query.go:190+ | Narrows chain by destination |
|
||||
| Best recall | recall.go:8-65 | Vector search with confidence gate (minScore + minMargin) |
|
||||
| Clarify store | dialogue/clarify.go | PendingQuestion stack (max depth 2), 90s TTL |
|
||||
| Follow-up merge | followup.go:99-194 | Slot inheritance across same-intent turns |
|
||||
| Continuation | continuation.go:57-98 | Elliptical follow-ups ("а завтра?") |
|
||||
| Tick loop | tick.go | Proactive nudge delivery, reminders, routines, patterns, digest |
|
||||
|
||||
### D7. Tests/data inventory
|
||||
|
||||
**Status: Extensive (354 test files)**
|
||||
|
||||
Key test fixtures:
|
||||
|
||||
| Fixture | File | Cases |
|
||||
|---------|------|-------|
|
||||
| Routing contract | internal/router/eval/ru_routing_v1.json | ~70 held-out utterances with intent/source/time/clarity expectations |
|
||||
| Ecosystem reach | internal/router/eval/ru_ecosystem_v1.json | ~50 act utterances with service/capability expectations |
|
||||
| Nudge phrasing | internal/phraser/eval/nudges_v1.json | 15 cases, on-topic + property checks |
|
||||
| Talk phrasing | internal/phraser/eval/talk_v1.json | 27 cases (chat/query/knowledge) |
|
||||
| Recall contract | internal/memory/recalleval/ru_recall_v1.json | ~40 cases with note sets |
|
||||
| Personal boundary | cmd/mavend/testdata/personal_boundary_v1.json | 72 cases |
|
||||
| Safety scenarios | cmd/mavend/testdata/system_safety_scenarios.json | 4 scenarios |
|
||||
| Simulator scenarios | cmd/mavend/testdata/scenarios/*.json | 5 full scripted scenarios |
|
||||
| Usage transcript | scripts/testdata/usage-turns.txt | 167-line simulated usage |
|
||||
| STT golden | cmd/mavsttd/testdata/golden_v1.json | 4 cases with WER bounds |
|
||||
|
||||
Key eval harnesses:
|
||||
|
||||
| Harness | File |
|
||||
|---------|------|
|
||||
| Routing eval | internal/router/eval/eval.go |
|
||||
| Heads eval | internal/router/eval/heads_test.go |
|
||||
| LLM router eval | internal/router/eval/llmrouter_test.go |
|
||||
| Ecosystem reach eval | internal/router/eval/reach_test.go |
|
||||
| Claim scoring | internal/router/eval/claims_test.go |
|
||||
| Nudge phrasing eval | internal/phraser/eval/eval.go |
|
||||
| Talk phrasing eval | internal/phraser/eval/talk.go |
|
||||
| Recall eval | internal/memory/recalleval/recalleval.go |
|
||||
| Kiwix rewrite eval | internal/kiwix/rewrite_eval.go |
|
||||
| Simulator | cmd/mavend/simulator_test.go |
|
||||
|
||||
Classifier seed phrases: models/seeds/{query,fact,chat,act,note,reminder,system}.txt
|
||||
|
||||
---
|
||||
|
||||
## E. Existing Contracts/Types We Can Reuse
|
||||
|
||||
### Already well-typed (reuse as-is or thin wrapper)
|
||||
|
||||
| Type | File:line | Notes |
|
||||
|------|-----------|-------|
|
||||
| `router.Intent` | intent.go:38 | String enum: act, reminder, fact, note, query, chat, system |
|
||||
| `router.Slots` | intent.go:66-94 | Typed: Time, Fn, Args, Key, Value, Text + Has* flags |
|
||||
| `router.Decision` | intent.go:100-128 | Utterance, Stage, Intent, Confidence, Slots, Clarify, Source, SourceAnchored |
|
||||
| `router.Source` | source.go | String enum: 12 destinations |
|
||||
| `router.Grammar` | stage0.go:20-29 | Pattern+Build or Decide |
|
||||
| `router.Extractor` | slots.go:45-49 | Time, Acts, Facts parsers |
|
||||
| `claim.Claim` | claim.go:88-117 | Band-based arbitration (not wired yet) |
|
||||
| `claim.Band` | claim.go:36-69 | Ordinal evidence kinds |
|
||||
| `dialogue.PendingAction` | pending.go:58 | Capability, slots, missing, utterance, TTL |
|
||||
| `dialogue.Capability` | pending.go:16-46 | 7 capability strings |
|
||||
| `tool.Executor` | tool/tool.go:116-122 | Exec with policy checks |
|
||||
| `tool.Policy` | tool/risk.go | Confirm, VoiceMayRun per tier |
|
||||
| `ipc.ChatReply` | ipc/api.go:752-761 | Reply, Source, TraceID |
|
||||
| `ipc.Tool` | ipc/api.go:699 | Wire shape of tool row |
|
||||
|
||||
### Partially exists (needs extension)
|
||||
|
||||
| Concept | Current form | Gap |
|
||||
|---------|-------------|-----|
|
||||
| NormalizedInput | Raw string in, `text string` parameter | No NormalizedInput struct; STT output passes as-is |
|
||||
| FastPathResult | Stage-0 grammar Decision | Not a separate type; embedded in Decision |
|
||||
| RouteDecision | `router.Decision` | Already carries Stage (0/1/2/3), Intent, Confidence, Slots. Could become RouteDecision |
|
||||
| ActionCandidate | `router.Decision` + `actionHandlers` dispatch | No explicit ActionCandidate type; intent + slots + handler selection are implicit |
|
||||
|
||||
### Missing (must be created if needed)
|
||||
|
||||
| Concept | Notes |
|
||||
|---------|-------|
|
||||
| Schema validation | No JSON-schema or struct validation on incoming slots before execution |
|
||||
| Confidence/risk policy | Risk tiers exist for tools but not for routing confidence. Stage 3 gate exists but is a simple threshold |
|
||||
| Post-execution verification | No explicit verification step after tool execution (success/failure is the extent) |
|
||||
|
||||
---
|
||||
|
||||
## F. Gaps Against the Proposed First-Stage Design
|
||||
|
||||
### Proposed pipeline vs current reality
|
||||
|
||||
| Proposed stage | Current state | Gap |
|
||||
|---------------|---------------|-----|
|
||||
| **NormalizedInput** | Raw `text string` everywhere | No normalization struct. Preprocessing is scattered: StripWakeToken in router, lowercase in matchers, trim in entry points |
|
||||
| **Deterministic fast-path** | Stage-0 grammars (22 rules) | EXISTS and is production-critical. However: grammars produce `Decision` directly, not a separate FastPathResult type. No schema validation on slots before returning |
|
||||
| **Learned routing** | Heads -> LLM -> Classifier cascade | EXISTS and complex. But: multiple confidence scales (stage-0=1.0, heads=softmax, LLM=1.0/0.3, classifier=cosine). No unified confidence model |
|
||||
| **RouteDecision** | `router.Decision` | EXISTS under another name. Carries Stage, Intent, Confidence, Slots, Clarify, Source. Could be wrapped/renamed |
|
||||
| **ActionCandidate** | Implicit in `router.Decision` + `actionHandlers` | Missing as explicit type. The decision arrives at applyAction and is dispatched by intent. No schema validation of slots before dispatch |
|
||||
| **Schema validation** | NONE | Slots are filled by extractors and used directly. No validation that e.g. reminder has both Text and Time before actionReminder runs |
|
||||
| **Confidence/risk policy** | Threshold gate (0.55) for routing; risk tiers for tools | No unified confidence policy. Routing confidence and tool risk are separate systems. No policy that says "if confidence < X, require confirmation for action" |
|
||||
| **Confirmation** | resolveConfirm with 3 pending slots | EXISTS for destructive tools, Hexis, and routines. Not applied to routing confidence (a low-confidence act just gets proposed, not confirmed) |
|
||||
| **Execution** | tool.Executor.Exec | EXISTS with MCP/HA/process dispatch. Well-structured. But: actionFact, actionReminder, actionNote bypass tool.Executor entirely (they call CoreAPI directly) |
|
||||
| **Post-execution verification** | Success/failure error handling in actionAct | Partially EXISTS. Tool execution returns (out, err). Error types are handled specifically. But: no structured verification step, no retry policy, no rollback |
|
||||
|
||||
### Architectural problems identified
|
||||
|
||||
1. **Fast paths that execute directly**: actionFact, actionReminder, actionNote call CoreAPI.WriteFact/CreateReminder/WriteNote directly from the action handler, bypassing tool.Executor. This means they skip the risk tier system, the confirm gate, and the allowlist. This is by design (facts/reminders are user-stated, not tool invocations) but means the "all actions through one pipeline" goal requires either wrapping these in tool-like abstractions or explicitly exempting them.
|
||||
|
||||
2. **Multiple confidence scales**: Stage-0 = 1.0 (hardcode), heads = softmax float, LLM = 1.0 or 0.3 (thin), classifier = cosine similarity. These are not on the same scale and cannot be compared. The claim system (claim.Band) explicitly addresses this by making confidence ordinal (Band) rather than graded. The proposed design should preserve this insight.
|
||||
|
||||
3. **Routing code that also performs tool selection**: actionAct at actions_act.go:29-33 runs the matcher inline when HasFn is false. The matcher is also the stage-2 ActMatcher. So tool selection happens both in the router (stage 2) and in the action handler (actionAct). The actionAct path is the fallback for LLM-routed acts where the verb didn't go through stage-0.
|
||||
|
||||
4. **Implicit fallthrough**: The router cascade is explicitly designed as fallthrough (each stage may decline). The query source chain is also fallthrough (first claim wins). The confirm resolver chain is also fallthrough. This is a consistent pattern, not a bug, but means the "stage-to-stage" architecture must preserve explicit decline semantics.
|
||||
|
||||
5. **Clarify bypasses action pipeline**: When dec.Clarify is true, applyAction returns "" immediately (voice.go:497-499). The clarifier can also call hexisBeforeClarify (voice.go:446) to try Hexis before asking, which is a hidden action path that runs before the normal action dispatch.
|
||||
|
||||
6. **No schema validation**: Slots filled by stage-2 extraction or stage-0 grammars are used directly by action handlers. actionReminder (actions_reminder.go) checks HasTime itself. actionFact checks HasKey. But there is no shared validation layer; each handler does its own checks.
|
||||
|
||||
7. **Voice-specific behavior**: HandlePushToTalk wraps runTurn with STT before and TTS after. The turnSource tag ("tap:voice" vs "tap:text") propagates into fact sources but does not change routing or action behavior. However: the voice path has barge-in, session management, and wake-word detection that the text path lacks entirely. The semantic behavior is the same; the infrastructure is different.
|
||||
|
||||
8. **Existing useful code to preserve**:
|
||||
- Stage-0 grammars: 22 ordered rules, battle-tested, each with extensive comments about why it sits where it sits. Moving or reordering them breaks routing.
|
||||
- The claim/Band system: explicitly designed for the problem of incomparable confidence scales. Not wired yet but well-tested.
|
||||
- The clarify store with stack support: handles nested clarification flows.
|
||||
- The query source chain with destination narrowing: 20+ sources with guessers vs lookups distinction.
|
||||
- The tool risk tier system: well-tested, with voice-specific authority limits.
|
||||
- The turnRoute memo pattern (V-560): computed once, shared via context, prevents routing disagreement.
|
||||
|
||||
---
|
||||
|
||||
## G. Smallest Behavior-Preserving Refactor Boundary
|
||||
|
||||
The smallest refactor that aligns with the proposed architecture without changing behavior:
|
||||
|
||||
**Wrap Decision in RouteDecision + add NormalizedInput as thin alias**
|
||||
|
||||
```
|
||||
Current: router.Route(ctx, utterance, now) -> (Decision, error)
|
||||
Proposed: router.Route(ctx, NormalizedInput, now) -> (RouteDecision, error)
|
||||
```
|
||||
|
||||
Where:
|
||||
- `NormalizedInput` is `type NormalizedInput struct { Text string; Source string }` -- a thin wrapper, not a transformation
|
||||
- `RouteDecision` is `type RouteDecision Decision` -- or just `Decision` with a type alias
|
||||
- The existing Stage field (0/1/2/3) already encodes which stage produced the result
|
||||
- The existing Confidence field already carries the per-stage confidence
|
||||
|
||||
This changes zero behavior. It names what exists. It creates the typed boundary the future stages need.
|
||||
|
||||
**Second step: extract action candidates**
|
||||
|
||||
Currently actionAct does tool matching inline. The matcher result (fn, args) should be a typed ActionCandidate returned by the router or by a post-route step, not discovered inside the action handler. But this changes the call structure of actionAct, which is a larger refactor.
|
||||
|
||||
**Third step: schema validation**
|
||||
|
||||
Add a Validate(slots) step between routing and action dispatch. Currently each handler validates its own slots; this would centralize it. Minimal behavior change: the same checks, in one place.
|
||||
|
||||
---
|
||||
|
||||
## H. Recommended Implementation Order
|
||||
|
||||
1. **Type the boundaries** (1-2 hours)
|
||||
- Define NormalizedInput, RouteDecision as thin wrappers
|
||||
- Route() signature change (internal callers only)
|
||||
- Zero behavior change
|
||||
|
||||
2. **Pin current behavior with regression tests** (2-3 hours)
|
||||
- Run existing eval fixtures and record baselines
|
||||
- Add integration tests for the full runTurn pipeline (text + voice paths)
|
||||
- Add tests for each action handler with representative inputs
|
||||
|
||||
3. **Extract ActionCandidate from actionAct** (3-4 hours)
|
||||
- Move tool matching out of actionAct into a post-route step
|
||||
- Return ActionCandidate{Fn, Args, Source} from routing
|
||||
- actionAct consumes ActionCandidate instead of re-matching
|
||||
|
||||
4. **Add schema validation layer** (2-3 hours)
|
||||
- Validate slots before action dispatch
|
||||
- Centralize the per-handler checks
|
||||
- Fail-closed: missing required slot -> clarify, not runtime error
|
||||
|
||||
5. **Unify confidence presentation** (3-4 hours)
|
||||
- Map per-stage confidence to ordinal Band (leverage existing claim.Band)
|
||||
- Expose in RouteDecision for downstream policy
|
||||
- Do NOT try to make confidence comparable across stages
|
||||
|
||||
6. **Wire claim.Band into cascade** (4-6 hours)
|
||||
- Replace ad-hoc precedence with MoreSpecificThan
|
||||
- This is the V-558/V-565 work already planned
|
||||
|
||||
---
|
||||
|
||||
## I. Tests That Should Pin Current Behavior Before Refactoring
|
||||
|
||||
### High-value regression pins
|
||||
|
||||
| Test | What it pins | File |
|
||||
|------|-------------|------|
|
||||
| Router cascade stage ordering | Stage-0 wins, heads decline correctly, LLM fallback, classifier floor | internal/router/router_test.go |
|
||||
| Held-out routing contract | ~70 utterances with intent/source/time expectations | internal/router/eval/eval_test.go |
|
||||
| Ecosystem reach contract | ~50 act utterances routing to correct service | internal/router/eval/reach_test.go |
|
||||
| Simulator scripted day | Full pipeline: STT -> router -> store -> phraser | cmd/mavend/simulator_test.go |
|
||||
| Safety scenarios | Destructive acts require confirmation, ambiguous entities clarified | cmd/mavend/eval_scenarios_test.go |
|
||||
| Tool risk assessment | Tier derivation from tool rows | internal/tool/ risk_test.go (implied) |
|
||||
| Confirm flow | y/n for parked acts, TTL expiry, chain ordering | cmd/mavend/confirm_test.go |
|
||||
| Clarify flow | Missing slots -> question -> answer -> continue | cmd/mavend/clarify_test.go |
|
||||
| Follow-up merge | Slot inheritance across turns | cmd/mavend/followup_test.go |
|
||||
| Query source chain | First-claim-wins, destination narrowing | cmd/mavend/querywalk_test.go |
|
||||
| Personal boundary | 72-case held-out fixture | cmd/mavend/personalboundary_test.go |
|
||||
| Recall contract | ~40 cases with paraphrased queries | internal/memory/recalleval/recalleval_test.go |
|
||||
| Action act risk | Destructive/irreversible classification | cmd/mavend/actions_act_risk_test.go |
|
||||
| Degradation | Each ecosystem service unreachable | cmd/mavend/ecosystem_degraded_test.go |
|
||||
|
||||
### What to run before and after each refactor step
|
||||
|
||||
```sh
|
||||
make test # full suite
|
||||
go test ./internal/router/eval/ -run Eval # routing contract
|
||||
go test ./cmd/mavend/ -run Simulator # integration
|
||||
go test ./cmd/mavend/ -run Eval # safety scenarios
|
||||
go test ./cmd/mavend/ -run PersonalBoundary # boundary fixture
|
||||
go test ./internal/phraser/eval/ -run Eval # phrasing contract
|
||||
go test ./internal/memory/recalleval/ -run Eval # recall contract
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## J. Unknowns That Cannot Be Established From Code/Tests
|
||||
|
||||
1. **Actual production accuracy numbers**: The eval fixtures measure held-out accuracy, but production routing traces (routing_traces table) are the real measure. We cannot inspect the production DB from code.
|
||||
|
||||
2. **Whether the LLM router is currently enabled in production**: The config shows `llm_router` settings but we cannot confirm the daemon is running with it wired. The heads may be the actual fast path.
|
||||
|
||||
3. **Real-world confirmation rates**: How often do users get asked to confirm? How often do they decline? This is behavioral data, not code.
|
||||
|
||||
4. **Whether the claim system should be wired**: claim.go says "Deliberately NOT wired by V-565" but the current ad-hoc precedence works. The claim system is tested but untested in production.
|
||||
|
||||
5. **Token budget pressure on the resident model**: The 4096 context window is shared between routing, phraser, and chat. We cannot tell from code whether context pressure causes routing failures in production.
|
||||
|
||||
6. **Whether stage-0 grammars overlap or shadow each other**: The ordering is documented, but no test measures "if grammar A were removed, which utterances would fall through differently." The cascade hides contention by design.
|
||||
|
||||
7. **Performance characteristics of the ONNX embedder in production**: Tests measure p50 (20.6ms for classifier). Production numbers on the actual hardware may differ.
|
||||
|
||||
8. **Whether the ecology of pre-route resolvers (steps 1-5e) can be unified**: Seven resolvers each claim the turn independently, in order. Whether they could be replaced by a single arbiter (the claim system) is a design question, not a code question.
|
||||
|
||||
---
|
||||
|
||||
## Proposed Mapping: Future Stage/Contract -> Current Implementation
|
||||
|
||||
| Future stage/contract | Current implementation | Reuse/wrap/move/replace | Reason |
|
||||
|----------------------|----------------------|------------------------|--------|
|
||||
| **NormalizedInput** | Raw `text string` parameter in handleText/HandlePushToTalk/runTurn | **wrap** | Create struct, pass through. No transformation needed yet. Existing preprocessing (StripWakeToken, trim) stays inside the router. |
|
||||
| **Deterministic fast path** | Stage-0 grammars (router/stagezero.go, stage0.go) | **reuse as-is** | 22 battle-tested rules with load-bearing ordering. Output is Decision at confidence 1.0. Naming it "fast path" is cosmetic. |
|
||||
| **FastPathResult** | Decision with Stage=0 | **wrap** | Type alias or thin struct. The Stage field already identifies the source. |
|
||||
| **RouteDecision** | `router.Decision` (intent.go:100-128) | **reuse (rename or alias)** | Already carries all needed fields: Intent, Confidence, Slots, Clarify, Source, Stage. The Stage field (0/1/2/3) tells which cascade stage produced it. |
|
||||
| **ActionCandidate** | Implicit: Decision.Intent + Decision.Slots + actionHandlers dispatch | **move** | Extract from actionAct into a post-route step. Currently, actionAct:29-33 re-runs the matcher when HasFn is false. This should produce an ActionCandidate that actionAct consumes. |
|
||||
| **Schema validation** | Per-handler checks (actionReminder checks HasTime, actionFact checks HasKey) | **move + centralize** | Currently scattered across action handlers. Centralize into a Validate(Decision) step before applyAction. |
|
||||
| **Confidence/risk policy** | Stage-3 gate (threshold 0.55) for routing; RiskOf/PolicyFor for tools | **extend** | These are separate systems today. The routing gate Clarify flag. The tool policy returns ErrNeedsConfirm/ErrNeedsAuthedSurface. A unified policy would map confidence bands to action policies. |
|
||||
| **Confirmation** | resolveConfirm (confirm.go:78) with 3 pending slots | **reuse** | Already handles destructive tools, Hexis, and routines. Would need extension if low-confidence acts should also confirm. |
|
||||
| **Execution** | tool.Executor.Exec (tool/tool.go:156) + per-intent handlers | **reuse** | Well-structured with MCP/HA/process dispatch. The per-intent handlers (actionFact, actionReminder) bypass Executor by design -- they write to the store, not run tools. |
|
||||
| **Post-execution verification** | Error handling in actionAct (actions_act.go:64-108) | **extend** | Currently: success -> "done", specific error -> specific reply. No structured verification step. Adding one would be a new layer. |
|
||||
|
||||
+43
-1
@@ -986,6 +986,48 @@ ends with `make test` green (gofmt + vet + `-race`), no exceptions.
|
||||
|
||||
---
|
||||
|
||||
## Prior art — external memory systems
|
||||
|
||||
Read before proposing a change to how memory is extracted or read back. Nothing
|
||||
here is adopted. Each entry says what does not transfer and what a cheap
|
||||
experiment against it would be.
|
||||
|
||||
### VoiceMem (github.com/xzf-thu/VoiceMem, Apache 2.0)
|
||||
|
||||
A streaming memory system for voice assistants, surveyed 2026-08-30. Python,
|
||||
Chinese-first. A "left brain" of keyed structured facts and a "right brain" of
|
||||
affect and relationship nodes, both extracted and queried while the user is
|
||||
still speaking.
|
||||
|
||||
**What does not transfer.** Its speech stack is Paraformer-zh streaming STT with
|
||||
Qwen-Omni or Step-Audio2-Mini as the conversational model. Maven runs
|
||||
whisper.cpp, piper and Qwen3-1.7B. It is a Python library, so adopting the code
|
||||
means a Python service on homesrv behind a new daemon seam (`docs/offload.md`),
|
||||
which is a large cost for a Chinese-tuned pipeline. The licence is not the
|
||||
barrier.
|
||||
|
||||
**What is worth taking.**
|
||||
|
||||
- **Streaming extraction.** Extraction and retrieval start on partial
|
||||
transcripts, not on a final one. Maven's `runTurn` waits for mavsttd to
|
||||
finish. This is the larger win and the larger change, because it touches both
|
||||
the STT seam and the turn ladder.
|
||||
- **A hard retrieval token budget.** They report roughly 430 memory tokens per
|
||||
query. At `n_ctx` 4096 the constraint binds directly on what memory may put in
|
||||
front of the resident model. This is the cheapest experiment: measure Maven's
|
||||
existing recall evals against a capped budget.
|
||||
- **The fact/affect split.** The factual half is what Maven already has. Affect
|
||||
and relationship nodes have no equivalent here, and they bear on § save-where.
|
||||
- **A shared embedder.** They also use multilingual-E5, so their retrieval
|
||||
scoring ports without a model change.
|
||||
|
||||
**Read their numbers carefully.** 91.2% on LoCoMo against 61.68% for Mem0 is
|
||||
self-reported by the authors with no independent replication found. The 134ms
|
||||
figure is memory-system latency, not a turn including STT and the resident
|
||||
model. Both LoCoMo and PersonaMem are English and Chinese, so no claim there
|
||||
holds for Russian recall until a translated fixture exists. Any number taken
|
||||
from this section into Maven's prose needs a `docs/evals/` file behind it.
|
||||
|
||||
## Open questions
|
||||
|
||||
Router+invocation, two-memory routing, presence, and auth were once listed
|
||||
@@ -1019,7 +1061,7 @@ here and are resolved by the sections above.
|
||||
- **compound captures** — "slept 6h, fan noise wrecked it" = one fact + one
|
||||
note in one utterance. Needs a second pass or it loses half.
|
||||
- **query read-path** — semantic RAG vs a structured read, depending on the
|
||||
ask.
|
||||
ask. Prior art in § Prior art — external memory systems (VoiceMem).
|
||||
- **presence — away tap override** — an explicit `away` tap as a hard
|
||||
override. Clean extension, deferred; scoring stands without it.
|
||||
- **presence — weights/τ hand-tuning** — first-guess numbers; expect tuning
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# Action-Resolution Boundary — Slice Report
|
||||
|
||||
## 1. Files changed
|
||||
|
||||
| File | Status | Purpose |
|
||||
|---|---|---|
|
||||
| `internal/router/actioncandidate.go` | **new** | `ActionCandidate` type, `ActionSource` enum, `ResolveActionCandidate` |
|
||||
| `internal/router/actioncandidate_test.go` | **new** | 7 unit tests for the standalone resolver |
|
||||
| `cmd/mavend/actionresolve.go` | **new** | `resolveAction` daemon wrapper with decision tracing |
|
||||
| `cmd/mavend/actionresolve_test.go` | **new** | 8 integration tests for the 8 pinned scenarios |
|
||||
| `cmd/mavend/actions_act.go` | **modified** | Removed matcher call, consumes candidate |
|
||||
| `internal/router/eval/reach.go` | **modified** | `Reach()` uses `ResolveActionCandidate` |
|
||||
|
||||
## 2. `ActionCandidate` contract
|
||||
|
||||
```go
|
||||
type ActionCandidate struct {
|
||||
Fn string // resolved function/tool identity (empty = unresolved)
|
||||
Args []string // positional arguments (may be nil)
|
||||
Source ActionSource // "route" or "matcher"
|
||||
Producer RouteProducer // cascade stage that produced the decision
|
||||
Confidence float64 // routing confidence
|
||||
}
|
||||
|
||||
type ActionSource string
|
||||
const (
|
||||
ActionSourceRoute ActionSource = "route" // Fn/Args resolved upstream
|
||||
ActionSourceMatcher ActionSource = "matcher" // fallback matcher resolved
|
||||
)
|
||||
```
|
||||
|
||||
## 3. Where `resolveAction` lives and why
|
||||
|
||||
- **Standalone:** `router.ResolveActionCandidate(dec, m)` in `internal/router/actioncandidate.go` — reusable by both daemon and eval harness.
|
||||
- **Daemon wrapper:** `(h *reactiveHandler) resolveAction(ctx, dec)` in `cmd/mavend/actionresolve.go` — delegates to the standalone function, adds decision tracing.
|
||||
|
||||
Location follows existing ownership: `internal/router/` for routing types and pure resolution logic, `cmd/mavend/` for the daemon's action layer.
|
||||
|
||||
## 4. Before/after act flow
|
||||
|
||||
**Before:**
|
||||
```
|
||||
RouteDecision → actionAct
|
||||
├─ if !HasFn: h.matcher.Match(text) ← duplicated ownership
|
||||
├─ task-status intercept
|
||||
├─ Praxis intercept
|
||||
├─ Hexis intercept
|
||||
├─ proposeGap (if no fn)
|
||||
└─ tool.Executor.Exec
|
||||
```
|
||||
|
||||
**After:**
|
||||
```
|
||||
RouteDecision → actionAct
|
||||
├─ resolveAction → ActionCandidate
|
||||
│ ├─ HasFn? → source=route
|
||||
│ └─ else? → h.matcher.Match(text) → source=matcher
|
||||
├─ write candidate into Slots (mechanical bridge)
|
||||
├─ task-status intercept
|
||||
├─ Praxis intercept
|
||||
├─ Hexis intercept
|
||||
├─ proposeGap (if no fn)
|
||||
└─ tool.Executor.Exec
|
||||
```
|
||||
|
||||
## 5. Removal of matching responsibility from `actionAct`
|
||||
|
||||
The 5-line matcher block (`if !dec.Slots.HasFn && dec.Slots.Text != "" && h.matcher != nil { ... }`) was removed from `actionAct`. It now calls `resolveAction` which delegates to `router.ResolveActionCandidate`. The candidate's resolved values are written back into `dec.Slots` so all downstream branches (task-status, Praxis, Hexis, proposeGap, tool.Executor) work unchanged.
|
||||
|
||||
## 6. Tests added
|
||||
|
||||
**Router unit tests** (`internal/router/actioncandidate_test.go`):
|
||||
1. `TestResolveActionCandidate_RouteSource` — HasFn=true → source=route
|
||||
2. `TestResolveActionCandidate_MatcherSource` — no Fn → matcher resolves
|
||||
3. `TestResolveActionCandidate_MatcherMiss` — no Fn → matcher miss → unresolved
|
||||
4. `TestResolveActionCandidate_NonAct` — non-act → empty candidate
|
||||
5. `TestResolveActionCandidate_Stage0Match` — stage-0 → route, confidence=1.0
|
||||
6. `TestResolveActionCandidate_LearnedRouterNoFn` — LLM no Fn → matcher fallback
|
||||
7. `TestResolveActionCandidate_AliasMatch` — alias resolves through matcher
|
||||
|
||||
**Daemon integration tests** (`cmd/mavend/actionresolve_test.go`):
|
||||
1. `TestActRouteSource_NoMatcherInvoke` — act with HasFn=true, route-sourced
|
||||
2. `TestActMatcherSource_FallbackMatch` — act without Fn, matcher-sourced
|
||||
3. `TestActMatcherMiss_ProposeGap` — matcher miss → propose-gap
|
||||
4. `TestActDestructive_ConfirmationUnchanged` — destructive → confirm
|
||||
5. `TestActTaskStatus_InterceptUnchanged` — task-status intercepted
|
||||
6. `TestActStage0_SameResult` — stage-0 act executes same tool
|
||||
7. `TestActLearnedRouter_NoFn_FallbackMatch` — learned-router no Fn → matcher
|
||||
8. `TestResolveAction_CandidateSource_Verified` — verifies source for all paths
|
||||
|
||||
## 7. Before/after test results
|
||||
|
||||
```
|
||||
ok github.com/kami/maven/cmd/mavend 22.149s
|
||||
ok github.com/kami/maven/internal/router 1.131s
|
||||
ok github.com/kami/maven/internal/router/eval 0.762s
|
||||
```
|
||||
|
||||
All 15 new tests pass. All existing tests pass unchanged.
|
||||
|
||||
## 8. Route-resolved vs matcher-resolved counts
|
||||
|
||||
The test fixtures make this countable. From the 8 new integration tests:
|
||||
- **Route-resolved:** 3 (TestActRouteSource, TestActStage0, TestResolveAction_CandidateSource route branch)
|
||||
- **Matcher-resolved:** 3 (TestActMatcherSource, TestActLearnedRouter, TestResolveAction_CandidateSource matcher branch)
|
||||
- **Matcher miss:** 2 (TestActMatcherMiss, TestResolveAction_CandidateSource miss branch)
|
||||
|
||||
The decision trace records `action-resolve:route:<fn>` or `action-resolve:matcher:<fn>` for every act turn, making production measurement possible via the existing `/trace` endpoint.
|
||||
|
||||
## 9. Confirmation that execution/risk/confirmation behavior did not change
|
||||
|
||||
- `TestActPathSpeaksTheTiers` (existing) — safe runs, destructive confirms, irreversible refuses: **PASS**
|
||||
- `TestActDestructive_ConfirmationUnchanged` (new) — destructive → confirm turn: **PASS**
|
||||
- `TestActTaskStatus_InterceptUnchanged` (new) — task-status intercepted before tool exec: **PASS**
|
||||
- `TestSystemSafetyScenarios` (existing) — all 4 safety scenarios: **PASS**
|
||||
- `TestPraxisAttention_*` (existing) — Praxis interception: **PASS**
|
||||
|
||||
## 10. Ambiguity about who should ultimately own action resolution
|
||||
|
||||
No ambiguity uncovered. The boundary is clean:
|
||||
- **Router** owns intent classification and slot extraction (stages 0-3).
|
||||
- **`ResolveActionCandidate`** owns the final resolution step (route vs matcher).
|
||||
- **`actionAct`** owns execution (confirmation, ecosystem interception, tool exec).
|
||||
|
||||
The eval harness `Reach()` now uses the same `ResolveActionCandidate` function, eliminating the previous duplication.
|
||||
|
||||
## 11. Commit hashes
|
||||
|
||||
Three commits, split to stay under the 300-line pre-commit cap:
|
||||
|
||||
```
|
||||
f6d7b05 mavend: add action-resolution regression tests
|
||||
025f81e mavend: wire resolveAction into actionAct
|
||||
064747f router: add ActionCandidate type and ResolveActionCandidate
|
||||
```
|
||||
@@ -0,0 +1,118 @@
|
||||
# Slice 1: Typed Ingress Boundary and Route Producer Observability
|
||||
|
||||
## 1. Files changed
|
||||
|
||||
**New types:**
|
||||
- `internal/router/source.go` — `InputSource`, `InputSourceVoice`, `InputSourceText`, `NormalizedInput`
|
||||
- `internal/router/intent.go` — `RouteProducer`, `RouteProducerGrammar/Heads/LLM/Classifier`, `Producer` field on `Decision`
|
||||
|
||||
**Cascade wiring:**
|
||||
- `internal/router/router.go` — `Producer` set at each of the four cascade stages
|
||||
|
||||
**Observability:**
|
||||
- `internal/decision/decision.go` — `InputSource` and `RouteProducer` fields on `Record`; `With()` accepts `inputSource`
|
||||
- `internal/store/routingtraces.go` — `RouteProducer` field on `RoutingTrace`
|
||||
- `internal/store/migrations.go` — migration #27: `ALTER TABLE routing_traces ADD COLUMN route_producer`
|
||||
- `cmd/mavend/routingtrace.go` — persists `RouteProducer` from the decision record
|
||||
|
||||
**Turn lifecycle:**
|
||||
- `cmd/mavend/voice.go` — `turnSource` is now `type turnSource = router.InputSource`; `runTurn` takes `NormalizedInput`; `HandlePushToTalk` and `handleText` construct `NormalizedInput`
|
||||
- `cmd/mavend/turnroute.go` — `turnRoute` carries `NormalizedInput`; `newTurnRoute` and `resolve` use it
|
||||
|
||||
**Test updates (signature适应):**
|
||||
- `cmd/mavend/clarify_test.go`, `reactive_notes_test.go`, `reminder_cancel_test.go`, `repair_test.go`, `simulator_test.go`, `turnrole_test.go` — `runTurn` calls updated to `NormalizedInput`
|
||||
|
||||
**New tests:**
|
||||
- `internal/router/boundary_test.go` — 7 tests: type shape, constants, producer per stage
|
||||
- `cmd/mavend/boundary_test.go` — 5 tests: convergence, source preservation, producer on record, pre-route empty producer, stage-0 unchanged
|
||||
|
||||
## 2. Boundary types introduced/reused
|
||||
|
||||
| Type | Package | Kind | Purpose |
|
||||
|---|---|---|---|
|
||||
| `NormalizedInput` | `router` | new struct | Typed ingress boundary: `Text string` + `Source InputSource` |
|
||||
| `InputSource` | `router` | new `string` type | Channel provenance: `tap:voice`, `tap:text` |
|
||||
| `RouteProducer` | `router` | new `string` type | Cascade stage provenance: `grammar`, `heads`, `llm`, `classifier` |
|
||||
| `turnSource` | `main` | **alias** for `router.InputSource` | Convenience alias; all existing call sites unchanged |
|
||||
|
||||
Reused: `router.Source` (destination), `router.Intent`, `router.Decision`, `decision.Record`.
|
||||
|
||||
## 3. Before/after flow diagram
|
||||
|
||||
```
|
||||
BEFORE:
|
||||
HandlePushToTalk → stt → runTurn(ctx, text, sourceVoice)
|
||||
handleText → runTurn(ctx, text, sourceText)
|
||||
runTurn(ctx, text, src):
|
||||
decision.With(ctx, text)
|
||||
newTurnRoute(text, now) → rt.text = text
|
||||
rt.resolve() → router.Route(ctx, text, now)
|
||||
Decision.Utterance = utterance
|
||||
[no producer field]
|
||||
|
||||
AFTER:
|
||||
HandlePushToTalk → stt → runTurn(ctx, NormalizedInput{text, sourceVoice})
|
||||
handleText → runTurn(ctx, NormalizedInput{text, sourceText})
|
||||
runTurn(ctx, input):
|
||||
decision.With(ctx, input.Text, input.Source)
|
||||
newTurnRoute(input, now) → rt.input = input
|
||||
rt.resolve() → router.Route(ctx, input.Text, now)
|
||||
Decision.Utterance = utterance
|
||||
Decision.Producer = grammar|heads|llm|classifier
|
||||
rec.RouteProducer = dec.Producer
|
||||
```
|
||||
|
||||
## 4. Tests added
|
||||
|
||||
**internal/router/boundary_test.go** (7 tests):
|
||||
- `TestNormalizedInputIsMinimalValueObject` — shape pin
|
||||
- `TestInputSourceConstants` — tap:voice, tap:text
|
||||
- `TestRouteProducerConstants` — grammar, heads, llm, classifier
|
||||
- `TestStage0SetsGrammarProducer` — grammar win carries grammar producer
|
||||
- `TestClassifierSetsProducer` — classifier floor sets its producer
|
||||
- `TestClarifyProducerIsClassifier` — clarified turn carries classifier producer
|
||||
- `TestStage0ProducerOnEveryGrammar` — property test over multiple grammars
|
||||
|
||||
**cmd/mavend/boundary_test.go** (5 tests):
|
||||
- `TestTextAndVoiceConvergeOnNormalizedInput` — same utterance, same route intent
|
||||
- `TestNormalizedInputSourcePreserved` — source survives to decision record
|
||||
- `TestRouteProducerOnDecisionRecord` — producer carried to record
|
||||
- `TestPreRouteClaimHasNoRouteProducer` — confirm-claimed turn has empty producer
|
||||
- `TestStage0ProducerUnchanged` — grammar stage-0 still produces same intents
|
||||
|
||||
## 5. Full test/eval results
|
||||
|
||||
| Suite | Before | After |
|
||||
|---|---|---|
|
||||
| `go test ./internal/router/` | PASS (1.058s) | PASS (1.568s) |
|
||||
| `go test ./internal/router/eval/` | PASS (0.783s) | PASS (1.219s) |
|
||||
| `go test ./cmd/mavend/ -run Simulator` | PASS (1.451s) | PASS (1.753s) |
|
||||
| `go test ./cmd/mavend/ -run PersonalBoundary` | PASS (0.147s) | PASS (0.183s) |
|
||||
| `go test ./cmd/mavend/ -run Eval` | PASS (0.015s) | PASS (0.012s) |
|
||||
| `go test ./cmd/mavend/` (full) | PASS (1.451s) | PASS (22.311s) |
|
||||
|
||||
The timing increase in `cmd/mavend` full suite is from the new boundary tests (293 lines of new test code), not from a regression.
|
||||
|
||||
## 6. Confirmation that routing outputs and action behavior are unchanged
|
||||
|
||||
- `internal/router/eval/` scores the held-out fixture against the same cascade; the number did not move.
|
||||
- `cmd/mavend -run Simulator` replays deterministic scripted days; all scenario assertions pass identically.
|
||||
- `cmd/mavend -run PersonalBoundary` exercises the personal boundary query chain; passes identically.
|
||||
- Stage-0 grammars: same ordering in `StageZeroGrammars()`, same matching semantics, same confidence 1.0.
|
||||
- `RouteProducer` is a new field with zero value `""` for existing code paths that don't set it; no existing consumer reads it.
|
||||
- `NormalizedInput` is the same `(text string, source turnSource)` pair passed as a struct; no transformation applied.
|
||||
|
||||
## 7. Semantic changes required
|
||||
|
||||
**None.** The refactoring is purely structural:
|
||||
- `turnSource` became a type alias for `router.InputSource` — identical underlying type, no conversion needed at any call site.
|
||||
- `runTurn` takes `NormalizedInput` instead of `(text, src)` — the destructuring `text := input.Text; src := input.Source` at the top of the function body produces identical local variables.
|
||||
- `decision.With` gained an `inputSource` parameter — a string stored on the record, never read back during routing.
|
||||
- `RouteProducer` is a new field on `Decision` — set after the decision is already produced, never consumed by the cascade.
|
||||
|
||||
## 8. Commit hashes
|
||||
|
||||
```
|
||||
87a3b16 router: introduce typed ingress boundary and route producer observability
|
||||
a55d909 router: add boundary tests for typed ingress and route producer
|
||||
```
|
||||
@@ -0,0 +1,188 @@
|
||||
# Slice 3: Structural validation between ActionCandidate resolution and execution
|
||||
|
||||
**Date:** 2026-09-05
|
||||
**Base:** 6a402bf (slice 2 committed)
|
||||
**Status:** complete
|
||||
|
||||
## 1. Files changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `internal/router/actioncandidate.go` | +76 lines: `ActionField`, `ActionValidationIssue`, `ActionValidationResult`, `ValidateActionCandidate` |
|
||||
| `internal/router/actioncandidate_test.go` | +98 lines: 6 unit tests for `ValidateActionCandidate` |
|
||||
| `cmd/mavend/actions_act.go` | +11 lines: validation call, invalid-candidate early return |
|
||||
| `cmd/mavend/actionresolve.go` | +37 lines: `noteActionValidation` tracing function |
|
||||
| `cmd/mavend/actionresolve_test.go` | +128 lines: 5 integration tests (malformed, unresolved, destructive, irreversible, tracing) |
|
||||
|
||||
## 2. Validation contract
|
||||
|
||||
```go
|
||||
type ActionValidationResult struct {
|
||||
Unresolved bool // Fn empty → proposeGap path
|
||||
Valid bool // Fn non-empty, structure sound
|
||||
Issues []ActionValidationIssue // non-empty when Invalid
|
||||
}
|
||||
|
||||
type ActionValidationIssue struct {
|
||||
Field ActionField // "fn" or "args"
|
||||
Reason string // machine-readable tag
|
||||
}
|
||||
|
||||
type ActionField string
|
||||
const (
|
||||
FieldFn ActionField = "fn"
|
||||
FieldArgs ActionField = "args"
|
||||
)
|
||||
```
|
||||
|
||||
Three disjoint outcomes:
|
||||
- **Unresolved**: Fn is empty. Not a validation error. Routes to proposeGap / clarification.
|
||||
- **Valid**: Fn non-empty, structurally admissible. Proceeds to risk policy and execution.
|
||||
- **Invalid**: Fn non-empty but malformed. Refused with `ActFail`.
|
||||
|
||||
## 3. Exact validation rules introduced
|
||||
|
||||
| Rule | Check | Outcome on fail |
|
||||
|------|-------|-----------------|
|
||||
| `unresolved` | `c.Fn == ""` | Unresolved (not invalid) |
|
||||
| `blank_function_name` | `strings.TrimSpace(c.Fn) == ""` when Fn is non-empty | Invalid |
|
||||
|
||||
The model is deliberately small. This is not a general validation framework.
|
||||
|
||||
## 4. Where each rule existed previously
|
||||
|
||||
| Rule | Previous location | Migration type |
|
||||
|------|-------------------|----------------|
|
||||
| Unresolved → proposeGap | `cmd/mavend/actions_act.go:82` (`!dec.Slots.HasFn`) | Preserved: the existing `!dec.Slots.HasFn` check now comes after validation, same observable behavior |
|
||||
| Blank Fn → refuse | No previous check existed | New: defensive check for a structurally malformed candidate that the route/matcher should never produce |
|
||||
|
||||
**Observation:** The current route and matcher never produce a blank (whitespace-only) Fn. The `blank_function_name` rule is a defensive gate against future producer defects, not a migration from existing behavior.
|
||||
|
||||
## 5. Before/after action flow
|
||||
|
||||
**Before (slice 2):**
|
||||
```
|
||||
RouteDecision → ResolveActionCandidate → ActionCandidate
|
||||
→ actionAct writes Fn/Args back into dec.Slots
|
||||
→ refusesCommand check
|
||||
→ task-status intercept
|
||||
→ Praxis intercept
|
||||
→ Hexis intercept
|
||||
→ !HasFn → proposeGap
|
||||
→ tool.Executor.Exec → error switch
|
||||
```
|
||||
|
||||
**After (slice 3):**
|
||||
```
|
||||
RouteDecision → ResolveActionCandidate → ActionCandidate
|
||||
→ ValidateActionCandidate
|
||||
├─ unresolved → (continues to existing flow; !HasFn → proposeGap)
|
||||
├─ invalid → ActFail (early return)
|
||||
└─ valid → (continues)
|
||||
→ actionAct writes Fn/Args back into dec.Slots
|
||||
→ refusesCommand check
|
||||
→ task-status intercept
|
||||
→ Praxis intercept
|
||||
→ Hexis intercept
|
||||
→ !HasFn → proposeGap
|
||||
→ tool.Executor.Exec → error switch
|
||||
```
|
||||
|
||||
The validation gate sits between resolution and the bridge write. Unresolved candidates skip the validation gate entirely and flow through the existing path unchanged.
|
||||
|
||||
## 6. How unresolved differs from invalid
|
||||
|
||||
| | Unresolved | Invalid |
|
||||
|---|---|---|
|
||||
| **Fn** | empty (`""`) | non-empty but malformed (e.g. whitespace-only) |
|
||||
| **Cause** | Matcher miss, non-act intent | Structural defect in route/matcher output |
|
||||
| **Trace** | `action-validation:declined:unresolved` | `action-validation:declined:invalid:<reason>` |
|
||||
| **Response** | proposeGap (existing proposal/scaffold path) | `ActFail` ("не получилось выполнить команду.") |
|
||||
| **Execution** | does not reach tool executor | does not reach tool executor |
|
||||
|
||||
The distinction is preserved: unresolved is "no match found" (the normal case for unknown verbs), while invalid is "a match was found but it's structurally broken" (a defect that should never happen in practice).
|
||||
|
||||
## 7. Tests added
|
||||
|
||||
### Unit tests (internal/router)
|
||||
|
||||
| Test | Pins |
|
||||
|------|------|
|
||||
| `TestValidateActionCandidate_UnresolvedEmptyFn` | Empty Fn → unresolved, not invalid |
|
||||
| `TestValidateActionCandidate_UnresolvedMatcherMiss` | Matcher miss candidate → unresolved |
|
||||
| `TestValidateActionCandidate_ValidRoute` | Route-resolved candidate → valid |
|
||||
| `TestValidateActionCandidate_ValidMatcher` | Matcher-resolved candidate → valid |
|
||||
| `TestValidateActionCandidate_ValidNoArgs` | Zero-arg tool → valid |
|
||||
| `TestValidateActionCandidate_BlankFn` | Whitespace-only Fn → invalid with `blank_function_name` issue |
|
||||
|
||||
### Integration tests (cmd/mavend)
|
||||
|
||||
| Test | Pins |
|
||||
|------|------|
|
||||
| `TestActValidation_MalformedCandidate_BlankFn` | Blank Fn does not execute, produces response |
|
||||
| `TestActValidation_UnresolvedCandidate_ProposeGap` | Matcher miss still flows to proposeGap |
|
||||
| `TestActValidation_DestructiveValid_StillConfirms` | Destructive valid act still triggers confirm turn |
|
||||
| `TestActValidation_IrreversibleValid_NeedsAuthedSurface` | Irreversible valid act still reaches authed-surface refusal |
|
||||
| `TestActValidation_ValidationTracing` | Validation outcome recorded in decision trace |
|
||||
|
||||
### Existing regression tests preserved (all pass)
|
||||
|
||||
| Test | What it pins |
|
||||
|------|-------------|
|
||||
| `TestActRouteSource_NoMatcherInvoke` | Route-resolved act executes, matcher not invoked |
|
||||
| `TestActMatcherSource_FallbackMatch` | Matcher-resolved act executes |
|
||||
| `TestActMatcherMiss_ProposeGap` | Matcher miss → proposeGap |
|
||||
| `TestActDestructive_ConfirmationUnchanged` | Destructive → confirm turn |
|
||||
| `TestActTaskStatus_InterceptUnchanged` | task_status intercepted before tool execution |
|
||||
| `TestActStage0_SameResult` | Stage-0 grammar act executes |
|
||||
| `TestActLearnedRouter_NoFn_FallbackMatch` | LLM-routed act without Fn → matcher fallback |
|
||||
| `TestActPathSpeaksTheTiers` | Risk tiers spoken correctly |
|
||||
| `TestActionActMarkerReferentMovesOnlyTheNamedStoredTask` | Task-status move unchanged |
|
||||
| `TestActOffAllowlistIsStillRefused` | Off-allowlist act refused |
|
||||
|
||||
## 8. Before/after suite results
|
||||
|
||||
**Before:** all tests in `internal/router` and `cmd/mavend` pass.
|
||||
**After:** all tests pass, including 6 new unit tests and 5 new integration tests.
|
||||
|
||||
```
|
||||
internal/router: PASS (0.940s) — 13 actioncandidate tests (7 resolve + 6 validate)
|
||||
cmd/mavend: PASS (23.5s) — 21 act-path tests (15 existing + 5 new + 1 tracing)
|
||||
```
|
||||
|
||||
## 9. Confirmation that risk/confirmation/execution behavior is unchanged
|
||||
|
||||
- **Risk tiers:** `tool.RiskOf` and `tool.PolicyFor` are not touched. Validation happens before risk policy.
|
||||
- **Confirmation:** `ErrNeedsConfirm` → confirm turn is unchanged. Tested by `TestActDestructive_StillConfirms`.
|
||||
- **Irreversible:** `ErrNeedsAuthedSurface` → refusal is unchanged. Tested by `TestActIrreversibleValid_NeedsAuthedSurface`.
|
||||
- **Execution:** `tool.Executor.Exec` is not modified. Validation is a pure pre-screen.
|
||||
- **Ecosystem intercepts:** Praxis and Hexis intercepts are unchanged. Validation sits before them; unresolved candidates pass through to them as before.
|
||||
|
||||
## 10. Remaining downstream consumers of Decision.Slots
|
||||
|
||||
After the bridge write (`dec.Slots.Fn = candidate.Fn`, etc.), the following branches read `dec.Slots`:
|
||||
|
||||
| Consumer | File | Reads |
|
||||
|----------|------|-------|
|
||||
| `resolveTaskStatus` | `cmd/mavend/actions_task.go:109` | `dec.Slots.Fn` (compared to `TaskStatusFn`) |
|
||||
| `handlePraxisAct` | `cmd/mavend/ecosystem_acts.go:110` | `dec.Slots.HasFn` (guard), `dec.Slots.Fn`, `dec.Slots.Args` |
|
||||
| `handleHexisAct` | `cmd/mavend/ecosystem_acts.go:660` | via `ActHasEntityTarget(dec)` which reads `dec.Slots.HasFn`, `dec.Slots.Args`, `dec.Slots.Text` |
|
||||
| `proposeGap` | `cmd/mavend/confirm.go:191` | `dec.Utterance` (not Slots) |
|
||||
| `tool.Executor.Exec` | `internal/tool/tool.go:156` | called with `dec.Slots.Fn, dec.Slots.Args` directly |
|
||||
| `actPhrase` | `cmd/mavend/actions_act.go` | `dec.Slots.Fn, dec.Slots.Args` |
|
||||
| `park` | `cmd/mavend/confirm.go` | `dec.Slots.Fn, dec.Slots.Args` |
|
||||
|
||||
## 11. Whether ActionCandidate can become authoritative in a later slice
|
||||
|
||||
Yes. The bridge write is the only thing coupling ActionCandidate to Decision.Slots. In a later slice:
|
||||
|
||||
1. `resolveTaskStatus` could accept `ActionCandidate` directly instead of reading `dec.Slots.Fn`.
|
||||
2. `handlePraxisAct` and `handleHexisAct` could accept the candidate instead of `dec.Slots.HasFn`.
|
||||
3. `tool.Executor.Exec` already takes `name string, args []string` — it could take the candidate's `Fn` and `Args` directly.
|
||||
4. `ActHasEntityTarget` could accept `ActionCandidate` instead of `Decision`.
|
||||
|
||||
The bridge would be removed once all consumers read from the candidate. No semantic changes required — this is a mechanical refactoring of argument passing.
|
||||
|
||||
## 12. Commit hash
|
||||
|
||||
Pending commit on top of 6a402bf.
|
||||
@@ -0,0 +1,158 @@
|
||||
# Slice 3: Structural validation between ActionCandidate resolution and execution
|
||||
|
||||
**Date:** 2026-09-05
|
||||
**Base:** 6a402bf (slice 2 committed)
|
||||
**Status:** complete
|
||||
|
||||
## Files changed
|
||||
|
||||
| File | Lines added |
|
||||
|------|------------|
|
||||
| `internal/router/actioncandidate.go` | +76 (types + `ValidateActionCandidate`) |
|
||||
| `internal/router/actioncandidate_test.go` | +98 (6 unit tests) |
|
||||
| `cmd/mavend/actions_act.go` | +11 (validation gate in `actionAct`) |
|
||||
| `cmd/mavend/actionresolve.go` | +37 (`noteActionValidation` tracing) |
|
||||
| `cmd/mavend/actionresolve_test.go` | +128 (5 integration tests) |
|
||||
| `docs/reports/2026-09-05-slice3-structural-validation.md` | full report |
|
||||
|
||||
## Validation contract
|
||||
|
||||
```go
|
||||
type ActionValidationResult struct {
|
||||
Unresolved bool
|
||||
Valid bool
|
||||
Issues []ActionValidationIssue
|
||||
}
|
||||
|
||||
type ActionValidationIssue struct {
|
||||
Field ActionField
|
||||
Reason string
|
||||
}
|
||||
|
||||
type ActionField string
|
||||
const (
|
||||
FieldFn ActionField = "fn"
|
||||
FieldArgs ActionField = "args"
|
||||
)
|
||||
```
|
||||
|
||||
Three disjoint outcomes:
|
||||
- **Unresolved**: Fn is empty. Not a validation error. Routes to proposeGap / clarification.
|
||||
- **Valid**: Fn non-empty, structurally admissible. Proceeds to risk policy and execution.
|
||||
- **Invalid**: Fn non-empty but malformed. Refused with `ActFail`.
|
||||
|
||||
## Exact validation rules
|
||||
|
||||
| Rule | Check | Outcome on fail |
|
||||
|------|-------|-----------------|
|
||||
| `unresolved` | `c.Fn == ""` | Unresolved (not invalid) |
|
||||
| `blank_function_name` | `strings.TrimSpace(c.Fn) == ""` when Fn is non-empty | Invalid |
|
||||
|
||||
## Before/after action flow
|
||||
|
||||
**Before (slice 2):**
|
||||
```
|
||||
RouteDecision → ResolveActionCandidate → ActionCandidate
|
||||
→ actionAct writes Fn/Args back into dec.Slots
|
||||
→ refusesCommand check
|
||||
→ task-status intercept
|
||||
→ Praxis intercept
|
||||
→ Hexis intercept
|
||||
→ !HasFn → proposeGap
|
||||
→ tool.Executor.Exec → error switch
|
||||
```
|
||||
|
||||
**After (slice 3):**
|
||||
```
|
||||
RouteDecision → ResolveActionCandidate → ActionCandidate
|
||||
→ ValidateActionCandidate
|
||||
├─ unresolved → (continues to existing flow; !HasFn → proposeGap)
|
||||
├─ invalid → ActFail (early return)
|
||||
└─ valid → (continues)
|
||||
→ actionAct writes Fn/Args back into dec.Slots
|
||||
→ refusesCommand check
|
||||
→ task-status intercept
|
||||
→ Praxis intercept
|
||||
→ Hexis intercept
|
||||
→ !HasFn → proposeGap
|
||||
→ tool.Executor.Exec → error switch
|
||||
```
|
||||
|
||||
## How unresolved differs from invalid
|
||||
|
||||
| | Unresolved | Invalid |
|
||||
|---|---|---|
|
||||
| **Fn** | empty (`""`) | non-empty but malformed (e.g. whitespace-only) |
|
||||
| **Cause** | Matcher miss, non-act intent | Structural defect in route/matcher output |
|
||||
| **Trace** | `action-validation:declined:unresolved` | `action-validation:declined:invalid:<reason>` |
|
||||
| **Response** | proposeGap (existing proposal/scaffold path) | `ActFail` |
|
||||
| **Execution** | does not reach tool executor | does not reach tool executor |
|
||||
|
||||
## Tests added
|
||||
|
||||
### Unit tests (internal/router)
|
||||
|
||||
| Test | Pins |
|
||||
|------|------|
|
||||
| `TestValidateActionCandidate_UnresolvedEmptyFn` | Empty Fn → unresolved, not invalid |
|
||||
| `TestValidateActionCandidate_UnresolvedMatcherMiss` | Matcher miss candidate → unresolved |
|
||||
| `TestValidateActionCandidate_ValidRoute` | Route-resolved candidate → valid |
|
||||
| `TestValidateActionCandidate_ValidMatcher` | Matcher-resolved candidate → valid |
|
||||
| `TestValidateActionCandidate_ValidNoArgs` | Zero-arg tool → valid |
|
||||
| `TestValidateActionCandidate_BlankFn` | Whitespace-only Fn → invalid with `blank_function_name` issue |
|
||||
|
||||
### Integration tests (cmd/mavend)
|
||||
|
||||
| Test | Pins |
|
||||
|------|------|
|
||||
| `TestActValidation_MalformedCandidate_BlankFn` | Blank Fn does not execute, produces response |
|
||||
| `TestActValidation_UnresolvedCandidate_ProposeGap` | Matcher miss still flows to proposeGap |
|
||||
| `TestActValidation_DestructiveValid_StillConfirms` | Destructive valid act still triggers confirm turn |
|
||||
| `TestActValidation_IrreversibleValid_NeedsAuthedSurface` | Irreversible valid act still reaches authed-surface refusal |
|
||||
| `TestActValidation_ValidationTracing` | Validation outcome recorded in decision trace |
|
||||
|
||||
### Existing regression tests preserved (all pass)
|
||||
|
||||
| Test | What it pins |
|
||||
|------|-------------|
|
||||
| `TestActRouteSource_NoMatcherInvoke` | Route-resolved act executes, matcher not invoked |
|
||||
| `TestActMatcherSource_FallbackMatch` | Matcher-resolved act executes |
|
||||
| `TestActMatcherMiss_ProposeGap` | Matcher miss → proposeGap |
|
||||
| `TestActDestructive_ConfirmationUnchanged` | Destructive → confirm turn |
|
||||
| `TestActTaskStatus_InterceptUnchanged` | task_status intercepted before tool execution |
|
||||
| `TestActStage0_SameResult` | Stage-0 grammar act executes |
|
||||
| `TestActLearnedRouter_NoFn_FallbackMatch` | LLM-routed act without Fn → matcher fallback |
|
||||
| `TestActPathSpeaksTheTiers` | Risk tiers spoken correctly |
|
||||
| `TestActionActMarkerReferentMovesOnlyTheNamedStoredTask` | Task-status move unchanged |
|
||||
| `TestActOffAllowlistIsStillRefused` | Off-allowlist act refused |
|
||||
|
||||
## Test results
|
||||
|
||||
```
|
||||
internal/router: PASS (1.1s) — 13 actioncandidate tests (7 resolve + 6 validate)
|
||||
cmd/mavend: PASS (23.4s) — 21 act-path tests (15 existing + 5 new + 1 tracing)
|
||||
```
|
||||
|
||||
## Risk/confirmation/execution behavior unchanged
|
||||
|
||||
- **Risk tiers:** `tool.RiskOf` and `tool.PolicyFor` not touched. Validation happens before risk policy.
|
||||
- **Confirmation:** `ErrNeedsConfirm` → confirm turn unchanged.
|
||||
- **Irreversible:** `ErrNeedsAuthedSurface` → refusal unchanged.
|
||||
- **Execution:** `tool.Executor.Exec` not modified. Validation is a pure pre-screen.
|
||||
- **Ecosystem intercepts:** Praxis and Hexis intercepts unchanged.
|
||||
|
||||
## Remaining downstream consumers of Decision.Slots
|
||||
|
||||
| Consumer | File | Reads |
|
||||
|----------|------|-------|
|
||||
| `resolveTaskStatus` | `cmd/mavend/actions_task.go:109` | `dec.Slots.Fn` |
|
||||
| `handlePraxisAct` | `cmd/mavend/ecosystem_acts.go:110` | `dec.Slots.HasFn`, `dec.Slots.Fn`, `dec.Slots.Args` |
|
||||
| `handleHexisAct` | `cmd/mavend/ecosystem_acts.go:660` | via `ActHasEntityTarget(dec)` |
|
||||
| `proposeGap` | `cmd/mavend/confirm.go:191` | `dec.Utterance` (not Slots) |
|
||||
| `tool.Executor.Exec` | `internal/tool/tool.go:156` | called with `dec.Slots.Fn, dec.Slots.Args` |
|
||||
| `actPhrase` | `cmd/mavend/actions_act.go` | `dec.Slots.Fn, dec.Slots.Args` |
|
||||
| `park` | `cmd/mavend/confirm.go` | `dec.Slots.Fn, dec.Slots.Args` |
|
||||
|
||||
## Can ActionCandidate become authoritative?
|
||||
|
||||
Yes. The bridge write is the only coupling. In a later slice, downstream consumers can read directly from the candidate. No semantic changes required — mechanical refactoring of argument passing.
|
||||
+7
-8
@@ -1,6 +1,6 @@
|
||||
# Session workflow: the five stores and the guards
|
||||
|
||||
*Last verified: 2026-08-11 @ 557f5a3*
|
||||
*Last verified: 2026-08-25 @ 5cae33a*
|
||||
|
||||
How a session starts, where each kind of writing belongs, and what the hooks
|
||||
refuse. `CLAUDE.md` carries the commands. This file carries the reasoning.
|
||||
@@ -37,9 +37,8 @@ This repo is project **Maven** (ID 2). MCP at `http://localhost:9100/mcp`, or
|
||||
`http://192.168.1.104:9100/mcp` from workpc. Feature, bug and deploy tasks go
|
||||
there.
|
||||
|
||||
A task holds the goal, the constraints and the assumption ledger. A session
|
||||
without a task id cannot be resumed by anyone, so a session with none asks for
|
||||
one first.
|
||||
A task holds the goal, the constraints and the assumption ledger. A session may
|
||||
start without one: filing is not a gate on work (owner's call, 2026-08-25).
|
||||
|
||||
**Close a finished task with `done: true` and nothing else** (owner's call,
|
||||
2026-08-07). Do not write a completion summary into the description on the way
|
||||
@@ -63,13 +62,13 @@ rather than letting the session compact.
|
||||
|
||||
## Guards
|
||||
|
||||
Two hooks in `.githooks/`, tracked, wired with `core.hooksPath`. A fresh clone
|
||||
needs `git config core.hooksPath .githooks`.
|
||||
One hook in `.githooks/`, tracked, wired with `core.hooksPath`. A fresh clone
|
||||
needs `git config core.hooksPath .githooks`. `commit-msg` and its `(V-<id>)`
|
||||
requirement were deleted on 2026-08-25: a subject ref that names a task nobody
|
||||
filed is a wrong link, not a record.
|
||||
|
||||
- `pre-commit` refuses master, and refuses more than 300 changed lines in
|
||||
non-markdown files. Markdown is exempt and may land as one batch.
|
||||
- `commit-msg` requires the subject to end with `(V-<id>)`. `V-` and not `#`,
|
||||
because Gitea autolinks `#123` to a Gitea issue, which is the wrong tracker.
|
||||
|
||||
Two more guards live outside the repo, in `~/.claude/hooks/`. `diff-budget.sh`
|
||||
blocks further edits past 600 changed lines on a `task/` branch.
|
||||
|
||||
@@ -31,6 +31,10 @@ type Slots struct {
|
||||
Fn string
|
||||
Args []string
|
||||
HasFn bool
|
||||
// ResolvedBy — which component selected the exact function. Mirrors
|
||||
// router.Slots.ResolvedBy. Carried as a string because dialogue cannot
|
||||
// import router (import cycle).
|
||||
ResolvedBy string
|
||||
}
|
||||
|
||||
// Turn represents one utterance in a multi-turn dialogue history.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package router
|
||||
|
||||
import "strings"
|
||||
|
||||
// ActionCandidate — the result of action resolution, produced before execution.
|
||||
// It replaces the implicit ownership split where the router filled Slots.Fn/Args
|
||||
// and actionAct re-matched when they were absent. One candidate is produced per
|
||||
@@ -20,6 +22,11 @@ type ActionCandidate struct {
|
||||
// here. Carried for observability; not used for dispatch.
|
||||
Producer RouteProducer
|
||||
|
||||
// ResolvedBy — which component actually selected the exact function.
|
||||
// Carried from the routing decision's Slots.ResolvedBy. Five disjoint
|
||||
// values; empty when no function was resolved.
|
||||
ResolvedBy ActionResolutionMethod
|
||||
|
||||
// Confidence — the routing confidence from the decision. Carried for
|
||||
// observability; not used for dispatch.
|
||||
Confidence float64
|
||||
@@ -44,6 +51,79 @@ const (
|
||||
// ActionResolved reports whether the candidate resolved to a function.
|
||||
func (c ActionCandidate) ActionResolved() bool { return c.Fn != "" }
|
||||
|
||||
// --- structural validation ---
|
||||
|
||||
// ActionField identifies a structural field of ActionCandidate for validation
|
||||
// reporting. Kept as a string enum so callers can switch on known values
|
||||
// without importing a large set.
|
||||
type ActionField string
|
||||
|
||||
const (
|
||||
FieldFn ActionField = "fn"
|
||||
FieldArgs ActionField = "args"
|
||||
)
|
||||
|
||||
// ActionValidationStatus is the typed classification of a validation outcome.
|
||||
// Five disjoint values: exactly one is set on every ActionValidationResult.
|
||||
type ActionValidationStatus string
|
||||
|
||||
const (
|
||||
// ActionValid — Fn is non-empty and the candidate is structurally
|
||||
// admissible. The caller may proceed to risk policy and execution.
|
||||
ActionValid ActionValidationStatus = "valid"
|
||||
|
||||
// ActionUnresolved — Fn is empty. The matcher did not match, or the
|
||||
// decision was not an act. The caller routes to proposeGap /
|
||||
// clarification. This is NOT a structural error.
|
||||
ActionUnresolved ActionValidationStatus = "unresolved"
|
||||
|
||||
// ActionMissingArgument — Fn is present but required arguments are
|
||||
// absent. Currently unused: no tool declares required args at the
|
||||
// candidate level. Preserved for future use without semantic change.
|
||||
ActionMissingArgument ActionValidationStatus = "missing_argument"
|
||||
|
||||
// ActionInvalidArgument — Fn is present and arguments are present but
|
||||
// structurally malformed (e.g. wrong shape for a known tool). Currently
|
||||
// unused: argument shape is validated by the tool executor, not the
|
||||
// candidate validator. Preserved for future use.
|
||||
ActionInvalidArgument ActionValidationStatus = "invalid_argument"
|
||||
|
||||
// ActionAmbiguousTarget — Fn is present but the entity/target reference
|
||||
// is ambiguous (multiple candidates, or a demonstrative with no
|
||||
// referent). Currently handled downstream by ecosystem handlers, not
|
||||
// the candidate validator. Preserved for future centralization.
|
||||
ActionAmbiguousTarget ActionValidationStatus = "ambiguous_target"
|
||||
)
|
||||
|
||||
// ActionValidationIssue records one structural problem found by
|
||||
// ValidateActionCandidate. Field names which field; Reason is a short
|
||||
// machine-readable tag, not a human sentence.
|
||||
type ActionValidationIssue struct {
|
||||
Field ActionField
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ActionValidationResult is the typed output of ValidateActionCandidate.
|
||||
// Exactly one of the five Status values is set. Issues carries detail for
|
||||
// invalid statuses; it is nil for valid and unresolved.
|
||||
//
|
||||
// The result answers only shape/completeness — not trust, confidence, risk,
|
||||
// or semantic correctness. Risk policy, confirmation, voice authority, and
|
||||
// irreversible-action policy remain downstream and unchanged.
|
||||
type ActionValidationResult struct {
|
||||
Status ActionValidationStatus
|
||||
Issues []ActionValidationIssue
|
||||
Missing []string `json:"Missing,omitempty"`
|
||||
}
|
||||
|
||||
// Unresolved reports whether the candidate was not resolved (Fn empty).
|
||||
// Kept as a method for backward compatibility with existing call sites.
|
||||
func (r ActionValidationResult) Unresolved() bool { return r.Status == ActionUnresolved }
|
||||
|
||||
// Valid reports whether the candidate is structurally admissible.
|
||||
// Kept as a method for backward compatibility with existing call sites.
|
||||
func (r ActionValidationResult) Valid() bool { return r.Status == ActionValid }
|
||||
|
||||
// ResolveActionCandidate produces an ActionCandidate from a routing decision.
|
||||
// It is the single boundary between routing and action resolution: everything
|
||||
// downstream consumes the candidate rather than re-resolving the function.
|
||||
@@ -71,6 +151,7 @@ func ResolveActionCandidate(dec Decision, m ActMatcher) ActionCandidate {
|
||||
Args: dec.Slots.Args,
|
||||
Source: ActionSourceRoute,
|
||||
Producer: dec.Producer,
|
||||
ResolvedBy: dec.Slots.ResolvedBy,
|
||||
Confidence: dec.Confidence,
|
||||
}
|
||||
}
|
||||
@@ -83,6 +164,7 @@ func ResolveActionCandidate(dec Decision, m ActMatcher) ActionCandidate {
|
||||
Args: args,
|
||||
Source: ActionSourceMatcher,
|
||||
Producer: dec.Producer,
|
||||
ResolvedBy: ActionResolutionFallbackMatcher,
|
||||
Confidence: dec.Confidence,
|
||||
}
|
||||
}
|
||||
@@ -93,3 +175,47 @@ func ResolveActionCandidate(dec Decision, m ActMatcher) ActionCandidate {
|
||||
Confidence: dec.Confidence,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateActionCandidate checks whether a resolved ActionCandidate is
|
||||
// structurally admissible for the current execution path. It answers only
|
||||
// shape/completeness — not trust, confidence, risk, or semantic correctness.
|
||||
//
|
||||
// Five outcomes (exactly one):
|
||||
// - Unresolved (Fn empty): the matcher missed. Caller routes to
|
||||
// proposeGap / clarification. This is NOT a validation error.
|
||||
// - Valid (Fn non-empty, structure sound): candidate may proceed to
|
||||
// risk policy and execution.
|
||||
// - InvalidArgument (Fn present but args malformed): candidate must not
|
||||
// execute. Currently unused; preserved for future use.
|
||||
// - MissingArgument (Fn present but required args absent): candidate must
|
||||
// not execute. Currently unused; preserved for future use.
|
||||
// - AmbiguousTarget (Fn present but target ambiguous): candidate must not
|
||||
// execute. Currently handled downstream; preserved for future use.
|
||||
//
|
||||
// Validation does not re-run routing, intent classification, matcher
|
||||
// resolution, language parsing, or entity inference.
|
||||
func ValidateActionCandidate(c ActionCandidate) ActionValidationResult {
|
||||
// Unresolved: Fn is empty. The matcher did not match, or the decision
|
||||
// was not an act. This flows into the existing propose-gap / proposal
|
||||
// path and must NOT be treated as a structural error.
|
||||
if c.Fn == "" {
|
||||
return ActionValidationResult{Status: ActionUnresolved}
|
||||
}
|
||||
|
||||
var issues []ActionValidationIssue
|
||||
|
||||
// Fn is present but must be a non-blank identifier. The route and
|
||||
// matcher both produce bare function names; whitespace-only or
|
||||
// control-character Fn would be a structural defect in the producer.
|
||||
if strings.TrimSpace(c.Fn) == "" {
|
||||
issues = append(issues, ActionValidationIssue{
|
||||
Field: FieldFn,
|
||||
Reason: "blank_function_name",
|
||||
})
|
||||
}
|
||||
|
||||
if len(issues) > 0 {
|
||||
return ActionValidationResult{Status: ActionInvalidArgument, Issues: issues}
|
||||
}
|
||||
return ActionValidationResult{Status: ActionValid}
|
||||
}
|
||||
|
||||
@@ -149,3 +149,384 @@ func TestResolveActionCandidate_AliasMatch(t *testing.T) {
|
||||
t.Errorf("Args = %v, want [роутер]", c.Args)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ValidateActionCandidate tests ---
|
||||
|
||||
// TestValidateActionCandidate_UnresolvedEmptyFn pins that an empty Fn
|
||||
// produces an unresolved result (not invalid).
|
||||
func TestValidateActionCandidate_UnresolvedEmptyFn(t *testing.T) {
|
||||
c := ActionCandidate{}
|
||||
v := ValidateActionCandidate(c)
|
||||
if v.Status != ActionUnresolved {
|
||||
t.Errorf("Status = %q, want unresolved", v.Status)
|
||||
}
|
||||
if v.Valid() {
|
||||
t.Error("unresolved must not be valid")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateActionCandidate_UnresolvedMatcherMiss pins that a matcher-miss
|
||||
// candidate (Fn empty, source empty) is unresolved.
|
||||
func TestValidateActionCandidate_UnresolvedMatcherMiss(t *testing.T) {
|
||||
c := ActionCandidate{
|
||||
Producer: RouteProducerLLM,
|
||||
Confidence: 0.5,
|
||||
}
|
||||
v := ValidateActionCandidate(c)
|
||||
if v.Status != ActionUnresolved {
|
||||
t.Errorf("Status = %q, want unresolved", v.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateActionCandidate_ValidRoute pins that a route-resolved candidate
|
||||
// with a non-empty Fn is valid.
|
||||
func TestValidateActionCandidate_ValidRoute(t *testing.T) {
|
||||
c := ActionCandidate{
|
||||
Fn: "restart",
|
||||
Args: []string{"nginx"},
|
||||
Source: ActionSourceRoute,
|
||||
}
|
||||
v := ValidateActionCandidate(c)
|
||||
if v.Unresolved() {
|
||||
t.Error("expected resolved, not unresolved")
|
||||
}
|
||||
if v.Status != ActionValid {
|
||||
t.Errorf("Status = %q, want valid; issues: %v", v.Status, v.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateActionCandidate_ValidMatcher pins that a matcher-resolved
|
||||
// candidate with a non-empty Fn is valid.
|
||||
func TestValidateActionCandidate_ValidMatcher(t *testing.T) {
|
||||
c := ActionCandidate{
|
||||
Fn: "status",
|
||||
Source: ActionSourceMatcher,
|
||||
}
|
||||
v := ValidateActionCandidate(c)
|
||||
if v.Unresolved() {
|
||||
t.Error("expected resolved, not unresolved")
|
||||
}
|
||||
if v.Status != ActionValid {
|
||||
t.Errorf("Status = %q, want valid; issues: %v", v.Status, v.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateActionCandidate_ValidNoArgs pins that a zero-arg tool is valid.
|
||||
func TestValidateActionCandidate_ValidNoArgs(t *testing.T) {
|
||||
c := ActionCandidate{
|
||||
Fn: "status",
|
||||
Source: ActionSourceRoute,
|
||||
}
|
||||
v := ValidateActionCandidate(c)
|
||||
if v.Unresolved() {
|
||||
t.Error("expected resolved, not unresolved")
|
||||
}
|
||||
if v.Status != ActionValid {
|
||||
t.Errorf("Status = %q, want valid; issues: %v", v.Status, v.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateActionCandidate_BlankFn pins that a whitespace-only Fn is
|
||||
// structurally invalid (not unresolved).
|
||||
func TestValidateActionCandidate_BlankFn(t *testing.T) {
|
||||
c := ActionCandidate{
|
||||
Fn: " ",
|
||||
Source: ActionSourceRoute,
|
||||
}
|
||||
v := ValidateActionCandidate(c)
|
||||
if v.Unresolved() {
|
||||
t.Error("blank Fn should be invalid, not unresolved")
|
||||
}
|
||||
if v.Valid() {
|
||||
t.Error("blank Fn should be invalid")
|
||||
}
|
||||
if v.Status != ActionInvalidArgument {
|
||||
t.Errorf("Status = %q, want invalid_argument", v.Status)
|
||||
}
|
||||
if len(v.Issues) != 1 {
|
||||
t.Fatalf("expected 1 issue, got %d", len(v.Issues))
|
||||
}
|
||||
if v.Issues[0].Field != FieldFn {
|
||||
t.Errorf("issue field = %q, want fn", v.Issues[0].Field)
|
||||
}
|
||||
}
|
||||
|
||||
// --- additional typed status tests ---
|
||||
|
||||
// TestValidateActionCandidate_StatusConstants pins that the five status
|
||||
// constants are distinct and non-empty.
|
||||
func TestValidateActionCandidate_StatusConstants(t *testing.T) {
|
||||
statuses := []ActionValidationStatus{
|
||||
ActionValid,
|
||||
ActionUnresolved,
|
||||
ActionMissingArgument,
|
||||
ActionInvalidArgument,
|
||||
ActionAmbiguousTarget,
|
||||
}
|
||||
seen := make(map[ActionValidationStatus]bool)
|
||||
for _, s := range statuses {
|
||||
if s == "" {
|
||||
t.Error("status constant is empty")
|
||||
}
|
||||
if seen[s] {
|
||||
t.Errorf("status %q appears twice", s)
|
||||
}
|
||||
seen[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateActionCandidate_UnresolvedBackwardCompat pins that the
|
||||
// Unresolved() method returns true only for ActionUnresolved status.
|
||||
func TestValidateActionCandidate_UnresolvedBackwardCompat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status ActionValidationStatus
|
||||
want bool
|
||||
}{
|
||||
{"unresolved", ActionUnresolved, true},
|
||||
{"valid", ActionValid, false},
|
||||
{"missing_arg", ActionMissingArgument, false},
|
||||
{"invalid_arg", ActionInvalidArgument, false},
|
||||
{"ambiguous", ActionAmbiguousTarget, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := ActionValidationResult{Status: tt.status}
|
||||
if got := r.Unresolved(); got != tt.want {
|
||||
t.Errorf("Unresolved() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateActionCandidate_ValidBackwardCompat pins that the Valid()
|
||||
// method returns true only for ActionValid status.
|
||||
func TestValidateActionCandidate_ValidBackwardCompat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status ActionValidationStatus
|
||||
want bool
|
||||
}{
|
||||
{"unresolved", ActionUnresolved, false},
|
||||
{"valid", ActionValid, true},
|
||||
{"missing_arg", ActionMissingArgument, false},
|
||||
{"invalid_arg", ActionInvalidArgument, false},
|
||||
{"ambiguous", ActionAmbiguousTarget, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := ActionValidationResult{Status: tt.status}
|
||||
if got := r.Valid(); got != tt.want {
|
||||
t.Errorf("Valid() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateActionCandidate_IssuesNilOnValid pins that Valid results have
|
||||
// nil Issues.
|
||||
func TestValidateActionCandidate_IssuesNilOnValid(t *testing.T) {
|
||||
c := ActionCandidate{Fn: "restart", Args: []string{"nginx"}}
|
||||
v := ValidateActionCandidate(c)
|
||||
if v.Issues != nil {
|
||||
t.Errorf("valid result has issues: %v", v.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateActionCandidate_IssuesNilOnUnresolved pins that Unresolved
|
||||
// results have nil Issues.
|
||||
func TestValidateActionCandidate_IssuesNilOnUnresolved(t *testing.T) {
|
||||
c := ActionCandidate{}
|
||||
v := ValidateActionCandidate(c)
|
||||
if v.Issues != nil {
|
||||
t.Errorf("unresolved result has issues: %v", v.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateActionCandidate_IssuesPopulatedOnInvalid pins that Invalid
|
||||
// results carry populated Issues.
|
||||
func TestValidateActionCandidate_IssuesPopulatedOnInvalid(t *testing.T) {
|
||||
c := ActionCandidate{Fn: "\t\n"}
|
||||
v := ValidateActionCandidate(c)
|
||||
if len(v.Issues) == 0 {
|
||||
t.Error("invalid result has no issues")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ResolutionMethod provenance tests ---
|
||||
|
||||
// TestResolveActionCandidate_GrammarFixed pins that a stage-0 grammar that
|
||||
// hardcodes fn (praxis/task-status) carries grammar_fixed provenance.
|
||||
func TestResolveActionCandidate_GrammarFixed(t *testing.T) {
|
||||
dec := Decision{
|
||||
Intent: IntentAct,
|
||||
Slots: Slots{
|
||||
Fn: "resolve_item", HasFn: true,
|
||||
ResolvedBy: ActionResolutionGrammarFixed,
|
||||
},
|
||||
}
|
||||
c := ResolveActionCandidate(dec, nil)
|
||||
if !c.ActionResolved() {
|
||||
t.Fatal("expected resolved candidate")
|
||||
}
|
||||
if c.ResolvedBy != ActionResolutionGrammarFixed {
|
||||
t.Errorf("ResolvedBy = %q, want grammar_fixed", c.ResolvedBy)
|
||||
}
|
||||
if c.Fn != "resolve_item" {
|
||||
t.Errorf("Fn = %q, want resolve_item", c.Fn)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveActionCandidate_GrammarMatcher pins that a stage-0 grammar that
|
||||
// invokes the ActMatcher (wakeword-act) carries grammar_matcher provenance.
|
||||
func TestResolveActionCandidate_GrammarMatcher(t *testing.T) {
|
||||
dec := Decision{
|
||||
Intent: IntentAct,
|
||||
Slots: Slots{
|
||||
Fn: "restart", Args: []string{"nginx"}, HasFn: true,
|
||||
ResolvedBy: ActionResolutionGrammarMatcher,
|
||||
},
|
||||
}
|
||||
c := ResolveActionCandidate(dec, nil)
|
||||
if !c.ActionResolved() {
|
||||
t.Fatal("expected resolved candidate")
|
||||
}
|
||||
if c.ResolvedBy != ActionResolutionGrammarMatcher {
|
||||
t.Errorf("ResolvedBy = %q, want grammar_matcher", c.ResolvedBy)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveActionCandidate_ExtractorRaw pins that a classifier/heads-routed
|
||||
// act whose extractor matched carries extractor_raw provenance.
|
||||
func TestResolveActionCandidate_ExtractorRaw(t *testing.T) {
|
||||
dec := Decision{
|
||||
Intent: IntentAct,
|
||||
Slots: Slots{
|
||||
Fn: "restart", HasFn: true,
|
||||
ResolvedBy: ActionResolutionExtractorRaw,
|
||||
},
|
||||
Producer: RouteProducerClassifier,
|
||||
}
|
||||
c := ResolveActionCandidate(dec, nil)
|
||||
if !c.ActionResolved() {
|
||||
t.Fatal("expected resolved candidate")
|
||||
}
|
||||
if c.ResolvedBy != ActionResolutionExtractorRaw {
|
||||
t.Errorf("ResolvedBy = %q, want extractor_raw", c.ResolvedBy)
|
||||
}
|
||||
if c.Producer != RouteProducerClassifier {
|
||||
t.Errorf("Producer = %q, want classifier", c.Producer)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveActionCandidate_ExtractorLLMText pins that an LLM-routed act
|
||||
// whose function was resolved from the LLM's cleaned text carries
|
||||
// extractor_llm_text provenance.
|
||||
func TestResolveActionCandidate_ExtractorLLMText(t *testing.T) {
|
||||
dec := Decision{
|
||||
Intent: IntentAct,
|
||||
Slots: Slots{
|
||||
Fn: "restart", HasFn: true,
|
||||
ResolvedBy: ActionResolutionExtractorLLMText,
|
||||
},
|
||||
Producer: RouteProducerLLM,
|
||||
}
|
||||
c := ResolveActionCandidate(dec, nil)
|
||||
if !c.ActionResolved() {
|
||||
t.Fatal("expected resolved candidate")
|
||||
}
|
||||
if c.ResolvedBy != ActionResolutionExtractorLLMText {
|
||||
t.Errorf("ResolvedBy = %q, want extractor_llm_text", c.ResolvedBy)
|
||||
}
|
||||
if c.Producer != RouteProducerLLM {
|
||||
t.Errorf("Producer = %q, want llm", c.Producer)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveActionCandidate_FallbackMatcher pins that a matcher fallback
|
||||
// carry fallback_matcher provenance.
|
||||
func TestResolveActionCandidate_FallbackMatcher(t *testing.T) {
|
||||
m := DefaultActMatcher{Fns: []string{"restart", "stop"}}
|
||||
dec := Decision{
|
||||
Intent: IntentAct,
|
||||
Slots: Slots{Text: "restart nginx"},
|
||||
}
|
||||
c := ResolveActionCandidate(dec, m)
|
||||
if !c.ActionResolved() {
|
||||
t.Fatal("expected resolved candidate")
|
||||
}
|
||||
if c.ResolvedBy != ActionResolutionFallbackMatcher {
|
||||
t.Errorf("ResolvedBy = %q, want fallback_matcher", c.ResolvedBy)
|
||||
}
|
||||
if c.Source != ActionSourceMatcher {
|
||||
t.Errorf("Source = %q, want matcher", c.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveActionCandidate_UnresolvedNoFalseMethod pins that an unresolved
|
||||
// candidate (matcher miss) has empty ResolvedBy.
|
||||
func TestResolveActionCandidate_UnresolvedNoFalseMethod(t *testing.T) {
|
||||
m := DefaultActMatcher{Fns: []string{"restart", "stop"}}
|
||||
dec := Decision{
|
||||
Intent: IntentAct,
|
||||
Slots: Slots{Text: "deploy the thing"},
|
||||
}
|
||||
c := ResolveActionCandidate(dec, m)
|
||||
if c.ActionResolved() {
|
||||
t.Fatal("expected unresolved candidate")
|
||||
}
|
||||
if c.ResolvedBy != "" {
|
||||
t.Errorf("ResolvedBy = %q, want empty for unresolved", c.ResolvedBy)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveActionCandidate_PropagatesResolvedBy pins that ResolvedBy
|
||||
// travels from Slots through to ActionCandidate for every route-sourced case.
|
||||
func TestResolveActionCandidate_PropagatesResolvedBy(t *testing.T) {
|
||||
methods := []ActionResolutionMethod{
|
||||
ActionResolutionGrammarFixed,
|
||||
ActionResolutionGrammarMatcher,
|
||||
ActionResolutionExtractorRaw,
|
||||
ActionResolutionExtractorLLMText,
|
||||
}
|
||||
for _, m := range methods {
|
||||
t.Run(string(m), func(t *testing.T) {
|
||||
dec := Decision{
|
||||
Intent: IntentAct,
|
||||
Slots: Slots{Fn: "restart", HasFn: true, ResolvedBy: m},
|
||||
}
|
||||
c := ResolveActionCandidate(dec, nil)
|
||||
if c.ResolvedBy != m {
|
||||
t.Errorf("ResolvedBy = %q, want %q", c.ResolvedBy, m)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveActionCandidate_FnArgsIdentical pins that adding ResolvedBy
|
||||
// does not change the selected fn or args for any path.
|
||||
func TestResolveActionCandidate_FnArgsIdentical(t *testing.T) {
|
||||
// Route-sourced.
|
||||
dec := Decision{
|
||||
Intent: IntentAct,
|
||||
Slots: Slots{
|
||||
Fn: "restart", Args: []string{"nginx"}, HasFn: true,
|
||||
ResolvedBy: ActionResolutionGrammarMatcher,
|
||||
},
|
||||
}
|
||||
c := ResolveActionCandidate(dec, nil)
|
||||
if c.Fn != "restart" || len(c.Args) != 1 || c.Args[0] != "nginx" {
|
||||
t.Errorf("route fn/args changed: Fn=%q Args=%v", c.Fn, c.Args)
|
||||
}
|
||||
|
||||
// Matcher-sourced.
|
||||
m := DefaultActMatcher{Fns: []string{"restart"}}
|
||||
dec2 := Decision{
|
||||
Intent: IntentAct,
|
||||
Slots: Slots{Text: "restart nginx"},
|
||||
}
|
||||
c2 := ResolveActionCandidate(dec2, m)
|
||||
if c2.Fn != "restart" || len(c2.Args) != 1 || c2.Args[0] != "nginx" {
|
||||
t.Errorf("matcher fn/args changed: Fn=%q Args=%v", c2.Fn, c2.Args)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// resolutionRow — one cell in the resolution matrix.
|
||||
type resolutionRow struct {
|
||||
Producer router.RouteProducer
|
||||
Method router.ActionResolutionMethod
|
||||
Resolved int
|
||||
Unresolved int
|
||||
Fns map[string]int
|
||||
}
|
||||
|
||||
// printMatrix renders a resolution matrix from act outcomes.
|
||||
func printMatrix(t *testing.T, label string, total int, matrix map[[2]string]*resolutionRow) {
|
||||
t.Helper()
|
||||
t.Logf("\n=== %s (%d act cases) ===", label, total)
|
||||
t.Logf("%-15s %-25s %8s %8s %s", "producer", "method", "resolved", "unresolved", "fns")
|
||||
keys := make([][2]string, 0, len(matrix))
|
||||
for k := range matrix {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
for i := 0; i < len(keys); i++ {
|
||||
for j := i + 1; j < len(keys); j++ {
|
||||
if keys[i][0]+keys[i][1] > keys[j][0]+keys[j][1] {
|
||||
keys[i], keys[j] = keys[j], keys[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range keys {
|
||||
row := matrix[k]
|
||||
fnList := ""
|
||||
for fn, n := range row.Fns {
|
||||
if fnList != "" {
|
||||
fnList += ", "
|
||||
}
|
||||
fnList += fmt.Sprintf("%s×%d", fn, n)
|
||||
}
|
||||
t.Logf("%-15s %-25s %8d %8d %s", row.Producer, row.Method, row.Resolved, row.Unresolved, fnList)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolutionMethodMatrix — diagnostic: runs both fixtures through the
|
||||
// baseline router and reports which component selected the exact function.
|
||||
func TestResolutionMethodMatrix(t *testing.T) {
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
m := router.DefaultActMatcher{Fns: actFns}
|
||||
ctx := context.Background()
|
||||
|
||||
// --- routing fixture ---
|
||||
rf, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
rfNow, err := rf.Now()
|
||||
if err != nil {
|
||||
t.Fatalf("Now: %v", err)
|
||||
}
|
||||
r := newBaselineRouter(t, emb, nil)
|
||||
|
||||
type actOutcome struct {
|
||||
Producer router.RouteProducer
|
||||
Method router.ActionResolutionMethod
|
||||
Fn string
|
||||
Resolved bool
|
||||
}
|
||||
|
||||
// routing fixture
|
||||
{
|
||||
matrix := map[[2]string]*resolutionRow{}
|
||||
total := 0
|
||||
resolved := 0
|
||||
counts := [5]int{} // fixed, matcher, raw, llm, fallback
|
||||
for _, c := range rf.Cases {
|
||||
d, err := r.Route(ctx, c.Utterance, rfNow)
|
||||
if err != nil || d.Intent != router.IntentAct {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
candidate := router.ResolveActionCandidate(d, m)
|
||||
key := [2]string{string(d.Producer), string(candidate.ResolvedBy)}
|
||||
row, ok := matrix[key]
|
||||
if !ok {
|
||||
row = &resolutionRow{Producer: d.Producer, Method: candidate.ResolvedBy, Fns: map[string]int{}}
|
||||
matrix[key] = row
|
||||
}
|
||||
if candidate.ActionResolved() {
|
||||
resolved++
|
||||
row.Resolved++
|
||||
row.Fns[candidate.Fn]++
|
||||
switch candidate.ResolvedBy {
|
||||
case router.ActionResolutionGrammarFixed:
|
||||
counts[0]++
|
||||
case router.ActionResolutionGrammarMatcher:
|
||||
counts[1]++
|
||||
case router.ActionResolutionExtractorRaw:
|
||||
counts[2]++
|
||||
case router.ActionResolutionExtractorLLMText:
|
||||
counts[3]++
|
||||
case router.ActionResolutionFallbackMatcher:
|
||||
counts[4]++
|
||||
}
|
||||
} else {
|
||||
row.Unresolved++
|
||||
}
|
||||
}
|
||||
printMatrix(t, "Routing fixture: IntentAct resolution", total, matrix)
|
||||
t.Logf("resolved: %d, unresolved: %d", resolved, total-resolved)
|
||||
t.Logf("grammar_fixed=%d grammar_matcher=%d extractor_raw=%d extractor_llm_text=%d fallback_matcher=%d",
|
||||
counts[0], counts[1], counts[2], counts[3], counts[4])
|
||||
}
|
||||
|
||||
// ecosystem fixture
|
||||
{
|
||||
ef, err := LoadReach()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadReach: %v", err)
|
||||
}
|
||||
efNow, err := ef.Now()
|
||||
if err != nil {
|
||||
t.Fatalf("Now: %v", err)
|
||||
}
|
||||
r2 := newBaselineRouter(t, emb, nil)
|
||||
matrix := map[[2]string]*resolutionRow{}
|
||||
total := 0
|
||||
resolved := 0
|
||||
counts := [5]int{}
|
||||
for _, c := range ef.Cases {
|
||||
d, err := r2.Route(ctx, c.Utterance, efNow)
|
||||
if err != nil || d.Intent != router.IntentAct {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
candidate := router.ResolveActionCandidate(d, m)
|
||||
key := [2]string{string(d.Producer), string(candidate.ResolvedBy)}
|
||||
row, ok := matrix[key]
|
||||
if !ok {
|
||||
row = &resolutionRow{Producer: d.Producer, Method: candidate.ResolvedBy, Fns: map[string]int{}}
|
||||
matrix[key] = row
|
||||
}
|
||||
if candidate.ActionResolved() {
|
||||
resolved++
|
||||
row.Resolved++
|
||||
row.Fns[candidate.Fn]++
|
||||
switch candidate.ResolvedBy {
|
||||
case router.ActionResolutionGrammarFixed:
|
||||
counts[0]++
|
||||
case router.ActionResolutionGrammarMatcher:
|
||||
counts[1]++
|
||||
case router.ActionResolutionExtractorRaw:
|
||||
counts[2]++
|
||||
case router.ActionResolutionExtractorLLMText:
|
||||
counts[3]++
|
||||
case router.ActionResolutionFallbackMatcher:
|
||||
counts[4]++
|
||||
}
|
||||
} else {
|
||||
row.Unresolved++
|
||||
}
|
||||
}
|
||||
printMatrix(t, "Ecosystem fixture: IntentAct resolution", total, matrix)
|
||||
t.Logf("resolved: %d, unresolved: %d", resolved, total-resolved)
|
||||
t.Logf("grammar_fixed=%d grammar_matcher=%d extractor_raw=%d extractor_llm_text=%d fallback_matcher=%d",
|
||||
counts[0], counts[1], counts[2], counts[3], counts[4])
|
||||
}
|
||||
}
|
||||
|
||||
// TestShadowMatcherComparison — diagnostic: for each IntentAct case with
|
||||
// HasFn=true, invoke the fallback matcher on the same text and compare.
|
||||
func TestShadowMatcherComparison(t *testing.T) {
|
||||
f, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
now, err := f.Now()
|
||||
if err != nil {
|
||||
t.Fatalf("Now: %v", err)
|
||||
}
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
r := newBaselineRouter(t, emb, nil)
|
||||
m := router.DefaultActMatcher{Fns: actFns}
|
||||
ctx := context.Background()
|
||||
|
||||
same, different, routeOnly, matcherOnly, missBoth, total := 0, 0, 0, 0, 0, 0
|
||||
for _, c := range f.Cases {
|
||||
d, err := r.Route(ctx, c.Utterance, now)
|
||||
if err != nil || d.Intent != router.IntentAct {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if d.Slots.HasFn {
|
||||
mFn, _, mOk := m.Match(d.Slots.Text)
|
||||
if !mOk {
|
||||
routeOnly++
|
||||
t.Logf(" %s %q: route=%s, matcher=miss", c.ID, c.Utterance, d.Slots.Fn)
|
||||
} else if mFn == d.Slots.Fn {
|
||||
same++
|
||||
} else {
|
||||
different++
|
||||
t.Logf(" %s %q: route=%s, matcher=%s", c.ID, c.Utterance, d.Slots.Fn, mFn)
|
||||
}
|
||||
} else {
|
||||
_, _, mOk := m.Match(d.Slots.Text)
|
||||
if !mOk {
|
||||
missBoth++
|
||||
} else {
|
||||
matcherOnly++
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("\n=== Shadow matcher comparison (%d act cases) ===", total)
|
||||
t.Logf("same=%d different=%d routeOnly=%d matcherOnly=%d bothMiss=%d",
|
||||
same, different, routeOnly, matcherOnly, missBoth)
|
||||
}
|
||||
@@ -74,6 +74,34 @@ const (
|
||||
IntentSystem Intent = "system"
|
||||
)
|
||||
|
||||
// ActionResolutionMethod — which component actually selected the exact
|
||||
// function. Recorded for observability so a trace can name the selection
|
||||
// mechanism without re-deriving it from the route producer and surrounding
|
||||
// claims. Five disjoint values; empty means no function was resolved.
|
||||
type ActionResolutionMethod string
|
||||
|
||||
const (
|
||||
// ActionResolutionGrammarFixed — a stage-0 grammar hardcodes a fixed
|
||||
// canonical fn (praxis lifecycle, task-status). No matcher involved.
|
||||
ActionResolutionGrammarFixed ActionResolutionMethod = "grammar_fixed"
|
||||
|
||||
// ActionResolutionGrammarMatcher — a stage-0 grammar invokes the
|
||||
// ActMatcher to select fn (wakeword-act fast path).
|
||||
ActionResolutionGrammarMatcher ActionResolutionMethod = "grammar_matcher"
|
||||
|
||||
// ActionResolutionExtractorRaw — post-route Extractor.Acts.Match over
|
||||
// the original/raw routed utterance.
|
||||
ActionResolutionExtractorRaw ActionResolutionMethod = "extractor_raw"
|
||||
|
||||
// ActionResolutionExtractorLLMText — the LLM produced cleaned
|
||||
// Slots.Text, then Extractor.Acts.Match selected fn from it.
|
||||
ActionResolutionExtractorLLMText ActionResolutionMethod = "extractor_llm_text"
|
||||
|
||||
// ActionResolutionFallbackMatcher — ResolveActionCandidate ran the
|
||||
// fallback matcher because routing/extraction left HasFn=false.
|
||||
ActionResolutionFallbackMatcher ActionResolutionMethod = "fallback_matcher"
|
||||
)
|
||||
|
||||
// Slots — per-intent extracted arguments (stage 2). Not every field is set for
|
||||
// every intent; the Intent decides which matter. A slot that doesn't parse
|
||||
// leaves its Has* flag false — the daemon's SLM last-resort lane picks it up
|
||||
@@ -93,6 +121,11 @@ type Slots struct {
|
||||
Args []string
|
||||
HasFn bool
|
||||
|
||||
// ResolvedBy — which component actually selected the exact function.
|
||||
// Set when Fn is set; empty when HasFn is false. Travels with the slot
|
||||
// so provenance is known at the selection point, not reconstructed later.
|
||||
ResolvedBy ActionResolutionMethod
|
||||
|
||||
// Fact: structured (key,value) the loop will evaluate predicates against.
|
||||
// "drank water" → key=water; "slept 6h" → key=sleep, value=6h. The value
|
||||
// is the raw string the daemon json-encodes before WriteFact.
|
||||
|
||||
+10
-10
@@ -210,12 +210,12 @@ func PraxisGrammars() []Grammar {
|
||||
if !ok {
|
||||
return Decision{}, false
|
||||
}
|
||||
return Decision{
|
||||
Stage: 0,
|
||||
Intent: IntentAct,
|
||||
Confidence: 1.0,
|
||||
Slots: Slots{Fn: c.Fn, HasFn: true, Value: c.Ref},
|
||||
}, true
|
||||
return Decision{
|
||||
Stage: 0,
|
||||
Intent: IntentAct,
|
||||
Confidence: 1.0,
|
||||
Slots: Slots{Fn: c.Fn, HasFn: true, Value: c.Ref, ResolvedBy: ActionResolutionGrammarFixed},
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -226,7 +226,7 @@ func PraxisGrammars() []Grammar {
|
||||
Stage: 0,
|
||||
Intent: IntentAct,
|
||||
Confidence: 1.0,
|
||||
Slots: Slots{Fn: "list_attention", HasFn: true},
|
||||
Slots: Slots{Fn: "list_attention", HasFn: true, ResolvedBy: ActionResolutionGrammarFixed},
|
||||
}, true
|
||||
},
|
||||
},
|
||||
@@ -238,7 +238,7 @@ func PraxisGrammars() []Grammar {
|
||||
Stage: 0,
|
||||
Intent: IntentAct,
|
||||
Confidence: 1.0,
|
||||
Slots: Slots{Fn: "list_changes", HasFn: true},
|
||||
Slots: Slots{Fn: "list_changes", HasFn: true, ResolvedBy: ActionResolutionGrammarFixed},
|
||||
}, true
|
||||
},
|
||||
},
|
||||
@@ -261,7 +261,7 @@ func PraxisGrammars() []Grammar {
|
||||
// Text, not Value: entityAttentionCapability reads Value
|
||||
// first and that slot means an item id everywhere else in
|
||||
// the Praxis dispatch.
|
||||
Slots: Slots{Fn: "entity_attention", HasFn: true, Text: subject},
|
||||
Slots: Slots{Fn: "entity_attention", HasFn: true, Text: subject, ResolvedBy: ActionResolutionGrammarFixed},
|
||||
}, true
|
||||
},
|
||||
},
|
||||
@@ -284,7 +284,7 @@ func praxisServiceAttentionDecision(utterance string) (Decision, bool) {
|
||||
Stage: 0,
|
||||
Intent: IntentAct,
|
||||
Confidence: 1,
|
||||
Slots: Slots{Fn: "entity_attention", HasFn: true, Text: tokens[3]},
|
||||
Slots: Slots{Fn: "entity_attention", HasFn: true, Text: tokens[3], ResolvedBy: ActionResolutionGrammarFixed},
|
||||
}, true
|
||||
}
|
||||
|
||||
|
||||
@@ -303,7 +303,7 @@ func (r *Router) fillMatchedSlots(ctx context.Context, d *Decision, now time.Tim
|
||||
d.Slots.Key, d.Slots.Value, d.Slots.HasKey = ex.Key, ex.Value, ex.HasKey
|
||||
}
|
||||
if !d.Slots.HasFn && ex.HasFn {
|
||||
d.Slots.Fn, d.Slots.Args, d.Slots.HasFn = ex.Fn, ex.Args, ex.HasFn
|
||||
d.Slots.Fn, d.Slots.Args, d.Slots.HasFn, d.Slots.ResolvedBy = ex.Fn, ex.Args, ex.HasFn, ex.ResolvedBy
|
||||
}
|
||||
return ex
|
||||
}
|
||||
@@ -320,6 +320,7 @@ func (r *Router) fillSlots(ctx context.Context, d *Decision, now time.Time) {
|
||||
d.Slots.Text != "" && d.Slots.Text != d.Utterance {
|
||||
if fn, args, ok := r.extractor.Acts.Match(d.Slots.Text); ok {
|
||||
d.Slots.Fn, d.Slots.Args, d.Slots.HasFn = fn, args, true
|
||||
d.Slots.ResolvedBy = ActionResolutionExtractorLLMText
|
||||
}
|
||||
}
|
||||
// The extractor's Text is the raw utterance, which is the payload for a
|
||||
|
||||
@@ -73,6 +73,7 @@ func (e Extractor) Extract(ctx context.Context, intent Intent, utterance string,
|
||||
s.Fn = fn
|
||||
s.Args = args
|
||||
s.HasFn = true
|
||||
s.ResolvedBy = ActionResolutionExtractorRaw
|
||||
}
|
||||
}
|
||||
case IntentFact:
|
||||
|
||||
@@ -95,12 +95,12 @@ func DefaultGrammars(actMatcher ActMatcher) []Grammar {
|
||||
if !ok {
|
||||
return Decision{}, false // fall through to classifier
|
||||
}
|
||||
return Decision{
|
||||
Stage: 0,
|
||||
Intent: IntentAct,
|
||||
Confidence: 1.0,
|
||||
Slots: Slots{Fn: fn, Args: args, HasFn: true, Text: rest},
|
||||
}, true
|
||||
return Decision{
|
||||
Stage: 0,
|
||||
Intent: IntentAct,
|
||||
Confidence: 1.0,
|
||||
Slots: Slots{Fn: fn, Args: args, HasFn: true, Text: rest, ResolvedBy: ActionResolutionGrammarMatcher},
|
||||
}, true
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -389,7 +389,7 @@ func TaskStatusGrammar() Grammar {
|
||||
// which is the pairing handlePraxisAct uses for an item and its
|
||||
// reference. Empty Text is a claim, not a refusal: the daemon
|
||||
// asks which task, having the list she does not.
|
||||
Slots: Slots{Fn: TaskStatusFn, HasFn: true, Value: c.Status, Text: c.Text},
|
||||
Slots: Slots{Fn: TaskStatusFn, HasFn: true, Value: c.Status, Text: c.Text, ResolvedBy: ActionResolutionGrammarFixed},
|
||||
}, true
|
||||
},
|
||||
}
|
||||
|
||||
Executable
BIN
Binary file not shown.
Reference in New Issue
Block a user