Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
cf6e6d4
feat(go): initialize Go module with dependencies
juliomenendez Apr 6, 2026
f4694ce
feat(go): add Message, Content, and Role types with constructors
juliomenendez Apr 6, 2026
24401ac
fix(go): run go mod tidy to fix missing transitive dependencies
juliomenendez Apr 6, 2026
ef5a8ab
feat(go): add ChatResponse, AgentResponse, and UsageDetails types
juliomenendez Apr 6, 2026
c15bb97
feat(go): add ChatOptions, RunOptions with functional option pattern
juliomenendez Apr 6, 2026
7f6e4fd
feat(go): add ChatClient interface and sentinel errors
juliomenendez Apr 6, 2026
09476c3
feat(go): add Agent interface and BaseAgent implementation
juliomenendez Apr 6, 2026
9d316fd
feat(go): add OpenAI ChatClient implementation
juliomenendez Apr 6, 2026
eb74154
chore(go): run go mod tidy — promote go-openai to direct dependency
juliomenendez Apr 6, 2026
460f6da
feat(go): add middleware interfaces, context objects, and chain builders
juliomenendez Apr 6, 2026
488598e
test(go): add middleware pipeline tests (red — not yet wired)
juliomenendez Apr 6, 2026
7e06ee7
feat(go): wire middleware pipeline into BaseAgent.Run()
juliomenendez Apr 6, 2026
0cbdbe1
chore(go): add OpenTelemetry dependencies
juliomenendez Apr 6, 2026
02adc96
feat(go): add OTel tracing middleware for agent and chat
juliomenendez Apr 6, 2026
e773159
feat(go): add OTel metrics middleware for chat client
juliomenendez Apr 6, 2026
86e5a25
feat(go): add slog logging middleware for agent runs
juliomenendez Apr 6, 2026
af09c83
chore(go): run go mod tidy — promote OTel to direct dependencies
juliomenendez Apr 6, 2026
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
205 changes: 205 additions & 0 deletions go/agent.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
package agentframework

import (
"context"
"maps"

"github.com/google/uuid"
)

// Agent runs a conversation with a language model.
type Agent interface {
ID() string
Name() string
Description() string
Run(ctx context.Context, messages []Message, opts ...RunOption) (*AgentResponse, error)
}

// BaseAgent is the standard Agent implementation backed by a ChatClient.
type BaseAgent struct {
id string
name string
description string
client ChatClient
instructions []string
defaultChatOpts []ChatOption
agentMiddleware []AgentMiddleware
chatMiddleware []ChatMiddleware
functionMiddleware []FunctionMiddleware
}

// AgentOption configures a BaseAgent.
type AgentOption func(*BaseAgent)

// NewAgent creates a new BaseAgent with the given ChatClient and options.
func NewAgent(client ChatClient, opts ...AgentOption) *BaseAgent {
a := &BaseAgent{
id: uuid.New().String(),
client: client,
}
for _, opt := range opts {
opt(a)
}
return a
}

func (a *BaseAgent) ID() string { return a.id }
func (a *BaseAgent) Name() string { return a.name }
func (a *BaseAgent) Description() string { return a.description }

// Run executes the agent with the given messages.
func (a *BaseAgent) Run(ctx context.Context, messages []Message, opts ...RunOption) (*AgentResponse, error) {
if len(messages) == 0 {
return nil, ErrEmptyMessages
}

runOpts := NewRunOptions(opts...)

ac := &AgentContext{
Agent: a,
Messages: messages,
Options: &runOpts,
Metadata: make(map[string]any),
}

terminal := func(ctx context.Context, ac *AgentContext) error {
resp, err := a.runCore(ctx, ac)
if err != nil {
return err
}
ac.Response = resp
return nil
}

handler := buildAgentChain(a.agentMiddleware, terminal)
if err := handler(ctx, ac); err != nil {
return nil, err
}

return ac.Response, nil
}

