Skip to content

Commit 318933d

Browse files
committed
implement agent reliability features from ADK
Signed-off-by: Peter Jausovec <peter.jausovec@solo.io>
1 parent 32e7221 commit 318933d

40 files changed

Lines changed: 1762 additions & 34 deletions

docs/architecture/crds-and-types.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ AgentSpec
7474
│ │ ├── summarizer: ContextSummarizerConfig
7575
│ │ ├── tokenThreshold: int
7676
│ │ └── eventRetentionSize: int
77+
│ ├── reliability: ReliabilityConfig
78+
│ │ ├── toolRetries: int (reflect-and-retry on failed tool calls)
79+
│ │ ├── maxLLMCalls: int (cap on model calls per request)
80+
│ │ └── debugLogging: bool (log every LLM request/response and tool call)
7781
│ └── executeCodeBlocks: bool (currently ignored)
7882
7983
└── byo: BYOAgentSpec (if type=BYO)
@@ -126,6 +130,10 @@ ModelConfigSpec
126130
│ ├── caCertSecretKey: string
127131
│ └── disableSystemCAs: bool
128132
133+
├── retry: ModelRetryConfig
134+
│ └── attempts: int (max retries of failed LLM HTTP requests with exponential backoff;
135+
│ OpenAI/AzureOpenAI/Anthropic/Gemini only)
136+
129137
├── openAI: OpenAIConfig
130138
│ ├── baseUrl, temperature, maxTokens, topP
131139
│ ├── frequencyPenalty, presencePenalty
@@ -340,7 +348,8 @@ When adding a field to an existing CRD, update all layers:
340348
5. **Translator**`go/core/internal/controller/translator/agent/adk_api_translator.go` (wire field into config)
341349
6. **Python ADK types**`python/packages/kagent-adk/src/kagent/adk/types.py` (mirror Go types)
342350
7. **Python runtime** — Use the field in agent setup if it affects runtime behavior
343-
8. **Tests** — Translator unit tests (golden files), E2E tests
344-
9. **Helm values** — If exposed to users installing via Helm
351+
8. **Go runtime**`go/adk/pkg/` (mirror runtime behavior for `runtime: go` agents)
352+
9. **Tests** — Translator unit tests (golden files), E2E tests
353+
10. **Helm values** — If exposed to users installing via Helm
345354

346355
See [controller-reconciliation.md](controller-reconciliation.md) for the reconciliation flow and the kagent-dev skill for step-by-step examples.

