diff --git a/README.md b/README.md index d3c1185..f242736 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/mort-migration.md b/docs/mort-migration.md new file mode 100644 index 0000000..8cdd24e --- /dev/null +++ b/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). diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..5138d31 --- /dev/null +++ b/examples/README.md @@ -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" +``` diff --git a/examples/agent/main.go b/examples/agent/main.go new file mode 100644 index 0000000..a509fa6 --- /dev/null +++ b/examples/agent/main.go @@ -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()) +} diff --git a/examples/envproviders/main.go b/examples/envproviders/main.go new file mode 100644 index 0000000..1ba751d --- /dev/null +++ b/examples/envproviders/main.go @@ -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()) +} diff --git a/examples/failover/main.go b/examples/failover/main.go new file mode 100644 index 0000000..b1c9a90 --- /dev/null +++ b/examples/failover/main.go @@ -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()) + } +} diff --git a/examples/multimodal/main.go b/examples/multimodal/main.go new file mode 100644 index 0000000..5c9dbba --- /dev/null +++ b/examples/multimodal/main.go @@ -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()) +} diff --git a/examples/parse/main.go b/examples/parse/main.go new file mode 100644 index 0000000..0971201 --- /dev/null +++ b/examples/parse/main.go @@ -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) +} diff --git a/examples/skillexample/main.go b/examples/skillexample/main.go new file mode 100644 index 0000000..cac5959 --- /dev/null +++ b/examples/skillexample/main.go @@ -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) + } + } +} diff --git a/examples/structured/main.go b/examples/structured/main.go new file mode 100644 index 0000000..c825771 --- /dev/null +++ b/examples/structured/main.go @@ -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) + } +} diff --git a/examples/tiers/main.go b/examples/tiers/main.go new file mode 100644 index 0000000..e5000d4 --- /dev/null +++ b/examples/tiers/main.go @@ -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()) +} diff --git a/examples/tools/main.go b/examples/tools/main.go new file mode 100644 index 0000000..c1c0c01 --- /dev/null +++ b/examples/tools/main.go @@ -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") +} diff --git a/progress.md b/progress.md index 8ebec9a..6e974e0 100644 --- a/progress.md +++ b/progress.md @@ -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