bots-garden/one-day-one-featurepublic Fork
⑂ ee1bc35
Commits
⬇ Clone ▾
git clone https://git.rickub.com/bots-garden/one-day-one-feature.git
git clone ssh://git@rickub.com/bots-garden/one-day-one-feature.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

🛟 Updated.

k33g committed 2026-10-24T08:55:51+02:00 Browse files
ee1bc35 parent: b6e91e6
added 03-decision-models/.gitignore +1 -0
new file mode 100644
@@ -0,0 +1 @@
1+docs
new file mode 100644
@@ -0,0 +1 @@
1+docs
added 03-decision-models/.mm/decider/20261024-083355.227-spell.json +27 -0
new file mode 100644
@@ -0,0 +1,27 @@
1+{
2+ "time": "2026-10-24T08:33:55.227191+02:00",
3+ "decider": "spell.yaml",
4+ "prompt": "Five goblins are standing in a tight group 40 feet away. My friends are behind me",
5+ "response": {
6+ "model": "hf.co/LiquidAI/d1-3B-GGUF:Q4_K_M",
7+ "answers": {
8+ "spell": {
9+ "type": "choice",
10+ "choice": "fireball",
11+ "probabilities": {
12+ "fireball": 0.5695902022814664,
13+ "arcane_bolt": 0.034815610221915747,
14+ "shield": 0.048659243008384034,
15+ "misty_step": 0.1160346606750908,
16+ "sleep": 0.23090028381314315
17+ },
18+ "confidence": 0.46198775285183297
19+ }
20+ },
21+ "usage": {
22+ "input_tokens": 117,
23+ "output_tokens": 0
24+ }
25+ },
26+ "context": "spell: fireball (confidence 0.46)"
27+}
new file mode 100644
@@ -0,0 +1,27 @@
1+{
2+ "time": "2026-10-24T08:33:55.227191+02:00",
3+ "decider": "spell.yaml",
4+ "prompt": "Five goblins are standing in a tight group 40 feet away. My friends are behind me",
5+ "response": {
6+ "model": "hf.co/LiquidAI/d1-3B-GGUF:Q4_K_M",
7+ "answers": {
8+ "spell": {
9+ "type": "choice",
10+ "choice": "fireball",
11+ "probabilities": {
12+ "fireball": 0.5695902022814664,
13+ "arcane_bolt": 0.034815610221915747,
14+ "shield": 0.048659243008384034,
15+ "misty_step": 0.1160346606750908,
16+ "sleep": 0.23090028381314315
17+ },
18+ "confidence": 0.46198775285183297
19+ }
20+ },
21+ "usage": {
22+ "input_tokens": 117,
23+ "output_tokens": 0
24+ }
25+ },
26+ "context": "spell: fireball (confidence 0.46)"
27+}
added 03-decision-models/dungeon_master.yaml +66 -0
new file mode 100644
@@ -0,0 +1,66 @@
1+# A Dungeon Master helped by a decider. Before each message reaches the chat
2+# model, a preGeneration hook — a Go program, hooks/ask-decider — sends it to a
3+# decision model (spell.yaml) and adds its pick under the message:
4+# "spell: <name> (confidence <c>)". The system prompt says what that line means.
5+#
6+# player ──► situation ──► hook (Go) ──► decider (d1-3B, spell.yaml)
7+# │◄── spell + confidence
8+# ▼
9+# situation + spell ──► Dungeon Master (Qwen3.5-4B) ──► advice
10+#
11+# cd demos/decision-dnd
12+# mm tui dungeon_master.yaml # or: mm -config dungeon_master.yaml -p "…"
13+#
14+# The hook runs with `go run`: Go must be on the PATH. DECIDER_DUMP keeps one
15+# JSON file per question in .mm/decider/ (git-ignored) — remove it to stop.
16+provider: llmman
17+model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
18+baseUrl: http://127.0.0.1:17434/v1
19+fallback: http://host.docker.internal:17434/v1
20+
21+acp:
22+ name: dungeon_master
23+ title: "dungeon master"
24+
25+system: |
26+ You are the Dungeon Master.
27+ You help a novice wizard choose a spell for the situation the player describes.
28+ The wizard knows exactly five spells:
29+ fireball, arcane_bolt, shield, misty_step and sleep. Never suggest another.
30+
31+ Under each message you find a block "[context added by a hook]"
32+ with the line "spell: <name> (confidence <c>)".
33+ It comes from a decision model that read the situation:
34+ <name> is the spell it picked among the five,
35+ and <c> is how sure it is of that pick (0 to 1) — not the chance the spell works.
36+
37+ Answer in three short parts:
38+ the spell and the confidence;
39+ one sentence on why it fits the situation;
40+ if the confidence is below 0.5, the other one of the five you would consider, and why.
41+
42+sampling:
43+ temperature: 0.2
44+ max_tokens: 1536
45+ # Qwen3.5 thinks by default; with rules like these it can loop until the limit.
46+ chat_template_kwargs:
47+ enable_thinking: false
48+
49+# The chat model answers from what it knows: no tools, no skills.
50+bashTool: false
51+editTools: false
52+planTool: false
53+fetchContentTool: false
54+askUserTool: false
55+unloadModels: false
56+skillsDir: no-skills
57+agentsMd:
58+ enabled: false
59+
60+# Shows a 🪝 line for each hook run: which hook ran and how much context it added.
61+displayHooks: true
62+
63+hooks:
64+ preGeneration:
65+ - command: cd "$MM_CONFIG_DIR/hooks/ask-decider" && DECIDER_DUMP=.mm/decider go run . spell.yaml
66+ timeout: 60s # the first call loads the decision model (and go run compiles once)
new file mode 100644
@@ -0,0 +1,66 @@
1+# A Dungeon Master helped by a decider. Before each message reaches the chat
2+# model, a preGeneration hook — a Go program, hooks/ask-decider — sends it to a
3+# decision model (spell.yaml) and adds its pick under the message:
4+# "spell: <name> (confidence <c>)". The system prompt says what that line means.
5+#
6+# player ──► situation ──► hook (Go) ──► decider (d1-3B, spell.yaml)
7+# │◄── spell + confidence
8+# ▼
9+# situation + spell ──► Dungeon Master (Qwen3.5-4B) ──► advice
10+#
11+# cd demos/decision-dnd
12+# mm tui dungeon_master.yaml # or: mm -config dungeon_master.yaml -p "…"
13+#
14+# The hook runs with `go run`: Go must be on the PATH. DECIDER_DUMP keeps one
15+# JSON file per question in .mm/decider/ (git-ignored) — remove it to stop.
16+provider: llmman
17+model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
18+baseUrl: http://127.0.0.1:17434/v1
19+fallback: http://host.docker.internal:17434/v1
20+
21+acp:
22+ name: dungeon_master
23+ title: "dungeon master"
24+
25+system: |
26+ You are the Dungeon Master.
27+ You help a novice wizard choose a spell for the situation the player describes.
28+ The wizard knows exactly five spells:
29+ fireball, arcane_bolt, shield, misty_step and sleep. Never suggest another.
30+
31+ Under each message you find a block "[context added by a hook]"
32+ with the line "spell: <name> (confidence <c>)".
33+ It comes from a decision model that read the situation:
34+ <name> is the spell it picked among the five,
35+ and <c> is how sure it is of that pick (0 to 1) — not the chance the spell works.
36+
37+ Answer in three short parts:
38+ the spell and the confidence;
39+ one sentence on why it fits the situation;
40+ if the confidence is below 0.5, the other one of the five you would consider, and why.
41+
42+sampling:
43+ temperature: 0.2
44+ max_tokens: 1536
45+ # Qwen3.5 thinks by default; with rules like these it can loop until the limit.
46+ chat_template_kwargs:
47+ enable_thinking: false
48+
49+# The chat model answers from what it knows: no tools, no skills.
50+bashTool: false
51+editTools: false
52+planTool: false
53+fetchContentTool: false
54+askUserTool: false
55+unloadModels: false
56+skillsDir: no-skills
57+agentsMd:
58+ enabled: false
59+
60+# Shows a 🪝 line for each hook run: which hook ran and how much context it added.
61+displayHooks: true
62+
63+hooks:
64+ preGeneration:
65+ - command: cd "$MM_CONFIG_DIR/hooks/ask-decider" && DECIDER_DUMP=.mm/decider go run . spell.yaml
66+ timeout: 60s # the first call loads the decision model (and go run compiles once)
added 03-decision-models/hooks/ask-decider/.gitignore +2 -0
new file mode 100644
@@ -0,0 +1,2 @@
1+# Built by `go build -o ask-decider .` (see the README); not committed.
2+/ask-decider
new file mode 100644
@@ -0,0 +1,2 @@
1+# Built by `go build -o ask-decider .` (see the README); not committed.
2+/ask-decider
added 03-decision-models/hooks/ask-decider/dump.go +43 -0
new file mode 100644
@@ -0,0 +1,43 @@
1+package main
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "time"
9+)
10+
11+// record is what one run of the hook keeps when DECIDER_DUMP is set: enough
12+// to see what the decider was asked, what it answered, and what the chat
13+// model was given.
14+type record struct {
15+ Time time.Time `json:"time"`
16+ Decider string `json:"decider"`
17+ Prompt string `json:"prompt"`
18+ Response json.RawMessage `json:"response,omitempty"` // the server's JSON, untouched
19+ Context string `json:"context,omitempty"` // what the chat model reads under the question
20+ Error string `json:"error,omitempty"` // why nothing was handed over
21+}
22+
23+// dump writes rec, indented, to a new file of folder named after its time and
24+// its decider — 20261024-081530.123-spell.json — creating folder if needed.
25+//
26+// dump(".mm/decider", record{Time: time.Now(), Decider: "spell.yaml", Prompt: "…"})
27+func dump(folder string, rec record) error {
28+ if err := os.MkdirAll(folder, 0o755); err != nil {
29+ return err
30+ }
31+ data, err := json.MarshalIndent(rec, "", " ")
32+ if err != nil {
33+ return err
34+ }
35+ return os.WriteFile(filepath.Join(folder, dumpName(rec)), append(data, '\n'), 0o644)
36+}
37+
38+// dumpName is the file name of a record: its time to the millisecond, then
39+// the decider file's name without its folder and extension.
40+func dumpName(rec record) string {
41+ decider := strings.TrimSuffix(filepath.Base(rec.Decider), filepath.Ext(rec.Decider))
42+ return rec.Time.Format("20060102-150405.000") + "-" + decider + ".json"
43+}
new file mode 100644
@@ -0,0 +1,43 @@
1+package main
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "time"
9+)
10+
11+// record is what one run of the hook keeps when DECIDER_DUMP is set: enough
12+// to see what the decider was asked, what it answered, and what the chat
13+// model was given.
14+type record struct {
15+ Time time.Time `json:"time"`
16+ Decider string `json:"decider"`
17+ Prompt string `json:"prompt"`
18+ Response json.RawMessage `json:"response,omitempty"` // the server's JSON, untouched
19+ Context string `json:"context,omitempty"` // what the chat model reads under the question
20+ Error string `json:"error,omitempty"` // why nothing was handed over
21+}
22+
23+// dump writes rec, indented, to a new file of folder named after its time and
24+// its decider — 20261024-081530.123-spell.json — creating folder if needed.
25+//
26+// dump(".mm/decider", record{Time: time.Now(), Decider: "spell.yaml", Prompt: "…"})
27+func dump(folder string, rec record) error {
28+ if err := os.MkdirAll(folder, 0o755); err != nil {
29+ return err
30+ }
31+ data, err := json.MarshalIndent(rec, "", " ")
32+ if err != nil {
33+ return err
34+ }
35+ return os.WriteFile(filepath.Join(folder, dumpName(rec)), append(data, '\n'), 0o644)
36+}
37+
38+// dumpName is the file name of a record: its time to the millisecond, then
39+// the decider file's name without its folder and extension.
40+func dumpName(rec record) string {
41+ decider := strings.TrimSuffix(filepath.Base(rec.Decider), filepath.Ext(rec.Decider))
42+ return rec.Time.Format("20060102-150405.000") + "-" + decider + ".json"
43+}
added 03-decision-models/hooks/ask-decider/go.mod +5 -0
new file mode 100644
@@ -0,0 +1,5 @@
1+// The demo's preGeneration hook. Its own module, so that `go build ./...` in
2+// mini-me does not build it.
3+module askdecider
4+
5+go 1.27
new file mode 100644
@@ -0,0 +1,5 @@
1+// The demo's preGeneration hook. Its own module, so that `go build ./...` in
2+// mini-me does not build it.
3+module askdecider
4+
5+go 1.27
added 03-decision-models/hooks/ask-decider/main.go +195 -0
new file mode 100644
@@ -0,0 +1,195 @@
1+// Command ask-decider is the preGeneration hook of demos/decision-dnd (a copy of
2+// the one of demos/decision-chat-golang).
3+//
4+// It reads the hook input on stdin, sends the user's question to the decider
5+// (run as `mm -config <decider> -p "<question>"`), and writes a reply whose
6+// context holds one line per answer:
7+//
8+// level: beginner (confidence 0.91)
9+// topic: concurrency (confidence 0.88)
10+// wants_code: yes (0.97)
11+//
12+// The lines are built from whatever questions the decider declares, in their
13+// order, so changing the decider's questions needs no change here. Set
14+// DECIDER_RAW=1 to hand over the server's JSON answers instead of the lines.
15+//
16+// The decider file is the first argument, relative to the folder of the agent
17+// file that declares the hook (MM_CONFIG_DIR); without one, agent.decider.yaml:
18+//
19+// command: cd "$MM_CONFIG_DIR/hooks/ask-decider" && go run . spell.yaml
20+//
21+// Set DECIDER_DUMP to a folder (relative to MM_CONFIG_DIR) to keep, for each
22+// question, a timestamped JSON file with the question, the decider's whole
23+// response, the context handed over, or the error — see dump.go:
24+//
25+// command: cd "$MM_CONFIG_DIR/hooks/ask-decider" && DECIDER_DUMP=.mm/decider go run . spell.yaml
26+//
27+// MM_BIN is the running mm (set by mm for its hooks); MM_CONFIG_DIR is the
28+// folder of agent.yaml. A decider that fails adds nothing: the program writes
29+// nothing and the chat model reads the question alone.
30+package main
31+
32+import (
33+ "bytes"
34+ "encoding/json"
35+ "fmt"
36+ "os"
37+ "os/exec"
38+ "path/filepath"
39+ "strconv"
40+ "strings"
41+ "time"
42+)
43+
44+// hookInput is the part of the preGeneration input this hook reads.
45+type hookInput struct {
46+ Prompt string `json:"prompt"`
47+}
48+
49+// answer is one answer of /v1/systemone; which fields are set depends on Type.
50+type answer struct {
51+ Type string `json:"type"`
52+ Choice string `json:"choice"`
53+ Confidence float64 `json:"confidence"`
54+ Noul float64 `json:"noul"`
55+ Score float64 `json:"score"`
56+ Legend map[string]string `json:"legend"`
57+}
58+
59+// named keeps an answer with its question's name, in the decider's order.
60+type named struct {
61+ Name string
62+ Answer answer
63+}
64+
65+func main() {
66+ var input hookInput
67+ if err := json.NewDecoder(os.Stdin).Decode(&input); err != nil {
68+ return
69+ }
70+ rec := record{Time: time.Now(), Decider: deciderFile(os.Args[1:]), Prompt: input.Prompt}
71+ if folder := os.Getenv("DECIDER_DUMP"); folder != "" {
72+ defer func() {
73+ if err := dump(inConfigDir(folder), rec); err != nil {
74+ fmt.Fprintln(os.Stderr, "ask-decider: dump:", err)
75+ }
76+ }()
77+ }
78+ payload, err := askDecider(rec.Decider, input.Prompt)
79+ if err != nil {
80+ rec.Error = err.Error()
81+ return // empty stdout = continue, the question unchanged
82+ }
83+ rec.Response = payload
84+ context, err := contextFor(payload, os.Getenv("DECIDER_RAW") != "")
85+ if err != nil {
86+ rec.Error = err.Error()
87+ return
88+ }
89+ rec.Context = context
90+ reply, _ := json.Marshal(map[string]string{"context": context})
91+ fmt.Println(string(reply))
92+}
93+
94+// inConfigDir resolves a path relative to MM_CONFIG_DIR, the folder of the
95+// agent file that declares the hook (../.. from here when it is not set).
96+func inConfigDir(path string) string {
97+ if filepath.IsAbs(path) {
98+ return path
99+ }
100+ dir := os.Getenv("MM_CONFIG_DIR")
101+ if dir == "" {
102+ dir = "../.."
103+ }
104+ return filepath.Join(dir, path)
105+}
106+
107+// deciderFile returns the decider's agent file named by the arguments, or
108+// agent.decider.yaml when there is none.
109+func deciderFile(args []string) string {
110+ if len(args) > 0 && args[0] != "" {
111+ return args[0]
112+ }
113+ return "agent.decider.yaml"
114+}
115+
116+// askDecider runs the decider file (relative to MM_CONFIG_DIR unless it is
117+// absolute) on the question and returns its stdout, the server's JSON.
118+func askDecider(file, question string) ([]byte, error) {
119+ mm := os.Getenv("MM_BIN")
120+ if mm == "" {
121+ mm = "mm"
122+ }
123+ var stderr bytes.Buffer
124+ cmd := exec.Command(mm, "-config", inConfigDir(file), "-p", question)
125+ cmd.Stderr = &stderr
126+ out, err := cmd.Output()
127+ if err != nil {
128+ return nil, fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
129+ }
130+ return out, nil
131+}
132+
133+// contextFor turns the decider's JSON into the text the chat model reads:
134+// one line per answer, or the raw answers object when raw is true.
135+func contextFor(payload []byte, raw bool) (string, error) {
136+ var envelope struct {
137+ Answers json.RawMessage `json:"answers"`
138+ }
139+ if err := json.Unmarshal(payload, &envelope); err != nil {
140+ return "", err
141+ }
142+ if raw {
143+ return string(envelope.Answers), nil
144+ }
145+ answers, err := inOrder(envelope.Answers)
146+ if err != nil {
147+ return "", err
148+ }
149+ lines := make([]string, len(answers))
150+ for i, a := range answers {
151+ lines[i] = line(a.Name, a.Answer)
152+ }
153+ return strings.Join(lines, "\n"), nil
154+}
155+
156+// inOrder decodes the answers object keeping the order of its keys, which is
157+// the order of the decider's questions (a Go map would lose it).
158+func inOrder(object json.RawMessage) ([]named, error) {
159+ decoder := json.NewDecoder(bytes.NewReader(object))
160+ if _, err := decoder.Token(); err != nil { // {
161+ return nil, err
162+ }
163+ var out []named
164+ for decoder.More() {
165+ key, err := decoder.Token()
166+ if err != nil {
167+ return nil, err
168+ }
169+ var a answer
170+ if err := decoder.Decode(&a); err != nil {
171+ return nil, err
172+ }
173+ out = append(out, named{Name: fmt.Sprint(key), Answer: a})
174+ }
175+ return out, nil
176+}
177+
178+// line writes one answer the way the chat model's system prompt reads it.
179+func line(name string, a answer) string {
180+ switch a.Type {
181+ case "choice":
182+ return fmt.Sprintf("%s: %s (confidence %.2f)", name, a.Choice, a.Confidence)
183+ case "noul":
184+ verdict := "no"
185+ if a.Noul >= 0.5 {
186+ verdict = "yes"
187+ }
188+ return fmt.Sprintf("%s: %s (%.2f)", name, verdict, a.Noul)
189+ case "score":
190+ level := a.Legend[strconv.Itoa(int(a.Score+0.5))]
191+ return fmt.Sprintf("%s: %s (score %.2f)", name, level, a.Score)
192+ default:
193+ return fmt.Sprintf("%s: (unknown answer type %q)", name, a.Type)
194+ }
195+}
new file mode 100644
@@ -0,0 +1,195 @@
1+// Command ask-decider is the preGeneration hook of demos/decision-dnd (a copy of
2+// the one of demos/decision-chat-golang).
3+//
4+// It reads the hook input on stdin, sends the user's question to the decider
5+// (run as `mm -config <decider> -p "<question>"`), and writes a reply whose
6+// context holds one line per answer:
7+//
8+// level: beginner (confidence 0.91)
9+// topic: concurrency (confidence 0.88)
10+// wants_code: yes (0.97)
11+//
12+// The lines are built from whatever questions the decider declares, in their
13+// order, so changing the decider's questions needs no change here. Set
14+// DECIDER_RAW=1 to hand over the server's JSON answers instead of the lines.
15+//
16+// The decider file is the first argument, relative to the folder of the agent
17+// file that declares the hook (MM_CONFIG_DIR); without one, agent.decider.yaml:
18+//
19+// command: cd "$MM_CONFIG_DIR/hooks/ask-decider" && go run . spell.yaml
20+//
21+// Set DECIDER_DUMP to a folder (relative to MM_CONFIG_DIR) to keep, for each
22+// question, a timestamped JSON file with the question, the decider's whole
23+// response, the context handed over, or the error — see dump.go:
24+//
25+// command: cd "$MM_CONFIG_DIR/hooks/ask-decider" && DECIDER_DUMP=.mm/decider go run . spell.yaml
26+//
27+// MM_BIN is the running mm (set by mm for its hooks); MM_CONFIG_DIR is the
28+// folder of agent.yaml. A decider that fails adds nothing: the program writes
29+// nothing and the chat model reads the question alone.
30+package main
31+
32+import (
33+ "bytes"
34+ "encoding/json"
35+ "fmt"
36+ "os"
37+ "os/exec"
38+ "path/filepath"
39+ "strconv"
40+ "strings"
41+ "time"
42+)
43+
44+// hookInput is the part of the preGeneration input this hook reads.
45+type hookInput struct {
46+ Prompt string `json:"prompt"`
47+}
48+
49+// answer is one answer of /v1/systemone; which fields are set depends on Type.
50+type answer struct {
51+ Type string `json:"type"`
52+ Choice string `json:"choice"`
53+ Confidence float64 `json:"confidence"`
54+ Noul float64 `json:"noul"`
55+ Score float64 `json:"score"`
56+ Legend map[string]string `json:"legend"`
57+}
58+
59+// named keeps an answer with its question's name, in the decider's order.
60+type named struct {
61+ Name string
62+ Answer answer
63+}
64+
65+func main() {
66+ var input hookInput
67+ if err := json.NewDecoder(os.Stdin).Decode(&input); err != nil {
68+ return
69+ }
70+ rec := record{Time: time.Now(), Decider: deciderFile(os.Args[1:]), Prompt: input.Prompt}
71+ if folder := os.Getenv("DECIDER_DUMP"); folder != "" {
72+ defer func() {
73+ if err := dump(inConfigDir(folder), rec); err != nil {
74+ fmt.Fprintln(os.Stderr, "ask-decider: dump:", err)
75+ }
76+ }()
77+ }
78+ payload, err := askDecider(rec.Decider, input.Prompt)
79+ if err != nil {
80+ rec.Error = err.Error()
81+ return // empty stdout = continue, the question unchanged
82+ }
83+ rec.Response = payload
84+ context, err := contextFor(payload, os.Getenv("DECIDER_RAW") != "")
85+ if err != nil {
86+ rec.Error = err.Error()
87+ return
88+ }
89+ rec.Context = context
90+ reply, _ := json.Marshal(map[string]string{"context": context})
91+ fmt.Println(string(reply))
92+}
93+
94+// inConfigDir resolves a path relative to MM_CONFIG_DIR, the folder of the
95+// agent file that declares the hook (../.. from here when it is not set).
96+func inConfigDir(path string) string {
97+ if filepath.IsAbs(path) {
98+ return path
99+ }
100+ dir := os.Getenv("MM_CONFIG_DIR")
101+ if dir == "" {
102+ dir = "../.."
103+ }
104+ return filepath.Join(dir, path)
105+}
106+
107+// deciderFile returns the decider's agent file named by the arguments, or
108+// agent.decider.yaml when there is none.
109+func deciderFile(args []string) string {
110+ if len(args) > 0 && args[0] != "" {
111+ return args[0]
112+ }
113+ return "agent.decider.yaml"
114+}
115+
116+// askDecider runs the decider file (relative to MM_CONFIG_DIR unless it is
117+// absolute) on the question and returns its stdout, the server's JSON.
118+func askDecider(file, question string) ([]byte, error) {
119+ mm := os.Getenv("MM_BIN")
120+ if mm == "" {
121+ mm = "mm"
122+ }
123+ var stderr bytes.Buffer
124+ cmd := exec.Command(mm, "-config", inConfigDir(file), "-p", question)
125+ cmd.Stderr = &stderr
126+ out, err := cmd.Output()
127+ if err != nil {
128+ return nil, fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
129+ }
130+ return out, nil
131+}
132+
133+// contextFor turns the decider's JSON into the text the chat model reads:
134+// one line per answer, or the raw answers object when raw is true.
135+func contextFor(payload []byte, raw bool) (string, error) {
136+ var envelope struct {
137+ Answers json.RawMessage `json:"answers"`
138+ }
139+ if err := json.Unmarshal(payload, &envelope); err != nil {
140+ return "", err
141+ }
142+ if raw {
143+ return string(envelope.Answers), nil
144+ }
145+ answers, err := inOrder(envelope.Answers)
146+ if err != nil {
147+ return "", err
148+ }
149+ lines := make([]string, len(answers))
150+ for i, a := range answers {
151+ lines[i] = line(a.Name, a.Answer)
152+ }
153+ return strings.Join(lines, "\n"), nil
154+}
155+
156+// inOrder decodes the answers object keeping the order of its keys, which is
157+// the order of the decider's questions (a Go map would lose it).
158+func inOrder(object json.RawMessage) ([]named, error) {
159+ decoder := json.NewDecoder(bytes.NewReader(object))
160+ if _, err := decoder.Token(); err != nil { // {
161+ return nil, err
162+ }
163+ var out []named
164+ for decoder.More() {
165+ key, err := decoder.Token()
166+ if err != nil {
167+ return nil, err
168+ }
169+ var a answer
170+ if err := decoder.Decode(&a); err != nil {
171+ return nil, err
172+ }
173+ out = append(out, named{Name: fmt.Sprint(key), Answer: a})
174+ }
175+ return out, nil
176+}
177+
178+// line writes one answer the way the chat model's system prompt reads it.
179+func line(name string, a answer) string {
180+ switch a.Type {
181+ case "choice":
182+ return fmt.Sprintf("%s: %s (confidence %.2f)", name, a.Choice, a.Confidence)
183+ case "noul":
184+ verdict := "no"
185+ if a.Noul >= 0.5 {
186+ verdict = "yes"
187+ }
188+ return fmt.Sprintf("%s: %s (%.2f)", name, verdict, a.Noul)
189+ case "score":
190+ level := a.Legend[strconv.Itoa(int(a.Score+0.5))]
191+ return fmt.Sprintf("%s: %s (score %.2f)", name, level, a.Score)
192+ default:
193+ return fmt.Sprintf("%s: (unknown answer type %q)", name, a.Type)
194+ }
195+}
added 03-decision-models/spell.yaml +31 -0
new file mode 100644
@@ -0,0 +1,31 @@
1+# A decider: Dungeons & Dragons: which spell for my wizard?
2+provider: llmman
3+model: hf.co/LiquidAI/d1-3B-GGUF:Q4_K_M
4+baseUrl: http://127.0.0.1:17434/v1
5+fallback: http://host.docker.internal:17434/v1
6+
7+decision:
8+ questions:
9+ spell:
10+ type: choice
11+ instructions: I am the party's wizard. Which spell should I cast now?
12+ criteria:
13+ fireball: Many enemies close together, far enough from my friends
14+ arcane_bolt: One weak enemy to finish off, or one that is hard to hit
15+ shield: An enemy is about to hit me
16+ misty_step: I am surrounded or grabbed and must get away
17+ sleep: Several weak enemies, and we would rather not kill them
18+
19+acp:
20+ name: sidekick
21+ title: "sidekick"
22+
23+# A decider declares no tool: its model never calls one.
24+bashTool: false
25+editTools: false
26+planTool: false
27+fetchContentTool: false
28+askUserTool: false
29+skillsDir: no-skills
30+agentsMd:
31+ enabled: false
new file mode 100644
@@ -0,0 +1,31 @@
1+# A decider: Dungeons & Dragons: which spell for my wizard?
2+provider: llmman
3+model: hf.co/LiquidAI/d1-3B-GGUF:Q4_K_M
4+baseUrl: http://127.0.0.1:17434/v1
5+fallback: http://host.docker.internal:17434/v1
6+
7+decision:
8+ questions:
9+ spell:
10+ type: choice
11+ instructions: I am the party's wizard. Which spell should I cast now?
12+ criteria:
13+ fireball: Many enemies close together, far enough from my friends
14+ arcane_bolt: One weak enemy to finish off, or one that is hard to hit
15+ shield: An enemy is about to hit me
16+ misty_step: I am surrounded or grabbed and must get away
17+ sleep: Several weak enemies, and we would rather not kill them
18+
19+acp:
20+ name: sidekick
21+ title: "sidekick"
22+
23+# A decider declares no tool: its model never calls one.
24+bashTool: false
25+editTools: false
26+planTool: false
27+fetchContentTool: false
28+askUserTool: false
29+skillsDir: no-skills
30+agentsMd:
31+ enabled: false