func (a *BaseAgent) runCore(ctx context.Context, ac *AgentContext) (*AgentResponse, error) {
var fullMessages []Message
for _, instr := range a.instructions {
fullMessages = append(fullMessages, NewSystemMessage(instr))
}
fullMessages = append(fullMessages, ac.Messages...)

chatOpts := make([]ChatOption, 0, len(a.defaultChatOpts)+1)
chatOpts = append(chatOpts, a.defaultChatOpts...)
chatOpts = append(chatOpts, func(o *ChatOptions) {
merged := ac.Options.ChatOptions
if merged.Temperature != nil {
o.Temperature = merged.Temperature
}
if merged.MaxTokens != nil {
o.MaxTokens = merged.MaxTokens
}
if merged.Model != "" {
o.Model = merged.Model
}
if merged.Metadata != nil {
if o.Metadata == nil {
o.Metadata = make(map[string]any)
}
maps.Copy(o.Metadata, merged.Metadata)
}
})

resolvedOpts := NewChatOptions(chatOpts...)

cc := &ChatContext{
Client: a.client,
Messages: fullMessages,
Options: &resolvedOpts,
Metadata: make(map[string]any),
}

chatTerminal := func(ctx context.Context, cc *ChatContext) error {
var opts []ChatOption
if cc.Options.Temperature != nil {
t := *cc.Options.Temperature
opts = append(opts, WithTemperature(t))
}
if cc.Options.MaxTokens != nil {
n := *cc.Options.MaxTokens
opts = append(opts, WithMaxTokens(n))
}
if cc.Options.Model != "" {
opts = append(opts, WithModel(cc.Options.Model))
}
resp, err := cc.Client.GetResponse(ctx, cc.Messages, opts...)
if err != nil {
return err
}
cc.Response = resp
return nil
}

chatHandler := buildChatChain(a.chatMiddleware, chatTerminal)
if err := chatHandler(ctx, cc); err != nil {
return nil, err
}

return &AgentResponse{
ChatResponse: *cc.Response,
AgentID: a.id,
}, nil
}

// WithID sets the agent ID.
func WithID(id string) AgentOption {
return func(a *BaseAgent) {
a.id = id
}
}

// WithName sets the agent name.
func WithName(name string) AgentOption {
return func(a *BaseAgent) {
a.name = name
}
}

// WithDescription sets the agent description.
func WithDescription(desc string) AgentOption {
return func(a *BaseAgent) {
a.description = desc
}
}

// WithInstructions sets the system instructions prepended to every request.
func WithInstructions(instructions ...string) AgentOption {
return func(a *BaseAgent) {
a.instructions = instructions
}
}

// WithDefaultChatOptions sets default ChatOptions applied to every request.
func WithDefaultChatOptions(opts ...ChatOption) AgentOption {
return func(a *BaseAgent) {
a.defaultChatOpts = opts
}
}

// WithAgentMiddleware appends agent-level middleware to the pipeline.
func WithAgentMiddleware(mw ...AgentMiddleware) AgentOption {
return func(a *BaseAgent) {
a.agentMiddleware = append(a.agentMiddleware, mw...)
}
}

// WithChatMiddleware appends chat-level middleware to the pipeline.
func WithChatMiddleware(mw ...ChatMiddleware) AgentOption {
return func(a *BaseAgent) {
a.chatMiddleware = append(a.chatMiddleware, mw...)
}
}

// WithFunctionMiddleware appends function-level middleware to the pipeline.
func WithFunctionMiddleware(mw ...FunctionMiddleware) AgentOption {
return func(a *BaseAgent) {
a.functionMiddleware = append(a.functionMiddleware, mw...)
}
}
165 changes: 165 additions & 0 deletions go/agent_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package agentframework_test

