agentkit

package module
v0.1.2 Latest Latest
Warning

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

Go to latest
Published: Sep 22, 2026 License: MIT Imports: 9 Imported by: 0

README

agentkit

Go Reference test

An agent loop in Go, as a library: a model, Go tools and MCP servers. The Agent holds the provider and the tools; each Conversation holds its own history, and a Send runs the model and its tools until the final answer.

What it does, and what it leaves to you

agentkit does:

  • the loop: model call, tool calls (in parallel), results back to the model, until an answer without tool calls or MaxSteps;
  • streaming, interruption (a new Send cuts the running one) and hooks on every step;
  • providers for the main APIs and any OpenAI-compatible server, with retries on transient errors;
  • MCP servers over stdio, SSE or streamable HTTP, whose tools sit next to your Go tools.

agentkit does not do long-term memory, persistence of conversations, prompt templates or configuration files. It holds no global state and writes no file: keep Conversation.Messages() wherever you like and pass it back to NewConversation.

Install

go get github.com/ThiraSoft/agentkit

Requires Go 1.25 or later.

Example

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"time"

	"github.com/ThiraSoft/agentkit"
	"github.com/ThiraSoft/agentkit/llm"
)

func main() {
	ctx := context.Background()

	agent, err := agentkit.New(ctx, agentkit.Config{
		Provider: "gemini",
		Model:    "gemini-2.5-flash-lite", // key: GEMINI_API_KEY
		Tools: []agentkit.Tool{{
			Name:        "clock",
			Description: "Tells the time.",
			Parameters:  llm.ToolParams{Type: "object", Properties: llm.ToolProperties{}},
			Run: func(ctx context.Context, args json.RawMessage) (string, error) {
				return time.Now().Format("15:04"), nil
			},
		}},
	})
	if err != nil {
		log.Fatal(err)
	}
	defer agent.Close()

	conv := agent.NewConversation("Answer in one sentence.")
	turn, err := conv.Send(ctx, "What time is it?", agentkit.Hooks{
		OnText: func(chunk string) { fmt.Print(chunk) },
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("\n(%d step(s))\n", turn.Steps)
}

Providers

Set Config.Provider and Config.Model. APIKey and BaseURL in the config take precedence over the environment variable and the default URL.

Provider Key from Default URL
openai OPENAI_API_KEY https://api.openai.com/v1
gemini GEMINI_API_KEY https://generativelanguage.googleapis.com/v1beta
anthropic ANTHROPIC_API_KEY https://api.anthropic.com/v1
mistral MISTRAL_API_KEY https://api.mistral.ai/v1
ollama none http://localhost:11434
llamacpp none LLAMACPP_URL (without /v1), or http://localhost:8080; if using BaseURL, include /v1 (e.g. http://localhost:8080/v1)
openai-compat Config.APIKey none: BaseURL is required, e.g. http://localhost:8000/v1

Your own provider: implement llm.Provider and pass it as Config.ProviderImpl. For a server that speaks the OpenAI chat/completions format but needs its own settings (headers, extra body fields, timeout), start from llm.NewOpenAICompat and wrap it with llm.WithRetry.

Hooks

Hooks are all optional and run in the goroutine of Send:

  • OnText(chunk): streamed text;
  • OnToolCall(call) and OnToolResult(result): each tool call and its result, the results as the tools finish (the history keeps them in the order of the calls);
  • Approve(call) bool: return false to refuse a call; the model is told;
  • OnStepEnd(): the model finished a message that asks for tools;
  • OnFinish(): the answer is complete; it may block (for instance while a voice finishes speaking);
  • Prepare(msgs) msgs: rewrite what is sent to the model at each step, without touching the history;
  • OnInterrupt(written) kept: choose what stays in the history when a turn is cut.

MCP

agent, err := agentkit.New(ctx, agentkit.Config{
	Provider: "openai-compat",
	BaseURL:  "http://localhost:8080/v1",
	Model:    "local",
	MCP: []mcp.ServerConfig{
		{Name: "files", Transport: "stdio", Command: "/usr/local/bin/files-mcp --root /tmp"},
		{Name: "search", Transport: "streamable", URL: "https://mcp.example.com/mcp",
			Headers: map[string]string{"Authorization": "Bearer ${SEARCH_TOKEN}"}},
	},
})

New fails if a server does not answer, or if two tools share a name. The mcp package can also be used alone: mcp.Dial for one server, mcp.NewManager for a list.

Stability

agentkit is v0: the API may change before v1. Changes are listed in the release notes.

Tests

go test -race ./...

Integration tests talk to real models and are behind a build tag:

GEMINI_API_KEY=... go test -tags integration . -run TestGemini
AGENTKIT_OPENAI_URL=http://localhost:8080/v1 AGENTKIT_OPENAI_MODEL=local \
  go test -tags integration . -run TestOpenAICompat

License

MIT, see LICENSE.

Documentation

Overview

Package agentkit is an agent loop as a library: a provider, Go tools and MCP servers, with no files and no global state. The Agent holds what is costly; each Conversation holds its own history.

Example

An agent with one Go tool. A real program sets Provider and Model (for instance "gemini" and "gemini-2.5-flash-lite") instead of ProviderImpl.

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"

	"github.com/ThiraSoft/agentkit"
	"github.com/ThiraSoft/agentkit/llm"
)

// scripted stands in for a model in these examples: it asks for the clock
// tool, then answers with what the tool returned.
type scripted struct{}

func (scripted) Name() string      { return "scripted" }
func (scripted) ModelName() string { return "scripted" }

func (s scripted) Chat(ctx context.Context, msgs []llm.Message, tools []llm.Tool) (*llm.Message, error) {
	return s.Stream(ctx, msgs, tools, func(string) error { return nil })
}

func (scripted) Stream(_ context.Context, msgs []llm.Message, _ []llm.Tool, onChunk func(string) error) (*llm.Message, error) {
	last := msgs[len(msgs)-1]
	if last.Role != "tool" {
		return &llm.Message{Role: "assistant", ToolCalls: []llm.ToolCall{{
			ID:       "1",
			Type:     "function",
			Function: llm.FunctionCall{Name: "clock", Arguments: json.RawMessage(`{}`)},
		}}}, nil
	}
	text := "It is " + last.Content + "."
	if err := onChunk(text); err != nil {
		return nil, err
	}
	return &llm.Message{Role: "assistant", Content: text}, nil
}

var clock = agentkit.Tool{
	Name:        "clock",
	Description: "Tells the time.",
	Parameters:  llm.ToolParams{Type: "object", Properties: llm.ToolProperties{}},
	Run: func(ctx context.Context, args json.RawMessage) (string, error) {
		return "12:00", nil
	},
}

func main() {
	ctx := context.Background()
	agent, err := agentkit.New(ctx, agentkit.Config{
		ProviderImpl: scripted{},
		Tools:        []agentkit.Tool{clock},
	})
	if err != nil {
		log.Fatal(err)
	}
	defer agent.Close()

	conv := agent.NewConversation("Answer in one sentence.")
	turn, err := conv.Send(ctx, "What time is it?", agentkit.Hooks{
		OnText: func(chunk string) { fmt.Println("text:", chunk) },
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("steps:", turn.Steps)
	fmt.Println("reply:", turn.Text)
}
Output:
text: It is 12:00.
steps: 2
reply: It is 12:00.

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrMaxSteps = errors.New("agentkit: maximum number of steps reached")

ErrMaxSteps is returned when a turn reaches MaxSteps model calls without finishing.

Functions

This section is empty.

Types

type Agent

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

Agent brings together a provider and tools. It holds no conversation state and serves multiple Conversations concurrently.

func New

func New(ctx context.Context, cfg Config) (*Agent, error)

New builds the provider, connects MCP servers and validates tools. It fails rather than falling back to something else.

func (*Agent) Close

func (a *Agent) Close() error

Close disconnects MCP servers.

func (*Agent) NewConversation

func (a *Agent) NewConversation(system string, history ...llm.Message) *Conversation

NewConversation starts a conversation, optionally with existing history (without system prompt).

func (*Agent) Tools

func (a *Agent) Tools() []string

Tools returns the tool names seen by the model, in the order it sees them: Go tools first, then MCP servers.

Example
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"

	"github.com/ThiraSoft/agentkit"
	"github.com/ThiraSoft/agentkit/llm"
)

// scripted stands in for a model in these examples: it asks for the clock
// tool, then answers with what the tool returned.
type scripted struct{}

func (scripted) Name() string      { return "scripted" }
func (scripted) ModelName() string { return "scripted" }

func (s scripted) Chat(ctx context.Context, msgs []llm.Message, tools []llm.Tool) (*llm.Message, error) {
	return s.Stream(ctx, msgs, tools, func(string) error { return nil })
}

func (scripted) Stream(_ context.Context, msgs []llm.Message, _ []llm.Tool, onChunk func(string) error) (*llm.Message, error) {
	last := msgs[len(msgs)-1]
	if last.Role != "tool" {
		return &llm.Message{Role: "assistant", ToolCalls: []llm.ToolCall{{
			ID:       "1",
			Type:     "function",
			Function: llm.FunctionCall{Name: "clock", Arguments: json.RawMessage(`{}`)},
		}}}, nil
	}
	text := "It is " + last.Content + "."
	if err := onChunk(text); err != nil {
		return nil, err
	}
	return &llm.Message{Role: "assistant", Content: text}, nil
}

var clock = agentkit.Tool{
	Name:        "clock",
	Description: "Tells the time.",
	Parameters:  llm.ToolParams{Type: "object", Properties: llm.ToolProperties{}},
	Run: func(ctx context.Context, args json.RawMessage) (string, error) {
		return "12:00", nil
	},
}

func clockAgent() *agentkit.Agent {
	agent, err := agentkit.New(context.Background(), agentkit.Config{
		ProviderImpl: scripted{},
		Tools:        []agentkit.Tool{clock},
	})
	if err != nil {
		log.Fatal(err)
	}
	return agent
}

func main() {
	agent := clockAgent()
	defer agent.Close()
	fmt.Println(agent.Tools())
}
Output:
[clock]

type Config

type Config struct {
	Provider string // "gemini", "openai-compat", "anthropic", "llamacpp"...
	Model    string
	BaseURL  string // full base URL of provider, e.g. https://generativelanguage.googleapis.com/v1beta for Gemini, http://host:port/v1 for an OpenAI-compatible server (empty = default)
	APIKey   string // empty = provider environment variable

	ProviderImpl llm.Provider

	Tools []Tool
	MCP   []mcp.ServerConfig

	MaxSteps      int // model calls per Send, 20 by default
	MaxToolResult int // bytes kept from a tool result, 32 KB by default
}

Config describes an agent. ProviderImpl, if non-nil, overrides Provider, Model, BaseURL and APIKey.

type Conversation

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

Conversation holds history, including the system prompt, and has only one active turn: a Send during another interrupts the first.

func (*Conversation) Messages

func (c *Conversation) Messages() []llm.Message

Messages returns a copy of the history.

Example
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"strings"

	"github.com/ThiraSoft/agentkit"
	"github.com/ThiraSoft/agentkit/llm"
)

// scripted stands in for a model in these examples: it asks for the clock
// tool, then answers with what the tool returned.
type scripted struct{}

func (scripted) Name() string      { return "scripted" }
func (scripted) ModelName() string { return "scripted" }

func (s scripted) Chat(ctx context.Context, msgs []llm.Message, tools []llm.Tool) (*llm.Message, error) {
	return s.Stream(ctx, msgs, tools, func(string) error { return nil })
}

func (scripted) Stream(_ context.Context, msgs []llm.Message, _ []llm.Tool, onChunk func(string) error) (*llm.Message, error) {
	last := msgs[len(msgs)-1]
	if last.Role != "tool" {
		return &llm.Message{Role: "assistant", ToolCalls: []llm.ToolCall{{
			ID:       "1",
			Type:     "function",
			Function: llm.FunctionCall{Name: "clock", Arguments: json.RawMessage(`{}`)},
		}}}, nil
	}
	text := "It is " + last.Content + "."
	if err := onChunk(text); err != nil {
		return nil, err
	}
	return &llm.Message{Role: "assistant", Content: text}, nil
}

var clock = agentkit.Tool{
	Name:        "clock",
	Description: "Tells the time.",
	Parameters:  llm.ToolParams{Type: "object", Properties: llm.ToolProperties{}},
	Run: func(ctx context.Context, args json.RawMessage) (string, error) {
		return "12:00", nil
	},
}

func clockAgent() *agentkit.Agent {
	agent, err := agentkit.New(context.Background(), agentkit.Config{
		ProviderImpl: scripted{},
		Tools:        []agentkit.Tool{clock},
	})
	if err != nil {
		log.Fatal(err)
	}
	return agent
}

func main() {
	agent := clockAgent()
	defer agent.Close()

	conv := agent.NewConversation("Answer in one sentence.")
	if _, err := conv.Send(context.Background(), "What time is it?", agentkit.Hooks{}); err != nil {
		log.Fatal(err)
	}
	var roles []string
	for _, m := range conv.Messages() {
		roles = append(roles, m.Role)
	}
	fmt.Println(strings.Join(roles, " "))
}
Output:
system user assistant tool assistant

func (*Conversation) Reset

func (c *Conversation) Reset()

Reset interrupts the active turn if any, then clears all messages except the system prompt.

Calling Send or Reset from within a hook of the active turn will deadlock; Messages and SetSystem remain permitted.

func (*Conversation) Send

func (c *Conversation) Send(ctx context.Context, text string, h Hooks) (Turn, error)

Send appends text to the history and runs the model and its tools until a response without tool calls is produced. Any active Send is interrupted first, and the new turn sees whatever remains of it.

Calling Send or Reset from within a hook of the active turn will deadlock; Messages and SetSystem remain permitted.

func (*Conversation) SetSystem

func (c *Conversation) SetSystem(system string)

SetSystem replaces the system prompt without modifying the rest.

type Hooks

type Hooks struct {
	OnText       func(chunk string)
	OnToolCall   func(ToolCall)
	OnToolResult func(ToolResult)
	// OnStepEnd: the model finished a message requesting tool calls.
	OnStepEnd func()
	// OnFinish: the model finished its response, the turn is about to close.
	// It may block (e.g. voice finishing speech); if the turn is interrupted
	// during this time, it is treated as an interruption and OnInterrupt decides
	// what text is kept.
	OnFinish func()
	// Approve: returning false rejects the call; the model receives a rejection.
	Approve func(ToolCall) bool
	// Prepare receives a copy of the history before each model call
	// and returns what will be sent. The copy is shallow: mutating in place
	// nested slice contents (Media, ToolCalls) modifies history; replacing
	// a field or an entire slice has no effect.
	Prepare func(msgs []llm.Message) []llm.Message
	// OnInterrupt receives the text written by the model during an interrupted
	// turn and returns what should be kept in history.
	OnInterrupt func(written string) (kept string)
}

Hooks are called during a Send, within its goroutine. All are optional. A hook that blocks (OnFinish waiting for voice output, Approve waiting for user confirmation) does not see the cancellation triggered by another Send or by Reset: it is up to the caller to unblock whatever it is waiting on when canceling.

Calling Send or Reset from within a hook of the active turn will deadlock; Messages and SetSystem remain permitted.

Example
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"

	"github.com/ThiraSoft/agentkit"
	"github.com/ThiraSoft/agentkit/llm"
)

