4
go.mod
4
go.mod
@@ -5,7 +5,7 @@ go 1.26.1
|
||||
require (
|
||||
codeberg.org/miekg/dns v0.6.65
|
||||
github.com/99designs/gqlgen v0.17.87
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||
github.com/anthropics/anthropic-sdk-go v1.25.1
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.10
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18
|
||||
@@ -87,7 +87,7 @@ require (
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
github.com/google/jsonschema-go v0.4.2
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
|
||||
6
go.sum
6
go.sum
@@ -12,8 +12,8 @@ github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KO
|
||||
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
||||
github.com/anthropics/anthropic-sdk-go v1.25.1 h1:pI8ls4kuiDCUq6Hw+fB7eJx/nfgetLqcT43o/FlZGN0=
|
||||
github.com/anthropics/anthropic-sdk-go v1.25.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
|
||||
@@ -85,8 +85,6 @@ github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49 h1:h+XMRXf+WLY0h/3
|
||||
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc=
|
||||
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea h1:ALRwvjsSP53QmnN3Bcj0NpR8SsFLnskny/EIMebAk1c=
|
||||
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y=
|
||||
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
|
||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||
github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk=
|
||||
github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
|
||||
368
pkg/agent/agent.go
Normal file
368
pkg/agent/agent.go
Normal file
@@ -0,0 +1,368 @@
|
||||
// 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
|
||||
|
||||
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
|
||||
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,
|
||||
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 {
|
||||
a.handoffs = append(a.handoffs, &Handoff{Agent: ag})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithHandoffConfigs(handoffs ...*Handoff) Option {
|
||||
return func(a *Agent) {
|
||||
a.handoffs = append(a.handoffs, handoffs...)
|
||||
}
|
||||
}
|
||||
|
||||
func WithMaxTurns(n int) Option {
|
||||
return func(a *Agent) {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
a.maxTurns = 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 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) {
|
||||
a.mcpServers = append(a.mcpServers, servers...)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
2918
pkg/agent/agent_test.go
Normal file
2918
pkg/agent/agent_test.go
Normal file
File diff suppressed because it is too large
Load Diff
87
pkg/agent/agent_tool.go
Normal file
87
pkg/agent/agent_tool.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// 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"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
type (
|
||||
agentTool struct {
|
||||
agent *Agent
|
||||
toolName string
|
||||
description string
|
||||
schema json.RawMessage
|
||||
}
|
||||
|
||||
agentToolParams struct {
|
||||
Input string `json:"input" jsonschema:"The input to send to the agent"`
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
agentToolParamsSchema = jsonSchemaFor[agentToolParams]()
|
||||
)
|
||||
|
||||
func newAgentTool(agent *Agent, name, description string) *agentTool {
|
||||
return &agentTool{
|
||||
agent: agent,
|
||||
toolName: name,
|
||||
description: description,
|
||||
schema: agentToolParamsSchema,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *agentTool) Name() string { return t.toolName }
|
||||
|
||||
func (t *agentTool) Definition() llm.Tool {
|
||||
return llm.Tool{
|
||||
Name: t.toolName,
|
||||
Description: t.description,
|
||||
Parameters: t.schema,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *agentTool) Execute(ctx context.Context, arguments string) (ToolResult, error) {
|
||||
var params agentToolParams
|
||||
|
||||
if err := json.Unmarshal([]byte(arguments), ¶ms); err != nil {
|
||||
return ToolResult{
|
||||
Content: fmt.Sprintf("Invalid parameters: %s", err.Error()),
|
||||
IsError: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
result, err := t.agent.Run(
|
||||
ctx,
|
||||
[]llm.Message{
|
||||
{
|
||||
Role: llm.RoleUser,
|
||||
Parts: []llm.Part{
|
||||
llm.TextPart{Text: params.Input},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return ToolResult{}, err
|
||||
}
|
||||
|
||||
return ToolResult{Content: result.FinalMessage().Text()}, nil
|
||||
}
|
||||
391
pkg/agent/agent_tool_test.go
Normal file
391
pkg/agent/agent_tool_test.go
Normal file
@@ -0,0 +1,391 @@
|
||||
// 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_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/agent"
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
func TestAgentTool_Name(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"returns custom tool name not agent name",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ag := agent.New(
|
||||
"geography",
|
||||
newTestClient(&mockProvider{}),
|
||||
agent.WithModel("test-model"),
|
||||
)
|
||||
|
||||
tool := ag.AsTool("geo_expert", "Ask geography questions")
|
||||
assert.Equal(t, "geo_expert", tool.Name())
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"different AsTool calls return different names",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ag := agent.New(
|
||||
"helper",
|
||||
newTestClient(&mockProvider{}),
|
||||
agent.WithModel("test-model"),
|
||||
)
|
||||
|
||||
tool1 := ag.AsTool("tool_a", "First tool")
|
||||
tool2 := ag.AsTool("tool_b", "Second tool")
|
||||
|
||||
assert.Equal(t, "tool_a", tool1.Name())
|
||||
assert.Equal(t, "tool_b", tool2.Name())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestAgentTool_Definition(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"returns name and description",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ag := agent.New(
|
||||
"sub",
|
||||
newTestClient(&mockProvider{}),
|
||||
agent.WithModel("test-model"),
|
||||
)
|
||||
|
||||
tool := ag.AsTool("my_tool", "Does something useful.")
|
||||
def := tool.Definition()
|
||||
|
||||
assert.Equal(t, "my_tool", def.Name)
|
||||
assert.Equal(t, "Does something useful.", def.Description)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"schema contains input string property",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ag := agent.New(
|
||||
"sub",
|
||||
newTestClient(&mockProvider{}),
|
||||
agent.WithModel("test-model"),
|
||||
)
|
||||
|
||||
tool := ag.AsTool("delegate", "Delegate work.")
|
||||
def := tool.Definition()
|
||||
|
||||
require.NotNil(t, def.Parameters)
|
||||
|
||||
var schema map[string]any
|
||||
require.NoError(t, json.Unmarshal(def.Parameters, &schema))
|
||||
|
||||
assert.Equal(t, "object", schema["type"])
|
||||
|
||||
props, ok := schema["properties"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, props, "input")
|
||||
|
||||
inputProp := props["input"].(map[string]any)
|
||||
assert.Equal(t, "string", inputProp["type"])
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"schema requires input field",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ag := agent.New(
|
||||
"sub",
|
||||
newTestClient(&mockProvider{}),
|
||||
agent.WithModel("test-model"),
|
||||
)
|
||||
|
||||
tool := ag.AsTool("ask", "Ask a question.")
|
||||
def := tool.Definition()
|
||||
|
||||
var schema map[string]any
|
||||
require.NoError(t, json.Unmarshal(def.Parameters, &schema))
|
||||
|
||||
required, ok := schema["required"].([]any)
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, required, "input")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestAgentTool_Execute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"runs sub-agent and returns final message",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := &mockProvider{
|
||||
responses: []*llm.ChatCompletionResponse{
|
||||
stopResponse("The capital of France is Paris."),
|
||||
},
|
||||
}
|
||||
|
||||
ag := agent.New(
|
||||
"geography",
|
||||
newTestClient(provider),
|
||||
agent.WithModel("test-model"),
|
||||
agent.WithInstructions("You are a geography expert."),
|
||||
)
|
||||
|
||||
tool := ag.AsTool("geo_expert", "Ask geography questions.")
|
||||
result, err := tool.Execute(
|
||||
context.Background(),
|
||||
`{"input":"What is the capital of France?"}`,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "The capital of France is Paris.", result.Content)
|
||||
assert.False(t, result.IsError)
|
||||
assert.Equal(t, 1, provider.calls)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"invalid JSON returns tool error not Go error",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ag := agent.New(
|
||||
"sub",
|
||||
newTestClient(&mockProvider{}),
|
||||
agent.WithModel("test-model"),
|
||||
)
|
||||
|
||||
tool := ag.AsTool("sub_tool", "A sub-agent tool.")
|
||||
result, err := tool.Execute(context.Background(), `{bad json}`)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.Content, "Invalid parameters")
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"empty JSON object returns tool error",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := &mockProvider{
|
||||
responses: []*llm.ChatCompletionResponse{
|
||||
stopResponse("ok"),
|
||||
},
|
||||
}
|
||||
|
||||
ag := agent.New(
|
||||
"sub",
|
||||
newTestClient(provider),
|
||||
agent.WithModel("test-model"),
|
||||
)
|
||||
|
||||
tool := ag.AsTool("sub_tool", "A sub-agent tool.")
|
||||
result, err := tool.Execute(context.Background(), `{}`)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, result.IsError)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"sub-agent error propagates as Go error",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := &mockProvider{
|
||||
responses: []*llm.ChatCompletionResponse{},
|
||||
}
|
||||
|
||||
ag := agent.New(
|
||||
"sub",
|
||||
newTestClient(provider),
|
||||
agent.WithModel("test-model"),
|
||||
)
|
||||
|
||||
tool := ag.AsTool("helper", "A helper agent.")
|
||||
_, err := tool.Execute(
|
||||
context.Background(),
|
||||
`{"input":"hello"}`,
|
||||
)
|
||||
|
||||
require.Error(t, err)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"context is forwarded to sub-agent",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type AppCtx struct {
|
||||
TenantID string
|
||||
}
|
||||
|
||||
var captured string
|
||||
|
||||
type Params struct{}
|
||||
tenantTool := agent.FunctionTool[Params](
|
||||
"get_tenant",
|
||||
"Get tenant",
|
||||
func(ctx context.Context, _ Params) (agent.ToolResult, error) {
|
||||
rc := agent.RunContextFrom[*AppCtx](ctx)
|
||||
captured = rc.TenantID
|
||||
return agent.ToolResult{Content: rc.TenantID}, nil
|
||||
},
|
||||
)
|
||||
|
||||
provider := &mockProvider{
|
||||
responses: []*llm.ChatCompletionResponse{
|
||||
toolCallResponse(llm.ToolCall{
|
||||
ID: "tc_1",
|
||||
Function: llm.FunctionCall{Name: "get_tenant", Arguments: `{}`},
|
||||
}),
|
||||
stopResponse("tenant is t_789"),
|
||||
},
|
||||
}
|
||||
|
||||
subAgent := agent.New(
|
||||
"tenant_agent",
|
||||
newTestClient(provider),
|
||||
agent.WithModel("test-model"),
|
||||
agent.WithTools(tenantTool),
|
||||
)
|
||||
|
||||
tool := subAgent.AsTool("check_tenant", "Check tenant info.")
|
||||
|
||||
ctx := agent.WithRunContext(
|
||||
context.Background(),
|
||||
&AppCtx{TenantID: "t_789"},
|
||||
)
|
||||
|
||||
result, err := tool.Execute(ctx, `{"input":"what tenant?"}`)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "tenant is t_789", result.Content)
|
||||
assert.Equal(t, "t_789", captured)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"sub-agent with tool calls completes multi-turn",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct {
|
||||
Expr string `json:"expr"`
|
||||
}
|
||||
|
||||
calcTool := agent.FunctionTool[Params](
|
||||
"calc",
|
||||
"Calculate expression",
|
||||
func(_ context.Context, p Params) (agent.ToolResult, error) {
|
||||
return agent.ToolResult{Content: "42"}, nil
|
||||
},
|
||||
)
|
||||
|
||||
provider := &mockProvider{
|
||||
responses: []*llm.ChatCompletionResponse{
|
||||
toolCallResponse(llm.ToolCall{
|
||||
ID: "tc_1",
|
||||
Function: llm.FunctionCall{Name: "calc", Arguments: `{"expr":"6*7"}`},
|
||||
}),
|
||||
stopResponse("The answer is 42."),
|
||||
},
|
||||
}
|
||||
|
||||
subAgent := agent.New(
|
||||
"math",
|
||||
newTestClient(provider),
|
||||
agent.WithModel("test-model"),
|
||||
agent.WithTools(calcTool),
|
||||
)
|
||||
|
||||
tool := subAgent.AsTool("math_expert", "Ask math questions.")
|
||||
result, err := tool.Execute(
|
||||
context.Background(),
|
||||
`{"input":"What is 6 times 7?"}`,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "The answer is 42.", result.Content)
|
||||
assert.False(t, result.IsError)
|
||||
assert.Equal(t, 2, provider.calls)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"extra JSON fields are ignored",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := &mockProvider{
|
||||
responses: []*llm.ChatCompletionResponse{
|
||||
stopResponse("ok"),
|
||||
},
|
||||
}
|
||||
|
||||
ag := agent.New(
|
||||
"sub",
|
||||
newTestClient(provider),
|
||||
agent.WithModel("test-model"),
|
||||
)
|
||||
|
||||
tool := ag.AsTool("sub_tool", "Sub tool.")
|
||||
result, err := tool.Execute(
|
||||
context.Background(),
|
||||
`{"input":"hello","extra":"ignored"}`,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, result.IsError)
|
||||
assert.Equal(t, "ok", result.Content)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestAgentTool_InterfaceSatisfaction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ag := agent.New(
|
||||
"sub",
|
||||
newTestClient(&mockProvider{}),
|
||||
agent.WithModel("test-model"),
|
||||
)
|
||||
|
||||
tool := ag.AsTool("test_tool", "Test tool")
|
||||
|
||||
assert.Implements(t, (*agent.Tool)(nil), tool)
|
||||
assert.Implements(t, (*agent.ToolDescriptor)(nil), tool)
|
||||
}
|
||||
64
pkg/agent/approval.go
Normal file
64
pkg/agent/approval.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// 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"
|
||||
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
type (
|
||||
ApprovalConfig struct {
|
||||
ToolNames []string
|
||||
ShouldApprove func(ctx context.Context, toolCall llm.ToolCall) bool
|
||||
|
||||
toolNameSet map[string]struct{}
|
||||
}
|
||||
|
||||
ApprovalResult struct {
|
||||
Approved bool
|
||||
Message string
|
||||
}
|
||||
|
||||
ResumeInput struct {
|
||||
Approvals map[string]ApprovalResult
|
||||
}
|
||||
)
|
||||
|
||||
func buildToolNameSet(names []string) map[string]struct{} {
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
set := make(map[string]struct{}, len(names))
|
||||
for _, name := range names {
|
||||
set[name] = struct{}{}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func (c *ApprovalConfig) requiresApproval(ctx context.Context, tc llm.ToolCall) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if c.ShouldApprove != nil {
|
||||
return c.ShouldApprove(ctx, tc)
|
||||
}
|
||||
|
||||
_, ok := c.toolNameSet[tc.Function.Name]
|
||||
return ok
|
||||
}
|
||||
203
pkg/agent/approval_test.go
Normal file
203
pkg/agent/approval_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
// 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"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
func TestBuildToolNameSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"nil input returns nil",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Nil(t, buildToolNameSet(nil))
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"empty slice returns nil",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Nil(t, buildToolNameSet([]string{}))
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"single name",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
set := buildToolNameSet([]string{"delete"})
|
||||
|
||||
assert.Len(t, set, 1)
|
||||
_, ok := set["delete"]
|
||||
assert.True(t, ok)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"multiple names",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
set := buildToolNameSet([]string{"delete", "update", "create"})
|
||||
|
||||
assert.Len(t, set, 3)
|
||||
for _, name := range []string{"delete", "update", "create"} {
|
||||
_, ok := set[name]
|
||||
assert.True(t, ok, "expected set to contain %q", name)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"duplicate names are deduplicated",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
set := buildToolNameSet([]string{"delete", "delete", "update"})
|
||||
|
||||
assert.Len(t, set, 2)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestApprovalConfig_RequiresApproval(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc := llm.ToolCall{
|
||||
ID: "tc_1",
|
||||
Function: llm.FunctionCall{Name: "delete_user", Arguments: `{}`},
|
||||
}
|
||||
|
||||
t.Run(
|
||||
"nil config returns false",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var c *ApprovalConfig
|
||||
assert.False(t, c.requiresApproval(context.Background(), tc))
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"empty config returns false",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := &ApprovalConfig{}
|
||||
assert.False(t, c.requiresApproval(context.Background(), tc))
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"tool name in set returns true",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := &ApprovalConfig{
|
||||
toolNameSet: map[string]struct{}{
|
||||
"delete_user": {},
|
||||
},
|
||||
}
|
||||
|
||||
assert.True(t, c.requiresApproval(context.Background(), tc))
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"tool name not in set returns false",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := &ApprovalConfig{
|
||||
toolNameSet: map[string]struct{}{
|
||||
"list_users": {},
|
||||
},
|
||||
}
|
||||
|
||||
assert.False(t, c.requiresApproval(context.Background(), tc))
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"ShouldApprove takes precedence over tool name set",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := &ApprovalConfig{
|
||||
toolNameSet: map[string]struct{}{
|
||||
"delete_user": {},
|
||||
},
|
||||
ShouldApprove: func(_ context.Context, _ llm.ToolCall) bool {
|
||||
return false
|
||||
},
|
||||
}
|
||||
|
||||
assert.False(t, c.requiresApproval(context.Background(), tc))
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"ShouldApprove receives context and tool call",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type ctxKey struct{}
|
||||
ctx := context.WithValue(context.Background(), ctxKey{}, "marker")
|
||||
|
||||
var capturedCtx context.Context
|
||||
var capturedTC llm.ToolCall
|
||||
|
||||
c := &ApprovalConfig{
|
||||
ShouldApprove: func(ctx context.Context, tc llm.ToolCall) bool {
|
||||
capturedCtx = ctx
|
||||
capturedTC = tc
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
result := c.requiresApproval(ctx, tc)
|
||||
|
||||
assert.True(t, result)
|
||||
assert.Equal(t, "marker", capturedCtx.Value(ctxKey{}))
|
||||
assert.Equal(t, "delete_user", capturedTC.Function.Name)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"ShouldApprove returning true requires approval",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := &ApprovalConfig{
|
||||
ShouldApprove: func(_ context.Context, _ llm.ToolCall) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
assert.True(t, c.requiresApproval(context.Background(), tc))
|
||||
},
|
||||
)
|
||||
}
|
||||
53
pkg/agent/context.go
Normal file
53
pkg/agent/context.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
type runContextKey struct{}
|
||||
|
||||
func WithRunContext(ctx context.Context, val any) context.Context {
|
||||
return context.WithValue(ctx, runContextKey{}, val)
|
||||
}
|
||||
|
||||
func RunContextFrom[C any](ctx context.Context) C {
|
||||
val := ctx.Value(runContextKey{})
|
||||
if val == nil {
|
||||
var zero C
|
||||
panic(fmt.Sprintf("agent: no run context found (expected %T)", zero))
|
||||
}
|
||||
|
||||
typed, ok := val.(C)
|
||||
if !ok {
|
||||
var zero C
|
||||
panic(fmt.Sprintf("agent: run context type mismatch: stored %T, requested %T", val, zero))
|
||||
}
|
||||
|
||||
return typed
|
||||
}
|
||||
|
||||
func TryRunContextFrom[C any](ctx context.Context) (C, bool) {
|
||||
val := ctx.Value(runContextKey{})
|
||||
if val == nil {
|
||||
var zero C
|
||||
return zero, false
|
||||
}
|
||||
|
||||
typed, ok := val.(C)
|
||||
return typed, ok
|
||||
}
|
||||
198
pkg/agent/context_test.go
Normal file
198
pkg/agent/context_test.go
Normal file
@@ -0,0 +1,198 @@
|
||||
// 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_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/agent"
|
||||
)
|
||||
|
||||
type testRunContext struct {
|
||||
UserID string
|
||||
Locale string
|
||||
}
|
||||
|
||||
func TestWithRunContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"stores value retrievable by RunContextFrom",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rc := testRunContext{UserID: "u_123", Locale: "en"}
|
||||
ctx := agent.WithRunContext(context.Background(), rc)
|
||||
|
||||
got := agent.RunContextFrom[testRunContext](ctx)
|
||||
assert.Equal(t, "u_123", got.UserID)
|
||||
assert.Equal(t, "en", got.Locale)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"overwrites previous run context",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := agent.WithRunContext(context.Background(), testRunContext{UserID: "first"})
|
||||
ctx = agent.WithRunContext(ctx, testRunContext{UserID: "second"})
|
||||
|
||||
got := agent.RunContextFrom[testRunContext](ctx)
|
||||
assert.Equal(t, "second", got.UserID)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestRunContextFrom(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"returns stored value with correct type",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := agent.WithRunContext(context.Background(), "hello")
|
||||
|
||||
got := agent.RunContextFrom[string](ctx)
|
||||
assert.Equal(t, "hello", got)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"panics when no run context is set",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Panics(
|
||||
t,
|
||||
func() {
|
||||
agent.RunContextFrom[string](context.Background())
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"panic message includes expected type when missing",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.PanicsWithValue(
|
||||
t,
|
||||
"agent: no run context found (expected string)",
|
||||
func() {
|
||||
agent.RunContextFrom[string](context.Background())
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"panics on type mismatch",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := agent.WithRunContext(context.Background(), 42)
|
||||
|
||||
require.Panics(
|
||||
t,
|
||||
func() {
|
||||
agent.RunContextFrom[string](ctx)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"panic message includes both types on mismatch",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := agent.WithRunContext(context.Background(), 42)
|
||||
|
||||
assert.PanicsWithValue(
|
||||
t,
|
||||
"agent: run context type mismatch: stored int, requested string",
|
||||
func() {
|
||||
agent.RunContextFrom[string](ctx)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestTryRunContextFrom(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"returns value and true when context is set",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rc := testRunContext{UserID: "u_456", Locale: "fr"}
|
||||
ctx := agent.WithRunContext(context.Background(), rc)
|
||||
|
||||
got, ok := agent.TryRunContextFrom[testRunContext](ctx)
|
||||
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "u_456", got.UserID)
|
||||
assert.Equal(t, "fr", got.Locale)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"returns zero value and false when no context is set",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, ok := agent.TryRunContextFrom[testRunContext](context.Background())
|
||||
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, testRunContext{}, got)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"returns zero value and false on type mismatch",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := agent.WithRunContext(context.Background(), 42)
|
||||
|
||||
got, ok := agent.TryRunContextFrom[string](ctx)
|
||||
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, "", got)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"works with interface types",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := agent.WithRunContext(context.Background(), "a string value")
|
||||
|
||||
got, ok := agent.TryRunContextFrom[any](ctx)
|
||||
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "a string value", got)
|
||||
},
|
||||
)
|
||||
}
|
||||
71
pkg/agent/errors.go
Normal file
71
pkg/agent/errors.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// 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 (
|
||||
"fmt"
|
||||
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
type (
|
||||
MaxTurnsExceededError struct {
|
||||
MaxTurns int
|
||||
}
|
||||
|
||||
InputGuardrailTrippedError struct {
|
||||
Guardrail string
|
||||
Message string
|
||||
}
|
||||
|
||||
OutputGuardrailTrippedError struct {
|
||||
Guardrail string
|
||||
Message string
|
||||
}
|
||||
|
||||
InterruptedError struct {
|
||||
ToolCalls []llm.ToolCall
|
||||
PendingApprovals []llm.ToolCall
|
||||
Agent *Agent
|
||||
Messages []llm.Message
|
||||
Usage llm.Usage
|
||||
Turns int
|
||||
}
|
||||
|
||||
needsApprovalError struct {
|
||||
allToolCalls []llm.ToolCall
|
||||
pendingApprovals []llm.ToolCall
|
||||
}
|
||||
)
|
||||
|
||||
func (e *MaxTurnsExceededError) Error() string {
|
||||
return fmt.Sprintf("agent exceeded maximum number of turns (%d)", e.MaxTurns)
|
||||
}
|
||||
|
||||
func (e *InputGuardrailTrippedError) Error() string {
|
||||
return fmt.Sprintf("input guardrail %q tripped: %s", e.Guardrail, e.Message)
|
||||
}
|
||||
|
||||
func (e *OutputGuardrailTrippedError) Error() string {
|
||||
return fmt.Sprintf("output guardrail %q tripped: %s", e.Guardrail, e.Message)
|
||||
}
|
||||
|
||||
func (e *InterruptedError) Error() string {
|
||||
return fmt.Sprintf("run interrupted: %d tool call(s) require approval", len(e.PendingApprovals))
|
||||
}
|
||||
|
||||
func (e *needsApprovalError) Error() string {
|
||||
return fmt.Sprintf("%d tool call(s) require approval", len(e.pendingApprovals))
|
||||
}
|
||||
38
pkg/agent/guardrail.go
Normal file
38
pkg/agent/guardrail.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// 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"
|
||||
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
type (
|
||||
GuardrailResult struct {
|
||||
Tripwire bool
|
||||
Message string
|
||||
}
|
||||
|
||||
InputGuardrail interface {
|
||||
Name() string
|
||||
Check(ctx context.Context, messages []llm.Message) (*GuardrailResult, error)
|
||||
}
|
||||
|
||||
OutputGuardrail interface {
|
||||
Name() string
|
||||
Check(ctx context.Context, message llm.Message) (*GuardrailResult, error)
|
||||
}
|
||||
)
|
||||
141
pkg/agent/handoff.go
Normal file
141
pkg/agent/handoff.go
Normal file
@@ -0,0 +1,141 @@
|
||||
// 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"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
type (
|
||||
HandoffInputData struct {
|
||||
InputHistory []llm.Message
|
||||
NewItems []llm.Message
|
||||
}
|
||||
|
||||
HandoffInputFilter func(data HandoffInputData) []llm.Message
|
||||
|
||||
HandoffOption func(*Handoff)
|
||||
|
||||
Handoff struct {
|
||||
Agent *Agent
|
||||
ToolName string
|
||||
ToolDescription string
|
||||
InputFilter HandoffInputFilter
|
||||
OnHandoff func(ctx context.Context) error
|
||||
}
|
||||
|
||||
handoffParams struct{}
|
||||
)
|
||||
|
||||
var (
|
||||
handoffParamsSchema = jsonSchemaFor[handoffParams]()
|
||||
)
|
||||
|
||||
func HandoffTo(agent *Agent, opts ...HandoffOption) *Handoff {
|
||||
h := &Handoff{Agent: agent}
|
||||
for _, opt := range opts {
|
||||
opt(h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func WithHandoffToolName(name string) HandoffOption {
|
||||
return func(h *Handoff) {
|
||||
h.ToolName = name
|
||||
}
|
||||
}
|
||||
|
||||
func WithHandoffToolDescription(desc string) HandoffOption {
|
||||
return func(h *Handoff) {
|
||||
h.ToolDescription = desc
|
||||
}
|
||||
}
|
||||
|
||||
func WithHandoffInputFilter(fn HandoffInputFilter) HandoffOption {
|
||||
return func(h *Handoff) {
|
||||
h.InputFilter = fn
|
||||
}
|
||||
}
|
||||
|
||||
func WithOnHandoff(fn func(ctx context.Context) error) HandoffOption {
|
||||
return func(h *Handoff) {
|
||||
h.OnHandoff = fn
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handoff) toolName() string {
|
||||
if h.ToolName != "" {
|
||||
return h.ToolName
|
||||
}
|
||||
return "transfer_to_" + sanitizeToolName(h.Agent.name)
|
||||
}
|
||||
|
||||
func sanitizeToolName(name string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(name))
|
||||
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case unicode.IsLetter(r), unicode.IsDigit(r):
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
|
||||
return strings.ToLower(b.String())
|
||||
}
|
||||
|
||||
func (h *Handoff) toolDescription() string {
|
||||
if h.ToolDescription != "" {
|
||||
return h.ToolDescription
|
||||
}
|
||||
|
||||
desc := fmt.Sprintf(
|
||||
"Transfer the conversation to %s.",
|
||||
h.Agent.name,
|
||||
)
|
||||
|
||||
if h.Agent.handoffDescription != "" {
|
||||
desc += " " + h.Agent.handoffDescription
|
||||
}
|
||||
|
||||
return desc
|
||||
}
|
||||
|
||||
func (h *Handoff) tool() ToolDescriptor {
|
||||
return &handoffToolAdapter{handoff: h}
|
||||
}
|
||||
|
||||
type handoffToolAdapter struct {
|
||||
handoff *Handoff
|
||||
}
|
||||
|
||||
func (t *handoffToolAdapter) Name() string {
|
||||
return t.handoff.toolName()
|
||||
}
|
||||
|
||||
func (t *handoffToolAdapter) Definition() llm.Tool {
|
||||
return llm.Tool{
|
||||
Name: t.handoff.toolName(),
|
||||
Description: t.handoff.toolDescription(),
|
||||
Parameters: handoffParamsSchema,
|
||||
}
|
||||
}
|
||||
306
pkg/agent/handoff_test.go
Normal file
306
pkg/agent/handoff_test.go
Normal file
@@ -0,0 +1,306 @@
|
||||
// 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_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/agent"
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
func TestHandoffTo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"sets the target agent",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
target := agent.New(
|
||||
"billing",
|
||||
newTestClient(&mockProvider{}),
|
||||
)
|
||||
|
||||
h := agent.HandoffTo(target)
|
||||
|
||||
assert.Equal(t, target, h.Agent)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"defaults have zero values",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
target := agent.New(
|
||||
"support",
|
||||
newTestClient(&mockProvider{}),
|
||||
)
|
||||
|
||||
h := agent.HandoffTo(target)
|
||||
|
||||
assert.Empty(t, h.ToolName)
|
||||
assert.Empty(t, h.ToolDescription)
|
||||
assert.Nil(t, h.InputFilter)
|
||||
assert.Nil(t, h.OnHandoff)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"applies multiple options",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
target := agent.New(
|
||||
"escalation",
|
||||
newTestClient(&mockProvider{}),
|
||||
)
|
||||
|
||||
filter := func(data agent.HandoffInputData) []llm.Message {
|
||||
return data.NewItems
|
||||
}
|
||||
|
||||
callback := func(_ context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
h := agent.HandoffTo(
|
||||
target,
|
||||
agent.WithHandoffToolName("escalate"),
|
||||
agent.WithHandoffToolDescription("Escalate to senior agent"),
|
||||
agent.WithHandoffInputFilter(filter),
|
||||
agent.WithOnHandoff(callback),
|
||||
)
|
||||
|
||||
assert.Equal(t, target, h.Agent)
|
||||
assert.Equal(t, "escalate", h.ToolName)
|
||||
assert.Equal(t, "Escalate to senior agent", h.ToolDescription)
|
||||
assert.NotNil(t, h.InputFilter)
|
||||
assert.NotNil(t, h.OnHandoff)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestWithHandoffToolName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
target := agent.New("billing", newTestClient(&mockProvider{}))
|
||||
h := agent.HandoffTo(target, agent.WithHandoffToolName("ask_billing"))
|
||||
|
||||
assert.Equal(t, "ask_billing", h.ToolName)
|
||||
}
|
||||
|
||||
func TestWithHandoffToolDescription(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
target := agent.New("billing", newTestClient(&mockProvider{}))
|
||||
h := agent.HandoffTo(
|
||||
target,
|
||||
agent.WithHandoffToolDescription("Route billing questions"),
|
||||
)
|
||||
|
||||
assert.Equal(t, "Route billing questions", h.ToolDescription)
|
||||
}
|
||||
|
||||
func TestWithHandoffInputFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"sets the filter function",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
target := agent.New("specialist", newTestClient(&mockProvider{}))
|
||||
h := agent.HandoffTo(
|
||||
target,
|
||||
agent.WithHandoffInputFilter(func(data agent.HandoffInputData) []llm.Message {
|
||||
return data.NewItems
|
||||
}),
|
||||
)
|
||||
|
||||
assert.NotNil(t, h.InputFilter)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"filter receives correct data and returns filtered messages",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
history := []llm.Message{
|
||||
userMessage("old message"),
|
||||
assistantMessage("old reply"),
|
||||
}
|
||||
newItems := []llm.Message{
|
||||
userMessage("new question"),
|
||||
}
|
||||
|
||||
target := agent.New("specialist", newTestClient(&mockProvider{}))
|
||||
h := agent.HandoffTo(
|
||||
target,
|
||||
agent.WithHandoffInputFilter(func(data agent.HandoffInputData) []llm.Message {
|
||||
var filtered []llm.Message
|
||||
for _, m := range data.NewItems {
|
||||
if m.Role == llm.RoleUser {
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}),
|
||||
)
|
||||
|
||||
result := h.InputFilter(agent.HandoffInputData{
|
||||
InputHistory: history,
|
||||
NewItems: newItems,
|
||||
})
|
||||
|
||||
require.Len(t, result, 1)
|
||||
assert.Equal(t, llm.RoleUser, result[0].Role)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"filter can combine history and new items",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
history := []llm.Message{
|
||||
userMessage("context"),
|
||||
}
|
||||
newItems := []llm.Message{
|
||||
userMessage("question"),
|
||||
}
|
||||
|
||||
target := agent.New("specialist", newTestClient(&mockProvider{}))
|
||||
h := agent.HandoffTo(
|
||||
target,
|
||||
agent.WithHandoffInputFilter(func(data agent.HandoffInputData) []llm.Message {
|
||||
all := make([]llm.Message, 0, len(data.InputHistory)+len(data.NewItems))
|
||||
all = append(all, data.InputHistory...)
|
||||
all = append(all, data.NewItems...)
|
||||
return all
|
||||
}),
|
||||
)
|
||||
|
||||
result := h.InputFilter(agent.HandoffInputData{
|
||||
InputHistory: history,
|
||||
NewItems: newItems,
|
||||
})
|
||||
|
||||
assert.Len(t, result, 2)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"filter can return empty slice",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
target := agent.New("specialist", newTestClient(&mockProvider{}))
|
||||
h := agent.HandoffTo(
|
||||
target,
|
||||
agent.WithHandoffInputFilter(func(_ agent.HandoffInputData) []llm.Message {
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
|
||||
result := h.InputFilter(agent.HandoffInputData{
|
||||
InputHistory: []llm.Message{userMessage("hello")},
|
||||
NewItems: []llm.Message{userMessage("world")},
|
||||
})
|
||||
|
||||
assert.Empty(t, result)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestWithOnHandoff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"sets the callback",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
target := agent.New("billing", newTestClient(&mockProvider{}))
|
||||
h := agent.HandoffTo(
|
||||
target,
|
||||
agent.WithOnHandoff(func(_ context.Context) error {
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
|
||||
assert.NotNil(t, h.OnHandoff)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"callback is invocable",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var called bool
|
||||
|
||||
target := agent.New("billing", newTestClient(&mockProvider{}))
|
||||
h := agent.HandoffTo(
|
||||
target,
|
||||
agent.WithOnHandoff(func(_ context.Context) error {
|
||||
called = true
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
|
||||
err := h.OnHandoff(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.True(t, called)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"callback propagates errors",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
target := agent.New("billing", newTestClient(&mockProvider{}))
|
||||
h := agent.HandoffTo(
|
||||
target,
|
||||
agent.WithOnHandoff(func(_ context.Context) error {
|
||||
return assert.AnError
|
||||
}),
|
||||
)
|
||||
|
||||
err := h.OnHandoff(context.Background())
|
||||
assert.ErrorIs(t, err, assert.AnError)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestHandoffTo_OptionOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
target := agent.New("billing", newTestClient(&mockProvider{}))
|
||||
|
||||
h := agent.HandoffTo(
|
||||
target,
|
||||
agent.WithHandoffToolName("first_name"),
|
||||
agent.WithHandoffToolName("second_name"),
|
||||
)
|
||||
|
||||
assert.Equal(t, "second_name", h.ToolName, "last option wins")
|
||||
}
|
||||
72
pkg/agent/hooks.go
Normal file
72
pkg/agent/hooks.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// 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"
|
||||
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
// RunHooks receives callbacks on lifecycle events for the entire agent run.
|
||||
type RunHooks interface {
|
||||
OnRunStart(ctx context.Context, agent *Agent, messages []llm.Message)
|
||||
OnRunEnd(ctx context.Context, agent *Agent, result *Result, err error)
|
||||
OnLLMStart(ctx context.Context, agent *Agent, messages []llm.Message)
|
||||
OnLLMEnd(ctx context.Context, agent *Agent, response *llm.ChatCompletionResponse, err error)
|
||||
OnToolStart(ctx context.Context, agent *Agent, tool Tool, arguments string)
|
||||
OnToolEnd(ctx context.Context, agent *Agent, tool Tool, result ToolResult, err error)
|
||||
OnHandoff(ctx context.Context, from *Agent, to *Agent)
|
||||
OnGuardrailTripped(ctx context.Context, agent *Agent, name string, result *GuardrailResult)
|
||||
}
|
||||
|
||||
// NoOpHooks is a RunHooks implementation that does nothing.
|
||||
type NoOpHooks struct{}
|
||||
|
||||
var _ RunHooks = NoOpHooks{}
|
||||
|
||||
func (NoOpHooks) OnRunStart(context.Context, *Agent, []llm.Message) {}
|
||||
func (NoOpHooks) OnRunEnd(context.Context, *Agent, *Result, error) {}
|
||||
func (NoOpHooks) OnLLMStart(context.Context, *Agent, []llm.Message) {}
|
||||
func (NoOpHooks) OnLLMEnd(context.Context, *Agent, *llm.ChatCompletionResponse, error) {}
|
||||
func (NoOpHooks) OnToolStart(context.Context, *Agent, Tool, string) {}
|
||||
func (NoOpHooks) OnToolEnd(context.Context, *Agent, Tool, ToolResult, error) {}
|
||||
func (NoOpHooks) OnHandoff(context.Context, *Agent, *Agent) {}
|
||||
func (NoOpHooks) OnGuardrailTripped(context.Context, *Agent, string, *GuardrailResult) {}
|
||||
|
||||
// AgentHooks receives callbacks on lifecycle events for a specific agent.
|
||||
// Set via WithAgentHooks on an individual agent.
|
||||
type AgentHooks interface {
|
||||
OnStart(ctx context.Context, agent *Agent)
|
||||
OnEnd(ctx context.Context, agent *Agent, output string)
|
||||
OnHandoff(ctx context.Context, agent *Agent, source *Agent)
|
||||
OnToolStart(ctx context.Context, agent *Agent, tool Tool)
|
||||
OnToolEnd(ctx context.Context, agent *Agent, tool Tool, result ToolResult)
|
||||
OnLLMStart(ctx context.Context, agent *Agent, messages []llm.Message)
|
||||
OnLLMEnd(ctx context.Context, agent *Agent, response *llm.ChatCompletionResponse, err error)
|
||||
}
|
||||
|
||||
// NoOpAgentHooks is an AgentHooks implementation that does nothing.
|
||||
type NoOpAgentHooks struct{}
|
||||
|
||||
var _ AgentHooks = NoOpAgentHooks{}
|
||||
|
||||
func (NoOpAgentHooks) OnStart(context.Context, *Agent) {}
|
||||
func (NoOpAgentHooks) OnEnd(context.Context, *Agent, string) {}
|
||||
func (NoOpAgentHooks) OnHandoff(context.Context, *Agent, *Agent) {}
|
||||
func (NoOpAgentHooks) OnToolStart(context.Context, *Agent, Tool) {}
|
||||
func (NoOpAgentHooks) OnToolEnd(context.Context, *Agent, Tool, ToolResult) {}
|
||||
func (NoOpAgentHooks) OnLLMStart(context.Context, *Agent, []llm.Message) {}
|
||||
func (NoOpAgentHooks) OnLLMEnd(context.Context, *Agent, *llm.ChatCompletionResponse, error) {}
|
||||
182
pkg/agent/mcp.go
Normal file
182
pkg/agent/mcp.go
Normal file
@@ -0,0 +1,182 @@
|
||||
// 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"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
type (
|
||||
MCPServer struct {
|
||||
name string
|
||||
session *mcp.ClientSession
|
||||
|
||||
mu sync.RWMutex
|
||||
cachedTools []Tool
|
||||
toolsCached bool
|
||||
}
|
||||
|
||||
mcpTool struct {
|
||||
server *MCPServer
|
||||
name string
|
||||
description string
|
||||
inputSchema json.RawMessage
|
||||
}
|
||||
)
|
||||
|
||||
func NewMCPServer(name string, session *mcp.ClientSession) *MCPServer {
|
||||
return &MCPServer{
|
||||
name: name,
|
||||
session: session,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MCPServer) Name() string {
|
||||
return s.name
|
||||
}
|
||||
|
||||
func (s *MCPServer) Tools(ctx context.Context) ([]Tool, error) {
|
||||
s.mu.RLock()
|
||||
if s.toolsCached {
|
||||
cp := make([]Tool, len(s.cachedTools))
|
||||
copy(cp, s.cachedTools)
|
||||
s.mu.RUnlock()
|
||||
return cp, nil
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.toolsCached {
|
||||
cp := make([]Tool, len(s.cachedTools))
|
||||
copy(cp, s.cachedTools)
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
var allTools []*mcp.Tool
|
||||
var cursor string
|
||||
|
||||
for {
|
||||
params := &mcp.ListToolsParams{}
|
||||
if cursor != "" {
|
||||
params.Cursor = cursor
|
||||
}
|
||||
|
||||
result, err := s.session.ListTools(ctx, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot list tools from MCP server %q: %w", s.name, err)
|
||||
}
|
||||
|
||||
allTools = append(allTools, result.Tools...)
|
||||
|
||||
if result.NextCursor == "" {
|
||||
break
|
||||
}
|
||||
cursor = result.NextCursor
|
||||
}
|
||||
|
||||
tools := make([]Tool, len(allTools))
|
||||
for i, t := range allTools {
|
||||
schema, err := json.Marshal(t.InputSchema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot marshal input schema for tool %q: %w", t.Name, err)
|
||||
}
|
||||
|
||||
tools[i] = &mcpTool{
|
||||
server: s,
|
||||
name: t.Name,
|
||||
description: t.Description,
|
||||
inputSchema: schema,
|
||||
}
|
||||
}
|
||||
|
||||
s.cachedTools = tools
|
||||
s.toolsCached = true
|
||||
|
||||
return tools, nil
|
||||
}
|
||||
|
||||
// ResetCache clears the cached tool definitions, forcing the next call to
|
||||
// Tools to re-fetch from the MCP server.
|
||||
func (s *MCPServer) ResetCache() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.cachedTools = nil
|
||||
s.toolsCached = false
|
||||
}
|
||||
|
||||
func (t *mcpTool) Name() string { return t.name }
|
||||
|
||||
func (t *mcpTool) Definition() llm.Tool {
|
||||
return llm.Tool{
|
||||
Name: t.name,
|
||||
Description: t.description,
|
||||
Parameters: t.inputSchema,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *mcpTool) Execute(ctx context.Context, arguments string) (ToolResult, error) {
|
||||
var args map[string]any
|
||||
if arguments != "" {
|
||||
if err := json.Unmarshal([]byte(arguments), &args); err != nil {
|
||||
return ToolResult{
|
||||
Content: fmt.Sprintf("Invalid arguments: %s", err.Error()),
|
||||
IsError: true,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
result, err := t.server.session.CallTool(
|
||||
ctx,
|
||||
&mcp.CallToolParams{
|
||||
Name: t.name,
|
||||
Arguments: args,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return ToolResult{}, fmt.Errorf("cannot call MCP tool %q: %w", t.name, err)
|
||||
}
|
||||
|
||||
content := extractMCPContent(result)
|
||||
|
||||
return ToolResult{
|
||||
Content: content,
|
||||
IsError: result.IsError,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func extractMCPContent(result *mcp.CallToolResult) string {
|
||||
if result == nil || len(result.Content) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var parts []string
|
||||
for _, c := range result.Content {
|
||||
if tc, ok := c.(*mcp.TextContent); ok {
|
||||
parts = append(parts, tc.Text)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
369
pkg/agent/mcp_test.go
Normal file
369
pkg/agent/mcp_test.go
Normal file
@@ -0,0 +1,369 @@
|
||||
// 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"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
func TestNewMCPServer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := NewMCPServer("my-server", nil)
|
||||
|
||||
require.NotNil(t, s)
|
||||
assert.Equal(t, "my-server", s.name)
|
||||
assert.Nil(t, s.session)
|
||||
assert.False(t, s.toolsCached)
|
||||
assert.Nil(t, s.cachedTools)
|
||||
}
|
||||
|
||||
func TestMCPServer_Name(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := &MCPServer{name: "test-server"}
|
||||
assert.Equal(t, "test-server", s.Name())
|
||||
}
|
||||
|
||||
func TestMCPServer_Tools(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"returns cached tools without calling session",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tool := &mcpTool{
|
||||
name: "cached-tool",
|
||||
description: "A cached tool",
|
||||
inputSchema: json.RawMessage(`{"type":"object"}`),
|
||||
}
|
||||
|
||||
s := &MCPServer{name: "s"}
|
||||
s.cachedTools = []Tool{tool}
|
||||
s.toolsCached = true
|
||||
|
||||
tools, err := s.Tools(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tools, 1)
|
||||
assert.Equal(t, "cached-tool", tools[0].Name())
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"returns a defensive copy of cached tools",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tool := &mcpTool{
|
||||
name: "tool-a",
|
||||
description: "Tool A",
|
||||
inputSchema: json.RawMessage(`{}`),
|
||||
}
|
||||
|
||||
s := &MCPServer{name: "s"}
|
||||
s.cachedTools = []Tool{tool}
|
||||
s.toolsCached = true
|
||||
|
||||
tools1, err := s.Tools(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
tools2, err := s.Tools(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Mutating one slice must not affect the other.
|
||||
tools1[0] = nil
|
||||
assert.NotNil(t, tools2[0])
|
||||
|
||||
// Underlying cache must be untouched.
|
||||
s.mu.RLock()
|
||||
assert.NotNil(t, s.cachedTools[0])
|
||||
s.mu.RUnlock()
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"concurrent reads return consistent results",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := &MCPServer{name: "s"}
|
||||
s.cachedTools = []Tool{
|
||||
&mcpTool{
|
||||
name: "echo",
|
||||
description: "Echoes the input",
|
||||
inputSchema: json.RawMessage(`{"type":"object"}`),
|
||||
},
|
||||
}
|
||||
s.toolsCached = true
|
||||
|
||||
const goroutines = 50
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
|
||||
for range goroutines {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
tools, err := s.Tools(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, tools, 1)
|
||||
assert.Equal(t, "echo", tools[0].Name())
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestMCPServer_ResetCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"clears cached tools",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := &MCPServer{name: "s"}
|
||||
s.cachedTools = []Tool{
|
||||
&mcpTool{
|
||||
name: "tool1",
|
||||
description: "A tool",
|
||||
inputSchema: json.RawMessage(`{"type":"object"}`),
|
||||
},
|
||||
}
|
||||
s.toolsCached = true
|
||||
|
||||
tools, err := s.Tools(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tools, 1)
|
||||
|
||||
s.ResetCache()
|
||||
|
||||
s.mu.RLock()
|
||||
assert.False(t, s.toolsCached)
|
||||
assert.Nil(t, s.cachedTools)
|
||||
s.mu.RUnlock()
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"is safe to call on fresh server",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := &MCPServer{name: "s"}
|
||||
s.ResetCache()
|
||||
|
||||
s.mu.RLock()
|
||||
assert.False(t, s.toolsCached)
|
||||
assert.Nil(t, s.cachedTools)
|
||||
s.mu.RUnlock()
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"concurrent resets do not race",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := &MCPServer{name: "s"}
|
||||
s.cachedTools = []Tool{
|
||||
&mcpTool{
|
||||
name: "tool1",
|
||||
description: "A tool",
|
||||
inputSchema: json.RawMessage(`{"type":"object"}`),
|
||||
},
|
||||
}
|
||||
s.toolsCached = true
|
||||
|
||||
const goroutines = 50
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
|
||||
for range goroutines {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
s.ResetCache()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
s.mu.RLock()
|
||||
assert.False(t, s.toolsCached)
|
||||
assert.Nil(t, s.cachedTools)
|
||||
s.mu.RUnlock()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestMCPTool_Name(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tool := &mcpTool{name: "get_weather"}
|
||||
assert.Equal(t, "get_weather", tool.Name())
|
||||
}
|
||||
|
||||
func TestMCPTool_Definition(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
schema := json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}}}`)
|
||||
|
||||
tool := &mcpTool{
|
||||
name: "get_weather",
|
||||
description: "Returns current weather for a city",
|
||||
inputSchema: schema,
|
||||
}
|
||||
|
||||
def := tool.Definition()
|
||||
|
||||
assert.Equal(t, llm.Tool{
|
||||
Name: "get_weather",
|
||||
Description: "Returns current weather for a city",
|
||||
Parameters: schema,
|
||||
}, def)
|
||||
}
|
||||
|
||||
func TestMCPTool_Execute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"invalid JSON arguments returns error result",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tool := &mcpTool{name: "my_tool"}
|
||||
|
||||
result, err := tool.Execute(context.Background(), "not-json")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.Content, "Invalid arguments")
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"malformed JSON arguments returns error result",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tool := &mcpTool{name: "my_tool"}
|
||||
|
||||
result, err := tool.Execute(context.Background(), `{"key": }`)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.Content, "Invalid arguments")
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"JSON array arguments returns error result",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tool := &mcpTool{name: "my_tool"}
|
||||
|
||||
// Execute expects a JSON object, not an array.
|
||||
result, err := tool.Execute(context.Background(), `[1,2,3]`)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.Content, "Invalid arguments")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestExtractMCPContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"nil result returns empty",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, "", extractMCPContent(nil))
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"empty content returns empty",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := &mcp.CallToolResult{Content: []mcp.Content{}}
|
||||
assert.Equal(t, "", extractMCPContent(result))
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"single text content",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: "hello world"},
|
||||
},
|
||||
}
|
||||
assert.Equal(t, "hello world", extractMCPContent(result))
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"multiple text contents joined by newline",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: "line one"},
|
||||
&mcp.TextContent{Text: "line two"},
|
||||
},
|
||||
}
|
||||
assert.Equal(t, "line one\nline two", extractMCPContent(result))
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"non-text content is skipped",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: "text part"},
|
||||
&mcp.ImageContent{Data: []byte("base64data"), MIMEType: "image/png"},
|
||||
},
|
||||
}
|
||||
assert.Equal(t, "text part", extractMCPContent(result))
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"only non-text content returns empty",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.ImageContent{Data: []byte("base64data"), MIMEType: "image/png"},
|
||||
},
|
||||
}
|
||||
assert.Equal(t, "", extractMCPContent(result))
|
||||
},
|
||||
)
|
||||
}
|
||||
28
pkg/agent/model_settings.go
Normal file
28
pkg/agent/model_settings.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// 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 "go.probo.inc/probo/pkg/llm"
|
||||
|
||||
type ModelSettings struct {
|
||||
Temperature *float64
|
||||
TopP *float64
|
||||
FrequencyPenalty *float64
|
||||
PresencePenalty *float64
|
||||
MaxTokens *int
|
||||
ToolChoice *llm.ToolChoice
|
||||
ParallelToolCalls *bool
|
||||
}
|
||||
|
||||
46
pkg/agent/output_type.go
Normal file
46
pkg/agent/output_type.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// 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 (
|
||||
"encoding/json"
|
||||
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
// OutputType describes a structured output schema that the agent should
|
||||
// produce. Build one with NewOutputType[T]().
|
||||
type OutputType struct {
|
||||
Name string
|
||||
Schema json.RawMessage
|
||||
}
|
||||
|
||||
func NewOutputType[T any](name string) *OutputType {
|
||||
return &OutputType{
|
||||
Name: name,
|
||||
Schema: jsonSchemaFor[T](),
|
||||
}
|
||||
}
|
||||
|
||||
func (o *OutputType) responseFormat() *llm.ResponseFormat {
|
||||
return &llm.ResponseFormat{
|
||||
Type: llm.ResponseFormatJSONSchema,
|
||||
JSONSchema: &llm.JSONSchema{
|
||||
Name: o.Name,
|
||||
Schema: o.Schema,
|
||||
Strict: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
107
pkg/agent/output_type_test.go
Normal file
107
pkg/agent/output_type_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// 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 (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
func TestNewOutputType_SetsNameAndSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Result struct {
|
||||
Answer string `json:"answer"`
|
||||
Score int `json:"score"`
|
||||
}
|
||||
|
||||
ot := NewOutputType[Result]("test_result")
|
||||
|
||||
assert.Equal(t, "test_result", ot.Name)
|
||||
require.NotNil(t, ot.Schema)
|
||||
|
||||
var schema map[string]any
|
||||
require.NoError(t, json.Unmarshal(ot.Schema, &schema))
|
||||
assert.Equal(t, "object", schema["type"])
|
||||
|
||||
props := schema["properties"].(map[string]any)
|
||||
assert.Contains(t, props, "answer")
|
||||
assert.Contains(t, props, "score")
|
||||
}
|
||||
|
||||
func TestNewOutputType_EmptyStruct(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Empty struct{}
|
||||
|
||||
ot := NewOutputType[Empty]("empty")
|
||||
|
||||
assert.Equal(t, "empty", ot.Name)
|
||||
|
||||
var schema map[string]any
|
||||
require.NoError(t, json.Unmarshal(ot.Schema, &schema))
|
||||
assert.Equal(t, "object", schema["type"])
|
||||
|
||||
props, ok := schema["properties"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Empty(t, props)
|
||||
}
|
||||
|
||||
func TestOutputType_responseFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Verdict struct {
|
||||
Approved bool `json:"approved"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
ot := NewOutputType[Verdict]("verdict")
|
||||
rf := ot.responseFormat()
|
||||
|
||||
require.NotNil(t, rf)
|
||||
assert.Equal(t, llm.ResponseFormatJSONSchema, rf.Type)
|
||||
require.NotNil(t, rf.JSONSchema)
|
||||
assert.Equal(t, "verdict", rf.JSONSchema.Name)
|
||||
assert.True(t, rf.JSONSchema.Strict)
|
||||
assert.JSONEq(t, string(ot.Schema), string(rf.JSONSchema.Schema))
|
||||
}
|
||||
|
||||
func TestOutputType_responseFormat_SchemaMatchesOutputType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Analysis struct {
|
||||
Summary string `json:"summary"`
|
||||
Tags []string `json:"tags"`
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
}
|
||||
|
||||
ot := NewOutputType[Analysis]("analysis")
|
||||
rf := ot.responseFormat()
|
||||
|
||||
var schema map[string]any
|
||||
require.NoError(t, json.Unmarshal(rf.JSONSchema.Schema, &schema))
|
||||
|
||||
props := schema["properties"].(map[string]any)
|
||||
assert.Contains(t, props, "summary")
|
||||
assert.Contains(t, props, "tags")
|
||||
assert.Contains(t, props, "priority")
|
||||
|
||||
tagsProp := props["tags"].(map[string]any)
|
||||
assert.Equal(t, "array", tagsProp["type"])
|
||||
}
|
||||
33
pkg/agent/result.go
Normal file
33
pkg/agent/result.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// 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 "go.probo.inc/probo/pkg/llm"
|
||||
|
||||
type (
|
||||
Result struct {
|
||||
Messages []llm.Message
|
||||
Usage llm.Usage
|
||||
Turns int
|
||||
LastAgent *Agent
|
||||
}
|
||||
)
|
||||
|
||||
func (r *Result) FinalMessage() llm.Message {
|
||||
if len(r.Messages) == 0 {
|
||||
return llm.Message{}
|
||||
}
|
||||
return r.Messages[len(r.Messages)-1]
|
||||
}
|
||||
91
pkg/agent/result_test.go
Normal file
91
pkg/agent/result_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// 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_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.probo.inc/probo/pkg/agent"
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
func TestResult_FinalMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"returns zero value when messages is empty",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := &agent.Result{}
|
||||
msg := r.FinalMessage()
|
||||
|
||||
assert.Equal(t, llm.Message{}, msg)
|
||||
assert.Equal(t, "", msg.Text())
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"returns the last message with single message",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := &agent.Result{
|
||||
Messages: []llm.Message{
|
||||
assistantMessage("Hello!"),
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, "Hello!", r.FinalMessage().Text())
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"returns the last message with multiple messages",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := &agent.Result{
|
||||
Messages: []llm.Message{
|
||||
userMessage("Hi"),
|
||||
assistantMessage("Hello!"),
|
||||
userMessage("How are you?"),
|
||||
assistantMessage("I'm fine."),
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, "I'm fine.", r.FinalMessage().Text())
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"returns message regardless of role",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := &agent.Result{
|
||||
Messages: []llm.Message{
|
||||
assistantMessage("first"),
|
||||
userMessage("last user message"),
|
||||
},
|
||||
}
|
||||
|
||||
msg := r.FinalMessage()
|
||||
assert.Equal(t, llm.RoleUser, msg.Role)
|
||||
assert.Equal(t, "last user message", msg.Text())
|
||||
},
|
||||
)
|
||||
}
|
||||
978
pkg/agent/run.go
Normal file
978
pkg/agent/run.go
Normal file
@@ -0,0 +1,978 @@
|
||||
// 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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"go.gearno.de/kit/log"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
const tracerName = "go.probo.inc/probo/pkg/agent"
|
||||
|
||||
type (
|
||||
CallLLMFunc func(ctx context.Context, agent *Agent, req *llm.ChatCompletionRequest) (*llm.ChatCompletionResponse, error)
|
||||
|
||||
runOpts struct {
|
||||
callLLM CallLLMFunc
|
||||
onEvent func(ctx context.Context, ev StreamEvent)
|
||||
skipInputGuardrails bool
|
||||
skipSessionLoad bool
|
||||
initialUsage llm.Usage
|
||||
initialTurns int
|
||||
}
|
||||
|
||||
loopState struct {
|
||||
agent *Agent
|
||||
toolMap map[string]ToolDescriptor
|
||||
toolDefs []llm.Tool
|
||||
messages []llm.Message
|
||||
inputMessages []llm.Message
|
||||
systemPrompt string
|
||||
totalUsage llm.Usage
|
||||
turns int
|
||||
toolUsedInRun bool
|
||||
|
||||
tracer trace.Tracer
|
||||
runSpan trace.Span
|
||||
opts runOpts
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
parallelToolEntry struct {
|
||||
result ToolResult
|
||||
err error
|
||||
}
|
||||
)
|
||||
|
||||
func noopEvent(_ context.Context, _ StreamEvent) {}
|
||||
|
||||
func blockingCallLLM(ctx context.Context, agent *Agent, req *llm.ChatCompletionRequest) (*llm.ChatCompletionResponse, error) {
|
||||
return agent.client.ChatCompletion(ctx, req)
|
||||
}
|
||||
|
||||
func (a *Agent) Run(ctx context.Context, messages []llm.Message) (*Result, error) {
|
||||
return coreLoop(
|
||||
ctx,
|
||||
a,
|
||||
messages,
|
||||
runOpts{
|
||||
callLLM: blockingCallLLM,
|
||||
onEvent: noopEvent,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *loopState) resolveAgentTools(ctx context.Context) error {
|
||||
tools, toolMap, err := s.agent.resolveTools(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
toolDefs := make([]llm.Tool, len(tools))
|
||||
for i, t := range tools {
|
||||
toolDefs[i] = t.Definition()
|
||||
}
|
||||
|
||||
s.toolMap = toolMap
|
||||
s.toolDefs = toolDefs
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *loopState) finishRun(ctx context.Context, result *Result, err error) (*Result, error) {
|
||||
defer s.runSpan.End()
|
||||
defer func() {
|
||||
emitHook(s.agent, func(h RunHooks) { h.OnRunEnd(ctx, s.agent, result, err) })
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
s.runSpan.RecordError(err)
|
||||
s.runSpan.SetStatus(codes.Error, err.Error())
|
||||
s.opts.onEvent(ctx, StreamEvent{Type: StreamEventError, Agent: s.agent, Err: err})
|
||||
|
||||
s.logger.ErrorCtx(
|
||||
ctx,
|
||||
"agent run failed",
|
||||
log.String("agent", s.agent.name),
|
||||
log.Int("turns", s.turns),
|
||||
log.Error(err),
|
||||
)
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
if s.agent.session != nil {
|
||||
if saveErr := s.agent.session.Save(ctx, s.agent.sessionID, result.Messages); saveErr != nil {
|
||||
err = fmt.Errorf("cannot save session: %w", saveErr)
|
||||
result = nil
|
||||
|
||||
s.runSpan.RecordError(err)
|
||||
s.runSpan.SetStatus(codes.Error, err.Error())
|
||||
s.opts.onEvent(ctx, StreamEvent{Type: StreamEventError, Agent: s.agent, Err: err})
|
||||
|
||||
s.logger.ErrorCtx(
|
||||
ctx,
|
||||
"cannot save session",
|
||||
log.String("agent", s.agent.name),
|
||||
log.Error(err),
|
||||
)
|
||||
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
|
||||
s.runSpan.SetAttributes(
|
||||
attribute.Int("agent.turns", result.Turns),
|
||||
attribute.Int("agent.usage.input_tokens", result.Usage.InputTokens),
|
||||
attribute.Int("agent.usage.output_tokens", result.Usage.OutputTokens),
|
||||
)
|
||||
|
||||
s.logger.InfoCtx(
|
||||
ctx,
|
||||
"agent run completed",
|
||||
log.String("agent", s.agent.name),
|
||||
log.Int("turns", result.Turns),
|
||||
log.Int("input_tokens", result.Usage.InputTokens),
|
||||
log.Int("output_tokens", result.Usage.OutputTokens),
|
||||
)
|
||||
|
||||
s.opts.onEvent(ctx, StreamEvent{Type: StreamEventComplete, Agent: s.agent, Result: result})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *loopState) applyHandoff(ctx context.Context, handoffTarget *Handoff) error {
|
||||
emitHook(s.agent, func(h RunHooks) { h.OnHandoff(ctx, s.agent, handoffTarget.Agent) })
|
||||
emitAgentHook(handoffTarget.Agent, func(h AgentHooks) { h.OnHandoff(ctx, handoffTarget.Agent, s.agent) })
|
||||
|
||||
s.opts.onEvent(ctx, StreamEvent{Type: StreamEventHandoff, Agent: handoffTarget.Agent})
|
||||
|
||||
s.logger.InfoCtx(
|
||||
ctx,
|
||||
"agent handoff",
|
||||
log.String("from", s.agent.name),
|
||||
log.String("to", handoffTarget.Agent.name),
|
||||
)
|
||||
|
||||
if handoffTarget.InputFilter != nil {
|
||||
s.messages = handoffTarget.InputFilter(
|
||||
HandoffInputData{
|
||||
InputHistory: s.inputMessages,
|
||||
NewItems: s.messages[len(s.inputMessages):],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
s.agent = handoffTarget.Agent
|
||||
s.logger = s.agent.logger
|
||||
|
||||
if err := s.resolveAgentTools(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
emitAgentHook(s.agent, func(h AgentHooks) { h.OnStart(ctx, s.agent) })
|
||||
|
||||
s.opts.onEvent(ctx, StreamEvent{Type: StreamEventAgentStart, Agent: s.agent})
|
||||
|
||||
s.systemPrompt = s.agent.buildSystemPrompt(ctx)
|
||||
s.toolUsedInRun = false
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func coreLoop(ctx context.Context, startAgent *Agent, inputMessages []llm.Message, opts runOpts) (*Result, error) {
|
||||
s := &loopState{
|
||||
agent: startAgent,
|
||||
inputMessages: inputMessages,
|
||||
systemPrompt: startAgent.buildSystemPrompt(ctx),
|
||||
totalUsage: opts.initialUsage,
|
||||
turns: opts.initialTurns,
|
||||
tracer: otel.GetTracerProvider().Tracer(tracerName),
|
||||
opts: opts,
|
||||
logger: startAgent.logger,
|
||||
}
|
||||
|
||||
s.messages = make([]llm.Message, 0, len(inputMessages))
|
||||
|
||||
if !opts.skipSessionLoad && s.agent.session != nil {
|
||||
prev, err := s.agent.session.Load(ctx, s.agent.sessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot load session: %w", err)
|
||||
}
|
||||
|
||||
s.logger.InfoCtx(
|
||||
ctx,
|
||||
"session loaded",
|
||||
log.String("session_id", s.agent.sessionID),
|
||||
log.Int("message_count", len(prev)),
|
||||
)
|
||||
|
||||
s.messages = append(s.messages, prev...)
|
||||
}
|
||||
|
||||
s.messages = append(s.messages, inputMessages...)
|
||||
|
||||
if !opts.skipInputGuardrails {
|
||||
if err := runInputGuardrails(ctx, s.agent, s.messages); err != nil {
|
||||
opts.onEvent(ctx, StreamEvent{Type: StreamEventError, Agent: s.agent, Err: err})
|
||||
|
||||
s.logger.ErrorCtx(
|
||||
ctx,
|
||||
"input guardrail tripped",
|
||||
log.Error(err),
|
||||
)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
emitHook(s.agent, func(h RunHooks) { h.OnRunStart(ctx, s.agent, s.messages) })
|
||||
emitAgentHook(s.agent, func(h AgentHooks) { h.OnStart(ctx, s.agent) })
|
||||
|
||||
opts.onEvent(ctx, StreamEvent{Type: StreamEventAgentStart, Agent: s.agent})
|
||||
|
||||
ctx, s.runSpan = s.tracer.Start(
|
||||
ctx,
|
||||
fmt.Sprintf("agent.run %s", s.agent.name),
|
||||
trace.WithSpanKind(trace.SpanKindInternal),
|
||||
trace.WithAttributes(
|
||||
attribute.String("agent.name", s.agent.name),
|
||||
attribute.String("agent.model", s.agent.model),
|
||||
),
|
||||
)
|
||||
|
||||
if err := s.resolveAgentTools(ctx); err != nil {
|
||||
s.runSpan.End()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.InfoCtx(
|
||||
ctx,
|
||||
"agent run started",
|
||||
log.String("model", s.agent.model),
|
||||
log.Int("max_turns", s.agent.maxTurns),
|
||||
log.Int("tool_count", len(s.toolDefs)),
|
||||
)
|
||||
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return s.finishRun(ctx, nil, fmt.Errorf("cannot complete: %w", err))
|
||||
}
|
||||
|
||||
if s.turns >= s.agent.maxTurns {
|
||||
return s.finishRun(ctx, nil, &MaxTurnsExceededError{MaxTurns: s.agent.maxTurns})
|
||||
}
|
||||
|
||||
fullMessages := buildFullMessages(s.systemPrompt, s.messages)
|
||||
|
||||
responseFormat := s.agent.responseFormat
|
||||
if responseFormat == nil && s.agent.outputType != nil {
|
||||
responseFormat = s.agent.outputType.responseFormat()
|
||||
}
|
||||
|
||||
toolChoice := s.agent.modelSettings.ToolChoice
|
||||
if s.toolUsedInRun && s.agent.resetToolChoice && toolChoice != nil {
|
||||
toolChoice = nil
|
||||
}
|
||||
|
||||
req := &llm.ChatCompletionRequest{
|
||||
Model: s.agent.model,
|
||||
Messages: fullMessages,
|
||||
Tools: s.toolDefs,
|
||||
Temperature: s.agent.modelSettings.Temperature,
|
||||
TopP: s.agent.modelSettings.TopP,
|
||||
FrequencyPenalty: s.agent.modelSettings.FrequencyPenalty,
|
||||
PresencePenalty: s.agent.modelSettings.PresencePenalty,
|
||||
MaxTokens: s.agent.modelSettings.MaxTokens,
|
||||
ToolChoice: toolChoice,
|
||||
ParallelToolCalls: s.agent.modelSettings.ParallelToolCalls,
|
||||
ResponseFormat: responseFormat,
|
||||
}
|
||||
|
||||
s.logger.InfoCtx(
|
||||
ctx,
|
||||
"calling LLM",
|
||||
log.Int("turn", s.turns+1),
|
||||
log.Int("message_count", len(fullMessages)),
|
||||
)
|
||||
|
||||
resp, err := callLLMWithHooks(ctx, s.agent, req, opts)
|
||||
if err != nil {
|
||||
return s.finishRun(ctx, nil, fmt.Errorf("cannot call LLM: %w", err))
|
||||
}
|
||||
|
||||
s.totalUsage = s.totalUsage.Add(resp.Usage)
|
||||
s.turns++
|
||||
|
||||
s.logger.InfoCtx(
|
||||
ctx,
|
||||
"LLM response received",
|
||||
log.Int("turn", s.turns),
|
||||
log.String("finish_reason", string(resp.FinishReason)),
|
||||
log.Int("input_tokens", resp.Usage.InputTokens),
|
||||
log.Int("output_tokens", resp.Usage.OutputTokens),
|
||||
)
|
||||
|
||||
s.messages = append(s.messages, resp.Message)
|
||||
|
||||
switch resp.FinishReason {
|
||||
case llm.FinishReasonStop, llm.FinishReasonLength:
|
||||
if err := runOutputGuardrails(ctx, s.agent, resp.Message); err != nil {
|
||||
return s.finishRun(ctx, nil, err)
|
||||
}
|
||||
|
||||
result := &Result{
|
||||
Messages: s.messages,
|
||||
Usage: s.totalUsage,
|
||||
Turns: s.turns,
|
||||
LastAgent: s.agent,
|
||||
}
|
||||
|
||||
emitAgentHook(s.agent, func(h AgentHooks) { h.OnEnd(ctx, s.agent, resp.Message.Text()) })
|
||||
|
||||
opts.onEvent(ctx, StreamEvent{Type: StreamEventAgentEnd, Agent: s.agent})
|
||||
return s.finishRun(ctx, result, nil)
|
||||
|
||||
case llm.FinishReasonToolCalls:
|
||||
s.toolUsedInRun = true
|
||||
|
||||
s.logger.InfoCtx(
|
||||
ctx,
|
||||
"dispatching tool calls",
|
||||
log.Int("count", len(resp.Message.ToolCalls)),
|
||||
)
|
||||
|
||||
handoffTarget, toolResults, toolMsgs, err := dispatchToolCalls(
|
||||
ctx,
|
||||
s.tracer,
|
||||
s.agent,
|
||||
resp.Message.ToolCalls,
|
||||
s.toolMap,
|
||||
opts.onEvent,
|
||||
s.logger,
|
||||
)
|
||||
s.messages = append(s.messages, toolMsgs...)
|
||||
|
||||
if err != nil {
|
||||
if nae, ok := errors.AsType[*needsApprovalError](err); ok {
|
||||
s.logger.InfoCtx(
|
||||
ctx,
|
||||
"run interrupted, approval required",
|
||||
log.Int("pending_count", len(nae.pendingApprovals)),
|
||||
)
|
||||
|
||||
msgsCopy := make([]llm.Message, len(s.messages))
|
||||
copy(msgsCopy, s.messages)
|
||||
|
||||
return s.finishRun(
|
||||
ctx,
|
||||
nil,
|
||||
&InterruptedError{
|
||||
ToolCalls: nae.allToolCalls,
|
||||
PendingApprovals: nae.pendingApprovals,
|
||||
Agent: s.agent,
|
||||
Messages: msgsCopy,
|
||||
Usage: s.totalUsage,
|
||||
Turns: s.turns,
|
||||
},
|
||||
)
|
||||
}
|
||||
return s.finishRun(ctx, nil, err)
|
||||
}
|
||||
|
||||
finalOutput, isFinal, behaviorErr := s.agent.toolUseBehavior(ctx, toolResults)
|
||||
if behaviorErr != nil {
|
||||
return s.finishRun(ctx, nil, fmt.Errorf("cannot evaluate tool use behavior: %w", behaviorErr))
|
||||
}
|
||||
|
||||
if isFinal {
|
||||
s.messages = append(
|
||||
s.messages, llm.Message{
|
||||
Role: llm.RoleAssistant,
|
||||
Parts: []llm.Part{llm.TextPart{Text: finalOutput}},
|
||||
},
|
||||
)
|
||||
|
||||
result := &Result{
|
||||
Messages: s.messages,
|
||||
Usage: s.totalUsage,
|
||||
Turns: s.turns,
|
||||
LastAgent: s.agent,
|
||||
}
|
||||
|
||||
emitAgentHook(s.agent, func(h AgentHooks) { h.OnEnd(ctx, s.agent, finalOutput) })
|
||||
|
||||
opts.onEvent(ctx, StreamEvent{Type: StreamEventAgentEnd, Agent: s.agent})
|
||||
return s.finishRun(ctx, result, nil)
|
||||
}
|
||||
|
||||
if handoffTarget != nil {
|
||||
if handoffErr := s.applyHandoff(ctx, handoffTarget); handoffErr != nil {
|
||||
return s.finishRun(ctx, nil, handoffErr)
|
||||
}
|
||||
}
|
||||
|
||||
case llm.FinishReasonContentFilter:
|
||||
return s.finishRun(ctx, nil, fmt.Errorf("cannot complete: content was filtered by the provider"))
|
||||
|
||||
default:
|
||||
return s.finishRun(ctx, nil, fmt.Errorf("cannot complete: unexpected finish reason %q", resp.FinishReason))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildFullMessages(systemPrompt string, messages []llm.Message) []llm.Message {
|
||||
fullMessages := make([]llm.Message, 0, len(messages)+1)
|
||||
if systemPrompt != "" {
|
||||
fullMessages = append(
|
||||
fullMessages,
|
||||
llm.Message{
|
||||
Role: llm.RoleSystem,
|
||||
Parts: []llm.Part{llm.TextPart{Text: systemPrompt}},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return append(fullMessages, messages...)
|
||||
}
|
||||
|
||||
func callLLMWithHooks(
|
||||
ctx context.Context,
|
||||
agent *Agent,
|
||||
req *llm.ChatCompletionRequest,
|
||||
opts runOpts,
|
||||
) (*llm.ChatCompletionResponse, error) {
|
||||
emitHook(agent, func(h RunHooks) { h.OnLLMStart(ctx, agent, req.Messages) })
|
||||
emitAgentHook(agent, func(h AgentHooks) { h.OnLLMStart(ctx, agent, req.Messages) })
|
||||
|
||||
resp, err := opts.callLLM(ctx, agent, req)
|
||||
if err != nil {
|
||||
emitHook(agent, func(h RunHooks) { h.OnLLMEnd(ctx, agent, nil, err) })
|
||||
emitAgentHook(agent, func(h AgentHooks) { h.OnLLMEnd(ctx, agent, nil, err) })
|
||||
return nil, err
|
||||
}
|
||||
|
||||
emitHook(agent, func(h RunHooks) { h.OnLLMEnd(ctx, agent, resp, nil) })
|
||||
emitAgentHook(agent, func(h AgentHooks) { h.OnLLMEnd(ctx, agent, resp, nil) })
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func dispatchToolCalls(
|
||||
ctx context.Context,
|
||||
tracer trace.Tracer,
|
||||
agent *Agent,
|
||||
toolCalls []llm.ToolCall,
|
||||
toolMap map[string]ToolDescriptor,
|
||||
onEvent func(context.Context, StreamEvent),
|
||||
logger *log.Logger,
|
||||
) (*Handoff, []ToolCallResult, []llm.Message, error) {
|
||||
if err := checkApproval(ctx, agent, toolCalls); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
return executeToolCalls(ctx, tracer, agent, toolCalls, toolMap, onEvent, logger)
|
||||
}
|
||||
|
||||
func executeToolCalls(
|
||||
ctx context.Context,
|
||||
tracer trace.Tracer,
|
||||
agent *Agent,
|
||||
toolCalls []llm.ToolCall,
|
||||
toolMap map[string]ToolDescriptor,
|
||||
onEvent func(context.Context, StreamEvent),
|
||||
logger *log.Logger,
|
||||
) (*Handoff, []ToolCallResult, []llm.Message, error) {
|
||||
descriptors := make([]ToolDescriptor, len(toolCalls))
|
||||
handoffIdx := -1
|
||||
|
||||
for i, tc := range toolCalls {
|
||||
desc, ok := toolMap[tc.Function.Name]
|
||||
if !ok {
|
||||
return nil, nil, nil, fmt.Errorf("cannot dispatch tool call: unknown tool %q", tc.Function.Name)
|
||||
}
|
||||
descriptors[i] = desc
|
||||
if _, isHandoff := desc.(*handoffToolAdapter); isHandoff && handoffIdx == -1 {
|
||||
handoffIdx = i
|
||||
}
|
||||
}
|
||||
|
||||
if handoffIdx >= 0 {
|
||||
return executeWithHandoff(ctx, tracer, agent, toolCalls, descriptors, handoffIdx, onEvent, logger)
|
||||
}
|
||||
|
||||
tools := make([]Tool, len(descriptors))
|
||||
for i, d := range descriptors {
|
||||
tools[i] = d.(Tool)
|
||||
}
|
||||
|
||||
results, msgs, err := executeParallel(ctx, tracer, agent, toolCalls, tools, onEvent, logger)
|
||||
return nil, results, msgs, err
|
||||
}
|
||||
|
||||
func executeWithHandoff(
|
||||
ctx context.Context,
|
||||
tracer trace.Tracer,
|
||||
agent *Agent,
|
||||
toolCalls []llm.ToolCall,
|
||||
descriptors []ToolDescriptor,
|
||||
handoffIdx int,
|
||||
onEvent func(context.Context, StreamEvent),
|
||||
logger *log.Logger,
|
||||
) (*Handoff, []ToolCallResult, []llm.Message, error) {
|
||||
var (
|
||||
results []ToolCallResult
|
||||
msgs []llm.Message
|
||||
)
|
||||
|
||||
for i := range handoffIdx {
|
||||
logger.InfoCtx(
|
||||
ctx,
|
||||
"executing tool before handoff",
|
||||
log.String("tool", toolCalls[i].Function.Name),
|
||||
)
|
||||
|
||||
tr, err := executeSingleTool(ctx, tracer, agent, toolCalls[i], descriptors[i].(Tool), onEvent, logger)
|
||||
if err != nil {
|
||||
return nil, nil, msgs, err
|
||||
}
|
||||
|
||||
msgs = append(
|
||||
msgs,
|
||||
llm.Message{
|
||||
Role: llm.RoleTool,
|
||||
ToolCallID: toolCalls[i].ID,
|
||||
Parts: []llm.Part{llm.TextPart{Text: tr.Content}},
|
||||
},
|
||||
)
|
||||
|
||||
results = append(
|
||||
results,
|
||||
ToolCallResult{
|
||||
ToolName: toolCalls[i].Function.Name,
|
||||
Arguments: toolCalls[i].Function.Arguments,
|
||||
Result: tr,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ht := descriptors[handoffIdx].(*handoffToolAdapter)
|
||||
if ht.handoff.OnHandoff != nil {
|
||||
if err := ht.handoff.OnHandoff(ctx); err != nil {
|
||||
return nil, nil, msgs, fmt.Errorf("cannot execute handoff callback for %q: %w", ht.handoff.Agent.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
msgs = append(
|
||||
msgs,
|
||||
llm.Message{
|
||||
Role: llm.RoleTool,
|
||||
ToolCallID: toolCalls[handoffIdx].ID,
|
||||
Parts: []llm.Part{llm.TextPart{Text: fmt.Sprintf("Transferred to %s", ht.handoff.Agent.name)}},
|
||||
},
|
||||
)
|
||||
|
||||
return ht.handoff, results, msgs, nil
|
||||
}
|
||||
|
||||
func executeParallel(
|
||||
ctx context.Context,
|
||||
tracer trace.Tracer,
|
||||
agent *Agent,
|
||||
toolCalls []llm.ToolCall,
|
||||
tools []Tool,
|
||||
onEvent func(context.Context, StreamEvent),
|
||||
logger *log.Logger,
|
||||
) ([]ToolCallResult, []llm.Message, error) {
|
||||
entries := make([]parallelToolEntry, len(toolCalls))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Add(len(toolCalls))
|
||||
for i := range toolCalls {
|
||||
go func(idx int, tc llm.ToolCall, tool Tool) {
|
||||
defer wg.Done()
|
||||
|
||||
tr, err := executeSingleTool(ctx, tracer, agent, tc, tool, onEvent, logger)
|
||||
if err != nil {
|
||||
entries[idx] = parallelToolEntry{err: err}
|
||||
return
|
||||
}
|
||||
entries[idx] = parallelToolEntry{result: tr}
|
||||
}(i, toolCalls[i], tools[i])
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
var (
|
||||
results []ToolCallResult
|
||||
msgs []llm.Message
|
||||
)
|
||||
|
||||
for i, tc := range toolCalls {
|
||||
entry := entries[i]
|
||||
|
||||
if entry.err != nil {
|
||||
msgs = append(
|
||||
msgs,
|
||||
llm.Message{
|
||||
Role: llm.RoleTool,
|
||||
ToolCallID: tc.ID,
|
||||
Parts: []llm.Part{
|
||||
llm.TextPart{
|
||||
Text: fmt.Sprintf("Error: %s", entry.err.Error()),
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
results = append(
|
||||
results,
|
||||
ToolCallResult{
|
||||
ToolName: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
Result: ToolResult{
|
||||
Content: fmt.Sprintf("Error: %s", entry.err.Error()),
|
||||
IsError: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
msgs = append(
|
||||
msgs,
|
||||
llm.Message{
|
||||
Role: llm.RoleTool,
|
||||
ToolCallID: tc.ID,
|
||||
Parts: []llm.Part{llm.TextPart{Text: entry.result.Content}},
|
||||
},
|
||||
)
|
||||
|
||||
results = append(
|
||||
results,
|
||||
ToolCallResult{
|
||||
ToolName: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
Result: entry.result,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return results, msgs, nil
|
||||
}
|
||||
|
||||
func executeSingleTool(
|
||||
ctx context.Context,
|
||||
tracer trace.Tracer,
|
||||
agent *Agent,
|
||||
tc llm.ToolCall,
|
||||
tool Tool,
|
||||
onEvent func(context.Context, StreamEvent),
|
||||
logger *log.Logger,
|
||||
) (ToolResult, error) {
|
||||
onEvent(ctx, StreamEvent{Type: StreamEventToolStart, Agent: agent, Tool: tool})
|
||||
|
||||
emitHook(agent, func(h RunHooks) { h.OnToolStart(ctx, agent, tool, tc.Function.Arguments) })
|
||||
emitAgentHook(agent, func(h AgentHooks) { h.OnToolStart(ctx, agent, tool) })
|
||||
|
||||
toolCtx, toolSpan := tracer.Start(
|
||||
ctx,
|
||||
fmt.Sprintf("agent.tool %s", tool.Name()),
|
||||
trace.WithSpanKind(trace.SpanKindInternal),
|
||||
trace.WithAttributes(
|
||||
attribute.String("tool.name", tool.Name()),
|
||||
),
|
||||
)
|
||||
|
||||
logger.InfoCtx(
|
||||
ctx,
|
||||
"executing tool",
|
||||
log.String("tool", tool.Name()),
|
||||
)
|
||||
|
||||
result, err := tool.Execute(toolCtx, tc.Function.Arguments)
|
||||
if err != nil {
|
||||
toolSpan.RecordError(err)
|
||||
toolSpan.SetStatus(codes.Error, err.Error())
|
||||
toolSpan.End()
|
||||
|
||||
emitHook(agent, func(h RunHooks) { h.OnToolEnd(ctx, agent, tool, result, err) })
|
||||
|
||||
logger.ErrorCtx(
|
||||
ctx,
|
||||
"tool execution failed",
|
||||
log.String("tool", tool.Name()),
|
||||
log.Error(err),
|
||||
)
|
||||
|
||||
return ToolResult{}, fmt.Errorf("cannot execute tool %q: %w", tool.Name(), err)
|
||||
}
|
||||
|
||||
toolSpan.SetAttributes(attribute.Bool("tool.is_error", result.IsError))
|
||||
toolSpan.End()
|
||||
|
||||
onEvent(ctx, StreamEvent{Type: StreamEventToolEnd, Agent: agent, Tool: tool, ToolResult: &result})
|
||||
|
||||
emitHook(agent, func(h RunHooks) { h.OnToolEnd(ctx, agent, tool, result, nil) })
|
||||
emitAgentHook(agent, func(h AgentHooks) { h.OnToolEnd(ctx, agent, tool, result) })
|
||||
|
||||
logger.InfoCtx(
|
||||
ctx,
|
||||
"tool execution completed",
|
||||
log.String("tool", tool.Name()),
|
||||
log.Bool("is_error", result.IsError),
|
||||
)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func checkApproval(ctx context.Context, a *Agent, toolCalls []llm.ToolCall) error {
|
||||
if a.approval == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var pending []llm.ToolCall
|
||||
for _, tc := range toolCalls {
|
||||
if a.approval.requiresApproval(ctx, tc) {
|
||||
pending = append(pending, tc)
|
||||
}
|
||||
}
|
||||
|
||||
if len(pending) > 0 {
|
||||
return &needsApprovalError{
|
||||
allToolCalls: toolCalls,
|
||||
pendingApprovals: pending,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runInputGuardrails(ctx context.Context, agent *Agent, messages []llm.Message) error {
|
||||
for _, g := range agent.inputGuardrails {
|
||||
result, err := g.Check(ctx, messages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot run input guardrail %q: %w", g.Name(), err)
|
||||
}
|
||||
|
||||
if result != nil && result.Tripwire {
|
||||
emitHook(agent, func(h RunHooks) { h.OnGuardrailTripped(ctx, agent, g.Name(), result) })
|
||||
|
||||
return &InputGuardrailTrippedError{
|
||||
Guardrail: g.Name(),
|
||||
Message: result.Message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runOutputGuardrails(ctx context.Context, agent *Agent, message llm.Message) error {
|
||||
for _, g := range agent.outputGuardrails {
|
||||
result, err := g.Check(ctx, message)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot run output guardrail %q: %w", g.Name(), err)
|
||||
}
|
||||
|
||||
if result != nil && result.Tripwire {
|
||||
emitHook(agent, func(h RunHooks) { h.OnGuardrailTripped(ctx, agent, g.Name(), result) })
|
||||
|
||||
return &OutputGuardrailTrippedError{
|
||||
Guardrail: g.Name(),
|
||||
Message: result.Message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resume continues an interrupted run after human approval decisions have
|
||||
// been collected. It executes or denies each pending tool call according to
|
||||
// the provided ResumeInput, then re-enters the agent loop. Input guardrails
|
||||
// are not re-evaluated because the messages were already validated in the
|
||||
// original Run call.
|
||||
func Resume(ctx context.Context, interrupted *InterruptedError, input ResumeInput) (*Result, error) {
|
||||
tracer := otel.GetTracerProvider().Tracer(tracerName)
|
||||
logger := interrupted.Agent.logger
|
||||
|
||||
agent := interrupted.Agent
|
||||
messages := interrupted.Messages
|
||||
|
||||
logger.InfoCtx(
|
||||
ctx,
|
||||
"resuming interrupted run",
|
||||
log.String("agent", agent.name),
|
||||
log.Int("pending_approvals", len(interrupted.PendingApprovals)),
|
||||
log.Int("total_tool_calls", len(interrupted.ToolCalls)),
|
||||
)
|
||||
|
||||
_, toolMap, err := agent.resolveTools(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot resolve tools for resume: %w", err)
|
||||
}
|
||||
|
||||
pendingSet := make(map[string]struct{}, len(interrupted.PendingApprovals))
|
||||
for _, tc := range interrupted.PendingApprovals {
|
||||
pendingSet[tc.ID] = struct{}{}
|
||||
}
|
||||
|
||||
var handoffTarget *Handoff
|
||||
|
||||
for _, tc := range interrupted.ToolCalls {
|
||||
if _, needsApproval := pendingSet[tc.ID]; needsApproval {
|
||||
approval, ok := input.Approvals[tc.ID]
|
||||
if !ok || !approval.Approved {
|
||||
reason := "Tool call was denied by human review."
|
||||
if ok && approval.Message != "" {
|
||||
reason = approval.Message
|
||||
}
|
||||
|
||||
logger.InfoCtx(
|
||||
ctx,
|
||||
"tool call denied",
|
||||
log.String("tool", tc.Function.Name),
|
||||
log.String("tool_call_id", tc.ID),
|
||||
log.String("reason", reason),
|
||||
)
|
||||
|
||||
messages = append(
|
||||
messages,
|
||||
llm.Message{
|
||||
Role: llm.RoleTool,
|
||||
ToolCallID: tc.ID,
|
||||
Parts: []llm.Part{
|
||||
llm.TextPart{Text: reason},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
logger.InfoCtx(
|
||||
ctx,
|
||||
"tool call approved",
|
||||
log.String("tool", tc.Function.Name),
|
||||
log.String("tool_call_id", tc.ID),
|
||||
)
|
||||
}
|
||||
|
||||
desc, toolOK := toolMap[tc.Function.Name]
|
||||
if !toolOK {
|
||||
return nil, fmt.Errorf("unknown tool %q", tc.Function.Name)
|
||||
}
|
||||
|
||||
if ht, ok := desc.(*handoffToolAdapter); ok {
|
||||
if ht.handoff.OnHandoff != nil {
|
||||
if err := ht.handoff.OnHandoff(ctx); err != nil {
|
||||
return nil, fmt.Errorf("cannot execute handoff callback for %q: %w", ht.handoff.Agent.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
messages = append(
|
||||
messages,
|
||||
llm.Message{
|
||||
Role: llm.RoleTool,
|
||||
ToolCallID: tc.ID,
|
||||
Parts: []llm.Part{
|
||||
llm.TextPart{
|
||||
Text: fmt.Sprintf("Transferred to %s", ht.handoff.Agent.name),
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
handoffTarget = ht.handoff
|
||||
break
|
||||
}
|
||||
|
||||
tr, execErr := executeSingleTool(ctx, tracer, agent, tc, desc.(Tool), noopEvent, logger)
|
||||
if execErr != nil {
|
||||
return nil, execErr
|
||||
}
|
||||
|
||||
messages = append(
|
||||
messages,
|
||||
llm.Message{
|
||||
Role: llm.RoleTool,
|
||||
ToolCallID: tc.ID,
|
||||
Parts: []llm.Part{
|
||||
llm.TextPart{
|
||||
Text: tr.Content,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
resumeAgent := agent
|
||||
if handoffTarget != nil {
|
||||
logger.InfoCtx(
|
||||
ctx,
|
||||
"resume handoff",
|
||||
log.String("from", agent.name),
|
||||
log.String("to", handoffTarget.Agent.name),
|
||||
)
|
||||
|
||||
emitHook(agent, func(h RunHooks) { h.OnHandoff(ctx, agent, handoffTarget.Agent) })
|
||||
emitAgentHook(handoffTarget.Agent, func(h AgentHooks) { h.OnHandoff(ctx, handoffTarget.Agent, agent) })
|
||||
|
||||
if handoffTarget.InputFilter != nil {
|
||||
filtered := handoffTarget.InputFilter(
|
||||
HandoffInputData{
|
||||
InputHistory: interrupted.Messages,
|
||||
NewItems: messages[len(interrupted.Messages):],
|
||||
},
|
||||
)
|
||||
messages = filtered
|
||||
}
|
||||
|
||||
resumeAgent = handoffTarget.Agent
|
||||
}
|
||||
|
||||
return coreLoop(
|
||||
ctx,
|
||||
resumeAgent,
|
||||
messages,
|
||||
runOpts{
|
||||
callLLM: blockingCallLLM,
|
||||
onEvent: noopEvent,
|
||||
skipInputGuardrails: true,
|
||||
skipSessionLoad: true,
|
||||
initialUsage: interrupted.Usage,
|
||||
initialTurns: interrupted.Turns,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func emitHook(agent *Agent, fn func(RunHooks)) {
|
||||
for _, h := range agent.hooks {
|
||||
fn(h)
|
||||
}
|
||||
}
|
||||
|
||||
func emitAgentHook(agent *Agent, fn func(AgentHooks)) {
|
||||
if agent.agentHooks != nil {
|
||||
fn(agent.agentHooks)
|
||||
}
|
||||
}
|
||||
84
pkg/agent/schema.go
Normal file
84
pkg/agent/schema.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// 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 (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
)
|
||||
|
||||
func jsonSchemaFor[T any]() json.RawMessage {
|
||||
t := reflect.TypeFor[T]()
|
||||
|
||||
schema, err := jsonschema.ForType(t, nil)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("cannot generate schema for %s: %v", t, err))
|
||||
}
|
||||
|
||||
stripNullTypes(schema)
|
||||
|
||||
data, err := json.Marshal(schema)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("cannot marshal schema for %s: %v", t, err))
|
||||
}
|
||||
|
||||
return json.RawMessage(data)
|
||||
}
|
||||
|
||||
// stripNullTypes removes "null" from union types produced by pointer fields
|
||||
// (e.g. ["null","string"] becomes "string") and clears integer bounds so that
|
||||
// LLM providers receive a clean schema without Go-specific type constraints.
|
||||
func stripNullTypes(s *jsonschema.Schema) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(s.Types) > 0 {
|
||||
filtered := make([]string, 0, len(s.Types))
|
||||
for _, t := range s.Types {
|
||||
if t != "null" {
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 1 {
|
||||
s.Type = filtered[0]
|
||||
s.Types = nil
|
||||
} else if len(filtered) > 1 {
|
||||
s.Types = filtered
|
||||
}
|
||||
}
|
||||
|
||||
s.Minimum = nil
|
||||
s.Maximum = nil
|
||||
|
||||
if s.Type == "object" && s.Properties == nil {
|
||||
s.Properties = make(map[string]*jsonschema.Schema)
|
||||
}
|
||||
|
||||
for _, prop := range s.Properties {
|
||||
stripNullTypes(prop)
|
||||
}
|
||||
|
||||
if s.Items != nil {
|
||||
stripNullTypes(s.Items)
|
||||
}
|
||||
|
||||
if s.AdditionalProperties != nil {
|
||||
stripNullTypes(s.AdditionalProperties)
|
||||
}
|
||||
}
|
||||
325
pkg/agent/schema_test.go
Normal file
325
pkg/agent/schema_test.go
Normal file
@@ -0,0 +1,325 @@
|
||||
// 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 (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenerateSchema_PointerFieldsStripNull(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct {
|
||||
Name *string `json:"name"`
|
||||
Count *int `json:"count"`
|
||||
Score *float64 `json:"score"`
|
||||
Done *bool `json:"done"`
|
||||
}
|
||||
|
||||
raw := jsonSchemaFor[Params]()
|
||||
|
||||
var schema map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &schema))
|
||||
|
||||
props := schema["properties"].(map[string]any)
|
||||
|
||||
for _, field := range []struct {
|
||||
name string
|
||||
wantType string
|
||||
}{
|
||||
{"name", "string"},
|
||||
{"count", "integer"},
|
||||
{"score", "number"},
|
||||
{"done", "boolean"},
|
||||
} {
|
||||
prop := props[field.name].(map[string]any)
|
||||
assert.Equal(t, field.wantType, prop["type"], "field %s", field.name)
|
||||
assert.Nil(t, prop["types"], "field %s should not have union types", field.name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSchema_IntegerBoundsStripped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct {
|
||||
Int8 int8 `json:"int8"`
|
||||
Int16 int16 `json:"int16"`
|
||||
Int32 int32 `json:"int32"`
|
||||
Uint8 uint8 `json:"uint8"`
|
||||
Uint16 uint16 `json:"uint16"`
|
||||
}
|
||||
|
||||
raw := jsonSchemaFor[Params]()
|
||||
|
||||
var schema map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &schema))
|
||||
|
||||
props := schema["properties"].(map[string]any)
|
||||
|
||||
for _, name := range []string{"int8", "int16", "int32", "uint8", "uint16"} {
|
||||
prop := props[name].(map[string]any)
|
||||
assert.Equal(t, "integer", prop["type"], "field %s", name)
|
||||
assert.Nil(t, prop["minimum"], "field %s should have no minimum", name)
|
||||
assert.Nil(t, prop["maximum"], "field %s should have no maximum", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSchema_EmptyStruct(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct{}
|
||||
|
||||
raw := jsonSchemaFor[Params]()
|
||||
|
||||
var schema map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &schema))
|
||||
|
||||
assert.Equal(t, "object", schema["type"])
|
||||
|
||||
props, ok := schema["properties"].(map[string]any)
|
||||
require.True(t, ok, "empty struct should have a properties field")
|
||||
assert.Empty(t, props)
|
||||
}
|
||||
|
||||
func TestGenerateSchema_MapField(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct {
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
raw := jsonSchemaFor[Params]()
|
||||
|
||||
var schema map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &schema))
|
||||
|
||||
props := schema["properties"].(map[string]any)
|
||||
metaProp := props["metadata"].(map[string]any)
|
||||
|
||||
assert.Equal(t, "object", metaProp["type"])
|
||||
|
||||
addlProps, ok := metaProp["additionalProperties"].(map[string]any)
|
||||
require.True(t, ok, "map field should produce additionalProperties")
|
||||
assert.Equal(t, "string", addlProps["type"])
|
||||
}
|
||||
|
||||
func TestGenerateSchema_NestedPointerStruct(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Inner struct {
|
||||
Value *string `json:"value"`
|
||||
}
|
||||
type Params struct {
|
||||
Inner *Inner `json:"inner"`
|
||||
}
|
||||
|
||||
raw := jsonSchemaFor[Params]()
|
||||
|
||||
var schema map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &schema))
|
||||
|
||||
props := schema["properties"].(map[string]any)
|
||||
|
||||
innerProp := props["inner"].(map[string]any)
|
||||
assert.Equal(t, "object", innerProp["type"])
|
||||
assert.Nil(t, innerProp["types"], "pointer to struct should not have union types")
|
||||
|
||||
innerProps := innerProp["properties"].(map[string]any)
|
||||
valueProp := innerProps["value"].(map[string]any)
|
||||
assert.Equal(t, "string", valueProp["type"])
|
||||
assert.Nil(t, valueProp["types"], "nested pointer field should not have union types")
|
||||
}
|
||||
|
||||
func TestGenerateSchema_SliceOfPointers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct {
|
||||
Names []*string `json:"names"`
|
||||
}
|
||||
|
||||
raw := jsonSchemaFor[Params]()
|
||||
|
||||
var schema map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &schema))
|
||||
|
||||
props := schema["properties"].(map[string]any)
|
||||
|
||||
namesProp := props["names"].(map[string]any)
|
||||
assert.Equal(t, "array", namesProp["type"])
|
||||
|
||||
items := namesProp["items"].(map[string]any)
|
||||
assert.Equal(t, "string", items["type"])
|
||||
assert.Nil(t, items["types"], "array items from pointer should not have union types")
|
||||
}
|
||||
|
||||
func TestGenerateSchema_MapWithPointerValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct {
|
||||
Scores map[string]*int `json:"scores"`
|
||||
}
|
||||
|
||||
raw := jsonSchemaFor[Params]()
|
||||
|
||||
var schema map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &schema))
|
||||
|
||||
props := schema["properties"].(map[string]any)
|
||||
|
||||
scoresProp := props["scores"].(map[string]any)
|
||||
assert.Equal(t, "object", scoresProp["type"])
|
||||
|
||||
addlProps := scoresProp["additionalProperties"].(map[string]any)
|
||||
assert.Equal(t, "integer", addlProps["type"])
|
||||
assert.Nil(t, addlProps["types"], "map pointer values should not have union types")
|
||||
assert.Nil(t, addlProps["minimum"])
|
||||
assert.Nil(t, addlProps["maximum"])
|
||||
}
|
||||
|
||||
func TestGenerateSchema_DescriptionFromJsonschemaTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct {
|
||||
Query string `json:"query" jsonschema:"The search query to execute"`
|
||||
Limit int `json:"limit" jsonschema:"Maximum number of results to return"`
|
||||
}
|
||||
|
||||
raw := jsonSchemaFor[Params]()
|
||||
|
||||
var schema map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &schema))
|
||||
|
||||
props := schema["properties"].(map[string]any)
|
||||
|
||||
queryProp := props["query"].(map[string]any)
|
||||
assert.Equal(t, "The search query to execute", queryProp["description"])
|
||||
|
||||
limitProp := props["limit"].(map[string]any)
|
||||
assert.Equal(t, "Maximum number of results to return", limitProp["description"])
|
||||
}
|
||||
|
||||
func TestGenerateSchema_RequiredVsOptional(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct {
|
||||
Required string `json:"required"`
|
||||
Optional *string `json:"optional,omitempty"`
|
||||
OmitEmpty string `json:"omit_empty,omitempty"`
|
||||
AlsoNeeded int `json:"also_needed"`
|
||||
}
|
||||
|
||||
raw := jsonSchemaFor[Params]()
|
||||
|
||||
var schema map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &schema))
|
||||
|
||||
required := schema["required"].([]any)
|
||||
assert.Contains(t, required, "required")
|
||||
assert.Contains(t, required, "also_needed")
|
||||
assert.NotContains(t, required, "optional")
|
||||
assert.NotContains(t, required, "omit_empty")
|
||||
}
|
||||
|
||||
func TestGenerateSchema_DeeplyNestedStructure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Level3 struct {
|
||||
Value *int `json:"value"`
|
||||
}
|
||||
type Level2 struct {
|
||||
Items []Level3 `json:"items"`
|
||||
}
|
||||
type Level1 struct {
|
||||
Child *Level2 `json:"child"`
|
||||
}
|
||||
type Params struct {
|
||||
Root Level1 `json:"root"`
|
||||
}
|
||||
|
||||
raw := jsonSchemaFor[Params]()
|
||||
|
||||
var schema map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &schema))
|
||||
|
||||
props := schema["properties"].(map[string]any)
|
||||
|
||||
rootProp := props["root"].(map[string]any)
|
||||
assert.Equal(t, "object", rootProp["type"])
|
||||
|
||||
rootProps := rootProp["properties"].(map[string]any)
|
||||
childProp := rootProps["child"].(map[string]any)
|
||||
assert.Equal(t, "object", childProp["type"])
|
||||
assert.Nil(t, childProp["types"])
|
||||
|
||||
childProps := childProp["properties"].(map[string]any)
|
||||
itemsProp := childProps["items"].(map[string]any)
|
||||
assert.Equal(t, "array", itemsProp["type"])
|
||||
|
||||
itemsItems := itemsProp["items"].(map[string]any)
|
||||
assert.Equal(t, "object", itemsItems["type"])
|
||||
|
||||
level3Props := itemsItems["properties"].(map[string]any)
|
||||
valueProp := level3Props["value"].(map[string]any)
|
||||
assert.Equal(t, "integer", valueProp["type"])
|
||||
assert.Nil(t, valueProp["types"])
|
||||
assert.Nil(t, valueProp["minimum"])
|
||||
assert.Nil(t, valueProp["maximum"])
|
||||
}
|
||||
|
||||
func TestGenerateSchema_SliceOfStructs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Item struct {
|
||||
Name string `json:"name"`
|
||||
Count *int `json:"count,omitempty"`
|
||||
}
|
||||
type Params struct {
|
||||
Items []Item `json:"items"`
|
||||
}
|
||||
|
||||
raw := jsonSchemaFor[Params]()
|
||||
|
||||
var schema map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &schema))
|
||||
|
||||
props := schema["properties"].(map[string]any)
|
||||
|
||||
itemsProp := props["items"].(map[string]any)
|
||||
assert.Equal(t, "array", itemsProp["type"])
|
||||
|
||||
items := itemsProp["items"].(map[string]any)
|
||||
assert.Equal(t, "object", items["type"])
|
||||
|
||||
itemProps := items["properties"].(map[string]any)
|
||||
assert.Contains(t, itemProps, "name")
|
||||
assert.Contains(t, itemProps, "count")
|
||||
|
||||
countProp := itemProps["count"].(map[string]any)
|
||||
assert.Equal(t, "integer", countProp["type"])
|
||||
assert.Nil(t, countProp["types"])
|
||||
assert.Nil(t, countProp["minimum"])
|
||||
}
|
||||
|
||||
func TestStripNullTypes_NilSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
stripNullTypes(nil)
|
||||
})
|
||||
}
|
||||
26
pkg/agent/session.go
Normal file
26
pkg/agent/session.go
Normal file
@@ -0,0 +1,26 @@
|
||||
// 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"
|
||||
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
type Session interface {
|
||||
Load(ctx context.Context, sessionID string) ([]llm.Message, error)
|
||||
Save(ctx context.Context, sessionID string, messages []llm.Message) error
|
||||
}
|
||||
84
pkg/agent/session_memory.go
Normal file
84
pkg/agent/session_memory.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// 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"
|
||||
"sync"
|
||||
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
var _ Session = (*memorySession)(nil)
|
||||
|
||||
type memorySession struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string][]llm.Message
|
||||
}
|
||||
|
||||
func NewMemorySession() *memorySession {
|
||||
return &memorySession{
|
||||
sessions: make(map[string][]llm.Message),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *memorySession) Load(_ context.Context, sessionID string) ([]llm.Message, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
msgs, ok := s.sessions[sessionID]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
cp := make([]llm.Message, len(msgs))
|
||||
for i, m := range msgs {
|
||||
cp[i] = copyMessage(m)
|
||||
}
|
||||
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
func (s *memorySession) Save(_ context.Context, sessionID string, messages []llm.Message) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
cp := make([]llm.Message, len(messages))
|
||||
for i, m := range messages {
|
||||
cp[i] = copyMessage(m)
|
||||
}
|
||||
|
||||
s.sessions[sessionID] = cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyMessage(m llm.Message) llm.Message {
|
||||
cp := llm.Message{
|
||||
Role: m.Role,
|
||||
ToolCallID: m.ToolCallID,
|
||||
}
|
||||
|
||||
if len(m.Parts) > 0 {
|
||||
cp.Parts = make([]llm.Part, len(m.Parts))
|
||||
copy(cp.Parts, m.Parts)
|
||||
}
|
||||
|
||||
if len(m.ToolCalls) > 0 {
|
||||
cp.ToolCalls = make([]llm.ToolCall, len(m.ToolCalls))
|
||||
copy(cp.ToolCalls, m.ToolCalls)
|
||||
}
|
||||
|
||||
return cp
|
||||
}
|
||||
275
pkg/agent/session_memory_test.go
Normal file
275
pkg/agent/session_memory_test.go
Normal file
@@ -0,0 +1,275 @@
|
||||
// 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_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/agent"
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
func TestNewMemorySession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"returns non-nil session",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := agent.NewMemorySession()
|
||||
assert.NotNil(t, s)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestMemorySession_Load(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"returns nil for unknown session ID",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := agent.NewMemorySession()
|
||||
|
||||
msgs, err := s.Load(context.Background(), "unknown")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, msgs)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"returns saved messages",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := agent.NewMemorySession()
|
||||
messages := []llm.Message{
|
||||
{
|
||||
Role: llm.RoleUser,
|
||||
Parts: []llm.Part{llm.TextPart{Text: "hello"}},
|
||||
},
|
||||
{
|
||||
Role: llm.RoleAssistant,
|
||||
Parts: []llm.Part{llm.TextPart{Text: "hi there"}},
|
||||
},
|
||||
}
|
||||
|
||||
err := s.Save(context.Background(), "sess-1", messages)
|
||||
require.NoError(t, err)
|
||||
|
||||
loaded, err := s.Load(context.Background(), "sess-1")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, loaded, 2)
|
||||
assert.Equal(t, llm.RoleUser, loaded[0].Role)
|
||||
assert.Equal(t, "hello", loaded[0].Text())
|
||||
assert.Equal(t, llm.RoleAssistant, loaded[1].Role)
|
||||
assert.Equal(t, "hi there", loaded[1].Text())
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"returns a defensive copy of messages",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := agent.NewMemorySession()
|
||||
messages := []llm.Message{
|
||||
{
|
||||
Role: llm.RoleUser,
|
||||
Parts: []llm.Part{llm.TextPart{Text: "original"}},
|
||||
},
|
||||
}
|
||||
|
||||
err := s.Save(context.Background(), "sess-copy", messages)
|
||||
require.NoError(t, err)
|
||||
|
||||
loaded, err := s.Load(context.Background(), "sess-copy")
|
||||
require.NoError(t, err)
|
||||
|
||||
loaded[0].Parts = []llm.Part{llm.TextPart{Text: "mutated"}}
|
||||
|
||||
reloaded, err := s.Load(context.Background(), "sess-copy")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "original", reloaded[0].Text())
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"different session IDs are independent",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := agent.NewMemorySession()
|
||||
|
||||
err := s.Save(
|
||||
context.Background(),
|
||||
"sess-a",
|
||||
[]llm.Message{
|
||||
{Role: llm.RoleUser, Parts: []llm.Part{llm.TextPart{Text: "alpha"}}},
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = s.Save(
|
||||
context.Background(),
|
||||
"sess-b",
|
||||
[]llm.Message{
|
||||
{Role: llm.RoleUser, Parts: []llm.Part{llm.TextPart{Text: "beta"}}},
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
a, err := s.Load(context.Background(), "sess-a")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, a, 1)
|
||||
assert.Equal(t, "alpha", a[0].Text())
|
||||
|
||||
b, err := s.Load(context.Background(), "sess-b")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, b, 1)
|
||||
assert.Equal(t, "beta", b[0].Text())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestMemorySession_Save(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"stores a defensive copy of input messages",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := agent.NewMemorySession()
|
||||
messages := []llm.Message{
|
||||
{
|
||||
Role: llm.RoleUser,
|
||||
Parts: []llm.Part{llm.TextPart{Text: "before"}},
|
||||
},
|
||||
}
|
||||
|
||||
err := s.Save(context.Background(), "sess-def", messages)
|
||||
require.NoError(t, err)
|
||||
|
||||
messages[0].Parts = []llm.Part{llm.TextPart{Text: "after"}}
|
||||
|
||||
loaded, err := s.Load(context.Background(), "sess-def")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "before", loaded[0].Text())
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"overwrites previous messages for same session ID",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := agent.NewMemorySession()
|
||||
|
||||
err := s.Save(
|
||||
context.Background(),
|
||||
"sess-ow",
|
||||
[]llm.Message{
|
||||
{Role: llm.RoleUser, Parts: []llm.Part{llm.TextPart{Text: "first"}}},
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = s.Save(
|
||||
context.Background(),
|
||||
"sess-ow",
|
||||
[]llm.Message{
|
||||
{Role: llm.RoleUser, Parts: []llm.Part{llm.TextPart{Text: "second"}}},
|
||||
{Role: llm.RoleAssistant, Parts: []llm.Part{llm.TextPart{Text: "reply"}}},
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
loaded, err := s.Load(context.Background(), "sess-ow")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, loaded, 2)
|
||||
assert.Equal(t, "second", loaded[0].Text())
|
||||
assert.Equal(t, "reply", loaded[1].Text())
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"preserves tool calls and tool call ID",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := agent.NewMemorySession()
|
||||
messages := []llm.Message{
|
||||
{
|
||||
Role: llm.RoleAssistant,
|
||||
ToolCalls: []llm.ToolCall{
|
||||
{
|
||||
ID: "call-1",
|
||||
Function: llm.FunctionCall{
|
||||
Name: "get_weather",
|
||||
Arguments: `{"city":"Paris"}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: llm.RoleTool,
|
||||
ToolCallID: "call-1",
|
||||
Parts: []llm.Part{llm.TextPart{Text: "sunny"}},
|
||||
},
|
||||
}
|
||||
|
||||
err := s.Save(context.Background(), "sess-tc", messages)
|
||||
require.NoError(t, err)
|
||||
|
||||
loaded, err := s.Load(context.Background(), "sess-tc")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, loaded, 2)
|
||||
|
||||
require.Len(t, loaded[0].ToolCalls, 1)
|
||||
assert.Equal(t, "call-1", loaded[0].ToolCalls[0].ID)
|
||||
assert.Equal(t, "get_weather", loaded[0].ToolCalls[0].Function.Name)
|
||||
assert.Equal(t, `{"city":"Paris"}`, loaded[0].ToolCalls[0].Function.Arguments)
|
||||
|
||||
assert.Equal(t, "call-1", loaded[1].ToolCallID)
|
||||
assert.Equal(t, "sunny", loaded[1].Text())
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"handles empty message slice",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := agent.NewMemorySession()
|
||||
|
||||
err := s.Save(context.Background(), "sess-empty", []llm.Message{})
|
||||
require.NoError(t, err)
|
||||
|
||||
loaded, err := s.Load(context.Background(), "sess-empty")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, loaded)
|
||||
},
|
||||
)
|
||||
}
|
||||
133
pkg/agent/stream.go
Normal file
133
pkg/agent/stream.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// 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"
|
||||
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
type (
|
||||
StreamEventType string
|
||||
|
||||
StreamEvent struct {
|
||||
Type StreamEventType
|
||||
Agent *Agent
|
||||
Delta string
|
||||
Tool Tool
|
||||
ToolResult *ToolResult
|
||||
Result *Result
|
||||
Err error
|
||||
}
|
||||
|
||||
StreamedRun struct {
|
||||
Events <-chan StreamEvent
|
||||
done chan struct{}
|
||||
result *Result
|
||||
err error
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
StreamEventAgentStart StreamEventType = "agent_start"
|
||||
StreamEventAgentEnd StreamEventType = "agent_end"
|
||||
StreamEventLLMDelta StreamEventType = "llm_delta"
|
||||
StreamEventToolStart StreamEventType = "tool_start"
|
||||
StreamEventToolEnd StreamEventType = "tool_end"
|
||||
StreamEventHandoff StreamEventType = "handoff"
|
||||
StreamEventComplete StreamEventType = "complete"
|
||||
StreamEventError StreamEventType = "error"
|
||||
)
|
||||
|
||||
func (sr *StreamedRun) Wait() (*Result, error) {
|
||||
for range sr.Events {
|
||||
}
|
||||
<-sr.done
|
||||
return sr.result, sr.err
|
||||
}
|
||||
|
||||
func (a *Agent) RunStreamed(ctx context.Context, messages []llm.Message) *StreamedRun {
|
||||
events := make(chan StreamEvent, 64)
|
||||
sr := &StreamedRun{
|
||||
Events: events,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(sr.done)
|
||||
defer close(events)
|
||||
|
||||
result, err := coreLoop(
|
||||
ctx,
|
||||
a,
|
||||
messages,
|
||||
runOpts{
|
||||
callLLM: streamingCallLLM(events),
|
||||
onEvent: func(ctx context.Context, ev StreamEvent) {
|
||||
trySendEvent(ctx, events, ev)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
sr.result = result
|
||||
sr.err = err
|
||||
}()
|
||||
|
||||
return sr
|
||||
}
|
||||
|
||||
func streamingCallLLM(events chan<- StreamEvent) CallLLMFunc {
|
||||
return func(ctx context.Context, agent *Agent, req *llm.ChatCompletionRequest) (*llm.ChatCompletionResponse, error) {
|
||||
stream, err := agent.client.ChatCompletionStream(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
acc := llm.NewStreamAccumulator(stream)
|
||||
for acc.Next() {
|
||||
ev := acc.Event()
|
||||
if ev.Delta.Content != "" {
|
||||
trySendEvent(
|
||||
ctx,
|
||||
events,
|
||||
StreamEvent{
|
||||
Type: StreamEventLLMDelta,
|
||||
Agent: agent,
|
||||
Delta: ev.Delta.Content,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if err := acc.Err(); err != nil {
|
||||
_ = stream.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := stream.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return acc.Response(), nil
|
||||
}
|
||||
}
|
||||
|
||||
func trySendEvent(ctx context.Context, events chan<- StreamEvent, ev StreamEvent) {
|
||||
select {
|
||||
case events <- ev:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
51
pkg/agent/system_prompt.go
Normal file
51
pkg/agent/system_prompt.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
type (
|
||||
systemPromptData struct {
|
||||
Instructions string
|
||||
Handoffs []systemPromptHandoff
|
||||
}
|
||||
|
||||
systemPromptHandoff struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
)
|
||||
|
||||
var systemPromptTmpl = template.Must(template.New("system_prompt").Parse(
|
||||
`{{- .Instructions -}}
|
||||
{{- if .Handoffs }}
|
||||
|
||||
## Handoffs
|
||||
You can transfer the conversation to a more specialized agent when appropriate:
|
||||
{{ range .Handoffs -}}
|
||||
- {{ .Name }}{{ with .Description }}: {{ . }}{{ end }}
|
||||
{{ end -}}
|
||||
{{- end -}}
|
||||
`))
|
||||
|
||||
func buildSystemPrompt(data systemPromptData) string {
|
||||
var buf bytes.Buffer
|
||||
_ = systemPromptTmpl.Execute(&buf, data)
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
121
pkg/agent/system_prompt_test.go
Normal file
121
pkg/agent/system_prompt_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// 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 (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBuildSystemPrompt(t *testing.T) {
|
||||
t.Run(
|
||||
"empty data returns empty string",
|
||||
func(t *testing.T) {
|
||||
got := buildSystemPrompt(systemPromptData{})
|
||||
assert.Equal(t, "", got)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"instructions only",
|
||||
func(t *testing.T) {
|
||||
got := buildSystemPrompt(systemPromptData{
|
||||
Instructions: "You are a helpful assistant.",
|
||||
})
|
||||
assert.Equal(t, "You are a helpful assistant.", got)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"handoffs only",
|
||||
func(t *testing.T) {
|
||||
got := buildSystemPrompt(systemPromptData{
|
||||
Handoffs: []systemPromptHandoff{
|
||||
{Name: "billing-agent", Description: "Handles billing questions."},
|
||||
},
|
||||
})
|
||||
|
||||
assert.Contains(t, got, "## Handoffs")
|
||||
assert.Contains(t, got, "- billing-agent: Handles billing questions.")
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"instructions with handoffs",
|
||||
func(t *testing.T) {
|
||||
got := buildSystemPrompt(systemPromptData{
|
||||
Instructions: "You are a triage agent.",
|
||||
Handoffs: []systemPromptHandoff{
|
||||
{Name: "billing-agent", Description: "Handles billing."},
|
||||
{Name: "support-agent", Description: "Handles support."},
|
||||
},
|
||||
})
|
||||
|
||||
assert.True(t, strings.HasPrefix(got, "You are a triage agent."))
|
||||
assert.Contains(t, got, "## Handoffs")
|
||||
assert.Contains(t, got, "- billing-agent: Handles billing.")
|
||||
assert.Contains(t, got, "- support-agent: Handles support.")
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"handoff without description",
|
||||
func(t *testing.T) {
|
||||
got := buildSystemPrompt(systemPromptData{
|
||||
Handoffs: []systemPromptHandoff{
|
||||
{Name: "silent-agent"},
|
||||
},
|
||||
})
|
||||
|
||||
assert.Contains(t, got, "- silent-agent\n")
|
||||
assert.NotContains(t, got, "- silent-agent:")
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"multiple handoffs preserve order",
|
||||
func(t *testing.T) {
|
||||
got := buildSystemPrompt(systemPromptData{
|
||||
Handoffs: []systemPromptHandoff{
|
||||
{Name: "alpha"},
|
||||
{Name: "beta"},
|
||||
{Name: "gamma"},
|
||||
},
|
||||
})
|
||||
|
||||
idxAlpha := strings.Index(got, "- alpha")
|
||||
idxBeta := strings.Index(got, "- beta")
|
||||
idxGamma := strings.Index(got, "- gamma")
|
||||
|
||||
assert.Greater(t, idxBeta, idxAlpha)
|
||||
assert.Greater(t, idxGamma, idxBeta)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"no trailing newline after last handoff",
|
||||
func(t *testing.T) {
|
||||
got := buildSystemPrompt(systemPromptData{
|
||||
Handoffs: []systemPromptHandoff{
|
||||
{Name: "agent-a", Description: "Does A."},
|
||||
},
|
||||
})
|
||||
|
||||
assert.False(t, strings.HasSuffix(got, "\n\n"), "should not end with double newline")
|
||||
},
|
||||
)
|
||||
}
|
||||
84
pkg/agent/tool.go
Normal file
84
pkg/agent/tool.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// 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"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
type (
|
||||
ToolResult struct {
|
||||
Content string
|
||||
IsError bool
|
||||
}
|
||||
|
||||
ToolDescriptor interface {
|
||||
Name() string
|
||||
Definition() llm.Tool
|
||||
}
|
||||
|
||||
Tool interface {
|
||||
ToolDescriptor
|
||||
Execute(ctx context.Context, arguments string) (ToolResult, error)
|
||||
}
|
||||
|
||||
functionTool[P any] struct {
|
||||
name string
|
||||
description string
|
||||
fn func(ctx context.Context, params P) (ToolResult, error)
|
||||
schema json.RawMessage
|
||||
}
|
||||
)
|
||||
|
||||
func FunctionTool[P any](
|
||||
name string,
|
||||
description string,
|
||||
fn func(ctx context.Context, params P) (ToolResult, error),
|
||||
) Tool {
|
||||
schema := jsonSchemaFor[P]()
|
||||
|
||||
return &functionTool[P]{
|
||||
name: name,
|
||||
description: description,
|
||||
fn: fn,
|
||||
schema: schema,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *functionTool[P]) Name() string { return t.name }
|
||||
|
||||
func (t *functionTool[P]) Definition() llm.Tool {
|
||||
return llm.Tool{
|
||||
Name: t.name,
|
||||
Description: t.description,
|
||||
Parameters: t.schema,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *functionTool[P]) Execute(ctx context.Context, arguments string) (ToolResult, error) {
|
||||
var params P
|
||||
if err := json.Unmarshal([]byte(arguments), ¶ms); err != nil {
|
||||
return ToolResult{
|
||||
Content: fmt.Sprintf("Invalid parameters: %s", err.Error()),
|
||||
IsError: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return t.fn(ctx, params)
|
||||
}
|
||||
361
pkg/agent/tool_test.go
Normal file
361
pkg/agent/tool_test.go
Normal file
@@ -0,0 +1,361 @@
|
||||
// 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_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/agent"
|
||||
)
|
||||
|
||||
func TestFunctionTool_Name(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct{}
|
||||
|
||||
tool := agent.FunctionTool(
|
||||
"my_tool",
|
||||
"does things",
|
||||
func(_ context.Context, _ Params) (agent.ToolResult, error) {
|
||||
return agent.ToolResult{}, nil
|
||||
},
|
||||
)
|
||||
|
||||
assert.Equal(t, "my_tool", tool.Name())
|
||||
}
|
||||
|
||||
func TestFunctionTool_Definition(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"returns name and description",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
tool := agent.FunctionTool(
|
||||
"search",
|
||||
"Search for items",
|
||||
func(_ context.Context, _ Params) (agent.ToolResult, error) {
|
||||
return agent.ToolResult{}, nil
|
||||
},
|
||||
)
|
||||
|
||||
def := tool.Definition()
|
||||
assert.Equal(t, "search", def.Name)
|
||||
assert.Equal(t, "Search for items", def.Description)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"generates valid JSON schema from params type",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct {
|
||||
Name string `json:"name" jsonschema:"The item name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
tool := agent.FunctionTool(
|
||||
"create",
|
||||
"Create items",
|
||||
func(_ context.Context, _ Params) (agent.ToolResult, error) {
|
||||
return agent.ToolResult{}, nil
|
||||
},
|
||||
)
|
||||
|
||||
def := tool.Definition()
|
||||
require.NotNil(t, def.Parameters)
|
||||
|
||||
var schema map[string]any
|
||||
require.NoError(t, json.Unmarshal(def.Parameters, &schema))
|
||||
|
||||
assert.Equal(t, "object", schema["type"])
|
||||
|
||||
props := schema["properties"].(map[string]any)
|
||||
assert.Contains(t, props, "name")
|
||||
assert.Contains(t, props, "count")
|
||||
|
||||
nameProp := props["name"].(map[string]any)
|
||||
assert.Equal(t, "string", nameProp["type"])
|
||||
assert.Equal(t, "The item name", nameProp["description"])
|
||||
|
||||
countProp := props["count"].(map[string]any)
|
||||
assert.Equal(t, "integer", countProp["type"])
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"empty struct produces object schema with no properties",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct{}
|
||||
|
||||
tool := agent.FunctionTool(
|
||||
"noop",
|
||||
"No-op",
|
||||
func(_ context.Context, _ Params) (agent.ToolResult, error) {
|
||||
return agent.ToolResult{}, nil
|
||||
},
|
||||
)
|
||||
|
||||
var schema map[string]any
|
||||
require.NoError(t, json.Unmarshal(tool.Definition().Parameters, &schema))
|
||||
assert.Equal(t, "object", schema["type"])
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"pointer fields are not nullable in schema",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
tool := agent.FunctionTool(
|
||||
"update",
|
||||
"Update",
|
||||
func(_ context.Context, _ Params) (agent.ToolResult, error) {
|
||||
return agent.ToolResult{}, nil
|
||||
},
|
||||
)
|
||||
|
||||
var schema map[string]any
|
||||
require.NoError(t, json.Unmarshal(tool.Definition().Parameters, &schema))
|
||||
|
||||
props := schema["properties"].(map[string]any)
|
||||
titleProp := props["title"].(map[string]any)
|
||||
assert.Equal(t, "string", titleProp["type"])
|
||||
assert.Nil(t, titleProp["types"])
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestFunctionTool_Execute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"unmarshals params and calls function",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct {
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
}
|
||||
|
||||
tool := agent.FunctionTool(
|
||||
"add",
|
||||
"Add two numbers",
|
||||
func(_ context.Context, p Params) (agent.ToolResult, error) {
|
||||
return agent.ToolResult{Content: "42"}, nil
|
||||
},
|
||||
)
|
||||
|
||||
result, err := tool.Execute(context.Background(), `{"x": 1, "y": 2}`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "42", result.Content)
|
||||
assert.False(t, result.IsError)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"passes received params to function",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct {
|
||||
City string `json:"city"`
|
||||
}
|
||||
|
||||
var received string
|
||||
tool := agent.FunctionTool(
|
||||
"weather",
|
||||
"Get weather",
|
||||
func(_ context.Context, p Params) (agent.ToolResult, error) {
|
||||
received = p.City
|
||||
return agent.ToolResult{Content: "sunny"}, nil
|
||||
},
|
||||
)
|
||||
|
||||
_, err := tool.Execute(context.Background(), `{"city":"Paris"}`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Paris", received)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"invalid JSON returns tool error not Go error",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct{}
|
||||
|
||||
tool := agent.FunctionTool(
|
||||
"noop",
|
||||
"No-op",
|
||||
func(_ context.Context, _ Params) (agent.ToolResult, error) {
|
||||
return agent.ToolResult{Content: "ok"}, nil
|
||||
},
|
||||
)
|
||||
|
||||
result, err := tool.Execute(context.Background(), `{invalid`)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.Content, "Invalid parameters")
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"infrastructure error propagated as Go error",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct{}
|
||||
|
||||
tool := agent.FunctionTool(
|
||||
"fail",
|
||||
"Always fails",
|
||||
func(_ context.Context, _ Params) (agent.ToolResult, error) {
|
||||
return agent.ToolResult{}, errors.New("db down")
|
||||
},
|
||||
)
|
||||
|
||||
_, err := tool.Execute(context.Background(), `{}`)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "db down")
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"context is forwarded to function",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type ctxKey struct{}
|
||||
type Params struct{}
|
||||
|
||||
tool := agent.FunctionTool(
|
||||
"ctx_check",
|
||||
"Check context",
|
||||
func(ctx context.Context, _ Params) (agent.ToolResult, error) {
|
||||
val := ctx.Value(ctxKey{}).(string)
|
||||
return agent.ToolResult{Content: val}, nil
|
||||
},
|
||||
)
|
||||
|
||||
ctx := context.WithValue(context.Background(), ctxKey{}, "hello")
|
||||
result, err := tool.Execute(ctx, `{}`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hello", result.Content)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"extra JSON fields are ignored",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
tool := agent.FunctionTool(
|
||||
"greet",
|
||||
"Greet",
|
||||
func(_ context.Context, p Params) (agent.ToolResult, error) {
|
||||
return agent.ToolResult{Content: "hi " + p.Name}, nil
|
||||
},
|
||||
)
|
||||
|
||||
result, err := tool.Execute(context.Background(), `{"name":"Alice","extra":"ignored"}`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hi Alice", result.Content)
|
||||
assert.False(t, result.IsError)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"empty JSON object works for empty params",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct{}
|
||||
|
||||
tool := agent.FunctionTool(
|
||||
"ping",
|
||||
"Ping",
|
||||
func(_ context.Context, _ Params) (agent.ToolResult, error) {
|
||||
return agent.ToolResult{Content: "pong"}, nil
|
||||
},
|
||||
)
|
||||
|
||||
result, err := tool.Execute(context.Background(), `{}`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "pong", result.Content)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"function can return IsError true",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct{}
|
||||
|
||||
tool := agent.FunctionTool(
|
||||
"validate",
|
||||
"Validate input",
|
||||
func(_ context.Context, _ Params) (agent.ToolResult, error) {
|
||||
return agent.ToolResult{Content: "validation failed", IsError: true}, nil
|
||||
},
|
||||
)
|
||||
|
||||
result, err := tool.Execute(context.Background(), `{}`)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.IsError)
|
||||
assert.Equal(t, "validation failed", result.Content)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestFunctionTool_InterfaceSatisfaction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Params struct{}
|
||||
|
||||
tool := agent.FunctionTool(
|
||||
"test",
|
||||
"test tool",
|
||||
func(_ context.Context, _ Params) (agent.ToolResult, error) {
|
||||
return agent.ToolResult{}, nil
|
||||
},
|
||||
)
|
||||
|
||||
assert.Implements(t, (*agent.Tool)(nil), tool)
|
||||
assert.Implements(t, (*agent.ToolDescriptor)(nil), tool)
|
||||
}
|
||||
64
pkg/agent/tool_use_behavior.go
Normal file
64
pkg/agent/tool_use_behavior.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// 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"
|
||||
|
||||
// ToolUseBehavior decides whether tool-call results constitute a final
|
||||
// output. When isFinal is true the run stops and finalOutput is returned
|
||||
// to the caller without sending results back to the LLM.
|
||||
type ToolUseBehavior func(ctx context.Context, results []ToolCallResult) (finalOutput string, isFinal bool, err error)
|
||||
|
||||
type ToolCallResult struct {
|
||||
ToolName string
|
||||
Arguments string
|
||||
Result ToolResult
|
||||
}
|
||||
|
||||
// RunLLMAgain is the default behavior: tools run, then the LLM receives the
|
||||
// results and gets to respond.
|
||||
func RunLLMAgain() ToolUseBehavior {
|
||||
return func(_ context.Context, _ []ToolCallResult) (string, bool, error) {
|
||||
return "", false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// StopOnFirstTool treats the output from the first tool call as the final
|
||||
// result, without sending it back to the LLM.
|
||||
func StopOnFirstTool() ToolUseBehavior {
|
||||
return func(_ context.Context, results []ToolCallResult) (string, bool, error) {
|
||||
if len(results) == 0 {
|
||||
return "", false, nil
|
||||
}
|
||||
return results[0].Result.Content, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// StopAtTools stops the agent when any of the listed tool names is called.
|
||||
// The matching tool's output becomes the final output.
|
||||
func StopAtTools(names ...string) ToolUseBehavior {
|
||||
stopSet := make(map[string]struct{}, len(names))
|
||||
for _, n := range names {
|
||||
stopSet[n] = struct{}{}
|
||||
}
|
||||
return func(_ context.Context, results []ToolCallResult) (string, bool, error) {
|
||||
for _, r := range results {
|
||||
if _, ok := stopSet[r.ToolName]; ok {
|
||||
return r.Result.Content, true, nil
|
||||
}
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
}
|
||||
288
pkg/agent/tool_use_behavior_test.go
Normal file
288
pkg/agent/tool_use_behavior_test.go
Normal file
@@ -0,0 +1,288 @@
|
||||
// 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_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/agent"
|
||||
)
|
||||
|
||||
func TestRunLLMAgain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"returns not final with no results",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
behavior := agent.RunLLMAgain()
|
||||
output, isFinal, err := behavior(context.Background(), nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isFinal)
|
||||
assert.Equal(t, "", output)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"returns not final with results present",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
behavior := agent.RunLLMAgain()
|
||||
results := []agent.ToolCallResult{
|
||||
{
|
||||
ToolName: "search",
|
||||
Arguments: `{"q":"test"}`,
|
||||
Result: agent.ToolResult{Content: "found 3 items"},
|
||||
},
|
||||
}
|
||||
|
||||
output, isFinal, err := behavior(context.Background(), results)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isFinal)
|
||||
assert.Equal(t, "", output)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestStopOnFirstTool(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"returns not final when results are empty",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
behavior := agent.StopOnFirstTool()
|
||||
output, isFinal, err := behavior(context.Background(), nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isFinal)
|
||||
assert.Equal(t, "", output)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"returns first tool output as final",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
behavior := agent.StopOnFirstTool()
|
||||
results := []agent.ToolCallResult{
|
||||
{
|
||||
ToolName: "lookup",
|
||||
Result: agent.ToolResult{Content: "first result"},
|
||||
},
|
||||
{
|
||||
ToolName: "search",
|
||||
Result: agent.ToolResult{Content: "second result"},
|
||||
},
|
||||
}
|
||||
|
||||
output, isFinal, err := behavior(context.Background(), results)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isFinal)
|
||||
assert.Equal(t, "first result", output)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"returns single result as final",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
behavior := agent.StopOnFirstTool()
|
||||
results := []agent.ToolCallResult{
|
||||
{
|
||||
ToolName: "compute",
|
||||
Result: agent.ToolResult{Content: "42"},
|
||||
},
|
||||
}
|
||||
|
||||
output, isFinal, err := behavior(context.Background(), results)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isFinal)
|
||||
assert.Equal(t, "42", output)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"returns empty content when first tool has empty output",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
behavior := agent.StopOnFirstTool()
|
||||
results := []agent.ToolCallResult{
|
||||
{
|
||||
ToolName: "noop",
|
||||
Result: agent.ToolResult{Content: ""},
|
||||
},
|
||||
}
|
||||
|
||||
output, isFinal, err := behavior(context.Background(), results)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isFinal)
|
||||
assert.Equal(t, "", output)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestStopAtTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"returns not final when results are empty",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
behavior := agent.StopAtTools("done")
|
||||
output, isFinal, err := behavior(context.Background(), nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isFinal)
|
||||
assert.Equal(t, "", output)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"returns not final when no tool matches",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
behavior := agent.StopAtTools("done", "finish")
|
||||
results := []agent.ToolCallResult{
|
||||
{
|
||||
ToolName: "search",
|
||||
Result: agent.ToolResult{Content: "results"},
|
||||
},
|
||||
{
|
||||
ToolName: "compute",
|
||||
Result: agent.ToolResult{Content: "42"},
|
||||
},
|
||||
}
|
||||
|
||||
output, isFinal, err := behavior(context.Background(), results)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isFinal)
|
||||
assert.Equal(t, "", output)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"stops on matching tool and returns its output",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
behavior := agent.StopAtTools("submit")
|
||||
results := []agent.ToolCallResult{
|
||||
{
|
||||
ToolName: "search",
|
||||
Result: agent.ToolResult{Content: "search result"},
|
||||
},
|
||||
{
|
||||
ToolName: "submit",
|
||||
Result: agent.ToolResult{Content: "submitted"},
|
||||
},
|
||||
}
|
||||
|
||||
output, isFinal, err := behavior(context.Background(), results)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isFinal)
|
||||
assert.Equal(t, "submitted", output)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"returns first matching tool when multiple match",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
behavior := agent.StopAtTools("done", "finish")
|
||||
results := []agent.ToolCallResult{
|
||||
{
|
||||
ToolName: "finish",
|
||||
Result: agent.ToolResult{Content: "finished"},
|
||||
},
|
||||
{
|
||||
ToolName: "done",
|
||||
Result: agent.ToolResult{Content: "done"},
|
||||
},
|
||||
}
|
||||
|
||||
output, isFinal, err := behavior(context.Background(), results)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isFinal)
|
||||
assert.Equal(t, "finished", output)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"matches any of the listed tool names",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
behavior := agent.StopAtTools("alpha", "beta", "gamma")
|
||||
results := []agent.ToolCallResult{
|
||||
{
|
||||
ToolName: "unrelated",
|
||||
Result: agent.ToolResult{Content: "ignored"},
|
||||
},
|
||||
{
|
||||
ToolName: "gamma",
|
||||
Result: agent.ToolResult{Content: "gamma output"},
|
||||
},
|
||||
}
|
||||
|
||||
output, isFinal, err := behavior(context.Background(), results)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isFinal)
|
||||
assert.Equal(t, "gamma output", output)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"no stop names means never final",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
behavior := agent.StopAtTools()
|
||||
results := []agent.ToolCallResult{
|
||||
{
|
||||
ToolName: "anything",
|
||||
Result: agent.ToolResult{Content: "value"},
|
||||
},
|
||||
}
|
||||
|
||||
output, isFinal, err := behavior(context.Background(), results)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isFinal)
|
||||
assert.Equal(t, "", output)
|
||||
},
|
||||
)
|
||||
}
|
||||
79
pkg/agent/typed.go
Normal file
79
pkg/agent/typed.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// 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"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
type TypedResult[T any] struct {
|
||||
Result
|
||||
Output T
|
||||
}
|
||||
|
||||
func RunTyped[T any](
|
||||
ctx context.Context,
|
||||
a *Agent,
|
||||
messages []llm.Message,
|
||||
) (*TypedResult[T], error) {
|
||||
typed := a.clone()
|
||||
|
||||
typed.responseFormat = &llm.ResponseFormat{
|
||||
Type: llm.ResponseFormatJSONSchema,
|
||||
JSONSchema: &llm.JSONSchema{
|
||||
Name: typeName[T](),
|
||||
Schema: jsonSchemaFor[T](),
|
||||
Strict: true,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := typed.Run(ctx, messages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
text := result.FinalMessage().Text()
|
||||
|
||||
var output T
|
||||
if err := json.Unmarshal([]byte(text), &output); err != nil {
|
||||
return nil, fmt.Errorf("cannot parse typed output: %w", err)
|
||||
}
|
||||
|
||||
return &TypedResult[T]{
|
||||
Result: *result,
|
||||
Output: output,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func typeName[T any]() string {
|
||||
var zero T
|
||||
t := reflect.TypeOf(zero)
|
||||
if t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
|
||||
name := t.Name()
|
||||
if name == "" {
|
||||
name = "output"
|
||||
}
|
||||
|
||||
return strings.ToLower(name)
|
||||
}
|
||||
458
pkg/agent/typed_test.go
Normal file
458
pkg/agent/typed_test.go
Normal file
@@ -0,0 +1,458 @@
|
||||
// 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"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
type typedMockProvider struct {
|
||||
responses []*llm.ChatCompletionResponse
|
||||
calls int
|
||||
}
|
||||
|
||||
func (m *typedMockProvider) ChatCompletion(_ context.Context, _ *llm.ChatCompletionRequest) (*llm.ChatCompletionResponse, error) {
|
||||
if m.calls >= len(m.responses) {
|
||||
return nil, errors.New("no more mock responses")
|
||||
}
|
||||
resp := m.responses[m.calls]
|
||||
m.calls++
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (m *typedMockProvider) ChatCompletionStream(_ context.Context, _ *llm.ChatCompletionRequest) (llm.ChatCompletionStream, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func typedStopResponse(text string) *llm.ChatCompletionResponse {
|
||||
return &llm.ChatCompletionResponse{
|
||||
Model: "test-model",
|
||||
Message: llm.Message{
|
||||
Role: llm.RoleAssistant,
|
||||
Parts: []llm.Part{llm.TextPart{Text: text}},
|
||||
},
|
||||
Usage: llm.Usage{InputTokens: 10, OutputTokens: 5},
|
||||
FinishReason: llm.FinishReasonStop,
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypeName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"named struct",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type UserInfo struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
assert.Equal(t, "userinfo", typeName[UserInfo]())
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"pointer to named struct",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Invoice struct {
|
||||
Amount int `json:"amount"`
|
||||
}
|
||||
|
||||
assert.Equal(t, "invoice", typeName[*Invoice]())
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"anonymous struct returns output",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, "output", typeName[struct{ X int }]())
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"basic string type",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, "string", typeName[string]())
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"basic int type",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, "int", typeName[int]())
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"custom type alias",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Status string
|
||||
|
||||
assert.Equal(t, "status", typeName[Status]())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestRunTyped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"successful structured output",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type CompanyInfo struct {
|
||||
Name string `json:"name"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
|
||||
provider := &typedMockProvider{
|
||||
responses: []*llm.ChatCompletionResponse{
|
||||
typedStopResponse(`{"name":"Probo","country":"FR"}`),
|
||||
},
|
||||
}
|
||||
|
||||
ag := New(
|
||||
"assistant",
|
||||
llm.NewClient(provider, "test"),
|
||||
WithModel("test-model"),
|
||||
WithInstructions("Return structured info."),
|
||||
)
|
||||
|
||||
result, err := RunTyped[CompanyInfo](
|
||||
context.Background(),
|
||||
ag,
|
||||
[]llm.Message{{
|
||||
Role: llm.RoleUser,
|
||||
Parts: []llm.Part{llm.TextPart{Text: "Tell me about Probo"}},
|
||||
}},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Probo", result.Output.Name)
|
||||
assert.Equal(t, "FR", result.Output.Country)
|
||||
assert.Equal(t, 1, result.Turns)
|
||||
assert.Equal(t, 10, result.Usage.InputTokens)
|
||||
assert.Equal(t, 5, result.Usage.OutputTokens)
|
||||
assert.Equal(t, "assistant", result.LastAgent.Name())
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"nested struct output",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Address struct {
|
||||
City string `json:"city"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
|
||||
type Person struct {
|
||||
Name string `json:"name"`
|
||||
Age int `json:"age"`
|
||||
Address Address `json:"address"`
|
||||
}
|
||||
|
||||
provider := &typedMockProvider{
|
||||
responses: []*llm.ChatCompletionResponse{
|
||||
typedStopResponse(`{"name":"Alice","age":30,"address":{"city":"Paris","country":"FR"}}`),
|
||||
},
|
||||
}
|
||||
|
||||
ag := New(
|
||||
"assistant",
|
||||
llm.NewClient(provider, "test"),
|
||||
WithModel("test-model"),
|
||||
)
|
||||
|
||||
result, err := RunTyped[Person](
|
||||
context.Background(),
|
||||
ag,
|
||||
[]llm.Message{{
|
||||
Role: llm.RoleUser,
|
||||
Parts: []llm.Part{llm.TextPart{Text: "Describe Alice"}},
|
||||
}},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Alice", result.Output.Name)
|
||||
assert.Equal(t, 30, result.Output.Age)
|
||||
assert.Equal(t, "Paris", result.Output.Address.City)
|
||||
assert.Equal(t, "FR", result.Output.Address.Country)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"invalid JSON response",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Info struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
provider := &typedMockProvider{
|
||||
responses: []*llm.ChatCompletionResponse{
|
||||
typedStopResponse(`not valid json`),
|
||||
},
|
||||
}
|
||||
|
||||
ag := New(
|
||||
"assistant",
|
||||
llm.NewClient(provider, "test"),
|
||||
WithModel("test-model"),
|
||||
)
|
||||
|
||||
_, err := RunTyped[Info](
|
||||
context.Background(),
|
||||
ag,
|
||||
[]llm.Message{{
|
||||
Role: llm.RoleUser,
|
||||
Parts: []llm.Part{llm.TextPart{Text: "test"}},
|
||||
}},
|
||||
)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cannot parse typed output")
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"underlying Run failure propagates",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Info struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
provider := &typedMockProvider{
|
||||
responses: []*llm.ChatCompletionResponse{},
|
||||
}
|
||||
|
||||
ag := New(
|
||||
"assistant",
|
||||
llm.NewClient(provider, "test"),
|
||||
WithModel("test-model"),
|
||||
)
|
||||
|
||||
_, err := RunTyped[Info](
|
||||
context.Background(),
|
||||
ag,
|
||||
[]llm.Message{{
|
||||
Role: llm.RoleUser,
|
||||
Parts: []llm.Part{llm.TextPart{Text: "test"}},
|
||||
}},
|
||||
)
|
||||
|
||||
require.Error(t, err)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"does not mutate original agent",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Info struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
provider := &typedMockProvider{
|
||||
responses: []*llm.ChatCompletionResponse{
|
||||
typedStopResponse(`{"name":"test"}`),
|
||||
typedStopResponse("plain text"),
|
||||
},
|
||||
}
|
||||
|
||||
client := llm.NewClient(provider, "test")
|
||||
|
||||
ag := New(
|
||||
"assistant",
|
||||
client,
|
||||
WithModel("test-model"),
|
||||
)
|
||||
|
||||
_, err := RunTyped[Info](
|
||||
context.Background(),
|
||||
ag,
|
||||
[]llm.Message{{
|
||||
Role: llm.RoleUser,
|
||||
Parts: []llm.Part{llm.TextPart{Text: "structured"}},
|
||||
}},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Nil(t, ag.responseFormat)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"slice output",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Items struct {
|
||||
Names []string `json:"names"`
|
||||
}
|
||||
|
||||
provider := &typedMockProvider{
|
||||
responses: []*llm.ChatCompletionResponse{
|
||||
typedStopResponse(`{"names":["Alice","Bob","Charlie"]}`),
|
||||
},
|
||||
}
|
||||
|
||||
ag := New(
|
||||
"assistant",
|
||||
llm.NewClient(provider, "test"),
|
||||
WithModel("test-model"),
|
||||
)
|
||||
|
||||
result, err := RunTyped[Items](
|
||||
context.Background(),
|
||||
ag,
|
||||
[]llm.Message{{
|
||||
Role: llm.RoleUser,
|
||||
Parts: []llm.Part{llm.TextPart{Text: "list names"}},
|
||||
}},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"Alice", "Bob", "Charlie"}, result.Output.Names)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"context cancellation",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Info struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
provider := &typedMockProvider{
|
||||
responses: []*llm.ChatCompletionResponse{
|
||||
typedStopResponse(`{"name":"test"}`),
|
||||
},
|
||||
}
|
||||
|
||||
ag := New(
|
||||
"assistant",
|
||||
llm.NewClient(provider, "test"),
|
||||
WithModel("test-model"),
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err := RunTyped[Info](
|
||||
ctx,
|
||||
ag,
|
||||
[]llm.Message{{
|
||||
Role: llm.RoleUser,
|
||||
Parts: []llm.Part{llm.TextPart{Text: "test"}},
|
||||
}},
|
||||
)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, 0, provider.calls)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"with tool call",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type Summary struct {
|
||||
City string `json:"city"`
|
||||
Weather string `json:"weather"`
|
||||
}
|
||||
|
||||
type Params struct {
|
||||
City string `json:"city"`
|
||||
}
|
||||
|
||||
weatherTool := FunctionTool[Params](
|
||||
"get_weather",
|
||||
"Get weather for a city",
|
||||
func(_ context.Context, p Params) (ToolResult, error) {
|
||||
return ToolResult{Content: "Sunny, 22°C in " + p.City}, nil
|
||||
},
|
||||
)
|
||||
|
||||
provider := &typedMockProvider{
|
||||
responses: []*llm.ChatCompletionResponse{
|
||||
{
|
||||
Model: "test-model",
|
||||
Message: llm.Message{
|
||||
Role: llm.RoleAssistant,
|
||||
ToolCalls: []llm.ToolCall{{
|
||||
ID: "tc_1",
|
||||
Function: llm.FunctionCall{
|
||||
Name: "get_weather",
|
||||
Arguments: `{"city":"Paris"}`,
|
||||
},
|
||||
}},
|
||||
},
|
||||
Usage: llm.Usage{InputTokens: 10, OutputTokens: 5},
|
||||
FinishReason: llm.FinishReasonToolCalls,
|
||||
},
|
||||
typedStopResponse(`{"city":"Paris","weather":"Sunny, 22°C"}`),
|
||||
},
|
||||
}
|
||||
|
||||
ag := New(
|
||||
"assistant",
|
||||
llm.NewClient(provider, "test"),
|
||||
WithModel("test-model"),
|
||||
WithTools(weatherTool),
|
||||
)
|
||||
|
||||
result, err := RunTyped[Summary](
|
||||
context.Background(),
|
||||
ag,
|
||||
[]llm.Message{{
|
||||
Role: llm.RoleUser,
|
||||
Parts: []llm.Part{llm.TextPart{Text: "What's the weather in Paris?"}},
|
||||
}},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Paris", result.Output.City)
|
||||
assert.Equal(t, "Sunny, 22°C", result.Output.Weather)
|
||||
assert.Equal(t, 2, result.Turns)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"go.probo.inc/probo/pkg/agent"
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
@@ -48,19 +49,30 @@ const (
|
||||
)
|
||||
|
||||
func (a *Agent) GenerateChangelog(ctx context.Context, oldContent string, newContent string) (*string, error) {
|
||||
resp, err := a.client.ChatCompletion(ctx, &llm.ChatCompletionRequest{
|
||||
Model: a.model,
|
||||
Messages: []llm.Message{
|
||||
{Role: llm.RoleSystem, Parts: []llm.Part{llm.TextPart{Text: changelogGeneratorSystemPrompt}}},
|
||||
{Role: llm.RoleUser, Parts: []llm.Part{llm.TextPart{Text: fmt.Sprintf(`Old content: %s`, oldContent)}}},
|
||||
{Role: llm.RoleUser, Parts: []llm.Part{llm.TextPart{Text: fmt.Sprintf(`New content: %s`, newContent)}}},
|
||||
ag := agent.New(
|
||||
"changelog_generator",
|
||||
a.client,
|
||||
agent.WithInstructions(changelogGeneratorSystemPrompt),
|
||||
agent.WithModel(a.model),
|
||||
agent.WithTemperature(a.temp),
|
||||
)
|
||||
|
||||
result, err := ag.Run(
|
||||
ctx,
|
||||
[]llm.Message{
|
||||
{
|
||||
Role: llm.RoleUser,
|
||||
Parts: []llm.Part{
|
||||
llm.TextPart{Text: fmt.Sprintf("Old content: %s", oldContent)},
|
||||
llm.TextPart{Text: fmt.Sprintf("New content: %s", newContent)},
|
||||
},
|
||||
},
|
||||
},
|
||||
Temperature: &a.temp,
|
||||
})
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate changelog: %w", err)
|
||||
}
|
||||
|
||||
text := resp.Message.Text()
|
||||
text := result.FinalMessage().Text()
|
||||
return &text, nil
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@ package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"go.probo.inc/probo/pkg/agent"
|
||||
"go.probo.inc/probo/pkg/llm"
|
||||
)
|
||||
|
||||
@@ -122,25 +122,27 @@ const (
|
||||
)
|
||||
|
||||
func (a *Agent) AssessVendor(ctx context.Context, websiteURL string) (*vendorInfo, error) {
|
||||
resp, err := a.client.ChatCompletion(
|
||||
ag := agent.New(
|
||||
"vendor_assessor",
|
||||
a.client,
|
||||
agent.WithInstructions(assessVendorSystemPrompt),
|
||||
agent.WithModel(a.model),
|
||||
agent.WithTemperature(a.temp),
|
||||
)
|
||||
|
||||
typedResult, err := agent.RunTyped[vendorInfo](
|
||||
ctx,
|
||||
&llm.ChatCompletionRequest{
|
||||
Model: a.model,
|
||||
Messages: []llm.Message{
|
||||
{Role: llm.RoleSystem, Parts: []llm.Part{llm.TextPart{Text: assessVendorSystemPrompt}}},
|
||||
{Role: llm.RoleUser, Parts: []llm.Part{llm.TextPart{Text: websiteURL}}},
|
||||
ag,
|
||||
[]llm.Message{
|
||||
{
|
||||
Role: llm.RoleUser,
|
||||
Parts: []llm.Part{llm.TextPart{Text: websiteURL}},
|
||||
},
|
||||
Temperature: &a.temp,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot assess vendor: %w", err)
|
||||
}
|
||||
|
||||
var info vendorInfo
|
||||
if err := json.Unmarshal([]byte(resp.Message.Text()), &info); err != nil {
|
||||
return nil, fmt.Errorf("cannot parse vendor info: %w", err)
|
||||
}
|
||||
|
||||
return &info, nil
|
||||
return &typedResult.Output, nil
|
||||
}
|
||||
|
||||
@@ -21,15 +21,18 @@ import (
|
||||
|
||||
type (
|
||||
ChatCompletionRequest struct {
|
||||
Model string
|
||||
Messages []Message
|
||||
MaxTokens *int
|
||||
Temperature *float64
|
||||
TopP *float64
|
||||
StopSequences []string
|
||||
Tools []Tool
|
||||
ToolChoice *ToolChoice
|
||||
ResponseFormat *ResponseFormat
|
||||
Model string
|
||||
Messages []Message
|
||||
MaxTokens *int
|
||||
Temperature *float64
|
||||
TopP *float64
|
||||
FrequencyPenalty *float64
|
||||
PresencePenalty *float64
|
||||
StopSequences []string
|
||||
Tools []Tool
|
||||
ToolChoice *ToolChoice
|
||||
ParallelToolCalls *bool
|
||||
ResponseFormat *ResponseFormat
|
||||
}
|
||||
|
||||
ToolChoiceType string
|
||||
@@ -50,6 +53,7 @@ type (
|
||||
Name string
|
||||
Description string
|
||||
Schema json.RawMessage
|
||||
Strict bool
|
||||
}
|
||||
|
||||
FinishReason string
|
||||
|
||||
Reference in New Issue
Block a user