Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 48 additions & 11 deletions tool/agenttool/agenttool.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ import (
// used when an agent exposes no description of its own.
const defaultDescription = "Invoke an agent to retrieve some information."

// Defaults for the single string argument the tool exposes, matching the .NET
// AIAgent function-tool schema.
const (
defaultArgName = "query"
defaultArgDescription = "input query to invoke the agent"
)

// invalidNameChars matches every run of characters that are not valid in a
// function name, matching the .NET SanitizeAgentName regex so agent display
// names such as "Weather Agent" become valid provider function names.
Expand All @@ -24,6 +31,15 @@ var invalidNameChars = regexp.MustCompile(`[^0-9A-Za-z]+`)
// Config represents the configuration for [New].
type Config struct {
RunOptions []agent.Option

// ArgName overrides the name of the single string argument the tool exposes
// to the model. Defaults to "query". Mirrors the .NET/Python as-tool
// argument-name option.
Comment on lines +35 to +37
ArgName string

// ArgDescription overrides the description of that argument. Defaults to
// "input query to invoke the agent".
ArgDescription string
}

// New creates a new FuncTool that invokes the given agent with the provided
Expand All @@ -32,15 +48,27 @@ func New(a *agent.Agent, config Config) tool.FuncTool {
if a == nil {
panic("agenttool: agent is required")
}
argName := config.ArgName
if argName == "" {
argName = defaultArgName
}
argDescription := config.ArgDescription
if argDescription == "" {
argDescription = defaultArgDescription
}
return functool{
opts: slices.Clone(config.RunOptions),
agent: a,
opts: slices.Clone(config.RunOptions),
agent: a,
argName: argName,
argDescription: argDescription,
}
}

type functool struct {
opts []agent.Option
agent *agent.Agent
opts []agent.Option
agent *agent.Agent
argName string
argDescription string
}

func (t functool) Name() string {
Expand All @@ -66,12 +94,12 @@ func (t functool) Schema() any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{
t.argName: map[string]any{
"type": "string",
"description": "input query to invoke the agent",
"description": t.argDescription,
},
},
"required": []string{"query"},
"required": []string{t.argName},
}
}

Expand All @@ -82,16 +110,25 @@ func (t functool) ReturnSchema() any {
}

func (t functool) Call(ctx context.Context, args string) (any, error) {
var in struct {
Query string `json:"query"`
}
if args == "" {
args = "{}"
}
// The argument name is configurable, so decode into a map of raw messages
// and unmarshal the configured key into a string. Other entries stay as raw
// JSON so unrelated arguments are ignored, while a wrong type for the
// configured key surfaces a JSON decoding error rather than silently
// invoking the agent with an empty message.
var in map[string]json.RawMessage
if err := json.Unmarshal([]byte(args), &in); err != nil {
return nil, err
}
resp, err := t.agent.RunText(ctx, in.Query, t.opts...).Collect()
var query string
if raw, ok := in[t.argName]; ok {
Comment on lines +125 to +126
if err := json.Unmarshal(raw, &query); err != nil {
return nil, err
}
}
resp, err := t.agent.RunText(ctx, query, t.opts...).Collect()
if err != nil {
return "", err
}
Expand Down
54 changes: 54 additions & 0 deletions tool/agenttool/agenttool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,41 @@ func TestNew_ExposesAgentMetadataAndSchemas(t *testing.T) {
}
}

func TestNew_CustomArgNameAndDescription(t *testing.T) {
var capturedMessages []*message.Message
a := agenttest.New(agenttest.NewResponseBuilder(
func(_ context.Context, messages []*message.Message, _ ...agent.Option) {
capturedMessages = messages
},
).AddText("ok").Build())
tl := agenttool.New(a, agenttool.Config{ArgName: "task", ArgDescription: "the task to run"})

// Schema reflects the custom argument.
schema := tl.Schema().(map[string]any)
properties := schema["properties"].(map[string]any)
if _, ok := properties["task"]; !ok {
t.Fatalf("schema properties = %#v, want a \"task\" argument", properties)
}
if _, ok := properties["query"]; ok {
t.Fatalf("schema still exposes the default \"query\" argument")
}
arg := properties["task"].(map[string]any)
if arg["description"] != "the task to run" {
t.Errorf("task description = %v, want %q", arg["description"], "the task to run")
}
if req := schema["required"].([]string); len(req) != 1 || req[0] != "task" {
t.Errorf("required = %v, want [task]", req)
}

// Call reads the custom key and forwards its value to the agent.
if _, err := tl.Call(t.Context(), `{"task":"do the thing"}`); err != nil {
t.Fatalf("Call() error = %v", err)
}
if len(capturedMessages) == 0 || capturedMessages[len(capturedMessages)-1].String() != "do the thing" {
t.Fatalf("agent received %v, want the task text", capturedMessages)
}
}

func TestNew_PanicsWithNilAgent(t *testing.T) {
defer func() {
if got, want := recover(), "agenttool: agent is required"; got != want {
Expand Down Expand Up @@ -222,6 +257,25 @@ func TestCall_InvalidJSONReturnsError(t *testing.T) {
}
}

// A non-string value for the configured argument must be rejected as a JSON
// decoding error rather than silently invoking the agent with an empty query,
// matching the schema's declared string type.
func TestCall_WrongArgTypeReturnsError(t *testing.T) {
var invoked bool
a := agenttest.New(agenttest.NewResponseBuilder(
func(context.Context, []*message.Message, ...agent.Option) { invoked = true },
).AddText("unused").Build())
tl := agenttool.New(a, agenttool.Config{ArgName: "task"})

_, err := tl.Call(t.Context(), `{"task":123}`)
if err == nil {
t.Fatal("expected a JSON decoding error for a non-string argument")
}
if invoked {
t.Fatal("agent should not be invoked when the argument has the wrong type")
}
}

func TestCall_PropagatesAgentError(t *testing.T) {
expectedErr := errors.New("agent failed")
a := agenttest.New(agenttest.NewResponseBuilder().AddError(expectedErr).Build())
Expand Down
Loading