Files
majordomo/generate.go
T
steve 7dab4112ff feat: agent run loop, Generate[T], reflect-derived schemas
Phase 5:
- agent/: model + system prompt + toolboxes composition; bounded
  tool-dispatch loop (default 10 steps); panic-proof tool execution;
  unknown-tool and duplicate-name handling; history continuation; step
  observers; partial results on ErrMaxSteps/errors (ADR-0012)
- llm.SchemaFor[T]: strict-compatible JSON schemas from Go types
  (nullable pointers, description/enum tags, recursion rejected)
- majordomo.Generate[T]: typed structured output with fence-stripping
  decode and model-naming errors
- README agents/structured-output sections + matrix synced

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:10:18 +02:00

72 lines
2.1 KiB
Go

package majordomo
import (
"context"
"encoding/json"
"fmt"
"reflect"
"strings"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
)
// SchemaFor re-exports llm.SchemaFor: a JSON Schema derived from a Go type,
// suitable for WithSchema and tool parameters.
func SchemaFor[T any]() (json.RawMessage, error) { return llm.SchemaFor[T]() }
// Generate performs a structured-output request and unmarshals the result
// into T: the schema is derived from T (llm.SchemaFor), injected via the
// provider's native structured-output mechanism, and the response text is
// decoded into a T value.
//
// type Verdict struct {
// Guilty bool `json:"guilty"`
// Why string `json:"why" description:"one-sentence rationale"`
// }
// v, err := majordomo.Generate[Verdict](ctx, m, req)
func Generate[T any](ctx context.Context, m Model, req Request, opts ...Option) (T, error) {
var zero T
schema, err := llm.SchemaFor[T]()
if err != nil {
return zero, err
}
name := "response"
if t := reflect.TypeFor[T](); t.Name() != "" {
name = strings.ToLower(t.Name())
}
resp, err := m.Generate(ctx, req, append(opts, llm.WithSchema(schema, name))...)
if err != nil {
return zero, err
}
text := strings.TrimSpace(resp.Text())
if text == "" {
return zero, fmt.Errorf("majordomo: structured response from %s is empty (finish: %s)", resp.Model, resp.FinishReason)
}
// Defensive: some models wrap JSON in a markdown fence despite the
// schema constraint.
text = stripFence(text)
var out T
if err := json.Unmarshal([]byte(text), &out); err != nil {
return zero, fmt.Errorf("majordomo: decode structured response from %s: %w (text: %.200s)", resp.Model, err, text)
}
return out, nil
}
// stripFence removes a surrounding markdown code fence, if present.
func stripFence(s string) string {
if !strings.HasPrefix(s, "```") {
return s
}
s = strings.TrimPrefix(s, "```")
// Drop an optional language tag on the opening fence line.
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[i+1:]
}
s = strings.TrimSuffix(strings.TrimSpace(s), "```")
return strings.TrimSpace(s)
}