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>
This commit is contained in:
+71
@@ -0,0 +1,71 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user