rig

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 16, 2026 License: MIT Imports: 17 Imported by: 0

README

rig

Minimal agent loop for OpenAI-compatible self-hosted models. A hardened client, a bounded tool-call loop, and a skills runner — a loop and a toolbox, not a framework.

Built for and extracted from kb. Designed to survive self-hosted reasoning models (Qwen, DeepSeek-style) whose serving templates leak chain-of-thought into message content.

Status: v0.x. The API is not stable until kb's migration onto it is complete.

Install

go get github.com/RandomCodeSpace/rig

Go 1.25+. Single third-party dependency: openai-go v3.

What it does

  • Hardened client — explicit-value construction only (ambient OPENAI_* environment values never reach the wire), a 1 MiB cap on decompressed response bodies, opaque error mapping that never leaks the endpoint URL or key, and a tool-calling probe that distinguishes "no tool support" from "reply truncated".
  • Bounded loopClient.Run sends, executes tool calls sequentially, feeds results back, and repeats. Iteration cap (default 8, hard cap 32), per-reply tool-call cap, explicit token budget required on every call, truncated replies are errors. Tool call ids and echoed assistant turns are normalized before anything reaches the next request.
  • Reasoning toleranceStripReasoning and DecodeJSONObject handle inline <think> blocks, templates that consume the opening tag, replies that die inside the reasoning, and reasoning that sketches the expected schema before the real answer. The JSON scan is cost-bounded against adversarial nested-brace payloads.
  • Skills — markdown files with name/description frontmatter, loaded from any fs.FS with override-by-name merging, advertised in the system prompt, and served to the model on demand through the built-in load_skill tool.

Example

client, err := rig.NewClient("http://127.0.0.1:11434/v1", "")
if err != nil {
    log.Fatal(err)
}

if err := client.ProbeToolCalling(ctx, "qwen3.5"); err != nil {
    log.Fatal(err) // model or backend cannot do tool calling
}

res, err := client.Run(ctx, rig.RunRequest{
    Model:     "qwen3.5",
    System:    "You are a release assistant.",
    Prompt:    "Summarize the open work.",
    MaxTokens: 4096,
    Tools: []rig.Tool{{
        Name:        "list_tasks",
        Description: "List open tasks on the board.",
        InputSchema: map[string]any{"type": "object", "properties": map[string]any{}},
        Run: func(ctx context.Context, input json.RawMessage) (string, error) {
            return listTasksJSON(ctx)
        },
    }},
})
if err != nil {
    log.Fatal(err)
}
fmt.Println(res.Text)

What it deliberately does not do

No streaming, no memory system, no multi-agent graphs, no provider abstraction beyond OpenAI-compatible endpoints, no built-in tools with side effects. SSRF protection for the transport belongs to the caller: pass a hardened *http.Client via rig.WithHTTPClient.

Design

See DESIGN.md for the full contract: API surface, loop semantics, error taxonomy, and testing requirements.

License

MIT

Documentation

Index

Constants

View Source
const (

	// LoadSkillToolName is the built-in tool the advertisement tells the model
	// to call. Callers append LoadSkillTool's output to their own tool list.
	LoadSkillToolName = "load_skill"
)

Variables

View Source
var (
	ErrNoToolCalling    = errors.New("backend did not return a tool call")
	ErrOutputLimit      = errors.New("reply hit the output token limit")
	ErrIterationLimit   = errors.New("tool loop hit the iteration limit")
	ErrResponseTooLarge = errors.New("response body exceeds the configured cap")
	ErrNotJSON          = errors.New("reply is not JSON")
	ErrInvalidJSON      = errors.New("reply is not valid JSON")
)

Every failure a caller can act on is one of these, wrapped with %w so the detail travels without the caller matching on message text.

Functions

func Advertise(skills []Skill) string

Advertise renders the skill catalogue for the system prompt. Output depends only on the set of skills, not on their input order. An empty set renders as the empty string so callers can concatenate unconditionally.

func DecodeJSONObject

func DecodeJSONObject(s string) (map[string]any, error)

DecodeJSONObject parses assistant content as a single JSON object, tolerating inline reasoning, stray prose, and code fences around it. When several candidates parse, the longest wins: the real reply contains every object nested inside it, while the schema sketches that leaked reasoning tends to include ("the shape is {\"stories\": [...]}") are short. Taking the first parseable candidate instead returned those sketches as the reply. Returns ErrNotJSON when no object is present at all, ErrInvalidJSON when braces are present but nothing parses.

func StripReasoning

func StripReasoning(s string) string

StripReasoning removes leaked chain-of-thought from assistant content. Closed <think>...</think> blocks are dropped. Some serving templates consume the opening tag, leaving bare reasoning that ends in </think>: everything through the last closing tag is reasoning, not reply. An opening tag left without a closing one opens reasoning that never ends, so everything from it on is dropped too -- a run that stopped inside the model's thinking has no reply, and passing the thinking through as one is worse than returning nothing. The result is trimmed of surrounding space.

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is one configured OpenAI-compatible endpoint. It is safe for concurrent use; the SDK client it wraps is immutable after construction.

func NewClient

func NewClient(baseURL, apiKey string, opts ...Option) (*Client, error)

NewClient builds a client for baseURL. apiKey may be empty for endpoints that need no credential (Ollama and friends), in which case no Authorization header is sent -- including one inherited from the process environment.

func (*Client) ProbeToolCalling

func (c *Client) ProbeToolCalling(ctx context.Context, model string) error

ProbeToolCalling reports whether model can call tools at all. Tool calling is a prerequisite, not a nice-to-have: the loop is built on it, so a model that only produces prose fails here rather than later on real work.

func (*Client) Run

func (c *Client) Run(ctx context.Context, req RunRequest) (*RunResult, error)

Run drives the tool loop until the model answers without calling a tool. Tool calls are executed sequentially in the order the model emitted them and their results are fed back as tool messages; a tool that fails yields "error: <msg>" rather than ending the run, because the model can recover from a bad argument and cannot recover from a dropped turn.

Every failure that came from the endpoint is opaque: the base URL and the key never appear in a returned error, so a caller that surfaces the error to a browser does not turn the endpoint into a reachability oracle.

type Option

type Option func(*config)

Option configures a Client at construction time. Options never read the environment; every value reaching the wire is supplied by the caller.

func WithHTTPClient

func WithHTTPClient(hc *http.Client) Option

WithHTTPClient supplies the transport. The endpoint is caller-configured, so the SSRF guard, timeout and redirect policy belong to the caller's client.

func WithMaxResponseBytes

func WithMaxResponseBytes(n int64) Option

WithMaxResponseBytes sets the cap on one decompressed response body. Must be positive; the default is 1 MiB.

func WithUserAgent

func WithUserAgent(ua string) Option

WithUserAgent overrides the SDK's User-Agent header.

type RunRequest

type RunRequest struct {
	Model         string
	System        string
	Prompt        string
	Tools         []Tool
	MaxTokens     int64
	MaxIterations int
	Temperature   float64
}

RunRequest is one agent run. MaxTokens is required: leaving the budget to the upstream default is what truncates a reply mid-object.

type RunResult

type RunResult struct {
	Text       string
	Iterations int
	ToolCalls  int
}

RunResult is a completed run: the final assistant text with inline reasoning removed, plus what it cost in rounds and tool invocations.

type Skill

type Skill struct {
	Name        string
	Description string
	Body        string
}

Skill is one markdown instruction file: YAML-ish frontmatter delimited by "---" lines carrying single-line name and description keys, then a body.

func LoadSkills

func LoadSkills(fsys fs.FS, dir string) ([]Skill, error)

LoadSkills reads every *.md file directly under dir; subdirectories are not walked. A file with no parseable frontmatter, no name, no description, or a name already claimed by an earlier file is an error naming every offender -- a broken skill file must fail loudly rather than vanish from the advertisement. Nothing is returned alongside such an error.

A missing dir is returned wrapped; callers that treat an absent user directory as "no skills" check errors.Is(err, fs.ErrNotExist).

The result is sorted by name so the advertisement is stable regardless of how the filesystem orders entries.

func MergeSkills

func MergeSkills(base, overrides []Skill) []Skill

MergeSkills layers overrides onto base by name -- an override replaces the base entry entirely, it is not merged field by field. The result is sorted by name and shares no backing array with either input.

type Tool

type Tool struct {
	Name        string
	Description string
	InputSchema map[string]any
	Run         func(ctx context.Context, input json.RawMessage) (string, error)
}

Tool is one function the model may call. InputSchema is a JSON Schema object describing the arguments; Run receives the raw arguments the model produced, which are untrusted and may not match the schema.

func LoadSkillTool

func LoadSkillTool(skills []Skill) Tool

LoadSkillTool returns the built-in tool that hands a skill body to the model. The catalogue is snapshotted, so mutating the caller's slice afterwards does not change what the tool serves. A miss is an error whose message lists the available names; the run loop feeds tool errors back to the model, so the model can correct itself without the loop aborting.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL