diff --git a/go/api/v1alpha2/agent_types.go b/go/api/v1alpha2/agent_types.go index 5dc8e4958..8abd1b909 100644 --- a/go/api/v1alpha2/agent_types.go +++ b/go/api/v1alpha2/agent_types.go @@ -118,6 +118,19 @@ type DeclarativeAgentSpec struct { // +optional // due to a bug in adk (https://github.com/google/adk-python/issues/3921), this field is ignored for now. ExecuteCodeBlocks *bool `json:"executeCodeBlocks,omitempty"` + + // Context configures context management for this agent. + // This includes event compaction (compression) and context caching. + // +optional + Context *ContextConfig `json:"context,omitempty"` + + // Memory configures the memory for the agent. + // +optional + Memory *MemoryConfig `json:"memory,omitempty"` + + // Resumability configures the resumability for the agent. + // +optional + Resumability *ResumabilityConfig `json:"resumability,omitempty"` } type DeclarativeDeploymentSpec struct { @@ -127,6 +140,133 @@ type DeclarativeDeploymentSpec struct { SharedDeploymentSpec `json:",inline"` } +// ResumabilityConfig configures the resumability for the agent. +type ResumabilityConfig struct { + // IsResumable enables agent resumability. + // +optional + IsResumable bool `json:"isResumable,omitempty"` +} + +// MemoryType represents the memory type +// +kubebuilder:validation:Enum=InMemory;VertexAI;McpServer +type MemoryType string + +const ( + MemoryTypeInMemory MemoryType = "InMemory" + MemoryTypeVertexAI MemoryType = "VertexAI" + MemoryTypeMcpServer MemoryType = "McpServer" +) + +// MemoryConfig configures the memory for the agent. +// +kubebuilder:validation:XValidation:rule="!has(self.inMemory) || self.type == 'InMemory'",message="inMemory configuration is only allowed when type is InMemory" +// +kubebuilder:validation:XValidation:rule="!has(self.vertexAi) || self.type == 'VertexAI'",message="vertexAi configuration is only allowed when type is VertexAI" +// +kubebuilder:validation:XValidation:rule="!has(self.mcpServer) || self.type == 'McpServer'",message="mcpServer configuration is only allowed when type is McpServer" +type MemoryConfig struct { + // +kubebuilder:default=InMemory + Type MemoryType `json:"type"` + + // +optional + InMemory *InMemoryConfig `json:"inMemory,omitempty"` + // +optional + VertexAI *VertexAIMemoryConfig `json:"vertexAi,omitempty"` + // +optional + McpServer *McpMemoryConfig `json:"mcpServer,omitempty"` +} + +type InMemoryConfig struct { +} + +type VertexAIMemoryConfig struct { + // +optional + ProjectID string `json:"projectID,omitempty"` + // +optional + Location string `json:"location,omitempty"` +} + +type McpMemoryConfig struct { + // Name is the name of the MCP server resource. + Name string `json:"name"` + // Kind is the kind of the MCP server resource. + // +optional + // +kubebuilder:default=MCPServer + Kind string `json:"kind,omitempty"` + // ApiGroup is the API group of the MCP server resource. + // +optional + // +kubebuilder:default=kagent.dev + ApiGroup string `json:"apiGroup,omitempty"` +} + +// ContextConfig configures context management for an agent. +// Context management includes event compaction (compression/summarization) and context caching. +type ContextConfig struct { + // Compaction configures event history compaction. + // When enabled, older events in the conversation are compacted (compressed/summarized) + // to reduce context size while preserving key information. + // +optional + Compaction *ContextCompressionConfig `json:"compaction,omitempty"` + // Cache configures context caching. + // When enabled, prefix context is cached at the provider level to reduce + // redundant processing of repeated context. + // +optional + Cache *ContextCacheConfig `json:"cache,omitempty"` +} + +// ContextCompressionConfig configures event history compaction/compression. +// +kubebuilder:validation:XValidation:rule="has(self.compactionInterval) && has(self.overlapSize)",message="compactionInterval and overlapSize are required" +type ContextCompressionConfig struct { + // The number of *new* user-initiated invocations that, once fully represented in the session's events, will trigger a compaction. + // +kubebuilder:validation:Minimum=1 + CompactionInterval int `json:"compactionInterval"` + // The number of preceding invocations to include from the end of the last compacted range. This creates an overlap between consecutive compacted summaries, maintaining context. + // +kubebuilder:validation:Minimum=0 + OverlapSize int `json:"overlapSize"` + // Summarizer configures an LLM-based summarizer for event compaction. + // If not specified, compacted events are simply truncated without summarization. + // +optional + Summarizer *ContextSummarizerConfig `json:"summarizer,omitempty"` + // Post-invocation token threshold trigger. If set, ADK will attempt a post-invocation compaction when the most recently + // observed prompt token count meets or exceeds this threshold. + // +optional + TokenThreshold *int `json:"tokenThreshold,omitempty"` + // EventRetentionSize is the number of most recent events to always retain. + // +optional + EventRetentionSize *int `json:"eventRetentionSize,omitempty"` +} + +// ContextSummarizerConfig configures the LLM-based event summarizer. +type ContextSummarizerConfig struct { + // ModelConfig is the name of a ModelConfig resource to use for summarization. + // Must be in the same namespace as the Agent. + // If not specified, uses the agent's own model. + // +optional + ModelConfig string `json:"modelConfig,omitempty"` + // PromptTemplate is a custom prompt template for the summarizer. + // +optional + PromptTemplate string `json:"promptTemplate,omitempty"` +} + +// ContextCacheConfig configures prefix context caching at the LLM provider level. +type ContextCacheConfig struct { + // CacheIntervals specifies how often (in number of events) to update the cache. + // Default: 10 + // +optional + // +kubebuilder:default=10 + // +kubebuilder:validation:Minimum=1 + CacheIntervals *int `json:"cacheIntervals,omitempty"` + // TTLSeconds specifies the time-to-live for cached context in seconds. + // Default: 1800 (30 minutes) + // +optional + // +kubebuilder:default=1800 + // +kubebuilder:validation:Minimum=0 + TTLSeconds *int `json:"ttlSeconds,omitempty"` + // MinTokens is the minimum number of tokens before caching is activated. + // Default: 0 + // +optional + // +kubebuilder:default=0 + // +kubebuilder:validation:Minimum=0 + MinTokens *int `json:"minTokens,omitempty"` +} + type BYOAgentSpec struct { // Trust relationship to the agent. // +optional diff --git a/go/api/v1alpha2/zz_generated.deepcopy.go b/go/api/v1alpha2/zz_generated.deepcopy.go index 100fdbbf1..a4b1b0760 100644 --- a/go/api/v1alpha2/zz_generated.deepcopy.go +++ b/go/api/v1alpha2/zz_generated.deepcopy.go @@ -356,6 +356,106 @@ func (in *ByoDeploymentSpec) DeepCopy() *ByoDeploymentSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ContextCacheConfig) DeepCopyInto(out *ContextCacheConfig) { + *out = *in + if in.CacheIntervals != nil { + in, out := &in.CacheIntervals, &out.CacheIntervals + *out = new(int) + **out = **in + } + if in.TTLSeconds != nil { + in, out := &in.TTLSeconds, &out.TTLSeconds + *out = new(int) + **out = **in + } + if in.MinTokens != nil { + in, out := &in.MinTokens, &out.MinTokens + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ContextCacheConfig. +func (in *ContextCacheConfig) DeepCopy() *ContextCacheConfig { + if in == nil { + return nil + } + out := new(ContextCacheConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ContextCompressionConfig) DeepCopyInto(out *ContextCompressionConfig) { + *out = *in + if in.Summarizer != nil { + in, out := &in.Summarizer, &out.Summarizer + *out = new(ContextSummarizerConfig) + **out = **in + } + if in.TokenThreshold != nil { + in, out := &in.TokenThreshold, &out.TokenThreshold + *out = new(int) + **out = **in + } + if in.EventRetentionSize != nil { + in, out := &in.EventRetentionSize, &out.EventRetentionSize + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ContextCompressionConfig. +func (in *ContextCompressionConfig) DeepCopy() *ContextCompressionConfig { + if in == nil { + return nil + } + out := new(ContextCompressionConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ContextConfig) DeepCopyInto(out *ContextConfig) { + *out = *in + if in.Compaction != nil { + in, out := &in.Compaction, &out.Compaction + *out = new(ContextCompressionConfig) + (*in).DeepCopyInto(*out) + } + if in.Cache != nil { + in, out := &in.Cache, &out.Cache + *out = new(ContextCacheConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ContextConfig. +func (in *ContextConfig) DeepCopy() *ContextConfig { + if in == nil { + return nil + } + out := new(ContextConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ContextSummarizerConfig) DeepCopyInto(out *ContextSummarizerConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ContextSummarizerConfig. +func (in *ContextSummarizerConfig) DeepCopy() *ContextSummarizerConfig { + if in == nil { + return nil + } + out := new(ContextSummarizerConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DeclarativeAgentSpec) DeepCopyInto(out *DeclarativeAgentSpec) { *out = *in @@ -390,6 +490,21 @@ func (in *DeclarativeAgentSpec) DeepCopyInto(out *DeclarativeAgentSpec) { *out = new(bool) **out = **in } + if in.Context != nil { + in, out := &in.Context, &out.Context + *out = new(ContextConfig) + (*in).DeepCopyInto(*out) + } + if in.Memory != nil { + in, out := &in.Memory, &out.Memory + *out = new(MemoryConfig) + (*in).DeepCopyInto(*out) + } + if in.Resumability != nil { + in, out := &in.Resumability, &out.Resumability + *out = new(ResumabilityConfig) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeclarativeAgentSpec. @@ -449,6 +564,21 @@ func (in *GeminiVertexAIConfig) DeepCopy() *GeminiVertexAIConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InMemoryConfig) DeepCopyInto(out *InMemoryConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InMemoryConfig. +func (in *InMemoryConfig) DeepCopy() *InMemoryConfig { + if in == nil { + return nil + } + out := new(InMemoryConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MCPTool) DeepCopyInto(out *MCPTool) { *out = *in @@ -464,6 +594,21 @@ func (in *MCPTool) DeepCopy() *MCPTool { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *McpMemoryConfig) DeepCopyInto(out *McpMemoryConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new McpMemoryConfig. +func (in *McpMemoryConfig) DeepCopy() *McpMemoryConfig { + if in == nil { + return nil + } + out := new(McpMemoryConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *McpServerTool) DeepCopyInto(out *McpServerTool) { *out = *in @@ -490,6 +635,36 @@ func (in *McpServerTool) DeepCopy() *McpServerTool { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MemoryConfig) DeepCopyInto(out *MemoryConfig) { + *out = *in + if in.InMemory != nil { + in, out := &in.InMemory, &out.InMemory + *out = new(InMemoryConfig) + **out = **in + } + if in.VertexAI != nil { + in, out := &in.VertexAI, &out.VertexAI + *out = new(VertexAIMemoryConfig) + **out = **in + } + if in.McpServer != nil { + in, out := &in.McpServer, &out.McpServer + *out = new(McpMemoryConfig) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MemoryConfig. +func (in *MemoryConfig) DeepCopy() *MemoryConfig { + if in == nil { + return nil + } + out := new(MemoryConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ModelConfig) DeepCopyInto(out *ModelConfig) { *out = *in @@ -829,6 +1004,21 @@ func (in *RemoteMCPServerStatus) DeepCopy() *RemoteMCPServerStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResumabilityConfig) DeepCopyInto(out *ResumabilityConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResumabilityConfig. +func (in *ResumabilityConfig) DeepCopy() *ResumabilityConfig { + if in == nil { + return nil + } + out := new(ResumabilityConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServiceAccountConfig) DeepCopyInto(out *ServiceAccountConfig) { *out = *in @@ -1078,3 +1268,18 @@ func (in *ValueSource) DeepCopy() *ValueSource { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VertexAIMemoryConfig) DeepCopyInto(out *VertexAIMemoryConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VertexAIMemoryConfig. +func (in *VertexAIMemoryConfig) DeepCopy() *VertexAIMemoryConfig { + if in == nil { + return nil + } + out := new(VertexAIMemoryConfig) + in.DeepCopyInto(out) + return out +} diff --git a/go/config/crd/bases/kagent.dev_agents.yaml b/go/config/crd/bases/kagent.dev_agents.yaml index 418225433..52b4ed2ed 100644 --- a/go/config/crd/bases/kagent.dev_agents.yaml +++ b/go/config/crd/bases/kagent.dev_agents.yaml @@ -6196,6 +6196,91 @@ spec: minItems: 1 type: array type: object + context: + description: |- + Context configures context management for this agent. + This includes event compaction (compression) and context caching. + properties: + cache: + description: |- + Cache configures context caching. + When enabled, prefix context is cached at the provider level to reduce + redundant processing of repeated context. + properties: + cacheIntervals: + default: 10 + description: |- + CacheIntervals specifies how often (in number of events) to update the cache. + Default: 10 + minimum: 1 + type: integer + minTokens: + default: 0 + description: |- + MinTokens is the minimum number of tokens before caching is activated. + Default: 0 + minimum: 0 + type: integer + ttlSeconds: + default: 1800 + description: |- + TTLSeconds specifies the time-to-live for cached context in seconds. + Default: 1800 (30 minutes) + minimum: 0 + type: integer + type: object + compaction: + description: |- + Compaction configures event history compaction. + When enabled, older events in the conversation are compacted (compressed/summarized) + to reduce context size while preserving key information. + properties: + compactionInterval: + description: The number of *new* user-initiated invocations + that, once fully represented in the session's events, + will trigger a compaction. + minimum: 1 + type: integer + eventRetentionSize: + description: EventRetentionSize is the number of most + recent events to always retain. + type: integer + overlapSize: + description: The number of preceding invocations to include + from the end of the last compacted range. This creates + an overlap between consecutive compacted summaries, + maintaining context. + minimum: 0 + type: integer + summarizer: + description: |- + Summarizer configures an LLM-based summarizer for event compaction. + If not specified, compacted events are simply truncated without summarization. + properties: + modelConfig: + description: |- + ModelConfig is the name of a ModelConfig resource to use for summarization. + Must be in the same namespace as the Agent. + If not specified, uses the agent's own model. + type: string + promptTemplate: + description: PromptTemplate is a custom prompt template + for the summarizer. + type: string + type: object + tokenThreshold: + description: |- + Post-invocation token threshold trigger. If set, ADK will attempt a post-invocation compaction when the most recently + observed prompt token count meets or exceeds this threshold. + type: integer + required: + - compactionInterval + - overlapSize + type: object + x-kubernetes-validations: + - message: compactionInterval and overlapSize are required + rule: has(self.compactionInterval) && has(self.overlapSize) + type: object deployment: properties: affinity: @@ -9863,12 +9948,70 @@ spec: Code will be executed in a sandboxed environment. due to a bug in adk (https://github.com/google/adk-python/issues/3921), this field is ignored for now. type: boolean + memory: + description: Memory configures the memory for the agent. + properties: + inMemory: + type: object + mcpServer: + properties: + apiGroup: + default: kagent.dev + description: ApiGroup is the API group of the MCP server + resource. + type: string + kind: + default: MCPServer + description: Kind is the kind of the MCP server resource. + type: string + name: + description: Name is the name of the MCP server resource. + type: string + required: + - name + type: object + type: + default: InMemory + description: MemoryType represents the memory type + enum: + - InMemory + - VertexAI + - McpServer + type: string + vertexAi: + properties: + location: + type: string + projectID: + type: string + type: object + required: + - type + type: object + x-kubernetes-validations: + - message: inMemory configuration is only allowed when type is + InMemory + rule: '!has(self.inMemory) || self.type == ''InMemory''' + - message: vertexAi configuration is only allowed when type is + VertexAI + rule: '!has(self.vertexAi) || self.type == ''VertexAI''' + - message: mcpServer configuration is only allowed when type is + McpServer + rule: '!has(self.mcpServer) || self.type == ''McpServer''' modelConfig: description: |- The name of the model config to use. If not specified, the default value is "default-model-config". Must be in the same namespace as the Agent. type: string + resumability: + description: Resumability configures the resumability for the + agent. + properties: + isResumable: + description: IsResumable enables agent resumability. + type: boolean + type: object stream: description: |- Whether to stream the response from the model. diff --git a/go/internal/controller/translator/agent/adk_api_translator.go b/go/internal/controller/translator/agent/adk_api_translator.go index 352db33a4..fc9ce0139 100644 --- a/go/internal/controller/translator/agent/adk_api_translator.go +++ b/go/internal/controller/translator/agent/adk_api_translator.go @@ -573,6 +573,67 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent *v1al Stream: agent.Spec.Declarative.Stream, } + // Translate context management configuration + if agent.Spec.Declarative.Context != nil { + contextCfg := &adk.AgentContextConfig{} + + if agent.Spec.Declarative.Context.Compaction != nil { + comp := agent.Spec.Declarative.Context.Compaction + compCfg := &adk.AgentCompressionConfig{ + CompactionInterval: comp.CompactionInterval, + OverlapSize: comp.OverlapSize, + TokenThreshold: comp.TokenThreshold, + EventRetentionSize: comp.EventRetentionSize, + } + + if comp.Summarizer != nil { + compCfg.PromptTemplate = comp.Summarizer.PromptTemplate + + if comp.Summarizer.ModelConfig == "" || comp.Summarizer.ModelConfig == agent.Spec.Declarative.ModelConfig { + compCfg.SummarizerModel = model + } else { + summarizerModel, summarizerMdd, summarizerSecretHash, err := a.translateModel(ctx, agent.Namespace, comp.Summarizer.ModelConfig) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to translate summarizer model config %q: %w", comp.Summarizer.ModelConfig, err) + } + compCfg.SummarizerModel = summarizerModel + mergeDeploymentData(mdd, summarizerMdd) + if len(summarizerSecretHash) > 0 { + secretHashBytes = append(secretHashBytes, summarizerSecretHash...) + } + } + } + + contextCfg.Compaction = compCfg + } + + if agent.Spec.Declarative.Context.Cache != nil { + contextCfg.Cache = &adk.AgentCacheConfig{ + CacheIntervals: agent.Spec.Declarative.Context.Cache.CacheIntervals, + TTLSeconds: agent.Spec.Declarative.Context.Cache.TTLSeconds, + MinTokens: agent.Spec.Declarative.Context.Cache.MinTokens, + } + } + + cfg.ContextConfig = contextCfg + } + + // Translate resumability configuration + if agent.Spec.Declarative.Resumability != nil { + cfg.ResumabilityConfig = &adk.AgentResumabilityConfig{ + IsResumable: agent.Spec.Declarative.Resumability.IsResumable, + } + } + + // Translate memory configuration + if agent.Spec.Declarative.Memory != nil { + memConfig, err := a.translateMemory(ctx, agent, agent.Spec.Declarative.Memory) + if err != nil { + return nil, nil, nil, err + } + cfg.Memory = memConfig + } + for _, tool := range agent.Spec.Declarative.Tools { headers, err := tool.ResolveHeaders(ctx, a.kube, agent.Namespace) if err != nil { @@ -1319,6 +1380,47 @@ func computeConfigHash(agentCfg, agentCard, secretData []byte) uint64 { return binary.BigEndian.Uint64(hash[:8]) } +// mergeDeploymentData adds env vars, volumes, and volume mounts from src into dst, +// skipping any that already exist in dst (by name for env/volumes, by mount path for mounts). +func mergeDeploymentData(dst, src *modelDeploymentData) { + for _, se := range src.EnvVars { + found := false + for _, e := range dst.EnvVars { + if e.Name == se.Name { + found = true + break + } + } + if !found { + dst.EnvVars = append(dst.EnvVars, se) + } + } + for _, sv := range src.Volumes { + found := false + for _, v := range dst.Volumes { + if v.Name == sv.Name { + found = true + break + } + } + if !found { + dst.Volumes = append(dst.Volumes, sv) + } + } + for _, sm := range src.VolumeMounts { + found := false + for _, m := range dst.VolumeMounts { + if m.MountPath == sm.MountPath { + found = true + break + } + } + if !found { + dst.VolumeMounts = append(dst.VolumeMounts, sm) + } + } +} + func collectOtelEnvFromProcess() []corev1.EnvVar { envVars := slices.Collect(utils.Map( utils.Filter( @@ -1353,3 +1455,73 @@ func (a *adkApiTranslator) runPlugins(ctx context.Context, agent *v1alpha2.Agent } return errs } + +func (a *adkApiTranslator) translateMemory(ctx context.Context, agent *v1alpha2.Agent, memConfig *v1alpha2.MemoryConfig) (any, error) { + if memConfig == nil { + return nil, nil + } + + switch memConfig.Type { + case v1alpha2.MemoryTypeInMemory: + return &adk.InMemoryConfig{BaseMemoryConfig: adk.BaseMemoryConfig{Type: "in_memory"}}, nil + case v1alpha2.MemoryTypeVertexAI: + var projectID *string + var location *string + if memConfig.VertexAI != nil { + if memConfig.VertexAI.ProjectID != "" { + projectID = &memConfig.VertexAI.ProjectID + } + if memConfig.VertexAI.Location != "" { + location = &memConfig.VertexAI.Location + } + } + return &adk.VertexAIMemoryConfig{ + BaseMemoryConfig: adk.BaseMemoryConfig{Type: "vertex_ai"}, + ProjectID: projectID, + Location: location, + }, nil + case v1alpha2.MemoryTypeMcpServer: + if memConfig.McpServer == nil { + return nil, fmt.Errorf("mcpServer configuration is required for type McpServer") + } + + // Use the existing translation logic for MCP servers, but capture the result + // instead of adding it to the agent's tool list. + tempAgentConfig := &adk.AgentConfig{} + + // Create a synthetic tool definition for the memory server. + toolDef := &v1alpha2.McpServerTool{ + TypedLocalReference: v1alpha2.TypedLocalReference{ + Name: memConfig.McpServer.Name, + Kind: memConfig.McpServer.Kind, + ApiGroup: memConfig.McpServer.ApiGroup, + }, + // Empty tool names as we're not exposing these tools to the agent itself + ToolNames: []string{}, + } + + err := a.translateMCPServerTarget(ctx, tempAgentConfig, agent.Namespace, toolDef, nil, a.globalProxyURL) + if err != nil { + return nil, fmt.Errorf("failed to translate memory MCP server: %w", err) + } + + var serverConfig any + if len(tempAgentConfig.HttpTools) > 0 { + serverConfig = tempAgentConfig.HttpTools[0] + } else if len(tempAgentConfig.SseTools) > 0 { + serverConfig = tempAgentConfig.SseTools[0] + } else { + return nil, fmt.Errorf("failed to resolve configuration for memory MCP server %s", memConfig.McpServer.Name) + } + + return &adk.McpMemoryConfig{ + BaseMemoryConfig: adk.BaseMemoryConfig{Type: "mcp"}, + Name: memConfig.McpServer.Name, + Kind: memConfig.McpServer.Kind, + ApiGroup: memConfig.McpServer.ApiGroup, + ServerConfig: serverConfig, + }, nil + default: + return nil, fmt.Errorf("unknown memory type: %s", memConfig.Type) + } +} diff --git a/go/internal/controller/translator/agent/adk_api_translator_test.go b/go/internal/controller/translator/agent/adk_api_translator_test.go index 7fa2fb345..5042b2bee 100644 --- a/go/internal/controller/translator/agent/adk_api_translator_test.go +++ b/go/internal/controller/translator/agent/adk_api_translator_test.go @@ -17,6 +17,7 @@ import ( "k8s.io/apimachinery/pkg/types" schemev1 "k8s.io/client-go/kubernetes/scheme" "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" ) @@ -913,3 +914,400 @@ func Test_AdkApiTranslator_RecursionDepthTracking(t *testing.T) { require.NoError(t, err, "diamond pattern should pass — D is not a cycle, just shared") }) } + +func Test_AdkApiTranslator_MergeDeploymentData(t *testing.T) { + scheme := schemev1.Scheme + require.NoError(t, v1alpha2.AddToScheme(scheme)) + + openAIModel := func(name, secretName string) *v1alpha2.ModelConfig { + return &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Spec: v1alpha2.ModelConfigSpec{ + Model: "gpt-4", + Provider: v1alpha2.ModelProviderOpenAI, + APIKeySecret: secretName, + APIKeySecretKey: "api-key", + }, + } + } + + anthropicModel := func(name, secretName string) *v1alpha2.ModelConfig { + return &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Spec: v1alpha2.ModelConfigSpec{ + Model: "claude-3", + Provider: v1alpha2.ModelProviderAnthropic, + APIKeySecret: secretName, + APIKeySecretKey: "api-key", + }, + } + } + + vertexAIModel := func(name, secretName string) *v1alpha2.ModelConfig { + return &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Spec: v1alpha2.ModelConfigSpec{ + Model: "gemini-1.5-pro", + Provider: v1alpha2.ModelProviderGeminiVertexAI, + APIKeySecret: secretName, + APIKeySecretKey: "service-account.json", + GeminiVertexAI: &v1alpha2.GeminiVertexAIConfig{ + BaseVertexAIConfig: v1alpha2.BaseVertexAIConfig{ + ProjectID: "my-project", + Location: "us-central1", + }, + }, + }, + } + } + + anthropicVertexModel := func(name, secretName string) *v1alpha2.ModelConfig { + return &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Spec: v1alpha2.ModelConfigSpec{ + Model: "claude-3-sonnet", + Provider: v1alpha2.ModelProviderAnthropicVertexAI, + APIKeySecret: secretName, + APIKeySecretKey: "service-account.json", + AnthropicVertexAI: &v1alpha2.AnthropicVertexAIConfig{ + BaseVertexAIConfig: v1alpha2.BaseVertexAIConfig{ + ProjectID: "my-project", + Location: "us-central1", + }, + }, + }, + } + } + + makeAgent := func(agentModel, summarizerModel string) *v1alpha2.Agent { + return &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{Name: "test-agent", Namespace: "default"}, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Description: "Test agent", + Declarative: &v1alpha2.DeclarativeAgentSpec{ + SystemMessage: "You are a test agent", + ModelConfig: agentModel, + Context: &v1alpha2.ContextConfig{ + Compaction: &v1alpha2.ContextCompressionConfig{ + CompactionInterval: 5, + OverlapSize: 2, + Summarizer: &v1alpha2.ContextSummarizerConfig{ + ModelConfig: summarizerModel, + }, + }, + }, + }, + }, + } + } + + findDeployment := func(t *testing.T, outputs *translator.AgentOutputs) *appsv1.Deployment { + t.Helper() + for _, obj := range outputs.Manifest { + if d, ok := obj.(*appsv1.Deployment); ok { + return d + } + } + t.Fatal("Deployment not found in manifest") + return nil + } + + envNames := func(envs []corev1.EnvVar) []string { + names := make([]string, len(envs)) + for i, e := range envs { + names[i] = e.Name + } + return names + } + + volumeNames := func(vols []corev1.Volume) []string { + names := make([]string, len(vols)) + for i, v := range vols { + names[i] = v.Name + } + return names + } + + mountPaths := func(mounts []corev1.VolumeMount) []string { + paths := make([]string, len(mounts)) + for i, m := range mounts { + paths[i] = m.MountPath + } + return paths + } + + countByName := func(envs []corev1.EnvVar, name string) int { + count := 0 + for _, e := range envs { + if e.Name == name { + count++ + } + } + return count + } + + tests := []struct { + name string + agentModel *v1alpha2.ModelConfig + summModel *v1alpha2.ModelConfig + assertDeploy func(t *testing.T, dep *appsv1.Deployment) + }{ + { + name: "different secrets add new env vars from summarizer", + agentModel: openAIModel("agent-model", "openai-secret"), + summModel: anthropicModel("summarizer-model", "anthropic-secret"), + assertDeploy: func(t *testing.T, dep *appsv1.Deployment) { + env := dep.Spec.Template.Spec.Containers[0].Env + names := envNames(env) + assert.Contains(t, names, "OPENAI_API_KEY") + assert.Contains(t, names, "ANTHROPIC_API_KEY") + }, + }, + { + name: "same env var name is not duplicated", + agentModel: openAIModel("agent-model", "shared-secret"), + summModel: openAIModel("summarizer-model", "other-secret"), + assertDeploy: func(t *testing.T, dep *appsv1.Deployment) { + env := dep.Spec.Template.Spec.Containers[0].Env + assert.Equal(t, 1, countByName(env, "OPENAI_API_KEY")) + }, + }, + { + name: "summarizer volumes and mounts are merged", + agentModel: openAIModel("agent-model", "openai-secret"), + summModel: vertexAIModel("summarizer-model", "gcp-secret"), + assertDeploy: func(t *testing.T, dep *appsv1.Deployment) { + vols := volumeNames(dep.Spec.Template.Spec.Volumes) + assert.Contains(t, vols, "google-creds") + mounts := mountPaths(dep.Spec.Template.Spec.Containers[0].VolumeMounts) + assert.Contains(t, mounts, "/creds") + }, + }, + { + name: "duplicate volumes and mounts are not added", + agentModel: vertexAIModel("agent-model", "gcp-secret-a"), + summModel: anthropicVertexModel("summarizer-model", "gcp-secret-b"), + assertDeploy: func(t *testing.T, dep *appsv1.Deployment) { + volCount := 0 + for _, v := range dep.Spec.Template.Spec.Volumes { + if v.Name == "google-creds" { + volCount++ + } + } + assert.Equal(t, 1, volCount) + mountCount := 0 + for _, m := range dep.Spec.Template.Spec.Containers[0].VolumeMounts { + if m.MountPath == "/creds" { + mountCount++ + } + } + assert.Equal(t, 1, mountCount) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + kubeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(tt.agentModel, tt.summModel). + Build() + + defaultModel := types.NamespacedName{Namespace: "default", Name: tt.agentModel.Name} + trans := translator.NewAdkApiTranslator(kubeClient, defaultModel, nil, "") + outputs, err := trans.TranslateAgent(context.Background(), makeAgent(tt.agentModel.Name, tt.summModel.Name)) + + require.NoError(t, err) + require.NotNil(t, outputs) + dep := findDeployment(t, outputs) + tt.assertDeploy(t, dep) + }) + } +} + +func Test_AdkApiTranslator_ContextConfig(t *testing.T) { + scheme := schemev1.Scheme + require.NoError(t, v1alpha2.AddToScheme(scheme)) + + modelConfig := &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-model", + Namespace: "default", + }, + Spec: v1alpha2.ModelConfigSpec{ + Model: "gpt-4", + Provider: v1alpha2.ModelProviderOpenAI, + }, + } + + summarizerModelConfig := &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "summarizer-model", + Namespace: "default", + }, + Spec: v1alpha2.ModelConfigSpec{ + Model: "gpt-4o-mini", + Provider: v1alpha2.ModelProviderOpenAI, + }, + } + + makeAgent := func(context *v1alpha2.ContextConfig) *v1alpha2.Agent { + return &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{Name: "test-agent", Namespace: "default"}, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Description: "Test agent", + Declarative: &v1alpha2.DeclarativeAgentSpec{ + SystemMessage: "You are a test agent", + ModelConfig: "test-model", + Context: context, + }, + }, + } + } + + tests := []struct { + name string + agent *v1alpha2.Agent + extraObjects []client.Object + wantErr bool + errContains string + assertConfig func(t *testing.T, cfg *adk.AgentConfig) + }{ + { + name: "no context config", + agent: makeAgent(nil), + assertConfig: func(t *testing.T, cfg *adk.AgentConfig) { + assert.Nil(t, cfg.ContextConfig) + }, + }, + { + name: "compaction only", + agent: makeAgent(&v1alpha2.ContextConfig{ + Compaction: &v1alpha2.ContextCompressionConfig{ + CompactionInterval: 5, + OverlapSize: 2, + }, + }), + assertConfig: func(t *testing.T, cfg *adk.AgentConfig) { + require.NotNil(t, cfg.ContextConfig) + require.NotNil(t, cfg.ContextConfig.Compaction) + assert.Equal(t, 5, cfg.ContextConfig.Compaction.CompactionInterval) + assert.Equal(t, 2, cfg.ContextConfig.Compaction.OverlapSize) + assert.Nil(t, cfg.ContextConfig.Compaction.SummarizerModel) + assert.Nil(t, cfg.ContextConfig.Cache) + }, + }, + { + name: "cache only", + agent: makeAgent(&v1alpha2.ContextConfig{ + Cache: &v1alpha2.ContextCacheConfig{ + CacheIntervals: ptr.To(20), + TTLSeconds: ptr.To(3600), + MinTokens: ptr.To(100), + }, + }), + assertConfig: func(t *testing.T, cfg *adk.AgentConfig) { + require.NotNil(t, cfg.ContextConfig) + assert.Nil(t, cfg.ContextConfig.Compaction) + require.NotNil(t, cfg.ContextConfig.Cache) + assert.Equal(t, ptr.To(20), cfg.ContextConfig.Cache.CacheIntervals) + assert.Equal(t, ptr.To(3600), cfg.ContextConfig.Cache.TTLSeconds) + assert.Equal(t, ptr.To(100), cfg.ContextConfig.Cache.MinTokens) + }, + }, + { + name: "both compaction and cache", + agent: makeAgent(&v1alpha2.ContextConfig{ + Compaction: &v1alpha2.ContextCompressionConfig{ + CompactionInterval: 10, + OverlapSize: 3, + TokenThreshold: ptr.To(1000), + EventRetentionSize: ptr.To(5), + }, + Cache: &v1alpha2.ContextCacheConfig{ + CacheIntervals: ptr.To(15), + }, + }), + assertConfig: func(t *testing.T, cfg *adk.AgentConfig) { + require.NotNil(t, cfg.ContextConfig) + require.NotNil(t, cfg.ContextConfig.Compaction) + assert.Equal(t, 10, cfg.ContextConfig.Compaction.CompactionInterval) + assert.Equal(t, 3, cfg.ContextConfig.Compaction.OverlapSize) + assert.Equal(t, ptr.To(1000), cfg.ContextConfig.Compaction.TokenThreshold) + assert.Equal(t, ptr.To(5), cfg.ContextConfig.Compaction.EventRetentionSize) + require.NotNil(t, cfg.ContextConfig.Cache) + assert.Equal(t, ptr.To(15), cfg.ContextConfig.Cache.CacheIntervals) + }, + }, + { + name: "compaction with summarizer using agent model", + agent: makeAgent(&v1alpha2.ContextConfig{ + Compaction: &v1alpha2.ContextCompressionConfig{ + CompactionInterval: 5, + OverlapSize: 2, + Summarizer: &v1alpha2.ContextSummarizerConfig{ + PromptTemplate: "Summarize: {{events}}", + }, + }, + }), + assertConfig: func(t *testing.T, cfg *adk.AgentConfig) { + require.NotNil(t, cfg.ContextConfig) + require.NotNil(t, cfg.ContextConfig.Compaction) + require.NotNil(t, cfg.ContextConfig.Compaction.SummarizerModel) + assert.Equal(t, adk.ModelTypeOpenAI, cfg.ContextConfig.Compaction.SummarizerModel.GetType()) + assert.Equal(t, "Summarize: {{events}}", cfg.ContextConfig.Compaction.PromptTemplate) + }, + }, + { + name: "compaction with summarizer using separate model", + agent: makeAgent(&v1alpha2.ContextConfig{ + Compaction: &v1alpha2.ContextCompressionConfig{ + CompactionInterval: 5, + OverlapSize: 2, + Summarizer: &v1alpha2.ContextSummarizerConfig{ + ModelConfig: "summarizer-model", + }, + }, + }), + extraObjects: []client.Object{summarizerModelConfig.DeepCopy()}, + assertConfig: func(t *testing.T, cfg *adk.AgentConfig) { + require.NotNil(t, cfg.ContextConfig) + require.NotNil(t, cfg.ContextConfig.Compaction) + require.NotNil(t, cfg.ContextConfig.Compaction.SummarizerModel) + assert.Equal(t, adk.ModelTypeOpenAI, cfg.ContextConfig.Compaction.SummarizerModel.GetType()) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + objects := []client.Object{modelConfig.DeepCopy()} + objects = append(objects, tt.extraObjects...) + kubeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + Build() + + defaultModel := types.NamespacedName{Namespace: "default", Name: "test-model"} + trans := translator.NewAdkApiTranslator(kubeClient, defaultModel, nil, "") + outputs, err := trans.TranslateAgent(context.Background(), tt.agent) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + + require.NoError(t, err) + require.NotNil(t, outputs) + require.NotNil(t, outputs.Config) + if tt.assertConfig != nil { + tt.assertConfig(t, outputs.Config) + } + }) + } +} diff --git a/go/pkg/adk/types.go b/go/pkg/adk/types.go index e2fb68858..78581d0ed 100644 --- a/go/pkg/adk/types.go +++ b/go/pkg/adk/types.go @@ -296,6 +296,94 @@ type RemoteAgentConfig struct { Description string `json:"description,omitempty"` } +// AgentContextConfig is the context management configuration that flows through config.json to the Python runtime. +type AgentContextConfig struct { + Compaction *AgentCompressionConfig `json:"compaction,omitempty"` + Cache *AgentCacheConfig `json:"cache,omitempty"` +} + +// AgentCompressionConfig maps to Python's ContextCompressionSettings. +type AgentCompressionConfig struct { + CompactionInterval int `json:"compaction_interval"` + OverlapSize int `json:"overlap_size"` + SummarizerModel Model `json:"summarizer_model,omitempty"` + PromptTemplate string `json:"prompt_template,omitempty"` + TokenThreshold *int `json:"token_threshold,omitempty"` + EventRetentionSize *int `json:"event_retention_size,omitempty"` +} + +func (c *AgentCompressionConfig) UnmarshalJSON(data []byte) error { + var tmp struct { + CompactionInterval int `json:"compaction_interval"` + OverlapSize int `json:"overlap_size"` + SummarizerModel json.RawMessage `json:"summarizer_model,omitempty"` + PromptTemplate string `json:"prompt_template,omitempty"` + TokenThreshold *int `json:"token_threshold,omitempty"` + EventRetentionSize *int `json:"event_retention_size,omitempty"` + } + if err := json.Unmarshal(data, &tmp); err != nil { + return err + } + c.CompactionInterval = tmp.CompactionInterval + c.OverlapSize = tmp.OverlapSize + c.PromptTemplate = tmp.PromptTemplate + c.TokenThreshold = tmp.TokenThreshold + c.EventRetentionSize = tmp.EventRetentionSize + if len(tmp.SummarizerModel) > 0 && string(tmp.SummarizerModel) != "null" { + model, err := ParseModel(tmp.SummarizerModel) + if err != nil { + return fmt.Errorf("failed to parse summarizer model: %w", err) + } + c.SummarizerModel = model + } + return nil +} + +// AgentCacheConfig maps to Python's ContextCacheSettings. +type AgentCacheConfig struct { + CacheIntervals *int `json:"cache_intervals,omitempty"` + TTLSeconds *int `json:"ttl_seconds,omitempty"` + MinTokens *int `json:"min_tokens,omitempty"` +} + +// AgentResumabilityConfig maps to Python's ResumabilityConfig. +type AgentResumabilityConfig struct { + IsResumable bool `json:"is_resumable"` +} + +type BaseMemoryConfig struct { + Type string `json:"type"` +} + +type InMemoryConfig struct { + BaseMemoryConfig +} + +type VertexAIMemoryConfig struct { + BaseMemoryConfig + ProjectID *string `json:"project_id,omitempty"` + Location *string `json:"location,omitempty"` +} + +type McpMemoryConfig struct { + BaseMemoryConfig + Name string `json:"name"` + Kind string `json:"kind"` + ApiGroup string `json:"apiGroup"` + ServerConfig any `json:"server_config,omitempty"` // HttpMcpServerConfig or SseMcpServerConfig +} + +func (m *McpMemoryConfig) MarshalJSON() ([]byte, error) { + type Alias McpMemoryConfig + return json.Marshal(&struct { + Type string `json:"type"` + *Alias + }{ + Type: "mcp", + Alias: (*Alias)(m), + }) +} + // See `python/packages/kagent-adk/src/kagent/adk/types.py` for the python version of this type AgentConfig struct { Model Model `json:"model"` @@ -306,16 +394,25 @@ type AgentConfig struct { RemoteAgents []RemoteAgentConfig `json:"remote_agents"` ExecuteCode bool `json:"execute_code,omitempty"` Stream bool `json:"stream"` + // Context management configuration + ContextConfig *AgentContextConfig `json:"context_config,omitempty"` + // Memory configuration + Memory any `json:"memory,omitempty"` // InMemoryConfig, VertexAIMemoryConfig, or McpMemoryConfig + // Resumability configuration + ResumabilityConfig *AgentResumabilityConfig `json:"resumability_config,omitempty"` } func (a *AgentConfig) UnmarshalJSON(data []byte) error { var tmp struct { - Model json.RawMessage `json:"model"` - Description string `json:"description"` - Instruction string `json:"instruction"` - HttpTools []HttpMcpServerConfig `json:"http_tools"` - SseTools []SseMcpServerConfig `json:"sse_tools"` - RemoteAgents []RemoteAgentConfig `json:"remote_agents"` + Model json.RawMessage `json:"model"` + Description string `json:"description"` + Instruction string `json:"instruction"` + HttpTools []HttpMcpServerConfig `json:"http_tools"` + SseTools []SseMcpServerConfig `json:"sse_tools"` + RemoteAgents []RemoteAgentConfig `json:"remote_agents"` + ContextConfig *AgentContextConfig `json:"context_config,omitempty"` + Memory json.RawMessage `json:"memory,omitempty"` + ResumabilityConfig *AgentResumabilityConfig `json:"resumability_config,omitempty"` } if err := json.Unmarshal(data, &tmp); err != nil { return err @@ -330,6 +427,39 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error { a.HttpTools = tmp.HttpTools a.SseTools = tmp.SseTools a.RemoteAgents = tmp.RemoteAgents + a.ContextConfig = tmp.ContextConfig + a.ResumabilityConfig = tmp.ResumabilityConfig + + if tmp.Memory != nil { + var base BaseMemoryConfig + if err := json.Unmarshal(tmp.Memory, &base); err != nil { + return err + } + switch base.Type { + case "in_memory": + var mem InMemoryConfig + if err := json.Unmarshal(tmp.Memory, &mem); err != nil { + return err + } + a.Memory = &mem + case "vertex_ai": + var mem VertexAIMemoryConfig + if err := json.Unmarshal(tmp.Memory, &mem); err != nil { + return err + } + a.Memory = &mem + case "mcp": + var mem McpMemoryConfig + if err := json.Unmarshal(tmp.Memory, &mem); err != nil { + return err + } + // server_config needs to be unmarshaled polymorphically if we were reading back + // For now, simple unmarshal might put it as map[string]interface{} + // If we need strict types here we'd need more logic, but for generation usually we are creating structs. + a.Memory = &mem + } + } + return nil } diff --git a/helm/kagent-crds/templates/kagent.dev_agents.yaml b/helm/kagent-crds/templates/kagent.dev_agents.yaml index 418225433..52b4ed2ed 100644 --- a/helm/kagent-crds/templates/kagent.dev_agents.yaml +++ b/helm/kagent-crds/templates/kagent.dev_agents.yaml @@ -6196,6 +6196,91 @@ spec: minItems: 1 type: array type: object + context: + description: |- + Context configures context management for this agent. + This includes event compaction (compression) and context caching. + properties: + cache: + description: |- + Cache configures context caching. + When enabled, prefix context is cached at the provider level to reduce + redundant processing of repeated context. + properties: + cacheIntervals: + default: 10 + description: |- + CacheIntervals specifies how often (in number of events) to update the cache. + Default: 10 + minimum: 1 + type: integer + minTokens: + default: 0 + description: |- + MinTokens is the minimum number of tokens before caching is activated. + Default: 0 + minimum: 0 + type: integer + ttlSeconds: + default: 1800 + description: |- + TTLSeconds specifies the time-to-live for cached context in seconds. + Default: 1800 (30 minutes) + minimum: 0 + type: integer + type: object + compaction: + description: |- + Compaction configures event history compaction. + When enabled, older events in the conversation are compacted (compressed/summarized) + to reduce context size while preserving key information. + properties: + compactionInterval: + description: The number of *new* user-initiated invocations + that, once fully represented in the session's events, + will trigger a compaction. + minimum: 1 + type: integer + eventRetentionSize: + description: EventRetentionSize is the number of most + recent events to always retain. + type: integer + overlapSize: + description: The number of preceding invocations to include + from the end of the last compacted range. This creates + an overlap between consecutive compacted summaries, + maintaining context. + minimum: 0 + type: integer + summarizer: + description: |- + Summarizer configures an LLM-based summarizer for event compaction. + If not specified, compacted events are simply truncated without summarization. + properties: + modelConfig: + description: |- + ModelConfig is the name of a ModelConfig resource to use for summarization. + Must be in the same namespace as the Agent. + If not specified, uses the agent's own model. + type: string + promptTemplate: + description: PromptTemplate is a custom prompt template + for the summarizer. + type: string + type: object + tokenThreshold: + description: |- + Post-invocation token threshold trigger. If set, ADK will attempt a post-invocation compaction when the most recently + observed prompt token count meets or exceeds this threshold. + type: integer + required: + - compactionInterval + - overlapSize + type: object + x-kubernetes-validations: + - message: compactionInterval and overlapSize are required + rule: has(self.compactionInterval) && has(self.overlapSize) + type: object deployment: properties: affinity: @@ -9863,12 +9948,70 @@ spec: Code will be executed in a sandboxed environment. due to a bug in adk (https://github.com/google/adk-python/issues/3921), this field is ignored for now. type: boolean + memory: + description: Memory configures the memory for the agent. + properties: + inMemory: + type: object + mcpServer: + properties: + apiGroup: + default: kagent.dev + description: ApiGroup is the API group of the MCP server + resource. + type: string + kind: + default: MCPServer + description: Kind is the kind of the MCP server resource. + type: string + name: + description: Name is the name of the MCP server resource. + type: string + required: + - name + type: object + type: + default: InMemory + description: MemoryType represents the memory type + enum: + - InMemory + - VertexAI + - McpServer + type: string + vertexAi: + properties: + location: + type: string + projectID: + type: string + type: object + required: + - type + type: object + x-kubernetes-validations: + - message: inMemory configuration is only allowed when type is + InMemory + rule: '!has(self.inMemory) || self.type == ''InMemory''' + - message: vertexAi configuration is only allowed when type is + VertexAI + rule: '!has(self.vertexAi) || self.type == ''VertexAI''' + - message: mcpServer configuration is only allowed when type is + McpServer + rule: '!has(self.mcpServer) || self.type == ''McpServer''' modelConfig: description: |- The name of the model config to use. If not specified, the default value is "default-model-config". Must be in the same namespace as the Agent. type: string + resumability: + description: Resumability configures the resumability for the + agent. + properties: + isResumable: + description: IsResumable enables agent resumability. + type: boolean + type: object stream: description: |- Whether to stream the response from the model. diff --git a/python/packages/kagent-adk/pyproject.toml b/python/packages/kagent-adk/pyproject.toml index 5f4ad9eb0..5f7b7437a 100644 --- a/python/packages/kagent-adk/pyproject.toml +++ b/python/packages/kagent-adk/pyproject.toml @@ -18,16 +18,16 @@ dependencies = [ "typer>=0.15.0", "uvicorn>=0.34.0", "openai>=1.72.0", - "mcp>=1.25.0", + "mcp>=1.26.0", "protobuf>=6.33.5", # CVE-2026-0994: Denial of Service due to recursion depth bypass "anthropic[vertex]>=0.49.0", "fastapi>=0.115.1", - "litellm>=1.74.3", + "litellm>=1.81.12", "google-adk>=1.25.0", - "google-genai>=1.21.1", - "google-auth>=2.40.2", - "httpx>=0.25.0", - "pydantic>=2.5.0", + "google-genai>=1.63.0", + "google-auth>=2.48.0", + "httpx>=0.28.1", + "pydantic>=2.12.5", "typing-extensions>=4.8.0", "jsonref>=1.1.0", "a2a-sdk>=0.3.23", diff --git a/python/packages/kagent-adk/src/kagent/adk/_a2a.py b/python/packages/kagent-adk/src/kagent/adk/_a2a.py index d32d9d83b..c26d549d3 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_a2a.py +++ b/python/packages/kagent-adk/src/kagent/adk/_a2a.py @@ -8,16 +8,20 @@ from a2a.server.apps import A2AFastAPIApplication from a2a.server.request_handlers import DefaultRequestHandler from a2a.server.tasks import InMemoryTaskStore -from a2a.types import AgentCard +from a2a.types import AgentCard, Task, TaskState, TaskStatusUpdateEvent from agentsts.adk import ADKSTSIntegration, ADKTokenPropagationPlugin from fastapi import FastAPI, Request from fastapi.responses import PlainTextResponse from google.adk.agents import BaseAgent +from google.adk.agents.context_cache_config import ContextCacheConfig as AdkContextCacheConfig from google.adk.apps import App +from google.adk.apps.app import EventsCompactionConfig, ResumabilityConfig from google.adk.artifacts import InMemoryArtifactService +from google.adk.memory import BaseMemoryService, InMemoryMemoryService, VertexAiRagMemoryService from google.adk.plugins import BasePlugin from google.adk.runners import Runner from google.adk.sessions import InMemorySessionService +from google.adk.tools.mcp_tool import SseConnectionParams, StreamableHTTPConnectionParams from google.genai import types from kagent.core.a2a import ( @@ -30,6 +34,15 @@ from ._lifespan import LifespanManager from ._session_service import KAgentSessionService from ._token import KAgentTokenService +from .memory import McpMemoryService +from .types import ( + BaseMemoryConfig, + HttpMcpServerConfig, + InMemoryConfig, + McpMemoryConfig, + SseMcpServerConfig, + VertexAIMemoryConfig, +) logger = logging.getLogger(__name__) @@ -60,6 +73,10 @@ def __init__( lifespan: Optional[Callable[[Any], Any]] = None, plugins: List[BasePlugin] = None, stream: bool = False, + events_compaction_config: Optional[EventsCompactionConfig] = None, + context_cache_config: Optional[AdkContextCacheConfig] = None, + memory_config: Optional[BaseMemoryConfig] = None, + resumability_config: Optional[ResumabilityConfig] = None, ): """Initialize the KAgent application. @@ -71,6 +88,10 @@ def __init__( lifespan: Optional lifespan function plugins: Optional list of plugins stream: Whether to stream the response + events_compaction_config: ADK compaction configuration + context_cache_config: ADK context caching config + memory_config: Optional memory configuration + resumability_config: Optional resumability configuration """ self.root_agent_factory = root_agent_factory self.kagent_url = kagent_url @@ -79,6 +100,42 @@ def __init__( self._lifespan = lifespan self.plugins = plugins if plugins is not None else [] self.stream = stream + self.events_compaction_config = events_compaction_config + self.context_cache_config = context_cache_config + self.memory_config = memory_config + self.resumability_config = resumability_config + + def _create_memory_service(self) -> Optional[BaseMemoryService]: + if not self.memory_config: + return None + + if isinstance(self.memory_config, InMemoryConfig): + return InMemoryMemoryService() + elif isinstance(self.memory_config, VertexAIMemoryConfig): + project_id = self.memory_config.project_id + location = self.memory_config.location + return VertexAiRagMemoryService( + project_id=project_id, + location=location, + ) + elif isinstance(self.memory_config, McpMemoryConfig): + config = self.memory_config.server_config + if isinstance(config, HttpMcpServerConfig): + # Ensure params are StreamableHTTPConnectionParams + if not isinstance(config.params, StreamableHTTPConnectionParams): + # Should be handled by pydantic validation, but good to be safe + raise ValueError("Invalid params for HttpMcpServerConfig") + return McpMemoryService(connection_params=config.params) + elif isinstance(config, SseMcpServerConfig): + if not isinstance(config.params, SseConnectionParams): + raise ValueError("Invalid params for SseMcpServerConfig") + return McpMemoryService(connection_params=config.params) + else: + logger.warning(f"Unsupported MCP memory server config: {type(config)}") + return None + else: + logger.warning(f"Unsupported memory config type: {type(self.memory_config)}") + return None def build(self, local=False) -> FastAPI: session_service = InMemorySessionService() @@ -95,12 +152,20 @@ def build(self, local=False) -> FastAPI: def create_runner() -> Runner: root_agent = self.root_agent_factory() - adk_app = App(name=self.app_name, root_agent=root_agent, plugins=self.plugins) + adk_app = App( + name=self.app_name, + root_agent=root_agent, + plugins=self.plugins, + events_compaction_config=self.events_compaction_config, + context_cache_config=self.context_cache_config, + resumability_config=self.resumability_config, + ) return Runner( app=adk_app, session_service=session_service, artifact_service=InMemoryArtifactService(), + memory_service=self._create_memory_service(), ) task_store: InMemoryTaskStore | KAgentTaskStore = InMemoryTaskStore() @@ -158,6 +223,7 @@ async def test(self, task: str): app_name=self.app_name, session_service=session_service, artifact_service=InMemoryArtifactService(), + memory_service=self._create_memory_service(), ) logger.info(f"\n>>> User Query: {task}") diff --git a/python/packages/kagent-adk/src/kagent/adk/cli.py b/python/packages/kagent-adk/src/kagent/adk/cli.py index ff1b1b21a..8dca387f2 100644 --- a/python/packages/kagent-adk/src/kagent/adk/cli.py +++ b/python/packages/kagent-adk/src/kagent/adk/cli.py @@ -17,6 +17,7 @@ from . import AgentConfig, KAgentApp from .skill_fetcher import fetch_skill from .tools import add_skills_tool_to_agent +from .types import build_adk_context_configs logger = logging.getLogger(__name__) logging.getLogger("google_adk.google.adk.tools.base_authenticated_tool").setLevel(logging.ERROR) @@ -24,6 +25,31 @@ app = typer.Typer() +def _build_context_kwargs(agent_config: AgentConfig) -> dict: + """Build context config kwargs for KAgentApp from agent config.""" + kwargs = {} + if agent_config.context_config is not None: + events_compaction_config, context_cache_config = build_adk_context_configs( + agent_config.context_config, + ) + if events_compaction_config is not None: + kwargs["events_compaction_config"] = events_compaction_config + if context_cache_config is not None: + kwargs["context_cache_config"] = context_cache_config + + if agent_config.memory is not None: + kwargs["memory_config"] = agent_config.memory + + if agent_config.resumability_config is not None: + from google.adk.apps.app import ResumabilityConfig as AdkResumabilityConfig + + kwargs["resumability_config"] = AdkResumabilityConfig( + is_resumable=agent_config.resumability_config.is_resumable + ) + + return kwargs + + kagent_url_override = os.getenv("KAGENT_URL") sts_well_known_uri = os.getenv("STS_WELL_KNOWN_URI") propagate_token = os.getenv("KAGENT_PROPAGATE_TOKEN") @@ -80,6 +106,7 @@ def root_agent_factory() -> BaseAgent: return root_agent + context_kwargs = _build_context_kwargs(agent_config) kagent_app = KAgentApp( root_agent_factory, agent_card, @@ -87,6 +114,7 @@ def root_agent_factory() -> BaseAgent: app_cfg.app_name, plugins=plugins, stream=agent_config.stream if agent_config.stream is not None else False, + **context_kwargs, ) server = kagent_app.build() @@ -190,6 +218,7 @@ def root_agent_factory() -> BaseAgent: except Exception: logger.exception(f"Failed to load agent module '{name}' for lifespan") + context_kwargs = _build_context_kwargs(agent_config) if agent_config else {} kagent_app = KAgentApp( root_agent_factory, agent_card, @@ -198,6 +227,7 @@ def root_agent_factory() -> BaseAgent: lifespan=lifespan, plugins=plugins, stream=agent_config.stream if agent_config and agent_config.stream is not None else False, + **context_kwargs, ) if local: @@ -229,7 +259,8 @@ def root_agent_factory() -> BaseAgent: maybe_add_skills(root_agent) return root_agent - app = KAgentApp(root_agent_factory, agent_card, app_cfg.url, app_cfg.app_name, plugins=plugins) + context_kwargs = _build_context_kwargs(agent_config) + app = KAgentApp(root_agent_factory, agent_card, app_cfg.url, app_cfg.app_name, plugins=plugins, **context_kwargs) await app.test(task) diff --git a/python/packages/kagent-adk/src/kagent/adk/memory.py b/python/packages/kagent-adk/src/kagent/adk/memory.py new file mode 100644 index 000000000..54611cffc --- /dev/null +++ b/python/packages/kagent-adk/src/kagent/adk/memory.py @@ -0,0 +1,92 @@ +import json +import logging +from typing import Any, Optional + +from google.adk.memory import BaseMemoryService +from google.adk.memory.base_memory_service import SearchMemoryResponse +from google.adk.sessions import Session +from google.adk.tools.mcp_tool import SseConnectionParams, StreamableHTTPConnectionParams + +from kagent.adk._mcp_toolset import KAgentMcpToolset + +logger = logging.getLogger(__name__) + + +class McpMemoryService(BaseMemoryService): + """Memory service that delegates to an MCP server.""" + + def __init__(self, connection_params: SseConnectionParams | StreamableHTTPConnectionParams): + super().__init__() + # Use KAgentMcpToolset for consistent error handling and features + self.toolset = KAgentMcpToolset(connection_params=connection_params) + self._tools = None + + async def _ensure_tools(self): + if self._tools is None: + tools = await self.toolset.get_tools() + self._tools = {t.name: t for t in tools} + + async def _call_tool(self, name: str, **kwargs) -> Any: + await self._ensure_tools() + if name not in self._tools: + # Try to find a tool that matches the name loosely or assume the server provides it? + # For now, strict match. + logger.warning(f"Tool '{name}' not found in MCP server. Available tools: {list(self._tools.keys())}") + raise ValueError(f"Tool '{name}' not found in MCP server.") + + tool = self._tools[name] + logger.debug(f"Calling memory tool '{name}' with kwargs: {kwargs.keys()}") + return await tool.run(**kwargs) + + async def add_session_to_memory(self, session: Session) -> None: + """Adds a session to the memory store.""" + try: + # We pass the session as a dict + # session.model_dump(mode='json') creates a dict with JSON-compatible types + session_data = session.model_dump(mode="json") + await self._call_tool("add_session_to_memory", session=session_data) + except Exception as e: + logger.error(f"Failed to add session to memory: {e}") + raise + + async def search_memory(self, *, app_name: str, user_id: str, query: str) -> SearchMemoryResponse: + """Searches the memory store.""" + try: + result = await self._call_tool("search_memory", app_name=app_name, user_id=user_id, query=query) + + # The result should be compatible with SearchMemoryResponse + if isinstance(result, dict): + # Resilience: convert "text" or string "content" to types.Content structure + if "memories" in result and isinstance(result["memories"], list): + for m in result["memories"]: + if not isinstance(m, dict): + continue + + # If it has "text" but no "content", map it + if "text" in m and "content" not in m: + m["content"] = {"parts": [{"text": m["text"]}], "role": "user"} + + # If "content" is a raw string, wrap it + if "content" in m and isinstance(m["content"], str): + m["content"] = {"parts": [{"text": m["content"]}], "role": "user"} + + return SearchMemoryResponse.model_validate(result) + elif isinstance(result, str): + try: + # Try to parse json if string + data = json.loads(result) + if isinstance(data, dict): + return SearchMemoryResponse.model_validate(data) + except json.JSONDecodeError: + pass + + # Maybe the string itself is content? Unlikely for SearchMemoryResponse. + + # If result is an object (like a Pydantic model from ADK tools?), try model_dump + if hasattr(result, "model_dump"): + return SearchMemoryResponse.model_validate(result.model_dump()) + + raise ValueError(f"Unexpected result type from search_memory: {type(result)}") + except Exception as e: + logger.error(f"Failed to search memory: {e}") + raise diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index eb0562425..1dba79a4c 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -218,10 +218,65 @@ class Bedrock(BaseLLM): type: Literal["bedrock"] -class AgentConfig(BaseModel): - model: Union[OpenAI, Anthropic, GeminiVertexAI, GeminiAnthropic, Ollama, AzureOpenAI, Gemini, Bedrock] = Field( - discriminator="type" +ModelUnion = Union[OpenAI, Anthropic, GeminiVertexAI, GeminiAnthropic, Ollama, AzureOpenAI, Gemini, Bedrock] + + +class ContextCompressionSettings(BaseModel): + compaction_interval: int + overlap_size: int + summarizer_model: ModelUnion | None = Field(default=None, discriminator="type") + prompt_template: str | None = None + token_threshold: int | None = None + event_retention_size: int | None = None + + +class ContextCacheSettings(BaseModel): + """Settings for prefix context caching.""" + + cache_intervals: int | None = None + ttl_seconds: int | None = None + min_tokens: int | None = None + + +class ContextConfig(BaseModel): + """Context management configuration containing compaction and cache settings.""" + + compaction: ContextCompressionSettings | None = None + cache: ContextCacheSettings | None = None + + +class BaseMemoryConfig(BaseModel): + type: str + + +class InMemoryConfig(BaseMemoryConfig): + type: Literal["in_memory"] + + +class VertexAIMemoryConfig(BaseMemoryConfig): + type: Literal["vertex_ai"] + project_id: str | None = None + location: str | None = None + + +class McpMemoryConfig(BaseMemoryConfig): + type: Literal["mcp"] + name: str = Field(description="Name of the MCP server") + kind: str = Field(default="MCPServer", description="Kind of the resource") + api_group: str = Field(default="kagent.dev", description="API Group of the resource", alias="apiGroup") + server_config: Union[HttpMcpServerConfig, SseMcpServerConfig] | None = Field( + default=None, description="Configuration for the MCP server that handles memory" ) + + +class ResumabilitySettings(BaseModel): + """Settings for agent resumability.""" + + is_resumable: bool + + +class AgentConfig(BaseModel): + model: ModelUnion = Field(discriminator="type") description: str instruction: str http_tools: list[HttpMcpServerConfig] | None = None # Streamable HTTP MCP tools @@ -230,6 +285,11 @@ class AgentConfig(BaseModel): execute_code: bool | None = None # This stream option refers to LLM response streaming, not A2A streaming stream: bool | None = None + context_config: ContextConfig | None = None + memory: Union[InMemoryConfig, VertexAIMemoryConfig, McpMemoryConfig] | None = Field( + default=None, discriminator="type" + ) + resumability_config: ResumabilitySettings | None = None def to_agent(self, name: str, sts_integration: Optional[ADKTokenPropagationPlugin] = None) -> Agent: if name is None or not str(name).strip(): @@ -337,75 +397,8 @@ async def rewrite_url_to_proxy(request: httpx.Request) -> None: tools.append(AgentTool(agent=remote_a2a_agent)) - extra_headers = self.model.headers or {} - code_executor = SandboxedLocalCodeExecutor() if self.execute_code else None - - if self.model.type == "openai": - model = OpenAINative( - type="openai", - base_url=self.model.base_url, - default_headers=extra_headers, - frequency_penalty=self.model.frequency_penalty, - max_tokens=self.model.max_tokens, - model=self.model.model, - n=self.model.n, - presence_penalty=self.model.presence_penalty, - reasoning_effort=self.model.reasoning_effort, - seed=self.model.seed, - temperature=self.model.temperature, - timeout=self.model.timeout, - top_p=self.model.top_p, - # TLS configuration - tls_disable_verify=self.model.tls_disable_verify, - tls_ca_cert_path=self.model.tls_ca_cert_path, - tls_disable_system_cas=self.model.tls_disable_system_cas, - # API key passthrough - api_key_passthrough=self.model.api_key_passthrough, - ) - elif self.model.type == "anthropic": - model = KAgentLiteLlm( - model=f"anthropic/{self.model.model}", - base_url=self.model.base_url, - extra_headers=extra_headers, - api_key_passthrough=self.model.api_key_passthrough, - ) - elif self.model.type == "gemini_vertex_ai": - model = GeminiLLM(model=self.model.model) - elif self.model.type == "gemini_anthropic": - model = ClaudeLLM(model=self.model.model) - elif self.model.type == "ollama": - # Convert string options to correct types (int, float, bool) for Ollama API - ollama_options = _convert_ollama_options(self.model.options) - model = KAgentLiteLlm( - model=f"ollama_chat/{self.model.model}", - extra_headers=extra_headers, - api_key_passthrough=self.model.api_key_passthrough, - **ollama_options, - ) - elif self.model.type == "azure_openai": - model = OpenAIAzure( - model=self.model.model, - type="azure_openai", - default_headers=extra_headers, - # TLS configuration - tls_disable_verify=self.model.tls_disable_verify, - tls_ca_cert_path=self.model.tls_ca_cert_path, - tls_disable_system_cas=self.model.tls_disable_system_cas, - # API key passthrough - api_key_passthrough=self.model.api_key_passthrough, - ) - elif self.model.type == "gemini": - model = self.model.model - elif self.model.type == "bedrock": - # LiteLLM handles Bedrock via boto3 internally when model starts with "bedrock/" - model = KAgentLiteLlm( - model=f"bedrock/{self.model.model}", - extra_headers=extra_headers, - api_key_passthrough=self.model.api_key_passthrough, - ) - else: - raise ValueError(f"Invalid model type: {self.model.type}") + model = _create_llm_from_model_config(self.model) return Agent( name=name, model=model, @@ -414,3 +407,113 @@ async def rewrite_url_to_proxy(request: httpx.Request) -> None: tools=tools, code_executor=code_executor, ) + + +def _create_llm_from_model_config(model_config: ModelUnion): + extra_headers = model_config.headers or {} + base_url = getattr(model_config, "base_url", None) + + if model_config.type == "openai": + return OpenAINative( + type="openai", + base_url=base_url, + default_headers=extra_headers, + frequency_penalty=model_config.frequency_penalty, + max_tokens=model_config.max_tokens, + model=model_config.model, + n=model_config.n, + presence_penalty=model_config.presence_penalty, + reasoning_effort=model_config.reasoning_effort, + seed=model_config.seed, + temperature=model_config.temperature, + timeout=model_config.timeout, + top_p=model_config.top_p, + # TLS configuration + tls_disable_verify=model_config.tls_disable_verify, + tls_ca_cert_path=model_config.tls_ca_cert_path, + tls_disable_system_cas=model_config.tls_disable_system_cas, + # API key passthrough + api_key_passthrough=model_config.api_key_passthrough, + ) + if model_config.type == "anthropic": + return KAgentLiteLlm( + model=f"anthropic/{model_config.model}", + base_url=base_url, + extra_headers=extra_headers, + # API key passthrough + api_key_passthrough=model_config.api_key_passthrough, + ) + if model_config.type == "gemini_vertex_ai": + return GeminiLLM(model=model_config.model) + if model_config.type == "gemini_anthropic": + return ClaudeLLM(model=model_config.model) + if model_config.type == "ollama": + # Convert string options to correct types (int, float, bool) for Ollama API + ollama_options = _convert_ollama_options(getattr(model_config, "options", None)) + return KAgentLiteLlm(model=f"ollama_chat/{model_config.model}", extra_headers=extra_headers, **ollama_options) + if model_config.type == "azure_openai": + return OpenAIAzure( + model=model_config.model, + type="azure_openai", + default_headers=extra_headers, + # TLS configuration + tls_disable_verify=model_config.tls_disable_verify, + tls_ca_cert_path=model_config.tls_ca_cert_path, + tls_disable_system_cas=model_config.tls_disable_system_cas, + ) + if model_config.type == "gemini": + return model_config.model + if model_config.type == "bedrock": + return KAgentLiteLlm( + model=f"bedrock/{model_config.model}", + extra_headers=extra_headers, + # API key passthrough + api_key_passthrough=model_config.api_key_passthrough, + ) + raise ValueError(f"Invalid model type: {model_config.type}") + + +def build_adk_context_configs(context_config: ContextConfig) -> tuple: + from google.adk.agents.context_cache_config import ( + ContextCacheConfig as AdkContextCacheConfig, + ) + from google.adk.apps.app import EventsCompactionConfig + + events_compaction_config = None + context_cache_config = None + + if context_config.compaction is not None: + comp = context_config.compaction + summarizer = None + + if comp.summarizer_model is not None: + from google.adk.apps.llm_event_summarizer import LlmEventSummarizer + + summarizer = LlmEventSummarizer( + llm=_create_llm_from_model_config(comp.summarizer_model), + prompt_template=comp.prompt_template, + ) + + compaction_kwargs: dict = { + "compaction_interval": comp.compaction_interval, + "overlap_size": comp.overlap_size, + "summarizer": summarizer, + } + if comp.token_threshold is not None and hasattr(EventsCompactionConfig, "token_threshold"): + compaction_kwargs["token_threshold"] = comp.token_threshold + if comp.event_retention_size is not None and hasattr(EventsCompactionConfig, "event_retention_size"): + compaction_kwargs["event_retention_size"] = comp.event_retention_size + events_compaction_config = EventsCompactionConfig(**compaction_kwargs) + + if context_config.cache is not None: + cache = context_config.cache + kwargs = {} + if cache.cache_intervals is not None: + kwargs["cache_intervals"] = cache.cache_intervals + if cache.ttl_seconds is not None: + kwargs["ttl_seconds"] = cache.ttl_seconds + if cache.min_tokens is not None: + kwargs["min_tokens"] = cache.min_tokens + context_cache_config = AdkContextCacheConfig(**kwargs) + + return events_compaction_config, context_cache_config diff --git a/python/packages/kagent-adk/tests/unittests/test_context_config.py b/python/packages/kagent-adk/tests/unittests/test_context_config.py new file mode 100644 index 000000000..5fb2a0bd4 --- /dev/null +++ b/python/packages/kagent-adk/tests/unittests/test_context_config.py @@ -0,0 +1,267 @@ +import json + +import pytest +from pydantic import ValidationError + +from kagent.adk.types import ( + AgentConfig, + ContextCacheSettings, + ContextCompressionSettings, + ContextConfig, + Gemini, + OpenAI, + build_adk_context_configs, +) + + +def _make_agent_config_json(**context_kwargs) -> str: + config = { + "model": {"type": "openai", "model": "gpt-4"}, + "description": "test agent", + "instruction": "test instruction", + } + if context_kwargs: + config["context_config"] = context_kwargs + return json.dumps(config) + + +class TestContextConfigParsing: + def test_no_context_config(self): + config = AgentConfig.model_validate_json(_make_agent_config_json()) + assert config.context_config is None + + def test_empty_context_config(self): + json_str = _make_agent_config_json() + data = json.loads(json_str) + data["context_config"] = {} + config = AgentConfig.model_validate(data) + assert config.context_config is not None + assert config.context_config.compaction is None + assert config.context_config.cache is None + + def test_compaction_only(self): + data = json.loads(_make_agent_config_json()) + data["context_config"] = {"compaction": {"compaction_interval": 5, "overlap_size": 2}} + config = AgentConfig.model_validate(data) + assert config.context_config is not None + assert config.context_config.compaction is not None + assert config.context_config.compaction.compaction_interval == 5 + assert config.context_config.compaction.overlap_size == 2 + assert config.context_config.cache is None + + def test_cache_only(self): + data = json.loads(_make_agent_config_json()) + data["context_config"] = {"cache": {"cache_intervals": 20, "ttl_seconds": 3600, "min_tokens": 100}} + config = AgentConfig.model_validate(data) + assert config.context_config is not None + assert config.context_config.compaction is None + assert config.context_config.cache is not None + assert config.context_config.cache.cache_intervals == 20 + assert config.context_config.cache.ttl_seconds == 3600 + assert config.context_config.cache.min_tokens == 100 + + def test_both_compaction_and_cache(self): + data = json.loads(_make_agent_config_json()) + data["context_config"] = { + "compaction": { + "compaction_interval": 10, + "overlap_size": 3, + "token_threshold": 1000, + "event_retention_size": 5, + }, + "cache": {"cache_intervals": 15}, + } + config = AgentConfig.model_validate(data) + assert config.context_config.compaction.compaction_interval == 10 + assert config.context_config.compaction.overlap_size == 3 + assert config.context_config.compaction.token_threshold == 1000 + assert config.context_config.compaction.event_retention_size == 5 + assert config.context_config.cache.cache_intervals == 15 + + def test_compaction_with_summarizer_model(self): + data = json.loads(_make_agent_config_json()) + data["context_config"] = { + "compaction": { + "compaction_interval": 5, + "overlap_size": 2, + "summarizer_model": {"type": "openai", "model": "gpt-4o-mini"}, + "prompt_template": "Summarize these events: {{events}}", + } + } + config = AgentConfig.model_validate(data) + comp = config.context_config.compaction + assert isinstance(comp.summarizer_model, OpenAI) + assert comp.summarizer_model.model == "gpt-4o-mini" + assert comp.prompt_template == "Summarize these events: {{events}}" + + def test_compaction_with_gemini_summarizer(self): + data = json.loads(_make_agent_config_json()) + data["context_config"] = { + "compaction": { + "compaction_interval": 5, + "overlap_size": 2, + "summarizer_model": {"type": "gemini", "model": "gemini-2.0-flash-lite"}, + } + } + config = AgentConfig.model_validate(data) + comp = config.context_config.compaction + assert isinstance(comp.summarizer_model, Gemini) + assert comp.summarizer_model.model == "gemini-2.0-flash-lite" + + def test_compaction_without_summarizer_model(self): + data = json.loads(_make_agent_config_json()) + data["context_config"] = { + "compaction": { + "compaction_interval": 5, + "overlap_size": 2, + } + } + config = AgentConfig.model_validate(data) + assert config.context_config.compaction.summarizer_model is None + + def test_compaction_missing_required_fields(self): + with pytest.raises(ValidationError): + ContextCompressionSettings(compaction_interval=5) # missing overlap_size + + with pytest.raises(ValidationError): + ContextCompressionSettings(overlap_size=2) # missing compaction_interval + + def test_cache_with_defaults(self): + cache = ContextCacheSettings() + assert cache.cache_intervals is None + assert cache.ttl_seconds is None + assert cache.min_tokens is None + + def test_null_vs_absent_context_config(self): + # Absent + data = json.loads(_make_agent_config_json()) + config1 = AgentConfig.model_validate(data) + assert config1.context_config is None + + # Explicit null + data["context_config"] = None + config2 = AgentConfig.model_validate(data) + assert config2.context_config is None + + +class TestBuildAdkContextConfigs: + def test_compaction_basic(self): + ctx_config = ContextConfig(compaction=ContextCompressionSettings(compaction_interval=5, overlap_size=2)) + events_cfg, cache_cfg = build_adk_context_configs(ctx_config) + assert events_cfg is not None + assert events_cfg.compaction_interval == 5 + assert events_cfg.overlap_size == 2 + assert events_cfg.summarizer is None + assert cache_cfg is None + + def test_cache_basic(self): + ctx_config = ContextConfig(cache=ContextCacheSettings(cache_intervals=20, ttl_seconds=3600, min_tokens=100)) + events_cfg, cache_cfg = build_adk_context_configs(ctx_config) + assert events_cfg is None + assert cache_cfg is not None + assert cache_cfg.cache_intervals == 20 + assert cache_cfg.ttl_seconds == 3600 + assert cache_cfg.min_tokens == 100 + + def test_cache_defaults(self): + ctx_config = ContextConfig(cache=ContextCacheSettings()) + events_cfg, cache_cfg = build_adk_context_configs(ctx_config) + assert events_cfg is None + assert cache_cfg is not None + assert cache_cfg.cache_intervals == 10 + assert cache_cfg.ttl_seconds == 1800 + assert cache_cfg.min_tokens == 0 + + def test_compaction_with_summarizer(self): + ctx_config = ContextConfig( + compaction=ContextCompressionSettings( + compaction_interval=5, + overlap_size=2, + summarizer_model=OpenAI(type="openai", model="gpt-4o-mini"), + prompt_template="Summarize: {{events}}", + ) + ) + events_cfg, cache_cfg = build_adk_context_configs(ctx_config) + assert events_cfg is not None + assert events_cfg.summarizer is not None + assert cache_cfg is None + + def test_compaction_no_summarizer_without_model(self): + ctx_config = ContextConfig( + compaction=ContextCompressionSettings( + compaction_interval=5, + overlap_size=2, + prompt_template="Summarize these events", + ) + ) + events_cfg, _ = build_adk_context_configs(ctx_config) + assert events_cfg is not None + assert events_cfg.summarizer is None + + def test_both_configs(self): + ctx_config = ContextConfig( + compaction=ContextCompressionSettings( + compaction_interval=10, + overlap_size=3, + token_threshold=1000, + event_retention_size=5, + ), + cache=ContextCacheSettings(cache_intervals=15), + ) + events_cfg, cache_cfg = build_adk_context_configs(ctx_config) + assert events_cfg is not None + assert events_cfg.compaction_interval == 10 + assert events_cfg.overlap_size == 3 + assert cache_cfg is not None + assert cache_cfg.cache_intervals == 15 + + +class TestJsonRoundTrip: + def test_full_config_round_trip(self): + go_style_json = { + "model": {"type": "openai", "model": "gpt-4"}, + "description": "My agent", + "instruction": "Help the user", + "http_tools": None, + "sse_tools": None, + "remote_agents": None, + "execute_code": False, + "stream": False, + "context_config": { + "compaction": { + "compaction_interval": 10, + "overlap_size": 3, + "summarizer_model": {"type": "openai", "model": "gpt-4o-mini"}, + "prompt_template": "Summarize: {{events}}", + "token_threshold": 500, + "event_retention_size": 5, + }, + "cache": { + "cache_intervals": 20, + "ttl_seconds": 3600, + "min_tokens": 100, + }, + }, + } + config = AgentConfig.model_validate(go_style_json) + assert config.context_config is not None + assert config.context_config.compaction.compaction_interval == 10 + assert isinstance(config.context_config.compaction.summarizer_model, OpenAI) + assert config.context_config.compaction.summarizer_model.model == "gpt-4o-mini" + assert config.context_config.cache.cache_intervals == 20 + assert config.context_config.cache.ttl_seconds == 3600 + + def test_config_without_context_round_trip(self): + go_style_json = { + "model": {"type": "openai", "model": "gpt-4"}, + "description": "My agent", + "instruction": "Help the user", + "http_tools": [], + "sse_tools": [], + "remote_agents": [], + "execute_code": False, + "stream": True, + } + config = AgentConfig.model_validate(go_style_json) + assert config.context_config is None + assert config.stream is True diff --git a/python/packages/kagent-adk/tests/unittests/test_memory.py b/python/packages/kagent-adk/tests/unittests/test_memory.py new file mode 100644 index 000000000..54697f9db --- /dev/null +++ b/python/packages/kagent-adk/tests/unittests/test_memory.py @@ -0,0 +1,172 @@ +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from kagent.adk.memory import McpMemoryService + + +def _make_tool(name: str, run_return=None): + tool = MagicMock() + tool.name = name + tool.run = AsyncMock(return_value=run_return) + return tool + + +def _make_service_with_tools(*tools): + with patch("kagent.adk.memory.KAgentMcpToolset") as mock_cls: + instance = mock_cls.return_value + instance.get_tools = AsyncMock(return_value=list(tools)) + service = McpMemoryService(connection_params=MagicMock()) + return service + + +def _make_session_mock(dump_data=None): + session = MagicMock() + session.model_dump = MagicMock(return_value=dump_data or {"id": "s1", "events": []}) + return session + + +class TestSearchMemoryDictResult: + @pytest.mark.asyncio + async def test_properly_structured_memories(self): + result = { + "memories": [ + { + "content": {"parts": [{"text": "hello"}], "role": "user"}, + } + ] + } + search_tool = _make_tool("search_memory", run_return=result) + service = _make_service_with_tools(search_tool) + + response = await service.search_memory(app_name="app", user_id="u1", query="q") + + assert len(response.memories) == 1 + assert response.memories[0].content.parts[0].text == "hello" + assert response.memories[0].content.role == "user" + + @pytest.mark.asyncio + async def test_text_without_content_auto_wraps(self): + result = {"memories": [{"text": "some memory text"}]} + search_tool = _make_tool("search_memory", run_return=result) + service = _make_service_with_tools(search_tool) + + response = await service.search_memory(app_name="app", user_id="u1", query="q") + + assert len(response.memories) == 1 + assert response.memories[0].content.parts[0].text == "some memory text" + assert response.memories[0].content.role == "user" + + @pytest.mark.asyncio + async def test_raw_string_content_auto_wraps(self): + result = {"memories": [{"content": "raw string content"}]} + search_tool = _make_tool("search_memory", run_return=result) + service = _make_service_with_tools(search_tool) + + response = await service.search_memory(app_name="app", user_id="u1", query="q") + + assert len(response.memories) == 1 + assert response.memories[0].content.parts[0].text == "raw string content" + assert response.memories[0].content.role == "user" + + +class TestSearchMemoryStringResult: + @pytest.mark.asyncio + async def test_valid_json_string_parsed(self): + data = {"memories": [{"content": {"parts": [{"text": "from json"}], "role": "model"}}]} + search_tool = _make_tool("search_memory", run_return=json.dumps(data)) + service = _make_service_with_tools(search_tool) + + response = await service.search_memory(app_name="app", user_id="u1", query="q") + + assert len(response.memories) == 1 + assert response.memories[0].content.parts[0].text == "from json" + + @pytest.mark.asyncio + async def test_invalid_json_string_raises(self): + search_tool = _make_tool("search_memory", run_return="not valid json {{{") + service = _make_service_with_tools(search_tool) + + with pytest.raises(ValueError, match="Unexpected result type"): + await service.search_memory(app_name="app", user_id="u1", query="q") + + +class TestSearchMemoryModelDumpResult: + @pytest.mark.asyncio + async def test_result_with_model_dump(self): + inner = {"memories": [{"content": {"parts": [{"text": "dumped"}], "role": "user"}}]} + result_obj = MagicMock() + result_obj.model_dump = MagicMock(return_value=inner) + result_obj.__class__ = type("CustomModel", (), {}) + + search_tool = _make_tool("search_memory", run_return=result_obj) + service = _make_service_with_tools(search_tool) + + response = await service.search_memory(app_name="app", user_id="u1", query="q") + + result_obj.model_dump.assert_called_once() + assert len(response.memories) == 1 + assert response.memories[0].content.parts[0].text == "dumped" + + +class TestSearchMemoryUnexpectedResult: + @pytest.mark.asyncio + async def test_unexpected_type_raises(self): + search_tool = _make_tool("search_memory", run_return=42) + service = _make_service_with_tools(search_tool) + + with pytest.raises(ValueError, match="Unexpected result type"): + await service.search_memory(app_name="app", user_id="u1", query="q") + + +class TestAddSessionToMemory: + @pytest.mark.asyncio + async def test_successful_call(self): + add_tool = _make_tool("add_session_to_memory", run_return=None) + service = _make_service_with_tools(add_tool) + session = _make_session_mock({"id": "s1", "events": [{"type": "msg"}]}) + + await service.add_session_to_memory(session) + + session.model_dump.assert_called_once_with(mode="json") + add_tool.run.assert_awaited_once_with(session={"id": "s1", "events": [{"type": "msg"}]}) + + @pytest.mark.asyncio + async def test_tool_exception_propagates(self): + add_tool = _make_tool("add_session_to_memory") + add_tool.run = AsyncMock(side_effect=RuntimeError("server down")) + service = _make_service_with_tools(add_tool) + session = _make_session_mock() + + with pytest.raises(RuntimeError, match="server down"): + await service.add_session_to_memory(session) + + +class TestCallToolNotFound: + @pytest.mark.asyncio + async def test_missing_tool_raises(self): + some_tool = _make_tool("other_tool") + service = _make_service_with_tools(some_tool) + + with pytest.raises(ValueError, match="not found in MCP server"): + await service.search_memory(app_name="app", user_id="u1", query="q") + + +class TestEnsureToolsCaching: + @pytest.mark.asyncio + async def test_tools_fetched_only_once(self): + search_tool = _make_tool("search_memory", run_return={"memories": []}) + add_tool = _make_tool("add_session_to_memory", run_return=None) + + with patch("kagent.adk.memory.KAgentMcpToolset") as mock_cls: + instance = mock_cls.return_value + instance.get_tools = AsyncMock(return_value=[search_tool, add_tool]) + service = McpMemoryService(connection_params=MagicMock()) + + await service.search_memory(app_name="a", user_id="u", query="q") + await service.search_memory(app_name="a", user_id="u", query="q2") + session = _make_session_mock() + await service.add_session_to_memory(session) + + instance.get_tools.assert_awaited_once() diff --git a/python/samples/adk/basic/pyproject.toml b/python/samples/adk/basic/pyproject.toml index ccdf6a137..c192d4c9c 100644 --- a/python/samples/adk/basic/pyproject.toml +++ b/python/samples/adk/basic/pyproject.toml @@ -4,5 +4,5 @@ version = "0.1" description = "Basic agent" readme = "README.md" dependencies = [ - "google-adk>=1.8.0", + "google-adk>=1.25.0", ] diff --git a/python/samples/memory/redis-agent-memory-server/Dockerfile b/python/samples/memory/redis-agent-memory-server/Dockerfile new file mode 100644 index 000000000..097ae8ab3 --- /dev/null +++ b/python/samples/memory/redis-agent-memory-server/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY server.py . + +# Expose port if running SSE (default 8000 if configured) +EXPOSE 8000 + +# Default to running the server (stdio mode by default for FastMCP) +CMD ["python", "server.py"] diff --git a/python/samples/memory/redis-agent-memory-server/README.md b/python/samples/memory/redis-agent-memory-server/README.md new file mode 100644 index 000000000..6b2d08470 --- /dev/null +++ b/python/samples/memory/redis-agent-memory-server/README.md @@ -0,0 +1,69 @@ +# Redis Agent Memory Server MCP Sample + +This is a sample implementation of an MCP server that delegates to a running Redis Agent Memory Server instance using the `agent-memory-client` SDK. + +## Prerequisites + +- A running instance of Redis Agent Memory Server (see [agent-memory-server](https://github.com/redis/agent-memory-server)). +- Docker installed. +- Kubernetes cluster with `kagent` installed. + +## Configuration + +The server is configured via environment variables: + +- `MEMORY_API_URL`: The URL of the Redis Agent Memory Server API (default: `http://localhost:8000`). +- `MEMORY_API_KEY`: API key for authentication (optional). +- `MCP_PORT`: Port for the MCP server if running in SSE mode (default: `8000`). +- `MCP_TRANSPORT`: Transport mode, either `stdio` (default) or `sse`. + +## Usage + +### Building the Docker Image + +```bash +docker build -t redis-agent-memory-mcp . +``` + +### Running Locally (Stdio) + +To use this server locally with an MCP client (like Claude Desktop or Gemini CLI) via Stdio: + +```bash +docker run -i --rm -e MEMORY_API_URL=http://host.docker.internal:8000 redis-agent-memory-mcp +``` + +*Note: Use `host.docker.internal` to access services running on the host machine from within the container.* + +### Kubernetes Deployment (kagent) + +The recommended way to use this with `kagent` is as an `MCPServer` resource. When you define an `MCPServer`, `kagent` automatically manages its lifecycle and creates a matching `RemoteMCPServer` that serves it over SSE. + +1. Build and push the Docker image to a registry accessible by your cluster. +2. Update `mcp-server.yaml` with your image and Redis Memory Server URL. +3. Apply the manifest: + +```bash +kubectl apply -f mcp-server.yaml +``` + +#### Configuring Agent Memory + +To enable the agent to use this server for memory, configure the `memory` section in your `Agent` resource. + +```yaml +apiVersion: kagent.dev/v1alpha2 +kind: Agent +metadata: + name: my-agent +spec: + declarative: + memory: + type: McpServer + mcpServer: + name: redis-memory-mcp + # kind: MCPServer # Optional, default + # apiGroup: kagent.dev # Optional, default +``` + +You do not need to list the MCP server in the `tools` section if you only intend to use it for memory. The controller will automatically configure the connection for the agent. diff --git a/python/samples/memory/redis-agent-memory-server/mcp-server.yaml b/python/samples/memory/redis-agent-memory-server/mcp-server.yaml new file mode 100644 index 000000000..55f99515c --- /dev/null +++ b/python/samples/memory/redis-agent-memory-server/mcp-server.yaml @@ -0,0 +1,24 @@ +apiVersion: kagent.dev/v1alpha2 +kind: MCPServer +metadata: + name: redis-memory-mcp + namespace: kagent + labels: + app.kubernetes.io/name: redis-memory-mcp + app.kubernetes.io/part-of: kagent +spec: + transportType: stdio + timeout: 30s + deployment: + image: kagent-redis-agent-memory-mcp-server:latest # Replace with your built image + cmd: python + args: + - "server.py" + env: + - name: MEMORY_API_URL + value: "http://redis-memory-server:8000" # Update to point to your Redis Memory Server + - name: MEMORY_API_KEY + valueFrom: + secretKeyRef: + name: agent-memory-server-token + key: AGENT_MEMORY_SERVER_TOKEN diff --git a/python/samples/memory/redis-agent-memory-server/requirements.txt b/python/samples/memory/redis-agent-memory-server/requirements.txt new file mode 100644 index 000000000..2cdfc7224 --- /dev/null +++ b/python/samples/memory/redis-agent-memory-server/requirements.txt @@ -0,0 +1,5 @@ +mcp +agent-memory-client +python-dotenv +starlette +uvicorn diff --git a/python/samples/memory/redis-agent-memory-server/server.py b/python/samples/memory/redis-agent-memory-server/server.py new file mode 100644 index 000000000..31838cf69 --- /dev/null +++ b/python/samples/memory/redis-agent-memory-server/server.py @@ -0,0 +1,177 @@ +import asyncio +import logging +import os +from datetime import datetime +from typing import Any, Dict, List, Optional + +import uvicorn +from agent_memory_client import MemoryAPIClient +from dotenv import load_dotenv +from mcp.server.fastmcp import FastMCP +from mcp.server.sse import SseServerTransport +from starlette.applications import Starlette +from starlette.routing import Mount, Route + +# Load environment variables +load_dotenv() + +# Configuration +MEMORY_API_URL = os.getenv("MEMORY_API_URL", "http://localhost:8000") +MEMORY_API_KEY = os.getenv("MEMORY_API_KEY", None) +MCP_PORT = int(os.getenv("MCP_PORT", "8000")) +MCP_TRANSPORT = os.getenv("MCP_TRANSPORT", "stdio") # 'stdio' or 'sse' + +# Initialize Client +client = MemoryAPIClient(base_url=MEMORY_API_URL, api_key=MEMORY_API_KEY) + +# Initialize MCP Server +mcp = FastMCP("Redis Agent Memory Client") + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + +# --- McpMemoryService Compatibility Tools --- + + +@mcp.tool() +def add_session_to_memory(session: Dict[str, Any]) -> Dict[str, Any]: + """ + Adds a session to the memory store. + Required by kagent-adk McpMemoryService. + """ + session_id = session.get("id") + user_id = session.get("user_id") + namespace = session.get("app_name") + events = session.get("events", []) + + messages = [] + for event in events: + content = event.get("content") + author = event.get("author", "user") # Default to user + + # Redis Agent Memory Server expects 'role' and 'content' + role = "user" + if isinstance(author, str): + if author.lower() in ("model", "assistant", "bot", "ai"): + role = "assistant" + elif author.lower() == "system": + role = "system" + + if content: + messages.append( + {"role": role, "content": content, "id": event.get("id"), "created_at": event.get("timestamp")} + ) + + logger.info(f"Adding session {session_id} to memory with {len(messages)} messages") + + return client.set_working_memory(session_id=session_id, messages=messages, user_id=user_id, namespace=namespace) + + +@mcp.tool() +def search_memory(app_name: str, user_id: str, query: str) -> Dict[str, Any]: + """ + Searches the memory store. + Required by kagent-adk McpMemoryService. + Returns: {"memories": [{"content": "...", ...}, ...]} + """ + logger.info(f"Searching memory for user {user_id} in {app_name}: {query}") + + results = client.search_long_term_memory(text=query, user_id=user_id, namespace=app_name, limit=5) + + raw_memories = results.get("memories", []) + formatted_memories = [] + + for m in raw_memories: + # Construct content structure compatible with google.genai.types.Content + content_text = m.get("text", "") + formatted_memories.append( + { + "content": {"parts": [{"text": content_text}], "role": "user"}, + "id": m.get("id"), + "author": m.get("user_id"), + "timestamp": m.get("created_at"), + "custom_metadata": { + "topics": m.get("topics"), + "entities": m.get("entities"), + "memory_type": m.get("memory_type"), + }, + } + ) + + return {"memories": formatted_memories} + + +# --- General Redis Agent Memory Server Tools --- + + +@mcp.tool() +def get_current_datetime() -> dict[str, str | int]: + """ + Get the current datetime in UTC for grounding relative time expressions. + """ + now = datetime.utcnow() + iso_utc = now.replace(microsecond=0).isoformat() + "Z" + return {"iso_utc": iso_utc, "unix_ts": int(now.timestamp())} + + +@mcp.tool() +def create_long_term_memories(memories: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + Create long-term memories directly. + """ + return client.create_long_term_memories(memories=memories) + + +@mcp.tool() +def memory_prompt( + query: str, + session_id: str | None = None, + namespace: str | None = None, + user_id: str | None = None, + limit: int = 10, +) -> Dict[str, Any]: + """ + Hydrate a query with relevant context. + """ + return client.memory_prompt(query=query, session_id=session_id, namespace=namespace, user_id=user_id, limit=limit) + + +@mcp.tool() +def delete_long_term_memories(memory_ids: List[str]) -> Dict[str, Any]: + """Delete memories by ID.""" + return client.delete_long_term_memories(memory_ids=memory_ids) + + +def create_sse_app(): + sse = SseServerTransport("/messages") + + async def handle_sse(request): + async with sse.connect_sse( + request.scope, + request.receive, + request._send, + ) as (read_stream, write_stream): + await mcp._mcp_server.run( + read_stream, + write_stream, + mcp._mcp_server.create_initialization_options(), + ) + + return Starlette( + debug=True, + routes=[ + Route("/sse", endpoint=handle_sse), + Mount("/messages", app=sse.handle_post_message), + ], + ) + + +if __name__ == "__main__": + if MCP_TRANSPORT == "sse": + logger.info(f"Starting MCP server on port {MCP_PORT} (SSE mode)") + app = create_sse_app() + uvicorn.run(app, host="0.0.0.0", port=MCP_PORT) + else: + # Default to stdio + logger.info("Starting MCP server (stdio mode)") + mcp.run() diff --git a/python/uv.lock b/python/uv.lock index ddd8e3af9..4fb36e79d 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -324,7 +324,7 @@ dependencies = [ ] [package.metadata] -requires-dist = [{ name = "google-adk", specifier = ">=1.8.0" }] +requires-dist = [{ name = "google-adk", specifier = ">=1.25.0" }] [[package]] name = "basic-openai-agent" @@ -896,6 +896,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" }, ] +[[package]] +name = "fastuuid" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26", size = 18232, upload-time = "2025-10-19T22:19:22.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/83/ae12dd39b9a39b55d7f90abb8971f1a5f3c321fd72d5aa83f90dc67fe9ed/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77a09cb7427e7af74c594e409f7731a0cf887221de2f698e1ca0ebf0f3139021", size = 510720, upload-time = "2025-10-19T22:42:34.633Z" }, + { url = "https://files.pythonhosted.org/packages/53/b0/a4b03ff5d00f563cc7546b933c28cb3f2a07344b2aec5834e874f7d44143/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9bd57289daf7b153bfa3e8013446aa144ce5e8c825e9e366d455155ede5ea2dc", size = 262024, upload-time = "2025-10-19T22:30:25.482Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6d/64aee0a0f6a58eeabadd582e55d0d7d70258ffdd01d093b30c53d668303b/fastuuid-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ac60fc860cdf3c3f327374db87ab8e064c86566ca8c49d2e30df15eda1b0c2d5", size = 251679, upload-time = "2025-10-19T22:36:14.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/a7e9cda8369e4f7919d36552db9b2ae21db7915083bc6336f1b0082c8b2e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab32f74bd56565b186f036e33129da77db8be09178cd2f5206a5d4035fb2a23f", size = 277862, upload-time = "2025-10-19T22:36:23.302Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/8ce11827c783affffd5bd4d6378b28eb6cc6d2ddf41474006b8d62e7448e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e678459cf4addaedd9936bbb038e35b3f6b2061330fd8f2f6a1d80414c0f87", size = 278278, upload-time = "2025-10-19T22:29:43.809Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/680fb6352d0bbade04036da46264a8001f74b7484e2fd1f4da9e3db1c666/fastuuid-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e3cc56742f76cd25ecb98e4b82a25f978ccffba02e4bdce8aba857b6d85d87b", size = 301788, upload-time = "2025-10-19T22:36:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7c/2014b5785bd8ebdab04ec857635ebd84d5ee4950186a577db9eff0fb8ff6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cb9a030f609194b679e1660f7e32733b7a0f332d519c5d5a6a0a580991290022", size = 459819, upload-time = "2025-10-19T22:35:31.623Z" }, + { url = "https://files.pythonhosted.org/packages/01/d2/524d4ceeba9160e7a9bc2ea3e8f4ccf1ad78f3bde34090ca0c51f09a5e91/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:09098762aad4f8da3a888eb9ae01c84430c907a297b97166b8abc07b640f2995", size = 478546, upload-time = "2025-10-19T22:26:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/354d04951ce114bf4afc78e27a18cfbd6ee319ab1829c2d5fb5e94063ac6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1383fff584fa249b16329a059c68ad45d030d5a4b70fb7c73a08d98fd53bcdab", size = 450921, upload-time = "2025-10-19T22:31:02.151Z" }, + { url = "https://files.pythonhosted.org/packages/fb/be/d7be8670151d16d88f15bb121c5b66cdb5ea6a0c2a362d0dcf30276ade53/fastuuid-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a0809f8cc5731c066c909047f9a314d5f536c871a7a22e815cc4967c110ac9ad", size = 154559, upload-time = "2025-10-19T22:36:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/22/1d/5573ef3624ceb7abf4a46073d3554e37191c868abc3aecd5289a72f9810a/fastuuid-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed", size = 156539, upload-time = "2025-10-19T22:33:35.898Z" }, + { url = "https://files.pythonhosted.org/packages/16/c9/8c7660d1fe3862e3f8acabd9be7fc9ad71eb270f1c65cce9a2b7a31329ab/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b852a870a61cfc26c884af205d502881a2e59cc07076b60ab4a951cc0c94d1ad", size = 510600, upload-time = "2025-10-19T22:43:44.17Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f4/a989c82f9a90d0ad995aa957b3e572ebef163c5299823b4027986f133dfb/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c7502d6f54cd08024c3ea9b3514e2d6f190feb2f46e6dbcd3747882264bb5f7b", size = 262069, upload-time = "2025-10-19T22:43:38.38Z" }, + { url = "https://files.pythonhosted.org/packages/da/6c/a1a24f73574ac995482b1326cf7ab41301af0fabaa3e37eeb6b3df00e6e2/fastuuid-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ca61b592120cf314cfd66e662a5b54a578c5a15b26305e1b8b618a6f22df714", size = 251543, upload-time = "2025-10-19T22:32:22.537Z" }, + { url = "https://files.pythonhosted.org/packages/1a/20/2a9b59185ba7a6c7b37808431477c2d739fcbdabbf63e00243e37bd6bf49/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa75b6657ec129d0abded3bec745e6f7ab642e6dba3a5272a68247e85f5f316f", size = 277798, upload-time = "2025-10-19T22:33:53.821Z" }, + { url = "https://files.pythonhosted.org/packages/ef/33/4105ca574f6ded0af6a797d39add041bcfb468a1255fbbe82fcb6f592da2/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8a0dfea3972200f72d4c7df02c8ac70bad1bb4c58d7e0ec1e6f341679073a7f", size = 278283, upload-time = "2025-10-19T22:29:02.812Z" }, + { url = "https://files.pythonhosted.org/packages/fe/8c/fca59f8e21c4deb013f574eae05723737ddb1d2937ce87cb2a5d20992dc3/fastuuid-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1bf539a7a95f35b419f9ad105d5a8a35036df35fdafae48fb2fd2e5f318f0d75", size = 301627, upload-time = "2025-10-19T22:35:54.985Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e2/f78c271b909c034d429218f2798ca4e89eeda7983f4257d7865976ddbb6c/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9a133bf9cc78fdbd1179cb58a59ad0100aa32d8675508150f3658814aeefeaa4", size = 459778, upload-time = "2025-10-19T22:28:00.999Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f0/5ff209d865897667a2ff3e7a572267a9ced8f7313919f6d6043aed8b1caa/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:f54d5b36c56a2d5e1a31e73b950b28a0d83eb0c37b91d10408875a5a29494bad", size = 478605, upload-time = "2025-10-19T22:36:21.764Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c8/2ce1c78f983a2c4987ea865d9516dbdfb141a120fd3abb977ae6f02ba7ca/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:ec27778c6ca3393ef662e2762dba8af13f4ec1aaa32d08d77f71f2a70ae9feb8", size = 450837, upload-time = "2025-10-19T22:34:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/df/60/dad662ec9a33b4a5fe44f60699258da64172c39bd041da2994422cdc40fe/fastuuid-0.14.0-cp314-cp314-win32.whl", hash = "sha256:e23fc6a83f112de4be0cc1990e5b127c27663ae43f866353166f87df58e73d06", size = 154532, upload-time = "2025-10-19T22:35:18.217Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/da4db31001e854025ffd26bc9ba0740a9cbba2c3259695f7c5834908b336/fastuuid-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a", size = 156457, upload-time = "2025-10-19T22:33:44.579Z" }, +] + [[package]] name = "filelock" version = "3.20.3" @@ -1071,20 +1101,20 @@ wheels = [ [[package]] name = "google-auth" -version = "2.47.0" +version = "2.48.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "cryptography" }, { name = "pyasn1-modules" }, { name = "rsa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/3c/ec64b9a275ca22fa1cd3b6e77fefcf837b0732c890aa32d2bd21313d9b33/google_auth-2.47.0.tar.gz", hash = "sha256:833229070a9dfee1a353ae9877dcd2dec069a8281a4e72e72f77d4a70ff945da", size = 323719, upload-time = "2026-01-06T21:55:31.045Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/18/79e9008530b79527e0d5f79e7eef08d3b179b7f851cfd3a2f27822fbdfa9/google_auth-2.47.0-py3-none-any.whl", hash = "sha256:c516d68336bfde7cf0da26aab674a36fedcf04b37ac4edd59c597178760c3498", size = 234867, upload-time = "2026-01-06T21:55:28.6Z" }, + { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" }, ] [package.optional-dependencies] pyopenssl = [ - { name = "cryptography" }, { name = "pyopenssl" }, ] requests = [ @@ -1442,7 +1472,7 @@ wheels = [ [[package]] name = "google-genai" -version = "1.59.0" +version = "1.63.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1456,9 +1486,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/34/c03bcbc759d67ac3d96077838cdc1eac85417de6ea3b65b313fe53043eee/google_genai-1.59.0.tar.gz", hash = "sha256:0b7a2dc24582850ae57294209d8dfc2c4f5fcfde0a3f11d81dc5aca75fb619e2", size = 487374, upload-time = "2026-01-15T20:29:46.619Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/d7/07ec5dadd0741f09e89f3ff5f0ce051ce2aa3a76797699d661dc88def077/google_genai-1.63.0.tar.gz", hash = "sha256:dc76cab810932df33cbec6c7ef3ce1538db5bef27aaf78df62ac38666c476294", size = 491970, upload-time = "2026-02-11T23:46:28.472Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/53/6d00692fe50d73409b3406ae90c71bc4499c8ae7fac377ba16e283da917c/google_genai-1.59.0-py3-none-any.whl", hash = "sha256:59fc01a225d074fe9d1e626c3433da292f33249dadce4deb34edea698305a6df", size = 719099, upload-time = "2026-01-15T20:29:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/82/c8/ba32159e553fab787708c612cf0c3a899dafe7aca81115d841766e3bfe69/google_genai-1.63.0-py3-none-any.whl", hash = "sha256:6206c13fc20f332703ca7375bea7c191c82f95d6781c29936c6982d86599b359", size = 724747, upload-time = "2026-02-11T23:46:26.697Z" }, ] [[package]] @@ -2011,19 +2041,19 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.115.1" }, { name = "filelock", specifier = ">=3.20.3" }, { name = "google-adk", specifier = ">=1.25.0" }, - { name = "google-auth", specifier = ">=2.40.2" }, - { name = "google-genai", specifier = ">=1.21.1" }, - { name = "httpx", specifier = ">=0.25.0" }, + { name = "google-auth", specifier = ">=2.48.0" }, + { name = "google-genai", specifier = ">=1.63.0" }, + { name = "httpx", specifier = ">=0.28.1" }, { name = "jsonref", specifier = ">=1.1.0" }, { name = "kagent-core", editable = "packages/kagent-core" }, { name = "kagent-skills", editable = "packages/kagent-skills" }, - { name = "litellm", specifier = ">=1.74.3" }, - { name = "mcp", specifier = ">=1.25.0" }, + { name = "litellm", specifier = ">=1.81.12" }, + { name = "mcp", specifier = ">=1.26.0" }, { name = "ollama", specifier = ">=0.3.6" }, { name = "openai", specifier = ">=1.72.0" }, { name = "protobuf", specifier = ">=6.33.5" }, { name = "psutil", marker = "extra == 'memory'", specifier = ">=6.1.0" }, - { name = "pydantic", specifier = ">=2.5.0" }, + { name = "pydantic", specifier = ">=2.12.5" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.5" }, { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.3" }, { name = "python-multipart", specifier = ">=0.0.22" }, @@ -2430,11 +2460,12 @@ otel = [ [[package]] name = "litellm" -version = "1.74.9" +version = "1.81.13" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "click" }, + { name = "fastuuid" }, { name = "httpx" }, { name = "importlib-metadata" }, { name = "jinja2" }, @@ -2445,9 +2476,9 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/5d/646bebdb4769d77e6a018b9152c9ccf17afe15d0f88974f338d3f2ee7c15/litellm-1.74.9.tar.gz", hash = "sha256:4a32eff70342e1aee4d1cbf2de2a6ed64a7c39d86345c58d4401036af018b7de", size = 9660510, upload-time = "2025-07-28T16:42:39.297Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/80/b6cb799e7100953d848e106d0575db34c75bc3b57f31f2eefdfb1e23655f/litellm-1.81.13.tar.gz", hash = "sha256:083788d9c94e3371ff1c42e40e0e8198c497772643292a65b1bc91a3b3b537ea", size = 16562861, upload-time = "2026-02-17T02:00:47.466Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/e4/f1546746049c99c6b8b247e2f34485b9eae36faa9322b84e2a17262e6712/litellm-1.74.9-py3-none-any.whl", hash = "sha256:ab8f8a6e4d8689d3c7c4f9c3bbc7e46212cc3ebc74ddd0f3c0c921bb459c9874", size = 8740449, upload-time = "2025-07-28T16:42:36.8Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/fffb7932870163cea7addc392165647a9a8a5489967de486c854226f1141/litellm-1.81.13-py3-none-any.whl", hash = "sha256:ae4aea2a55e85993f5f6dd36d036519422d24812a1a3e8540d9e987f2d7a4304", size = 14587505, upload-time = "2026-02-17T02:00:44.22Z" }, ] [[package]] @@ -2566,7 +2597,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.25.0" +version = "1.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2584,9 +2615,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/2d/649d80a0ecf6a1f82632ca44bec21c0461a9d9fc8934d38cb5b319f2db5e/mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802", size = 605387, upload-time = "2025-12-19T10:19:56.985Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a", size = 233076, upload-time = "2025-12-19T10:19:55.416Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, ] [[package]] diff --git a/ui/package-lock.json b/ui/package-lock.json index fd1927f12..db193b65f 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -24,6 +24,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/typography": "^0.5.19", @@ -3582,6 +3583,35 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tabs": { "version": "1.1.13", "license": "MIT", diff --git a/ui/package.json b/ui/package.json index 77f0d5684..4501c8e94 100644 --- a/ui/package.json +++ b/ui/package.json @@ -29,6 +29,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/typography": "^0.5.19", diff --git a/ui/src/app/actions/agents.ts b/ui/src/app/actions/agents.ts index 96c4936b0..7dad9fb92 100644 --- a/ui/src/app/actions/agents.ts +++ b/ui/src/app/actions/agents.ts @@ -114,6 +114,9 @@ function fromAgentFormDataToAgent(agentFormData: AgentFormData): Agent { modelConfig: modelConfigName || "", stream: agentFormData.stream ?? true, tools: convertTools(agentFormData.tools || []), + context: agentFormData.context, + memory: agentFormData.memory, + resumability: agentFormData.resumability, }; if (agentFormData.skillRefs && agentFormData.skillRefs.length > 0) { diff --git a/ui/src/app/agents/new/page.tsx b/ui/src/app/agents/new/page.tsx index 4d15e6195..5f98ae9ef 100644 --- a/ui/src/app/agents/new/page.tsx +++ b/ui/src/app/agents/new/page.tsx @@ -5,9 +5,12 @@ import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Loader2, Settings2, PlusCircle, Trash2 } from "lucide-react"; -import { ModelConfig, AgentType } from "@/types"; -import { SystemPromptSection } from "@/components/create/SystemPromptSection"; +import { ModelConfig, AgentType, Tool, EnvVar, ContextConfig, MemoryConfig, ResumabilityConfig } from "@/types"; +import { ContextSection } from "@/components/create/ContextSection"; +import { MemorySection } from "@/components/create/MemorySection"; +import { ResumabilitySection } from "@/components/create/ResumabilitySection"; import { ModelSelectionSection } from "@/components/create/ModelSelectionSection"; +import { SystemPromptSection } from "@/components/create/SystemPromptSection"; import { ToolsSection } from "@/components/create/ToolsSection"; import { useRouter, useSearchParams } from "next/navigation"; import { useAgents } from "@/components/AgentsProvider"; @@ -15,7 +18,6 @@ import { LoadingState } from "@/components/LoadingState"; import { ErrorState } from "@/components/ErrorState"; import KagentLogo from "@/components/kagent-logo"; import { AgentFormData } from "@/components/AgentsProvider"; -import { Tool, EnvVar } from "@/types"; import { toast } from "sonner"; import { NamespaceCombobox } from "@/components/NamespaceCombobox"; import { Label } from "@/components/ui/label"; @@ -32,6 +34,9 @@ interface ValidationErrors { knowledgeSources?: string; tools?: string; skills?: string; + memory?: string; + context?: string; + resumability?: string; } interface AgentPageContentProps { @@ -69,6 +74,9 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo selectedModel: SelectedModelType | null; selectedTools: Tool[]; skillRefs: string[]; + contextConfig?: ContextConfig; + memoryConfig?: MemoryConfig; + resumabilityConfig?: ResumabilityConfig; byoImage: string; byoCmd: string; byoArgs: string; @@ -91,6 +99,9 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo selectedModel: null, selectedTools: [], skillRefs: [""], + contextConfig: undefined, + memoryConfig: undefined, + resumabilityConfig: undefined, byoImage: "", byoCmd: "", byoArgs: "", @@ -137,6 +148,9 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo selectedModel: agentResponse.modelConfigRef ? { model: agentResponse.model || "default-model-config", ref: agentResponse.modelConfigRef } : null, skillRefs: (agent.spec?.skills?.refs && agent.spec.skills.refs.length > 0) ? agent.spec.skills.refs : [""], stream: agent.spec?.declarative?.stream ?? false, + contextConfig: agent.spec?.declarative?.context, + memoryConfig: agent.spec?.declarative?.memory, + resumabilityConfig: agent.spec?.declarative?.resumability, byoImage: "", byoCmd: "", byoArgs: "", @@ -148,6 +162,9 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo systemPrompt: "", selectedModel: null, selectedTools: [], + contextConfig: undefined, + memoryConfig: undefined, + resumabilityConfig: undefined, byoImage: agent.spec?.byo?.deployment?.image || "", byoCmd: agent.spec?.byo?.deployment?.cmd || "", byoArgs: (agent.spec?.byo?.deployment?.args || []).join(" "), @@ -202,6 +219,10 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo const newErrors = validateAgentData(formData); + if (state.memoryConfig?.type === "McpServer" && !state.memoryConfig.mcpServer?.name) { + newErrors.memory = "MCP Server Name is required"; + } + if (state.agentType === "Declarative" && state.skillRefs && state.skillRefs.length > 0) { // Filter out empty/whitespace entries first - if all are empty, treat as "no skills" const nonEmptyRefs = state.skillRefs.filter(ref => ref.trim()); @@ -270,7 +291,7 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo throw new Error("Model is required to create a declarative agent."); } - const agentData = { + const agentData: AgentFormData = { name: state.name, namespace: state.namespace, description: state.description, @@ -280,6 +301,9 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo stream: state.stream, tools: state.selectedTools, skillRefs: state.agentType === "Declarative" ? (state.skillRefs || []).filter(ref => ref.trim()) : undefined, + context: state.contextConfig, + memory: state.memoryConfig, + resumability: state.resumabilityConfig, // BYO byoImage: state.byoImage, byoCmd: state.byoCmd || undefined, @@ -450,6 +474,28 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo agentNamespace={state.namespace} /> + setState(prev => ({ ...prev, contextConfig: config }))} + error={state.errors.context} + disabled={state.isSubmitting || state.isLoading} + models={models} + agentNamespace={state.namespace} + /> + + setState(prev => ({ ...prev, memoryConfig: config }))} + error={state.errors.memory} + disabled={state.isSubmitting || state.isLoading} + /> + + setState(prev => ({ ...prev, resumabilityConfig: config }))} + disabled={state.isSubmitting || state.isLoading} + /> +
void; + error?: string; + disabled: boolean; + models: ModelConfig[]; + agentNamespace: string; +} + +export const ContextSection = ({ config, onChange, error, disabled, models, agentNamespace }: ContextSectionProps) => { + const compaction = config?.compaction; + const cache = config?.cache; + + const handleCompactionChange = (enabled: boolean) => { + if (enabled) { + onChange({ + ...config, + compaction: { + compactionInterval: 10, + overlapSize: 2, + }, + }); + } else { + const newConfig = { ...config }; + delete newConfig.compaction; + if (!newConfig.cache && Object.keys(newConfig).length === 0) { + onChange(undefined); + } else { + onChange(newConfig); + } + } + }; + + const handleCacheChange = (enabled: boolean) => { + if (enabled) { + onChange({ + ...config, + cache: { + cacheIntervals: 10, + ttlSeconds: 1800, + minTokens: 0, + }, + }); + } else { + const newConfig = { ...config }; + delete newConfig.cache; + if (!newConfig.compaction && Object.keys(newConfig).length === 0) { + onChange(undefined); + } else { + onChange(newConfig); + } + } + }; + + const updateCompaction = (key: string, value: string | number | undefined) => { + if (!compaction) return; + onChange({ + ...config, + compaction: { + ...compaction, + [key]: value, + }, + }); + }; + + const updateSummarizer = (key: string, value: string | undefined) => { + if (!compaction) return; + const currentSummarizer = compaction.summarizer || {}; + // If value is empty/undefined and promptTemplate is also empty, we could clear summarizer, but simpler to keep it if initialized + onChange({ + ...config, + compaction: { + ...compaction, + summarizer: { + ...currentSummarizer, + [key]: value, + }, + }, + }); + }; + + const updateCache = (key: string, value: number | undefined) => { + if (!cache) return; + onChange({ + ...config, + cache: { + ...cache, + [key]: value, + }, + }); + }; + + return ( + + + + + Context Management + + + + {/* Compaction Config */} +
+
+
+ +

+ Summarize older events to reduce context size. +

+
+ +
+ + {compaction && ( +
+
+ + updateCompaction("compactionInterval", parseInt(e.target.value) || 0)} + disabled={disabled} + /> +
+
+ + updateCompaction("overlapSize", parseInt(e.target.value) || 0)} + disabled={disabled} + /> +
+
+ + updateCompaction("tokenThreshold", e.target.value ? parseInt(e.target.value) : undefined)} + placeholder="e.g. 1000" + disabled={disabled} + /> +
+
+ + updateCompaction("eventRetentionSize", e.target.value ? parseInt(e.target.value) : undefined)} + placeholder="e.g. 50" + disabled={disabled} + /> +
+
+ + +
+
+ +