Skip to content

Latest commit

 

History

15 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

gage

gage is a provider-agnostic Go toolkit for building agentic systems. It is a library — you import it into your own program; it never starts a server or owns a main. Everything streams end to end.

It gives you, behind clean interfaces:

  • LLM providers: Anthropic (API key), OpenRouter, vLLM, Ollama, Codex (ChatGPT/Codex plan via OAuth) and Claude Code (Claude plan via OAuth) — with reasoning round-trip (signed thinking blocks), prompt caching (cache_control), and structured output (JSON mode / JSON Schema).
  • Built-in tools: read, write, edit, bash, grep, glob, list_dir, webfetch, websearch — plus tools.Typed[T] to define your own tools from plain Go structs (schema generated by reflection).
  • MCP: connect to Model Context Protocol servers (stdio or streamable HTTP, with header/bearer auth): tools (with live tools/list_changed sync), resources, prompts, and sampling backed by any gage.Provider.
  • Skills: load Claude Code–style SKILL.md folders.
  • Memory: a MemoryStore port, in-memory adapter, and memory_remember/memory_recall/memory_forget tools.
  • Agents: an agentic loop with tool execution (sequential or parallel), permissions (allow/deny/rewrite/remember), JSON Schema tool-input validation, hooks, context compaction, sub-agents, aggregated usage, and a terminal Result.
  • Production guards: secure policy helpers, per-tool timeouts, panic recovery, structured observations, concurrency and result-size wrappers, SSRF-pinned webfetch, sandboxable/sanitized bash, root-confined filesystem tools, encrypted file stores, and durable workflow checkpoints.
  • HTTP: an SSE http.Handler to expose an agent (you mount it).

Architecture

gage is built as hexagonal ports & adapters. The root package gage holds the domain types and the ports (interfaces); everything else is an adapter that depends on the core, never the reverse.

gage/                 core: Message, Event, Usage, Result + ports (Provider, Tool, Approver, Compactor, ...)
├── providers/        Provider adapters (anthropic, openrouter, vllm, ollama, codex, claudecode)
│   ├── shared/       HTTP client, SSE parser, OAuth (PKCE, token source, stores)
│   ├── openai/       reusable Chat Completions + Responses wire formats
│   └── anthropic/    reusable Messages wire format + API-key provider
├── tools/            built-in tools, Typed[T], registry + permission/limit decorators
├── policy/           conservative Approver policies for secure defaults
├── search/           SearchProvider impls (duckduckgo, brave, tavily)
├── mcp/              MCP client → gage.Tool bridge (+ resources, prompts, sampling)
├── skills/           SKILL.md loader + the "skill" tool
├── memory/           in-memory MemoryStore + memory tools
├── workflow/         durable run/checkpoint persistence around an agent
├── jsonschema/       small JSON Schema builder for tool parameters
├── agent/            the agentic loop, hooks, compactors, sub-agents
└── httpx/            SSE handler (no server)

The two central contracts:

// A model backend.
type Provider interface {
    Stream(ctx context.Context, req Request) (<-chan Event, error)
    Name() string
}

// An executable capability the model can call.
type Tool interface {
    Name() string
    Description() string
    Schema() JSONSchema
    Execute(ctx context.Context, input json.RawMessage) (ToolResult, error)
}

A Provider streams a channel of unified Events (text_delta, reasoning_delta, tool_call_*, usage, message_done, ...). The agent relays those events, runs the requested tools, feeds results back, and repeats until a final answer — emitting a terminal done event.

Install

go get github.com/deepteams/gage

Requires Go 1.26+.

Quick start

package main

import (
    "context"
    "fmt"

    "github.com/deepteams/gage"
    "github.com/deepteams/gage/agent"
    "github.com/deepteams/gage/tools"
    "github.com/deepteams/gage/providers/openrouter"
)

