Skip to content

Repository files navigation

ezai

A small, provider-agnostic agent runtime for Go.

Define a tool as an ordinary Go function. ezai derives its JSON schema from the argument struct, runs the model loop, dispatches tool calls in parallel, and emits a typed event at every step so runs can be traced and costed.

go get github.com/0xACE3/ezai

Quick start

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/0xACE3/ezai"
	"github.com/0xACE3/ezai/ctxb"
	"github.com/0xACE3/ezai/provider"
)

// Tool arguments are a plain struct. The schema sent to the model is derived
// from it: `desc` documents a field, `enum` constrains it, and `omitempty`
// marks it optional.
type WeatherArgs struct {
	City  string `json:"city" desc:"City to look up"`
	Units string `json:"units,omitempty" desc:"Temperature units" enum:"c,f"`
}

func main() {
	weather := ezai.NewTool("get_weather", "Current weather for a city",
		func(ctx context.Context, in WeatherArgs) (string, error) {
			return fmt.Sprintf("%s: 22C, clear", in.City), nil
		})

	agent, err := ezai.NewAgent(ezai.Agent{
		Name:     "assistant",
		Model:    ezai.OpenRouter.ClaudeSonnet4_6,
		Provider: provider.NewOpenRouter(""), // falls back to OPENROUTER_API_KEY
		Context:  ctxb.NewBuilder(ctxb.New("You are a concise assistant.")),
		Tools:    ezai.Tools(weather),
	})
	if err != nil {
		log.Fatal(err)
	}

	res, err := agent.Run(context.Background(), ezai.Job{
		Input: "What's the weather in Lagos?",
	})
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(res.Text)
	fmt.Printf("%d tokens, %d tool calls\n", res.Usage.Total, len(res.Calls))
}

The loop

Agent.Run builds context, then iterates until the model stops asking for tools or MaxLoops is reached. Each pass:

  1. Assembles messages through the ContextBuilder and resolves any lazy content references.
  2. Calls the provider.
  3. Compacts history if input tokens crossed the configured threshold.
  4. If the completion was truncated, appends a continuation turn and loops again.
  5. Otherwise runs every returned tool call in parallel and feeds the results back.

Token usage accumulates across the whole run, not just the final call.

Tools

NewTool is generic over the argument type. The JSON schema is generated from the struct by reflection, and arguments are parsed and validated before your function is called, so the function body only deals with typed input.

type SearchArgs struct {
	Query string `json:"query" desc:"Search query"`
	Limit int    `json:"limit,omitempty" desc:"Max results"`
}

search := ezai.NewTool("search", "Search the index",
	func(ctx context.Context, in SearchArgs) (string, error) {
		return index.Query(ctx, in.Query, in.Limit)
	})

Tools are parallel by default. A tool that fails returns its error to the model as text rather than aborting the run, letting the model recover.

Agents as tools

Any agent can be exposed as a tool, which is how multi-agent setups are composed:

researcher, _ := ezai.NewAgent(ezai.Agent{Name: "researcher", /* ... */})

supervisor, _ := ezai.NewAgent(ezai.Agent{
	Name:  "supervisor",
	Tools: ezai.Tools(researcher.AsTool(), search),
	// ...
})

Context

ctxb.Builder assembles each run's message list in a fixed order: system prompt, skills, user memory, conversation history, then the current input.

cb := ctxb.NewBuilder(ctxb.Markdown().
		Section("role", "You are a research assistant.").
		Section("rules", "Cite every claim.")).
	WithMemory(store).
	WithSkills("skills/research.md").
	WithCompaction(120_000, 0.8, 200_000)

WithCompaction sets the token ceiling at which history is summarized mid-run. On(trigger, skips...) selectively omits memory, skills or user memory for a given trigger.

Observability

A Hook receives a typed Event for every stage. run_done is emitted from a deferred call, so it fires even on error, and hooks are recovered from — a panicking hook cannot take down a run.

Kind Emitted when Carries
run_start run begins input
llm_start before each provider call outbound messages
llm_done after each provider call usage, content
tool_start before a tool executes tool name, call ID, args
tool_done after a tool executes tool name, call ID, output
compact history summarized token count and limit
run_done run ends, success or failure result, error

Every event carries the run, session and user IDs, the elapsed time, and full model metadata including per-million-token input, output and cache costs, so spend can be attributed per run:

Hook: ezai.Hooks(
	func(ctx context.Context, e ezai.Event) {
		if e.Kind == ezai.KindLLMDone {
			cost := float64(e.Usage.Input)/1e6*e.ModelInfo.InputCost +
				float64(e.Usage.Output)/1e6*e.ModelInfo.OutputCost
			metrics.Record(e.Agent, cost, e.Took)
		}
	},
	tracer.Hook,
),

Providers

OpenAI, OpenRouter and xAI ship in provider. Each supports Complete, streaming Stream over SSE, and Embed. Malformed-JSON responses are retried once before failing, and errors include a truncated body prefix for debugging.

provider.NewOpenAI("")                                  // OPENAI_API_KEY
provider.NewOpenRouter("").WithApp("myapp")             // OPENROUTER_API_KEY
provider.NewXAI("")                                     // XAI_API_KEY

Implementing schema.Provider adds a new backend without touching the loop.

Models

ezai.OpenRouter and ezai.XAI are typed registries carrying context windows, output limits, capability flags and pricing, so cost and context budgeting come from the model value itself:

m := ezai.OpenRouter.ClaudeSonnet4_6
m.Context      // 1_000_000
m.InputCost    // 3.0 per 1M tokens
m.HasCap("tools")

Status

Early and under active development. The API may change.

License

MIT — see LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages