🛟 Updated.
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 | ||