416 lines
9.1 KiB
Go
416 lines
9.1 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
|
//
|
|
// Permission to use, copy, modify, and/or distribute this software for any
|
|
// purpose with or without fee is hereby granted, provided that the above
|
|
// copyright notice and this permission notice appear in all copies.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
// PERFORMANCE OF THIS SOFTWARE.
|
|
|
|
package agent
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
|
|
"go.gearno.de/kit/log"
|
|
"go.probo.inc/probo/pkg/llm"
|
|
)
|
|
|
|
const (
|
|
DefaultMaxTurns = 10
|
|
DefaultMaxEmptyOutputRetries = 2
|
|
)
|
|
|
|
type (
|
|
Option func(*Agent)
|
|
|
|
Agent struct {
|
|
name string
|
|
handoffDescription string
|
|
instructions string
|
|
instructionsFunc func(ctx context.Context, a *Agent) string
|
|
model string
|
|
modelSettings ModelSettings
|
|
tools []Tool
|
|
handoffs []*Handoff
|
|
mcpServers []*MCPServer
|
|
maxTurns int
|
|
maxEmptyOutputRetries int
|
|
maxToolDepth int
|
|
client *llm.Client
|
|
logger *log.Logger
|
|
hooks []RunHooks
|
|
agentHooks AgentHooks
|
|
inputGuardrails []InputGuardrail
|
|
outputGuardrails []OutputGuardrail
|
|
session Session
|
|
sessionID string
|
|
outputType *OutputType
|
|
toolUseBehavior ToolUseBehavior
|
|
resetToolChoice bool
|
|
responseFormat *llm.ResponseFormat
|
|
approval *ApprovalConfig
|
|
}
|
|
)
|
|
|
|
func New(name string, client *llm.Client, opts ...Option) *Agent {
|
|
a := &Agent{
|
|
name: name,
|
|
client: client,
|
|
maxTurns: DefaultMaxTurns,
|
|
maxEmptyOutputRetries: DefaultMaxEmptyOutputRetries,
|
|
maxToolDepth: DefaultMaxToolDepth,
|
|
toolUseBehavior: RunLLMAgain(),
|
|
resetToolChoice: true,
|
|
logger: log.NewLogger(log.WithOutput(io.Discard)),
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(a)
|
|
}
|
|
|
|
a.logger = a.logger.Named("agent").With(log.String("agent", name))
|
|
|
|
return a
|
|
}
|
|
|
|
func (a *Agent) Name() string {
|
|
return a.name
|
|
}
|
|
|
|
func (a *Agent) AsTool(name, description string) Tool {
|
|
return newAgentTool(a, name, description)
|
|
}
|
|
|
|
func (a *Agent) HandoffDescription() string {
|
|
return a.handoffDescription
|
|
}
|
|
|
|
// Clone creates a shallow copy of the agent with the given options applied.
|
|
func (a *Agent) Clone(opts ...Option) *Agent {
|
|
cp := *a
|
|
|
|
cp.tools = make([]Tool, len(a.tools))
|
|
copy(cp.tools, a.tools)
|
|
|
|
cp.handoffs = make([]*Handoff, len(a.handoffs))
|
|
copy(cp.handoffs, a.handoffs)
|
|
|
|
cp.mcpServers = make([]*MCPServer, len(a.mcpServers))
|
|
copy(cp.mcpServers, a.mcpServers)
|
|
|
|
cp.hooks = make([]RunHooks, len(a.hooks))
|
|
copy(cp.hooks, a.hooks)
|
|
|
|
cp.inputGuardrails = make([]InputGuardrail, len(a.inputGuardrails))
|
|
copy(cp.inputGuardrails, a.inputGuardrails)
|
|
|
|
cp.outputGuardrails = make([]OutputGuardrail, len(a.outputGuardrails))
|
|
copy(cp.outputGuardrails, a.outputGuardrails)
|
|
|
|
if a.approval != nil {
|
|
newApproval := ApprovalConfig{
|
|
ShouldApprove: a.approval.ShouldApprove,
|
|
}
|
|
if len(a.approval.ToolNames) > 0 {
|
|
newApproval.ToolNames = make([]string, len(a.approval.ToolNames))
|
|
copy(newApproval.ToolNames, a.approval.ToolNames)
|
|
newApproval.toolNameSet = buildToolNameSet(newApproval.ToolNames)
|
|
}
|
|
cp.approval = &newApproval
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(&cp)
|
|
}
|
|
|
|
return &cp
|
|
}
|
|
|
|
// clone creates a shallow copy suitable for overriding pointer fields
|
|
// (e.g. responseFormat) without affecting the original. Slice fields remain
|
|
// shared; use the exported Clone method when slice mutations are needed.
|
|
func (a *Agent) clone() *Agent {
|
|
cp := *a
|
|
return &cp
|
|
}
|
|
|
|
func WithInstructions(s string) Option {
|
|
return func(a *Agent) {
|
|
a.instructions = s
|
|
a.instructionsFunc = nil
|
|
}
|
|
}
|
|
|
|
func WithInstructionsFunc(fn func(ctx context.Context, a *Agent) string) Option {
|
|
return func(a *Agent) {
|
|
a.instructionsFunc = fn
|
|
a.instructions = ""
|
|
}
|
|
}
|
|
|
|
func WithHandoffDescription(desc string) Option {
|
|
return func(a *Agent) {
|
|
a.handoffDescription = desc
|
|
}
|
|
}
|
|
|
|
func WithModel(m string) Option {
|
|
return func(a *Agent) {
|
|
a.model = m
|
|
}
|
|
}
|
|
|
|
func WithModelSettings(s ModelSettings) Option {
|
|
return func(a *Agent) {
|
|
a.modelSettings = s
|
|
}
|
|
}
|
|
|
|
func WithTools(tools ...Tool) Option {
|
|
return func(a *Agent) {
|
|
a.tools = append(a.tools, tools...)
|
|
}
|
|
}
|
|
|
|
func WithHandoffs(agents ...*Agent) Option {
|
|
return func(a *Agent) {
|
|
for _, ag := range agents {
|
|
if ag != nil {
|
|
a.handoffs = append(a.handoffs, &Handoff{Agent: ag})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func WithHandoffConfigs(handoffs ...*Handoff) Option {
|
|
return func(a *Agent) {
|
|
for _, h := range handoffs {
|
|
if h != nil && h.Agent != nil {
|
|
a.handoffs = append(a.handoffs, h)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func WithMaxTurns(n int) Option {
|
|
return func(a *Agent) {
|
|
if n < 1 {
|
|
n = 1
|
|
}
|
|
a.maxTurns = n
|
|
}
|
|
}
|
|
|
|
// WithMaxEmptyOutputRetries bounds the number of times the core loop
|
|
// will re-ask the model to produce a structured output after it
|
|
// returned a thinking-only empty response on a synthesis turn.
|
|
func WithMaxEmptyOutputRetries(n int) Option {
|
|
return func(a *Agent) {
|
|
if n < 0 {
|
|
n = 0
|
|
}
|
|
a.maxEmptyOutputRetries = n
|
|
}
|
|
}
|
|
|
|
func WithMaxToolDepth(n int) Option {
|
|
return func(a *Agent) {
|
|
if n < 1 {
|
|
n = 1
|
|
}
|
|
a.maxToolDepth = n
|
|
}
|
|
}
|
|
|
|
func WithTemperature(t float64) Option {
|
|
return func(a *Agent) {
|
|
a.modelSettings.Temperature = &t
|
|
}
|
|
}
|
|
|
|
func WithTopP(p float64) Option {
|
|
return func(a *Agent) {
|
|
a.modelSettings.TopP = &p
|
|
}
|
|
}
|
|
|
|
func WithFrequencyPenalty(p float64) Option {
|
|
return func(a *Agent) {
|
|
a.modelSettings.FrequencyPenalty = &p
|
|
}
|
|
}
|
|
|
|
func WithPresencePenalty(p float64) Option {
|
|
return func(a *Agent) {
|
|
a.modelSettings.PresencePenalty = &p
|
|
}
|
|
}
|
|
|
|
func WithMaxTokens(n int) Option {
|
|
return func(a *Agent) {
|
|
a.modelSettings.MaxTokens = &n
|
|
}
|
|
}
|
|
|
|
func WithToolChoice(tc llm.ToolChoice) Option {
|
|
return func(a *Agent) {
|
|
a.modelSettings.ToolChoice = &tc
|
|
}
|
|
}
|
|
|
|
func WithParallelToolCalls(enabled bool) Option {
|
|
return func(a *Agent) {
|
|
a.modelSettings.ParallelToolCalls = &enabled
|
|
}
|
|
}
|
|
|
|
func WithThinking(budgetTokens int) Option {
|
|
return func(a *Agent) {
|
|
a.modelSettings.Thinking = &llm.ThinkingConfig{
|
|
Enabled: true,
|
|
BudgetTokens: budgetTokens,
|
|
}
|
|
}
|
|
}
|
|
|
|
func WithLogger(l *log.Logger) Option {
|
|
return func(a *Agent) {
|
|
a.logger = l
|
|
}
|
|
}
|
|
|
|
func WithHooks(hooks ...RunHooks) Option {
|
|
return func(a *Agent) {
|
|
a.hooks = append(a.hooks, hooks...)
|
|
}
|
|
}
|
|
|
|
func WithAgentHooks(hooks AgentHooks) Option {
|
|
return func(a *Agent) {
|
|
a.agentHooks = hooks
|
|
}
|
|
}
|
|
|
|
func WithInputGuardrails(guards ...InputGuardrail) Option {
|
|
return func(a *Agent) {
|
|
a.inputGuardrails = append(a.inputGuardrails, guards...)
|
|
}
|
|
}
|
|
|
|
func WithOutputGuardrails(guards ...OutputGuardrail) Option {
|
|
return func(a *Agent) {
|
|
a.outputGuardrails = append(a.outputGuardrails, guards...)
|
|
}
|
|
}
|
|
|
|
func WithSession(s Session, sessionID string) Option {
|
|
return func(a *Agent) {
|
|
a.session = s
|
|
a.sessionID = sessionID
|
|
}
|
|
}
|
|
|
|
func WithOutputType(t *OutputType) Option {
|
|
return func(a *Agent) {
|
|
a.outputType = t
|
|
}
|
|
}
|
|
|
|
func WithToolUseBehavior(b ToolUseBehavior) Option {
|
|
return func(a *Agent) {
|
|
a.toolUseBehavior = b
|
|
}
|
|
}
|
|
|
|
func WithResetToolChoice(reset bool) Option {
|
|
return func(a *Agent) {
|
|
a.resetToolChoice = reset
|
|
}
|
|
}
|
|
|
|
func WithMCPServers(servers ...*MCPServer) Option {
|
|
return func(a *Agent) {
|
|
for _, s := range servers {
|
|
if s != nil {
|
|
a.mcpServers = append(a.mcpServers, s)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func WithApproval(config ApprovalConfig) Option {
|
|
config.toolNameSet = buildToolNameSet(config.ToolNames)
|
|
return func(a *Agent) {
|
|
a.approval = &config
|
|
}
|
|
}
|
|
|
|
func (a *Agent) resolveTools(ctx context.Context) ([]ToolDescriptor, map[string]ToolDescriptor, error) {
|
|
var all []ToolDescriptor
|
|
|
|
for _, t := range a.tools {
|
|
all = append(all, t)
|
|
}
|
|
|
|
for _, h := range a.handoffs {
|
|
all = append(all, h.tool())
|
|
}
|
|
|
|
for _, s := range a.mcpServers {
|
|
mcpTools, err := s.Tools(ctx)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("cannot resolve MCP tools from %q: %w", s.name, err)
|
|
}
|
|
for _, t := range mcpTools {
|
|
all = append(all, t)
|
|
}
|
|
}
|
|
|
|
toolMap := make(map[string]ToolDescriptor, len(all))
|
|
for _, t := range all {
|
|
name := t.Name()
|
|
if _, exists := toolMap[name]; exists {
|
|
return nil, nil, fmt.Errorf("cannot resolve tools: duplicate tool name %q", name)
|
|
}
|
|
toolMap[name] = t
|
|
}
|
|
|
|
return all, toolMap, nil
|
|
}
|
|
|
|
func (a *Agent) buildSystemPrompt(ctx context.Context) string {
|
|
instr := a.instructions
|
|
if a.instructionsFunc != nil {
|
|
instr = a.instructionsFunc(ctx, a)
|
|
}
|
|
|
|
data := systemPromptData{
|
|
Instructions: instr,
|
|
}
|
|
|
|
for _, h := range a.handoffs {
|
|
desc := h.toolDescription()
|
|
if len([]rune(desc)) > 200 {
|
|
desc = string([]rune(desc)[:200]) + "..."
|
|
}
|
|
|
|
data.Handoffs = append(
|
|
data.Handoffs,
|
|
systemPromptHandoff{
|
|
Name: h.Agent.name,
|
|
Description: desc,
|
|
},
|
|
)
|
|
}
|
|
|
|
return buildSystemPrompt(data)
|
|
}
|