func main() {
    // 1. A provider.
    provider := openrouter.New("sk-or-...", openrouter.WithDefaultModel("anthropic/claude-sonnet-4.5"))

    // 2. A tool registry with built-in tools confined to a working dir.
    reg := tools.NewRegistry()
    reg.MustRegister(tools.NewFSTools(tools.FSConfig{Root: "."})...)
    reg.MustRegister(tools.NewSearchTools(tools.FSConfig{Root: "."})...)
    reg.MustRegister(tools.NewBashTool(tools.BashConfig{Dir: "."}))

    // 3. An agent.
    ag, _ := agent.New(agent.Config{
        Provider: provider,
        Registry: reg,
        System:   "You are a coding assistant. Use tools to inspect the repo.",
    })

    // 4. Stream the run.
    stream, _ := ag.Run(context.Background(), []gage.Message{
        gage.UserText("List the Go files and summarize the project."),
    })
    for ev := range stream {
        switch ev.Type {
        case gage.EventTextDelta:
            fmt.Print(ev.Text)
        case gage.EventToolResult:
            fmt.Printf("\n[tool %s]\n", ev.ToolResult.Text())
        }
    }
}

Providers

Provider Constructor Auth
Anthropic anthropic.New(anthropic.Config{APIKey: ...}) API key
OpenRouter openrouter.New(apiKey, ...) API key
vLLM vllm.New(baseURL, ...) optional key
Ollama ollama.New(baseURL, ...) none (local)
Codex codex.New(store, ...) OAuth (ChatGPT/Codex plan)
Claude Code claudecode.New(store, console, ...) OAuth (Claude plan)

All implement gage.Provider, so they are interchangeable in agent.Config.

Generation options are uniform, and providers fail fast with gage.ErrUnsupported instead of silently dropping an option they cannot honor:

Options: gage.ApplyOptions(gage.GenerateOptions{},
    gage.WithJSONSchema("report", schema), // structured output
    gage.WithPromptCache(),                // cache_control breakpoints (Anthropic)
    gage.WithReasoningEffort(gage.ReasoningHigh),
),

Reasoning/thinking blocks are preserved across turns: providers emit reasoning_done events carrying an opaque signature, the agent replays them in history, and encoders reattach them (required for Anthropic extended thinking with tool use).

OAuth providers (Codex, Claude Code)

⚠️ Heads-up. Codex and Claude Code here authenticate against undocumented backend endpoints using the OAuth "plan" flow, presenting themselves as the official CLIs. These endpoints are not a public API, can change without notice, and their use is subject to the respective providers' terms. Use at your own risk.

You supply a gage.TokenStoreyou own how tokens are persisted. gage provides an optional file/memory store in providers/shared/oauth, but any implementation works (database, keychain, secret manager):

type TokenStore interface {
    Load(ctx context.Context) (Credentials, error)
    Save(ctx context.Context, c Credentials) error
}

Log in once to populate the store, then construct the provider:

store := oauth.NewFileStore("/secure/path/codex.json") // or your own TokenStore

// One-time login (opens a browser to the auth URL).
_, err := codex.Login(ctx, store, func(url string) { browser.Open(url) })

// Use it — tokens refresh transparently through the store.
provider := codex.New(store)

Claude Code uses a copy-paste redirect flow:

authURL, complete, _ := claudecode.Login(false)
fmt.Println("Visit:", authURL)
// user pastes the returned "code#state"
creds, _ := complete(ctx, store, pasted)
provider := claudecode.New(store, false)

Tools & permissions

Register the built-ins you want, or define your own from a plain struct with tools.Typed — the JSON Schema is generated by reflection and inputs are unmarshaled for you:

type WeatherArgs struct {
    City string `json:"city" desc:"City name"`
    Unit string `json:"unit,omitempty" desc:"Unit" enum:"celsius,fahrenheit"`
}

reg.MustRegister(tools.Typed("weather", "Get the current weather.",
    func(ctx context.Context, a WeatherArgs) (gage.ToolResult, error) {
        return gage.TextResult("", fetchWeather(a.City, a.Unit)), nil
    }))

