docs: examples for every hard requirement + mort migration blueprint
Phase 7: nine runnable examples/ programs (parse, failover chains with trailing alias, tiers, LLM_* env providers, multimodal, tool loop, Generate[T], agent, skills); docs/mort-migration.md mapping mort's go-llm/go-agentkit usage onto majordomo APIs with the planned additive library extensions and conversion order; README finalized with the complete matrix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,10 +5,10 @@ over many model providers, a parseable model naming / failover / tiering
|
||||
system with built-in health tracking, capability-aware multimodality, tool
|
||||
calls, structured output, and composable agents and skills.
|
||||
|
||||
> **Status:** under construction, phase by phase. The
|
||||
> [support matrix](#featureprovider-support-matrix) below is kept honest:
|
||||
> *pending* means not built yet, and this README is updated in the same
|
||||
> commit as the behavior it describes.
|
||||
> The [support matrix](#featureprovider-support-matrix) below is kept
|
||||
> honest: *pending* means not built, and this README is updated in the
|
||||
> same commit as the behavior it describes. Runnable programs for every
|
||||
> feature live in [examples/](examples/README.md).
|
||||
|
||||
## Install
|
||||
|
||||
@@ -300,4 +300,5 @@ read `.env` (see `.env.example`; never commit `.env`).
|
||||
|
||||
Design decisions are recorded in [docs/adr/](docs/adr/README.md);
|
||||
conventions in [CLAUDE.md](CLAUDE.md); build history in
|
||||
[progress.md](progress.md).
|
||||
[progress.md](progress.md); the mort conversion plan in
|
||||
[docs/mort-migration.md](docs/mort-migration.md).
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# mort → majordomo migration blueprint
|
||||
|
||||
The plan for converting mort off `go-llm`/`go-agentkit` and onto majordomo,
|
||||
executed as the final phase of the build run. Inventory source: a full
|
||||
sweep of mort's 160 importing files (2026-06-10).
|
||||
|
||||
**Mandate (Steve, mid-run):** this is not a 1:1 port. mort's agent / LLM /
|
||||
tool code may be rewritten wholesale into a properly designed, extensible
|
||||
system — the constraint is user-facing behavior: `.query` still does quick
|
||||
research, `.research` / `.deepresearch` keep their roles, chatbot and
|
||||
general agents keep broadly the same functionality.
|
||||
|
||||
## Layering: what moves, what stays
|
||||
|
||||
```
|
||||
mort features (.query, chatbot, cookbook, scaddy, ...) — stay, re-wired
|
||||
mort orchestration (lanes, run-critic, budgets, delivery,
|
||||
encryption, compaction policy, tracing/usage recording) — stay, re-based
|
||||
go-llm + go-agentkit — DELETED
|
||||
majordomo (Parse/tiers/chains/health, providers, media,
|
||||
tools, agents, skills) — the new floor
|
||||
```
|
||||
|
||||
mort-side systems survive because majordomo's seams are public: providers
|
||||
and models are small interfaces (lane wrapping = an `llm.Provider`
|
||||
decorator; the foreman 30-minute timeout = a `Model` decorator), the agent
|
||||
loop exposes observers, and the registry accepts any provider.
|
||||
|
||||
## Core mappings
|
||||
|
||||
| mort today (go-llm / agentkit) | majordomo |
|
||||
|---|---|
|
||||
| `llm.Parse(spec)` / `llms.ParseModelRequest` | `Registry.Parse` (same grammar; chains, aliases, `LLM_*` lazily) |
|
||||
| tier convars + `llm.RegisterResolver` | `RegisterResolver` (library addition №1) backed by the same convar reads |
|
||||
| legacy aliases (`sonnet`, `haiku`, ...) | `RegisterAlias` table, unchanged strings |
|
||||
| comma failover chains + bench-on-failure | chain executor + health tracker (ADR-0006/0008) |
|
||||
| `llm.SetFailoverDefaults/Observer`, `failoverlog` | `WithHealthConfig`/`WithChainConfig` + `ChainConfig.Observer` (addition №7) feeding the same `llm_failover_events` store |
|
||||
| `.failover bench/unbench/list` | `health.Tracker` `Bench`/`Unbench`/`Snapshot` (addition №6) |
|
||||
| `llm.Client` + provider constructors + `OPENAI_KEY` etc. | built-ins + env keys; mort's nonstandard `OPENAI_KEY`/`GOOGLE_GEMINI_API_KEY` bridged via `WithAPIKey` at wiring |
|
||||
| deepseek/moonshot/xai/groq clients | `openai.New(WithName(...), WithBaseURL(...))` compat presets registered at boot |
|
||||
| `LLM_M1=foreman://...` env DSN | identical (ADR-0004 parity); foreman timeout via a Model-wrapping decorator |
|
||||
| `llm.Define[Args]` / `DefineSimple` (60+ sites) | `DefineTool[Args]` (addition №2, schema via `SchemaFor`) |
|
||||
| `llm.NewToolBox` / `ToolBox.Execute/AllTools` | `llm.NewToolbox` / `Execute` / `Tools` |
|
||||
| `llm.Message/UserMessage/SystemMessage/UserMessageWithImages` | `Message` constructors + `UserParts(Text, Image...)` |
|
||||
| `llm.Image{Base64/URL}` | `ImagePart` (bytes; URL images fetched by mort's imageutil at the edge — it already downloads Discord attachments) |
|
||||
| `model.Complete(ctx, req)` | `Model.Generate` |
|
||||
| `llms.GenerateWith[T]` (hidden tool trick) | `majordomo.Generate[T]` (native structured output; `flexBool`-style tolerance stays mort-side in the few schemas that need it) |
|
||||
| `llm.WithPromptCaching/WithMaxTokens/WithTemperature` | request options; prompt caching = addition №4 (Anthropic cache_control) |
|
||||
| reasoning `:high` suffixes in specs | stripped by mort's spec layer → `WithReasoningEffort` (ADR-0003) |
|
||||
| usage detail keys (cached/reasoning tokens) | `Usage` detail fields (addition №3) |
|
||||
| `agentkit.Agent{...}.Run` | `agent.New(...).Run` |
|
||||
| `Observer/FuncObserver/MultiObserver` | `WithStepObserver` / `OnStep` (multiple registrations compose) |
|
||||
| `MaxIter` / `MaxIterFunc` (critic adjust_budget) | `WithMaxSteps` / `WithMaxStepsFunc` (addition №5a) |
|
||||
| `Steer` mailbox (critic nudges) | `WithSteer` per-step message injection (addition №5b) |
|
||||
| `ContextCompactor` | `WithCompactor` pre-step hook (addition №5c); mort keeps its summarize-the-middle policy |
|
||||
| `MaxConsecutiveToolErrors` / `MaxSameToolCallRepeats` | loop guards (addition №5d) |
|
||||
| `ErrMaxIterExhausted` | `agent.ErrMaxSteps` |
|
||||
| `SessionTools{Tools,PostRun,Cleanup}` / `AttachImages` | mort-side session-tool layer over `AddToolbox` + run history; artifacts/delivery were always mort code |
|
||||
| `AgentDefinition`/`PhaseDefinition`/`RunPipeline` | mort-side phase runner: sequential `agent.Run` calls with per-phase model/tools/system + template vars (mort's executor owns this today anyway) |
|
||||
| skill manifests (`skills/*/skill.yml`) | unchanged YAML → `skill.New(name, WithInstructions(sys), WithToolbox(...))` + mort's executor bounds |
|
||||
| `llm.Transcription` types | move into `pkg/logic/transcribe` (out of majordomo's scope) |
|
||||
| lane wrapping of `provider.Provider` | `laneProvider` implementing `llm.Provider`, registered via `RegisterProvider` — no fork needed (this was inventory pain point №6) |
|
||||
|
||||
## Library additions planned during conversion
|
||||
|
||||
Each is additive, general-purpose, and lands in majordomo main with tests
|
||||
and an ADR before mort consumes it:
|
||||
|
||||
1. `Registry.RegisterResolver(Resolver)` — dynamic alias resolution
|
||||
(DB-backed tiers); checked after static aliases during expansion, output
|
||||
re-expanded recursively with the same cycle guard.
|
||||
2. `DefineTool[Args](name, desc, fn)` — typed tools: schema from
|
||||
`SchemaFor[Args]`, arguments unmarshaled before the handler runs.
|
||||
3. `Usage{CacheReadTokens, CacheWriteTokens, ReasoningTokens}` populated by
|
||||
the providers that report them (Anthropic, OpenAI, Google).
|
||||
4. `WithPromptCaching()` → Anthropic `cache_control` (system + last turn);
|
||||
no-op elsewhere.
|
||||
5. Agent loop hooks: (a) `WithMaxStepsFunc`, (b) `WithSteer`,
|
||||
(c) `WithCompactor`, (d) `WithToolErrorLimits`.
|
||||
6. `health.Tracker.Bench(key, until)` / `Unbench(key)` / `Snapshot()` for
|
||||
the `.failover` admin surface and web UI.
|
||||
7. `ChainConfig.Observer` — one callback per failover decision (attempt,
|
||||
classification, bench) feeding mort's event log.
|
||||
|
||||
## Conversion order (Phase 9 execution plan)
|
||||
|
||||
1. **Library additions** above (majordomo main, gated, committed).
|
||||
2. **`pkg/logic/llms` rebuild** — the choke point. One mort package that
|
||||
owns: a `majordomo.Registry` with mort's env keys, compat providers,
|
||||
lane-wrapped registration, the convar-backed tier resolver, legacy
|
||||
aliases, the foreman timeout decorator, health observer wiring, and the
|
||||
`ParseModelRequest/ParseModelForContext/GenerateWith/CallAndExecute/
|
||||
SimpleCall` mort-facing API (now thin wrappers over majordomo). The
|
||||
reasoning-suffix dialect is stripped here.
|
||||
3. **Tool layer** — `pkg/skilltools` registry keeps its gating/audit
|
||||
design; `gated_tool.go` re-bases `NewGatedTool[Args]` on
|
||||
`DefineTool[Args]`; ~96 tools recompile against `llm.Tool`.
|
||||
4. **Executors** — `skillexec` + `agentexec` re-based on `agent.Agent`
|
||||
(loop guards, compactor, steer, observers from the additions). The
|
||||
no-tools direct path falls out naturally (empty toolset sends no tools
|
||||
array). Phased agents = mort-side sequential runner.
|
||||
5. **Call sites** — chatbot, cookbook, recipe, summary, standingquery,
|
||||
tasks, fitness, drawbot, lottery, reminder, epon, scaddy, clipper,
|
||||
transcribe: mechanical re-pointing to the new `llms` wrappers and
|
||||
canonical types.
|
||||
6. **Delete** go-llm + go-agentkit from go.mod; repo-wide grep must be
|
||||
clean; `go build ./...` + tests green.
|
||||
7. Branch, push, PR (never touching mort's `main`).
|
||||
|
||||
## Behavioral deltas to verify after conversion
|
||||
|
||||
- Default chain knobs differ (go-llm: 3 retries/5m flat cooldown/jitter;
|
||||
majordomo: 1 retry/threshold 2/5s→5m exponential). mort's convars
|
||||
(`llms.failover.max_retries`, `llms.failover.cooldown_seconds`) map onto
|
||||
`ChainConfig.TransientRetries` + `health.Config` so operators keep their
|
||||
dials; semantics ADR-0006 apply.
|
||||
- go-llm benched-everything chains ignore cooldowns; majordomo returns the
|
||||
joined exhaustion error. mort's executor already retries failed runs.
|
||||
- `ErrRequestSpecific` (400/413/422) failed over WITHOUT benching in
|
||||
go-llm; majordomo fails fast by default — mort sets
|
||||
`AdvanceOnPermanent: true` to preserve availability behavior.
|
||||
- Image URLs: go-llm providers fetched `llm.Image{URL}`; majordomo is
|
||||
bytes-only — `imageutil.FromTextURLs` now downloads (it already has the
|
||||
HTTP plumbing and the 50MB cap).
|
||||
@@ -0,0 +1,25 @@
|
||||
# Examples
|
||||
|
||||
One runnable program per hard requirement. Each takes `-model` (a full
|
||||
majordomo spec — single target, chain, or alias) and reads provider keys
|
||||
from the environment (`OLLAMA_API_KEY`, `OPENAI_API_KEY`,
|
||||
`ANTHROPIC_API_KEY`, `GOOGLE_API_KEY`, `LLM_*` DSNs).
|
||||
|
||||
| Example | Shows |
|
||||
|---|---|
|
||||
| `parse/` | resolving a spec and a plain chat |
|
||||
| `failover/` | failover chains, incl. the trailing-alias form, + health behavior |
|
||||
| `tiers/` | registering custom tiers (aliases) and parsing them |
|
||||
| `envproviders/` | `LLM_*` env-DSN providers (foreman) as first-class chain elements |
|
||||
| `multimodal/` | attaching an image; capability-aware normalization |
|
||||
| `tools/` | the canonical tool-call loop against the raw Model API |
|
||||
| `structured/` | typed structured output via `Generate[T]` |
|
||||
| `agent/` | an agent with a toolbox running the tool-dispatch loop |
|
||||
| `skillexample/` | attaching the ready-made clock + calc skills to an agent |
|
||||
| `live/` | the Phase 8 live-validation harness (real Ollama Cloud; needs `.env`) |
|
||||
|
||||
Run any of them like:
|
||||
|
||||
```bash
|
||||
OLLAMA_API_KEY=... go run ./examples/parse -model "ollama-cloud/minimax-m3:cloud"
|
||||
```
|
||||
@@ -0,0 +1,66 @@
|
||||
// Command agent demonstrates the agent loop: model + system prompt +
|
||||
// toolbox, run to completion with step observation.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/agent"
|
||||
)
|
||||
|
||||
func main() {
|
||||
model := flag.String("model", "ollama-cloud/minimax-m3:cloud", "model spec")
|
||||
flag.Parse()
|
||||
|
||||
files := majordomo.NewToolbox("files",
|
||||
majordomo.Tool{
|
||||
Name: "list_dir",
|
||||
Description: "List the files in a directory.",
|
||||
Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}`),
|
||||
Handler: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||
var p struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(p.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
return names, nil
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
m, err := majordomo.Parse(*model)
|
||||
if err != nil {
|
||||
log.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
a := agent.New(m, "You are a filesystem assistant. Use your tools; never invent file names.",
|
||||
agent.WithToolbox(files),
|
||||
agent.WithMaxSteps(6),
|
||||
agent.WithStepObserver(func(s agent.Step) {
|
||||
for _, call := range s.Response.ToolCalls {
|
||||
fmt.Printf(" step %d: %s(%s)\n", s.Index, call.Name, call.Arguments)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
res, err := a.Run(context.Background(), "What Go files are in the current directory?")
|
||||
if err != nil {
|
||||
log.Fatalf("run: %v", err)
|
||||
}
|
||||
fmt.Printf("\n%s\n(%d steps, %d tokens)\n", res.Output, len(res.Steps), res.Usage.Total())
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Command envproviders demonstrates LLM_* env-DSN provider definitions
|
||||
// (go-llm parity): named providers built entirely from the environment,
|
||||
// first-class in Parse, chains, and tiers.
|
||||
//
|
||||
// export LLM_M1=foreman://test-token-change-me@foreman-m1.orgrimmar.dudenhoeffer.casa
|
||||
// export LLM_M5=foreman://test-token-change-me@foreman-m5.orgrimmar.dudenhoeffer.casa
|
||||
// go run ./examples/envproviders
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
spec := flag.String("model", "m5/qwen3:30b,m1/qwen3:30b,thinking", "spec mixing env providers, built-ins, and aliases")
|
||||
flag.Parse()
|
||||
|
||||
if os.Getenv("LLM_M1") == "" && os.Getenv("LLM_M5") == "" {
|
||||
log.Println("note: no LLM_M1/LLM_M5 set — set foreman DSNs to run this against real daemons")
|
||||
}
|
||||
|
||||
reg := majordomo.New() // eagerly loads every LLM_* var; unknown names also resolve lazily
|
||||
reg.RegisterAlias("thinking", "ollama-cloud/minimax-m3:cloud")
|
||||
|
||||
m, err := reg.Parse(*spec)
|
||||
if err != nil {
|
||||
log.Fatalf("parse %q: %v", *spec, err)
|
||||
}
|
||||
resp, err := m.Generate(context.Background(), majordomo.Request{
|
||||
Messages: []majordomo.Message{majordomo.UserText("Which machine are you running on? One sentence.")},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("generate: %v", err)
|
||||
}
|
||||
fmt.Printf("[%s] %s\n", resp.Model, resp.Text())
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Command failover demonstrates ordered failover chains with health
|
||||
// tracking — including the README's flagship examples: a plain chain and
|
||||
// the same chain with a registered alias expanded inline at the tail.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
reg := majordomo.New()
|
||||
reg.RegisterAlias("thinking", "anthropic/opus-4.8,ollama-cloud/minimax-m3:cloud")
|
||||
|
||||
// Plain chain: try head-to-tail. One transient blip on a target is
|
||||
// retried in place; repeated failures bench the target (exponential
|
||||
// cooldown) and the chain advances.
|
||||
plain := "ollama-cloud/minimax-m3:cloud,ollama-cloud/kimi-k2.6:cloud,anthropic/opus-4.8"
|
||||
|
||||
// Same chain with the registered alias appended: "thinking" expands
|
||||
// inline as the tail (duplicates are dropped, first occurrence wins).
|
||||
withAlias := plain + ",thinking"
|
||||
|
||||
for _, spec := range []string{plain, withAlias} {
|
||||
m, err := reg.Parse(spec)
|
||||
if err != nil {
|
||||
log.Fatalf("parse %q: %v", spec, err)
|
||||
}
|
||||
resp, err := m.Generate(context.Background(), majordomo.Request{
|
||||
Messages: []majordomo.Message{majordomo.UserText("In one sentence: why do failover chains beat single targets?")},
|
||||
})
|
||||
if err != nil {
|
||||
// The joined error names every target and why it failed.
|
||||
log.Fatalf("chain exhausted:\n%v", err)
|
||||
}
|
||||
fmt.Printf("spec: %s\n served by %s: %s\n\n", spec, resp.Model, resp.Text())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Command multimodal demonstrates capability-aware image input: attach an
|
||||
// image without knowing the target's limits — majordomo sniffs the real
|
||||
// format, downscales, re-encodes, and enforces counts/sizes against the
|
||||
// actual serving target before the request goes out.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
model := flag.String("model", "ollama-cloud/kimi-k2.6:cloud", "vision-capable model spec")
|
||||
path := flag.String("image", "", "path to a jpeg/png/gif image (required)")
|
||||
flag.Parse()
|
||||
|
||||
if *path == "" {
|
||||
log.Fatal("usage: multimodal -image photo.jpg [-model spec]")
|
||||
}
|
||||
data, err := os.ReadFile(*path)
|
||||
if err != nil {
|
||||
log.Fatalf("read image: %v", err)
|
||||
}
|
||||
|
||||
m, err := majordomo.Parse(*model)
|
||||
if err != nil {
|
||||
log.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
// The declared MIME may even be wrong — the media pipeline sniffs the
|
||||
// bytes and corrects it.
|
||||
resp, err := m.Generate(context.Background(), majordomo.Request{
|
||||
Messages: []majordomo.Message{majordomo.UserParts(
|
||||
majordomo.Text("Describe this image in two sentences."),
|
||||
majordomo.Image("image/jpeg", data),
|
||||
)},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("generate: %v", err)
|
||||
}
|
||||
fmt.Printf("[%s] %s\n", resp.Model, resp.Text())
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Command parse demonstrates the resolver: one spec string in, one Model
|
||||
// out, regardless of whether it names a single target, a chain, or a tier.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
model := flag.String("model", "ollama-cloud/minimax-m3:cloud", "model spec (provider/model, chain, or alias)")
|
||||
prompt := flag.String("prompt", "Say hello in five words.", "prompt to send")
|
||||
flag.Parse()
|
||||
|
||||
m, err := majordomo.Parse(*model)
|
||||
if err != nil {
|
||||
log.Fatalf("parse %q: %v", *model, err)
|
||||
}
|
||||
|
||||
resp, err := m.Generate(context.Background(), majordomo.Request{
|
||||
Messages: []majordomo.Message{majordomo.UserText(*prompt)},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("generate: %v", err)
|
||||
}
|
||||
fmt.Printf("[%s] %s\n", resp.Model, resp.Text())
|
||||
fmt.Printf("usage: %d in / %d out tokens\n", resp.Usage.InputTokens, resp.Usage.OutputTokens)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Command skillexample demonstrates skills: reusable instruction+tool
|
||||
// bundles attached to an agent on demand — here the ready-made clock and
|
||||
// calc skills, which require tool use for time and arithmetic.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/agent"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/skill/calc"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/skill/clock"
|
||||
)
|
||||
|
||||
func main() {
|
||||
model := flag.String("model", "ollama-cloud/minimax-m3:cloud", "model spec")
|
||||
flag.Parse()
|
||||
|
||||
m, err := majordomo.Parse(*model)
|
||||
if err != nil {
|
||||
log.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
a := agent.New(m, "You are a precise assistant.")
|
||||
a.AddSkill(clock.New()) // time_now / time_convert + instructions
|
||||
a.AddSkill(calc.New()) // calculate + instructions
|
||||
|
||||
res, err := a.Run(context.Background(),
|
||||
"How many minutes are left until midnight UTC? Use your tools, then show the calculation.")
|
||||
if err != nil {
|
||||
log.Fatalf("run: %v", err)
|
||||
}
|
||||
fmt.Println(res.Output)
|
||||
for _, step := range res.Steps {
|
||||
for _, r := range step.Results {
|
||||
fmt.Printf(" tool %s → %s\n", r.Name, r.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Command structured demonstrates typed structured output: the JSON schema
|
||||
// is derived from a Go struct, mapped to the provider's native mechanism,
|
||||
// and the response is unmarshaled back into the struct.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||
)
|
||||
|
||||
// Recipe is the shape we want back. Field tags document and constrain the
|
||||
// schema (`description`, `enum`); pointers mark nullable fields.
|
||||
type Recipe struct {
|
||||
Name string `json:"name"`
|
||||
Servings int `json:"servings"`
|
||||
Difficulty string `json:"difficulty" enum:"easy,medium,hard"`
|
||||
Steps []string `json:"steps" description:"short imperative steps"`
|
||||
WinePair *string `json:"wine_pairing" description:"null if none appropriate"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
model := flag.String("model", "ollama-cloud/minimax-m3:cloud", "model spec")
|
||||
flag.Parse()
|
||||
|
||||
m, err := majordomo.Parse(*model)
|
||||
if err != nil {
|
||||
log.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
recipe, err := majordomo.Generate[Recipe](context.Background(), m, majordomo.Request{
|
||||
Messages: []majordomo.Message{majordomo.UserText("A weeknight mushroom risotto.")},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("generate: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s (serves %d, %s)\n", recipe.Name, recipe.Servings, recipe.Difficulty)
|
||||
for i, s := range recipe.Steps {
|
||||
fmt.Printf(" %d. %s\n", i+1, s)
|
||||
}
|
||||
if recipe.WinePair != nil {
|
||||
fmt.Printf("wine: %s\n", *recipe.WinePair)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Command tiers demonstrates custom tiers: register an alias once, then
|
||||
// address models by task shape ("thinking", "workhorse") instead of
|
||||
// hard-coding model strings at every call site.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
reg := majordomo.New()
|
||||
reg.RegisterAlias("thinking", "ollama-cloud/minimax-m3:cloud,ollama-cloud/kimi-k2.6:cloud")
|
||||
reg.RegisterAlias("workhorse", "ollama-cloud/minimax-m2.7:cloud,ollama-cloud/qwen3-coder:480b-cloud")
|
||||
// Tiers can reference other tiers; expansion is recursive (with cycle
|
||||
// detection), so "best-effort" is thinking with a workhorse tail.
|
||||
reg.RegisterAlias("best-effort", "thinking,workhorse")
|
||||
|
||||
m, err := reg.Parse("best-effort")
|
||||
if err != nil {
|
||||
log.Fatalf("parse: %v", err)
|
||||
}
|
||||
resp, err := m.Generate(context.Background(), majordomo.Request{
|
||||
Messages: []majordomo.Message{majordomo.UserText("One sentence: what is a model tier?")},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("generate: %v", err)
|
||||
}
|
||||
fmt.Printf("[%s] %s\n", resp.Model, resp.Text())
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Command tools demonstrates the canonical tool-call loop against the raw
|
||||
// Model API: offer tools, execute the calls the model makes, feed results
|
||||
// back, repeat until a final answer. (The agent package automates exactly
|
||||
// this loop; see examples/agent.)
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
model := flag.String("model", "ollama-cloud/minimax-m3:cloud", "model spec")
|
||||
flag.Parse()
|
||||
|
||||
weather := majordomo.Tool{
|
||||
Name: "get_weather",
|
||||
Description: "Get the current weather for a city.",
|
||||
Parameters: json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}`),
|
||||
Handler: func(_ context.Context, args json.RawMessage) (any, error) {
|
||||
var p struct {
|
||||
City string `json:"city"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A real implementation would call a weather API.
|
||||
return map[string]any{"city": p.City, "temp_c": 21, "sky": "partly cloudy"}, nil
|
||||
},
|
||||
}
|
||||
box := majordomo.NewToolbox("demo", weather)
|
||||
|
||||
m, err := majordomo.Parse(*model)
|
||||
if err != nil {
|
||||
log.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
msgs := []majordomo.Message{majordomo.UserText("What's the weather in Tokyo right now?")}
|
||||
|
||||
for range 5 {
|
||||
resp, err := m.Generate(ctx, majordomo.Request{Messages: msgs}, majordomo.WithToolbox(box))
|
||||
if err != nil {
|
||||
log.Fatalf("generate: %v", err)
|
||||
}
|
||||
msgs = append(msgs, resp.Message())
|
||||
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
fmt.Printf("[%s] %s\n", resp.Model, resp.Text())
|
||||
return
|
||||
}
|
||||
results := make([]majordomo.ToolResult, 0, len(resp.ToolCalls))
|
||||
for _, call := range resp.ToolCalls {
|
||||
fmt.Printf("→ model called %s(%s)\n", call.Name, call.Arguments)
|
||||
results = append(results, box.Execute(ctx, call))
|
||||
}
|
||||
msgs = append(msgs, majordomo.ToolResultsMessage(results...))
|
||||
}
|
||||
log.Fatal("no final answer within 5 steps")
|
||||
}
|
||||
+18
@@ -1,5 +1,23 @@
|
||||
# progress
|
||||
|
||||
## 2026-06-10 — Phase 7: examples, migration blueprint, README finalization
|
||||
|
||||
**Landed:** `examples/` — nine runnable programs, one per hard requirement
|
||||
(parse, failover incl. trailing-alias chains, custom tiers, LLM_* env
|
||||
providers + foreman, multimodal, raw tool loop, structured Generate[T],
|
||||
agent with toolbox, skills) + examples/README index; all built by the
|
||||
hermetic gate suite. `docs/mort-migration.md` — the full conversion
|
||||
blueprint: layering (what stays mort-side), the symbol-level core
|
||||
mappings table, seven planned additive library extensions (dynamic
|
||||
resolvers, DefineTool[Args], usage detail fields, prompt caching, agent
|
||||
loop hooks, manual bench controls, failover observer), the Phase 9
|
||||
execution order, and the behavioral deltas to verify (failover knob
|
||||
mapping, AdvanceOnPermanent for go-llm's ErrRequestSpecific behavior,
|
||||
bytes-only images). README final pass with the complete feature/provider
|
||||
matrix.
|
||||
|
||||
**Next:** Phase 8 — live validation against real Ollama Cloud.
|
||||
|
||||
## 2026-06-10 — Phase 6: skills
|
||||
|
||||
**Landed:** `skill/` (ADR-0013): the agent.Skill contract satisfied by a
|
||||
|
||||
Reference in New Issue
Block a user