Skip to content

Commit 1e7b9c1

Browse files
authored
feat: multi-sessions ui (#620)
1 parent f3eb44a commit 1e7b9c1

21 files changed

Lines changed: 1662 additions & 443 deletions

cmd/main.go

Lines changed: 105 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,6 @@ func (o *Options) InitDefaults() {
181181

182182
// Session management options
183183
o.ResumeSession = ""
184-
o.NewSession = false
185184
o.ListSessions = false
186185
o.DeleteSession = ""
187186
o.SessionBackend = "memory"
@@ -334,19 +333,19 @@ func (opt *Options) bindCLIFlags(f *pflag.FlagSet) error {
334333
f.StringVar(&opt.SandboxImage, "sandbox-image", opt.SandboxImage, "container image to use for the sandbox")
335334

336335
f.StringVar(&opt.ResumeSession, "resume-session", opt.ResumeSession, "ID of session to resume (use 'latest' for the most recent session)")
337-
f.BoolVar(&opt.NewSession, "new-session", opt.NewSession, "create a new session")
338336
f.BoolVar(&opt.ListSessions, "list-sessions", opt.ListSessions, "list all available sessions")
339337
f.StringVar(&opt.DeleteSession, "delete-session", opt.DeleteSession, "delete a session by ID")
338+
f.BoolVar(&opt.NewSession, "new-session", opt.NewSession, "start a new persistent session")
340339
f.StringVar(&opt.SessionBackend, "session-backend", opt.SessionBackend,
341340
"session backend to use (memory or filesystem)")
342341

343342
return nil
344343
}
345344

346345
func RunRootCommand(ctx context.Context, opt Options, args []string) error {
347-
var err error // Declare err once for the whole function
346+
var err error
348347

349-
// Automatically upgrade backend to filesystem if session persistence flags are requested explicitly.
348+
// Automatically upgrade backend to filesystem if session persistence flags are requested explicitly
350349
if (opt.NewSession || opt.ResumeSession != "" || opt.ListSessions || opt.DeleteSession != "") && opt.SessionBackend == "memory" {
351350
klog.Infof("Upgrading session-backend to 'filesystem' based on provided flags")
352351
opt.SessionBackend = "filesystem"
@@ -397,144 +396,145 @@ func RunRootCommand(ctx context.Context, opt Options, args []string) error {
397396

398397
klog.Info("Application started", "pid", os.Getpid())
399398

400-
var llmClient gollm.Client
401-
if opt.SkipVerifySSL {
402-
llmClient, err = gollm.NewClient(ctx, opt.ProviderID, gollm.WithSkipVerifySSL())
399+
var recorder journal.Recorder
400+
if opt.TracePath != "" {
401+
var fileRecorder journal.Recorder
402+
fileRecorder, err = journal.NewFileRecorder(opt.TracePath)
403+
if err != nil {
404+
return fmt.Errorf("creating trace recorder: %w", err)
405+
}
406+
defer fileRecorder.Close()
407+
recorder = fileRecorder
403408
} else {
404-
llmClient, err = gollm.NewClient(ctx, opt.ProviderID)
405-
}
406-
if err != nil {
407-
return fmt.Errorf("creating llm client: %w", err)
409+
// Ensure we always have a recorder, to avoid nil checks
410+
recorder = &journal.LogRecorder{}
411+
defer recorder.Close()
408412
}
409-
defer llmClient.Close()
410413

411414
// Initialize session management
412415
var session *api.Session
413416
var sessionManager *sessions.SessionManager
414417

415-
if opt.NewSession || opt.ResumeSession != "" {
416-
sessionManager, err = sessions.NewSessionManager(opt.SessionBackend)
418+
sessionManager, err = sessions.NewSessionManager(opt.SessionBackend)
419+
if err != nil {
420+
return fmt.Errorf("failed to create session manager: %w", err)
421+
}
422+
423+
// Build agentFactory for new agents
424+
agentFactory := func(ctx context.Context) (*agent.Agent, error) {
425+
var client gollm.Client
426+
var err error
427+
if opt.SkipVerifySSL {
428+
client, err = gollm.NewClient(ctx, opt.ProviderID, gollm.WithSkipVerifySSL())
429+
} else {
430+
client, err = gollm.NewClient(ctx, opt.ProviderID)
431+
}
417432
if err != nil {
418-
return fmt.Errorf("failed to create session manager: %w", err)
433+
return nil, fmt.Errorf("creating llm client: %w", err)
419434
}
420435

421-
if opt.NewSession {
422-
meta := sessions.Metadata{
423-
ProviderID: opt.ProviderID,
424-
ModelID: opt.ModelID,
425-
}
426-
session, err = sessionManager.NewSession(meta)
436+
return &agent.Agent{
437+
Model: opt.ModelID,
438+
Provider: opt.ProviderID,
439+
Kubeconfig: opt.KubeConfigPath,
440+
LLM: client,
441+
MaxIterations: opt.MaxIterations,
442+
PromptTemplateFile: opt.PromptTemplateFilePath,
443+
ExtraPromptPaths: opt.ExtraPromptPaths,
444+
Tools: tools.Default(),
445+
Recorder: recorder,
446+
RemoveWorkDir: opt.RemoveWorkDir,
447+
SkipPermissions: opt.SkipPermissions,
448+
EnableToolUseShim: opt.EnableToolUseShim,
449+
MCPClientEnabled: opt.MCPClient,
450+
Sandbox: opt.Sandbox,
451+
SandboxImage: opt.SandboxImage,
452+
SessionBackend: opt.SessionBackend,
453+
RunOnce: opt.Quiet,
454+
InitialQuery: queryFromCmd,
455+
}, nil
456+
}
457+
458+
agentManager := agent.NewAgentManager(agentFactory, sessionManager)
459+
460+
// Register cleanup for all sessions and agents
461+
defer agentManager.Close()
462+
463+
if opt.ResumeSession != "" {
464+
if opt.ResumeSession == "latest" {
465+
session, err = sessionManager.GetLatestSession()
427466
if err != nil {
428-
return fmt.Errorf("failed to create a new session: %w", err)
467+
return fmt.Errorf("failed to get latest session: %w", err)
429468
}
430-
if opt.SessionBackend == "filesystem" {
431-
klog.Infof("Created new session: %s\n", session.ID)
469+
if session == nil {
470+
// No latest session found, create a new one
471+
klog.Info("No previous session found to resume. Creating new session.")
432472
}
433473
} else {
434-
if opt.ResumeSession == "" || opt.ResumeSession == "latest" {
435-
session, err = sessionManager.GetLatestSession()
436-
if err != nil {
437-
return fmt.Errorf("failed to get latest session: %w", err)
438-
}
439-
if session == nil {
440-
meta := sessions.Metadata{
441-
ProviderID: opt.ProviderID,
442-
ModelID: opt.ModelID,
443-
}
444-
session, err = sessionManager.NewSession(meta)
445-
if err != nil {
446-
return fmt.Errorf("failed to create new session: %w", err)
447-
}
448-
if opt.SessionBackend == "filesystem" {
449-
klog.Infof("No previous session found. Created new session: %s\n", session.ID)
450-
}
451-
}
452-
} else {
453-
sessionID := opt.ResumeSession
454-
session, err = sessionManager.FindSessionByID(sessionID)
455-
if err != nil {
456-
return fmt.Errorf("session %s not found: %w", sessionID, err)
457-
}
458-
}
459-
460-
if session != nil {
461-
if err := sessionManager.UpdateLastAccessed(session); err != nil {
462-
klog.Warningf("Failed to update session last accessed time: %v", err)
463-
}
474+
session, err = sessionManager.FindSessionByID(opt.ResumeSession)
475+
if err != nil {
476+
return fmt.Errorf("session %s not found: %w", opt.ResumeSession, err)
464477
}
465478
}
466479
}
467480

468-
var chatStore api.ChatMessageStore
469-
if session != nil {
470-
chatStore = session.ChatMessageStore
471-
}
481+
var defaultAgent *agent.Agent
472482

473-
var recorder journal.Recorder
474-
if opt.TracePath != "" {
475-
var fileRecorder journal.Recorder
476-
fileRecorder, err = journal.NewFileRecorder(opt.TracePath)
483+
// If no session loaded (or resume failed/not requested), create a new one
484+
if session == nil {
485+
meta := sessions.Metadata{
486+
ModelID: opt.ModelID,
487+
ProviderID: opt.ProviderID,
488+
}
489+
session, err = sessionManager.NewSession(meta)
477490
if err != nil {
478-
return fmt.Errorf("creating trace recorder: %w", err)
491+
return fmt.Errorf("failed to create a new session: %w", err)
479492
}
480-
defer fileRecorder.Close()
481-
recorder = fileRecorder
493+
494+
defaultAgent, err = agentManager.GetAgent(ctx, session.ID)
495+
if err != nil {
496+
return fmt.Errorf("failed to get agent for new session: %w", err)
497+
}
498+
klog.Infof("Created new session: %s\n", session.ID)
482499
} else {
483-
// Ensure we always have a recorder, to avoid nil checks
484-
recorder = &journal.LogRecorder{}
485-
defer recorder.Close()
486-
}
500+
// Update last accessed for resumed session
501+
if err := sessionManager.UpdateLastAccessed(session); err != nil {
502+
klog.Warningf("Failed to update session last accessed time: %v", err)
503+
}
504+
klog.Infof("Resuming session: %s\n", session.ID)
487505

488-
k8sAgent := &agent.Agent{
489-
Model: opt.ModelID,
490-
Provider: opt.ProviderID,
491-
Kubeconfig: opt.KubeConfigPath,
492-
LLM: llmClient,
493-
MaxIterations: opt.MaxIterations,
494-
PromptTemplateFile: opt.PromptTemplateFilePath,
495-
ExtraPromptPaths: opt.ExtraPromptPaths,
496-
Tools: tools.Default(),
497-
Recorder: recorder,
498-
RemoveWorkDir: opt.RemoveWorkDir,
499-
SkipPermissions: opt.SkipPermissions,
500-
EnableToolUseShim: opt.EnableToolUseShim,
501-
MCPClientEnabled: opt.MCPClient,
502-
RunOnce: opt.Quiet,
503-
InitialQuery: queryFromCmd,
504-
ChatMessageStore: chatStore,
505-
Sandbox: opt.Sandbox,
506-
SandboxImage: opt.SandboxImage,
507-
Session: session,
508-
SessionBackend: opt.SessionBackend,
509-
}
510-
511-
err = k8sAgent.Init(ctx)
512-
if err != nil {
513-
return fmt.Errorf("starting k8s agent: %w", err)
506+
defaultAgent, err = agentManager.GetAgent(ctx, session.ID)
507+
if err != nil {
508+
return fmt.Errorf("failed to get agent for session: %w", err)
509+
}
514510
}
515-
defer k8sAgent.Close()
516511

517512
var userInterface ui.UI
518513
switch opt.UIType {
519514
case ui.UITypeTerminal:
520515
// since stdin is already consumed, we use TTY for taking input from user
521516
useTTYForInput := hasInputData
522-
userInterface, err = ui.NewTerminalUI(k8sAgent, useTTYForInput, opt.ShowToolOutput, recorder)
517+
userInterface, err = ui.NewTerminalUI(defaultAgent, useTTYForInput, opt.ShowToolOutput, recorder)
523518
if err != nil {
524519
return fmt.Errorf("creating terminal UI: %w", err)
525520
}
526521
case ui.UITypeWeb:
527-
userInterface, err = html.NewHTMLUserInterface(k8sAgent, opt.UIListenAddress, recorder)
522+
userInterface, err = html.NewHTMLUserInterface(agentManager, sessionManager, opt.ModelID, opt.ProviderID, opt.UIListenAddress, recorder)
528523
if err != nil {
529524
return fmt.Errorf("creating web UI: %w", err)
530525
}
531526
case ui.UITypeTUI:
532-
userInterface = ui.NewTUI(k8sAgent)
527+
userInterface = ui.NewTUI(defaultAgent)
533528
default:
534529
return fmt.Errorf("ui-type mode %q is not known", opt.UIType)
535530
}
536531

537-
return repl(ctx, queryFromCmd, userInterface, k8sAgent)
532+
err = userInterface.Run(ctx)
533+
if err != nil && !errors.Is(err, context.Canceled) {
534+
return fmt.Errorf("running UI: %w", err)
535+
}
536+
537+
return nil
538538
}
539539

540540
func handleCustomTools(toolConfigPaths []string) error {
@@ -576,24 +576,6 @@ func handleCustomTools(toolConfigPaths []string) error {
576576
return nil
577577
}
578578

579-
// repl is a read-eval-print loop for the chat session.
580-
func repl(ctx context.Context, initialQuery string, ui ui.UI, agent *agent.Agent) error {
581-
query := initialQuery
582-
// Note: Initial greeting and MCP status are now handled by the agent itself
583-
// through the message-based system
584-
err := agent.Run(ctx, query)
585-
if err != nil {
586-
return fmt.Errorf("running agent: %w", err)
587-
}
588-
589-
err = ui.Run(ctx)
590-
if err != nil && !errors.Is(err, context.Canceled) {
591-
return fmt.Errorf("running UI: %w", err)
592-
}
593-
594-
return nil
595-
}
596-
597579
// Redirect standard log output to our custom klog writer
598580
// This is primarily to suppress warning messages from
599581
// genai library https://github.com/googleapis/go-genai/blob/6ac4afc0168762dc3b7a4d940fc463cc1854f366/types.go#L1633
@@ -608,7 +590,6 @@ func redirectStdLogToKlog() {
608590
// Define a custom writer that forwards messages to klog.Warning
609591
type klogWriter struct{}
610592

611-
// Implement the io.Writer interface
612593
func (writer klogWriter) Write(data []byte) (n int, err error) {
613594
// We trim the trailing newline because klog adds its own.
614595
message := string(bytes.TrimSuffix(data, []byte("\n")))
@@ -689,7 +670,11 @@ func resolveKubeConfigPath(opt *Options) error {
689670
if err != nil {
690671
return fmt.Errorf("failed to get user home directory: %w", err)
691672
}
692-
opt.KubeConfigPath = filepath.Join(home, ".kube", "config")
673+
defaultPath := filepath.Join(home, ".kube", "config")
674+
// Only use the default path if it exists
675+
if _, err := os.Stat(defaultPath); err == nil {
676+
opt.KubeConfigPath = defaultPath
677+
}
693678
}
694679

695680
// We resolve the kubeconfig path to an absolute path, so we can run kubectl from any working directory.
@@ -756,7 +741,6 @@ func handleDeleteSession(opt Options) error {
756741
return fmt.Errorf("failed to create session manager: %w", err)
757742
}
758743

759-
// Check if session exists
760744
session, err := manager.FindSessionByID(opt.DeleteSession)
761745
if err != nil {
762746
return fmt.Errorf("session %s not found: %w", opt.DeleteSession, err)

0 commit comments

Comments
 (0)