diff --git a/cmd/init.go b/cmd/init.go index 269e6e2..bd43200 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -5,9 +5,12 @@ import ( "os" "path/filepath" + "github.com/agenticgokit/agenticgokit/observability" "github.com/fatih/color" "github.com/rs/zerolog" "github.com/spf13/cobra" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" "github.com/agenticgokit/agk/pkg/scaffold" ) @@ -66,15 +69,29 @@ Examples: // runInitCommand executes the init command func runInitCommand(cmd *cobra.Command, args []string) error { + // Create observability span for command execution + tracer := observability.GetTracer("agk-cli") + ctx, span := tracer.Start(cmd.Context(), "agk.init") + defer span.End() + // Handle --list flag if initListTemplates { + span.SetAttributes(attribute.Bool("list_templates", true)) + span.SetStatus(codes.Ok, "listed templates") return listTemplates() } projectName := args[0] + span.SetAttributes( + attribute.String("project_name", projectName), + attribute.String("template", initTemplate), + attribute.Bool("force", initForce), + ) // Validate project name if err := validateProjectName(projectName); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "invalid project name") color.Red("✗ Invalid project name: %v", err) return err } @@ -83,21 +100,29 @@ func runInitCommand(cmd *cobra.Command, args []string) error { // Check if path already exists if _, err := os.Stat(projectPath); err == nil && !initForce { + err := fmt.Errorf("project directory already exists") + span.RecordError(err) + span.SetStatus(codes.Error, "directory exists") color.Red("✗ Directory already exists: %s", projectPath) color.Yellow("Use --force to overwrite") - return fmt.Errorf("project directory already exists") + return err } // Validate and get template type templateType, err := scaffold.ValidateTemplate(initTemplate) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "invalid template") color.Red("✗ %v", err) return err } + span.SetAttributes(attribute.String("template_type", string(templateType))) // Get template generator generator, err := scaffold.GetTemplateGenerator(templateType) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get generator") color.Red("✗ Failed to get template generator: %v", err) return err } @@ -121,7 +146,9 @@ func runInitCommand(cmd *cobra.Command, args []string) error { color.Cyan(" Files: %d | Features: %v\n", metadata.FileCount, metadata.Features) // Generate project using the template generator - if err := generator.Generate(cmd.Context(), opts); err != nil { + if err := generator.Generate(ctx, opts); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "generation failed") color.Red("✗ Project generation failed: %v", err) if logger != nil { logger.Error().Err(err).Msg("project generation failed") @@ -136,6 +163,13 @@ func runInitCommand(cmd *cobra.Command, args []string) error { // Print success message color.Green("\n✅ Project initialized successfully!\n") + // Record success metrics + span.SetAttributes( + attribute.Int("file_count", metadata.FileCount), + attribute.StringSlice("features", metadata.Features), + ) + span.SetStatus(codes.Ok, "project initialized") + // Print next steps printNextSteps(projectName, projectPath, templateType, metadata) diff --git a/cmd/root.go b/cmd/root.go index 05a59e3..2bded1c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,10 +2,12 @@ package cmd import ( + "context" "fmt" "os" "time" + "github.com/agenticgokit/agenticgokit/observability" "github.com/agenticgokit/agk/internal/utils" "github.com/rs/zerolog" "github.com/spf13/cobra" @@ -13,10 +15,16 @@ import ( ) var ( - cfgFile string - verbose bool - debug bool - logger *zerolog.Logger + cfgFile string + verbose bool + debug bool + trace bool + traceExporter string + traceEndpoint string + traceSample float64 + storePrompts bool + tracerShutdown func(context.Context) error + logger *zerolog.Logger ) // rootCmd represents the base command @@ -53,9 +61,65 @@ Get started with: agk init my-project`, *logger = logger.With().Timestamp().Logger() // Use RFC3339 time format consistently zerolog.TimeFieldFormat = time.RFC3339 + + // Initialize tracing if enabled + // Check both command flag and environment variable + trace = viper.GetBool("trace") + if !trace && os.Getenv("AGK_TRACE") == "true" { + trace = true + } + + traceExporter = viper.GetString("trace_exporter") + traceEndpoint = viper.GetString("trace_endpoint") + traceSample = viper.GetFloat64("trace_sample") + + if trace { + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + + runID := generateRunID() + ctx = observability.WithRunID(ctx, runID) + ctx = observability.WithLogger(ctx, logger) + cmd.SetContext(ctx) + + // For file exporter, create runs directory and trace file path + filePath := traceEndpoint + if traceExporter == "" || traceExporter == "file" { + if filePath == "" { + runDir := fmt.Sprintf(".agk/runs/%s", runID) + os.MkdirAll(runDir, 0755) + filePath = fmt.Sprintf("%s/trace.jsonl", runDir) + } + traceExporter = "file" + } + + cfg := observability.TracerConfig{ + ServiceName: "agk-cli", + ServiceVersion: Version, + Environment: viper.GetString("environment"), + Endpoint: traceEndpoint, + Exporter: traceExporter, + SampleRate: traceSample, + Debug: debug, + FilePath: filePath, + } + + tracerShutdown, err = observability.SetupTracer(ctx, cfg) + if err != nil { + logger.Error().Err(err).Msg("failed to set up tracer") + } + } }, PersistentPostRun: func(cmd *cobra.Command, args []string) { - // No cleanup required for zerolog + if tracerShutdown != nil { + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + _ = tracerShutdown(ctx) + } }, } @@ -71,10 +135,20 @@ func init() { rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.agk.toml)") rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output") rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "debug mode") + rootCmd.PersistentFlags().BoolVar(&trace, "trace", false, "enable tracing") + rootCmd.PersistentFlags().StringVar(&traceExporter, "trace-exporter", "console", "trace exporter: console|otlp|file") + rootCmd.PersistentFlags().StringVar(&traceEndpoint, "trace-endpoint", "", "OTLP endpoint URL or file path (for file exporter)") + rootCmd.PersistentFlags().Float64Var(&traceSample, "trace-sample", 1.0, "trace sample rate (0.0-1.0)") + rootCmd.PersistentFlags().BoolVar(&storePrompts, "store-prompts", false, "store prompts for debugging (if supported by commands)") // Bind flags to viper _ = viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose")) _ = viper.BindPFlag("debug", rootCmd.PersistentFlags().Lookup("debug")) + _ = viper.BindPFlag("trace", rootCmd.PersistentFlags().Lookup("trace")) + _ = viper.BindPFlag("trace_exporter", rootCmd.PersistentFlags().Lookup("trace-exporter")) + _ = viper.BindPFlag("trace_endpoint", rootCmd.PersistentFlags().Lookup("trace-endpoint")) + _ = viper.BindPFlag("trace_sample", rootCmd.PersistentFlags().Lookup("trace-sample")) + _ = viper.BindPFlag("store_prompts", rootCmd.PersistentFlags().Lookup("store-prompts")) } func initConfig() { @@ -89,8 +163,13 @@ func initConfig() { viper.SetConfigName(".agk") } + viper.SetEnvPrefix("AGK") viper.AutomaticEnv() + viper.SetDefault("trace_exporter", "console") + viper.SetDefault("trace_sample", 1.0) + viper.SetDefault("environment", "dev") + if err := viper.ReadInConfig(); err == nil && verbose { fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed()) } @@ -109,3 +188,7 @@ func GetLogger() *zerolog.Logger { } return logger } + +func generateRunID() string { + return fmt.Sprintf("run-%d", time.Now().UnixNano()) +} diff --git a/cmd/trace.go b/cmd/trace.go new file mode 100644 index 0000000..b6348f4 --- /dev/null +++ b/cmd/trace.go @@ -0,0 +1,789 @@ +package cmd + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/agenticgokit/agk/internal/tui" + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" +) + +// traceCmd represents the trace command +var traceCmd = &cobra.Command{ + Use: "trace", + Short: "Manage and view execution traces", + Long: `Manage and view execution traces from AgenticGoKit runs. + +Traces are automatically stored in .agk/runs// when AGK_TRACE=true. + +Examples: + agk trace # Launch interactive trace explorer + agk trace list # List all stored traces + agk trace show # Display trace details in TUI + agk trace view # Show run manifest/summary + agk trace export # Export trace for external tools +`, + RunE: func(cmd *cobra.Command, args []string) error { + return launchTraceExplorer() + }, +} + +// listCmd shows all stored traces +var listCmd = &cobra.Command{ + Use: "list", + Short: "List all stored traces", + RunE: func(cmd *cobra.Command, args []string) error { + return listTraces() + }, +} + +// showCmd displays trace details in interactive viewer +var showCmd = &cobra.Command{ + Use: "show [run-id]", + Short: "Show trace in interactive viewer", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + runID := "" + if len(args) > 0 { + runID = args[0] + } + return showTrace(runID) + }, +} + +// viewCmd shows run manifest/summary +var viewCmd = &cobra.Command{ + Use: "view [run-id]", + Short: "View run summary and manifest", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + runID := "" + if len(args) > 0 { + runID = args[0] + } + return viewRun(runID) + }, +} + +// exportCmd exports trace for external tools +var exportCmd = &cobra.Command{ + Use: "export [run-id]", + Short: "Export trace for external tools", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + runID := "" + if len(args) > 0 { + runID = args[0] + } + + format, _ := cmd.Flags().GetString("format") + output, _ := cmd.Flags().GetString("output") + + return exportTraceInternal(runID, format, output) + }, +} + +func init() { + rootCmd.AddCommand(traceCmd) + traceCmd.AddCommand(listCmd) + traceCmd.AddCommand(showCmd) + traceCmd.AddCommand(viewCmd) + traceCmd.AddCommand(exportCmd) + + // Export flags + exportCmd.Flags().String("format", "json", "Export format: json, jaeger, otel") + exportCmd.Flags().String("output", "", "Output file (default: stdout)") +} + +// TraceRun represents a stored trace run +type TraceRun struct { + RunID string `json:"run_id"` + Command string `json:"command"` + Status string `json:"status"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + Duration float64 `json:"duration_seconds"` + SpanCount int `json:"span_count"` + LLMCalls int `json:"llm_calls"` + TotalTokens int `json:"total_tokens"` + EstimatedCost float64 `json:"estimated_cost"` +} + +// launchTraceExplorer launches the unified trace explorer TUI +func launchTraceExplorer() error { + runsDir := ".agk/runs" + + // Check if directory exists + if _, err := os.Stat(runsDir); os.IsNotExist(err) { + fmt.Println("No traces found. Run with AGK_TRACE=true to generate traces.") + return nil + } + + entries, err := ioutil.ReadDir(runsDir) + if err != nil { + return fmt.Errorf("failed to read runs directory: %w", err) + } + + if len(entries) == 0 { + fmt.Println("No traces found. Run with AGK_TRACE=true to generate traces.") + return nil + } + + // Load all runs with their spans + var runDataList []tui.RunData + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + runPath := filepath.Join(runsDir, entry.Name()) + manifest, err := readManifest(runPath) + if err != nil { + continue + } + + // Read spans + tracePath := filepath.Join(runPath, "trace.jsonl") + data, err := ioutil.ReadFile(tracePath) + if err != nil { + continue + } + spans := tui.ParseSpans(string(data)) + + runDataList = append(runDataList, tui.RunData{ + Manifest: tui.TraceRun{ + RunID: manifest.RunID, + Command: manifest.Command, + Status: manifest.Status, + Duration: manifest.Duration, + SpanCount: manifest.SpanCount, + LLMCalls: manifest.LLMCalls, + TotalTokens: manifest.TotalTokens, + EstimatedCost: manifest.EstimatedCost, + }, + Spans: spans, + }) + } + + if len(runDataList) == 0 { + fmt.Println("No valid traces found.") + return nil + } + + // Sort by newest first (assuming run IDs contain timestamps) + sort.Slice(runDataList, func(i, j int) bool { + return runDataList[i].Manifest.RunID > runDataList[j].Manifest.RunID + }) + + // Create and run TUI explorer + model := tui.NewTraceExplorer(runDataList) + p := tea.NewProgram(model, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + return fmt.Errorf("failed to run TUI: %w", err) + } + + return nil +} + +func listTraces() error { + runsDir := ".agk/runs" + + // Create directory if it doesn't exist + if _, err := os.Stat(runsDir); os.IsNotExist(err) { + fmt.Println("No traces found. Run with AGK_TRACE=true to generate traces.") + return nil + } + + entries, err := ioutil.ReadDir(runsDir) + if err != nil { + return fmt.Errorf("failed to read runs directory: %w", err) + } + + if len(entries) == 0 { + fmt.Println("No traces found. Run with AGK_TRACE=true to generate traces.") + return nil + } + + // Parse all runs + var runs []TraceRun + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + manifest, err := readManifest(filepath.Join(runsDir, entry.Name())) + if err != nil { + continue // Skip runs without valid manifest + } + runs = append(runs, manifest) + } + + if len(runs) == 0 { + fmt.Println("No valid traces found.") + return nil + } + + // Sort by start time (newest first) + sort.Slice(runs, func(i, j int) bool { + return runs[i].StartTime.After(runs[j].StartTime) + }) + + // Print table + fmt.Println() + fmt.Printf("%-40s %-12s %-8s %-10s %-10s %-12s\n", + "Run ID", "Command", "Status", "Duration", "LLM Calls", "Tokens") + fmt.Println(strings.Repeat("-", 92)) + + for _, run := range runs { + status := "✅ OK" + if run.Status != "completed" && run.Status != "ok" { + status = "❌ ERROR" + } + + duration := fmt.Sprintf("%.2fs", run.Duration) + llmCalls := fmt.Sprintf("%d", run.LLMCalls) + tokens := fmt.Sprintf("%d", run.TotalTokens) + + fmt.Printf("%-40s %-12s %-8s %-10s %-10s %-12s\n", + run.RunID, run.Command, status, duration, llmCalls, tokens) + } + fmt.Println() + + return nil +} + +func showTrace(runID string) error { + runsDir := ".agk/runs" + + // If no run ID provided, use latest + if runID == "" { + runID = getLatestRunID(runsDir) + if runID == "" { + fmt.Println("No traces found. Run with AGK_TRACE=true to generate traces.") + return nil + } + } + + runPath := filepath.Join(runsDir, runID) + + // Check if run exists + if _, err := os.Stat(runPath); os.IsNotExist(err) { + return fmt.Errorf("trace not found: %s", runID) + } + + // Read trace file + tracePath := filepath.Join(runPath, "trace.jsonl") + data, err := ioutil.ReadFile(tracePath) + if err != nil { + return fmt.Errorf("failed to read trace: %w", err) + } + + // Parse spans using TUI package + spans := tui.ParseSpans(string(data)) + manifest, _ := readManifest(runPath) + + // Convert manifest to TUI format + tuiManifest := tui.TraceRun{ + RunID: manifest.RunID, + Command: manifest.Command, + Status: manifest.Status, + Duration: manifest.Duration, + SpanCount: manifest.SpanCount, + LLMCalls: manifest.LLMCalls, + TotalTokens: manifest.TotalTokens, + EstimatedCost: manifest.EstimatedCost, + } + + // Create and run TUI + model := tui.NewTraceViewer(runID, tuiManifest, spans) + p := tea.NewProgram(model, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + return fmt.Errorf("failed to run TUI: %w", err) + } + + return nil +} + +func viewRun(runID string) error { + runsDir := ".agk/runs" + + // If no run ID provided, use latest + if runID == "" { + runID = getLatestRunID(runsDir) + if runID == "" { + fmt.Println("No traces found. Run with AGK_TRACE=true to generate traces.") + return nil + } + } + + runPath := filepath.Join(runsDir, runID) + + // Check if run exists + if _, err := os.Stat(runPath); os.IsNotExist(err) { + return fmt.Errorf("trace not found: %s", runID) + } + + manifest, err := readManifest(runPath) + if err != nil { + return fmt.Errorf("failed to read manifest: %w", err) + } + + // Display manifest + fmt.Println() + fmt.Printf("Run Information\n") + fmt.Println(strings.Repeat("─", 60)) + fmt.Printf("Run ID: %s\n", manifest.RunID) + fmt.Printf("Command: %s\n", manifest.Command) + fmt.Printf("Status: ✅ %s\n", manifest.Status) + fmt.Printf("Started: %s\n", manifest.StartTime.Format("2006-01-02 15:04:05")) + fmt.Printf("Completed: %s\n", manifest.EndTime.Format("2006-01-02 15:04:05")) + fmt.Printf("Duration: %.2fs\n", manifest.Duration) + fmt.Println() + fmt.Printf("Execution Stats\n") + fmt.Println(strings.Repeat("─", 60)) + fmt.Printf("Spans: %d\n", manifest.SpanCount) + fmt.Printf("LLM Calls: %d\n", manifest.LLMCalls) + fmt.Printf("Total Tokens: %d\n", manifest.TotalTokens) + fmt.Printf("Estimated Cost: $%.4f\n", manifest.EstimatedCost) + fmt.Println() + fmt.Printf("Files\n") + fmt.Println(strings.Repeat("─", 60)) + fmt.Printf("Trace: %s/trace.jsonl\n", runPath) + fmt.Printf("Events: %s/events.jsonl\n", runPath) + fmt.Printf("Manifest: %s/manifest.json\n", runPath) + fmt.Println() + + return nil +} + +func exportTraceInternal(runID, format, output string) error { + runsDir := ".agk/runs" + + // If no run ID provided, use latest + if runID == "" { + runID = getLatestRunID(runsDir) + if runID == "" { + fmt.Println("No traces found. Run with AGK_TRACE=true to generate traces.") + return nil + } + } + + runPath := filepath.Join(runsDir, runID) + tracePath := filepath.Join(runPath, "trace.jsonl") + + // Read trace data + data, err := ioutil.ReadFile(tracePath) + if err != nil { + return fmt.Errorf("failed to read trace: %w", err) + } + + // Parse JSONL into spans + lines := strings.Split(string(data), "\n") + var spans []map[string]interface{} + for _, line := range lines { + if line == "" { + continue + } + var span map[string]interface{} + if err := json.Unmarshal([]byte(line), &span); err != nil { + continue + } + spans = append(spans, span) + } + + // Format and export based on format flag + var exportData interface{} + + switch format { + case "json": + // Raw JSONL as JSON array + exportData = spans + + case "jaeger": + // Convert to Jaeger format + exportData = convertToJaegerFormat(spans, runID) + + case "otel", "otlp": + // Convert to OpenTelemetry format + exportData = convertToOTLPFormat(spans, runID) + + default: + return fmt.Errorf("unknown format: %s (supported: json, jaeger, otel)", format) + } + + // Marshal data + exportBytes, err := json.MarshalIndent(exportData, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal data: %w", err) + } + + // Write output + if output != "" { + if err := ioutil.WriteFile(output, exportBytes, 0644); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + fmt.Printf("✅ Exported trace to %s (format: %s)\n", output, format) + } else { + fmt.Println(string(exportBytes)) + } + + return nil +} + +// convertToJaegerFormat converts OpenTelemetry spans to Jaeger format +func convertToJaegerFormat(spans []map[string]interface{}, runID string) map[string]interface{} { + jaegerSpans := make([]map[string]interface{}, 0) + + for _, span := range spans { + jaegerSpan := map[string]interface{}{} + + // Extract and map fields + if traceID, ok := span["SpanContext"].(map[string]interface{})["TraceID"]; ok { + jaegerSpan["traceID"] = traceID + } + if spanID, ok := span["SpanContext"].(map[string]interface{})["SpanID"]; ok { + jaegerSpan["spanID"] = spanID + } + if name, ok := span["Name"]; ok { + jaegerSpan["operationName"] = name + } + if startTime, ok := span["StartTime"]; ok { + jaegerSpan["startTime"] = startTime + } + if endTime, ok := span["EndTime"]; ok { + jaegerSpan["endTime"] = endTime + } + + // Map attributes to tags + if attrs, ok := span["Attributes"].([]interface{}); ok { + tags := make([]map[string]interface{}, 0) + for _, attr := range attrs { + if attrMap, ok := attr.(map[string]interface{}); ok { + tag := map[string]interface{}{ + "key": attrMap["Key"], + "value": attrMap["Value"], + } + tags = append(tags, tag) + } + } + jaegerSpan["tags"] = tags + } + + jaegerSpans = append(jaegerSpans, jaegerSpan) + } + + return map[string]interface{}{ + "traceID": getTraceID(spans), + "spans": jaegerSpans, + } +} + +// convertToOTLPFormat converts to OpenTelemetry Protocol format +func convertToOTLPFormat(spans []map[string]interface{}, runID string) map[string]interface{} { + return map[string]interface{}{ + "resourceSpans": []map[string]interface{}{ + { + "resource": map[string]interface{}{ + "attributes": []map[string]interface{}{ + { + "key": "service.name", + "value": map[string]interface{}{ + "stringValue": "agenticgokit", + }, + }, + { + "key": "service.version", + "value": map[string]interface{}{ + "stringValue": "0.6.0", + }, + }, + }, + }, + "scopeSpans": []map[string]interface{}{ + { + "scope": map[string]interface{}{ + "name": "agenticgokit", + }, + "spans": spans, + }, + }, + }, + }, + } +} + +// getTraceID extracts the trace ID from spans +func getTraceID(spans []map[string]interface{}) string { + if len(spans) > 0 { + if spanCtx, ok := spans[0]["SpanContext"].(map[string]interface{}); ok { + if traceID, ok := spanCtx["TraceID"]; ok { + return traceID.(string) + } + } + } + return "" +} + +// Helper functions + +func readManifest(runPath string) (TraceRun, error) { + // First try to read manifest.json if it exists + manifestPath := filepath.Join(runPath, "manifest.json") + data, err := ioutil.ReadFile(manifestPath) + if err == nil { + var manifest TraceRun + if err := json.Unmarshal(data, &manifest); err == nil { + return manifest, nil + } + } + + // Fallback: parse trace.jsonl and create synthetic manifest + return parseTraceFile(runPath) +} + +// parseTraceFile reads trace.jsonl and creates a TraceRun from the trace data +func parseTraceFile(runPath string) (TraceRun, error) { + tracePath := filepath.Join(runPath, "trace.jsonl") + data, err := ioutil.ReadFile(tracePath) + if err != nil { + return TraceRun{}, fmt.Errorf("no trace file found: %w", err) + } + + runID := filepath.Base(runPath) + spanCount := 0 + llmCalls := 0 + totalTokens := 0 + + // Parse JSONL to extract span information + scanner := bufio.NewScanner(bytes.NewReader(data)) + var firstSpan, lastSpan time.Time + + for scanner.Scan() { + line := scanner.Bytes() + var span map[string]interface{} + if err := json.Unmarshal(line, &span); err != nil { + continue + } + + spanCount++ + + // Check if this is an LLM span + if spanName, ok := span["Name"].(string); ok { + if strings.Contains(spanName, "llm") { + llmCalls++ + } + } + + // Extract token count from attributes + if attrs, ok := span["Attributes"].([]interface{}); ok { + for _, attr := range attrs { + if attrMap, ok := attr.(map[string]interface{}); ok { + if key, ok := attrMap["Key"].(string); ok { + // Look for token-related attributes + if key == "llm.usage.completion_tokens" || key == "llm.completion_tokens" { + if val, ok := attrMap["Value"].(map[string]interface{}); ok { + if tokenVal, ok := val["Value"]; ok { + if tokenInt, err := toInt64(tokenVal); err == nil { + totalTokens += int(tokenInt) + } + } + } + } + } + } + } + } + + // Extract start and end times from span + // Format: "2026-01-19T18:36:38.897+09:00" + if st, ok := span["StartTime"].(string); ok { + // Try to parse with timezone + if t, err := time.Parse(time.RFC3339, st); err == nil { + if firstSpan.IsZero() || t.Before(firstSpan) { + firstSpan = t + } + if t.After(lastSpan) { + lastSpan = t + } + } + } + + // Also check EndTime to get the latest time + if et, ok := span["EndTime"].(string); ok { + if t, err := time.Parse(time.RFC3339, et); err == nil { + if t.After(lastSpan) { + lastSpan = t + } + } + } + } + + if firstSpan.IsZero() { + firstSpan = time.Now() + } + if lastSpan.IsZero() { + lastSpan = firstSpan + } + + // Parse run ID to extract command name + // Format: run-{timestamp} or run-{timestamp}-{command} + command := "agent" + if parts := strings.Split(runID, "-"); len(parts) > 2 { + command = strings.Join(parts[2:], "-") + } + + durationSeconds := lastSpan.Sub(firstSpan).Seconds() + estimatedCost := float64(totalTokens) * 0.00001 // Rough estimate + + return TraceRun{ + RunID: runID, + Command: command, + Status: "completed", + StartTime: firstSpan, + EndTime: lastSpan, + Duration: durationSeconds, + SpanCount: spanCount, + LLMCalls: llmCalls, + TotalTokens: totalTokens, + EstimatedCost: estimatedCost, + }, nil +} + +// toInt64 safely converts a value to int64 +func toInt64(v interface{}) (int64, error) { + switch val := v.(type) { + case float64: + return int64(val), nil + case int: + return int64(val), nil + case int64: + return val, nil + case string: + i, err := strconv.ParseInt(val, 10, 64) + return i, err + default: + return 0, fmt.Errorf("cannot convert %T to int64", v) + } +} + +func getLatestRunID(runsDir string) string { + entries, err := ioutil.ReadDir(runsDir) + if err != nil { + return "" + } + + var latest os.FileInfo + for _, entry := range entries { + if entry.IsDir() && strings.HasPrefix(entry.Name(), "run-") { + if latest == nil || entry.ModTime().After(latest.ModTime()) { + latest = entry + } + } + } + + if latest != nil { + return latest.Name() + } + return "" +} + +type Span struct { + Name string `json:"Name"` + StartTime string `json:"StartTime"` + EndTime string `json:"EndTime"` + Attributes []map[string]interface{} `json:"Attributes,omitempty"` + ParentSpanID string `json:"ParentSpanId,omitempty"` + SpanID string `json:"SpanId"` + SpanKind int `json:"SpanKind"` + Status map[string]interface{} `json:"Status"` + ChildSpanCount int `json:"ChildSpanCount"` + InstrumentationScope map[string]interface{} `json:"InstrumentationScope"` +} + +func parseSpans(data string) []Span { + var spans []Span + lines := strings.Split(data, "\n") + + for _, line := range lines { + if line == "" { + continue + } + var span Span + if err := json.Unmarshal([]byte(line), &span); err != nil { + continue + } + spans = append(spans, span) + } + + return spans +} + +func displaySpanTree(spans []Span) { + // Display spans with details + for i, span := range spans { + indent := " " + prefix := "├── " + if i == len(spans)-1 { + prefix = "└── " + } + + // Calculate duration + duration := "0ms" + if span.StartTime != "" && span.EndTime != "" { + startTime, _ := time.Parse(time.RFC3339, span.StartTime) + endTime, _ := time.Parse(time.RFC3339, span.EndTime) + durationMs := endTime.Sub(startTime).Milliseconds() + duration = fmt.Sprintf("%dms", durationMs) + } + + // Display span name and duration + fmt.Printf("║ %s%s%s (%s)\n", indent, prefix, span.Name, duration) + + // Display key attributes + if len(span.Attributes) > 0 { + for attrIdx, attr := range span.Attributes { + if key, ok := attr["Key"].(string); ok { + if value, ok := attr["Value"].(map[string]interface{}); ok { + if val, ok := value["Value"]; ok { + // Filter to show important attributes for all span types + shouldDisplay := strings.Contains(key, "llm") || + strings.Contains(key, "tokens") || + strings.Contains(key, "http") || + strings.Contains(key, "error") || + strings.Contains(key, "tool") || + strings.Contains(key, "mcp") || + strings.Contains(key, "workflow") || + strings.Contains(key, "subworkflow") + + if shouldDisplay { + isLast := attrIdx == len(span.Attributes)-1 + subPrefix := "├─ " + if isLast { + subPrefix = "└─ " + } + fmt.Printf("║ %s %s%s: %v\n", indent, subPrefix, key, val) + } + } + } + } + } + } + + // Show span status if error + if span.Status != nil { + if code, ok := span.Status["Code"].(string); ok && code != "Ok" { + fmt.Printf("║ %s └─ status: %s\n", indent, code) + } + } + } +} diff --git a/go.mod b/go.mod index d1a2fc1..d3993b0 100644 --- a/go.mod +++ b/go.mod @@ -4,34 +4,72 @@ go 1.24.1 replace github.com/agenticgokit/agenticgokit/v1beta => ../agenticgokit +replace github.com/agenticgokit/agenticgokit => ../agenticgokit + require ( + github.com/agenticgokit/agenticgokit v0.0.0 github.com/fatih/color v1.14.1 - github.com/rs/zerolog v1.33.0 + github.com/rs/zerolog v1.34.0 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.18.0 ) require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.2 // indirect + github.com/charmbracelet/bubbles v0.21.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/pelletier/go-toml/v2 v2.1.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.6 // indirect - github.com/stretchr/testify v1.10.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/sdk v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.opentelemetry.io/proto/otlp v1.7.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20231127185646-65229373498e // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/text v0.24.0 // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.26.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/grpc v1.73.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 21ca35d..3a92dba 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,49 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= +github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w= github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -21,26 +52,42 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= -github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= @@ -68,20 +115,57 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 h1:bDMKF3RUSxshZ5OjOTi8rsHGaPKsAt76FaqgvIUySLc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0/go.mod h1:dDT67G/IkA46Mr2l9Uj7HsQVwsjASyV9SjGofsiUZDA= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0 h1:SNhVp/9q4Go/XHBkQ1/d5u9P/U+L1yaGPoi0x+mStaI= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0/go.mod h1:tx8OOlGH6R4kLV67YaYO44GFXloEjGPZuMjEkaaqIp4= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= +go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/exp v0.0.0-20231127185646-65229373498e h1:Gvh4YaCaXNs6dKTlfgismwWZKyjVZXwOPfIyUaqU3No= golang.org/x/exp v0.0.0-20231127185646-65229373498e/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= +google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/tui/span_tree.go b/internal/tui/span_tree.go new file mode 100644 index 0000000..a1425b5 --- /dev/null +++ b/internal/tui/span_tree.go @@ -0,0 +1,255 @@ +package tui + +import ( + "encoding/json" + "strings" + "time" +) + +// Span represents a parsed OpenTelemetry span +type Span struct { + Name string `json:"Name"` + StartTime string `json:"StartTime"` + EndTime string `json:"EndTime"` + Attributes []map[string]interface{} `json:"Attributes,omitempty"` + SpanContext SpanContext `json:"SpanContext"` + Parent ParentSpan `json:"Parent"` + SpanKind int `json:"SpanKind"` + Status SpanStatus `json:"Status"` + ChildSpanCount int `json:"ChildSpanCount"` + InstrumentationScope map[string]interface{} `json:"InstrumentationScope"` +} + +// SpanContext contains span identification +type SpanContext struct { + TraceID string `json:"TraceID"` + SpanID string `json:"SpanID"` +} + +// ParentSpan contains parent span reference +type ParentSpan struct { + TraceID string `json:"TraceID"` + SpanID string `json:"SpanID"` +} + +// SpanStatus contains span status +type SpanStatus struct { + Code string `json:"Code"` + Description string `json:"Description,omitempty"` +} + +// SpanNode represents a span in the hierarchical tree +type SpanNode struct { + Span Span + Children []*SpanNode + Depth int + Expanded bool + Parent *SpanNode + DurationMs int64 +} + +// ParseSpans parses JSONL trace data into spans +func ParseSpans(data string) []Span { + var spans []Span + lines := strings.Split(data, "\n") + + for _, line := range lines { + if line == "" { + continue + } + var span Span + if err := json.Unmarshal([]byte(line), &span); err != nil { + continue + } + spans = append(spans, span) + } + + return spans +} + +// BuildSpanTree builds a hierarchical tree from flat span list +func BuildSpanTree(spans []Span) []*SpanNode { + // Create node map + nodeMap := make(map[string]*SpanNode) + for i := range spans { + node := &SpanNode{ + Span: spans[i], + Children: make([]*SpanNode, 0), + Expanded: true, // Start expanded + DurationMs: calculateDuration(spans[i].StartTime, spans[i].EndTime), + } + nodeMap[spans[i].SpanContext.SpanID] = node + } + + // Build tree structure + var roots []*SpanNode + for _, node := range nodeMap { + parentID := node.Span.Parent.SpanID + if parentID == "" || parentID == "0000000000000000" { + // This is a root span + node.Depth = 0 + roots = append(roots, node) + } else if parent, ok := nodeMap[parentID]; ok { + // Has a parent in our span set + node.Parent = parent + parent.Children = append(parent.Children, node) + } else { + // Parent not found, treat as root + node.Depth = 0 + roots = append(roots, node) + } + } + + // Calculate depths + for _, root := range roots { + setDepths(root, 0) + } + + // Sort roots by start time + sortNodesByTime(roots) + + return roots +} + +// setDepths recursively sets node depths +func setDepths(node *SpanNode, depth int) { + node.Depth = depth + sortNodesByTime(node.Children) + for _, child := range node.Children { + setDepths(child, depth+1) + } +} + +// sortNodesByTime sorts nodes by start time +func sortNodesByTime(nodes []*SpanNode) { + for i := 0; i < len(nodes)-1; i++ { + for j := i + 1; j < len(nodes); j++ { + t1, _ := time.Parse(time.RFC3339, nodes[i].Span.StartTime) + t2, _ := time.Parse(time.RFC3339, nodes[j].Span.StartTime) + if t1.After(t2) { + nodes[i], nodes[j] = nodes[j], nodes[i] + } + } + } +} + +// FlattenTree returns a flat list of visible nodes for display +func FlattenTree(roots []*SpanNode) []*SpanNode { + var result []*SpanNode + for _, root := range roots { + flattenNode(root, &result) + } + return result +} + +func flattenNode(node *SpanNode, result *[]*SpanNode) { + *result = append(*result, node) + if node.Expanded { + for _, child := range node.Children { + flattenNode(child, result) + } + } +} + +// calculateDuration calculates duration in milliseconds +func calculateDuration(startTime, endTime string) int64 { + if startTime == "" || endTime == "" { + return 0 + } + start, err := time.Parse(time.RFC3339, startTime) + if err != nil { + return 0 + } + end, err := time.Parse(time.RFC3339, endTime) + if err != nil { + return 0 + } + return end.Sub(start).Milliseconds() +} + +// GetAttribute gets an attribute value by key +func (s *Span) GetAttribute(key string) (interface{}, bool) { + for _, attr := range s.Attributes { + if k, ok := attr["Key"].(string); ok && k == key { + if value, ok := attr["Value"].(map[string]interface{}); ok { + if val, ok := value["Value"]; ok { + return val, true + } + } + } + } + return nil, false +} + +// GetImportantAttributes returns filtered important attributes +func (s *Span) GetImportantAttributes() map[string]interface{} { + important := make(map[string]interface{}) + importantKeys := []string{ + "agk.llm.provider", "agk.llm.model", "agk.llm.max_tokens", "agk.llm.temperature", + "agk.stream.tokens", "agk.llm.latency_ms", + "agk.workflow.step_name", "agk.workflow.step_index", "agk.workflow.mode", + "agk.workflow.success", "agk.workflow.latency_ms", "agk.workflow.id", + "agk.tools.count", "agk.tool.name", + "http.status_code", "llm.streaming", + "error.message", "error.type", + } + + for _, attr := range s.Attributes { + if key, ok := attr["Key"].(string); ok { + for _, impKey := range importantKeys { + if key == impKey { + if value, ok := attr["Value"].(map[string]interface{}); ok { + if val, ok := value["Value"]; ok { + important[key] = val + } + } + break + } + } + } + } + + return important +} + +// GetAllAttributes returns all attributes as key-value pairs +func (s *Span) GetAllAttributes() map[string]interface{} { + attrs := make(map[string]interface{}) + for _, attr := range s.Attributes { + if key, ok := attr["Key"].(string); ok { + if value, ok := attr["Value"].(map[string]interface{}); ok { + if val, ok := value["Value"]; ok { + attrs[key] = val + } + } + } + } + return attrs +} + +// HasChildren returns true if the span has children +func (n *SpanNode) HasChildren() bool { + return len(n.Children) > 0 +} + +// ToggleExpanded toggles the expanded state +func (n *SpanNode) ToggleExpanded() { + n.Expanded = !n.Expanded +} + +// GetSpanType returns the type of span for styling +func (s *Span) GetSpanType() string { + name := strings.ToLower(s.Name) + switch { + case strings.Contains(name, "workflow"): + return "workflow" + case strings.Contains(name, "agent"): + return "agent" + case strings.Contains(name, "llm"): + return "llm" + case strings.Contains(name, "tool"), strings.Contains(name, "mcp"): + return "tool" + default: + return "other" + } +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..bd1e0f0 --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,134 @@ +// Package tui provides interactive terminal UI components for agk CLI. +package tui + +import "github.com/charmbracelet/lipgloss" + +// Color palette +var ( + primaryColor = lipgloss.Color("#7C3AED") // Purple + secondaryColor = lipgloss.Color("#06B6D4") // Cyan + successColor = lipgloss.Color("#10B981") // Green + errorColor = lipgloss.Color("#EF4444") // Red + warningColor = lipgloss.Color("#F59E0B") // Amber + mutedColor = lipgloss.Color("#6B7280") // Gray + accentColor = lipgloss.Color("#F472B6") // Pink +) + +// Box styles +var ( + // BoxStyle is the main container style + BoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(primaryColor). + Padding(0, 1) + + // HeaderStyle for headers + HeaderStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(primaryColor). + Padding(0, 1) + + // TitleStyle for main titles + TitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + Background(primaryColor). + Padding(0, 2) +) + +// Text styles +var ( + // SelectedStyle for selected items + SelectedStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + Background(secondaryColor) + + // CursorStyle for the cursor indicator + CursorStyle = lipgloss.NewStyle(). + Foreground(secondaryColor). + Bold(true) + + // MutedStyle for less important text + MutedStyle = lipgloss.NewStyle(). + Foreground(mutedColor) + + // SuccessStyle for success indicators + SuccessStyle = lipgloss.NewStyle(). + Foreground(successColor) + + // ErrorStyle for error indicators + ErrorStyle = lipgloss.NewStyle(). + Foreground(errorColor) + + // WarningStyle for warnings + WarningStyle = lipgloss.NewStyle(). + Foreground(warningColor) + + // DurationStyle for duration values + DurationStyle = lipgloss.NewStyle(). + Foreground(accentColor) + + // AttributeKeyStyle for attribute keys + AttributeKeyStyle = lipgloss.NewStyle(). + Foreground(secondaryColor) + + // AttributeValueStyle for attribute values + AttributeValueStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")) +) + +// Span type styles +var ( + WorkflowSpanStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#8B5CF6")) // Violet + + AgentSpanStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#3B82F6")) // Blue + + LLMSpanStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#10B981")) // Emerald + + ToolSpanStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#F59E0B")) // Amber +) + +// Help bar style +var ( + HelpStyle = lipgloss.NewStyle(). + Foreground(mutedColor). + Padding(0, 1) + + HelpKeyStyle = lipgloss.NewStyle(). + Foreground(secondaryColor). + Bold(true) +) + +// GetSpanStyle returns the appropriate style based on span name +func GetSpanStyle(spanName string) lipgloss.Style { + switch { + case contains(spanName, "workflow"): + return WorkflowSpanStyle + case contains(spanName, "agent"): + return AgentSpanStyle + case contains(spanName, "llm"): + return LLMSpanStyle + case contains(spanName, "tool"), contains(spanName, "mcp"): + return ToolSpanStyle + default: + return lipgloss.NewStyle() + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr)) +} + +func containsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/tui/trace_viewer.go b/internal/tui/trace_viewer.go new file mode 100644 index 0000000..300ae0d --- /dev/null +++ b/internal/tui/trace_viewer.go @@ -0,0 +1,795 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" +) + +// ViewMode represents the current viewing mode +type ViewMode int + +const ( + RunListView ViewMode = iota + TreeView + DetailView +) + +// TraceRun contains trace run metadata +type TraceRun struct { + RunID string + Command string + Status string + Duration float64 + SpanCount int + LLMCalls int + TotalTokens int + EstimatedCost float64 +} + +// RunData contains a run with its parsed spans +type RunData struct { + Manifest TraceRun + Spans []Span +} + +// Model is the main bubbletea model for the trace viewer +type Model struct { + // Multi-run support + allRuns []RunData + runCursor int + selectedRun int + + // Current run data + runID string + manifest TraceRun + roots []*SpanNode + visibleNodes []*SpanNode + cursor int + viewMode ViewMode + viewport viewport.Model + ready bool + width int + height int + // Computed metrics + totalTokens int + estimatedCost float64 + errorCount int + slowestSpan *SpanNode + top3Slowest []*SpanNode +} + +// NewTraceViewer creates a new trace viewer model +func NewTraceViewer(runID string, manifest TraceRun, spans []Span) Model { + roots := BuildSpanTree(spans) + visible := FlattenTree(roots) + + // Compute metrics from spans + var totalTokens int + var errorCount int + var slowest *SpanNode + top3 := make([]*SpanNode, 0, 3) + + for _, node := range visible { + attrs := node.Span.GetAllAttributes() + + // Count tokens (from various possible attribute names) + if tokens, ok := attrs["agk.stream.tokens"]; ok { + if t, ok := tokens.(float64); ok { + totalTokens += int(t) + } + } + if tokens, ok := attrs["llm.usage.total_tokens"]; ok { + if t, ok := tokens.(float64); ok { + totalTokens += int(t) + } + } + + // Count errors + if node.Span.Status.Code != "" && node.Span.Status.Code != "Unset" && node.Span.Status.Code != "Ok" { + errorCount++ + } + + // Track slowest spans (only leaf nodes or LLM spans) + if !node.HasChildren() || strings.Contains(strings.ToLower(node.Span.Name), "llm") { + if slowest == nil || node.DurationMs > slowest.DurationMs { + slowest = node + } + // Insert into top 3 + inserted := false + for i, s := range top3 { + if node.DurationMs > s.DurationMs { + // Insert at position i + top3 = append(top3[:i], append([]*SpanNode{node}, top3[i:]...)...) + inserted = true + break + } + } + if !inserted && len(top3) < 3 { + top3 = append(top3, node) + } + if len(top3) > 3 { + top3 = top3[:3] + } + } + } + + // Estimate cost (rough: $0.002 per 1K tokens for GPT-3.5 class) + estimatedCost := float64(totalTokens) * 0.000002 + + return Model{ + runID: runID, + manifest: manifest, + roots: roots, + visibleNodes: visible, + cursor: 0, + viewMode: TreeView, + totalTokens: totalTokens, + estimatedCost: estimatedCost, + errorCount: errorCount, + slowestSpan: slowest, + top3Slowest: top3, + } +} + +// NewTraceExplorer creates a trace explorer with multiple runs (for `agk trace` command) +func NewTraceExplorer(runs []RunData) Model { + m := Model{ + allRuns: runs, + runCursor: 0, + viewMode: RunListView, + } + + // If we have runs, prepare the first one + if len(runs) > 0 { + m.loadRun(0) + } + + return m +} + +// loadRun loads a specific run's data into the model +func (m *Model) loadRun(index int) { + if index < 0 || index >= len(m.allRuns) { + return + } + + run := m.allRuns[index] + m.selectedRun = index + m.runID = run.Manifest.RunID + m.manifest = run.Manifest + m.roots = BuildSpanTree(run.Spans) + m.visibleNodes = FlattenTree(m.roots) + m.cursor = 0 + + // Recompute metrics + m.computeMetrics() +} + +// computeMetrics calculates metrics for the current run +func (m *Model) computeMetrics() { + var totalTokens int + var errorCount int + var slowest *SpanNode + top3 := make([]*SpanNode, 0, 3) + + for _, node := range m.visibleNodes { + attrs := node.Span.GetAllAttributes() + + if tokens, ok := attrs["agk.stream.tokens"]; ok { + if t, ok := tokens.(float64); ok { + totalTokens += int(t) + } + } + if tokens, ok := attrs["llm.usage.total_tokens"]; ok { + if t, ok := tokens.(float64); ok { + totalTokens += int(t) + } + } + + if node.Span.Status.Code != "" && node.Span.Status.Code != "Unset" && node.Span.Status.Code != "Ok" { + errorCount++ + } + + if !node.HasChildren() || strings.Contains(strings.ToLower(node.Span.Name), "llm") { + if slowest == nil || node.DurationMs > slowest.DurationMs { + slowest = node + } + inserted := false + for i, s := range top3 { + if node.DurationMs > s.DurationMs { + top3 = append(top3[:i], append([]*SpanNode{node}, top3[i:]...)...) + inserted = true + break + } + } + if !inserted && len(top3) < 3 { + top3 = append(top3, node) + } + if len(top3) > 3 { + top3 = top3[:3] + } + } + } + + m.totalTokens = totalTokens + m.estimatedCost = float64(totalTokens) * 0.000002 + m.errorCount = errorCount + m.slowestSpan = slowest + m.top3Slowest = top3 +} + +// Init initializes the model +func (m Model) Init() tea.Cmd { + return nil +} + +// Update handles messages +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch m.viewMode { + case RunListView: + return m.updateRunListView(msg) + case TreeView: + return m.updateTreeView(msg) + case DetailView: + return m.updateDetailView(msg) + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + if !m.ready { + m.viewport = viewport.New(msg.Width-4, msg.Height-10) + m.viewport.YPosition = 0 + m.ready = true + } else { + m.viewport.Width = msg.Width - 4 + m.viewport.Height = msg.Height - 10 + } + } + + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +// updateRunListView handles input in run list view +func (m Model) updateRunListView(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + + case "up", "k": + if m.runCursor > 0 { + m.runCursor-- + } + + case "down", "j": + if m.runCursor < len(m.allRuns)-1 { + m.runCursor++ + } + + case "enter", "l", "right": + if m.runCursor < len(m.allRuns) { + m.loadRun(m.runCursor) + m.viewMode = TreeView + } + } + + return m, nil +} + +func (m Model) updateTreeView(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + + case "esc", "backspace": + // Go back to run list (if we have multiple runs) + if len(m.allRuns) > 0 { + m.viewMode = RunListView + return m, nil + } + return m, tea.Quit + + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + + case "down", "j": + if m.cursor < len(m.visibleNodes)-1 { + m.cursor++ + } + + case "enter", "l", "right": + if m.cursor < len(m.visibleNodes) { + node := m.visibleNodes[m.cursor] + if node.HasChildren() { + node.ToggleExpanded() + m.visibleNodes = FlattenTree(m.roots) + } else { + // Show detail view for leaf nodes + m.viewMode = DetailView + m.updateDetailViewport() + } + } + + case "h", "left": + if m.cursor < len(m.visibleNodes) { + node := m.visibleNodes[m.cursor] + if node.HasChildren() && node.Expanded { + node.Expanded = false + m.visibleNodes = FlattenTree(m.roots) + } else if node.Parent != nil { + // Navigate to parent + for i, n := range m.visibleNodes { + if n == node.Parent { + m.cursor = i + break + } + } + } + } + + case " ": + // Toggle expand/collapse with space + if m.cursor < len(m.visibleNodes) { + node := m.visibleNodes[m.cursor] + if node.HasChildren() { + node.ToggleExpanded() + m.visibleNodes = FlattenTree(m.roots) + } + } + + case "d": + // Show details + if m.cursor < len(m.visibleNodes) { + m.viewMode = DetailView + m.updateDetailViewport() + } + + case "[": + // Previous run + if len(m.allRuns) > 0 && m.selectedRun > 0 { + m.selectedRun-- + m.runCursor = m.selectedRun + m.loadRun(m.selectedRun) + } + + case "]": + // Next run + if len(m.allRuns) > 0 && m.selectedRun < len(m.allRuns)-1 { + m.selectedRun++ + m.runCursor = m.selectedRun + m.loadRun(m.selectedRun) + } + } + + return m, nil +} + +func (m Model) updateDetailView(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + + case "esc", "backspace", "h", "left": + m.viewMode = TreeView + return m, nil + } + + // Let viewport handle scrolling + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m *Model) updateDetailViewport() { + if m.cursor >= len(m.visibleNodes) { + return + } + node := m.visibleNodes[m.cursor] + content := m.renderDetailContent(node) + m.viewport.SetContent(content) +} + +// View renders the model +func (m Model) View() string { + if !m.ready { + return "Loading..." + } + + switch m.viewMode { + case RunListView: + return m.renderRunListView() + case TreeView: + return m.renderTreeView() + case DetailView: + return m.renderDetailView() + default: + return m.renderRunListView() + } +} + +func (m Model) renderRunListView() string { + var b strings.Builder + + // Title + b.WriteString(HeaderStyle.Render("Trace Runs")) + b.WriteString("\n") + b.WriteString(strings.Repeat("─", m.width-6)) + b.WriteString("\n\n") + + if len(m.allRuns) == 0 { + b.WriteString(MutedStyle.Render("No traces found. Run with AGK_TRACE=true to generate traces.")) + b.WriteString("\n") + } else { + // Calculate visible area + maxVisible := m.height - 8 + if maxVisible < 5 { + maxVisible = 5 + } + + // Scroll offset + scrollOffset := 0 + if m.runCursor >= maxVisible { + scrollOffset = m.runCursor - maxVisible + 1 + } + + for i, run := range m.allRuns { + if i < scrollOffset || i >= scrollOffset+maxVisible { + continue + } + + // Status + status := SuccessStyle.Render("[OK]") + if run.Manifest.Status != "completed" && run.Manifest.Status != "ok" { + status = ErrorStyle.Render("[FAIL]") + } + + // Format line + runLine := fmt.Sprintf("%-28s %-12s %6.2fs %d LLM %s", + run.Manifest.RunID, + run.Manifest.Command, + run.Manifest.Duration, + run.Manifest.LLMCalls, + status, + ) + + if i == m.runCursor { + b.WriteString(CursorStyle.Render("→ ")) + b.WriteString(SelectedStyle.Render(runLine)) + } else { + b.WriteString(" ") + b.WriteString(runLine) + } + b.WriteString("\n") + } + + // Scroll indicator + if len(m.allRuns) > maxVisible { + b.WriteString("\n") + b.WriteString(MutedStyle.Render(fmt.Sprintf("[%d/%d runs]", m.runCursor+1, len(m.allRuns)))) + } + } + + b.WriteString("\n\n") + // Help bar + help := HelpKeyStyle.Render("[↑↓]") + " Navigate " + + HelpKeyStyle.Render("[Enter]") + " View spans " + + HelpKeyStyle.Render("[q]") + " Quit" + b.WriteString(HelpStyle.Render(help)) + + return BoxStyle.Width(m.width - 2).Render(b.String()) +} + +func (m Model) renderTreeView() string { + var b strings.Builder + + // Back indicator if we have multiple runs + if len(m.allRuns) > 0 { + backHint := MutedStyle.Render(fmt.Sprintf("[Esc] Back to list | Run %d/%d", m.selectedRun+1, len(m.allRuns))) + b.WriteString(backHint) + b.WriteString("\n") + } + + // Header + header := m.renderHeader() + b.WriteString(header) + b.WriteString("\n") + + // Span tree + treeContent := m.renderSpanTree() + b.WriteString(treeContent) + + // Help bar + help := m.renderHelpBar() + b.WriteString("\n") + b.WriteString(help) + + return BoxStyle.Width(m.width - 2).Render(b.String()) +} + +func (m Model) renderHeader() string { + var lines []string + + // Title line with run ID + title := fmt.Sprintf("Trace: %s", m.runID) + lines = append(lines, HeaderStyle.Render(title)) + + // Status indicator + status := SuccessStyle.Render("[OK]") + if m.manifest.Status != "completed" && m.manifest.Status != "ok" { + status = ErrorStyle.Render("[FAIL]") + } + + // Combined stats line: Duration | Spans | LLM Calls | Tokens | Cost | Status + var statParts []string + statParts = append(statParts, fmt.Sprintf("Duration: %s", DurationStyle.Render(fmt.Sprintf("%.2fs", m.manifest.Duration)))) + statParts = append(statParts, fmt.Sprintf("Spans: %d", m.manifest.SpanCount)) + statParts = append(statParts, fmt.Sprintf("LLM: %d", m.manifest.LLMCalls)) + if m.totalTokens > 0 { + statParts = append(statParts, fmt.Sprintf("Tokens: %s", DurationStyle.Render(fmt.Sprintf("%d", m.totalTokens)))) + statParts = append(statParts, fmt.Sprintf("Cost: %s", WarningStyle.Render(fmt.Sprintf("$%.4f", m.estimatedCost)))) + } + statParts = append(statParts, fmt.Sprintf("Status: %s", status)) + + // Error count inline if present + if m.errorCount > 0 { + statParts = append(statParts, ErrorStyle.Render(fmt.Sprintf("Errors: %d", m.errorCount))) + } + + statsLine := strings.Join(statParts, " | ") + lines = append(lines, MutedStyle.Render(statsLine)) + + // Slowest span on separate line (only if meaningful) + if m.slowestSpan != nil && m.slowestSpan.DurationMs > 100 { + slowestName := m.slowestSpan.Span.Name + attrs := m.slowestSpan.Span.GetAllAttributes() + if stepName, ok := attrs["agk.workflow.step_name"]; ok { + slowestName = fmt.Sprintf("%v", stepName) + } else if model, ok := attrs["agk.llm.model"]; ok { + slowestName = fmt.Sprintf("%s [%v]", m.slowestSpan.Span.Name, model) + } + slowestLine := fmt.Sprintf( + "Bottleneck: %s %s", + MutedStyle.Render(slowestName), + DurationStyle.Render(fmt.Sprintf("(%dms)", m.slowestSpan.DurationMs)), + ) + lines = append(lines, slowestLine) + } + + return strings.Join(lines, "\n") + "\n" + strings.Repeat("─", m.width-6) +} + +func (m Model) renderSpanTree() string { + var b strings.Builder + + // Calculate visible area + maxVisible := m.height - 12 + if maxVisible < 5 { + maxVisible = 5 + } + + // Calculate scroll offset + scrollOffset := 0 + if m.cursor >= maxVisible { + scrollOffset = m.cursor - maxVisible + 1 + } + + for i, node := range m.visibleNodes { + if i < scrollOffset || i >= scrollOffset+maxVisible { + continue + } + + line := m.renderSpanLine(node, i == m.cursor) + b.WriteString(line) + b.WriteString("\n") + } + + // Scroll indicator + if len(m.visibleNodes) > maxVisible { + indicator := MutedStyle.Render(fmt.Sprintf(" [%d/%d spans]", m.cursor+1, len(m.visibleNodes))) + b.WriteString(indicator) + } + + return b.String() +} + +func (m Model) renderSpanLine(node *SpanNode, selected bool) string { + // Indentation + indent := strings.Repeat(" ", node.Depth) + + // Tree connector + var prefix string + if node.HasChildren() { + if node.Expanded { + prefix = "▼ " + } else { + prefix = "▶ " + } + } else { + prefix = " " + } + + // Span name with styling + spanStyle := GetSpanStyle(node.Span.Name) + name := spanStyle.Render(node.Span.Name) + + // Get additional context from attributes + var context string + attrs := node.Span.GetAllAttributes() + + // For workflow steps, show the step name + if stepName, ok := attrs["agk.workflow.step_name"]; ok { + context = MutedStyle.Render(fmt.Sprintf(" [%v]", stepName)) + } + + // For LLM spans, show the model + if model, ok := attrs["agk.llm.model"]; ok { + context = MutedStyle.Render(fmt.Sprintf(" [%v]", model)) + } + + // For agent spans, show provider info + if provider, ok := attrs["agk.llm.provider"]; ok { + if context == "" { + context = MutedStyle.Render(fmt.Sprintf(" [%v]", provider)) + } + } + + // Error indicator + errorIndicator := "" + if node.Span.Status.Code != "" && node.Span.Status.Code != "Unset" && node.Span.Status.Code != "Ok" { + errorIndicator = ErrorStyle.Render(" [ERR]") + } + + // Duration + duration := DurationStyle.Render(fmt.Sprintf("(%dms)", node.DurationMs)) + + // Build line + line := fmt.Sprintf("%s%s%s%s%s %s", indent, prefix, name, context, errorIndicator, duration) + + // Apply selection styling + if selected { + line = CursorStyle.Render("→ ") + SelectedStyle.Render(line) + } else { + line = " " + line + } + + return line +} + +func (m Model) renderDetailView() string { + var b strings.Builder + + if m.cursor >= len(m.visibleNodes) { + return "No span selected" + } + + node := m.visibleNodes[m.cursor] + + // Header + title := fmt.Sprintf("📋 Span: %s", node.Span.Name) + b.WriteString(HeaderStyle.Render(title)) + b.WriteString("\n") + b.WriteString(strings.Repeat("─", m.width-6)) + b.WriteString("\n\n") + + // Viewport content + b.WriteString(m.viewport.View()) + b.WriteString("\n") + + // Help bar + help := HelpKeyStyle.Render("[Esc]") + " Back " + + HelpKeyStyle.Render("[↑↓]") + " Scroll " + + HelpKeyStyle.Render("[q]") + " Quit" + b.WriteString("\n") + b.WriteString(HelpStyle.Render(help)) + + return BoxStyle.Width(m.width - 2).Render(b.String()) +} + +func (m Model) renderDetailContent(node *SpanNode) string { + var b strings.Builder + + // Basic info + b.WriteString(AttributeKeyStyle.Render("Duration: ")) + b.WriteString(DurationStyle.Render(fmt.Sprintf("%dms", node.DurationMs))) + b.WriteString("\n") + + b.WriteString(AttributeKeyStyle.Render("Status: ")) + if node.Span.Status.Code == "" || node.Span.Status.Code == "Unset" || node.Span.Status.Code == "Ok" { + b.WriteString(SuccessStyle.Render("OK")) + } else { + b.WriteString(ErrorStyle.Render(node.Span.Status.Code)) + } + b.WriteString("\n") + + b.WriteString(AttributeKeyStyle.Render("Span ID: ")) + b.WriteString(MutedStyle.Render(node.Span.SpanContext.SpanID)) + b.WriteString("\n") + + if node.Parent != nil { + b.WriteString(AttributeKeyStyle.Render("Parent ID: ")) + b.WriteString(MutedStyle.Render(node.Span.Parent.SpanID)) + b.WriteString("\n") + } + + b.WriteString("\n") + + // Attributes section + attrs := node.Span.GetAllAttributes() + if len(attrs) > 0 { + b.WriteString(HeaderStyle.Render("Attributes")) + b.WriteString("\n") + b.WriteString(strings.Repeat("─", 40)) + b.WriteString("\n") + + // Sort keys for consistent display + keys := make([]string, 0, len(attrs)) + for k := range attrs { + keys = append(keys, k) + } + // Simple sort + for i := 0; i < len(keys)-1; i++ { + for j := i + 1; j < len(keys); j++ { + if keys[i] > keys[j] { + keys[i], keys[j] = keys[j], keys[i] + } + } + } + + for _, key := range keys { + val := attrs[key] + keyStyled := AttributeKeyStyle.Render(fmt.Sprintf(" %s: ", key)) + valStyled := AttributeValueStyle.Render(fmt.Sprintf("%v", val)) + b.WriteString(keyStyled) + b.WriteString(valStyled) + b.WriteString("\n") + } + } else { + b.WriteString(MutedStyle.Render("No attributes")) + b.WriteString("\n") + } + + return b.String() +} + +func (m Model) renderHelpBar() string { + var help string + if len(m.allRuns) > 0 { + help = HelpKeyStyle.Render("[↑↓]") + " Navigate " + + HelpKeyStyle.Render("[Enter]") + " Expand " + + HelpKeyStyle.Render("[d]") + " Details " + + HelpKeyStyle.Render("[/]") + " Prev/Next Run " + + HelpKeyStyle.Render("[Esc]") + " List " + + HelpKeyStyle.Render("[q]") + " Quit" + } else { + help = HelpKeyStyle.Render("[↑↓]") + " Navigate " + + HelpKeyStyle.Render("[Enter/→]") + " Expand/Details " + + HelpKeyStyle.Render("[←]") + " Collapse " + + HelpKeyStyle.Render("[d]") + " Details " + + HelpKeyStyle.Render("[q]") + " Quit" + } + + return HelpStyle.Render(help) +} + +// Width returns a copy with updated width +func (m Model) Width(w int) Model { + m.width = w + return m +} + +// Height returns a copy with updated height +func (m Model) Height(h int) Model { + m.height = h + return m +} diff --git a/pkg/scaffold/templates/quickstart/main.go.tmpl b/pkg/scaffold/templates/quickstart/main.go.tmpl index e25630b..2f5e8c6 100644 --- a/pkg/scaffold/templates/quickstart/main.go.tmpl +++ b/pkg/scaffold/templates/quickstart/main.go.tmpl @@ -4,6 +4,7 @@ import ( "context" "fmt" "log" + "os" "time" agk "github.com/agenticgokit/agenticgokit/v1beta" @@ -13,21 +14,23 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - // Create a simple agent with hardcoded configuration - agent, err := agk.QuickChatAgentWithConfig("assistant", &agk.Config{ - Name: "quickstart-agent", - SystemPrompt: "You are a helpful AI assistant. Provide clear, concise answers.", - Timeout: 30 * time.Second, - LLM: agk.LLMConfig{ - Provider: "{{.LLMProvider}}", - Model: "{{.LLMModel}}", - Temperature: 0.7, - MaxTokens: 1000, - }, - }) + // Get service version from environment or use default + serviceVersion := os.Getenv("SERVICE_VERSION") + if serviceVersion == "" { + serviceVersion = "0.1.0" + } + + // Create a simple agent with automatic observability configuration + // Tracing is enabled if AGK_TRACE=true environment variable is set + // Usage: AGK_TRACE=true go run main.go + agent, err := agk.NewBuilder("{{.ProjectName}}"). + WithLLM("{{.LLMProvider}}", "{{.LLMModel}}"). + WithObservability("{{.ProjectName}}", serviceVersion). + Build() if err != nil { log.Fatalf("Failed to create agent: %v", err) } + defer agent.Cleanup(ctx) // Simple conversation userMessage := "What is AgenticGoKit?" diff --git a/pkg/scaffold/templates/single-agent/main.go.tmpl b/pkg/scaffold/templates/single-agent/main.go.tmpl index f65edd5..fd6dfb2 100644 --- a/pkg/scaffold/templates/single-agent/main.go.tmpl +++ b/pkg/scaffold/templates/single-agent/main.go.tmpl @@ -4,6 +4,7 @@ import ( "context" "fmt" "log" + "os" "time" agk "github.com/agenticgokit/agenticgokit/v1beta" @@ -13,17 +14,23 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - // Note: API key may not be required for local models like Ollama - // Uncomment below if using a cloud-based LLM (OpenAI, Anthropic, etc.) - // apiKey := os.Getenv("OPENAI_API_KEY") - // if apiKey == "" { - // log.Fatal("OPENAI_API_KEY environment variable not set") - // } + // Get service version from environment or use default + serviceVersion := os.Getenv("SERVICE_VERSION") + if serviceVersion == "" { + serviceVersion = "0.1.0" + } // Create an agent with tools and memory - agent, err := agk.NewBuilder("researcher"). + // Observability is automatically configured via the builder pattern: + // - If AGK_TRACE=true, tracing is automatically enabled + // - Default exporter is file (creates traces-{runID}.jsonl) + // - Can be customized via env vars: AGK_TRACE_EXPORTER, AGK_TRACE_ENDPOINT, AGK_TRACE_SAMPLE + // Usage: AGK_TRACE=true go run main.go + agent, err := agk.NewBuilder("{{.ProjectName}}"). + WithLLM("{{.LLMProvider}}", "{{.LLMModel}}"). + WithObservability("{{.ProjectName}}", serviceVersion). WithConfig(&agk.Config{ - Name: "researcher", + Name: "{{.ProjectName}}", SystemPrompt: "You are a helpful research assistant. Provide detailed, well-structured answers with examples when appropriate.", Timeout: 30 * time.Second, LLM: agk.LLMConfig{ @@ -37,6 +44,7 @@ func main() { if err != nil { log.Fatalf("Failed to create agent: %v", err) } + defer agent.Cleanup(ctx) // Example with tools would be added here // For now, basic conversation with streaming