go/adk/pkg/agent/agent.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ func CreateLLM(ctx context.Context, m adk.Model, log logr.Logger) (adkmodel.LLM,
228228
if err != nil {
229229
return nil, fmt.Errorf("failed to build HTTP client for Gemini: %w", err)
230230
}
231+
warnIgnoredMaxRetries(log, m.BaseModel, "gemini")
231232
return adkgemini.NewModel(ctx, modelName, &genai.ClientConfig{
232233
APIKey: apiKey,
233234
HTTPClient: httpClient,
@@ -246,6 +247,7 @@ func CreateLLM(ctx context.Context, m adk.Model, log logr.Logger) (adkmodel.LLM,
246247
if modelName == "" {
247248
modelName = DefaultGeminiModel
248249
}
250+
warnIgnoredMaxRetries(log, m.BaseModel, "gemini_vertex_ai")
249251
return adkgemini.NewModel(ctx, modelName, &genai.ClientConfig{
250252
Backend: genai.BackendVertexAI,
251253
Project: project,
@@ -278,6 +280,7 @@ func CreateLLM(ctx context.Context, m adk.Model, log logr.Logger) (adkmodel.LLM,
278280
modelName = DefaultOllamaModel
279281
}
280282
// Create OllamaConfig with native SDK support for Ollama-specific options
283+
warnIgnoredMaxRetries(log, m.BaseModel, "ollama")
281284
cfg := &models.OllamaConfig{
282285
TransportConfig: transportConfigFromBase(m.BaseModel, nil),
283286
Model: modelName,
@@ -299,6 +302,7 @@ func CreateLLM(ctx context.Context, m adk.Model, log logr.Logger) (adkmodel.LLM,
299302
return nil, fmt.Errorf("bedrock requires a model name (e.g. anthropic.claude-3-sonnet-20240229-v1:0)")
300303
}
301304
// Use Bedrock Converse API for ALL models (including Anthropic)
305+
warnIgnoredMaxRetries(log, m.BaseModel, "bedrock")
302306
cfg := &models.BedrockConfig{
303307
TransportConfig: transportConfigFromBase(m.BaseModel, nil),
304308
Model: modelName,
@@ -329,6 +333,7 @@ func CreateLLM(ctx context.Context, m adk.Model, log logr.Logger) (adkmodel.LLM,
329333
return models.NewAnthropicVertexAIModelWithLogger(ctx, cfg, region, project, log)
330334

331335
case *adk.SAPAICore:
336+
warnIgnoredMaxRetries(log, m.BaseModel, "sap_ai_core")
332337
cfg := models.SAPAICoreConfig{
333338
Model: m.Model,
334339
BaseUrl: m.BaseUrl,
@@ -352,6 +357,15 @@ func transportConfigFromBase(b adk.BaseModel, timeout *int) models.TransportConf
352357
TLSDisableSystemCAs: b.TLSDisableSystemCAs,
353358
APIKeyPassthrough: b.APIKeyPassthrough,
354359
Timeout: timeout,
360+
MaxRetries: b.MaxRetries,
361+
}
362+
}
363+
364+
// warnIgnoredMaxRetries logs a warning when retry configuration is set on a
365+
// provider whose Go SDK does not support configurable HTTP retries.
366+
func warnIgnoredMaxRetries(log logr.Logger, b adk.BaseModel, provider string) {
367+
if b.MaxRetries != nil {
368+
log.Info("Model retry configuration (max_retries) is not supported for this provider in the Go runtime; ignoring", "provider", provider)
355369
}
356370
}
357371

go/adk/pkg/models/anthropic.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ func newAnthropicModelFromConfig(config *AnthropicConfig, apiKey string, logger
6565
if config.BaseUrl != "" {
6666
opts = append(opts, option.WithBaseURL(config.BaseUrl))
6767
}
68+
if config.MaxRetries != nil {
69+
opts = append(opts, option.WithMaxRetries(*config.MaxRetries))
70+
}
6871

6972
// Create HTTP client with TLS, custom headers, and timeout.
7073
httpClient, err := BuildHTTPClient(config.TransportConfig)
@@ -95,6 +98,9 @@ func NewAnthropicVertexAIModelWithLogger(ctx context.Context, config *AnthropicC
9598
opts := []option.RequestOption{
9699
vertex.WithGoogleAuth(ctx, region, projectID),
97100
}
101+
if config.MaxRetries != nil {
102+
opts = append(opts, option.WithMaxRetries(*config.MaxRetries))
103+
}
98104

99105
// Create HTTP client with timeout, custom headers, TLS, and passthrough
100106
httpClient, err := BuildHTTPClient(config.TransportConfig)
@@ -126,6 +132,9 @@ func NewAnthropicBedrockModelWithLogger(ctx context.Context, config *AnthropicCo
126132
awsconfig.WithRegion(region),
127133
),
128134
}
135+
if config.MaxRetries != nil {
136+
opts = append(opts, option.WithMaxRetries(*config.MaxRetries))
137+
}
129138

130139
// Create HTTP client with timeout, custom headers, TLS, and passthrough
131140
httpClient, err := BuildHTTPClient(config.TransportConfig)

go/adk/pkg/models/base.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type TransportConfig struct {
2020
TLSDisableSystemCAs *bool
2121
APIKeyPassthrough bool
2222
Timeout *int // seconds; nil = defaultTimeout
23+
MaxRetries *int // HTTP retry attempts for transient failures; nil = SDK default
2324
}
2425

2526
// BuildHTTPClient creates an http.Client with the full transport stack:

go/adk/pkg/models/openai.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ func newOpenAIModelFromConfig(config *OpenAIConfig, apiKey string, logger logr.L
8484
if config.BaseUrl != "" {
8585
opts = append(opts, option.WithBaseURL(config.BaseUrl))
8686
}
87+
if config.MaxRetries != nil {
88+
opts = append(opts, option.WithMaxRetries(*config.MaxRetries))
89+
}
8790
httpClient, err := BuildHTTPClient(config.TransportConfig)
8891
if err != nil {
8992
return nil, err
@@ -123,6 +126,9 @@ func NewAzureOpenAIModelWithLogger(config *AzureOpenAIConfig, logger logr.Logger
123126
option.WithQueryAdd("api-version", apiVersion),
124127
option.WithMiddleware(azurePathRewriteMiddleware()),
125128
}
129+
if config.MaxRetries != nil {
130+
opts = append(opts, option.WithMaxRetries(*config.MaxRetries))
131+
}
126132

127133
if !config.APIKeyPassthrough {
128134
apiKey := os.Getenv("AZURE_OPENAI_API_KEY")

go/adk/pkg/runner/adapter.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import (
1414
"github.com/kagent-dev/kagent/go/api/adk"
1515
adkmemory "google.golang.org/adk/memory"
1616
adkplugin "google.golang.org/adk/plugin"
17+
"google.golang.org/adk/plugin/loggingplugin"
18+
"google.golang.org/adk/plugin/retryandreflect"
1719
"google.golang.org/adk/runner"
1820
adksession "google.golang.org/adk/session"
1921
adktool "google.golang.org/adk/tool"
@@ -83,6 +85,12 @@ func CreateRunnerConfig(
8385
}
8486
}
8587

88+
reliabilityPlugins, err := buildReliabilityPlugins(agentConfig.Reliability, log)
89+
if err != nil {
90+
return runner.Config{}, nil, err
91+
}
92+
adkPlugins = append(adkPlugins, reliabilityPlugins...)
93+
8694
cfg := runner.Config{
8795
AppName: appName,
8896
Agent: adkAgent,
@@ -96,6 +104,49 @@ func CreateRunnerConfig(
96104
return cfg, subagentSessionIDs, nil
97105
}
98106

107+
// buildReliabilityPlugins translates the agent reliability configuration into
108+
// ADK plugins: debug logging, tool retry (reflect-and-retry), and a max LLM
109+
// calls limit.
110+
func buildReliabilityPlugins(r *adk.ReliabilityConfig, log logr.Logger) ([]*adkplugin.Plugin, error) {
111+
if r == nil {
112+
return nil, nil
113+
}
114+
115+
var plugins []*adkplugin.Plugin
116+
117+
if r.DebugLogging != nil && *r.DebugLogging {
118+
p, err := loggingplugin.New("kagent_debug_logging")
119+
if err != nil {
120+
return nil, fmt.Errorf("failed to create debug logging plugin: %w", err)
121+
}
122+
plugins = append(plugins, p)
123+
log.Info("Debug logging enabled for agent")
124+
}
125+
126+
if r.ToolRetries != nil && *r.ToolRetries > 0 {
127+
p, err := retryandreflect.New(
128+
retryandreflect.WithMaxRetries(*r.ToolRetries),
129+
retryandreflect.WithErrorIfRetryExceeded(false),
130+
)
131+
if err != nil {
132+
return nil, fmt.Errorf("failed to create tool retry plugin: %w", err)
133+
}
134+
plugins = append(plugins, p)
135+
log.Info("Tool retry enabled for agent", "toolRetries", *r.ToolRetries)
136+
}
137+
138+
if r.MaxLLMCalls != nil && *r.MaxLLMCalls > 0 {
139+
p, err := newMaxLLMCallsPlugin(*r.MaxLLMCalls)
140+
if err != nil {
141+
return nil, fmt.Errorf("failed to create max LLM calls plugin: %w", err)
142+
}
143+
plugins = append(plugins, p)
144+
log.Info("Max LLM calls limit enabled for agent", "maxLLMCalls", *r.MaxLLMCalls)
145+
}
146+
147+
return plugins, nil
148+
}
149+
99150
func buildTokenPropagationPlugin(ctx context.Context, log logr.Logger) (*sts.TokenPropagationPlugin, error) {
100151
propagateToken := strings.EqualFold(strings.TrimSpace(os.Getenv("KAGENT_PROPAGATE_TOKEN")), "true")
101152
stsWellKnownURI := strings.TrimSpace(os.Getenv("STS_WELL_KNOWN_URI"))

go/adk/pkg/runner/adapter_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package runner
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
8+
"github.com/go-logr/logr"
9+
"github.com/kagent-dev/kagent/go/api/adk"
10+
"google.golang.org/adk/agent"
11+
"google.golang.org/adk/session"
12+
"google.golang.org/genai"
13+
)
14+
15+
func TestBuildReliabilityPlugins(t *testing.T) {
16+
tests := []struct {
17+
name string
18+
config *adk.ReliabilityConfig
19+
wantNames []string
20+
}{
21+
{
22+
name: "nil config",
23+
config: nil,
24+
wantNames: nil,
25+
},
26+
{
27+
name: "empty config",
28+
config: &adk.ReliabilityConfig{},
29+
wantNames: nil,
30+
},
31+
{
32+
name: "debug logging only",
33+
config: &adk.ReliabilityConfig{DebugLogging: new(true)},
34+
wantNames: []string{"kagent_debug_logging"},
35+
},
36+
{
37+
name: "debug logging false",
38+
config: &adk.ReliabilityConfig{DebugLogging: new(false)},
39+
wantNames: nil,
40+
},
41+
{
42+
name: "tool retries only",
43+
config: &adk.ReliabilityConfig{ToolRetries: new(3)},
44+
wantNames: []string{"RetryAndReflectPlugin"},
45+
},
46+
{
47+
name: "max llm calls only",
48+
config: &adk.ReliabilityConfig{MaxLLMCalls: new(10)},
49+
wantNames: []string{"kagent_max_llm_calls"},
50+
},
51+
{
52+
name: "all enabled",
53+
config: &adk.ReliabilityConfig{
54+
ToolRetries: new(2),
55+
MaxLLMCalls: new(50),
56+
DebugLogging: new(true),
57+
},
58+
wantNames: []string{"kagent_debug_logging", "RetryAndReflectPlugin", "kagent_max_llm_calls"},
59+
},
60+
}
61+
62+
for _, tt := range tests {
63+
t.Run(tt.name, func(t *testing.T) {
64+
plugins, err := buildReliabilityPlugins(tt.config, logr.Discard())
65+
if err != nil {
66+
t.Fatalf("buildReliabilityPlugins() error = %v", err)
67+
}
68+
if len(plugins) != len(tt.wantNames) {
69+
t.Fatalf("got %d plugins, want %d", len(plugins), len(tt.wantNames))
70+
}
71+
for i, want := range tt.wantNames {
72+
if got := plugins[i].Name(); got != want {
73+
t.Errorf("plugin[%d].Name() = %q, want %q", i, got, want)
74+
}
75+
}
76+
})
77+
}
78+
}
79+
80+
// fakeCallbackContext is a minimal agent.CallbackContext for testing.
81+
type fakeCallbackContext struct {
82+
context.Context
83+
invocationID string
84+
}
85+
86+
func (f *fakeCallbackContext) UserContent() *genai.Content { return nil }
87+
func (f *fakeCallbackContext) InvocationID() string { return f.invocationID }
88+
func (f *fakeCallbackContext) AgentName() string { return "test-agent" }
89+
func (f *fakeCallbackContext) ReadonlyState() session.ReadonlyState { return nil }
90+
func (f *fakeCallbackContext) UserID() string { return "user" }
91+
func (f *fakeCallbackContext) AppName() string { return "app" }
92+
func (f *fakeCallbackContext) SessionID() string { return "session" }
93+
func (f *fakeCallbackContext) Branch() string { return "" }
94+
func (f *fakeCallbackContext) Artifacts() agent.Artifacts { return nil }
95+
func (f *fakeCallbackContext) State() session.State { return nil }
96+
97+
func TestMaxLLMCallsPlugin(t *testing.T) {
98+
p, err := newMaxLLMCallsPlugin(2)
99+
if err != nil {
100+
t.Fatalf("newMaxLLMCallsPlugin() error = %v", err)
101+
}
102+
cb := p.BeforeModelCallback()
103+
if cb == nil {
104+
t.Fatal("BeforeModelCallback is nil")
105+
}
106+
107+
ctxA := &fakeCallbackContext{Context: context.Background(), invocationID: "inv-a"}
108+
ctxB := &fakeCallbackContext{Context: context.Background(), invocationID: "inv-b"}
109+
110+
// First two calls within the limit succeed.
111+
for i := range 2 {
112+
if _, err := cb(ctxA, nil); err != nil {
113+
t.Fatalf("call %d: unexpected error: %v", i+1, err)
114+
}
115+
}
116+
117+
// Third call exceeds the limit.
118+
if _, err := cb(ctxA, nil); err == nil {
119+
t.Fatal("expected error after exceeding limit, got nil")
120+
} else if !strings.Contains(err.Error(), "limit of 2 model calls") {
121+
t.Errorf("unexpected error message: %v", err)
122+
}
123+
124+
// A different invocation has its own counter.
125+
if _, err := cb(ctxB, nil); err != nil {
126+
t.Fatalf("different invocation should not be limited: %v", err)
127+
}
128+
}

go/adk/pkg/runner/maxllmcalls.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package runner
2+
3+
import (
4+
"fmt"
5+
"sync"
6+
7+
"google.golang.org/adk/agent"
8+
"google.golang.org/adk/model"
9+
adkplugin "google.golang.org/adk/plugin"
10+
)
11+
12+
// newMaxLLMCallsPlugin returns a plugin that enforces a limit on the number of
13+
// LLM calls within a single invocation. The Go ADK has no native equivalent of
14+
// the Python RunConfig.max_llm_calls, so this is implemented as a
15+
// BeforeModelCallback that counts model calls per invocation ID and aborts the
16+
// run when the limit is exceeded.
17+
func newMaxLLMCallsPlugin(limit int) (*adkplugin.Plugin, error) {
18+
var mu sync.Mutex
19+
counts := make(map[string]int)
20+
21+
return adkplugin.New(adkplugin.Config{
22+
Name: "kagent_max_llm_calls",
23+
BeforeModelCallback: func(ctx agent.CallbackContext, llmRequest *model.LLMRequest) (*model.LLMResponse, error) {
24+
mu.Lock()
25+
defer mu.Unlock()
26+
id := ctx.InvocationID()
27+
counts[id]++
28+
if counts[id] > limit {
29+
return nil, fmt.Errorf(
30+
"agent stopped: exceeded the configured limit of %d model calls in a single run (reliability.maxLLMCalls)",
31+
limit,
32+
)
33+
}
34+
return nil, nil
35+
},
36+
AfterRunCallback: func(ictx agent.InvocationContext) {
37+
mu.Lock()
38+
defer mu.Unlock()
39+
delete(counts, ictx.InvocationID())
40+
},
41+
})
42+
}

0 commit comments

Comments
 (0)