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 ¶
- Variables
- func Builtins() []string
- func EstimateTokens(m llm.Message) int
- func KeepTokens(budget int) func([]llm.Message) []llm.Message
- func KeepTurns(n int) func([]llm.Message) []llm.Message
- func SchemaFor[T any]() (json.RawMessage, error)
- type Agent
- type Config
- type Conversation
- type Hooks
- type Tool
- type ToolCall
- type ToolResult
- type Turn
Examples ¶
Constants ¶
This section is empty.
Variables ¶
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
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
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
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)) }
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 ¶
New builds the provider, connects MCP servers and validates tools. It fails rather than falling back to something else.
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 ¶
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
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 ¶
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
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 ¶
ToolResult is what the model receives in response. Err is empty if and only if the tool succeeded.
Source Files
¶
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. |