agentkit

package module
v0.4.2 Latest Latest
Warning

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

Go to latest
Published: Sep 24, 2026 License: MIT Imports: 19 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 or prompt templates. It holds no global state and writes no file, unless you turn on its built-in file tools: 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 ; asks for usage with stream_options, which a strict server may refuse (then use llm.NewOpenAICompat)

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.

Config.Temperature and Config.MaxTokens go to every provider built by name, in its own terms (max_completion_tokens for OpenAI, maxOutputTokens for Gemini, num_predict for Ollama). Left unset, the provider's default holds; Anthropic, which requires a cap, gets 32000. A provider given as ProviderImpl, or built with llm.NewOpenAICompat, takes these options itself (ExtraBody for the latter).

Config.PromptCache marks the prompt for caching on Anthropic, which has to be told: the tools and the system prompt, and the conversation as it grows. OpenAI and Gemini cache on their own. Either way, Turn.Usage says how many prompt tokens came from the cache.

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.

Context

agentkit sends the whole history at every step. KeepTurns(n) and KeepTokens(budget) are ready-made Prepare hooks that send only the last turns, or as many as fit in a token budget (a rough four bytes per token), always with the system prompt and the turn under way. They cut at a user message, so a tool call never loses its result, and the history itself stays whole. With PromptCache, a sliding window changes the cached prefix at every turn: only the tools and the system prompt are read back from the cache.

Tools

A Tool describes its arguments with Parameters, or with Schema, a raw JSON Schema that can say more (enums, nested objects, bounds). NewTool writes the schema from a Go type and decodes the arguments into it:

type forecastArgs struct {
	City string `json:"city" jsonschema:"the city to forecast"`
	Days int    `json:"days,omitempty" jsonschema:"how many days, 1 by default"`
}

forecast, err := agentkit.NewTool("forecast", "Weather forecast for a city.",
	func(ctx context.Context, args forecastArgs) (string, error) {
		return lookup(ctx, args.City, args.Days)
	})

A panic in a tool is recovered: the model is told the tool failed.

Usage

Turn.Usage sums the tokens of the model calls of a Send: InputTokens (the whole prompt, cache included), OutputTokens, CacheReadTokens and CacheWriteTokens, as far as the provider reports them. Each message a provider returns carries its own in llm.Message.Usage.

Structured output

Config.ResponseSchema makes the model answer with JSON that follows a schema, which SchemaFor writes from a Go type:

type verdict struct {
	Spam   bool   `json:"spam"`
	Reason string `json:"reason"`
}

schema, err := agentkit.SchemaFor[verdict]()
agent, err := agentkit.New(ctx, agentkit.Config{Provider: "openai", Model: "gpt-5-mini", ResponseSchema: schema})
conv := agent.NewConversation("Classify the message.")
_, err = conv.Send(ctx, text, agentkit.Hooks{})
msgs := conv.Messages()
var v verdict
err = json.Unmarshal([]byte(msgs[len(msgs)-1].Content), &v)

Not every model takes a response schema together with tools. With tools, Turn.Text joins the text of every step; the answer is the last message.

Configuration file

LoadConfig reads a Config from JSON, everything but the Go tools:

{
  "provider": "anthropic",
  "model": "claude-sonnet-5",
  "apiKey": "${ANTHROPIC_API_KEY}",
  "maxTokens": 8192,
  "promptCache": true,
  "mcp": [{"name": "files", "transport": "stdio", "command": "files-mcp --root /tmp"}]
}

The other fields are baseURL, maxSteps, maxToolResult, temperature, responseSchema, extraBody, timeout, dropReasoning, tools, workdir and system. ${VAR} in apiKey, baseURL, workdir and the MCP servers is replaced by the environment variable; an unknown field is an error.

extraBody adds raw fields to the body of every request, for the providers that speak OpenAI's format: top_p, presence_penalty, chat_template_kwargs and whatever else the server reads. timeout, in seconds, is the longest one model call may take: 600 by default for the OpenAI format, too short for a local model that thinks at length.