import (
"context"
"errors"
"testing"

af "github.com/microsoft/agent-framework/go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// mockChatClient is a test double for ChatClient.
type mockChatClient struct {
response *af.ChatResponse
err error
captured struct {
messages []af.Message
opts af.ChatOptions
}
}

func (m *mockChatClient) GetResponse(_ context.Context, messages []af.Message, opts ...af.ChatOption) (*af.ChatResponse, error) {
m.captured.messages = messages
m.captured.opts = af.NewChatOptions(opts...)
if m.err != nil {
return nil, m.err
}
return m.response, nil
}

func TestNewAgent(t *testing.T) {
t.Run("generates a UUID id by default", func(t *testing.T) {
agent := af.NewAgent(&mockChatClient{})
assert.NotEmpty(t, agent.ID())
})

t.Run("uses custom id when provided", func(t *testing.T) {
agent := af.NewAgent(&mockChatClient{}, af.WithID("custom-id"))
assert.Equal(t, "custom-id", agent.ID())
})

t.Run("sets name and description", func(t *testing.T) {
agent := af.NewAgent(&mockChatClient{},
af.WithName("TestBot"),
af.WithDescription("A test bot"),
)
assert.Equal(t, "TestBot", agent.Name())
assert.Equal(t, "A test bot", agent.Description())
})
}

func TestBaseAgentRun(t *testing.T) {
t.Run("delegates to chat client and returns AgentResponse", func(t *testing.T) {
mock := &mockChatClient{
response: &af.ChatResponse{
Messages: []af.Message{af.NewTextMessage(af.RoleAssistant, "Paris")},
ResponseID: "resp-1",
Usage: &af.UsageDetails{InputTokens: 10, OutputTokens: 5, TotalTokens: 15},
},
}
agent := af.NewAgent(mock, af.WithName("Geo"))

resp, err := agent.Run(context.Background(), []af.Message{
af.NewUserMessage("What is the capital of France?"),
})

require.NoError(t, err)
assert.Equal(t, "resp-1", resp.ResponseID)
assert.Equal(t, agent.ID(), resp.AgentID)
assert.Len(t, resp.Messages, 1)
assert.Equal(t, "Paris", resp.Messages[0].Text())
assert.Equal(t, 15, resp.Usage.TotalTokens)
})

t.Run("prepends system message from instructions", func(t *testing.T) {
mock := &mockChatClient{
response: &af.ChatResponse{
Messages: []af.Message{af.NewTextMessage(af.RoleAssistant, "ok")},
},
}
agent := af.NewAgent(mock,
af.WithInstructions("You are helpful.", "Be concise."),
)

_, err := agent.Run(context.Background(), []af.Message{
af.NewUserMessage("hi"),
})

require.NoError(t, err)
msgs := mock.captured.messages
require.Len(t, msgs, 3)
assert.Equal(t, af.RoleSystem, msgs[0].Role)
assert.Equal(t, "You are helpful.", msgs[0].Text())
assert.Equal(t, af.RoleSystem, msgs[1].Role)
assert.Equal(t, "Be concise.", msgs[1].Text())
assert.Equal(t, af.RoleUser, msgs[2].Role)
assert.Equal(t, "hi", msgs[2].Text())
})

t.Run("applies default chat options", func(t *testing.T) {
mock := &mockChatClient{
response: &af.ChatResponse{
Messages: []af.Message{af.NewTextMessage(af.RoleAssistant, "ok")},
},
}
agent := af.NewAgent(mock,
af.WithDefaultChatOptions(af.WithTemperature(0.3), af.WithModel("gpt-4o")),
)

_, err := agent.Run(context.Background(), []af.Message{
af.NewUserMessage("hi"),
})

require.NoError(t, err)
assert.InDelta(t, 0.3, *mock.captured.opts.Temperature, 0.001)
assert.Equal(t, "gpt-4o", mock.captured.opts.Model)
})

t.Run("run options override default chat options", func(t *testing.T) {
mock := &mockChatClient{
response: &af.ChatResponse{
Messages: []af.Message{af.NewTextMessage(af.RoleAssistant, "ok")},
},
}
agent := af.NewAgent(mock,
af.WithDefaultChatOptions(af.WithTemperature(0.3)),
)

_, err := agent.Run(context.Background(), []af.Message{
af.NewUserMessage("hi"),
}, af.WithChatOption(af.WithTemperature(0.9)))

require.NoError(t, err)
assert.InDelta(t, 0.9, *mock.captured.opts.Temperature, 0.001)
})

t.Run("propagates client error", func(t *testing.T) {
clientErr := errors.New("api error")
mock := &mockChatClient{err: clientErr}
agent := af.NewAgent(mock)

_, err := agent.Run(context.Background(), []af.Message{
af.NewUserMessage("hi"),
})

assert.ErrorIs(t, err, clientErr)
})

t.Run("returns error for empty messages", func(t *testing.T) {
agent := af.NewAgent(&mockChatClient{})

_, err := agent.Run(context.Background(), nil)

assert.ErrorIs(t, err, af.ErrEmptyMessages)
})

t.Run("returns error for empty messages slice", func(t *testing.T) {
agent := af.NewAgent(&mockChatClient{})

_, err := agent.Run(context.Background(), []af.Message{})

assert.ErrorIs(t, err, af.ErrEmptyMessages)
})
}
8 changes: 8 additions & 0 deletions go/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package agentframework

import "context"

// ChatClient sends messages to a language model and returns a response.
type ChatClient interface {
GetResponse(ctx context.Context, messages []Message, opts ...ChatOption) (*ChatResponse, error)
}
11 changes: 11 additions & 0 deletions go/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package agentframework

import "errors"

var (
// ErrEmptyMessages is returned when an agent is called with no messages.
ErrEmptyMessages = errors.New("agentframework: messages must not be empty")

// ErrNilClient is returned when an agent is created with a nil ChatClient.
ErrNilClient = errors.New("agentframework: chat client must not be nil")
)
Loading
Loading