// scripted stands in for a model in these examples: it asks for the clock
// tool, then answers with what the tool returned.
type scripted struct{}

func (scripted) Name() string      { return "scripted" }
func (scripted) ModelName() string { return "scripted" }

func (s scripted) Chat(ctx context.Context, msgs []llm.Message, tools []llm.Tool) (*llm.Message, error) {
	return s.Stream(ctx, msgs, tools, func(string) error { return nil })
}

func (scripted) Stream(_ context.Context, msgs []llm.Message, _ []llm.Tool, onChunk func(string) error) (*llm.Message, error) {
	last := msgs[len(msgs)-1]
	if last.Role != "tool" {
		return &llm.Message{Role: "assistant", ToolCalls: []llm.ToolCall{{
			ID:       "1",
			Type:     "function",
			Function: llm.FunctionCall{Name: "clock", Arguments: json.RawMessage(`{}`)},
		}}}, nil
	}
	text := "It is " + last.Content + "."
	if err := onChunk(text); err != nil {
		return nil, err
	}
	return &llm.Message{Role: "assistant", Content: text}, nil
}

var clock = agentkit.Tool{
	Name:        "clock",
	Description: "Tells the time.",
	Parameters:  llm.ToolParams{Type: "object", Properties: llm.ToolProperties{}},
	Run: func(ctx context.Context, args json.RawMessage) (string, error) {
		return "12:00", nil
	},
}

