The source headers, LICENSE files, and license metadata had drifted apart. Align the entire project to MIT: - Convert every source-file header to the MIT text across all comment styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including SPDX-License-Identifier tags - Set the root and cookie-banner LICENSE files to the MIT text with a "MIT License" title line - Switch the package.json license fields, Docker image label, and cookie-banner README to MIT - Update docs and the genmodels header generator accordingly - Normalize copyright lines to a single format (Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the hello@getprobo.com and hello@probo.inc emails to hello@probo.com and the comma-separated years to a hyphenated range Genuine third-party references are intentionally left untouched: the Lucide icon attributions (Lucide is ISC) and the trivy dependency license allowlist. Signed-off-by: Sacha Al Himdani <sacha@probo.com>
429 lines
9.5 KiB
Go
429 lines
9.5 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// 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)
|
|
}
|