What a model thinks, streamed apart as reasoning_content, is kept in Message.Reasoning and goes back with the history, as llama.cpp reads it: Qwen's template renders it for the turn under way, so a model working through tools keeps its train of thought from one step to the next. dropReasoning sends the history back without it. Which past turns keep theirs is the template's business: Qwen's takes preserve_thinking in chat_template_kwargs. system is kept in Config.System for the caller; cmd/agentkit uses it.

Built-in tools

agentkit comes with tools for an agent that works on code: read_file, write_file, edit_file, list_dir, grep and bash. An agent has none of them unless its config names them, in Config.Builtins or tools in the file:

{
  "provider": "openai-compat",
  "baseURL": "http://127.0.0.1:8080/v1",
  "tools": ["read_file", "edit_file", "grep", "bash"],
  "workdir": "~/src/project"
}

They work in Workdir, the current directory when empty. The file tools cannot leave it, through .. or a link; bash runs sh -c there and can do whatever the process can, so name it only for an agent you would let type in your terminal. BuiltinTools(dir, names) builds them for a Config of your own. examples/implementer.json is an agent that implements a bounded task in a repository, with all of them.

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.

Command line

cmd/agentkit is a small terminal client, handy to try a model or an MCP server:

go install github.com/ThiraSoft/agentkit/cmd/agentkit@latest
agentkit -config agent.json
agentkit -provider gemini -model gemini-2.5-flash

The answer streams on stdout, the tool calls on stderr. /reset, /usage, /tools and /quit do what they say; Ctrl-C cuts the answer under way. Each release on GitHub carries the binaries.

-p sends one message and leaves: stdout gets the last message alone, so a script or another agent reads the answer and nothing else, and stderr the errors; -v shows there what the model wrote on the way and its tool calls. The exit code is 1 when the turn failed. -session file reads the conversation from the file if it exists and writes it back after each turn, to take a task up again; -workdir overrides the config's.

agentkit -config examples/implementer.json -workdir . -session task.json -p "Add a --json flag to cmd/list."
agentkit -config examples/implementer.json -workdir . -session task.json -p "The test fails on Windows paths, fix that."

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

func Builtins added in v0.4.0

func Builtins() []string

Builtins lists the names of the tools agentkit provides, in the order the model sees them.

func EstimateTokens added in v0.2.0

func EstimateTokens(m llm.Message) int

EstimateTokens is the rough count KeepTokens works with: a token per four bytes of text, tool name and tool arguments, 1000 per media, and 4 for the message itself.

func KeepTokens added in v0.2.0

func KeepTokens(budget int) func([]llm.Message) []llm.Message

KeepTokens returns a Prepare hook that drops the oldest turns until what is sent fits in budget tokens, as EstimateTokens counts them. The system prompt and the turn under way are always sent, even over budget. The history itself stays whole.

func KeepTurns added in v0.2.0

func KeepTurns(n int) func([]llm.Message) []llm.Message

KeepTurns returns a Prepare hook that sends the model the system prompt and the last n turns of the history, a turn starting at a user message. The history itself stays whole. n below 1 counts as 1: the turn under way is always sent. Cutting at a user message never separates a tool call from its result.

To combine it with a Prepare of your own, call it from there:

keep := agentkit.KeepTurns(10)
hooks.Prepare = func(msgs []llm.Message) []llm.Message { return mine(keep(msgs)) }

func SchemaFor added in v0.2.0

func SchemaFor[T any]() (json.RawMessage, error)

SchemaFor returns the JSON Schema of T, a struct: its exported fields under their json names, required unless tagged omitempty or omitzero, described by the text of a `jsonschema:"..."` tag.

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) System added in v0.4.0

func (a *Agent) System() string

