diff --git a/agents/analyzer/analyzer.go b/agents/analyzer/analyzer.go new file mode 100644 index 0000000..77dfc2f --- /dev/null +++ b/agents/analyzer/analyzer.go @@ -0,0 +1,5 @@ +package analyzer + +type Analysis struct { + // TODO +} diff --git a/agents/documentor/documentor.go b/agents/documentor/documentor.go index 7daff90..5decf7d 100644 --- a/agents/documentor/documentor.go +++ b/agents/documentor/documentor.go @@ -3,12 +3,15 @@ package documentor import ( "context" + "github.com/ATMackay/agent/state" "github.com/ATMackay/agent/tools" "google.golang.org/adk/agent" "google.golang.org/adk/agent/llmagent" "google.golang.org/adk/model" ) +const AgentName = "documentor" + type Documentor struct { agent.Agent } @@ -22,7 +25,7 @@ func NewDocumentor(ctx context.Context, cfg *Config, model model.LLM) (*Document functionTools, err := tools.GetTools([]tools.Kind{ tools.FetchRepoTree, // Fetch repository tree to understand the structure of the codebase. tools.ReadFile, // Read specific files to understand code details and extract relevant information for documentation. - tools.SearchRepo, // Search the repository to find relevant code snippets or information. + tools.SearchFiles, // Search the repository to find relevant code snippets or information. tools.WriteFile, // Write documentation or other output files. }, &deps) if err != nil { @@ -31,12 +34,12 @@ func NewDocumentor(ctx context.Context, cfg *Config, model model.LLM) (*Document // Instantiate Documentor LLM agent da, err := llmagent.New(llmagent.Config{ - Name: "documentor", + Name: AgentName, Model: model, Description: "Retrieves code from a GitHub repository and writes high-quality markdown documentation.", Instruction: buildInstruction(), Tools: functionTools, - OutputKey: tools.StateDocumentation, + OutputKey: state.StateDocumentation, }) if err != nil { return nil, err diff --git a/agents/documentor/prompt.go b/agents/documentor/prompt.go index 6dddc4a..43a6477 100644 --- a/agents/documentor/prompt.go +++ b/agents/documentor/prompt.go @@ -1,5 +1,7 @@ package documentor +import "google.golang.org/genai" + func buildInstruction() string { return ` You are a code documentation agent. @@ -69,3 +71,16 @@ Before each file read, ask: “What specific question am I trying to answer from If that question is not specific, search first instead of reading. ` } + +// UserMessage returns the initial user message for the documentor service. +func UserMessage() *genai.Content { + return &genai.Content{ + Role: "user", + Parts: []*genai.Part{ + { + Text: "Generate detailed code documentation for the configured repository. " + + "Use fetch_repo_tree first, then read relevant files, then write the markdown output file.", + }, + }, + } +} diff --git a/cmd/analyze.go b/cmd/analyze.go new file mode 100644 index 0000000..243663b --- /dev/null +++ b/cmd/analyze.go @@ -0,0 +1,123 @@ +package cmd + +import ( + "fmt" + "log/slog" + "os" + + "github.com/ATMackay/agent/agents/documentor" + "github.com/ATMackay/agent/workflow" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "google.golang.org/adk/session" +) + +func NewAnalyzerCmd() *cobra.Command { + var repoURL string + var ref string + var pathPrefix string + var output string + var maxFiles int + var modelName, modelProvider string + var apiKey string + + cmd := &cobra.Command{ + Use: "analyzer", + Short: "Run the code analyzer agent", + RunE: func(cmd *cobra.Command, args []string) error { + // Prefer explicit flag, then env vars via Viper. + apiKey = viper.GetString("api-key") + if apiKey == "" { + return fmt.Errorf("google gemini or claude api key is required; set --api-key or export API_KEY") + } + if repoURL == "" { + return fmt.Errorf("--repo is required") + } + if output == "" { + return fmt.Errorf("--output is required") + } + + workDir, err := os.MkdirTemp("", "agent-analyzer-*") + if err != nil { + return fmt.Errorf("create work dir: %w", err) + } + defer func() { + if err := os.RemoveAll(workDir); err != nil { + slog.Error("error removing body", "err", err) + } + }() + + ctx := cmd.Context() + + slog.Info( + "creating agent", + "agent_name", documentor.AgentName, + "dir", workDir, + "model", modelName, + "provider", modelProvider, + "output", output, + "repoURL", repoURL, + ) + + // Select model provider. Supported providers: 'claude' or gemini. + // modelCfg := &model.Config{ + // Provider: model.Provider(modelProvider), + // Model: modelName, + // } + // mod, err := model.New(ctx, modelCfg.WithAPIKey(apiKey)) + // if err != nil { + // return fmt.Errorf("create model: %w", err) + // } + + // slog.Info( + // "created agent", + // "agent_name", ag.Name(), + // "agent_description", ag.Description(), + // ) + + initState := map[string]any{ + // TODO + } + + s, err := workflow.New(ctx, "analyzer", session.InMemoryService(), nil /* TODO */, initState) + if err != nil { + return fmt.Errorf("create runner: %w", err) + } + + userMsg := documentor.UserMessage() + + if err := s.Start(ctx, userCLI, userMsg); err != nil { + return err + } + + if _, err := os.Stat(output); err != nil { + return fmt.Errorf("agent finished but output file was not created: %w", err) + } + + slog.Info("Documentation written to", "output_file", output) + return nil + }, + } + + cmd.Flags().StringVar(&repoURL, "repo", "", "GitHub repository URL") + cmd.Flags().StringVar(&ref, "ref", "", "Optional branch, tag, or commit") + cmd.Flags().StringVar(&pathPrefix, "path", "", "Optional subdirectory to document") + cmd.Flags().StringVar(&output, "output", "doc.agentcli.md", "Output file path for the generated markdown") + cmd.Flags().IntVar(&maxFiles, "max-files", 50, "Maximum number of files to read") + cmd.Flags().StringVar(&modelName, "model", "claude-opus-4-1-20250805", "Language model to use") + cmd.Flags().StringVar(&modelProvider, "provider", "claude", "LLM provider to use (claude or gemini)") + + // Bind flags to environment variables + must(viper.BindPFlag("repo", cmd.Flags().Lookup("repo"))) + must(viper.BindPFlag("ref", cmd.Flags().Lookup("ref"))) + must(viper.BindPFlag("path", cmd.Flags().Lookup("path"))) + must(viper.BindPFlag("output", cmd.Flags().Lookup("output"))) + must(viper.BindPFlag("max-files", cmd.Flags().Lookup("max-files"))) + must(viper.BindPFlag("model", cmd.Flags().Lookup("model"))) + must(viper.BindPFlag("provider", cmd.Flags().Lookup("provider"))) + + // API_KEY is preferred, GOOGLE_API_KEY, GEMINI_API_KEY, CLAUDE_API_KEY are accepted as fallback. + must(viper.BindEnv("api-key", "API_KEY", "GOOGLE_API_KEY", "GEMINI_API_KEY", "CLAUDE_API_KEY")) + + return cmd +} diff --git a/cmd/cmd.go b/cmd/cmd.go index adec6ac..ddff08a 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -11,9 +11,21 @@ const EnvPrefix = "AGENT" func NewAgentCLICmd() *cobra.Command { cmd := &cobra.Command{ - Use: "agent [subcommand]", - Short: fmt.Sprintf("agent command line interface.\n\nVERSION:\n semver: %s\n commit: %s\n compilation date: %s", - constants.Version, constants.GitCommit, constants.BuildDate), + Use: "agent [subcommand]", + Short: "CLI for running AI agents and workflows", + Long: fmt.Sprintf(`Agent CLI + +Run and manage AI agents such as code documentors, reviewers, and other workflows. + +Version: + semver: %s + commit: %s + build: %s +`, + constants.Version, + constants.GitCommit, + constants.BuildDate, + ), RunE: runHelp, } diff --git a/cmd/constants.go b/cmd/constants.go new file mode 100644 index 0000000..afdd22c --- /dev/null +++ b/cmd/constants.go @@ -0,0 +1,3 @@ +package cmd + +const userCLI = "cli-user" diff --git a/cmd/documentor.go b/cmd/documentor.go index 63bfbe4..0d32537 100644 --- a/cmd/documentor.go +++ b/cmd/documentor.go @@ -4,21 +4,16 @@ import ( "fmt" "log/slog" "os" - "time" "github.com/ATMackay/agent/agents/documentor" "github.com/ATMackay/agent/model" - "github.com/ATMackay/agent/tools" + "github.com/ATMackay/agent/state" + "github.com/ATMackay/agent/workflow" "github.com/spf13/cobra" "github.com/spf13/viper" - agentpkg "google.golang.org/adk/agent" - "google.golang.org/adk/runner" "google.golang.org/adk/session" - "google.golang.org/genai" ) -const userCLI = "cli-user" - func NewDocumentorCmd() *cobra.Command { var repoURL string var ref string @@ -62,7 +57,8 @@ func NewDocumentorCmd() *cobra.Command { ctx := cmd.Context() slog.Info( - "creating documentor agent", + "creating agent", + "agent_name", documentor.AgentName, "dir", workDir, "model", modelName, "provider", modelProvider, @@ -91,68 +87,31 @@ func NewDocumentorCmd() *cobra.Command { "agent_description", docAgent.Description(), ) - sessService := session.InMemoryService() - r, err := runner.New(runner.Config{ - AppName: "documentor", - Agent: docAgent, - SessionService: sessService, - }) - if err != nil { - return fmt.Errorf("create runner: %w", err) - } - initState := map[string]any{ - tools.StateRepoURL: repoURL, - tools.StateRepoRef: ref, - tools.StateOutputPath: output, - tools.StateMaxFiles: maxFiles, + state.StateRepoURL: repoURL, + state.StateRepoRef: ref, + state.StateOutputPath: output, + state.StateMaxFiles: maxFiles, } if pathPrefix != "" { - initState[tools.StateSubPath] = pathPrefix + initState[state.StateSubPath] = pathPrefix } - resp, err := sessService.Create(ctx, &session.CreateRequest{ - AppName: "documentor", - UserID: userCLI, - State: initState, - }) + s, err := workflow.New( + ctx, + documentor.AgentName, + session.InMemoryService(), + docAgent, + initState) if err != nil { - return fmt.Errorf("create session: %w", err) - } - - userMsg := &genai.Content{ - Role: "user", - Parts: []*genai.Part{ - { - Text: "Generate detailed code documentation for the configured repository. " + - "Use fetch_repo_tree first, then read relevant files, then write the markdown output file.", - }, - }, + return err } - slog.Info( - "running documentor agent", - "session_id", resp.Session.ID(), - ) + userMsg := documentor.UserMessage() - start := time.Now() - for event, err := range r.Run(ctx, userCLI, resp.Session.ID(), userMsg, agentpkg.RunConfig{}) { - if err != nil { - return fmt.Errorf("agent error: %w", err) - } - // handle event (log) - if event.UsageMetadata == nil { - continue - } - slog.Info("event", "author", event.Author, "event_id", event.ID, "prompt_tokens", event.UsageMetadata.PromptTokenCount, "total_tokens", event.UsageMetadata.TotalTokenCount) - if event.Content == nil { - continue - } - for _, p := range event.Content.Parts { - slog.Debug("response_content", "role", event.Content.Role, "text", p.Text, "function_call", p.FunctionCall) - } + if err := s.Start(ctx, userCLI, userMsg); err != nil { + return err } - slog.Info("Agent execution complete", "time_taken", time.Since(start)) if _, err := os.Stat(output); err != nil { return fmt.Errorf("agent finished but output file was not created: %w", err) diff --git a/cmd/run.go b/cmd/run.go index 070dac5..2222334 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -36,6 +36,7 @@ func NewRunCmd() *cobra.Command { // Add subcommands cmd.AddCommand(NewDocumentorCmd()) + cmd.AddCommand(NewAnalyzerCmd()) // TODO - more agent types // Bind flags and ENV vars diff --git a/tools/state.go b/state/state.go similarity index 96% rename from tools/state.go rename to state/state.go index 6d5a60a..8e17c55 100644 --- a/tools/state.go +++ b/state/state.go @@ -1,4 +1,4 @@ -package tools +package state // Session state keys. TODO agent specific, might refactor... const ( diff --git a/tools/edit_file.go b/tools/edit_file.go new file mode 100644 index 0000000..a5d7b99 --- /dev/null +++ b/tools/edit_file.go @@ -0,0 +1,12 @@ +package tools + +type EditFileArgs struct { + Path string `json:"path"` + StartLine string `json:"start_line"` + EndLine int `json:"end_line,omitempty"` + Content string `json:"content"` // multi-line content separated by '\n' +} + +type EditFileResult struct { + // TODO +} diff --git a/tools/git_repo.go b/tools/git_repo.go index c52333e..f86acc7 100644 --- a/tools/git_repo.go +++ b/tools/git_repo.go @@ -18,6 +18,7 @@ import ( "strings" "time" + "github.com/ATMackay/agent/state" "google.golang.org/adk/tool" "google.golang.org/adk/tool/functiontool" ) @@ -50,6 +51,7 @@ type FetchRepoTreeResult struct { } // NewFetchRepoTool returns a fetch_repo_tree function tool. +// TODO - decouple fetch from manifest derivation. func NewFetchRepoTreeTool(workDir string) (tool.Tool, error) { fetchRepoTreeTool, err := functiontool.New( functiontool.Config{ @@ -77,11 +79,11 @@ func newFetchRepoTreeTool(workDir string) func(tool.Context, FetchRepoTreeArgs) return FetchRepoTreeResult{}, err } - ctx.Actions().StateDelta[StateRepoURL] = args.RepositoryURL - ctx.Actions().StateDelta[StateRepoRef] = args.Ref - ctx.Actions().StateDelta[StateSubPath] = args.SubPath - ctx.Actions().StateDelta[StateRepoManifest] = string(raw) - ctx.Actions().StateDelta[StateRepoLocalPath] = localPath + ctx.Actions().StateDelta[state.StateRepoURL] = args.RepositoryURL + ctx.Actions().StateDelta[state.StateRepoRef] = args.Ref + ctx.Actions().StateDelta[state.StateSubPath] = args.SubPath + ctx.Actions().StateDelta[state.StateRepoManifest] = string(raw) + ctx.Actions().StateDelta[state.StateRepoLocalPath] = localPath return FetchRepoTreeResult{ FileCount: len(manifest), diff --git a/tools/read_file.go b/tools/read_file.go index 15c71b2..b9a017a 100644 --- a/tools/read_file.go +++ b/tools/read_file.go @@ -9,10 +9,17 @@ import ( "path/filepath" "strings" + "github.com/ATMackay/agent/state" "google.golang.org/adk/tool" "google.golang.org/adk/tool/functiontool" ) +const ( + defaultSnippetLines = 120 + defaultMaxBytes = 8_000 + hardMaxBytes = 20_000 +) + type ReadFileArgs struct { Path string `json:"path"` StartLine int `json:"start_line,omitempty"` @@ -42,7 +49,7 @@ func newReadFileTool() func(tool.Context, ReadFileArgs) (ReadFileResult, error) return func(ctx tool.Context, args ReadFileArgs) (ReadFileResult, error) { slog.Info("tool call", "function", "read_repo_file", "args", toJSONString(args)) - v, err := ctx.State().Get(StateRepoLocalPath) + v, err := ctx.State().Get(state.StateRepoLocalPath) if err != nil { return ReadFileResult{}, fmt.Errorf("read repo local path from state: %w", err) } @@ -58,7 +65,7 @@ func newReadFileTool() func(tool.Context, ReadFileArgs) (ReadFileResult, error) } loaded := map[string]LoadedFileMeta{} - existing, err := ctx.State().Get(StateLoadedFiles) + existing, err := ctx.State().Get(state.StateLoadedFiles) if err == nil && existing != nil { if s, ok := existing.(string); ok && s != "" { _ = json.Unmarshal([]byte(s), &loaded) @@ -74,7 +81,7 @@ func newReadFileTool() func(tool.Context, ReadFileArgs) (ReadFileResult, error) } raw, _ := json.Marshal(loaded) - ctx.Actions().StateDelta[StateLoadedFiles] = string(raw) + ctx.Actions().StateDelta[state.StateLoadedFiles] = string(raw) return result, nil } @@ -129,12 +136,6 @@ func ReadFileSnippetFromCachedCheckout(localPath string, args ReadFileArgs) (Rea }, nil } - const ( - defaultSnippetLines = 120 - defaultMaxBytes = 8_000 - hardMaxBytes = 20_000 - ) - maxBytes := args.MaxBytes if maxBytes <= 0 { maxBytes = defaultMaxBytes diff --git a/tools/search.go b/tools/search_files.go similarity index 76% rename from tools/search.go rename to tools/search_files.go index 1a0c677..0c8698c 100644 --- a/tools/search.go +++ b/tools/search_files.go @@ -8,11 +8,12 @@ import ( "path/filepath" "strings" + "github.com/ATMackay/agent/state" "google.golang.org/adk/tool" "google.golang.org/adk/tool/functiontool" ) -type SearchRepoArgs struct { +type SearchFilesArgs struct { Query string `json:"query"` PathPrefix string `json:"path_prefix,omitempty"` MaxResults int `json:"max_results,omitempty"` @@ -27,34 +28,34 @@ type SearchMatch struct { Snippet string `json:"snippet"` } -type SearchRepoResult struct { +type SearchFilesResult struct { Query string `json:"query"` MatchCount int `json:"match_count"` Truncated bool `json:"truncated"` Matches []SearchMatch `json:"matches"` } -// NewSearchRepoTool returns a repo search tool -func NewSearchRepoTool() (tool.Tool, error) { - searchRepoTool, err := functiontool.New( +// NewSearchFilesTool returns a repo search tool +func NewSearchFilesTool() (tool.Tool, error) { + SearchFilesTool, err := functiontool.New( functiontool.Config{ - Name: "search_repo", - Description: "Search the cached repository for text matches and return matching file paths, line numbers, and short snippets. Use this before reading files to locate relevant symbols, functions, types, config keys, or strings.", + Name: "search_files", + Description: "Search the cached files for text matches and return matching file paths, line numbers, and short snippets. Use this before reading files to locate relevant symbols, functions, types, config keys, or strings.", }, - newSearchRepoTool(), + newSearchFilesTool(), ) if err != nil { - return nil, fmt.Errorf("create search_repo tool: %w", err) + return nil, fmt.Errorf("create search_files tool: %w", err) } - return searchRepoTool, nil + return SearchFilesTool, nil } -func newSearchRepoTool() func(tool.Context, SearchRepoArgs) (SearchRepoResult, error) { - return func(ctx tool.Context, args SearchRepoArgs) (SearchRepoResult, error) { - slog.Info("tool call", "function", "search_repo", "args", toJSONString(args)) +func newSearchFilesTool() func(tool.Context, SearchFilesArgs) (SearchFilesResult, error) { + return func(ctx tool.Context, args SearchFilesArgs) (SearchFilesResult, error) { + slog.Info("tool call", "function", "search_files", "args", toJSONString(args)) if strings.TrimSpace(args.Query) == "" { - return SearchRepoResult{}, fmt.Errorf("query is required") + return SearchFilesResult{}, fmt.Errorf("query is required") } // Sanitize tool args to prevent context overload @@ -71,14 +72,14 @@ func newSearchRepoTool() func(tool.Context, SearchRepoArgs) (SearchRepoResult, e args.ContextLines = 3 } - v, err := ctx.State().Get(StateRepoLocalPath) + v, err := ctx.State().Get(state.StateRepoLocalPath) if err != nil { - return SearchRepoResult{}, fmt.Errorf("read repo local path from state: %w", err) + return SearchFilesResult{}, fmt.Errorf("read repo local path from state: %w", err) } localPath, ok := v.(string) if !ok || localPath == "" { - return SearchRepoResult{}, fmt.Errorf("repository cache not initialized; call fetch_repo_tree first") + return SearchFilesResult{}, fmt.Errorf("repository cache not initialized; call fetch_repo_tree first") } searchRoot := localPath @@ -125,10 +126,10 @@ func newSearchRepoTool() func(tool.Context, SearchRepoArgs) (SearchRepoResult, e // swallow the sentinel-ish stop condition if err != nil && !strings.Contains(err.Error(), "search result limit reached") { - return SearchRepoResult{}, err + return SearchFilesResult{}, err } - return SearchRepoResult{ + return SearchFilesResult{ Query: args.Query, MatchCount: len(matches), Truncated: truncated, diff --git a/tools/tools.go b/tools/tools.go index 4cd4274..96b8608 100644 --- a/tools/tools.go +++ b/tools/tools.go @@ -12,7 +12,7 @@ type Kind string const ( FetchRepoTree Kind = "fetch_repo_tree" ReadFile Kind = "read_file" - SearchRepo Kind = "search_repo" + SearchFiles Kind = "search_repo" WriteFile Kind = "write_file" ) @@ -30,8 +30,8 @@ func GetToolByEnum(kind Kind, deps *Deps) (tool.Tool, error) { return NewFetchRepoTreeTool(cfg.WorkDir) case ReadFile: return NewReadFileTool() - case SearchRepo: - return NewSearchRepoTool() + case SearchFiles: + return NewSearchFilesTool() case WriteFile: return NewWriteFileTool() default: diff --git a/tools/write_file.go b/tools/write_file.go index 8363cab..1cb4dfd 100644 --- a/tools/write_file.go +++ b/tools/write_file.go @@ -6,12 +6,13 @@ import ( "os" "path/filepath" + "github.com/ATMackay/agent/state" "google.golang.org/adk/tool" "google.golang.org/adk/tool/functiontool" ) type WriteFileArgs struct { - Markdown string `json:"markdown"` + Content string `json:"content"` OutputPath string `json:"output_path,omitempty"` } @@ -39,7 +40,7 @@ func newWriteFileTool() func(tool.Context, WriteFileArgs) (WriteFileResult, erro slog.Info("tool call", "function", string(WriteFile), "content_length", len(toJSONString(args))) out := args.OutputPath if out == "" { - v, err := ctx.State().Get(StateOutputPath) + v, err := ctx.State().Get(state.StateOutputPath) if err == nil { if s, ok := v.(string); ok { out = s @@ -50,11 +51,11 @@ func newWriteFileTool() func(tool.Context, WriteFileArgs) (WriteFileResult, erro return WriteFileResult{}, fmt.Errorf("output path is required") } - if err := writeTextFile(out, args.Markdown); err != nil { + if err := writeTextFile(out, args.Content); err != nil { return WriteFileResult{}, err } - ctx.Actions().StateDelta[StateDocumentation] = args.Markdown + ctx.Actions().StateDelta[state.StateDocumentation] = args.Content return WriteFileResult{Path: out}, nil } } diff --git a/workflow/runner.go b/workflow/runner.go new file mode 100644 index 0000000..924fcf1 --- /dev/null +++ b/workflow/runner.go @@ -0,0 +1,14 @@ +package workflow + +import ( + "context" + "iter" + + "google.golang.org/adk/agent" + "google.golang.org/adk/session" + "google.golang.org/genai" +) + +type Runner interface { + Run(ctx context.Context, userID string, sessionID string, msg *genai.Content, cfg agent.RunConfig) iter.Seq2[*session.Event, error] +} \ No newline at end of file diff --git a/workflow/workflow.go b/workflow/workflow.go new file mode 100644 index 0000000..c8eed6a --- /dev/null +++ b/workflow/workflow.go @@ -0,0 +1,92 @@ +package workflow + +import ( + "context" + "fmt" + "log/slog" + "time" + + "google.golang.org/adk/agent" + "google.golang.org/adk/runner" + "google.golang.org/adk/session" + "google.golang.org/genai" +) + +type Workflow struct { + name string + runner Runner + session session.Service + // state + initialState map[string]any +} + +// New creates a new workflow service. +func New(ctx context.Context, appName string, sessSrv session.Service, ag agent.Agent, initialState map[string]any) (*Workflow, error) { + // Create runner + r, err := runner.New(runner.Config{ + AppName: appName, + Agent: ag, + SessionService: sessSrv, + }) + if err != nil { + return nil, fmt.Errorf("create runner: %w", err) + } + return &Workflow{ + name: appName, + runner: r, + session: sessSrv, + initialState: initialState, + }, nil +} + +// Start triggers a new agent workflow. +func (s *Workflow) Start(ctx context.Context, userID string, usrMsg *genai.Content) error { + // Create new session + resp, err := s.session.Create(ctx, &session.CreateRequest{ + AppName: s.name, + UserID: userID, + State: s.initialState, + }) + if err != nil { + return fmt.Errorf("create session: %w", err) + } + + slog.Info( + "running agent", + "agent_name", s.name, + "session_id", resp.Session.ID(), + ) + + start := time.Now() + for event, err := range s.runner.Run(ctx, userID, resp.Session.ID(), usrMsg, agent.RunConfig{}) { + if err != nil { + return fmt.Errorf("agent error: %w", err) + } + // handle event (log) + if event.UsageMetadata == nil { + continue + } + slog.Info("tokens_used", + "event_id", event.ID, + "author", event.Author, + "total_tokens", event.UsageMetadata.TotalTokenCount, + "prompt_tokens", event.UsageMetadata.PromptTokenCount, + "tool_use_token_count", event.UsageMetadata.ToolUsePromptTokenCount, + "thought_token_count", event.UsageMetadata.ThoughtsTokenCount, + ) + if event.Content == nil { + continue + } + for _, p := range event.Content.Parts { + slog.Debug("response_content", + "event_id", event.ID, + "role", event.Content.Role, + "text", p.Text, + "function_call", p.FunctionCall, + "function_response", p.FunctionResponse, + ) + } + } + slog.Info("Agent execution complete", "time_taken", time.Since(start)) + return nil +}