func clockAgent() *agentkit.Agent {
	agent, err := agentkit.New(context.Background(), agentkit.Config{
		ProviderImpl: scripted{},
		Tools:        []agentkit.Tool{clock},
	})
	if err != nil {
		log.Fatal(err)
	}
	return agent
}

func main() {
	agent := clockAgent()
	defer agent.Close()

	_, err := agent.NewConversation("").Send(context.Background(), "What time is it?", agentkit.Hooks{
		OnToolCall:   func(c agentkit.ToolCall) { fmt.Println("call:", c.Name, string(c.Arguments)) },
		Approve:      func(c agentkit.ToolCall) bool { return c.Name == "clock" },
		OnToolResult: func(r agentkit.ToolResult) { fmt.Println("result:", r.Content) },
		OnFinish:     func() { fmt.Println("finished") },
	})
	if err != nil {
		log.Fatal(err)
	}
}
Output:
call: clock {}
result: 12:00
finished

type Tool

type Tool struct {
	Name        string
	Description string
	Parameters  llm.ToolParams
	Run         func(ctx context.Context, args json.RawMessage) (string, error)
}

Tool is a tool executed in the caller's process. Run must respect ctx cancellation: cancellation waits for tools to finish. A panic in Run crashes the process (the tool runs in a goroutine).