System returns Config.System.

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

	// Temperature, when set, is the sampling temperature. Some models refuse
	// it (recent Anthropic ones, OpenAI reasoning models).
	Temperature *float64
	// MaxTokens caps the tokens generated by one model call; 0 leaves the
	// provider's default (32000 for Anthropic, which requires a value).
	MaxTokens int
	// PromptCache asks the provider to cache the prompt where it has to be
	// told (Anthropic); see Turn.Usage for what was read from the cache.
	PromptCache bool
	// ResponseSchema, when set, is a JSON Schema (an object) the model's
	// answers must follow; SchemaFor writes one from a Go type, and the
	// answer is the text of the last message of the history (Turn.Text too
	// when no tool ran). Not every model takes it together with tools.
	ResponseSchema json.RawMessage
	// ExtraBody adds raw fields to the request body of the providers that
	// speak OpenAI's format; see llm.Config.ExtraBody.
	ExtraBody map[string]any
	// Timeout is the longest one model call may take; see llm.Config.Timeout.
	Timeout time.Duration
	// DropReasoning leaves what the model thought out of the history sent
	// back; see llm.Config.DropReasoning.
	DropReasoning bool

	ProviderImpl llm.Provider

	Tools []Tool
	MCP   []mcp.ServerConfig

	// Builtins names the tools agentkit provides that the agent may use,
	// from Builtins(); none by default. They work in Workdir, the current
	// directory when empty. See BuiltinTools.
	Builtins []string
	Workdir  string

	// System is the agent's system prompt, kept for the caller: New does
	// not use it, NewConversation takes the prompt it is given.
	System string

	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, APIKey and the provider options below (Temperature, MaxTokens, PromptCache, ResponseSchema, ExtraBody, Timeout, DropReasoning).

func LoadConfig added in v0.2.0

func LoadConfig(path string) (Config, error)

LoadConfig reads a Config from a JSON file:

{
  "provider": "openai-compat",
  "model": "local",
  "baseURL": "http://localhost:8080/v1",
  "apiKey": "${LOCAL_KEY}",
  "maxSteps": 20,
  "maxToolResult": 32768,
  "temperature": 0.7,
  "maxTokens": 4096,
  "promptCache": false,
  "responseSchema": {"type": "object"},
  "extraBody": {"top_p": 0.8, "chat_template_kwargs": {"enable_thinking": true}},
  "timeout": 1800,
  "dropReasoning": false,
  "mcp": [{"name": "files", "transport": "stdio", "command": "files-mcp --root /tmp"}],
  "tools": ["read_file", "edit_file", "grep"],
  "workdir": "~/src/project",
  "system": "You are a careful engineer."
}

timeout is in seconds, the longest one model call may take. Only provider is required. tools names built-in tools (Config.Builtins), none if absent. ${VAR} in apiKey, baseURL and workdir is replaced by the environment variable, and a leading ~ in workdir by the home directory; in mcp, Dial does the same when it connects. An unknown field is an error. Tools and ProviderImpl, being Go, are for the caller to add to the returned Config.

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
	// Schema is a JSON Schema of the arguments, an object. When set, it
	// replaces Parameters, and can say what Parameters cannot: enums,
	// nested objects, bounds. NewTool writes it from a Go type.
	Schema json.RawMessage
	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 is recovered and reported to the model as an error.

func BuiltinTools added in v0.4.0

func BuiltinTools(dir string, names []string) ([]Tool, error)

BuiltinTools builds the tools named, working in dir. The file tools cannot reach outside dir, through .. or a link. bash runs sh -c in dir and can do anything the process can: name it only for an agent you would let type in your terminal.

func NewTool added in v0.2.0

func NewTool[T any](name, description string, run func(ctx context.Context, args T) (string, error)) (Tool, error)

NewTool builds a Tool whose arguments are a T: its Schema comes from SchemaFor, and run receives the arguments decoded. Arguments that do not decode into a T are an error the model is told about.

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
	Usage       llm.Usage // summed over the model calls of the turn
}

Turn summarizes a Send.

Directories

Path Synopsis
cmd
agentkit command
Command agentkit talks with a model and its MCP tools in the terminal.
Command agentkit talks with a model and its MCP tools in the terminal.
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