Gate every execution behind an approver. An approval can carry a denial reason (shown to the model), a rewritten input, and a "remember" flag:

import "github.com/deepteams/gage/policy"

// Secure allows local read-only filesystem tools and pauses network, shell,
// writes, MCP, memory mutations and unknown tools for out-of-band approval.
approver := policy.Secure()

Or provide your own application policy:

approver := gage.ApproverFunc(func(ctx context.Context, r gage.PermissionRequest) (gage.Approval, error) {
    if r.Metadata.ReadOnly {
        return gage.Approval{Allow: true, Remember: true}, nil
    }
    // Your app decides how to ask the user or apply policy. r includes:
    // Tool, Input, Agent, RunID, Turn, Metadata, and Summary.
    return askUserForApproval(ctx, r.Summary)
})
// gage.RememberingPerInput caches remembered decisions by tool+arguments.
ag, _ := agent.New(agent.Config{Provider: p, Registry: reg, Approver: gage.RememberingPerInput(approver)})

The agent validates tool arguments against each tool's JSON Schema before execution by default. Set agent.Config.DisableToolInputValidation only when you deliberately need raw, schema-incompatible inputs.

gage.Remembering is also available when you deliberately want broad caching by tool name. For write, shell, network, and other argument-sensitive tools, prefer RememberingPerInput or RememberingBy with an app-specific key.

Built-in tools expose advisory ToolMetadata (ReadOnly, Filesystem, Network, Shell, Destructive, RequiresApproval, Tags) and concise call summaries. Custom tools can implement gage.ToolMetadataProvider / gage.ToolCallDescriber, or use tools.FuncWithMetadata.

For production use, add a per-tool timeout and an observer for audit logs, metrics, or traces:

observer := agent.ObserverFunc(func(ctx context.Context, o agent.Observation) {
    log.Printf("run=%s type=%s tool=%s error=%v duration=%s",
        o.RunID, o.Type, o.Tool, o.IsError, o.Duration)
})

ag, _ := agent.New(agent.Config{
    Provider:    p,
    Registry:    reg,
    Approver:    approver,
    ToolTimeout: 30 * time.Second,
    Observer:    observer,
})

Tool panics are recovered and returned to the model as failed tool results. tools.LimitConcurrency caps concurrent executions of expensive tools, and tools.LimitResultSize keeps oversized results (MCP, custom tools) from blowing the context window:

reg.MustRegister(tools.LimitConcurrency(tools.NewBashTool(tools.BashConfig{Dir: "."}), 2))
reg.MustRegister(tools.LimitResultSize(myTool, 64<<10))

The agent loop

agent.Config controls the loop end to end:

ag, _ := agent.New(agent.Config{
    Provider:         provider,
    Registry:         reg,
    MaxParallelTools: 4,                    // run a turn's tool calls concurrently
    Compactor:        agent.Summarize(provider, "", 20), // or agent.Trim(30)
    CompactAfter:     150_000,              // input-token threshold
    Hooks: agent.Hooks{
        PreToolUse: func(ctx context.Context, tc gage.ToolCall) (gage.ToolCall, error) {
            return tc, nil // rewrite the input, or return an error to block
        },
        PostToolUse: func(ctx context.Context, tc gage.ToolCall, res gage.ToolResult) gage.ToolResult {
            return redact(res) // rewrite results before the model sees them
        },
    },
})

Every run ends with a terminal done event carrying a gage.Result — the full conversation, final text, stop reason, aggregated usage, and turn count — so multi-run conversations need no event bookkeeping. ag.RunSync(ctx, msgs) is the blocking shortcut when you don't need the stream.

websearch needs a SearchProvider. DuckDuckGo needs no key:

reg.MustRegister(tools.NewWebTools(tools.WebConfig{Search: duckduckgo.New()})...)

brave.New(key) and tavily.New(key) are drop-in alternatives.