type ToolCall

type ToolCall struct {
	ID, Name  string
	Arguments json.RawMessage
}

ToolCall is a tool call requested by the model.

type ToolResult

type ToolResult struct {
	ID, Name string
	Content  string
	Err      string
}

ToolResult is what the model receives in response. Err is empty if and only if the tool succeeded.

type Turn

type Turn struct {
	Text        string // assistant text of the turn, joined by double newlines
	Steps       int    // model calls
	Interrupted bool
}

Turn summarizes a Send.

Directories

Path Synopsis
internal
testmcp command
Command testmcp is a minimal stdio MCP server for agentkit tests, with a single tool, get_time.
Command testmcp is a minimal stdio MCP server for agentkit tests, with a single tool, get_time.
Package llm talks to language models behind one interface, Provider (Chat and Stream), with the message and tool types they share.
Package llm talks to language models behind one interface, Provider (Chat and Stream), with the message and tool types they share.
Package mcp connects to Model Context Protocol servers over stdio, SSE or streamable HTTP, and exposes their tools in the llm.Tool format so an agent can call them next to its own tools.
Package mcp connects to Model Context Protocol servers over stdio, SSE or streamable HTTP, and exposes their tools in the llm.Tool format so an agent can call them next to its own tools.

Jump to

Keyboard shortcuts

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