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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions go/adk/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/go-logr/logr"
"github.com/go-logr/zapr"
"github.com/kagent-dev/kagent/go/adk/pkg/a2a"
agentpkg "github.com/kagent-dev/kagent/go/adk/pkg/agent"
"github.com/kagent-dev/kagent/go/adk/pkg/app"
"github.com/kagent-dev/kagent/go/adk/pkg/auth"
"github.com/kagent-dev/kagent/go/adk/pkg/config"
Expand All @@ -20,6 +21,7 @@ import (
"github.com/kagent-dev/kagent/go/adk/pkg/session"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
adkmodel "google.golang.org/adk/model"
)

func setupLogger(logLevel string) (logr.Logger, *zap.Logger) {
Expand Down Expand Up @@ -149,13 +151,24 @@ func main() {
}

stream := agentConfig.GetStream()

var sessionNameLLM adkmodel.LLM
if sessionService != nil {
if llm, err := agentpkg.CreateLLM(ctx, agentConfig.Model, logger); err == nil {
sessionNameLLM = llm
} else {
logger.Info("Could not create LLM for session name generation, names will not be set", "error", err)
}
}

executor := a2a.NewKAgentExecutor(a2a.KAgentExecutorConfig{
RunnerConfig: runnerConfig,
SubagentSessionIDs: subagentSessionIDs,
SessionService: sessionService,
Stream: stream,
AppName: appName,
Logger: logger,
SessionNameLLM: sessionNameLLM,
})

// Build the agent card.
Expand Down
145 changes: 120 additions & 25 deletions go/adk/pkg/a2a/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"fmt"
"maps"
"os"
"strings"
"time"

a2atype "github.com/a2aproject/a2a-go/a2a"
"github.com/a2aproject/a2a-go/a2asrv"
Expand All @@ -14,14 +16,22 @@ import (
"github.com/kagent-dev/kagent/go/adk/pkg/skills"
"github.com/kagent-dev/kagent/go/adk/pkg/telemetry"
adkagent "google.golang.org/adk/agent"
"google.golang.org/adk/model"
"google.golang.org/adk/runner"
"google.golang.org/adk/server/adka2a"
"google.golang.org/genai"
)

const (
defaultSkillsDirectory = "/skills"
envSkillsFolder = "KAGENT_SKILLS_FOLDER"
sessionNameMaxLength = 20
defaultSkillsDirectory = "/skills"
envSkillsFolder = "KAGENT_SKILLS_FOLDER"
envSessionNameUpdateInterval = "KAGENT_SESSION_NAME_UPDATE_INTERVAL"
)

const (
sessionNameSummarizationPrompt = `
Generate a short title (5-7 words max, no quotes or punctuation) for a conversation that starts with this message: %s\nRespond with only the title, nothing else.
`
)

// KAgentExecutorConfig holds the configuration for KAgentExecutor
Expand All @@ -33,17 +43,27 @@ type KAgentExecutorConfig struct {
AppName string
SkillsDirectory string
Logger logr.Logger
SessionNameLLM model.LLM
}

// sessionNameMeta holds per-request metadata used for session name generation.
type sessionNameMeta struct {
userID string
updatedAt time.Time
messageText string
}

// KAgentExecutor implements a2asrv.AgentExecutor
type KAgentExecutor struct {
runnerConfig runner.Config
subagentSessionIDs map[string]string
sessionService *session.KAgentSessionService
stream bool
appName string
skillsDirectory string
logger logr.Logger
runnerConfig runner.Config
subagentSessionIDs map[string]string
sessionService *session.KAgentSessionService
stream bool
appName string
skillsDirectory string
logger logr.Logger
sessionNameLLM model.LLM
sessionNameUpdateInterval time.Duration
}

var _ a2asrv.AgentExecutor = (*KAgentExecutor)(nil)
Expand All @@ -57,14 +77,24 @@ func NewKAgentExecutor(cfg KAgentExecutorConfig) *KAgentExecutor {
if skillsDir == "" {
skillsDir = defaultSkillsDirectory
}

var sessionNameUpdateInterval time.Duration
if intervalStr := os.Getenv(envSessionNameUpdateInterval); intervalStr != "" {
if d, err := time.ParseDuration(intervalStr); err == nil {
sessionNameUpdateInterval = d
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NewKAgentExecutor silently ignores invalid KAGENT_SESSION_NAME_UPDATE_INTERVAL values (ParseDuration error just leaves interval at 0). This makes misconfiguration hard to diagnose. Log a warning when parsing fails so operators know why session name generation isn't running.

Suggested change
sessionNameUpdateInterval = d
sessionNameUpdateInterval = d
} else {
cfg.Logger.WithName("kagent-executor").WithValues(
"envVar", envSessionNameUpdateInterval,
"value", intervalStr,
).Info("Invalid duration for session name update interval; session name generation will not be scheduled")

Copilot uses AI. Check for mistakes.
}
}

return &KAgentExecutor{
runnerConfig: cfg.RunnerConfig,
subagentSessionIDs: cfg.SubagentSessionIDs,
sessionService: cfg.SessionService,
stream: cfg.Stream,
appName: cfg.AppName,
skillsDirectory: skillsDir,
logger: cfg.Logger.WithName("kagent-executor"),
runnerConfig: cfg.RunnerConfig,
subagentSessionIDs: cfg.SubagentSessionIDs,
sessionService: cfg.SessionService,
stream: cfg.Stream,
appName: cfg.AppName,
skillsDirectory: skillsDir,
logger: cfg.Logger.WithName("kagent-executor"),
sessionNameLLM: cfg.SessionNameLLM,
sessionNameUpdateInterval: sessionNameUpdateInterval,
}
}

Expand Down Expand Up @@ -140,22 +170,36 @@ func (e *KAgentExecutor) Execute(ctx context.Context, reqCtx *a2asrv.RequestCont
}

// 4. Create / lookup session via sessionService.
var meta *sessionNameMeta
if e.sessionService != nil {
sess, err := e.sessionService.GetSession(ctx, e.appName, userID, sessionID)
if err != nil {
e.logger.V(1).Info("Session lookup failed, will create", "error", err, "sessionID", sessionID)
sess = nil
}

// Track the session's last update time for post-execution name generation.
// For new sessions, updatedAt stays zero which always exceeds any interval.
var updatedAt time.Time
if sess != nil {
type timeProvider interface {
LastUpdateTime() time.Time
}
if tp, ok := sess.(timeProvider); ok {
updatedAt = tp.LastUpdateTime()
}
}

if sess == nil {
sessionName := extractSessionName(reqCtx.Message)
sessionName := extractMessageText(reqCtx.Message)
state := make(map[string]any)
if sessionName != "" {
state[StateKeySessionName] = sessionName
}
Comment on lines 193 to 198
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New sessions are still created with StateKeySessionName set to the raw first message text (extractMessageText), which no longer trims or truncates. This can regress UX (very long sidebar titles) and may bloat indexed DB fields. Consider restoring the prior truncation/normalization (e.g., trim whitespace and cap length with ellipsis) when using the message text as an initial placeholder name.

Copilot uses AI. Check for mistakes.
// Propagate x-kagent-source so the session is tagged in the DB.
if callCtx, ok := a2asrv.CallContextFrom(ctx); ok {
if meta := callCtx.RequestMeta(); meta != nil {
if vals, ok := meta.Get("x-kagent-source"); ok && len(vals) > 0 && vals[0] != "" {
if callMeta := callCtx.RequestMeta(); callMeta != nil {
if vals, ok := callMeta.Get("x-kagent-source"); ok && len(vals) > 0 && vals[0] != "" {
state[StateKeySource] = vals[0]
}
}
Expand All @@ -164,6 +208,14 @@ func (e *KAgentExecutor) Execute(ctx context.Context, reqCtx *a2asrv.RequestCont
return fmt.Errorf("failed to create session: %w", err)
}
}

if e.sessionNameLLM != nil {
meta = &sessionNameMeta{
userID: userID,
updatedAt: updatedAt,
messageText: extractMessageText(reqCtx.Message),
}
}
}

// 5. Detect HITL decision and build the resume message if needed.
Expand Down Expand Up @@ -398,6 +450,21 @@ func (e *KAgentExecutor) Execute(ctx context.Context, reqCtx *a2asrv.RequestCont
return queue.Write(ctx, inputRequired)
}

// Generate session name via LLM if the update interval has elapsed.
if meta != nil && e.sessionNameLLM != nil && e.sessionNameUpdateInterval > 0 {
if time.Since(meta.updatedAt) >= e.sessionNameUpdateInterval && meta.messageText != "" {
if name := e.generateSessionName(ctx, meta.messageText); name != "" {
if updateErr := e.sessionService.UpdateSessionName(ctx, meta.userID, sessionID, name); updateErr != nil {
e.logger.V(1).Info("Failed to update session name", "error", updateErr, "sessionID", sessionID)
} else {
e.logger.Info("Session name updated", "sessionID", sessionID, "name", name)
finalMeta[GetKAgentMetadataKey("session_name")] = name
finalMeta[GetKAgentMetadataKey("session_id")] = sessionID
}
}
}
}

// completed: inject last text into final status if no message present.
var finalMsg *a2atype.Message
if len(lastTextParts) > 0 {
Expand All @@ -419,16 +486,44 @@ func (e *KAgentExecutor) Cancel(ctx context.Context, reqCtx *a2asrv.RequestConte
return queue.Write(ctx, event)
}

// extractSessionName extracts session name from the first text part of a message.
func extractSessionName(message *a2atype.Message) string {
// generateSessionName calls the LLM to produce a short title from the first user message.
func (e *KAgentExecutor) generateSessionName(ctx context.Context, messageText string) string {
if e.sessionNameLLM == nil || messageText == "" {
return ""
}

prompt := fmt.Sprintf(sessionNameSummarizationPrompt, messageText)

req := &model.LLMRequest{
Contents: []*genai.Content{
{Role: "user", Parts: []*genai.Part{{Text: prompt}}},
},
}

var name string
for resp, err := range e.sessionNameLLM.GenerateContent(ctx, req, false) {
if err != nil {
e.logger.V(1).Info("LLM error during session name generation", "error", err)
return ""
}
if resp != nil && resp.Content != nil {
for _, part := range resp.Content.Parts {
if part != nil && part.Text != "" {
name = strings.TrimSpace(part.Text)
}
}
}
}
return name
Comment on lines +503 to +517
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generateSessionName overwrites name for each streamed response part (name = strings.TrimSpace(part.Text)), which can drop earlier chunks if the LLM splits output across parts/chunks. Accumulate text across parts/chunks (append) and trim once at the end to ensure the full title is captured.

Copilot uses AI. Check for mistakes.
}

// extractMessageText returns the first text part of a message.
func extractMessageText(message *a2atype.Message) string {
if message == nil {
return ""
}
for _, part := range message.Parts {
if tp, ok := part.(a2atype.TextPart); ok && tp.Text != "" {
if len(tp.Text) > sessionNameMaxLength {
return tp.Text[:sessionNameMaxLength] + "..."
}
return tp.Text
}
}
Expand Down
40 changes: 38 additions & 2 deletions go/adk/pkg/session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,10 @@ func (s *KAgentSessionService) Get(ctx context.Context, req *adksession.GetReque
var result struct {
Data struct {
Session struct {
ID string `json:"id"`
UserID string `json:"user_id"`
ID string `json:"id"`
UserID string `json:"user_id"`
Name *string `json:"name"`
UpdatedAt time.Time `json:"updated_at"`
} `json:"session"`
Events []struct {
Data json.RawMessage `json:"data"`
Expand Down Expand Up @@ -172,13 +174,16 @@ func (s *KAgentSessionService) Get(ctx context.Context, req *adksession.GetReque
adkEvents = append(adkEvents, e)
}

log.V(1).Info("Parsed session events", "totalEvents", len(result.Data.Events), "outputEvents", len(adkEvents))

return &adksession.GetResponse{
Session: &localSession{
appName: req.AppName,
userID: result.Data.Session.UserID,
sessionID: result.Data.Session.ID,
events: adkEvents,
state: make(map[string]any),
updatedAt: result.Data.Session.UpdatedAt,
},
}, nil
}
Expand Down Expand Up @@ -310,6 +315,37 @@ func (s *KAgentSessionService) CreateSession(ctx context.Context, appName, userI
return err
}

// UpdateSessionName updates the display name of a session via the KAgent API.
func (s *KAgentSessionService) UpdateSessionName(ctx context.Context, userID, sessionID, name string) error {
log := logr.FromContextOrDiscard(ctx)
log.V(1).Info("Updating session name", "sessionID", sessionID, "userID", userID, "name", name)

body, err := json.Marshal(map[string]string{"name": name})
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, s.BaseURL+"/api/sessions/"+sessionID, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", userID)

resp, err := s.Client.Do(req)
if err != nil {
return fmt.Errorf("failed to execute update session name request: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to update session name: status %d - %s", resp.StatusCode, string(bodyBytes))
}

log.V(1).Info("Session name updated successfully", "sessionID", sessionID)
return nil
}
Comment on lines +318 to +347
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New UpdateSessionName() has no unit test coverage, even though this package already has session_service tests. Add tests that (1) verify it issues a PATCH to /api/sessions/{id} with the expected JSON body and X-User-ID header, and (2) verifies non-200 responses are surfaced as errors (including response body).

Copilot uses AI. Check for mistakes.

// normalizeConfirmationEventRole fixes the role on adk_request_confirmation
// functionCall events from "model" to "user".
//
Expand Down
1 change: 1 addition & 0 deletions go/api/database/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type Client interface {

// Get methods
GetSession(ctx context.Context, sessionID string, userID string) (*Session, error)
GetSessionByID(ctx context.Context, sessionID string) (*Session, error)
GetAgent(ctx context.Context, name string) (*Agent, error)
GetTask(ctx context.Context, id string) (*protocol.Task, error)
GetTool(ctx context.Context, name string) (*Tool, error)
Expand Down
7 changes: 7 additions & 0 deletions go/core/internal/database/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,13 @@ func (c *clientImpl) GetSession(ctx context.Context, sessionID string, userID st
Clause{Key: "user_id", Value: userID})
}

// GetSessionByID retrieves a session by id only, without filtering by user ID.
// Use this for internal/agent callers that need cross-user session visibility.
func (c *clientImpl) GetSessionByID(ctx context.Context, sessionID string) (*dbpkg.Session, error) {
return get[dbpkg.Session](c.db.WithContext(ctx),
Clause{Key: "id", Value: sessionID})
}

// GetAgent retrieves an agent by name and user ID
func (c *clientImpl) GetAgent(ctx context.Context, agentID string) (*dbpkg.Agent, error) {
return get[dbpkg.Agent](c.db.WithContext(ctx), Clause{Key: "id", Value: agentID})
Expand Down
13 changes: 13 additions & 0 deletions go/core/internal/database/fake/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,19 @@ func (c *InMemoryFakeClient) GetSession(_ context.Context, sessionID string, use
return session, nil
}

// GetSessionByID retrieves a session by ID only, without filtering by user ID.
func (c *InMemoryFakeClient) GetSessionByID(_ context.Context, sessionID string) (*database.Session, error) {
c.mu.RLock()
defer c.mu.RUnlock()

for _, session := range c.sessions {
if session.ID == sessionID {
return session, nil
}
}
return nil, gorm.ErrRecordNotFound
}

// GetAgent retrieves an agent by name
func (c *InMemoryFakeClient) GetAgent(_ context.Context, agentName string) (*database.Agent, error) {
c.mu.RLock()
Expand Down
Loading
Loading