webfetch blocks localhost, private, link-local, multicast and unspecified addresses by default, including redirects. For trusted local/internal use only, set tools.WebConfig{AllowPrivateHosts: true}.

bash sanitizes the environment, applies time/output limits, and kills the process group on timeout, but direct shell execution is not an operating-system sandbox. For untrusted commands, require an external sandbox:

bash := tools.NewBashTool(tools.BashConfig{
    RequireSandbox: true,
    Sandbox: tools.ExternalSandbox{
        Label:  "firejail",
        Binary: "firejail",
        Args:   []string{"--private", "--", "{{shell}}", "-c", "{{command}}"},
    },
})

Memory

import "github.com/deepteams/gage/memory"

mem := memory.New()
reg.MustRegister(memory.NewTools(mem)...)

Memories carry optional namespace, user_id, provenance, sensitivity, confidence, expires_at, and metadata fields. Recall can be scoped by namespace/user and hides expired memories by default. The built-in store uses simple keyword recall (or cosine similarity with memory.NewWithEmbedder) and is useful for tests and local agents. Production systems can implement gage.MemoryStore with a database, vector index, user-profile service, or tenant-scoped memory layer without changing the agent loop.

Durable Sessions

sessions.NewFileStore writes atomic 0600 JSON files. For local encrypted storage, use AES-GCM:

store, _ := sessions.NewEncryptedFileStore("./sessions", key32Bytes)

OAuth helpers have the same option:

tokenStore, _ := oauth.NewEncryptedFileStore("./tokens/codex.json", key32Bytes)

workflow.Runner wraps an agent with a SessionStore: completed runs persist their full conversation, and paused approval checkpoints are saved so another process can resume later.

runner := workflow.New(ag, store)
out, err := runner.Run(ctx, "session-1", []gage.Message{gage.UserText("ship it")})
if errors.Is(err, gage.ErrApprovalPending) {
    // Persisted already; collect decisions, then:
    out, err = runner.Resume(ctx, "session-1", decisions)
}

MCP

client, _ := mcp.ConnectStdio(ctx, mcp.StdioConfig{
    Name: "fs", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-filesystem", "/data"},
},
    mcp.WithToolSync(reg),              // keep the registry in sync on tools/list_changed
    mcp.WithSamplingProvider(provider), // let the server sample through your Provider
)
defer client.Close()
client.Register(ctx, reg) // tools appear as "fs__<tool>"

resources, _ := client.Resources(ctx)          // list server resources
parts, _ := client.ReadResource(ctx, uri)      // text and images as ContentParts
msgs, _ := client.GetPrompt(ctx, "review", nil) // server prompts as []gage.Message

HTTP servers with auth:

client, _ := mcp.ConnectHTTP(ctx, mcp.HTTPConfig{
    Name: "api", Endpoint: "https://mcp.example.com",
    Headers: mcp.BearerHeaders(token),
})

Skills

set, _ := skills.LoadDir("./skills")   // folders each holding a SKILL.md
reg.MustRegister(skills.NewTool(set))  // the model can load a skill on demand
ag, _ := agent.New(agent.Config{Provider: p, Registry: reg, Skills: set})

Sub-agents

Any agent can be exposed as a tool for another agent:

researcher, _ := agent.New(agent.Config{Provider: p, Registry: researchReg, Name: "researcher"})
reg.MustRegister(researcher.AsTool("researcher", "Delegate research tasks."))

Serving an agent over HTTP (SSE)

gage gives you the handler; you mount and serve it:

h := httpx.StreamHandler(ag, func(r *http.Request) ([]gage.Message, error) {
    var body struct{ Prompt string `json:"prompt"` }
    if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
        return nil, err
    }
    return []gage.Message{gage.UserText(body.Prompt)}, nil
})
http.Handle("/agent", h)
http.ListenAndServe(":8080", nil) // your app owns this

Testing

Everything is tested against httptest/in-memory transports — no network access:

go test ./... -race

License

MIT. See LICENSE.

About

No description, website, or topics provided.

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages