467 lines
11 KiB
Go
467 lines
11 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@probo.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(
|
|
"missing single required field returns tool error",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type Params struct {
|
|
City string `json:"city"`
|
|
}
|
|
|
|
tool := agent.FunctionTool(
|
|
"weather",
|
|
"Get weather",
|
|
func(_ context.Context, _ Params) (agent.ToolResult, error) {
|
|
t.Fatal("function should not be called")
|
|
return agent.ToolResult{}, nil
|
|
},
|
|
)
|
|
|
|
result, err := tool.Execute(context.Background(), `{}`)
|
|
require.NoError(t, err)
|
|
assert.True(t, result.IsError)
|
|
assert.Contains(t, result.Content, "city")
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"missing multiple required fields lists all of them",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type Params struct {
|
|
City string `json:"city"`
|
|
Country string `json:"country"`
|
|
}
|
|
|
|
tool := agent.FunctionTool(
|
|
"weather",
|
|
"Get weather",
|
|
func(_ context.Context, _ Params) (agent.ToolResult, error) {
|
|
t.Fatal("function should not be called")
|
|
return agent.ToolResult{}, nil
|
|
},
|
|
)
|
|
|
|
result, err := tool.Execute(context.Background(), `{}`)
|
|
require.NoError(t, err)
|
|
assert.True(t, result.IsError)
|
|
assert.Contains(t, result.Content, "city")
|
|
assert.Contains(t, result.Content, "country")
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"partially missing required fields detected",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type Params struct {
|
|
City string `json:"city"`
|
|
Country string `json:"country"`
|
|
}
|
|
|
|
tool := agent.FunctionTool(
|
|
"weather",
|
|
"Get weather",
|
|
func(_ context.Context, _ Params) (agent.ToolResult, error) {
|
|
t.Fatal("function should not be called")
|
|
return agent.ToolResult{}, nil
|
|
},
|
|
)
|
|
|
|
result, err := tool.Execute(context.Background(), `{"city":"Paris"}`)
|
|
require.NoError(t, err)
|
|
assert.True(t, result.IsError)
|
|
assert.Contains(t, result.Content, "country")
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"optional fields may be empty but must be present",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type Params struct {
|
|
City string `json:"city"`
|
|
Units *string `json:"units,omitempty"`
|
|
}
|
|
|
|
tool := agent.FunctionTool(
|
|
"weather",
|
|
"Get weather",
|
|
func(_ context.Context, p Params) (agent.ToolResult, error) {
|
|
return agent.ToolResult{Content: "sunny in " + p.City}, nil
|
|
},
|
|
)
|
|
|
|
result, err := tool.Execute(context.Background(), `{"city":"Paris","units":""}`)
|
|
require.NoError(t, err)
|
|
assert.False(t, result.IsError)
|
|
assert.Equal(t, "sunny in Paris", 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)
|
|
}
|