diff --git a/Directory.Packages.props b/Directory.Packages.props index c17b511b4..96eaeb132 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,7 @@ + @@ -17,7 +18,11 @@ + + + + @@ -27,6 +32,9 @@ + + + @@ -37,14 +45,19 @@ + + - - - + + + + + + - + diff --git a/MSBuildStructuredLog.sln b/MSBuildStructuredLog.sln index 9d583cd6b..926d6ddb0 100644 --- a/MSBuildStructuredLog.sln +++ b/MSBuildStructuredLog.sln @@ -32,48 +32,166 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StructuredLogger.Utils", "s EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StructuredLogViewer", "src\StructuredLogViewer\StructuredLogViewer.csproj", "{C2E67DD4-F4F7-4CDE-A51A-6D46049A0CCA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StructuredLogger.LLM", "src\StructuredLogger.LLM\StructuredLogger.LLM.csproj", "{59292C59-F9D0-4EFF-A686-9A1FD91C0A90}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StructuredLogger.LLM.Tests", "src\StructuredLogger.LLM.Tests\StructuredLogger.LLM.Tests.csproj", "{DDB3FD8A-9B73-4A0E-A54E-A533ED9293AA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BinlogTool.Tests", "src\BinlogTool.Tests\BinlogTool.Tests.csproj", "{EBB1C3A3-3513-273E-7D43-FD6284FCE671}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {A939886E-A191-4A46-A84B-21AD670081B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A939886E-A191-4A46-A84B-21AD670081B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A939886E-A191-4A46-A84B-21AD670081B9}.Debug|x64.ActiveCfg = Debug|Any CPU + {A939886E-A191-4A46-A84B-21AD670081B9}.Debug|x64.Build.0 = Debug|Any CPU + {A939886E-A191-4A46-A84B-21AD670081B9}.Debug|x86.ActiveCfg = Debug|Any CPU + {A939886E-A191-4A46-A84B-21AD670081B9}.Debug|x86.Build.0 = Debug|Any CPU {A939886E-A191-4A46-A84B-21AD670081B9}.Release|Any CPU.ActiveCfg = Release|Any CPU {A939886E-A191-4A46-A84B-21AD670081B9}.Release|Any CPU.Build.0 = Release|Any CPU + {A939886E-A191-4A46-A84B-21AD670081B9}.Release|x64.ActiveCfg = Release|Any CPU + {A939886E-A191-4A46-A84B-21AD670081B9}.Release|x64.Build.0 = Release|Any CPU + {A939886E-A191-4A46-A84B-21AD670081B9}.Release|x86.ActiveCfg = Release|Any CPU + {A939886E-A191-4A46-A84B-21AD670081B9}.Release|x86.Build.0 = Release|Any CPU {8A327073-0811-49D6-B1AA-1FB0157E750A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8A327073-0811-49D6-B1AA-1FB0157E750A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8A327073-0811-49D6-B1AA-1FB0157E750A}.Debug|x64.ActiveCfg = Debug|Any CPU + {8A327073-0811-49D6-B1AA-1FB0157E750A}.Debug|x64.Build.0 = Debug|Any CPU + {8A327073-0811-49D6-B1AA-1FB0157E750A}.Debug|x86.ActiveCfg = Debug|Any CPU + {8A327073-0811-49D6-B1AA-1FB0157E750A}.Debug|x86.Build.0 = Debug|Any CPU {8A327073-0811-49D6-B1AA-1FB0157E750A}.Release|Any CPU.ActiveCfg = Release|Any CPU {8A327073-0811-49D6-B1AA-1FB0157E750A}.Release|Any CPU.Build.0 = Release|Any CPU + {8A327073-0811-49D6-B1AA-1FB0157E750A}.Release|x64.ActiveCfg = Release|Any CPU + {8A327073-0811-49D6-B1AA-1FB0157E750A}.Release|x64.Build.0 = Release|Any CPU + {8A327073-0811-49D6-B1AA-1FB0157E750A}.Release|x86.ActiveCfg = Release|Any CPU + {8A327073-0811-49D6-B1AA-1FB0157E750A}.Release|x86.Build.0 = Release|Any CPU {B8539A63-8D03-4D16-9945-FE7F8489B56D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B8539A63-8D03-4D16-9945-FE7F8489B56D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8539A63-8D03-4D16-9945-FE7F8489B56D}.Debug|x64.ActiveCfg = Debug|Any CPU + {B8539A63-8D03-4D16-9945-FE7F8489B56D}.Debug|x64.Build.0 = Debug|Any CPU + {B8539A63-8D03-4D16-9945-FE7F8489B56D}.Debug|x86.ActiveCfg = Debug|Any CPU + {B8539A63-8D03-4D16-9945-FE7F8489B56D}.Debug|x86.Build.0 = Debug|Any CPU {B8539A63-8D03-4D16-9945-FE7F8489B56D}.Release|Any CPU.ActiveCfg = Release|Any CPU {B8539A63-8D03-4D16-9945-FE7F8489B56D}.Release|Any CPU.Build.0 = Release|Any CPU + {B8539A63-8D03-4D16-9945-FE7F8489B56D}.Release|x64.ActiveCfg = Release|Any CPU + {B8539A63-8D03-4D16-9945-FE7F8489B56D}.Release|x64.Build.0 = Release|Any CPU + {B8539A63-8D03-4D16-9945-FE7F8489B56D}.Release|x86.ActiveCfg = Release|Any CPU + {B8539A63-8D03-4D16-9945-FE7F8489B56D}.Release|x86.Build.0 = Release|Any CPU {3C655C5D-22C3-4B8D-969C-3FC497294703}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3C655C5D-22C3-4B8D-969C-3FC497294703}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3C655C5D-22C3-4B8D-969C-3FC497294703}.Debug|x64.ActiveCfg = Debug|Any CPU + {3C655C5D-22C3-4B8D-969C-3FC497294703}.Debug|x64.Build.0 = Debug|Any CPU + {3C655C5D-22C3-4B8D-969C-3FC497294703}.Debug|x86.ActiveCfg = Debug|Any CPU + {3C655C5D-22C3-4B8D-969C-3FC497294703}.Debug|x86.Build.0 = Debug|Any CPU {3C655C5D-22C3-4B8D-969C-3FC497294703}.Release|Any CPU.ActiveCfg = Release|Any CPU {3C655C5D-22C3-4B8D-969C-3FC497294703}.Release|Any CPU.Build.0 = Release|Any CPU + {3C655C5D-22C3-4B8D-969C-3FC497294703}.Release|x64.ActiveCfg = Release|Any CPU + {3C655C5D-22C3-4B8D-969C-3FC497294703}.Release|x64.Build.0 = Release|Any CPU + {3C655C5D-22C3-4B8D-969C-3FC497294703}.Release|x86.ActiveCfg = Release|Any CPU + {3C655C5D-22C3-4B8D-969C-3FC497294703}.Release|x86.Build.0 = Release|Any CPU {B8DB3798-1636-417D-B57A-25C51C574F1E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B8DB3798-1636-417D-B57A-25C51C574F1E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8DB3798-1636-417D-B57A-25C51C574F1E}.Debug|x64.ActiveCfg = Debug|Any CPU + {B8DB3798-1636-417D-B57A-25C51C574F1E}.Debug|x64.Build.0 = Debug|Any CPU + {B8DB3798-1636-417D-B57A-25C51C574F1E}.Debug|x86.ActiveCfg = Debug|Any CPU + {B8DB3798-1636-417D-B57A-25C51C574F1E}.Debug|x86.Build.0 = Debug|Any CPU {B8DB3798-1636-417D-B57A-25C51C574F1E}.Release|Any CPU.ActiveCfg = Release|Any CPU {B8DB3798-1636-417D-B57A-25C51C574F1E}.Release|Any CPU.Build.0 = Release|Any CPU + {B8DB3798-1636-417D-B57A-25C51C574F1E}.Release|x64.ActiveCfg = Release|Any CPU + {B8DB3798-1636-417D-B57A-25C51C574F1E}.Release|x64.Build.0 = Release|Any CPU + {B8DB3798-1636-417D-B57A-25C51C574F1E}.Release|x86.ActiveCfg = Release|Any CPU + {B8DB3798-1636-417D-B57A-25C51C574F1E}.Release|x86.Build.0 = Release|Any CPU {B94C5F8A-E55F-4E07-A8BE-D8D3A203FD14}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B94C5F8A-E55F-4E07-A8BE-D8D3A203FD14}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B94C5F8A-E55F-4E07-A8BE-D8D3A203FD14}.Debug|x64.ActiveCfg = Debug|Any CPU + {B94C5F8A-E55F-4E07-A8BE-D8D3A203FD14}.Debug|x64.Build.0 = Debug|Any CPU + {B94C5F8A-E55F-4E07-A8BE-D8D3A203FD14}.Debug|x86.ActiveCfg = Debug|Any CPU + {B94C5F8A-E55F-4E07-A8BE-D8D3A203FD14}.Debug|x86.Build.0 = Debug|Any CPU {B94C5F8A-E55F-4E07-A8BE-D8D3A203FD14}.Release|Any CPU.ActiveCfg = Release|Any CPU {B94C5F8A-E55F-4E07-A8BE-D8D3A203FD14}.Release|Any CPU.Build.0 = Release|Any CPU + {B94C5F8A-E55F-4E07-A8BE-D8D3A203FD14}.Release|x64.ActiveCfg = Release|Any CPU + {B94C5F8A-E55F-4E07-A8BE-D8D3A203FD14}.Release|x64.Build.0 = Release|Any CPU + {B94C5F8A-E55F-4E07-A8BE-D8D3A203FD14}.Release|x86.ActiveCfg = Release|Any CPU + {B94C5F8A-E55F-4E07-A8BE-D8D3A203FD14}.Release|x86.Build.0 = Release|Any CPU {35F44EA6-7259-43CC-8C17-E058F3EB86D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {35F44EA6-7259-43CC-8C17-E058F3EB86D3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {35F44EA6-7259-43CC-8C17-E058F3EB86D3}.Debug|x64.ActiveCfg = Debug|Any CPU + {35F44EA6-7259-43CC-8C17-E058F3EB86D3}.Debug|x64.Build.0 = Debug|Any CPU + {35F44EA6-7259-43CC-8C17-E058F3EB86D3}.Debug|x86.ActiveCfg = Debug|Any CPU + {35F44EA6-7259-43CC-8C17-E058F3EB86D3}.Debug|x86.Build.0 = Debug|Any CPU {35F44EA6-7259-43CC-8C17-E058F3EB86D3}.Release|Any CPU.ActiveCfg = Release|Any CPU {35F44EA6-7259-43CC-8C17-E058F3EB86D3}.Release|Any CPU.Build.0 = Release|Any CPU + {35F44EA6-7259-43CC-8C17-E058F3EB86D3}.Release|x64.ActiveCfg = Release|Any CPU + {35F44EA6-7259-43CC-8C17-E058F3EB86D3}.Release|x64.Build.0 = Release|Any CPU + {35F44EA6-7259-43CC-8C17-E058F3EB86D3}.Release|x86.ActiveCfg = Release|Any CPU + {35F44EA6-7259-43CC-8C17-E058F3EB86D3}.Release|x86.Build.0 = Release|Any CPU {AC634B46-D57C-44C5-BF56-480843182F21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AC634B46-D57C-44C5-BF56-480843182F21}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AC634B46-D57C-44C5-BF56-480843182F21}.Debug|x64.ActiveCfg = Debug|Any CPU + {AC634B46-D57C-44C5-BF56-480843182F21}.Debug|x64.Build.0 = Debug|Any CPU + {AC634B46-D57C-44C5-BF56-480843182F21}.Debug|x86.ActiveCfg = Debug|Any CPU + {AC634B46-D57C-44C5-BF56-480843182F21}.Debug|x86.Build.0 = Debug|Any CPU {AC634B46-D57C-44C5-BF56-480843182F21}.Release|Any CPU.ActiveCfg = Release|Any CPU {AC634B46-D57C-44C5-BF56-480843182F21}.Release|Any CPU.Build.0 = Release|Any CPU + {AC634B46-D57C-44C5-BF56-480843182F21}.Release|x64.ActiveCfg = Release|Any CPU + {AC634B46-D57C-44C5-BF56-480843182F21}.Release|x64.Build.0 = Release|Any CPU + {AC634B46-D57C-44C5-BF56-480843182F21}.Release|x86.ActiveCfg = Release|Any CPU + {AC634B46-D57C-44C5-BF56-480843182F21}.Release|x86.Build.0 = Release|Any CPU {C2E67DD4-F4F7-4CDE-A51A-6D46049A0CCA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C2E67DD4-F4F7-4CDE-A51A-6D46049A0CCA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C2E67DD4-F4F7-4CDE-A51A-6D46049A0CCA}.Debug|x64.ActiveCfg = Debug|Any CPU + {C2E67DD4-F4F7-4CDE-A51A-6D46049A0CCA}.Debug|x64.Build.0 = Debug|Any CPU + {C2E67DD4-F4F7-4CDE-A51A-6D46049A0CCA}.Debug|x86.ActiveCfg = Debug|Any CPU + {C2E67DD4-F4F7-4CDE-A51A-6D46049A0CCA}.Debug|x86.Build.0 = Debug|Any CPU {C2E67DD4-F4F7-4CDE-A51A-6D46049A0CCA}.Release|Any CPU.ActiveCfg = Release|Any CPU {C2E67DD4-F4F7-4CDE-A51A-6D46049A0CCA}.Release|Any CPU.Build.0 = Release|Any CPU + {C2E67DD4-F4F7-4CDE-A51A-6D46049A0CCA}.Release|x64.ActiveCfg = Release|Any CPU + {C2E67DD4-F4F7-4CDE-A51A-6D46049A0CCA}.Release|x64.Build.0 = Release|Any CPU + {C2E67DD4-F4F7-4CDE-A51A-6D46049A0CCA}.Release|x86.ActiveCfg = Release|Any CPU + {C2E67DD4-F4F7-4CDE-A51A-6D46049A0CCA}.Release|x86.Build.0 = Release|Any CPU + {59292C59-F9D0-4EFF-A686-9A1FD91C0A90}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {59292C59-F9D0-4EFF-A686-9A1FD91C0A90}.Debug|Any CPU.Build.0 = Debug|Any CPU + {59292C59-F9D0-4EFF-A686-9A1FD91C0A90}.Debug|x64.ActiveCfg = Debug|Any CPU + {59292C59-F9D0-4EFF-A686-9A1FD91C0A90}.Debug|x64.Build.0 = Debug|Any CPU + {59292C59-F9D0-4EFF-A686-9A1FD91C0A90}.Debug|x86.ActiveCfg = Debug|Any CPU + {59292C59-F9D0-4EFF-A686-9A1FD91C0A90}.Debug|x86.Build.0 = Debug|Any CPU + {59292C59-F9D0-4EFF-A686-9A1FD91C0A90}.Release|Any CPU.ActiveCfg = Release|Any CPU + {59292C59-F9D0-4EFF-A686-9A1FD91C0A90}.Release|Any CPU.Build.0 = Release|Any CPU + {59292C59-F9D0-4EFF-A686-9A1FD91C0A90}.Release|x64.ActiveCfg = Release|Any CPU + {59292C59-F9D0-4EFF-A686-9A1FD91C0A90}.Release|x64.Build.0 = Release|Any CPU + {59292C59-F9D0-4EFF-A686-9A1FD91C0A90}.Release|x86.ActiveCfg = Release|Any CPU + {59292C59-F9D0-4EFF-A686-9A1FD91C0A90}.Release|x86.Build.0 = Release|Any CPU + {DDB3FD8A-9B73-4A0E-A54E-A533ED9293AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DDB3FD8A-9B73-4A0E-A54E-A533ED9293AA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DDB3FD8A-9B73-4A0E-A54E-A533ED9293AA}.Debug|x64.ActiveCfg = Debug|Any CPU + {DDB3FD8A-9B73-4A0E-A54E-A533ED9293AA}.Debug|x64.Build.0 = Debug|Any CPU + {DDB3FD8A-9B73-4A0E-A54E-A533ED9293AA}.Debug|x86.ActiveCfg = Debug|Any CPU + {DDB3FD8A-9B73-4A0E-A54E-A533ED9293AA}.Debug|x86.Build.0 = Debug|Any CPU + {DDB3FD8A-9B73-4A0E-A54E-A533ED9293AA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DDB3FD8A-9B73-4A0E-A54E-A533ED9293AA}.Release|Any CPU.Build.0 = Release|Any CPU + {DDB3FD8A-9B73-4A0E-A54E-A533ED9293AA}.Release|x64.ActiveCfg = Release|Any CPU + {DDB3FD8A-9B73-4A0E-A54E-A533ED9293AA}.Release|x64.Build.0 = Release|Any CPU + {DDB3FD8A-9B73-4A0E-A54E-A533ED9293AA}.Release|x86.ActiveCfg = Release|Any CPU + {DDB3FD8A-9B73-4A0E-A54E-A533ED9293AA}.Release|x86.Build.0 = Release|Any CPU + {EBB1C3A3-3513-273E-7D43-FD6284FCE671}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EBB1C3A3-3513-273E-7D43-FD6284FCE671}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EBB1C3A3-3513-273E-7D43-FD6284FCE671}.Debug|x64.ActiveCfg = Debug|Any CPU + {EBB1C3A3-3513-273E-7D43-FD6284FCE671}.Debug|x64.Build.0 = Debug|Any CPU + {EBB1C3A3-3513-273E-7D43-FD6284FCE671}.Debug|x86.ActiveCfg = Debug|Any CPU + {EBB1C3A3-3513-273E-7D43-FD6284FCE671}.Debug|x86.Build.0 = Debug|Any CPU + {EBB1C3A3-3513-273E-7D43-FD6284FCE671}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EBB1C3A3-3513-273E-7D43-FD6284FCE671}.Release|Any CPU.Build.0 = Release|Any CPU + {EBB1C3A3-3513-273E-7D43-FD6284FCE671}.Release|x64.ActiveCfg = Release|Any CPU + {EBB1C3A3-3513-273E-7D43-FD6284FCE671}.Release|x64.Build.0 = Release|Any CPU + {EBB1C3A3-3513-273E-7D43-FD6284FCE671}.Release|x86.ActiveCfg = Release|Any CPU + {EBB1C3A3-3513-273E-7D43-FD6284FCE671}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/docs/BinlogTool-Prompt-README.md b/docs/BinlogTool-Prompt-README.md new file mode 100644 index 000000000..d7a2bb4e3 --- /dev/null +++ b/docs/BinlogTool-Prompt-README.md @@ -0,0 +1,381 @@ +# BinlogTool Prompt - AI-Powered Build Log Analysis + +Use natural language to analyze your MSBuild binlog files powered by AI. + +## Quick Start + +```bash +# Set environment variables (one-time setup) +export LLM_ENDPOINT="https://your-resource.azure.com/..." +export LLM_MODEL="claude-sonnet-4-5-2" # or "gpt-4", etc. +export LLM_API_KEY="your-api-key" + +# Analyze your build +binlogtool prompt why is this build slow + +# Quick answer mode +binlogtool prompt -mode:singleshot count the projects + +# Interactive conversation +binlogtool prompt -interactive +``` + +## Command Syntax + +``` +binlogtool prompt [options] +binlogtool prompt -interactive [options] +``` + +## Options + +| Option | Description | +|--------|-------------| +| `-binlog:` | Path to binlog file(s), comma-separated for multiple | +| `--recurse` | Search subdirectories for binlog files | +| `-llm-endpoint:` | Override LLM endpoint (overrides env var) | +| `-llm-model:` | Override model name (overrides env var) | +| `-llm-api-key:` | Override API key (overrides env var) | +| `-mode:` | Execution mode (default: agent) | +| `-interactive` | Enter interactive REPL mode | +| `-verbose` | Show detailed progress and tool results | +| `-quiet` | Show only final output and errors | +| `-help` | Show help message | + +## Modes + +### Agent Mode (Default) +Multi-step reasoning with automatic planning, research, and summarization. + +**Best for**: Complex questions requiring thorough analysis + +**Example**: +```bash +binlogtool prompt why is this build taking so long +``` + +The agent will: +1. Create a research plan (3-5 tasks) +2. Execute each task using available tools +3. Synthesize findings into comprehensive answer + +### Single-Shot Mode +Direct question answering without planning overhead. + +**Best for**: Simple, straightforward questions + +**Example**: +```bash +binlogtool prompt -mode:singleshot how many projects +binlogtool prompt -mode:singleshot what errors occurred +``` + +### Interactive Mode +REPL-style conversation with history. + +**Best for**: Exploratory analysis, multiple questions + +**Example**: +```bash +binlogtool prompt -interactive +> what errors occurred +> why did project X take so long +> /mode singleshot +> count the warnings +> clear +> exit +``` + +**Interactive Commands**: +- `exit` or `quit` - Leave interactive mode +- `clear` - Clear conversation history +- `/mode agent` - Switch to agent mode +- `/mode singleshot` - Switch to single-shot mode + +## Configuration + +### Environment Variables + +The same variables used by the GUI StructuredLogViewer: + +```bash +# Required +export LLM_ENDPOINT="https://your-resource.services.ai.azure.com/..." +export LLM_MODEL="claude-sonnet-4-5-2" +export LLM_API_KEY="your-api-key-here" +``` + +### Provider Detection + +The tool automatically detects the LLM provider based on endpoint and model: + +- **Azure OpenAI**: Endpoints containing `cognitiveservices.azure.com` or `openai.azure.com` +- **Anthropic**: Endpoints containing `/anthropic/` or models starting with `claude` +- **Azure AI Inference**: Other Azure endpoints + +### Command-Line Overrides + +CLI arguments take precedence over environment variables: + +```bash +binlogtool prompt -llm-api-key:temp-key what failed +``` + +## Binlog Discovery + +### Auto-Discovery + +If no `-binlog` specified, searches for `*.binlog` in current directory: + +```bash +cd path/to/build/output +binlogtool prompt what failed +``` + +Use `--recurse` to search subdirectories: + +```bash +binlogtool prompt --recurse find all errors +``` + +### Explicit Paths + +Specify one or more binlog files: + +```bash +# Single file +binlogtool prompt -binlog:mybuild.binlog what failed + +# Multiple files (CSV) +binlogtool prompt -binlog:build1.binlog,build2.binlog compare these + +# Path with spaces (use quotes) +binlogtool prompt -binlog:"C:\My Build\output.binlog" analyze this +``` + +## Output Verbosity + +### Normal (Default) +Shows progress, tool calls, and results: + +``` +[SYSTEM] Loading binlog: msbuild.binlog +[SYSTEM] LLM configured: claude-sonnet-4-5-2 (Anthropic) +[TOOL] 🔧 Executing: GetBuildSummary +[TOOL] ✓ GetBuildSummary (0.1s) +The build completed in 11.011 seconds... +``` + +### Verbose (-verbose) +Includes tool arguments and result previews: + +``` +[VERBOSE] Found 1 binlog file(s): +[VERBOSE] - C:\path\to\msbuild.binlog +[TOOL] 🔧 Executing: GetProjects +[VERBOSE] Arguments: maxResults: 50 +[TOOL] ✓ GetProjects (0.1s) +[VERBOSE] Result: Project1.csproj (8.3s)... +``` + +### Quiet (-quiet) +Only final output and errors (perfect for scripts): + +``` +The build completed in 11.011 seconds with 0 errors and 0 warnings. +``` + +## Visual Output Format + +Color-coded console output helps distinguish message types: + +| Prefix | Color | Purpose | +|--------|-------|---------| +| `[SYSTEM]` | Gray | System/status messages | +| `[TOOL]` | Cyan | Tool execution with timing | +| `[AGENT]` | Yellow | Agent progress with emojis | +| `[RETRY]` | Magenta | Retry/throttling information | +| `[ERROR]` | Red | Error messages | +| `[VERBOSE]` | Dark Gray | Verbose debug info | +| (none) | White/Green | LLM responses | + +## Example Prompts + +### Build Analysis +```bash +binlogtool prompt why is this build slow +binlogtool prompt what's taking the most time +binlogtool prompt which project built the longest +binlogtool prompt how can I make this faster +``` + +### Error Investigation +```bash +binlogtool prompt what errors occurred +binlogtool prompt show me compilation errors +binlogtool prompt why did project X fail +binlogtool prompt list all warnings +``` + +### Build Information +```bash +binlogtool prompt count the projects +binlogtool prompt list all projects +binlogtool prompt show build summary +binlogtool prompt how long did it take +``` + +### Target Analysis +```bash +binlogtool prompt which targets ran +binlogtool prompt what did the Build target do +binlogtool prompt show me the CoreCompile execution +``` + +### Advanced Analysis +```bash +binlogtool prompt compare this build to best practices +binlogtool prompt analyze build parallelization +binlogtool prompt find performance bottlenecks +binlogtool prompt what's causing the rebuild +``` + +## Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| -1 | No binlog file found | +| -2 | LLM not configured (missing env vars) | +| -3 | Invalid command-line arguments | +| -4 | LLM execution failed | +| -5 | Cancelled by user (Ctrl+C) | + +Perfect for scripting and CI/CD integration! + +## Error Handling + +### No Binlog Found +``` +[ERROR] No binlog files found. +[SYSTEM] Searched in: C:\current\directory +``` + +**Solution**: Ensure you're in the correct directory or use `-binlog:` + +### LLM Not Configured +``` +[ERROR] LLM is not configured. +[SYSTEM] Please set these environment variables: +[SYSTEM] LLM_ENDPOINT - LLM service endpoint URL +[SYSTEM] LLM_MODEL - Model name +[SYSTEM] LLM_API_KEY - API key for authentication +``` + +**Solution**: Set the required environment variables or use CLI overrides + +### Invalid Arguments +``` +Error: Unknown option: -badarg +Use 'binlogtool prompt -help' for usage information. +``` + +**Solution**: Check the help for valid options + +## Tips & Best Practices + +### 1. Choose the Right Mode + +- **Agent mode**: Complex questions, thorough analysis +- **Single-shot**: Quick facts, simple queries +- **Interactive**: Exploring, multiple related questions + +### 2. Use Verbosity Appropriately + +- **Normal**: Day-to-day use +- **Verbose**: Debugging, understanding tool behavior +- **Quiet**: Scripts, automation, piping output + +### 3. Binlog Location + +Place your terminal in the build output directory for auto-discovery: +```bash +cd path/to/bin/Debug +binlogtool prompt analyze this build +``` + +### 4. Interactive Exploration + +Start interactive mode to ask follow-up questions: +```bash +binlogtool prompt -interactive +> what failed +> why did that target take so long +> show me the compilation warnings +``` + +### 5. Combine with Other Tools + +Quiet mode makes it easy to integrate with other tools: +```bash +# Save analysis to file +binlogtool prompt -quiet what failed > analysis.txt + +# Use in CI/CD +if binlogtool prompt -quiet "did the build succeed" | grep -q "succeeded"; then + echo "Build OK" +fi +``` + +## Performance + +- **Cold start**: 3-5 seconds (loading binlog) +- **Single-shot**: 2-10 seconds (simple query) +- **Agent mode**: 15-60 seconds (comprehensive analysis) +- **Interactive**: Instant response for follow-ups + +## Troubleshooting + +### Slow Responses + +LLM responses depend on model, complexity, and API latency. Agent mode performs multiple tool calls for thorough analysis. + +**Solution**: Use single-shot mode for faster answers + +### Rate Limiting + +If you see throttling messages: +``` +[RETRY] ⚠️ Rate limited, retrying in 2s (attempt 1/3) +``` + +The tool automatically retries with exponential backoff. + +### Cancellation + +Press Ctrl+C to gracefully cancel: +``` +Cancelling... +[SYSTEM] Operation cancelled by user. +``` + +All resources are properly cleaned up. + +## Related Commands + +- `binlogtool search` - Text search in binlog +- `binlogtool listtools` - List all tasks +- `binlogtool savefiles` - Extract embedded files +- `binlogtool dumprecords` - Binary structure analysis + +See `binlogtool --help` for all commands. + +## Support + +For issues or questions: +- GitHub: https://github.com/KirillOsenkov/MSBuildStructuredLog +- Documentation: https://msbuildlog.com + +--- + +**Happy analyzing! 🔍✨** diff --git a/docs/LLM_CHAT_README.md b/docs/LLM_CHAT_README.md new file mode 100644 index 000000000..b0f8bb692 --- /dev/null +++ b/docs/LLM_CHAT_README.md @@ -0,0 +1,65 @@ +# LLM Chat Feature + +The MSBuild Structured Log Viewer now includes an AI-powered LLM Chat feature that allows you to query your build logs using natural language. + +## Features + +- **Natural Language Queries**: Ask questions about your build in plain English +- **Context-Aware**: Automatically includes the currently selected build node in the chat context +- **Build Traversal Tools**: The AI can search, filter, and analyze your build log using built-in tools: + - `GetBuildSummary`: Get overall build statistics + - `SearchNodes`: Search for specific nodes by query text + - `GetErrorsAndWarnings`: Filter errors or warnings + - `GetProjects`: List all projects in the build + - `GetProjectTargets`: Get targets for a specific project + +## Setup + +Set the following environment variables: + +``` +LLM_ENDPOINT=https://your-resource.openai.azure.com/ +LLM_API_KEY=your-api-key +LLM_MODEL=gpt-4 +``` + +The system will automatically detect which provider to use based on your endpoint: +- **Azure OpenAI**: Endpoints containing `cognitiveservices.azure.com` or `openai.azure.com` +- **Anthropic (Azure AI Foundry)**: Endpoints containing `/anthropic/` or models starting with `claude` +- **Azure AI Inference**: Other Azure AI Foundry or GitHub Models endpoints + +After setting the variables, restart the application. + +## Usage + +1. **Open a binlog file** in the MSBuild Structured Log Viewer +2. **Click the LLM button** (✨) in the toolbar +3. **Ask questions** in the chat panel, such as: + - "What caused the build to fail?" + - "Show me all the errors in this build" + - "Which project took the longest to build?" + - "What targets were executed for the WebAPI project?" +4. **Select nodes** in the tree view to automatically include them as context in your chat + +## Architecture + +The feature is built using: + +- **Microsoft.Extensions.AI**: Provides the `IChatClient` abstraction for AI services +- **Azure.AI.OpenAI**: SDK for Azure OpenAI Service +- **Azure.AI.Inference**: SDK for Azure AI Foundry and GitHub Models +- **AIFunction Tool Calling**: Enables the AI to invoke build analysis tools + +The implementation uses a service layer pattern with minimal changes to existing code: +- `LLMConfiguration`: Loads configuration from environment variables +- `AzureFoundryLLMClient`: Creates the appropriate AI client +- `LLMChatService`: Orchestrates chat sessions with tool calling +- `BinlogToolExecutor`: Implements the build analysis tools +- `BinlogContextProvider`: Extracts context from selected build nodes + +## Notes + +- The Azure AI Inference SDK (`Azure.AI.Inference`) is designed for Azure AI Foundry and GitHub Models +- For Azure OpenAI Service, use the Azure OpenAI SDK (`Azure.AI.OpenAI`) +- The configuration automatically detects which provider you're using based on environment variables +- Azure OpenAI variables take priority if both sets are configured diff --git a/src/BinlogTool.Tests/BinlogDiscoveryTests.cs b/src/BinlogTool.Tests/BinlogDiscoveryTests.cs new file mode 100644 index 000000000..5559c6d96 --- /dev/null +++ b/src/BinlogTool.Tests/BinlogDiscoveryTests.cs @@ -0,0 +1,68 @@ +using FluentAssertions; +using Xunit; + +namespace BinlogTool.Tests; + +public class BinlogDiscoveryTests +{ + [Fact] + public void ParseCsvPaths_WithSinglePath_ReturnsSingleItem() + { + // Arrange + var input = "build.binlog"; + + // Act + var result = BinlogDiscovery.ParseCsvPaths(input); + + // Assert + result.Should().HaveCount(1); + result[0].Should().Be("build.binlog"); + } + + [Fact] + public void ParseCsvPaths_WithMultiplePaths_ReturnsAllPaths() + { + // Arrange + var input = "build.binlog,test.binlog,deploy.binlog"; + + // Act + var result = BinlogDiscovery.ParseCsvPaths(input); + + // Assert + result.Should().HaveCount(3); + result[0].Should().Be("build.binlog"); + result[1].Should().Be("test.binlog"); + result[2].Should().Be("deploy.binlog"); + } + + [Fact] + public void ParseCsvPaths_WithQuotedPathContainingComma_TreatsCommaAsLiteral() + { + // Arrange + var input = "\"C:\\Program Files\\Build, Test.binlog\",other.binlog"; + + // Act + var result = BinlogDiscovery.ParseCsvPaths(input); + + // Assert + result.Should().HaveCount(2); + result[0].Should().Be("C:\\Program Files\\Build, Test.binlog"); + result[1].Should().Be("other.binlog"); + } + + [Fact] + public void ParseCsvPaths_WithSpacesAroundPaths_TrimsWhitespace() + { + // Arrange + var input = " build.binlog , test.binlog , deploy.binlog "; + + // Act + var result = BinlogDiscovery.ParseCsvPaths(input); + + // Assert + result.Should().HaveCount(3); + result[0].Should().Be("build.binlog"); + result[1].Should().Be("test.binlog"); + result[2].Should().Be("deploy.binlog"); + } +} diff --git a/src/BinlogTool.Tests/BinlogTool.Tests.csproj b/src/BinlogTool.Tests/BinlogTool.Tests.csproj new file mode 100644 index 000000000..4099d59f3 --- /dev/null +++ b/src/BinlogTool.Tests/BinlogTool.Tests.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/BinlogTool.Tests/PromptConfigurationTests.cs b/src/BinlogTool.Tests/PromptConfigurationTests.cs new file mode 100644 index 000000000..d31422e34 --- /dev/null +++ b/src/BinlogTool.Tests/PromptConfigurationTests.cs @@ -0,0 +1,337 @@ +using FluentAssertions; +using Xunit; + +namespace BinlogTool.Tests; + +public class PromptConfigurationTests +{ + [Fact] + public void Parse_WithNoArguments_ReturnsError() + { + // Arrange + var args = new[] { "prompt" }; + + // Act + var (config, errorMessage) = PromptConfiguration.Parse(args); + + // Assert + config.Should().BeNull(); + errorMessage.Should().Be("Prompt text is required (or use -interactive mode)"); + } + + [Fact] + public void Parse_WithSimplePrompt_ParsesCorrectly() + { + // Arrange + var args = new[] { "prompt", "why", "is", "this", "slow" }; + + // Act + var (config, errorMessage) = PromptConfiguration.Parse(args); + + // Assert + errorMessage.Should().BeNull(); + config.Should().NotBeNull(); + config!.PromptText.Should().Be("why is this slow"); + config.AgentMode.Should().BeTrue(); // Default + config.Interactive.Should().BeFalse(); + } + + [Fact] + public void Parse_WithModeOption_ParsesCorrectly() + { + // Arrange + var args = new[] { "prompt", "-mode:singleshot", "count", "projects" }; + + // Act + var (config, errorMessage) = PromptConfiguration.Parse(args); + + // Assert + errorMessage.Should().BeNull(); + config.Should().NotBeNull(); + config!.PromptText.Should().Be("count projects"); + config.AgentMode.Should().BeFalse(); + } + + [Fact] + public void Parse_WithAgentMode_ParsesCorrectly() + { + // Arrange + var args = new[] { "prompt", "-mode:agent", "analyze", "build" }; + + // Act + var (config, errorMessage) = PromptConfiguration.Parse(args); + + // Assert + errorMessage.Should().BeNull(); + config.Should().NotBeNull(); + config!.AgentMode.Should().BeTrue(); + } + + [Fact] + public void Parse_WithInvalidMode_ReturnsError() + { + // Arrange + var args = new[] { "prompt", "-mode:invalid", "test" }; + + // Act + var (config, errorMessage) = PromptConfiguration.Parse(args); + + // Assert + config.Should().BeNull(); + errorMessage.Should().Contain("Invalid mode"); + } + + [Fact] + public void Parse_WithInteractiveFlag_ParsesCorrectly() + { + // Arrange + var args = new[] { "prompt", "-interactive" }; + + // Act + var (config, errorMessage) = PromptConfiguration.Parse(args); + + // Assert + errorMessage.Should().BeNull(); + config.Should().NotBeNull(); + config!.Interactive.Should().BeTrue(); + config.PromptText.Should().BeEmpty(); + } + + [Fact] + public void Parse_WithVerboseFlag_SetsVerbosity() + { + // Arrange + var args = new[] { "prompt", "-verbose", "test" }; + + // Act + var (config, errorMessage) = PromptConfiguration.Parse(args); + + // Assert + errorMessage.Should().BeNull(); + config.Should().NotBeNull(); + config!.Verbosity.Should().Be(CliLogger.Verbosity.Verbose); + } + + [Fact] + public void Parse_WithQuietFlag_SetsVerbosity() + { + // Arrange + var args = new[] { "prompt", "-quiet", "test" }; + + // Act + var (config, errorMessage) = PromptConfiguration.Parse(args); + + // Assert + errorMessage.Should().BeNull(); + config.Should().NotBeNull(); + config!.Verbosity.Should().Be(CliLogger.Verbosity.Quiet); + } + + [Fact] + public void Parse_WithBinlogPath_ParsesCorrectly() + { + // Arrange + var args = new[] { "prompt", "-binlog:test.binlog", "analyze" }; + + // Act + var (config, errorMessage) = PromptConfiguration.Parse(args); + + // Assert + errorMessage.Should().BeNull(); + config.Should().NotBeNull(); + config!.BinlogPaths.Should().ContainSingle().Which.Should().Be("test.binlog"); + } + + [Fact] + public void Parse_WithQuotedBinlogPath_RemovesQuotes() + { + // Arrange + var args = new[] { "prompt", "-binlog:\"C:\\My Path\\build.binlog\"", "analyze" }; + + // Act + var (config, errorMessage) = PromptConfiguration.Parse(args); + + // Assert + errorMessage.Should().BeNull(); + config.Should().NotBeNull(); + config!.BinlogPaths.Should().ContainSingle().Which.Should().Be("C:\\My Path\\build.binlog"); + } + + [Fact] + public void Parse_WithRecurseFlag_SetsRecurse() + { + // Arrange + var args = new[] { "prompt", "--recurse", "test" }; + + // Act + var (config, errorMessage) = PromptConfiguration.Parse(args); + + // Assert + errorMessage.Should().BeNull(); + config.Should().NotBeNull(); + config!.Recurse.Should().BeTrue(); + } + + [Fact] + public void Parse_WithLLMEndpoint_ParsesCorrectly() + { + // Arrange + var args = new[] { "prompt", "-llm-endpoint:https://test.com", "query" }; + + // Act + var (config, errorMessage) = PromptConfiguration.Parse(args); + + // Assert + errorMessage.Should().BeNull(); + config.Should().NotBeNull(); + config!.Endpoint.Should().Be("https://test.com"); + } + + [Fact] + public void Parse_WithLLMModel_ParsesCorrectly() + { + // Arrange + var args = new[] { "prompt", "-llm-model:gpt-4", "query" }; + + // Act + var (config, errorMessage) = PromptConfiguration.Parse(args); + + // Assert + errorMessage.Should().BeNull(); + config.Should().NotBeNull(); + config!.Model.Should().Be("gpt-4"); + } + + [Fact] + public void Parse_WithLLMApiKey_ParsesCorrectly() + { + // Arrange + var args = new[] { "prompt", "-llm-api-key:secret123", "query" }; + + // Act + var (config, errorMessage) = PromptConfiguration.Parse(args); + + // Assert + errorMessage.Should().BeNull(); + config.Should().NotBeNull(); + config!.ApiKey.Should().Be("secret123"); + } + + [Fact] + public void Parse_WithMultipleOptions_ParsesAllCorrectly() + { + // Arrange + var args = new[] + { + "prompt", + "-binlog:test.binlog", + "--recurse", + "-mode:singleshot", + "-verbose", + "-llm-endpoint:https://test.com", + "-llm-model:gpt-4", + "-llm-api-key:key123", + "what", "failed" + }; + + // Act + var (config, errorMessage) = PromptConfiguration.Parse(args); + + // Assert + errorMessage.Should().BeNull(); + config.Should().NotBeNull(); + config!.BinlogPaths.Should().ContainSingle().Which.Should().Be("test.binlog"); + config.Recurse.Should().BeTrue(); + config.AgentMode.Should().BeFalse(); + config.Verbosity.Should().Be(CliLogger.Verbosity.Verbose); + config.Endpoint.Should().Be("https://test.com"); + config.Model.Should().Be("gpt-4"); + config.ApiKey.Should().Be("key123"); + config.PromptText.Should().Be("what failed"); + } + + [Fact] + public void Parse_WithPromptContainingDashes_TreatsThemAsPrompt() + { + // Arrange + var args = new[] { "prompt", "-mode:singleshot", "count", "projects", "-arg:value" }; + + // Act + var (config, errorMessage) = PromptConfiguration.Parse(args); + + // Assert + errorMessage.Should().BeNull(); + config.Should().NotBeNull(); + config!.PromptText.Should().Be("count projects -arg:value"); + } + + [Fact] + public void Parse_WithHelpFlag_ReturnsNullWithoutError() + { + // Arrange + var args = new[] { "prompt", "-help" }; + + // Act + var (config, errorMessage) = PromptConfiguration.Parse(args); + + // Assert + config.Should().BeNull(); + errorMessage.Should().BeNull(); // Signals to show help + } + + [Fact] + public void Parse_WithUnknownOption_ReturnsError() + { + // Arrange + var args = new[] { "prompt", "-unknown:value", "test" }; + + // Act + var (config, errorMessage) = PromptConfiguration.Parse(args); + + // Assert + config.Should().BeNull(); + errorMessage.Should().Contain("Unknown option"); + } + + [Fact] + public void ToLLMConfiguration_WithNoOverrides_UsesEnvironmentVariables() + { + // Arrange + var config = new PromptConfiguration + { + PromptText = "test", + AgentMode = true + }; + + // Act + var llmConfig = config.ToLLMConfiguration(); + + // Assert + llmConfig.Should().NotBeNull(); + llmConfig.AgentMode.Should().BeTrue(); + } + + [Fact] + public void ToLLMConfiguration_WithOverrides_UsesOverrides() + { + // Arrange + var config = new PromptConfiguration + { + PromptText = "test", + Endpoint = "https://test.com", + Model = "test-model", + ApiKey = "test-key", + AgentMode = false + }; + + // Act + var llmConfig = config.ToLLMConfiguration(); + + // Assert + llmConfig.Should().NotBeNull(); + llmConfig.Endpoint.Should().Be("https://test.com"); + llmConfig.ModelName.Should().Be("test-model"); + llmConfig.ApiKey.Should().Be("test-key"); + llmConfig.AgentMode.Should().BeFalse(); + } +} diff --git a/src/BinlogTool/BinlogDiscovery.cs b/src/BinlogTool/BinlogDiscovery.cs new file mode 100644 index 000000000..05b8a14ec --- /dev/null +++ b/src/BinlogTool/BinlogDiscovery.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace BinlogTool +{ + /// + /// Discovers binlog files based on paths, patterns, and recursion options. + /// + public static class BinlogDiscovery + { + /// + /// Discovers binlog files from the given inputs. + /// + /// Comma-separated list of paths, patterns, or null for auto-discovery + /// Whether to search subdirectories + /// List of discovered binlog file paths + public static List DiscoverBinlogs(string binlogPaths, bool recurse) + { + var result = new List(); + + // If no paths specified, auto-discover + if (string.IsNullOrWhiteSpace(binlogPaths)) + { + return DiscoverInDirectory(Environment.CurrentDirectory, recurse); + } + + // Parse CSV (handle quoted paths with spaces) + var paths = ParseCsvPaths(binlogPaths); + + foreach (var path in paths) + { + if (File.Exists(path)) + { + // Direct file path + result.Add(Path.GetFullPath(path)); + } + else if (Directory.Exists(path)) + { + // Directory - find all binlogs + result.AddRange(DiscoverInDirectory(path, recurse)); + } + else + { + // Pattern or wildcard + var discoveredFiles = FindBinlogsByPattern(path, recurse); + result.AddRange(discoveredFiles); + } + } + + return result.Distinct().ToList(); + } + + internal static List ParseCsvPaths(string csvPaths) + { + var result = new List(); + var current = ""; + bool inQuotes = false; + + for (int i = 0; i < csvPaths.Length; i++) + { + char c = csvPaths[i]; + + if (c == '"') + { + inQuotes = !inQuotes; + } + else if (c == ',' && !inQuotes) + { + if (!string.IsNullOrWhiteSpace(current)) + { + result.Add(current.Trim()); + } + current = ""; + } + else + { + current += c; + } + } + + if (!string.IsNullOrWhiteSpace(current)) + { + result.Add(current.Trim()); + } + + return result; + } + + private static List DiscoverInDirectory(string directory, bool recurse) + { + try + { + var searchOption = recurse ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + return Directory.EnumerateFiles(directory, "*.binlog", searchOption) + .Select(Path.GetFullPath) + .ToList(); + } + catch (Exception) + { + return new List(); + } + } + + private static List FindBinlogsByPattern(string pattern, bool recurse) + { + try + { + pattern = pattern.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + + string fileName; + string directory; + + if (pattern.Contains(Path.DirectorySeparatorChar)) + { + fileName = Path.GetFileName(pattern); + directory = Path.GetDirectoryName(pattern); + if (!Path.IsPathRooted(directory)) + { + directory = Path.GetFullPath(directory); + } + } + else + { + fileName = pattern; + directory = Environment.CurrentDirectory; + } + + var searchOption = recurse ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + return Directory.EnumerateFiles(directory, fileName, searchOption) + .Select(Path.GetFullPath) + .ToList(); + } + catch (Exception) + { + return new List(); + } + } + } +} diff --git a/src/BinlogTool/BinlogTool.csproj b/src/BinlogTool/BinlogTool.csproj index 438679dd9..28cda9b83 100644 --- a/src/BinlogTool/BinlogTool.csproj +++ b/src/BinlogTool/BinlogTool.csproj @@ -23,9 +23,14 @@ MSBuild Log Logger Structure Structured Binlog BinlogTool + + + + + diff --git a/src/BinlogTool/CliLogger.cs b/src/BinlogTool/CliLogger.cs new file mode 100644 index 000000000..504ec8799 --- /dev/null +++ b/src/BinlogTool/CliLogger.cs @@ -0,0 +1,109 @@ +using System; + +namespace BinlogTool +{ + /// + /// Logging abstraction for CLI output with verbosity levels. + /// + public class CliLogger + { + public enum Verbosity + { + Quiet, // Only final output and errors + Normal, // Normal output (default) + Verbose // All details including debug info + } + + private readonly Verbosity level; + + public CliLogger(Verbosity level = Verbosity.Normal) + { + this.level = level; + } + + public bool IsQuiet => level == Verbosity.Quiet; + public bool IsNormal => level == Verbosity.Normal; + public bool IsVerbose => level == Verbosity.Verbose; + + public void LogSystem(string message) + { + if (level >= Verbosity.Normal) + { + WriteColored("[SYSTEM] ", ConsoleColor.Gray); + Console.WriteLine(message); + } + } + + public void LogTool(string message) + { + if (level >= Verbosity.Normal) + { + WriteColored("[TOOL] ", ConsoleColor.Cyan); + Console.WriteLine(message); + } + } + + public void LogAgent(string message) + { + if (level >= Verbosity.Normal) + { + WriteColored("[AGENT] ", ConsoleColor.Yellow); + Console.WriteLine(message); + } + } + + public void LogRetry(string message) + { + if (level >= Verbosity.Normal) + { + WriteColored("[RETRY] ", ConsoleColor.Magenta); + Console.WriteLine(message); + } + } + + public void LogError(string message) + { + WriteColored("[ERROR] ", ConsoleColor.Red); + Console.Error.WriteLine(message); + } + + public void LogWarning(string message) + { + if (level >= Verbosity.Normal) + { + WriteColored("[WARNING] ", ConsoleColor.DarkYellow); + Console.WriteLine(message); + } + } + + public void LogResponse(string message) + { + Console.WriteLine(message); + } + + public void LogVerbose(string message) + { + if (level >= Verbosity.Verbose) + { + WriteColored("[VERBOSE] ", ConsoleColor.DarkGray); + Console.WriteLine(message); + } + } + + public void LogInfo(string message) + { + if (level >= Verbosity.Normal) + { + Console.WriteLine(message); + } + } + + private void WriteColored(string text, ConsoleColor color) + { + var oldColor = Console.ForegroundColor; + Console.ForegroundColor = color; + Console.Write(text); + Console.ForegroundColor = oldColor; + } + } +} diff --git a/src/BinlogTool/ConsoleUserInteraction.cs b/src/BinlogTool/ConsoleUserInteraction.cs new file mode 100644 index 000000000..1eec31718 --- /dev/null +++ b/src/BinlogTool/ConsoleUserInteraction.cs @@ -0,0 +1,49 @@ +using System; +using System.Threading.Tasks; +using StructuredLogger.LLM; + +namespace BinlogTool +{ + /// + /// Console-based implementation of IUserInteraction for BinlogTool CLI. + /// + public class ConsoleUserInteraction : IUserInteraction + { + public Task AskUser(string question, string[]? options = null) + { + Console.WriteLine(); + Console.WriteLine("=== User Input Required ==="); + Console.WriteLine(question); + + if (options != null && options.Length > 0) + { + Console.WriteLine(); + Console.WriteLine("Available options:"); + for (int i = 0; i < options.Length; i++) + { + Console.WriteLine($" {i + 1}. {options[i]}"); + } + Console.WriteLine(); + Console.Write("Enter your choice (number) or custom response: "); + } + else + { + Console.WriteLine(); + Console.Write("Your response: "); + } + + var response = Console.ReadLine() ?? string.Empty; + + // If options were provided and user entered a number, return the corresponding option + if (options != null && options.Length > 0 && int.TryParse(response.Trim(), out int choice)) + { + if (choice >= 1 && choice <= options.Length) + { + return Task.FromResult(options[choice - 1]); + } + } + + return Task.FromResult(response); + } + } +} diff --git a/src/BinlogTool/Program.cs b/src/BinlogTool/Program.cs index b91012c97..7f09a0c67 100644 --- a/src/BinlogTool/Program.cs +++ b/src/BinlogTool/Program.cs @@ -10,11 +10,12 @@ namespace BinlogTool { class Program { - static int Main(string[] args) + static async System.Threading.Tasks.Task Main(string[] args) { if (args.Length == 0) { Console.WriteLine(@"Usage: + binlogtool prompt [options] - Analyze binlog using LLM (use -help for details) binlogtool listtools input.binlog binlogtool savefiles input.binlog output_path binlogtool listnuget input.binlog output_path @@ -30,6 +31,12 @@ binlogtool search *.binlog search string var firstArg = args[0]; + // LLM Prompt command + if (string.Equals(firstArg, "prompt", StringComparison.OrdinalIgnoreCase)) + { + return await new PromptCommand().Execute(args); + } + if (args.Length == 3 && string.Equals(firstArg, "savefiles", StringComparison.OrdinalIgnoreCase)) { var binlog = args[1]; diff --git a/src/BinlogTool/PromptCommand.cs b/src/BinlogTool/PromptCommand.cs new file mode 100644 index 000000000..6b3390b6c --- /dev/null +++ b/src/BinlogTool/PromptCommand.cs @@ -0,0 +1,539 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Build.Logging.StructuredLogger; +using StructuredLogger.LLM; +using StructuredLogger.LLM.Logging; + +namespace BinlogTool +{ + /// + /// Command for LLM-powered binlog analysis via prompts. + /// + public class PromptCommand + { + private CliLogger logger; + private PromptProgressReporter reporter; + private CancellationTokenSource cancellationTokenSource; + + public async Task Execute(string[] args) + { + // Parse configuration + var (config, errorMessage) = PromptConfiguration.Parse(args); + + if (config == null) + { + if (errorMessage == null) + { + // Help requested + PromptConfiguration.ShowHelp(); + return 0; + } + else + { + // Parse error + Console.Error.WriteLine($"Error: {errorMessage}"); + Console.Error.WriteLine("Use 'binlogtool prompt -help' for usage information."); + return -3; + } + } + + // Initialize logger + logger = new CliLogger(config.Verbosity); + reporter = new PromptProgressReporter(logger); + + // Setup cancellation (Ctrl+C) + cancellationTokenSource = new CancellationTokenSource(); + Console.CancelKeyPress += OnCancelKeyPress; + + try + { + // Discover binlog files + var binlogFiles = DiscoverBinlogFiles(config); + if (binlogFiles.Count == 0) + { + logger.LogError("No binlog files found."); + logger.LogSystem("Searched in: " + Environment.CurrentDirectory); + if (config.Recurse) + { + logger.LogSystem("Recursion: enabled"); + } + return -1; + } + + logger.LogVerbose($"Found {binlogFiles.Count} binlog file(s):"); + foreach (var file in binlogFiles) + { + logger.LogVerbose($" - {file}"); + } + + // Load all discovered binlogs into a multi-build context + var buildContext = new MultiBuildContext(); + var maxBinlogs = config.MaxBinlogs; + var filesToLoad = binlogFiles.Take(maxBinlogs).ToList(); + + if (binlogFiles.Count > maxBinlogs) + { + logger.LogSystem($"Limiting to first {maxBinlogs} binlog files (use -max-binlogs: to change)."); + } + + logger.LogSystem($"Loading {filesToLoad.Count} binlog file(s)..."); + + foreach (var binlogPath in filesToLoad) + { + try + { + var build = BinaryLog.ReadBuild(binlogPath); + var buildId = buildContext.AddBuild(build); + logger.LogVerbose($" ✓ Loaded: {System.IO.Path.GetFileName(binlogPath)} as [{buildId}]"); + } + catch (Exception ex) + { + logger.LogWarning($" ✗ Failed to load: {System.IO.Path.GetFileName(binlogPath)} - {ex.Message}"); + } + } + + if (buildContext.BuildCount == 0) + { + logger.LogError("No binlog files could be loaded."); + return -1; + } + + // Display loaded builds summary + logger.LogSystem(""); + logger.LogSystem("Loaded builds:"); + foreach (var buildInfo in buildContext.GetAllBuilds()) + { + var primary = buildInfo.IsPrimary ? " [PRIMARY]" : ""; + var status = buildInfo.Succeeded ? "✓" : "✗"; + logger.LogSystem($" [{buildInfo.BuildId}] {buildInfo.FriendlyName}{primary} - {status} {buildInfo.DurationText}"); + logger.LogVerbose($" Path: {buildInfo.FullPath}"); + } + logger.LogSystem(""); + + // If specific primary requested, set it + if (!string.IsNullOrEmpty(config.PrimaryBuildId)) + { + try + { + buildContext.SetPrimaryBuild(config.PrimaryBuildId); + logger.LogVerbose($"Primary build set to: {config.PrimaryBuildId}"); + } + catch (ArgumentException) + { + logger.LogWarning($"Requested primary build '{config.PrimaryBuildId}' not found. Using default."); + } + } + + // Configure LLM + var llmConfig = config.ToLLMConfiguration(); + + // If GitHub Copilot is not configured (no API key), trigger device flow + if (!llmConfig.IsConfigured && llmConfig.Type == LLMConfiguration.ClientType.GitHubCopilot) + { + logger.LogSystem("GitHub Copilot selected but no API key provided. Initiating device flow authentication..."); + logger.LogSystem(""); + + try + { + using var authenticator = new StructuredLogger.LLM.Clients.GitHub.GitHubDeviceFlowAuthenticator(); + var githubToken = await authenticator.AuthenticateAsync(cancellationTokenSource.Token); + + // Update configuration with obtained token + llmConfig.ApiKey = githubToken; + + logger.LogSystem(""); + logger.LogSystem("✓ Authentication successful!"); + logger.LogSystem(""); + } + catch (Exception ex) + { + logger.LogError($"Authentication failed: {ex.Message}"); + return -2; + } + } + + // Check if configuration is complete + if (!llmConfig.IsConfigured) + { + logger.LogError("LLM is not configured."); + logger.LogSystem("Please set these environment variables:"); + logger.LogSystem(" LLM_ENDPOINT - LLM service endpoint URL"); + logger.LogSystem(" LLM_MODEL - Model name (e.g., claude-sonnet-4-5-2, gpt-4)"); + logger.LogSystem(" LLM_API_KEY - API key for authentication"); + logger.LogSystem("Or use command-line options: -llm-endpoint, -llm-model, -llm-api-key"); + logger.LogSystem(""); + logger.LogSystem("For GitHub Copilot, set LLM_ENDPOINT to 'github-copilot' and device flow will be used."); + return -2; + } + + logger.LogSystem($"LLM configured: {llmConfig.ModelName} ({llmConfig.Type})"); + + // Execute based on mode + if (config.Interactive) + { + return await ExecuteInteractiveMode(buildContext, llmConfig, cancellationTokenSource.Token); + } + else + { + return await ExecuteSinglePrompt(buildContext, llmConfig, config.PromptText, cancellationTokenSource.Token); + } + } + catch (OperationCanceledException) + { + logger.LogSystem("Operation cancelled by user."); + return -5; + } + catch (Exception ex) + { + logger.LogError($"Unexpected error: {ex.Message}"); + logger.LogVerbose(ex.StackTrace); + return -4; + } + finally + { + Console.CancelKeyPress -= OnCancelKeyPress; + cancellationTokenSource?.Dispose(); + } + } + + private List DiscoverBinlogFiles(PromptConfiguration config) + { + // If explicit binlog paths provided, use them + if (config.BinlogPaths.Any()) + { + var result = new List(); + foreach (var path in config.BinlogPaths) + { + result.AddRange(BinlogDiscovery.DiscoverBinlogs(path, config.Recurse)); + } + return result; + } + + // Otherwise, auto-discover in current directory + return BinlogDiscovery.DiscoverBinlogs(null, config.Recurse); + } + + private async Task ExecuteSinglePrompt( + MultiBuildContext buildContext, + LLMConfiguration llmConfig, + string promptText, + CancellationToken cancellationToken) + { + logger.LogSystem($"Mode: {(llmConfig.AgentMode ? "Agent" : "Single-Shot")}"); + logger.LogSystem($"Prompt: {promptText}"); + logger.LogSystem(""); + + try + { + if (llmConfig.AgentMode) + { + // Agent mode - multi-step reasoning + var loggerAdapter = new CliLoggerAdapter(logger); + var agenticService = await AgenticLLMChatService.CreateAsync(buildContext, llmConfig, loggerAdapter, cancellationToken); + + // Subscribe to events + agenticService.ProgressUpdated += reporter.OnAgentProgress; + agenticService.MessageAdded += reporter.OnMessage; + agenticService.ToolCallExecuting += reporter.OnToolCallStarted; + agenticService.ToolCallExecuted += reporter.OnToolCallCompleted; + agenticService.RequestRetrying += reporter.OnRetrying; + + var result = await agenticService.ExecuteAgenticWorkflowAsync(promptText, cancellationToken); + + logger.LogSystem(""); + logger.LogSystem("=== Final Answer ==="); + logger.LogResponse(result); + } + else + { + // Single-shot mode - direct Q&A + var loggerAdapter = new CliLoggerAdapter(logger); + var chatService = await LLMChatService.CreateAsync(buildContext, llmConfig, loggerAdapter, cancellationToken); + + // Subscribe to events + chatService.MessageAdded += reporter.OnMessage; + chatService.ToolCallExecuting += reporter.OnToolCallStarted; + chatService.ToolCallExecuted += reporter.OnToolCallCompleted; + chatService.RequestRetrying += reporter.OnRetrying; + + var result = await chatService.SendMessageAsync(promptText, cancellationToken); + + logger.LogSystem(""); + logger.LogResponse(result); + } + + return 0; + } + catch (OperationCanceledException) + { + throw; // Re-throw to be handled by outer catch + } + catch (Exception ex) + { + logger.LogError($"LLM execution failed: {ex.Message}"); + logger.LogVerbose(ex.StackTrace); + return -4; + } + } + + private async Task ExecuteInteractiveMode( + MultiBuildContext buildContext, + LLMConfiguration llmConfig, + CancellationToken cancellationToken) + { + logger.LogSystem("Entering interactive mode. Type 'exit' or 'quit' to leave, 'clear' to clear history."); + logger.LogSystem($"Mode: {(llmConfig.AgentMode ? "Agent" : "Single-Shot")} (use '/mode agent' or '/mode singleshot' to switch)"); + logger.LogSystem($"Builds loaded: {buildContext.BuildCount} (use '.builds' to list, '.primary ' to change primary)"); + logger.LogSystem(""); + + LLMChatService chatService = null; + AgenticLLMChatService agenticService = null; + + // Initialize appropriate service + async System.Threading.Tasks.Task InitializeServicesAsync() + { + chatService?.Dispose(); + agenticService?.Dispose(); + + var loggerAdapter = new CliLoggerAdapter(logger); + + if (llmConfig.AgentMode) + { + agenticService = await AgenticLLMChatService.CreateAsync(buildContext, llmConfig, loggerAdapter, cancellationToken); + + // Register AskUser tool for interactive user clarification + agenticService.RegisterToolContainer(new AskUserToolExecutor(new ConsoleUserInteraction())); + + agenticService.ProgressUpdated += reporter.OnAgentProgress; + agenticService.MessageAdded += reporter.OnMessage; + agenticService.ToolCallExecuting += reporter.OnToolCallStarted; + agenticService.ToolCallExecuted += reporter.OnToolCallCompleted; + agenticService.RequestRetrying += reporter.OnRetrying; + } + else + { + chatService = await LLMChatService.CreateAsync(buildContext, llmConfig, loggerAdapter, cancellationToken); + + // Register AskUser tool for interactive user clarification + chatService.RegisterToolContainer(new AskUserToolExecutor(new ConsoleUserInteraction())); + + chatService.MessageAdded += reporter.OnMessage; + chatService.ToolCallExecuting += reporter.OnToolCallStarted; + chatService.ToolCallExecuted += reporter.OnToolCallCompleted; + chatService.RequestRetrying += reporter.OnRetrying; + } + } + + await InitializeServicesAsync(); + + while (!cancellationToken.IsCancellationRequested) + { + // Prompt for input with build context + Console.Write($"[{buildContext.PrimaryBuildId}]> "); + var input = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(input)) + { + continue; + } + + // Handle dot commands for build management + if (input.StartsWith(".")) + { + if (HandleDotCommand(input, buildContext)) + continue; + } + + // Handle commands + if (input.Equals("exit", StringComparison.OrdinalIgnoreCase) || + input.Equals("quit", StringComparison.OrdinalIgnoreCase)) + { + logger.LogSystem("Exiting interactive mode."); + break; + } + + if (input.Equals("clear", StringComparison.OrdinalIgnoreCase)) + { + chatService?.ClearConversation(); + logger.LogSystem("Conversation history cleared."); + continue; + } + + if (input.StartsWith("/mode ", StringComparison.OrdinalIgnoreCase)) + { + var mode = input.Substring("/mode ".Length).Trim(); + if (mode.Equals("agent", StringComparison.OrdinalIgnoreCase)) + { + llmConfig.AgentMode = true; + await InitializeServicesAsync(); + logger.LogSystem("Switched to Agent mode."); + } + else if (mode.Equals("singleshot", StringComparison.OrdinalIgnoreCase)) + { + llmConfig.AgentMode = false; + await InitializeServicesAsync(); + logger.LogSystem("Switched to Single-Shot mode."); + } + else + { + logger.LogError($"Unknown mode: {mode}. Use 'agent' or 'singleshot'."); + } + continue; + } + + // Execute prompt + try + { + if (llmConfig.AgentMode) + { + var result = await agenticService.ExecuteAgenticWorkflowAsync(input, cancellationToken); + logger.LogSystem(""); + logger.LogResponse(result); + } + else + { + var result = await chatService.SendMessageAsync(input, cancellationToken); + logger.LogSystem(""); + logger.LogResponse(result); + } + + Console.WriteLine(); + } + catch (OperationCanceledException) + { + logger.LogSystem("Operation cancelled."); + break; + } + catch (Exception ex) + { + logger.LogError($"Error: {ex.Message}"); + logger.LogVerbose(ex.StackTrace); + } + } + + // Cleanup + chatService?.Dispose(); + agenticService?.Dispose(); + + return 0; + } + + /// + /// Handles dot commands for build management in interactive mode. + /// + private bool HandleDotCommand(string input, MultiBuildContext context) + { + var parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + var command = parts[0].ToLowerInvariant(); + var arg = parts.Length > 1 ? parts[1] : null; + + switch (command) + { + case ".builds": + // List all loaded builds + logger.LogSystem("Loaded builds:"); + foreach (var buildInfo in context.GetAllBuilds()) + { + var primary = buildInfo.IsPrimary ? " [PRIMARY]" : ""; + var status = buildInfo.Succeeded ? "✓" : "✗"; + logger.LogSystem($" [{buildInfo.BuildId}] {buildInfo.FriendlyName}{primary} - {status} {buildInfo.DurationText}"); + logger.LogVerbose($" Path: {buildInfo.FullPath}"); + } + return true; + + case ".primary": + // Switch primary build + if (string.IsNullOrEmpty(arg)) + { + logger.LogSystem($"Current primary: {context.PrimaryBuildId}"); + logger.LogSystem("Usage: .primary "); + } + else + { + try + { + context.SetPrimaryBuild(arg); + logger.LogSystem($"Primary build changed to: {arg}"); + } + catch (ArgumentException ex) + { + logger.LogError(ex.Message); + } + } + return true; + + case ".add": + // Add another binlog + if (string.IsNullOrEmpty(arg)) + { + logger.LogSystem("Usage: .add "); + } + else + { + try + { + var build = BinaryLog.ReadBuild(arg); + var buildId = context.AddBuild(build); + logger.LogSystem($"Added build: [{buildId}] from {System.IO.Path.GetFileName(arg)}"); + } + catch (Exception ex) + { + logger.LogError($"Failed to load binlog: {ex.Message}"); + } + } + return true; + + case ".remove": + // Remove a build + if (string.IsNullOrEmpty(arg)) + { + logger.LogSystem("Usage: .remove "); + } + else + { + try + { + context.RemoveBuild(arg); + logger.LogSystem($"Removed build: {arg}"); + } + catch (ArgumentException ex) + { + logger.LogError(ex.Message); + } + catch (InvalidOperationException ex) + { + logger.LogError(ex.Message); + } + } + return true; + + case ".help": + logger.LogSystem("Interactive Commands:"); + logger.LogSystem(" .builds - List all loaded builds"); + logger.LogSystem(" .primary - Set primary build"); + logger.LogSystem(" .add - Add another binlog file"); + logger.LogSystem(" .remove - Remove a build"); + logger.LogSystem(" .help - Show this help"); + logger.LogSystem(" exit/quit - Exit interactive mode"); + logger.LogSystem(" clear - Clear chat history"); + logger.LogSystem(" /mode agent - Switch to Agent mode"); + logger.LogSystem(" /mode singleshot - Switch to Single-Shot mode"); + return true; + + default: + logger.LogWarning($"Unknown command: {command}. Type .help for available commands."); + return true; + } + } + + private void OnCancelKeyPress(object sender, ConsoleCancelEventArgs e) + { + e.Cancel = true; // Prevent immediate termination + cancellationTokenSource?.Cancel(); + logger?.LogSystem("Cancelling..."); + } + } +} diff --git a/src/BinlogTool/PromptConfiguration.cs b/src/BinlogTool/PromptConfiguration.cs new file mode 100644 index 000000000..5f8131083 --- /dev/null +++ b/src/BinlogTool/PromptConfiguration.cs @@ -0,0 +1,239 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StructuredLogger.LLM; + +namespace BinlogTool +{ + /// + /// Configuration for the prompt command, parsed from CLI arguments and environment. + /// + public class PromptConfiguration + { + public List BinlogPaths { get; set; } = new List(); + public bool Recurse { get; set; } + public string Endpoint { get; set; } + public string Model { get; set; } + public string ApiKey { get; set; } + public bool AgentMode { get; set; } = true; // Default to agent mode + public bool Interactive { get; set; } + public CliLogger.Verbosity Verbosity { get; set; } = CliLogger.Verbosity.Normal; + public string PromptText { get; set; } + + // Multi-binlog support + public int MaxBinlogs { get; set; } = 10; // Limit number of binlogs + public string PrimaryBuildId { get; set; } // Specify primary build + + public static (PromptConfiguration config, string errorMessage) Parse(string[] args) + { + var config = new PromptConfiguration(); + var promptParts = new List(); + bool parsingOptions = true; + + // Skip "prompt" command itself + var argsToProcess = args.Skip(1).ToArray(); + + foreach (var arg in argsToProcess) + { + if (parsingOptions && arg.StartsWith("-")) + { + // Parse option + if (arg.StartsWith("-binlog:", StringComparison.OrdinalIgnoreCase)) + { + var value = arg.Substring("-binlog:".Length); + // Remove surrounding quotes if present + if (value.StartsWith("\"") && value.EndsWith("\"")) + { + value = value.Substring(1, value.Length - 2); + } + if (string.IsNullOrWhiteSpace(value)) + { + return (null, "Invalid -binlog option: value is required"); + } + config.BinlogPaths.Add(value); + } + else if (arg.Equals("--recurse", StringComparison.OrdinalIgnoreCase)) + { + config.Recurse = true; + } + else if (arg.StartsWith("-max-binlogs:", StringComparison.OrdinalIgnoreCase)) + { + if (int.TryParse(arg.Substring("-max-binlogs:".Length), out int max) && max > 0) + { + config.MaxBinlogs = max; + } + else + { + return (null, "Invalid -max-binlogs value. Must be a positive integer."); + } + } + else if (arg.StartsWith("-primary:", StringComparison.OrdinalIgnoreCase)) + { + config.PrimaryBuildId = arg.Substring("-primary:".Length); + } + else if (arg.StartsWith("-llm-endpoint:", StringComparison.OrdinalIgnoreCase)) + { + config.Endpoint = arg.Substring("-llm-endpoint:".Length); + } + else if (arg.StartsWith("-llm-model:", StringComparison.OrdinalIgnoreCase)) + { + config.Model = arg.Substring("-llm-model:".Length); + } + else if (arg.StartsWith("-llm-api-key:", StringComparison.OrdinalIgnoreCase)) + { + config.ApiKey = arg.Substring("-llm-api-key:".Length); + } + else if (arg.StartsWith("-mode:", StringComparison.OrdinalIgnoreCase)) + { + var mode = arg.Substring("-mode:".Length); + if (mode.Equals("agent", StringComparison.OrdinalIgnoreCase)) + { + config.AgentMode = true; + } + else if (mode.Equals("singleshot", StringComparison.OrdinalIgnoreCase)) + { + config.AgentMode = false; + } + else + { + return (null, $"Invalid mode: {mode}. Use 'agent' or 'singleshot'"); + } + } + else if (arg.Equals("-interactive", StringComparison.OrdinalIgnoreCase)) + { + config.Interactive = true; + } + else if (arg.Equals("-verbose", StringComparison.OrdinalIgnoreCase)) + { + config.Verbosity = CliLogger.Verbosity.Verbose; + } + else if (arg.Equals("-quiet", StringComparison.OrdinalIgnoreCase)) + { + config.Verbosity = CliLogger.Verbosity.Quiet; + } + else if (arg.Equals("-help", StringComparison.OrdinalIgnoreCase) || + arg.Equals("--help", StringComparison.OrdinalIgnoreCase) || + arg.Equals("-h", StringComparison.OrdinalIgnoreCase)) + { + return (null, null); // Signal to show help + } + else + { + return (null, $"Unknown option: {arg}"); + } + } + else + { + // First non-option argument starts the prompt + parsingOptions = false; + promptParts.Add(arg); + } + } + + config.PromptText = string.Join(" ", promptParts); + + // Validate: interactive mode doesn't require prompt, but non-interactive does (unless showing help) + if (!config.Interactive && string.IsNullOrWhiteSpace(config.PromptText)) + { + return (null, "Prompt text is required (or use -interactive mode)"); + } + + return (config, null); + } + + public LLMConfiguration ToLLMConfiguration() + { + // Start with environment variables + var llmConfig = LLMConfiguration.LoadFromEnvironment(); + + // Override with CLI arguments if provided + if (!string.IsNullOrWhiteSpace(Endpoint)) + { + llmConfig.Endpoint = Endpoint; + } + if (!string.IsNullOrWhiteSpace(Model)) + { + llmConfig.ModelName = Model; + } + if (!string.IsNullOrWhiteSpace(ApiKey)) + { + llmConfig.ApiKey = ApiKey; + } + + llmConfig.AgentMode = AgentMode; + llmConfig.UpdateType(); + + return llmConfig; + } + + public static void ShowHelp() + { + Console.WriteLine(@" +BinlogTool Prompt - Analyze MSBuild binlogs using LLM + +Usage: + binlogtool prompt [options] + binlogtool prompt -interactive [options] + +Options: + -binlog: Path to binlog file(s). Can specify multiple times. + Supports wildcards. If omitted, searches current directory. + --recurse Search subdirectories for binlog files + -max-binlogs: Maximum number of binlogs to load (default: 10) + -primary: Set specific build as primary (e.g., 'build_001') + + -llm-endpoint: LLM endpoint URL (overrides LLM_ENDPOINT env var) + Use 'github-copilot' for GitHub Copilot + -llm-model: LLM model name (overrides LLM_MODEL env var) + -llm-api-key: LLM API key (overrides LLM_API_KEY env var) + For GitHub Copilot: use GitHub token (optional - device flow if omitted) + + -mode: Execution mode (default: agent) + -interactive Enter interactive REPL mode + -verbose Show detailed progress and tool results + -quiet Show only final output and errors + -help Show this help message + +Environment Variables: + LLM_ENDPOINT LLM service endpoint URL or 'github-copilot' + LLM_MODEL Model name (e.g., claude-sonnet-4-5-2, gpt-4) + LLM_API_KEY API key or GitHub token (optional for Copilot device flow) + +Multi-Binlog Support: + Multiple binlog files can be loaded for comparative analysis. + Use -binlog: multiple times or wildcards to load multiple files. + All builds are queryable via tools using the buildId parameter. + The first loaded build is the default (PRIMARY) build. + +Interactive Mode Commands: + .builds - List all loaded builds + .primary - Set primary build + .add - Add another binlog file + .remove - Remove a build + .help - Show interactive help + exit/quit - Exit interactive mode + clear - Clear chat history + /mode agent - Switch to Agent mode + /mode singleshot - Switch to Single-Shot mode + +Examples: + binlogtool prompt why is this build slow + binlogtool prompt -mode:singleshot count the projects + binlogtool prompt -binlog:build1.binlog -binlog:build2.binlog compare these builds + binlogtool prompt -binlog:*.binlog -max-binlogs:5 find common errors + binlogtool prompt -interactive + + # GitHub Copilot examples: + binlogtool prompt -llm-endpoint:github-copilot why did the build fail + binlogtool prompt -llm-endpoint:copilot -interactive + +Notes: + - All options must come before the prompt text + - Prompt text can contain spaces and special characters + - Agent mode breaks down complex queries into research tasks + - SingleShot mode gives direct answers without planning + - GitHub Copilot supports device flow authentication (no API key needed) +"); + } + } +} diff --git a/src/BinlogTool/PromptProgressReporter.cs b/src/BinlogTool/PromptProgressReporter.cs new file mode 100644 index 000000000..43e1c424c --- /dev/null +++ b/src/BinlogTool/PromptProgressReporter.cs @@ -0,0 +1,97 @@ +using System; +using StructuredLogger.LLM; + +namespace BinlogTool +{ + /// + /// Reports LLM progress events to the console with visual formatting. + /// + public class PromptProgressReporter + { + private readonly CliLogger logger; + + public PromptProgressReporter(CliLogger logger) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void OnMessage(object sender, ChatMessageViewModel message) + { + if (message.IsError) + { + logger.LogError(message.Content); + } + else if (message.Role == "Assistant" || message.Role == "Agent") + { + logger.LogResponse(message.Content); + } + else if (message.Role == "System") + { + logger.LogSystem(message.Content); + } + else + { + logger.LogInfo(message.Content); + } + } + + public void OnToolCallStarted(object sender, ToolCallInfo toolCall) + { + logger.LogTool($"🔧 Executing: {toolCall.ToolName}"); + + if (logger.IsVerbose) + { + var argsSummary = toolCall.GetArgumentsSummary(200); + logger.LogVerbose($" Arguments: {argsSummary}"); + } + } + + public void OnToolCallCompleted(object sender, ToolCallInfo toolCall) + { + var duration = toolCall.Duration?.TotalSeconds.ToString("F1") ?? "?"; + + if (toolCall.IsError) + { + logger.LogError($"❌ {toolCall.ToolName} failed ({duration}s): {toolCall.ErrorMessage}"); + } + else + { + logger.LogTool($"✓ {toolCall.ToolName} ({duration}s)"); + + if (logger.IsVerbose && !string.IsNullOrWhiteSpace(toolCall.ResultText)) + { + var preview = toolCall.ResultText.Length > 200 + ? toolCall.ResultText.Substring(0, 200) + "..." + : toolCall.ResultText; + logger.LogVerbose($" Result: {preview}"); + } + } + } + + public void OnRetrying(object sender, ResilienceEventArgs e) + { + logger.LogRetry($"⚠️ {e.Message}"); + } + + public void OnAgentProgress(object sender, AgentProgressEventArgs e) + { + var phaseEmoji = e.Phase switch + { + AgentExecutionPhase.Planning => "📋", + AgentExecutionPhase.Research => "🔍", + AgentExecutionPhase.Summarization => "📊", + AgentExecutionPhase.Complete => "✅", + AgentExecutionPhase.Failed => "❌", + _ => "⏳" + }; + + logger.LogAgent($"{phaseEmoji} Phase: {e.Phase} - {e.Message}"); + + // Show task progress in verbose mode + if (logger.IsVerbose && e.CurrentTask != null) + { + logger.LogVerbose($" Task: {e.CurrentTask.Description} [{e.CurrentTask.Status}]"); + } + } + } +} diff --git a/src/StructuredLogViewer.Core/SettingsService.cs b/src/StructuredLogViewer.Core/SettingsService.cs index 58279b8b6..51a959315 100644 --- a/src/StructuredLogViewer.Core/SettingsService.cs +++ b/src/StructuredLogViewer.Core/SettingsService.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Diagnostics.Contracts; using System.IO; using System.Linq; using System.Text; @@ -387,6 +385,64 @@ public static string? IgnoreEmbeddedFiles set => Set(ref ignoreEmbeddedFiles, value); } + // LLM Configuration Settings + private static string? llmEndpoint; + public static string? LLMEndpoint + { + get => Get(ref llmEndpoint); + set => Set(ref llmEndpoint, value); + } + + private static string? llmModel; + public static string? LLMModel + { + get => Get(ref llmModel); + set => Set(ref llmModel, value); + } + + private static string? llmApiKeyEncrypted; + public static string? LLMApiKeyEncrypted + { + get => Get(ref llmApiKeyEncrypted); + set => Set(ref llmApiKeyEncrypted, value); + } + + private static bool llmAutoSendOnEnter = true; + public static bool LLMAutoSendOnEnter + { + get => Get(ref llmAutoSendOnEnter); + set => Set(ref llmAutoSendOnEnter, value); + } + + private static bool llmAgentMode = true; + public static bool LLMAgentMode + { + get => Get(ref llmAgentMode); + set => Set(ref llmAgentMode, value); + } + + private static int llmLoggingLevel = 1; // Default to Normal + public static int LLMLoggingLevel + { + get => Get(ref llmLoggingLevel); + set => Set(ref llmLoggingLevel, value); + } + + private static string? llmAvailableModels; + public static string? LLMAvailableModels + { + get => Get(ref llmAvailableModels); + set => Set(ref llmAvailableModels, value); + } + + private static bool llmEnableAskUser = true; // Default to enabled + public static bool LLMEnableAskUser + { + get => Get(ref llmEnableAskUser); + set => Set(ref llmEnableAskUser, value); + } + + private static void EnsureSettingsRead() { if (!settingsRead) @@ -402,6 +458,14 @@ private static void EnsureSettingsRead() const string UseDarkThemeSetting = "UseDarkTheme="; const string WindowPositionSetting = "WindowPosition="; const string IgnoreEmbeddedFilesSetting = "IgnoreEmbeddedFiles="; + const string LLMEndpointSetting = "LLMEndpoint="; + const string LLMModelSetting = "LLMModel="; + const string LLMApiKeyEncryptedSetting = "LLMApiKeyEncrypted="; + const string LLMAutoSendOnEnterSetting = "LLMAutoSendOnEnter="; + const string LLMAgentModeSetting = "LLMAgentMode="; + const string LLMLoggingLevelSetting = "LLMLoggingLevel="; + const string LLMAvailableModelsSetting = "LLMAvailableModels="; + const string LLMEnableAskUserSetting = "LLMEnableAskUser="; private static void SaveSettings() { @@ -413,6 +477,14 @@ private static void SaveSettings() sb.AppendLine(UseDarkThemeSetting + useDarkTheme.ToString()); sb.AppendLine(WindowPositionSetting + windowPosition); sb.AppendLine(IgnoreEmbeddedFilesSetting + IgnoreEmbeddedFiles); + sb.AppendLine(LLMEndpointSetting + llmEndpoint); + sb.AppendLine(LLMModelSetting + llmModel); + sb.AppendLine(LLMApiKeyEncryptedSetting + llmApiKeyEncrypted); + sb.AppendLine(LLMAutoSendOnEnterSetting + llmAutoSendOnEnter.ToString()); + sb.AppendLine(LLMAgentModeSetting + llmAgentMode.ToString()); + sb.AppendLine(LLMLoggingLevelSetting + llmLoggingLevel.ToString()); + sb.AppendLine(LLMAvailableModelsSetting + llmAvailableModels); + sb.AppendLine(LLMEnableAskUserSetting + llmEnableAskUser.ToString()); using (SingleGlobalInstance.Acquire(Path.GetFileName(settingsFilePath))) { @@ -441,6 +513,14 @@ private static void ReadSettings() ProcessLine(UseDarkThemeSetting, line, ref useDarkTheme); ProcessString(WindowPositionSetting, line, ref windowPosition); ProcessString(IgnoreEmbeddedFilesSetting, line, ref ignoreEmbeddedFiles); + ProcessString(LLMEndpointSetting, line, ref llmEndpoint); + ProcessString(LLMModelSetting, line, ref llmModel); + ProcessString(LLMApiKeyEncryptedSetting, line, ref llmApiKeyEncrypted); + ProcessLine(LLMAutoSendOnEnterSetting, line, ref llmAutoSendOnEnter); + ProcessLine(LLMAgentModeSetting, line, ref llmAgentMode); + ProcessInt(LLMLoggingLevelSetting, line, ref llmLoggingLevel); + ProcessString(LLMAvailableModelsSetting, line, ref llmAvailableModels); + ProcessLine(LLMEnableAskUserSetting, line, ref llmEnableAskUser); void ProcessString(string setting, string text, ref string? variable) { @@ -466,6 +546,20 @@ void ProcessLine(string setting, string text, ref bool variable) variable = boolValue; } } + + void ProcessInt(string setting, string text, ref int variable) + { + if (!text.StartsWith(setting)) + { + return; + } + + var value = text.Substring(setting.Length); + if (int.TryParse(value, out int intValue)) + { + variable = intValue; + } + } } } } @@ -540,5 +634,20 @@ private static void CleanupTempFiles() } } } + + /// + /// Clears persisted LLM configuration (useful when tokens expire or become invalid). + /// + public static void ClearLLMConfiguration() + { + LLMEndpoint = null; + LLMModel = null; + LLMApiKeyEncrypted = null; + LLMAutoSendOnEnter = true; + LLMAgentMode = true; + LLMLoggingLevel = 1; + LLMAvailableModels = null; + LLMEnableAskUser = true; + } } } diff --git a/src/StructuredLogViewer.Core/StructuredLogViewer.Core.csproj b/src/StructuredLogViewer.Core/StructuredLogViewer.Core.csproj index e42484638..151b5026b 100644 --- a/src/StructuredLogViewer.Core/StructuredLogViewer.Core.csproj +++ b/src/StructuredLogViewer.Core/StructuredLogViewer.Core.csproj @@ -13,6 +13,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/StructuredLogViewer/App.config b/src/StructuredLogViewer/App.config index 09ea72414..011aec0bf 100644 --- a/src/StructuredLogViewer/App.config +++ b/src/StructuredLogViewer/App.config @@ -24,11 +24,23 @@ - + - + + + + + + + + + + + + + @@ -52,24 +64,54 @@ - + - + + + + + - + + + + + + + + + + + - + + + + + + + + + + + + + + + + + diff --git a/src/StructuredLogViewer/Controls/AgentProgressPanel.xaml b/src/StructuredLogViewer/Controls/AgentProgressPanel.xaml new file mode 100644 index 000000000..f58e51bf7 --- /dev/null +++ b/src/StructuredLogViewer/Controls/AgentProgressPanel.xaml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + Exception: + + + + + + + + diff --git a/src/StructuredLogViewer/MainWindow.xaml.cs b/src/StructuredLogViewer/MainWindow.xaml.cs index 18f752b58..183dfe8ae 100644 --- a/src/StructuredLogViewer/MainWindow.xaml.cs +++ b/src/StructuredLogViewer/MainWindow.xaml.cs @@ -476,6 +476,7 @@ private void SetContent(object content) if (mainContent.Content is BuildControl current) { lastSearchText = current.searchLogControl.SearchText; + current.LLMChatInitialized -= OnLLMChatInitialized; } mainContent.Content = content; @@ -485,19 +486,27 @@ private void SetContent(object content) logFilePath = null; projectFilePath = null; currentBuild = null; + llmButton.Visibility = Visibility.Collapsed; } - if (content is BuildControl) + if (content is BuildControl buildControl) { ReloadMenu.Visibility = logFilePath != null ? Visibility.Visible : Visibility.Collapsed; SaveAsMenu.Visibility = Visibility.Visible; RedactSecretsMenu.Visibility = Visibility.Visible; + + // Show LLM button immediately - initialization will happen lazily when first opened + llmButton.Visibility = Visibility.Visible; + + // Subscribe to LLM chat initialization event (still used for error reporting) + buildControl.LLMChatInitialized += OnLLMChatInitialized; } else { ReloadMenu.Visibility = Visibility.Collapsed; SaveAsMenu.Visibility = Visibility.Collapsed; RedactSecretsMenu.Visibility = Visibility.Collapsed; + llmButton.Visibility = Visibility.Collapsed; } // If we had text inside search log control bring it back @@ -1250,5 +1259,29 @@ private void UpdateExceptionVisibility() exceptionPanel.Visibility = Visibility.Hidden; } } + + private void OnLLMChatInitialized(object sender, bool success) + { + // Event is now primarily used to report initialization errors + // Button is already visible since we use lazy initialization + if (!success) + { + // Could show an error message to the user here if needed + } + } + + private void LLMButton_Click(object sender, RoutedEventArgs e) + { + var buildControl = CurrentBuildControl; + if (buildControl != null) + { + buildControl.ToggleLLMChat(llmButton.IsChecked == true); + } + else + { + // No build loaded + llmButton.IsChecked = false; + } + } } } diff --git a/src/StructuredLogViewer/StructuredLogViewer.csproj b/src/StructuredLogViewer/StructuredLogViewer.csproj index 50181e291..1455d859b 100644 --- a/src/StructuredLogViewer/StructuredLogViewer.csproj +++ b/src/StructuredLogViewer/StructuredLogViewer.csproj @@ -39,11 +39,23 @@ + + + + + + + + + + + + diff --git a/src/StructuredLogger.LLM.Tests/StructuredLogger.LLM.Tests.csproj b/src/StructuredLogger.LLM.Tests/StructuredLogger.LLM.Tests.csproj new file mode 100644 index 000000000..5979fcaf8 --- /dev/null +++ b/src/StructuredLogger.LLM.Tests/StructuredLogger.LLM.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + diff --git a/src/StructuredLogger.LLM.Tests/ToolsContainerTests.cs b/src/StructuredLogger.LLM.Tests/ToolsContainerTests.cs new file mode 100644 index 000000000..efc7fe4e8 --- /dev/null +++ b/src/StructuredLogger.LLM.Tests/ToolsContainerTests.cs @@ -0,0 +1,257 @@ +using FluentAssertions; +using Microsoft.Build.Logging.StructuredLogger; +using Microsoft.Extensions.AI; + +namespace StructuredLogger.LLM.Tests; + +/// +/// Tests for the IToolsContainer abstraction and tool executor implementations. +/// These tests verify that the refactored tool abstraction works correctly. +/// +public class ToolsContainerTests +{ + private static Build CreateMockBuild() + { + var build = new Build(); + build.Succeeded = true; + // Initialize StringTable for search functionality + if (build.StringTable == null || build.StringTable.Instances == null) + { + // StringTable will be initialized automatically when nodes are added + } + return build; + } + + [Fact] + public void EmbeddedFilesToolExecutor_GetTools_ToolsHaveCorrectPhases() + { + // Arrange + var build = CreateMockBuild(); + var executor = new EmbeddedFilesToolExecutor(build); + + // Act + var tools = executor.GetTools().ToList(); + + // Assert - All embedded file tools should be applicable to Research and Summarization + foreach (var tool in tools) + { + tool.ApplicablePhases.Should().HaveFlag(AgentPhase.Research); + tool.ApplicablePhases.Should().HaveFlag(AgentPhase.Summarization); + } + } + + [Fact] + public void ToolsContainer_CanFilterByPhase() + { + // Arrange + var build = CreateMockBuild(); + var executor = new BinlogToolExecutor(build); + var phase = AgentPhase.Planning; + + // Act + var planningTools = executor.GetTools() + .Where(t => (t.ApplicablePhases & phase) != 0) + .ToList(); + + // Assert + planningTools.Should().NotBeEmpty("Planning phase should have at least GetBuildSummary"); + planningTools.Should().Contain(t => t.Function.Name == "GetBuildSummary"); + planningTools.Should().Contain(t => t.Function.Name == "GetErrorsAndWarnings"); + } + + [Fact] + public void ToolsContainer_ResearchPhaseHasMoreToolsThanPlanning() + { + // Arrange + var build = CreateMockBuild(); + var executor = new BinlogToolExecutor(build); + + // Act + var planningTools = executor.GetTools() + .Where(t => (t.ApplicablePhases & AgentPhase.Planning) != 0) + .ToList(); + + var researchTools = executor.GetTools() + .Where(t => (t.ApplicablePhases & AgentPhase.Research) != 0) + .ToList(); + + // Assert + researchTools.Count.Should().BeGreaterThan(planningTools.Count, + "Research phase should have more tools than planning"); + } + + [Fact] + public void MonitoredAIFunction_WrapsToolCorrectly() + { + // Arrange + var build = CreateMockBuild(); + var executor = new BinlogToolExecutor(build); + var tool = executor.GetTools().First(); + + // Act + var monitored = new MonitoredAIFunction(tool.Function); + + // Assert + monitored.Name.Should().Be(tool.Function.Name); + monitored.Description.Should().Be(tool.Function.Description); + } + + [Fact] + public void MonitoredAIFunction_ThrowsOnNullFunction() + { + // Act & Assert + var act = () => new MonitoredAIFunction(null!); + act.Should().Throw(); + } + + [Fact] + public void MultipleToolExecutors_CanBeEnumerated() + { + // Arrange + var build = CreateMockBuild(); + IToolsContainer[] executors = new IToolsContainer[] + { + new BinlogToolExecutor(build), + new EmbeddedFilesToolExecutor(build) + }; + + // Act + var allTools = executors.SelectMany(e => e.GetTools()).ToList(); + + // Assert + allTools.Should().NotBeEmpty(); + allTools.Should().HaveCount(9, "BinlogToolExecutor(5) + EmbeddedFilesToolExecutor(3) + ListEventsToolExecutor(1) = 9"); + } + + [Fact] + public void ToolFunctions_HaveDescriptions() + { + // Arrange + var build = CreateMockBuild(); + var executor = new BinlogToolExecutor(build); + + // Act + var tools = executor.GetTools().ToList(); + + // Assert + foreach (var tool in tools) + { + tool.Function.Description.Should().NotBeNullOrWhiteSpace( + $"Tool {tool.Function.Name} should have a description"); + } + } + + [Fact] + public async System.Threading.Tasks.Task SearchNodesAsync_WithEmptyQuery_ReturnsError() + { + // Arrange + var build = CreateMockBuild(); + var executor = new BinlogToolExecutor(build); + + // Act + var result = await executor.SearchNodesAsync("", 10); + + // Assert + result.Should().Contain("Error"); + result.Should().Contain("cannot be empty"); + } + + [Fact] + public async System.Threading.Tasks.Task SearchNodesAsync_WithBasicQuery_ReturnsResults() + { + // Arrange + var build = CreateMockBuild(); + build.AddChild(new Project { Name = "TestProject" }); + build.AddChild(new Target { Name = "Build" }); + build.AddChild(new Message { Text = "Building project" }); + + var executor = new BinlogToolExecutor(build); + + // Act + var result = await executor.SearchNodesAsync("Build", 10); + + // Assert + result.Should().Contain("Build"); + result.Should().NotContain("Error"); + } + + [Fact] + public async System.Threading.Tasks.Task SearchNodesAsync_WithNodeTypeFilter_FiltersCorrectly() + { + // Arrange + var build = CreateMockBuild(); + build.AddChild(new Project { Name = "TestProject" }); + build.AddChild(new Target { Name = "Build" }); + build.AddChild(new Message { Text = "Building" }); + + var executor = new BinlogToolExecutor(build); + + // Act - Search with simple query that should find nodes + var result = await executor.SearchNodesAsync("Test", 10); + + // Assert - Should find the TestProject + result.Should().NotContain("Error", "search should execute without errors"); + (result.Contains("Project") || result.Contains("TestProject") || result.Contains("No nodes")) + .Should().BeTrue("result should either find the project or indicate no matches"); + } + + [Fact] + public async System.Threading.Tasks.Task SearchNodesAsync_WithNoMatches_ReturnsNoResultsMessage() + { + // Arrange + var build = CreateMockBuild(); + build.AddChild(new Project { Name = "TestProject" }); + + var executor = new BinlogToolExecutor(build); + + // Act + var result = await executor.SearchNodesAsync("NonExistent", 10); + + // Assert + result.Should().Contain("No nodes found"); + result.Should().Contain("NonExistent"); + } + + [Fact] + public void SearchNodesAsync_Description_ContainsComprehensiveSyntaxGuide() + { + // Arrange + var build = CreateMockBuild(); + var executor = new BinlogToolExecutor(build); + var tools = executor.GetTools().ToList(); + + // SearchNodes is the actual AIFunction name (without Async suffix) + var searchTool = tools.First(t => t.Function.Name == "SearchNodes"); + + // Assert + searchTool.Should().NotBeNull("SearchNodes tool should exist"); + var description = searchTool.Function.Description; + + // Verify description contains key search features + description.Should().Contain("$project", "should document project filter"); + description.Should().Contain("$target", "should document target filter"); + description.Should().Contain("$task", "should document task filter"); + description.Should().Contain("$error", "should document error filter"); + description.Should().Contain("$warning", "should document warning filter"); + description.Should().Contain("under(", "should document under() clause"); + description.Should().Contain("project(", "should document project() clause"); + description.Should().Contain("skipped=", "should document skipped filter"); + description.Should().Contain("$duration", "should document duration display"); + description.Should().Contain("start<", "should document time-based filtering"); + description.Should().Contain("$copy", "should document copy operations"); + description.Should().Contain("EXAMPLES", "should include examples"); + } + + [Fact] + public void ToolsContainer_HasGuiTools_DefaultsToFalse() + { + // Arrange + var build = CreateMockBuild(); + + // Act & Assert - All non-UI tool containers should return false for HasGuiTools + new BinlogToolExecutor(build).HasGuiTools.Should().BeFalse("BinlogToolExecutor should not have GUI tools"); + new EmbeddedFilesToolExecutor(build).HasGuiTools.Should().BeFalse("EmbeddedFilesToolExecutor should not have GUI tools"); + new ListEventsToolExecutor(build).HasGuiTools.Should().BeFalse("ListEventsToolExecutor should not have GUI tools"); + new ResultsToolExecutor().HasGuiTools.Should().BeFalse("ResultsToolExecutor should not have GUI tools"); + } +} diff --git a/src/StructuredLogger.LLM/Clients/GitHub/GitHubCopilotChatClient.cs b/src/StructuredLogger.LLM/Clients/GitHub/GitHubCopilotChatClient.cs new file mode 100644 index 000000000..d3e6f4f6d --- /dev/null +++ b/src/StructuredLogger.LLM/Clients/GitHub/GitHubCopilotChatClient.cs @@ -0,0 +1,614 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using StructuredLogger.LLM.Logging; + +namespace StructuredLogger.LLM.Clients.GitHub +{ + /// + /// GitHub Copilot chat client implementing IChatClient interface. + /// Provides integration with Microsoft.Extensions.AI abstractions. + /// + public class GitHubCopilotChatClient : IChatClient + { + private readonly GitHubCopilotTokenProvider tokenProvider; + private readonly string modelName; + private readonly HttpClient httpClient; + private readonly ILLMLogger logger; + + public ChatClientMetadata Metadata { get; } + + public GitHubCopilotChatClient( + GitHubCopilotTokenProvider tokenProvider, + string modelName, + ILLMLogger? logger = null) + { + this.tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider)); + this.modelName = modelName ?? throw new ArgumentNullException(nameof(modelName)); + this.httpClient = new HttpClient(); + this.logger = logger ?? NullLLMLogger.Instance; + + this.Metadata = new("GitHubCopilot", new Uri("https://api.githubcopilot.com"), modelName); + } + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + // Ensure token is fresh + var copilotToken = await tokenProvider.RefreshIfNeededAsync(cancellationToken); + + // Build request + var request = BuildRequest(messages, options, stream: false); + + // Send request + var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"{copilotToken.BaseUrl}/chat/completions"); + + // Use TryAddWithoutValidation because Copilot token contains special characters (semicolons) + httpRequest.Headers.TryAddWithoutValidation("Authorization", $"Bearer {copilotToken.Token}"); + httpRequest.Headers.TryAddWithoutValidation("Accept", "application/json"); + + // Required Copilot API headers + httpRequest.Headers.TryAddWithoutValidation("User-Agent", "MSBuildStructuredLogViewer/1.0"); + httpRequest.Headers.TryAddWithoutValidation("Editor-Version", "vscode/1.107.0"); + httpRequest.Headers.TryAddWithoutValidation("Editor-Plugin-Version", "copilot-chat/0.35.0"); + httpRequest.Headers.TryAddWithoutValidation("Copilot-Integration-Id", "vscode-chat"); + httpRequest.Headers.TryAddWithoutValidation("openai-intent", "conversation-panel"); + httpRequest.Headers.TryAddWithoutValidation("x-request-id", Guid.NewGuid().ToString()); + httpRequest.Headers.TryAddWithoutValidation("X-Initiator", "user"); + httpRequest.Content = new StringContent( + JsonSerializer.Serialize(request), + Encoding.UTF8, + "application/json"); + + var httpResponse = await httpClient.SendAsync(httpRequest, cancellationToken); + + if (!httpResponse.IsSuccessStatusCode) + { + var errorContent = await httpResponse.Content.ReadAsStringAsync(); + logger.LogError($"[GitHubCopilot] API Error {(int)httpResponse.StatusCode}: {errorContent}"); + logger.LogError($"[GitHubCopilot] Request had {request.Messages.Count} messages"); + throw new HttpRequestException($"GitHub Copilot API returned {(int)httpResponse.StatusCode} ({httpResponse.ReasonPhrase}): {errorContent}"); + } + + var responseJson = await httpResponse.Content.ReadAsStringAsync(); + var copilotResponse = JsonSerializer.Deserialize(responseJson); + + if (copilotResponse == null || copilotResponse.Choices.Count == 0) + { + throw new InvalidOperationException("Invalid response from GitHub Copilot"); + } + + // Convert to ChatResponse + return ConvertToResponse(copilotResponse); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Ensure token is fresh + var copilotToken = await tokenProvider.RefreshIfNeededAsync(cancellationToken); + + // Build request + var request = BuildRequest(messages, options, stream: true); + + // Send request + var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"{copilotToken.BaseUrl}/chat/completions"); + + // Use TryAddWithoutValidation because Copilot token contains special characters (semicolons) + httpRequest.Headers.TryAddWithoutValidation("Authorization", $"Bearer {copilotToken.Token}"); + httpRequest.Headers.TryAddWithoutValidation("Accept", "text/event-stream"); + + // Required Copilot API headers + httpRequest.Headers.TryAddWithoutValidation("User-Agent", "MSBuildStructuredLogViewer/1.0"); + httpRequest.Headers.TryAddWithoutValidation("Editor-Version", "vscode/1.107.0"); + httpRequest.Headers.TryAddWithoutValidation("Editor-Plugin-Version", "copilot-chat/0.35.0"); + httpRequest.Headers.TryAddWithoutValidation("Copilot-Integration-Id", "vscode-chat"); + httpRequest.Headers.TryAddWithoutValidation("openai-intent", "conversation-panel"); + httpRequest.Headers.TryAddWithoutValidation("x-request-id", Guid.NewGuid().ToString()); + httpRequest.Headers.TryAddWithoutValidation("X-Initiator", "user"); + httpRequest.Content = new StringContent( + JsonSerializer.Serialize(request), + Encoding.UTF8, + "application/json"); + + var httpResponse = await httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + httpResponse.EnsureSuccessStatusCode(); + + var stream = await httpResponse.Content.ReadAsStreamAsync(); + using var reader = new System.IO.StreamReader(stream); + + while (!reader.EndOfStream && !cancellationToken.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(); + if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: ")) + { + continue; + } + + var data = line.Substring("data: ".Length).Trim(); + if (data == "[DONE]") + { + break; + } + + ChatCompletionChunk? chunk; + try + { + chunk = JsonSerializer.Deserialize(data); + } + catch + { + continue; // Skip malformed chunks + } + + if (chunk == null || chunk.Choices.Count == 0) + { + continue; + } + + var update = ConvertToUpdate(chunk); + if (update != null) + { + yield return update; + } + } + } + + private ChatCompletionRequest BuildRequest( + IEnumerable messages, + ChatOptions? options, + bool stream) + { + // Merge consecutive assistant messages to avoid API errors + // The FunctionInvokingChatClient sometimes creates multiple assistant messages + // for multiple tool calls, but the API requires they be combined into one + var messagesList = messages.ToList(); + var mergedMessages = MergeConsecutiveAssistantMessages(messagesList).ToList(); + + // Expand tool messages - each FunctionResultContent needs its own API message + var expandedMessages = ExpandToolMessages(mergedMessages).ToList(); + + // Debug logging + logger.LogVerbose($"[GitHubCopilot] Messages before merge: {messagesList.Count}, after merge: {mergedMessages.Count}, after expand: {expandedMessages.Count}"); + for (int i = 0; i < Math.Min(expandedMessages.Count, 10); i++) // Limit to first 10 for brevity + { + var msg = expandedMessages[i]; + var toolCallCount = msg.Contents?.OfType().Count() ?? 0; + var toolResultCount = msg.Contents?.OfType().Count() ?? 0; + var hasText = !string.IsNullOrEmpty(msg.Text) || msg.Contents?.OfType().Any() == true; + logger.LogVerbose($" [{i}] Role: {msg.Role}, ToolCalls: {toolCallCount}, ToolResults: {toolResultCount}, HasText: {hasText}"); + } + + var request = new ChatCompletionRequest + { + Model = modelName, + Messages = expandedMessages.Select(ConvertMessage).ToList(), + Stream = stream + }; + + if (options != null) + { + request.Temperature = options.Temperature; + request.TopP = options.TopP; + request.MaxTokens = options.MaxOutputTokens; + + // Convert tools if present + if (options.Tools != null && options.Tools.Count > 0) + { + request.Tools = options.Tools.Select(ConvertTool).ToList(); + + // Set tool_choice to "auto" to enable tool calling + request.ToolChoice = "auto"; + } + } + + return request; + } + + private IEnumerable MergeConsecutiveAssistantMessages(IEnumerable messages) + { + var result = new List(); + ChatMessage? pendingAssistant = null; + var pendingToolCalls = new List(); + string? pendingText = null; + + foreach (var message in messages) + { + if (message.Role == ChatRole.Assistant) + { + // Collect tool calls from this assistant message + var toolCalls = message.Contents?.OfType().ToList() ?? new List(); + var textContent = message.Contents?.OfType().FirstOrDefault(); + var messageText = textContent?.Text ?? message.Text; + + if (toolCalls.Any() || !string.IsNullOrEmpty(messageText)) + { + // Accumulate tool calls and text + pendingToolCalls.AddRange(toolCalls); + if (!string.IsNullOrEmpty(messageText)) + { + // If we already have pending text, append with newline + if (!string.IsNullOrEmpty(pendingText)) + { + pendingText += "\n" + messageText; + } + else + { + pendingText = messageText; + } + } + if (pendingAssistant == null) + { + pendingAssistant = message; // Keep first assistant for properties + } + } + // Skip empty assistant messages (neither text nor tool calls) + } + else + { + // Non-assistant message: flush pending assistant message if any + if (pendingAssistant != null && (pendingToolCalls.Any() || !string.IsNullOrEmpty(pendingText))) + { + // Create merged assistant message + var contents = new List(); + if (!string.IsNullOrEmpty(pendingText)) + { + contents.Add(new TextContent(pendingText)); + } + contents.AddRange(pendingToolCalls); + + result.Add(new ChatMessage(ChatRole.Assistant, contents) + { + AdditionalProperties = pendingAssistant.AdditionalProperties + }); + + // Reset pending state + pendingAssistant = null; + pendingToolCalls.Clear(); + pendingText = null; + } + + // Add the non-assistant message + result.Add(message); + } + } + + // Flush any remaining assistant message + if (pendingAssistant != null && (pendingToolCalls.Any() || !string.IsNullOrEmpty(pendingText))) + { + var contents = new List(); + if (!string.IsNullOrEmpty(pendingText)) + { + contents.Add(new TextContent(pendingText)); + } + contents.AddRange(pendingToolCalls); + + result.Add(new ChatMessage(ChatRole.Assistant, contents) + { + AdditionalProperties = pendingAssistant.AdditionalProperties + }); + } + + return result; + } + + private IEnumerable ExpandToolMessages(IEnumerable messages) + { + // Each FunctionResultContent must be a separate message with its own tool_call_id + // The API requires one tool message per tool_call_id in the assistant's tool_calls + foreach (var message in messages) + { + if (message.Role == ChatRole.Tool && message.Contents != null) + { + var functionResults = message.Contents.OfType().ToList(); + if (functionResults.Count > 1) + { + // Split into multiple messages, one per function result + foreach (var result in functionResults) + { + yield return new ChatMessage(ChatRole.Tool, new List { result }) + { + AdditionalProperties = message.AdditionalProperties + }; + } + } + else + { + // Single result or no results - keep as is + yield return message; + } + } + else + { + // Non-tool message - keep as is + yield return message; + } + } + } + + private ChatMessageDto ConvertMessage(ChatMessage message) + { + var dto = new ChatMessageDto + { + Role = message.Role.Value.ToLowerInvariant() + }; + + // Handle content + if (message.Contents != null) + { + var textContent = message.Contents + .OfType() + .FirstOrDefault(); + + if (textContent != null) + { + dto.Content = textContent.Text; + } + } + else if (message.Text != null) + { + dto.Content = message.Text; + } + + // Handle tool calls + var toolCalls = message.Contents?.OfType().ToList(); + if (toolCalls != null && toolCalls.Count > 0) + { + dto.ToolCalls = toolCalls.Select(tc => new ToolCallDto + { + Id = tc.CallId ?? Guid.NewGuid().ToString(), + Type = "function", + Function = new FunctionCallDto + { + Name = tc.Name, + Arguments = JsonSerializer.Serialize(tc.Arguments) + } + }).ToList(); + } + + // Handle tool results + var toolResult = message.Contents?.OfType().FirstOrDefault(); + if (toolResult != null) + { + dto.ToolCallId = toolResult.CallId; + dto.Content = toolResult.Result?.ToString() ?? string.Empty; + } + + return dto; + } + + private ToolDto ConvertTool(AITool tool) + { + if (tool is AIFunction function) + { + return new ToolDto + { + Type = "function", + Function = new FunctionDto + { + Name = function.Name, + Description = function.Description, + Parameters = function.JsonSchema + } + }; + } + + throw new NotSupportedException($"Tool type {tool.GetType().Name} is not supported"); + } + + private ChatResponse ConvertToResponse(ChatCompletionResponse copilotResponse) + { + var chatResponse = new ChatResponse(new List()) + { + CreatedAt = copilotResponse.Created.HasValue ? DateTimeOffset.FromUnixTimeSeconds(copilotResponse.Created.Value) : null, + ResponseId = copilotResponse.Id, + ModelId = copilotResponse.Model + }; + + // Process all choices (not just the first one) + if (copilotResponse.Choices != null) + { + foreach (var choice in copilotResponse.Choices) + { + if (choice.Message == null) + { + continue; + } + + var message = choice.Message; + var contentsList = new List(); + + // Handle tool calls first (they may come without text content) + if (message.ToolCalls != null && message.ToolCalls.Count > 0) + { + foreach (var toolCall in message.ToolCalls) + { + try + { + var arguments = JsonSerializer.Deserialize>(toolCall.Function.Arguments); + contentsList.Add(new FunctionCallContent( + toolCall.Id, + toolCall.Function.Name, + arguments)); + } + catch (JsonException ex) + { + logger?.LogError($"Failed to deserialize tool call arguments: {ex.Message}"); + } + } + } + + // Add text content if present (may be null when tool calls are present) + if (!string.IsNullOrEmpty(message.Content)) + { + contentsList.Add(new TextContent(message.Content)); + } + + var chatMessage = new ChatMessage(new ChatRole(message.Role ?? "assistant"), contentsList); + + // Add any additional properties from the choice to the message + if (choice.AdditionalProperties != null) + { + if (chatMessage.AdditionalProperties == null) + { + chatMessage.AdditionalProperties = new AdditionalPropertiesDictionary(); + } + foreach (var kvp in choice.AdditionalProperties) + { + chatMessage.AdditionalProperties[kvp.Key] = kvp.Value; + } + } + + // Preserve reasoning_opaque for round-tripping + if (!string.IsNullOrEmpty(message.ReasoningOpaque)) + { + if (chatMessage.AdditionalProperties == null) + { + chatMessage.AdditionalProperties = new AdditionalPropertiesDictionary(); + } + chatMessage.AdditionalProperties["reasoning_opaque"] = message.ReasoningOpaque; + } + + // Add message additional properties + if (message.AdditionalProperties != null) + { + if (chatMessage.AdditionalProperties == null) + { + chatMessage.AdditionalProperties = new AdditionalPropertiesDictionary(); + } + foreach (var kvp in message.AdditionalProperties) + { + chatMessage.AdditionalProperties[kvp.Key] = kvp.Value; + } + } + + chatResponse.Messages.Add(chatMessage); + } + + // Set the finish reason from the last choice (if any) + var lastChoice = copilotResponse.Choices.LastOrDefault(); + if (lastChoice != null && !string.IsNullOrEmpty(lastChoice.FinishReason)) + { + chatResponse.FinishReason = ConvertFinishReason(lastChoice.FinishReason); + } + } + + // Add usage information + if (copilotResponse.Usage != null) + { + chatResponse.Usage = new UsageDetails + { + InputTokenCount = copilotResponse.Usage.PromptTokens, + OutputTokenCount = copilotResponse.Usage.CompletionTokens, + TotalTokenCount = copilotResponse.Usage.TotalTokens + }; + } + + // Add system_fingerprint if present + if (!string.IsNullOrEmpty(copilotResponse.SystemFingerprint)) + { + if (chatResponse.AdditionalProperties == null) + { + chatResponse.AdditionalProperties = new AdditionalPropertiesDictionary(); + } + chatResponse.AdditionalProperties["system_fingerprint"] = copilotResponse.SystemFingerprint; + } + + // Merge any additional properties from the API response + if (copilotResponse.AdditionalProperties != null) + { + if (chatResponse.AdditionalProperties == null) + { + chatResponse.AdditionalProperties = new AdditionalPropertiesDictionary(); + } + foreach (var kvp in copilotResponse.AdditionalProperties) + { + chatResponse.AdditionalProperties[kvp.Key] = kvp.Value; + } + } + + return chatResponse; + } + + private ChatResponseUpdate? ConvertToUpdate(ChatCompletionChunk chunk) + { + var choice = chunk.Choices[0]; + var delta = choice.Delta; + + var contentsList = new List(); + + if (!string.IsNullOrEmpty(delta.Content)) + { + contentsList.Add(new TextContent(delta.Content)); + } + + // Handle tool calls in streaming + if (delta.ToolCalls != null && delta.ToolCalls.Count > 0) + { + foreach (var toolCall in delta.ToolCalls) + { + if (!string.IsNullOrEmpty(toolCall.Function.Name)) + { + var arguments = string.IsNullOrEmpty(toolCall.Function.Arguments) + ? new Dictionary() + : JsonSerializer.Deserialize>(toolCall.Function.Arguments); + + contentsList.Add(new FunctionCallContent( + toolCall.Id, + toolCall.Function.Name, + arguments)); + } + } + } + + ChatRole? role = null; + if (!string.IsNullOrEmpty(delta.Role)) + { + role = new ChatRole(delta.Role!); + } + + var update = new ChatResponseUpdate + { + Role = role, + Contents = contentsList, + FinishReason = ConvertFinishReason(choice.FinishReason), + ModelId = chunk.Model + }; + + return update; + } + + private ChatFinishReason? ConvertFinishReason(string? finishReason) + { + return finishReason?.ToLowerInvariant() switch + { + "stop" => ChatFinishReason.Stop, + "length" => ChatFinishReason.Length, + "tool_calls" => ChatFinishReason.ToolCalls, + "content_filter" => ChatFinishReason.ContentFilter, + _ => null + }; + } + + public void Dispose() + { + httpClient?.Dispose(); + tokenProvider?.Dispose(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return serviceType == typeof(GitHubCopilotTokenProvider) ? tokenProvider : null; + } + } +} diff --git a/src/StructuredLogger.LLM/Clients/GitHub/GitHubCopilotClientBuilder.cs b/src/StructuredLogger.LLM/Clients/GitHub/GitHubCopilotClientBuilder.cs new file mode 100644 index 000000000..1f7468f6e --- /dev/null +++ b/src/StructuredLogger.LLM/Clients/GitHub/GitHubCopilotClientBuilder.cs @@ -0,0 +1,103 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using StructuredLogger.LLM.Logging; + +namespace StructuredLogger.LLM.Clients.GitHub +{ + /// + /// Builder for creating GitHub Copilot chat clients with proper initialization. + /// + public class GitHubCopilotClientBuilder + { + private string? githubToken; + private string modelName = "claude-sonnet-4.5"; + private CopilotAccountType accountType = CopilotAccountType.Individual; + private DeviceCodeCallback? deviceCodeCallback; + private ILLMLogger? logger; + + /// + /// Sets the GitHub access token (skips device flow if provided). + /// + public GitHubCopilotClientBuilder WithGitHubToken(string token) + { + this.githubToken = token; + return this; + } + + /// + /// Sets the model name (default: claude-sonnet-4.5). + /// + public GitHubCopilotClientBuilder WithModel(string model) + { + this.modelName = model; + return this; + } + + /// + /// Sets the Copilot account type (default: Individual). + /// + public GitHubCopilotClientBuilder WithAccountType(CopilotAccountType type) + { + this.accountType = type; + return this; + } + + /// + /// Sets the device code callback for authentication UI. + /// If not set, device code will be printed to console. + /// + public GitHubCopilotClientBuilder WithDeviceCodeCallback(DeviceCodeCallback callback) + { + this.deviceCodeCallback = callback; + return this; + } + + /// + /// Sets the logger for debugging and error reporting. + /// If not set, uses NullLLMLogger (silent). + /// + public GitHubCopilotClientBuilder WithLogger(ILLMLogger logger) + { + this.logger = logger; + return this; + } + + /// + /// Builds and initializes the GitHub Copilot client. + /// If no GitHub token is provided, initiates device flow authentication. + /// + public async Task BuildAsync(CancellationToken cancellationToken = default) + { + // If no token provided, use device flow + if (string.IsNullOrWhiteSpace(githubToken)) + { + using var authenticator = new GitHubDeviceFlowAuthenticator(deviceCodeCallback); + githubToken = await authenticator.AuthenticateAsync(cancellationToken); + } + + // Create token provider and get initial Copilot token + var tokenProvider = new GitHubCopilotTokenProvider(githubToken!, accountType, logger); + await tokenProvider.GetCopilotTokenAsync(cancellationToken); + + // Create client with logger + return new GitHubCopilotChatClient(tokenProvider, modelName, logger); + } + + /// + /// Creates a builder from LLMConfiguration. + /// + public static GitHubCopilotClientBuilder FromConfiguration(LLMConfiguration config) + { + var builder = new GitHubCopilotClientBuilder() + .WithModel(config.ModelName); + + if (!string.IsNullOrWhiteSpace(config.ApiKey)) + { + builder.WithGitHubToken(config.ApiKey); + } + + return builder; + } + } +} diff --git a/src/StructuredLogger.LLM/Clients/GitHub/GitHubCopilotModelInfo.cs b/src/StructuredLogger.LLM/Clients/GitHub/GitHubCopilotModelInfo.cs new file mode 100644 index 000000000..3274f93ba --- /dev/null +++ b/src/StructuredLogger.LLM/Clients/GitHub/GitHubCopilotModelInfo.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace StructuredLogger.LLM.Clients.GitHub +{ + /// + /// Response from GitHub Copilot models API. + /// + public class ModelsResponse + { + [JsonPropertyName("data")] + public List Data { get; set; } = new List(); + } + + /// + /// Information about an available GitHub Copilot model. + /// + public class ModelInfo + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("model_picker_enabled")] + public bool ModelPickerEnabled { get; set; } + + [JsonPropertyName("policy")] + public ModelPolicy Policy { get; set; } = new ModelPolicy(); + } + + /// + /// Model policy information. + /// + public class ModelPolicy + { + [JsonPropertyName("state")] + public string State { get; set; } = string.Empty; + } +} diff --git a/src/StructuredLogger.LLM/Clients/GitHub/GitHubCopilotModels.cs b/src/StructuredLogger.LLM/Clients/GitHub/GitHubCopilotModels.cs new file mode 100644 index 000000000..c7a84ee81 --- /dev/null +++ b/src/StructuredLogger.LLM/Clients/GitHub/GitHubCopilotModels.cs @@ -0,0 +1,311 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace StructuredLogger.LLM.Clients.GitHub +{ + /// + /// Account type for GitHub Copilot. + /// + public enum CopilotAccountType + { + Individual, + Business, + Enterprise + } + + /// + /// GitHub Copilot token with metadata. + /// + public class CopilotToken + { + [JsonPropertyName("token")] + public string Token { get; set; } = string.Empty; + + [JsonPropertyName("expires_at")] + public long ExpiresAtUnix { get; set; } + + [JsonPropertyName("refresh_in")] + public int RefreshIn { get; set; } + + [JsonIgnore] + public DateTimeOffset ExpiresAt + { + get => DateTimeOffset.FromUnixTimeSeconds(ExpiresAtUnix); + set => ExpiresAtUnix = value.ToUnixTimeSeconds(); + } + + [JsonIgnore] + public string? BaseUrl { get; set; } + + [JsonIgnore] + public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAt.AddMinutes(-5); + } + + /// + /// Response from GitHub device code flow initiation. + /// + public class DeviceCodeResponse + { + [JsonPropertyName("device_code")] + public string DeviceCode { get; set; } = string.Empty; + + [JsonPropertyName("user_code")] + public string UserCode { get; set; } = string.Empty; + + [JsonPropertyName("verification_uri")] + public string VerificationUri { get; set; } = string.Empty; + + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } + + [JsonPropertyName("interval")] + public int Interval { get; set; } + } + + /// + /// Response from GitHub access token polling. + /// + public class AccessTokenResponse + { + [JsonPropertyName("access_token")] + public string? AccessToken { get; set; } + + [JsonPropertyName("token_type")] + public string? TokenType { get; set; } + + [JsonPropertyName("scope")] + public string? Scope { get; set; } + + [JsonPropertyName("error")] + public string? Error { get; set; } + + [JsonPropertyName("error_description")] + public string? ErrorDescription { get; set; } + } + + /// + /// Chat completion request for GitHub Copilot API. + /// Compatible with OpenAI format. + /// + public class ChatCompletionRequest + { + [JsonPropertyName("model")] + public string Model { get; set; } = string.Empty; + + [JsonPropertyName("messages")] + public List Messages { get; set; } = new List(); + + [JsonPropertyName("temperature")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public float? Temperature { get; set; } + + [JsonPropertyName("top_p")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public float? TopP { get; set; } + + [JsonPropertyName("max_tokens")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? MaxTokens { get; set; } + + [JsonPropertyName("stream")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Stream { get; set; } + + [JsonPropertyName("tools")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Tools { get; set; } + + [JsonPropertyName("tool_choice")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ToolChoice { get; set; } + } + + /// + /// Chat message DTO for API requests/responses. + /// + public class ChatMessageDto + { + [JsonPropertyName("role")] + public string Role { get; set; } = string.Empty; + + [JsonPropertyName("content")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Content { get; set; } + + [JsonPropertyName("tool_calls")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? ToolCalls { get; set; } + + [JsonPropertyName("tool_call_id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ToolCallId { get; set; } + + [JsonPropertyName("reasoning_opaque")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ReasoningOpaque { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalProperties { get; set; } + } + + /// + /// Tool definition for function calling. + /// + public class ToolDto + { + [JsonPropertyName("type")] + public string Type { get; set; } = "function"; + + [JsonPropertyName("function")] + public FunctionDto Function { get; set; } = new FunctionDto(); + } + + /// + /// Function definition. + /// + public class FunctionDto + { + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; set; } + + [JsonPropertyName("parameters")] + public object? Parameters { get; set; } + } + + /// + /// Tool call from assistant. + /// + public class ToolCallDto + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public string Type { get; set; } = "function"; + + [JsonPropertyName("function")] + public FunctionCallDto Function { get; set; } = new FunctionCallDto(); + } + + /// + /// Function call details. + /// + public class FunctionCallDto + { + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("arguments")] + public string Arguments { get; set; } = string.Empty; + } + + /// + /// Response from GitHub Copilot chat completions. + /// + public class ChatCompletionResponse + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("model")] + public string Model { get; set; } = string.Empty; + + [JsonPropertyName("choices")] + public List Choices { get; set; } = new List(); + + [JsonPropertyName("usage")] + public UsageDto? Usage { get; set; } + + [JsonPropertyName("created")] + public long? Created { get; set; } + + [JsonPropertyName("system_fingerprint")] + public string? SystemFingerprint { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalProperties { get; set; } + } + + /// + /// Choice in the response. + /// + public class ChoiceDto + { + [JsonPropertyName("index")] + public int Index { get; set; } + + [JsonPropertyName("message")] + public ChatMessageDto Message { get; set; } = new ChatMessageDto(); + + [JsonPropertyName("finish_reason")] + public string? FinishReason { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalProperties { get; set; } + } + + /// + /// Token usage information. + /// + public class UsageDto + { + [JsonPropertyName("prompt_tokens")] + public int PromptTokens { get; set; } + + [JsonPropertyName("completion_tokens")] + public int CompletionTokens { get; set; } + + [JsonPropertyName("total_tokens")] + public int TotalTokens { get; set; } + } + + /// + /// Streaming chunk from chat completions. + /// + public class ChatCompletionChunk + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("model")] + public string Model { get; set; } = string.Empty; + + [JsonPropertyName("choices")] + public List Choices { get; set; } = new List(); + } + + /// + /// Streaming choice. + /// + public class StreamChoiceDto + { + [JsonPropertyName("index")] + public int Index { get; set; } + + [JsonPropertyName("delta")] + public DeltaDto Delta { get; set; } = new DeltaDto(); + + [JsonPropertyName("finish_reason")] + public string? FinishReason { get; set; } + } + + /// + /// Delta in streaming response. + /// + public class DeltaDto + { + [JsonPropertyName("role")] + public string? Role { get; set; } + + [JsonPropertyName("content")] + public string? Content { get; set; } + + [JsonPropertyName("tool_calls")] + public List? ToolCalls { get; set; } + } +} diff --git a/src/StructuredLogger.LLM/Clients/GitHub/GitHubCopilotTokenProvider.cs b/src/StructuredLogger.LLM/Clients/GitHub/GitHubCopilotTokenProvider.cs new file mode 100644 index 000000000..8822efaba --- /dev/null +++ b/src/StructuredLogger.LLM/Clients/GitHub/GitHubCopilotTokenProvider.cs @@ -0,0 +1,133 @@ +using System; +using System.Net.Http; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using StructuredLogger.LLM.Logging; + +namespace StructuredLogger.LLM.Clients.GitHub +{ + /// + /// Manages GitHub Copilot tokens - exchanges GitHub access tokens for Copilot tokens + /// and handles token refresh. + /// + public class GitHubCopilotTokenProvider : IDisposable + { + private const string CopilotTokenUrl = "https://api.github.com/copilot_internal/v2/token"; + private const string GitHubApiVersion = "2022-11-28"; + + private readonly string githubAccessToken; + private readonly CopilotAccountType accountType; + private readonly HttpClient httpClient; + private readonly ILLMLogger? logger; + private CopilotToken? currentToken; + + public event EventHandler? TokenRefreshed; + + /// + /// Initializes a new instance of the GitHubCopilotTokenProvider. + /// + public GitHubCopilotTokenProvider(string githubAccessToken, CopilotAccountType accountType = CopilotAccountType.Individual, ILLMLogger? logger = null) + { + if (string.IsNullOrWhiteSpace(githubAccessToken)) + throw new ArgumentException("GitHub access token is required.", nameof(githubAccessToken)); + + this.githubAccessToken = githubAccessToken; + this.accountType = accountType; + this.httpClient = new HttpClient(); + this.logger = logger; + } + + /// + /// Gets a Copilot token by exchanging the GitHub access token. + /// + /// Thrown when the GitHub access token is invalid or expired. + public async Task GetCopilotTokenAsync(CancellationToken cancellationToken = default) + { + var request = new HttpRequestMessage(HttpMethod.Get, CopilotTokenUrl); + request.Headers.Add("Authorization", $"Bearer {githubAccessToken}"); + request.Headers.Add("Accept", "application/json"); + request.Headers.Add("User-Agent", "MSBuildStructuredLogViewer"); + request.Headers.Add("X-GitHub-Api-Version", GitHubApiVersion); + + var response = await httpClient.SendAsync(request, cancellationToken); + + // Handle expired or invalid GitHub access token + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || + response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + throw new UnauthorizedAccessException( + "GitHub access token is invalid or expired. Please re-authenticate using the GitHub Login button in the configuration dialog."); + } + + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + var token = JsonSerializer.Deserialize(json); + + if (token == null) + { + throw new InvalidOperationException("Failed to deserialize Copilot token response"); + } + + // Extract base URL from token + token.BaseUrl = ExtractBaseUrlFromToken(token.Token, accountType); + + currentToken = token; + logger?.LogVerbose($"Copilot token obtained. Expires at: {token.ExpiresAt}"); + + return token; + } + + /// + /// Extracts the base URL from the Copilot token's proxy-ep parameter. + /// Token format: tid=...;exp=...;proxy-ep=proxy.individual.githubcopilot.com;... + /// + private string ExtractBaseUrlFromToken(string token, CopilotAccountType accountType) + { + // Try to extract from proxy-ep parameter + var match = Regex.Match(token, @"proxy-ep=([^;]+)"); + if (match.Success) + { + var proxyHost = match.Groups[1].Value; + // Convert proxy.X.githubcopilot.com to api.X.githubcopilot.com + var apiHost = proxyHost.Replace("proxy.", "api."); + return $"https://{apiHost}"; + } + + // Fallback based on account type + return accountType switch + { + CopilotAccountType.Business => "https://api.business.githubcopilot.com", + CopilotAccountType.Enterprise => "https://api.enterprise.githubcopilot.com", + _ => "https://api.individual.githubcopilot.com" + }; + } + + /// + /// Gets the current Copilot token (may be expired). + /// + public CopilotToken? GetCurrentToken() => currentToken; + + /// + /// Refreshes the token if needed. + /// + public async Task RefreshIfNeededAsync(CancellationToken cancellationToken = default) + { + if (currentToken == null || currentToken.IsExpired) + { + var newToken = await GetCopilotTokenAsync(cancellationToken); + TokenRefreshed?.Invoke(this, newToken); + return newToken; + } + + return currentToken; + } + + public void Dispose() + { + httpClient?.Dispose(); + } + } +} diff --git a/src/StructuredLogger.LLM/Clients/GitHub/GitHubDeviceFlowAuthenticator.cs b/src/StructuredLogger.LLM/Clients/GitHub/GitHubDeviceFlowAuthenticator.cs new file mode 100644 index 000000000..42bd2cad4 --- /dev/null +++ b/src/StructuredLogger.LLM/Clients/GitHub/GitHubDeviceFlowAuthenticator.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace StructuredLogger.LLM.Clients.GitHub +{ + /// + /// Callback for displaying device code to user. + /// Parameters: userCode, verificationUrl + /// + public delegate void DeviceCodeCallback(string userCode, string verificationUrl); + + /// + /// Implements GitHub OAuth Device Code Flow for authentication. + /// See: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow + /// + public class GitHubDeviceFlowAuthenticator : IDisposable + { + private const string GitHubClientId = "Iv1" + "." + "b507" + "a08c87ecfe98"; + private const string GitHubDeviceCodeUrl = "https://github.com/login/device/code"; + private const string GitHubAccessTokenUrl = "https://github.com/login/oauth/access_token"; + + private readonly DeviceCodeCallback? deviceCodeCallback; + private readonly HttpClient httpClient; + + /// + /// Initializes a new instance of the GitHubDeviceFlowAuthenticator. + /// + /// Optional callback invoked with (userCode, verificationUrl) to display to user. If null, info is written to console. + public GitHubDeviceFlowAuthenticator(DeviceCodeCallback? deviceCodeCallback = null) + { + this.deviceCodeCallback = deviceCodeCallback; + this.httpClient = new HttpClient(); + } + + /// + /// Authenticates user via device code flow. + /// + public async Task AuthenticateAsync(CancellationToken cancellationToken = default) + { + // Step 1: Request device code + var deviceCodeResponse = await RequestDeviceCodeAsync(cancellationToken); + + // Step 2: Display device code to user + if (deviceCodeCallback != null) + { + deviceCodeCallback(deviceCodeResponse.UserCode, deviceCodeResponse.VerificationUri); + } + else + { + // Fallback to console output + Console.WriteLine(); + Console.WriteLine("=".PadRight(70, '=')); + Console.WriteLine("GitHub Authentication Required"); + Console.WriteLine("=".PadRight(70, '=')); + Console.WriteLine(); + Console.WriteLine($"Please visit: {deviceCodeResponse.VerificationUri}"); + Console.WriteLine($"And enter code: {deviceCodeResponse.UserCode}"); + Console.WriteLine(); + Console.WriteLine("Waiting for authorization..."); + Console.WriteLine(); + } + + // Step 3: Poll for access token + var accessToken = await PollForAccessTokenAsync(deviceCodeResponse, cancellationToken); + + return accessToken; + } + + /// + /// Requests a device code from GitHub. + /// + private async Task RequestDeviceCodeAsync(CancellationToken cancellationToken) + { + var request = new HttpRequestMessage(HttpMethod.Post, GitHubDeviceCodeUrl); + request.Headers.Add("Accept", "application/json"); + + var content = new FormUrlEncodedContent(new[] + { + new KeyValuePair("client_id", GitHubClientId), + new KeyValuePair("scope", "read:user") + }); + request.Content = content; + + var response = await httpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + var deviceCodeResponse = JsonSerializer.Deserialize(json); + + if (deviceCodeResponse == null) + { + throw new InvalidOperationException("Failed to deserialize device code response"); + } + + return deviceCodeResponse; + } + + /// + /// Polls GitHub for access token after user authorizes. + /// + private async Task PollForAccessTokenAsync(DeviceCodeResponse deviceCode, CancellationToken cancellationToken) + { + var expiresAt = DateTimeOffset.UtcNow.AddSeconds(deviceCode.ExpiresIn); + var intervalMs = deviceCode.Interval * 1000; + + while (DateTimeOffset.UtcNow < expiresAt) + { + await Task.Delay(intervalMs, cancellationToken); + + var request = new HttpRequestMessage(HttpMethod.Post, GitHubAccessTokenUrl); + request.Headers.Add("Accept", "application/json"); + + var content = new FormUrlEncodedContent(new[] + { + new KeyValuePair("client_id", GitHubClientId), + new KeyValuePair("device_code", deviceCode.DeviceCode), + new KeyValuePair("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + }); + request.Content = content; + + var response = await httpClient.SendAsync(request, cancellationToken); + var json = await response.Content.ReadAsStringAsync(); + var tokenResponse = JsonSerializer.Deserialize(json); + + if (tokenResponse == null) + { + continue; + } + + if (!string.IsNullOrEmpty(tokenResponse.AccessToken)) + { + return tokenResponse.AccessToken!; + } + + if (!string.IsNullOrEmpty(tokenResponse.Error)) + { + if (tokenResponse.Error == "authorization_pending") + { + // User hasn't authorized yet, continue polling + continue; + } + else if (tokenResponse.Error == "slow_down") + { + // Increase interval by 5 seconds + intervalMs += 5000; + continue; + } + else if (tokenResponse.Error == "expired_token") + { + throw new InvalidOperationException("Device code expired. Please try again."); + } + else if (tokenResponse.Error == "access_denied") + { + throw new InvalidOperationException("User denied authorization."); + } + else + { + throw new InvalidOperationException($"GitHub returned error: {tokenResponse.Error} - {tokenResponse.ErrorDescription}"); + } + } + } + + throw new TimeoutException("Device code expired before user authorized."); + } + + public void Dispose() + { + httpClient?.Dispose(); + } + } +} diff --git a/src/StructuredLogger.LLM/Configuration/MultiProviderLLMClient.cs b/src/StructuredLogger.LLM/Configuration/MultiProviderLLMClient.cs new file mode 100644 index 000000000..fab7921fa --- /dev/null +++ b/src/StructuredLogger.LLM/Configuration/MultiProviderLLMClient.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Anthropic; +using Azure; +using Azure.AI.Inference; +using Azure.AI.OpenAI; +using Azure.Core; +using Microsoft.Extensions.AI; +using StructuredLogger.LLM.Clients.GitHub; +using StructuredLogger.LLM.Logging; + +namespace StructuredLogger.LLM +{ + /// + /// Multi-provider LLM client supporting Azure OpenAI, Azure AI Inference, Anthropic, and GitHub Copilot. + /// Provides automatic retry and resilience logic. + /// + public class MultiProviderLLMClient : IDisposable + { + private IChatClient? chatClient; + private ResilientChatClient? resilientClient; + private readonly string modelName; + private readonly LLMConfiguration.ClientType clientType; + private readonly LLMConfiguration configuration; + private readonly DeviceCodeCallback? deviceCodeCallback; + private readonly ILLMLogger? logger; + private bool isInitialized; + + /// + /// Initializes a new MultiProviderLLMClient. + /// For GitHub Copilot, call InitializeAsync() before using the client. + /// For other providers, initialization is synchronous and happens in constructor. + /// + public MultiProviderLLMClient( + LLMConfiguration config, + DeviceCodeCallback? deviceCodeCallback = null, + ILLMLogger? logger = null) + { + if (config == null) + { + throw new ArgumentNullException(nameof(config)); + } + + if (!config.IsConfigured) + { + throw new InvalidOperationException("LLM configuration is incomplete."); + } + + this.configuration = config; + this.modelName = config.ModelName; + this.clientType = config.Type; + this.deviceCodeCallback = deviceCodeCallback; + this.logger = logger; + + // For non-GitHub Copilot providers, initialize synchronously + if (config.Type != LLMConfiguration.ClientType.GitHubCopilot) + { + InitializeSynchronousClient(config); + isInitialized = true; + } + } + + /// + /// Initializes the client asynchronously. + /// Required for GitHub Copilot (for token exchange and device flow). + /// No-op for other providers (already initialized in constructor). + /// + public async Task InitializeAsync(CancellationToken cancellationToken = default) + { + if (isInitialized) + { + return; // Already initialized + } + + if (configuration.Type == LLMConfiguration.ClientType.GitHubCopilot) + { + await InitializeGitHubCopilotAsync(cancellationToken); + isInitialized = true; + } + } + + private void InitializeSynchronousClient(LLMConfiguration config) + { + var endpoint = new Uri(config.Endpoint); + var credential = new AzureKeyCredential(config.ApiKey); + + if (config.Type == LLMConfiguration.ClientType.Anthropic) + { + chatClient = new AnthropicClient( + new Anthropic.Core.ClientOptions() + { + BaseUrl = endpoint, + APIKey = config.ApiKey, + }) + .AsIChatClient(modelName); + } + else if (config.Type == LLMConfiguration.ClientType.AzureOpenAI) + { + var openAIClient = new AzureOpenAIClient(endpoint, credential); + chatClient = openAIClient.GetChatClient(modelName).AsIChatClient(); + } + else + { + var inferenceClient = new ChatCompletionsClient(endpoint, credential); + chatClient = inferenceClient.AsIChatClient(modelName); + } + + WrapWithResilienceAndFunctionInvocation(); + } + + private async Task InitializeGitHubCopilotAsync(CancellationToken cancellationToken) + { + var builder = GitHubCopilotClientBuilder.FromConfiguration(configuration); + + if (deviceCodeCallback != null) + { + builder.WithDeviceCodeCallback(deviceCodeCallback); + } + + if (logger != null) + { + builder.WithLogger(logger); + } + + chatClient = await builder.BuildAsync(cancellationToken); + WrapWithResilienceAndFunctionInvocation(); + } + + private void WrapWithResilienceAndFunctionInvocation() + { + if (chatClient == null) + { + throw new InvalidOperationException("Chat client not initialized"); + } + + // Wrap with resilient client for automatic retry on rate limits and transient errors + resilientClient = new ResilientChatClient(chatClient, maxRetries: 10, logger: logger); + chatClient = resilientClient; + + // Apply function invocation after resilient wrapper + chatClient = new ChatClientBuilder(chatClient).UseFunctionInvocation().Build(); + } + + public string ProviderName => clientType switch + { + LLMConfiguration.ClientType.AzureOpenAI => "Azure OpenAI", + LLMConfiguration.ClientType.AzureInference => "Azure AI Inference", + LLMConfiguration.ClientType.Anthropic => "Anthropic", + LLMConfiguration.ClientType.GitHubCopilot => "GitHub Copilot", + _ => "Unknown" + }; + + public IChatClient ChatClient + { + get + { + if (!isInitialized) + { + throw new InvalidOperationException("Client not initialized. Call InitializeAsync() first for GitHub Copilot clients."); + } + return chatClient!; + } + } + + /// + /// Access to the resilient client for event subscription + /// + public ResilientChatClient ResilientClient + { + get + { + if (!isInitialized) + { + throw new InvalidOperationException("Client not initialized. Call InitializeAsync() first for GitHub Copilot clients."); + } + return resilientClient!; + } + } + + public async Task CompleteChatAsync( + IList messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + if (!isInitialized) + { + throw new InvalidOperationException("Client not initialized. Call InitializeAsync() first for GitHub Copilot clients."); + } + return await chatClient!.GetResponseAsync(messages, options, cancellationToken); + } + + public IAsyncEnumerable CompleteChatStreamingAsync( + IList messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + if (!isInitialized) + { + throw new InvalidOperationException("Client not initialized. Call InitializeAsync() first for GitHub Copilot clients."); + } + return chatClient!.GetStreamingResponseAsync(messages, options, cancellationToken); + } + + public void Dispose() + { + chatClient?.Dispose(); + } + } +} diff --git a/src/StructuredLogger.LLM/Configuration/ResilientChatClient.cs b/src/StructuredLogger.LLM/Configuration/ResilientChatClient.cs new file mode 100644 index 000000000..0c732ee3f --- /dev/null +++ b/src/StructuredLogger.LLM/Configuration/ResilientChatClient.cs @@ -0,0 +1,570 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Anthropic.Exceptions; +using Azure; +using Microsoft.Build.Logging.StructuredLogger; +using Microsoft.Extensions.AI; +using StructuredLogger.LLM.Logging; + +namespace StructuredLogger.LLM +{ + /// + /// Type of resilience action being taken + /// + public enum ResilienceType + { + Throttling, + ContextTrimming + } + + /// + /// Event args for resilience events + /// + public class ResilienceEventArgs : EventArgs + { + public string Message { get; set; } = string.Empty; + public int Attempt { get; set; } + public int MaxAttempts { get; set; } + public ResilienceType Type { get; set; } + } + + /// + /// Wraps an IChatClient to provide automatic retry with exponential backoff for recoverable errors. + /// Handles rate limit errors, transient failures, and context overflow with intelligent truncation. + /// + public class ResilientChatClient : IChatClient + { + private readonly IChatClient innerClient; + private readonly int maxRetries; + private readonly TimeSpan initialDelay; + private readonly TimeSpan maxDelay; + private readonly ILLMLogger? logger; + + /// + /// Raised when the client is retrying a request due to recoverable errors + /// + public event EventHandler? RequestRetrying; + + public ResilientChatClient(IChatClient innerClient, int maxRetries = 10, TimeSpan? initialDelay = null, TimeSpan? maxDelay = null, ILLMLogger? logger = null) + { + this.innerClient = innerClient ?? throw new ArgumentNullException(nameof(innerClient)); + this.maxRetries = maxRetries; + this.initialDelay = initialDelay ?? TimeSpan.FromSeconds(1); + this.maxDelay = maxDelay ?? TimeSpan.FromMinutes(2); + this.logger = logger; + } + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + return ExecuteWithRetryAsync( + messages, + options ?? new ChatOptions(), + cancellationToken); + } + + public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + // Note: Streaming doesn't benefit as much from retry logic since it's already started, + // but we wrap it for consistency + return innerClient.GetStreamingResponseAsync(messages, options, cancellationToken); + } + + public void Dispose() + { + innerClient?.Dispose(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return innerClient.GetService(serviceType, serviceKey); + } + + private async Task ExecuteWithRetryAsync( + IEnumerable messages, + ChatOptions options, + CancellationToken cancellationToken) + { + int attempt = 0; + TimeSpan delay = initialDelay; + var currentMessages = messages.ToList(); // Convert to list for potential truncation + + while (true) + { + try + { + return await innerClient.GetResponseAsync(currentMessages, options, cancellationToken); + } + catch (Exception ex) when (attempt < maxRetries && ex is not OperationCanceledException && !cancellationToken.IsCancellationRequested) + { + // Check if this is a context overflow error + var contextOverflow = ExtractContextOverflowInfo(ex, currentMessages); + if (contextOverflow.IsOverflow) + { + attempt++; + logger?.LogInfo( + $"Context overflow detected: {contextOverflow.CurrentTokens} > {contextOverflow.MaxTokens} " + + $"(attempt {attempt}/{maxRetries})"); + + // Raise context trimming event + RequestRetrying?.Invoke(this, new ResilienceEventArgs + { + Message = "Context trimmed", + Attempt = attempt, + MaxAttempts = maxRetries, + Type = ResilienceType.ContextTrimming + }); + + // Truncate messages to fit within limit + currentMessages = TruncateMessages( + currentMessages, + contextOverflow.MaxTokens, + contextOverflow.CurrentTokens); + + if (currentMessages.Count == 0) + { + logger?.LogError("Cannot truncate messages further. Throwing exception."); + throw; + } + + logger?.LogInfo( + $"Retrying with {currentMessages.Count} messages (estimated {EstimateTokens(currentMessages)} tokens)"); + + // Small delay before retry + await System.Threading.Tasks.Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken); + continue; + } + + // Handle other retryable errors (rate limits, transient failures) + if (IsRetryable(ex)) + { + attempt++; + + // Try to extract wait time from the error message + var waitTime = ExtractWaitTimeFromError(ex); + if (waitTime.HasValue) + { + delay = waitTime.Value; + logger?.LogInfo($"Rate limit hit. Waiting {delay.TotalSeconds}s as specified in error (attempt {attempt}/{maxRetries})"); + } + else + { + // Use exponential backoff + delay = TimeSpan.FromSeconds(Math.Min( + initialDelay.TotalSeconds * Math.Pow(2, attempt - 1), + maxDelay.TotalSeconds)); + logger?.LogInfo($"Retryable error encountered. Waiting {delay.TotalSeconds}s before retry (attempt {attempt}/{maxRetries}): {ex.Message}"); + } + + // Raise throttling event + RequestRetrying?.Invoke(this, new ResilienceEventArgs + { + Message = "Throttling requests", + Attempt = attempt, + MaxAttempts = maxRetries, + Type = ResilienceType.Throttling + }); + + // Check if delay would exceed cancellation + try + { + await System.Threading.Tasks.Task.Delay(delay, cancellationToken); + } + catch (OperationCanceledException) + { + // If cancelled during delay, re-throw + throw; + } + + // Continue to next attempt + continue; + } + + // If not retryable, throw + throw; + } + // If max retries exceeded, the loop will throw the exception + } + } + + /// + /// Determines if an exception is retryable. + /// + private bool IsRetryable(Exception ex) + { + // Anthropic rate limit exceptions + if (ex is AnthropicRateLimitException) + { + return true; + } + + // Azure RequestFailedException with rate limit status codes + if (ex is RequestFailedException requestEx) + { + return requestEx.Status == 429 || // Too Many Requests + requestEx.Status == 503 || // Service Unavailable + requestEx.Status == 504 || // Gateway Timeout + requestEx.Status == 402 || // Payment Required + requestEx.Status == 429 || // Too Many Requests + requestEx.Status == 502; // Bad Gateway + } + + // Check for HTTP status code in message (fallback) + if (ex.Message.Contains("429") || ex.Message.ContainsIgnoreCase("Rate limit")) + { + return true; + } + + // Transient network errors + if (ex is System.Net.Http.HttpRequestException || + ex is System.Net.Sockets.SocketException || + ex is TimeoutException) + { + return true; + } + + // Check inner exception + if (ex.InnerException != null) + { + return IsRetryable(ex.InnerException); + } + + return false; + } + + /// + /// Attempts to extract the wait time from rate limit error messages. + /// + private TimeSpan? ExtractWaitTimeFromError(Exception ex) + { + var message = ex.Message; + + // Look for patterns like "wait 59 seconds" or "wait for 59s" or "retry after 60 seconds" + var patterns = new[] + { + @"wait\s+(\d+)\s+seconds?", + @"wait\s+for\s+(\d+)\s*s\b", + @"retry\s+after\s+(\d+)\s+seconds?", + @"try\s+again\s+in\s+(\d+)\s+seconds?", + @"Please wait (\d+) seconds before retrying" + }; + + foreach (var pattern in patterns) + { + var match = Regex.Match(message, pattern, RegexOptions.IgnoreCase); + if (match.Success && int.TryParse(match.Groups[1].Value, out int seconds)) + { + // Add a small buffer (1 second) to ensure we're past the limit + return TimeSpan.FromSeconds(seconds + 1); + } + } + + // Check for Retry-After header value in Azure exceptions + if (ex is RequestFailedException requestEx && requestEx.Status == 429) + { + // Azure SDK may include retry-after in the exception details + // This is a best-effort attempt + } + + return null; + } + + /// + /// Context overflow information extracted from an exception. + /// + private struct ContextOverflowInfo + { + public bool IsOverflow { get; set; } + public int CurrentTokens { get; set; } + public int MaxTokens { get; set; } + } + + /// + /// Pattern definition for extracting token overflow information from error messages. + /// + private readonly struct OverflowPattern + { + public string Regex { get; } + public int CurrentTokensGroup { get; } + public int MaxTokensGroup { get; } + + public OverflowPattern(string regex, int currentTokensGroup, int maxTokensGroup) + { + Regex = regex; + CurrentTokensGroup = currentTokensGroup; + MaxTokensGroup = maxTokensGroup; + } + } + + /// + /// Patterns for detecting context overflow errors from various LLM providers. + /// + private static readonly OverflowPattern[] OverflowPatterns = + { + // Anthropic: "prompt is too long: 216483 tokens > 200000 maximum" + new(@"prompt is too long:\s*(\d+)\s*tokens?\s*>\s*(\d+)\s*maximum", 1, 2), + // GitHub Copilot: "prompt token count of 795491 exceeds the limit of 128000" + new(@"prompt token count of\s*(\d+)\s*exceeds the limit of\s*(\d+)", 1, 2), + // OpenAI: "maximum context length is 128000 tokens" + new(@"maximum context length is\s*(\d+)\s*tokens?", -1, 1), + // OpenAI: "context length of 150000 exceeds" + new(@"context length of\s*(\d+)\s*exceeds", 1, -1), + }; + + /// + /// Extracts context overflow information from an exception. + /// + private ContextOverflowInfo ExtractContextOverflowInfo(Exception ex, List messages) + { + var message = ex.Message; + + // Try known patterns first + foreach (var pattern in OverflowPatterns) + { + var result = TryMatchOverflowPattern(message, pattern, messages); + if (result.IsOverflow) + { + return result; + } + } + + // Check for model_max_prompt_tokens_exceeded code + if (message.ContainsIgnoreCase("model_max_prompt_tokens_exceeded")) + { + return CreateOverflowInfo(messages, ExtractLimitFromMessage(message, messages)); + } + + // Check for generic context/prompt + token + overflow keywords + if (IsGenericOverflowMessage(message)) + { + return CreateOverflowInfo(messages); + } + + // Check for Anthropic BadRequest with token mention + if (ex is AnthropicBadRequestException && message.ContainsIgnoreCase("token")) + { + return CreateOverflowInfo(messages); + } + + return new ContextOverflowInfo { IsOverflow = false }; + } + + private ContextOverflowInfo TryMatchOverflowPattern( + string message, + OverflowPattern pattern, + List messages) + { + var match = Regex.Match(message, pattern.Regex, RegexOptions.IgnoreCase); + if (!match.Success) + { + return new ContextOverflowInfo { IsOverflow = false }; + } + + int currentTokens = pattern.CurrentTokensGroup > 0 && + int.TryParse(match.Groups[pattern.CurrentTokensGroup].Value, out int c) + ? c + : EstimateTokens(messages); + + int maxTokens = pattern.MaxTokensGroup > 0 && + int.TryParse(match.Groups[pattern.MaxTokensGroup].Value, out int m) + ? m + : (int)(currentTokens * 0.9); // Estimate max as 90% of current + + return new ContextOverflowInfo + { + IsOverflow = true, + CurrentTokens = currentTokens, + MaxTokens = maxTokens + }; + } + + private ContextOverflowInfo CreateOverflowInfo(List messages, int? maxTokens = null) + { + int currentTokens = EstimateTokens(messages); + return new ContextOverflowInfo + { + IsOverflow = true, + CurrentTokens = currentTokens, + MaxTokens = maxTokens ?? (int)(currentTokens * 0.8) + }; + } + + private int ExtractLimitFromMessage(string message, List messages) + { + var match = Regex.Match(message, @"limit of\s*(\d+)", RegexOptions.IgnoreCase); + return match.Success && int.TryParse(match.Groups[1].Value, out int limit) + ? limit + : (int)(EstimateTokens(messages) * 0.8); + } + + private static bool IsGenericOverflowMessage(string message) + { + bool hasContextOrPrompt = message.ContainsIgnoreCase("context") || + message.ContainsIgnoreCase("prompt"); + bool hasToken = message.ContainsIgnoreCase("token"); + bool hasOverflowKeyword = message.ContainsIgnoreCase("too long") || + message.ContainsIgnoreCase("exceeds") || + message.ContainsIgnoreCase("limit"); + + return hasContextOrPrompt && hasToken && hasOverflowKeyword; + } + + /// + /// Estimates the number of tokens in messages (rough approximation). + /// Uses 4 characters ≈ 1 token as a conservative estimate. + /// + private int EstimateTokens(List messages) + { + int totalChars = 0; + foreach (var msg in messages) + { + // Get text content from the message + var text = msg.Text; + totalChars += text?.Length ?? 0; + // Add overhead for role and structure + totalChars += 50; + } + return totalChars / 4; // Conservative: 4 chars = 1 token + } + + /// + /// Intelligently truncates messages to fit within token limit. + /// Strategy: + /// 1. Keep system message intact (usually first) + /// 2. Keep most recent user message intact (usually last) + /// 3. Progressively remove older messages from the middle + /// 4. If still too long, truncate message contents + /// + private List TruncateMessages( + List messages, + int maxTokens, + int currentTokens) + { + if (messages.Count == 0) + { + return messages; + } + + // Calculate target tokens (use 95% of max as safety margin, or 80% of current if max unknown) + int targetTokens = maxTokens > 0 ? (int)(maxTokens * 0.95) : (int)(currentTokens * 0.8); + + logger?.LogVerbose( + $"Truncating messages: current={currentTokens}, max={maxTokens}, target={targetTokens}"); + + var result = new List(); + + // Identify system message (usually first) and latest user message (usually last) + ChatMessage? systemMessage = null; + ChatMessage? latestUserMessage = null; + var middleMessages = new List(); + + for (int i = 0; i < messages.Count; i++) + { + var msg = messages[i]; + if (i == 0 && msg.Role == ChatRole.System) + { + systemMessage = msg; + } + else if (i == messages.Count - 1 && msg.Role == ChatRole.User) + { + latestUserMessage = msg; + } + else + { + middleMessages.Add(msg); + } + } + + // Always include system message + if (systemMessage != null) + { + result.Add(systemMessage); + } + + // Try including all middle messages initially, then remove from oldest + var includedMiddle = new List(middleMessages); + int estimatedTokens = EstimateTokens(result); + + if (latestUserMessage != null) + { + estimatedTokens += EstimateTokens(new List { latestUserMessage }); + } + + // Remove middle messages from the beginning until we fit + while (includedMiddle.Count > 0) + { + var middleTokens = EstimateTokens(includedMiddle); + if (estimatedTokens + middleTokens <= targetTokens) + { + break; // Everything fits + } + + // Remove oldest message + includedMiddle.RemoveAt(0); + logger?.LogVerbose($"Removed 1 message, {includedMiddle.Count} middle messages remaining"); + } + + result.AddRange(includedMiddle); + + // Add latest user message + if (latestUserMessage != null) + { + result.Add(latestUserMessage); + } + + // Final check - if still too large, truncate content of messages + estimatedTokens = EstimateTokens(result); + if (estimatedTokens > targetTokens && result.Count > 0) + { + logger?.LogVerbose( + $"Still too large ({estimatedTokens} tokens), truncating message contents"); + + // Truncate from end backwards, but preserve system and last user message structure + double reductionFactor = (double)targetTokens / estimatedTokens; + + for (int i = 0; i < result.Count; i++) + { + var msg = result[i]; + var msgText = msg.Text; + if (string.IsNullOrEmpty(msgText)) + { + continue; + } + + // Don't truncate system message or final user message too aggressively + bool isImportant = (i == 0 && msg.Role == ChatRole.System) || + (i == result.Count - 1 && msg.Role == ChatRole.User); + + int targetLength = isImportant + ? (int)(msgText.Length * Math.Max(0.7, reductionFactor)) // Keep at least 70% + : (int)(msgText.Length * reductionFactor); + + if (msgText.Length > targetLength) + { + var truncated = msgText.Substring(0, targetLength); + // Try to cut at a nice boundary + var lastNewline = truncated.LastIndexOf('\n'); + if (lastNewline > targetLength * 0.8) + { + truncated = truncated.Substring(0, lastNewline); + } + + result[i] = new ChatMessage( + msg.Role, + truncated + "\n\n[... content truncated to fit token limit ...]"); + } + } + } + + logger?.LogVerbose( + $"Truncation complete: {messages.Count} -> {result.Count} messages, " + + $"estimated {EstimateTokens(result)} tokens"); + + return result; + } + } +} diff --git a/src/StructuredLogger.LLM/Context/BinlogContextProvider.cs b/src/StructuredLogger.LLM/Context/BinlogContextProvider.cs new file mode 100644 index 000000000..0b649cbdf --- /dev/null +++ b/src/StructuredLogger.LLM/Context/BinlogContextProvider.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.Build.Logging.StructuredLogger; + +namespace StructuredLogger.LLM +{ + /// + /// Provides context information from the binlog to the LLM chat. + /// + public class BinlogContextProvider + { + private readonly Build build; + + public BinlogContextProvider(Build build) + { + this.build = build ?? throw new ArgumentNullException(nameof(build)); + } + + public string GetBuildOverview() + { + if (build == null) + { + return "No build loaded."; + } + + var sb = new StringBuilder(); + sb.AppendLine("=== Build Overview ==="); + sb.AppendLine($"Status: {(build.Succeeded ? "Succeeded" : "Failed")}"); + sb.AppendLine($"Duration: {build.DurationText}"); + + if (!string.IsNullOrEmpty(build.LogFilePath)) + { + sb.AppendLine($"Log File: {build.LogFilePath}"); + } + + // Count errors and warnings + int errorCount = 0; + int warningCount = 0; + build.VisitAllChildren(node => + { + if (node is Error) errorCount++; + else if (node is Warning) warningCount++; + }); + + sb.AppendLine($"Errors: {errorCount}"); + sb.AppendLine($"Warnings: {warningCount}"); + + return sb.ToString(); + } + + public string GetSelectedNodeContext(BaseNode selectedNode) + { + if (selectedNode == null) + { + return "No node selected."; + } + + var sb = new StringBuilder(); + sb.AppendLine($"=== Selected Node ==="); + sb.AppendLine($"Type: {selectedNode.GetType().Name}"); + sb.AppendLine($"Text: {selectedNode.ToString()}"); + + if (selectedNode is TimedNode timedNode) + { + sb.AppendLine($"Duration: {timedNode.DurationText}"); + sb.AppendLine($"Start: {timedNode.StartTime}"); + sb.AppendLine($"End: {timedNode.EndTime}"); + } + + if (selectedNode is NamedNode namedNode) + { + sb.AppendLine($"Name: {namedNode.Name}"); + } + + if (selectedNode is Project project) + { + sb.AppendLine($"Project File: {project.ProjectFile}"); + if (project.HasChildren) + { + sb.AppendLine($"Child Count: {project.Children.Count}"); + } + } + + if (selectedNode is Target target) + { + sb.AppendLine($"Target Name: {target.Name}"); + sb.AppendLine($"Project: {target.GetNearestParent()?.Name ?? "Unknown"}"); + } + + if (selectedNode is Error error) + { + sb.AppendLine($"Error Code: {error.Code}"); + sb.AppendLine($"File: {error.File}"); + sb.AppendLine($"Line: {error.LineNumber}"); + } + + if (selectedNode is Warning warning) + { + sb.AppendLine($"Warning Code: {warning.Code}"); + sb.AppendLine($"File: {warning.File}"); + sb.AppendLine($"Line: {warning.LineNumber}"); + } + + // Include parent hierarchy + var parents = new List(); + var parent = selectedNode.Parent; + while (parent != null && parents.Count < 5) + { + parents.Add($"{parent.GetType().Name}: {parent.ToString()}"); + parent = parent.Parent; + } + + if (parents.Any()) + { + sb.AppendLine("\n=== Parent Hierarchy ==="); + foreach (var p in parents) + { + sb.AppendLine(p); + } + } + + return sb.ToString(); + } + + public string GetFullContext(BaseNode? selectedNode = null) + { + var sb = new StringBuilder(); + sb.AppendLine(GetBuildOverview()); + + if (selectedNode != null) + { + sb.AppendLine(); + sb.AppendLine(GetSelectedNodeContext(selectedNode)); + } + + return sb.ToString(); + } + } +} diff --git a/src/StructuredLogger.LLM/Context/BuildInfo.cs b/src/StructuredLogger.LLM/Context/BuildInfo.cs new file mode 100644 index 000000000..aa728ad12 --- /dev/null +++ b/src/StructuredLogger.LLM/Context/BuildInfo.cs @@ -0,0 +1,112 @@ +using System; +using Microsoft.Build.Logging.StructuredLogger; + +namespace StructuredLogger.LLM +{ + /// + /// Wrapper containing Build object with identification metadata. + /// Used for multi-binlog scenarios where each build needs unique identification. + /// + public class BuildInfo + { + /// + /// Unique identifier for this build (e.g., "build_001"). + /// + public string BuildId { get; } + + /// + /// Human-friendly name for display (e.g., "CoreLib", "Tests"). + /// Derived from project folder name or user-specified. + /// + public string FriendlyName { get; } + + /// + /// Full path to the binlog file. Used for uniqueness checks. + /// + public string FullPath { get; } + + /// + /// The actual Build object from the binlog. + /// + public Build Build { get; } + + /// + /// When this build was loaded into the context. + /// + public DateTime LoadedAt { get; } + + /// + /// Whether this is the primary/default build for operations. + /// + public bool IsPrimary { get; set; } + + // Cached summary info for quick access + private int? cachedErrorCount; + private int? cachedWarningCount; + + public bool Succeeded => Build.Succeeded; + public string DurationText => Build.DurationText; + + public int ErrorCount + { + get + { + if (cachedErrorCount == null) + { + CountDiagnostics(); + } + return cachedErrorCount.Value; + } + } + + public int WarningCount + { + get + { + if (cachedWarningCount == null) + { + CountDiagnostics(); + } + return cachedWarningCount.Value; + } + } + + public BuildInfo(string buildId, string friendlyName, string fullPath, Build build) + { + BuildId = buildId ?? throw new ArgumentNullException(nameof(buildId)); + FriendlyName = friendlyName ?? throw new ArgumentNullException(nameof(friendlyName)); + FullPath = fullPath ?? string.Empty; + Build = build ?? throw new ArgumentNullException(nameof(build)); + LoadedAt = DateTime.Now; + IsPrimary = false; + } + + private void CountDiagnostics() + { + int errors = 0; + int warnings = 0; + + Build.VisitAllChildren(node => + { + if (node is Error) + { + errors++; + } + else if (node is Warning) + { + warnings++; + } + }); + + cachedErrorCount = errors; + cachedWarningCount = warnings; + } + + public override string ToString() + { + var status = Succeeded ? "Succeeded" : "FAILED"; + var primary = IsPrimary ? " [PRIMARY]" : ""; + return $"[{BuildId}] {FriendlyName}{primary} - {status} ({DurationText})"; + } + } +} diff --git a/src/StructuredLogger.LLM/Context/MultiBuildContext.cs b/src/StructuredLogger.LLM/Context/MultiBuildContext.cs new file mode 100644 index 000000000..c03705154 --- /dev/null +++ b/src/StructuredLogger.LLM/Context/MultiBuildContext.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.Build.Logging.StructuredLogger; + +namespace StructuredLogger.LLM +{ + /// + /// Manages multiple Build objects with unique identification. + /// Provides methods to add, remove, and query builds by ID. + /// + public class MultiBuildContext + { + private readonly Dictionary builds; + private readonly HashSet usedFriendlyNames; + private string primaryBuildId; + private int nextBuildNumber = 1; + + /// + /// Gets the ID of the primary (default) build. + /// + public string PrimaryBuildId => primaryBuildId; + + /// + /// Gets the number of loaded builds. + /// + public int BuildCount => builds.Count; + + public MultiBuildContext() + { + builds = new Dictionary(StringComparer.OrdinalIgnoreCase); + usedFriendlyNames = new HashSet(StringComparer.OrdinalIgnoreCase); + } + + /// + /// Adds a build to the context and returns its unique ID. + /// The first build added becomes the primary build. + /// + /// The Build object to add. + /// Optional friendly name. If null, derived from path. + /// The unique build ID assigned. + public string AddBuild(Build build, string friendlyName = null) + { + if (build == null) + { + throw new ArgumentNullException(nameof(build)); + } + + var buildId = GenerateUniqueBuildId(); + var fullPath = build.LogFilePath ?? string.Empty; + var name = friendlyName ?? GenerateFriendlyName(fullPath); + + // Ensure name uniqueness + name = EnsureUniqueName(name); + usedFriendlyNames.Add(name); + + var buildInfo = new BuildInfo(buildId, name, fullPath, build); + + // First build becomes primary + if (builds.Count == 0) + { + buildInfo.IsPrimary = true; + primaryBuildId = buildId; + } + + builds[buildId] = buildInfo; + return buildId; + } + + /// + /// Removes a build from the context. + /// If the removed build was primary, a new primary is selected. + /// + /// The ID of the build to remove. + /// If buildId is not found. + /// If trying to remove the last build. + public void RemoveBuild(string buildId) + { + if (!builds.TryGetValue(buildId, out var buildInfo)) + { + throw new ArgumentException($"Build '{buildId}' not found.", nameof(buildId)); + } + + if (builds.Count == 1) + { + throw new InvalidOperationException("Cannot remove the last build from context."); + } + + usedFriendlyNames.Remove(buildInfo.FriendlyName); + builds.Remove(buildId); + + // If removed build was primary, select new primary + if (buildId.Equals(primaryBuildId, StringComparison.OrdinalIgnoreCase)) + { + var newPrimary = builds.Values.First(); + newPrimary.IsPrimary = true; + primaryBuildId = newPrimary.BuildId; + } + } + + /// + /// Gets a build by its ID. + /// + /// The build ID. + /// The BuildInfo for the requested build. + /// If buildId is not found. + public BuildInfo GetBuild(string buildId) + { + if (!builds.TryGetValue(buildId, out var buildInfo)) + { + throw new ArgumentException($"Build '{buildId}' not found. Use ListBuilds to see available builds.", nameof(buildId)); + } + return buildInfo; + } + + /// + /// Gets the primary (default) build. + /// + /// The BuildInfo for the primary build. + /// If no builds are loaded. + public BuildInfo GetPrimaryBuild() + { + if (string.IsNullOrEmpty(primaryBuildId) || !builds.TryGetValue(primaryBuildId, out var buildInfo)) + { + throw new InvalidOperationException("No builds loaded."); + } + return buildInfo; + } + + /// + /// Gets all loaded builds. + /// + /// Enumerable of all BuildInfo objects. + public IEnumerable GetAllBuilds() + { + return builds.Values.OrderBy(b => b.LoadedAt); + } + + /// + /// Sets a specific build as the primary build. + /// + /// The ID of the build to set as primary. + /// If buildId is not found. + public void SetPrimaryBuild(string buildId) + { + if (!builds.TryGetValue(buildId, out var newPrimary)) + { + throw new ArgumentException($"Build '{buildId}' not found.", nameof(buildId)); + } + + // Clear current primary + if (!string.IsNullOrEmpty(primaryBuildId) && builds.TryGetValue(primaryBuildId, out var currentPrimary)) + { + currentPrimary.IsPrimary = false; + } + + newPrimary.IsPrimary = true; + primaryBuildId = buildId; + } + + /// + /// Attempts to get a build by ID. + /// + /// The build ID. + /// The BuildInfo if found. + /// True if found, false otherwise. + public bool TryGetBuild(string buildId, out BuildInfo buildInfo) + { + return builds.TryGetValue(buildId, out buildInfo); + } + + /// + /// Checks if a build with the given full path is already loaded. + /// + /// The full path to check. + /// True if already loaded. + public bool ContainsBuildByPath(string fullPath) + { + if (string.IsNullOrEmpty(fullPath)) + { + return false; + } + return builds.Values.Any(b => + b.FullPath.Equals(fullPath, StringComparison.OrdinalIgnoreCase)); + } + + private string GenerateUniqueBuildId() + { + return $"build_{nextBuildNumber++:D3}"; + } + + private string GenerateFriendlyName(string fullPath) + { + if (string.IsNullOrEmpty(fullPath)) + { + return $"Build{nextBuildNumber}"; + } + + try + { + // Get the parent directory name (usually project/solution folder) + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory)) + { + var folderName = Path.GetFileName(directory); + if (!string.IsNullOrEmpty(folderName)) + { + // If it's a common folder like 'bin' or 'Debug', go up another level + if (folderName.Equals("bin", StringComparison.OrdinalIgnoreCase) || + folderName.Equals("Debug", StringComparison.OrdinalIgnoreCase) || + folderName.Equals("Release", StringComparison.OrdinalIgnoreCase)) + { + var parentDir = Path.GetDirectoryName(directory); + if (!string.IsNullOrEmpty(parentDir)) + { + folderName = Path.GetFileName(parentDir); + } + } + + if (!string.IsNullOrEmpty(folderName)) + { + return folderName; + } + } + } + + // Fallback to filename without extension + var fileName = Path.GetFileNameWithoutExtension(fullPath); + if (!string.IsNullOrEmpty(fileName)) + { + return fileName; + } + } + catch + { + // Ignore path parsing errors + } + + return $"Build{nextBuildNumber}"; + } + + private string EnsureUniqueName(string baseName) + { + if (!usedFriendlyNames.Contains(baseName)) + { + return baseName; + } + + // Append number to make unique + int suffix = 2; + string uniqueName; + do + { + uniqueName = $"{baseName}_{suffix++}"; + } while (usedFriendlyNames.Contains(uniqueName)); + + return uniqueName; + } + } +} diff --git a/src/StructuredLogger.LLM/Context/MultiBuildContextProvider.cs b/src/StructuredLogger.LLM/Context/MultiBuildContextProvider.cs new file mode 100644 index 000000000..b471afef2 --- /dev/null +++ b/src/StructuredLogger.LLM/Context/MultiBuildContextProvider.cs @@ -0,0 +1,172 @@ +using System; +using System.Linq; +using System.Text; +using Microsoft.Build.Logging.StructuredLogger; + +namespace StructuredLogger.LLM +{ + /// + /// Provides context information from multiple binlogs to the LLM chat. + /// + public class MultiBuildContextProvider + { + private readonly MultiBuildContext context; + + public MultiBuildContextProvider(MultiBuildContext context) + { + this.context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + /// Gets overview of ALL loaded builds for system prompt. + /// + public string GetAllBuildsOverview() + { + var sb = new StringBuilder(); + sb.AppendLine("=== Loaded Builds ==="); + sb.AppendLine($"Total builds: {context.BuildCount}"); + sb.AppendLine($"Primary build: {context.PrimaryBuildId}"); + sb.AppendLine(); + + foreach (var buildInfo in context.GetAllBuilds()) + { + var marker = buildInfo.IsPrimary ? " [PRIMARY]" : ""; + var status = buildInfo.Succeeded ? "Succeeded" : "FAILED"; + + sb.AppendLine($"[{buildInfo.BuildId}] {buildInfo.FriendlyName}{marker}"); + sb.AppendLine($" Path: {buildInfo.FullPath}"); + sb.AppendLine($" Status: {status}"); + sb.AppendLine($" Duration: {buildInfo.DurationText}"); + sb.AppendLine($" Errors: {buildInfo.ErrorCount}, Warnings: {buildInfo.WarningCount}"); + sb.AppendLine(); + } + + return sb.ToString(); + } + + /// + /// Gets detailed overview of a specific build. + /// + public string GetBuildOverview(string buildId) + { + if (!context.TryGetBuild(buildId, out var buildInfo)) + { + return $"Build '{buildId}' not found."; + } + + return GetBuildOverview(buildInfo); + } + + /// + /// Gets detailed overview of a specific build. + /// + public string GetBuildOverview(BuildInfo buildInfo) + { + if (buildInfo == null) + { + return "No build provided."; + } + + var build = buildInfo.Build; + var sb = new StringBuilder(); + + sb.AppendLine("=== Build Overview ==="); + sb.AppendLine($"Build ID: {buildInfo.BuildId}"); + sb.AppendLine($"Name: {buildInfo.FriendlyName}"); + sb.AppendLine($"Status: {(build.Succeeded ? "Succeeded" : "Failed")}"); + sb.AppendLine($"Duration: {build.DurationText}"); + + if (!string.IsNullOrEmpty(buildInfo.FullPath)) + { + sb.AppendLine($"Log File: {buildInfo.FullPath}"); + } + + sb.AppendLine($"Errors: {buildInfo.ErrorCount}"); + sb.AppendLine($"Warnings: {buildInfo.WarningCount}"); + + return sb.ToString(); + } + + /// + /// Gets context for selected node in specific build. + /// + public string GetSelectedNodeContext(BaseNode selectedNode, string buildId) + { + if (selectedNode == null) + { + return "No node selected."; + } + + if (!context.TryGetBuild(buildId, out var buildInfo)) + { + return $"Build '{buildId}' not found."; + } + + var sb = new StringBuilder(); + sb.AppendLine($"=== Selected Node (from {buildInfo.FriendlyName}) ==="); + sb.AppendLine($"Type: {selectedNode.GetType().Name}"); + sb.AppendLine($"Text: {selectedNode.ToString()}"); + + if (selectedNode is TimedNode timedNode) + { + sb.AppendLine($"Duration: {timedNode.DurationText}"); + sb.AppendLine($"Start: {timedNode.StartTime}"); + sb.AppendLine($"End: {timedNode.EndTime}"); + } + + if (selectedNode is NamedNode namedNode) + { + sb.AppendLine($"Name: {namedNode.Name}"); + } + + if (selectedNode is Project project) + { + sb.AppendLine($"Project File: {project.ProjectFile}"); + if (project.HasChildren) + { + sb.AppendLine($"Child Count: {project.Children.Count}"); + } + } + + if (selectedNode is Target target) + { + sb.AppendLine($"Target Name: {target.Name}"); + sb.AppendLine($"Project: {target.GetNearestParent()?.Name ?? "Unknown"}"); + } + + if (selectedNode is Error error) + { + sb.AppendLine($"Error Code: {error.Code}"); + sb.AppendLine($"File: {error.File}"); + sb.AppendLine($"Line: {error.LineNumber}"); + } + + if (selectedNode is Warning warning) + { + sb.AppendLine($"Warning Code: {warning.Code}"); + sb.AppendLine($"File: {warning.File}"); + sb.AppendLine($"Line: {warning.LineNumber}"); + } + + return sb.ToString(); + } + + /// + /// Gets a brief summary of all builds (for compact display). + /// + public string GetBuildsSummary() + { + var sb = new StringBuilder(); + sb.AppendLine($"Builds loaded: {context.BuildCount}"); + + foreach (var buildInfo in context.GetAllBuilds()) + { + var status = buildInfo.Succeeded ? "✓" : "✗"; + var primary = buildInfo.IsPrimary ? "*" : " "; + sb.AppendLine($" {primary}[{buildInfo.BuildId}] {buildInfo.FriendlyName}: {status} {buildInfo.DurationText}"); + } + + return sb.ToString(); + } + } +} diff --git a/src/StructuredLogger.LLM/Interfaces/AgentPhase.cs b/src/StructuredLogger.LLM/Interfaces/AgentPhase.cs new file mode 100644 index 000000000..0d3d1f7ac --- /dev/null +++ b/src/StructuredLogger.LLM/Interfaces/AgentPhase.cs @@ -0,0 +1,36 @@ +using System; + +namespace StructuredLogger.LLM +{ + /// + /// Defines the phases of agent execution. Tool executors can declare which phases they support. + /// + [Flags] + public enum AgentPhase + { + /// + /// No phase specified + /// + None = 0, + + /// + /// Planning phase - agent is creating a plan or decomposing tasks + /// + Planning = 1 << 0, + + /// + /// Research phase - agent is gathering information and context + /// + Research = 1 << 1, + + /// + /// Summarization phase - agent is synthesizing results and generating summaries + /// + Summarization = 1 << 2, + + /// + /// All phases - tool is applicable in any phase + /// + All = Planning | Research | Summarization + } +} diff --git a/src/StructuredLogger.LLM/Interfaces/IToolsContainer.cs b/src/StructuredLogger.LLM/Interfaces/IToolsContainer.cs new file mode 100644 index 000000000..a1a42a786 --- /dev/null +++ b/src/StructuredLogger.LLM/Interfaces/IToolsContainer.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.AI; +using System.Collections.Generic; + +namespace StructuredLogger.LLM +{ + /// + /// Defines a container for AI tools that can be registered with chat services. + /// Tools containers expose a collection of delegates that can be invoked by the LLM. + /// + public interface IToolsContainer + { + /// + /// Gets whether this container provides GUI manipulation tools. + /// GUI manipulation tools are those that can interact with the UI to show, highlight, or navigate to specific content. + /// + bool HasGuiTools { get; } + + /// + /// Gets all tools provided by this container with their applicable phases. + /// + /// Enumerable of tuples containing AIFunction and the phases where it's applicable. + IEnumerable<(AIFunction Function, AgentPhase ApplicablePhases)> GetTools(); + } +} diff --git a/src/StructuredLogger.LLM/Interfaces/IUserInteraction.cs b/src/StructuredLogger.LLM/Interfaces/IUserInteraction.cs new file mode 100644 index 000000000..f015d57bc --- /dev/null +++ b/src/StructuredLogger.LLM/Interfaces/IUserInteraction.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; + +namespace StructuredLogger.LLM +{ + /// + /// Interface for user interaction, allowing LLMs to ask clarifying questions. + /// Implementations can use Console, GUI, or other mechanisms. + /// + public interface IUserInteraction + { + /// + /// Asks the user a question and returns their response. + /// + /// The question to ask the user. + /// Optional list of suggested options. If provided, shows as numbered choices. + /// User's response as a string. + Task AskUser(string question, string[]? options = null); + } +} diff --git a/src/StructuredLogger.LLM/Logging/ChatWindowLogger.cs b/src/StructuredLogger.LLM/Logging/ChatWindowLogger.cs new file mode 100644 index 000000000..0c9e863dc --- /dev/null +++ b/src/StructuredLogger.LLM/Logging/ChatWindowLogger.cs @@ -0,0 +1,53 @@ +using System; + +namespace StructuredLogger.LLM.Logging +{ + /// + /// Logger that writes messages to a chat window via a callback. + /// Used in GUI mode to show LLM client debug/error messages in the chat interface. + /// + public class ChatWindowLogger : ILLMLogger + { + private readonly Action addMessageCallback; + private LoggingLevel level; + + /// + /// Creates a new ChatWindowLogger. + /// + /// Callback to add a message to the chat window (message, isError) + /// Initial logging level (default: Normal) + public ChatWindowLogger(Action addMessageCallback, LoggingLevel level = LoggingLevel.Normal) + { + this.addMessageCallback = addMessageCallback ?? throw new ArgumentNullException(nameof(addMessageCallback)); + this.level = level; + } + + public LoggingLevel Level + { + get => level; + set => level = value; + } + + public void LogVerbose(string message) + { + if (level >= LoggingLevel.Verbose) + { + addMessageCallback($"[Verbose] {message}", false); + } + } + + public void LogInfo(string message) + { + if (level >= LoggingLevel.Normal) + { + addMessageCallback($"[Info] {message}", false); + } + } + + public void LogError(string message) + { + // Errors are always shown regardless of level + addMessageCallback($"[Error] {message}", true); + } + } +} diff --git a/src/StructuredLogger.LLM/Logging/CliLoggerAdapter.cs b/src/StructuredLogger.LLM/Logging/CliLoggerAdapter.cs new file mode 100644 index 000000000..fa4419401 --- /dev/null +++ b/src/StructuredLogger.LLM/Logging/CliLoggerAdapter.cs @@ -0,0 +1,55 @@ +using System; + +namespace StructuredLogger.LLM.Logging +{ + /// + /// Adapter that wraps BinlogTool's CliLogger to implement ILLMLogger. + /// Used when running GitHub Copilot client in CLI mode. + /// + public class CliLoggerAdapter : ILLMLogger + { + private readonly object cliLogger; + private readonly Action logVerboseAction; + private readonly Action logInfoAction; + private readonly Action logErrorAction; + + public LoggingLevel Level { get; set; } = LoggingLevel.Normal; + + public CliLoggerAdapter(object cliLogger) + { + this.cliLogger = cliLogger ?? throw new ArgumentNullException(nameof(cliLogger)); + + // Use reflection to get the methods from CliLogger + var type = cliLogger.GetType(); + var logVerboseMethod = type.GetMethod("LogVerbose"); + var logInfoMethod = type.GetMethod("LogInfo"); + var logErrorMethod = type.GetMethod("LogError"); + + if (logVerboseMethod == null || logErrorMethod == null) + { + throw new InvalidOperationException("CliLogger must have LogVerbose and LogError methods"); + } + + this.logVerboseAction = message => logVerboseMethod.Invoke(cliLogger, new object[] { message }); + this.logInfoAction = logInfoMethod != null + ? message => logInfoMethod.Invoke(cliLogger, new object[] { message }) + : message => logVerboseMethod.Invoke(cliLogger, new object[] { message }); // Fallback to verbose + this.logErrorAction = message => logErrorMethod.Invoke(cliLogger, new object[] { message }); + } + + public void LogVerbose(string message) + { + logVerboseAction(message); + } + + public void LogInfo(string message) + { + logInfoAction(message); + } + + public void LogError(string message) + { + logErrorAction(message); + } + } +} diff --git a/src/StructuredLogger.LLM/Logging/ILLMLogger.cs b/src/StructuredLogger.LLM/Logging/ILLMLogger.cs new file mode 100644 index 000000000..8b03011e3 --- /dev/null +++ b/src/StructuredLogger.LLM/Logging/ILLMLogger.cs @@ -0,0 +1,54 @@ +namespace StructuredLogger.LLM.Logging +{ + /// + /// Logging level for LLM operations. + /// + public enum LoggingLevel + { + Quiet = 0, // Only errors + Normal = 1, // Errors and important info + Verbose = 2 // All messages including debug + } + + /// + /// Simple logging abstraction for LLM client operations. + /// + public interface ILLMLogger + { + /// + /// Current logging level. + /// + LoggingLevel Level { get; set; } + + /// + /// Log verbose/debug information (only shown in Verbose mode). + /// + void LogVerbose(string message); + + /// + /// Log normal informational messages (shown in Normal and Verbose modes). + /// + void LogInfo(string message); + + /// + /// Log errors (always shown regardless of level). + /// + void LogError(string message); + } + + /// + /// No-op logger that discards all messages. + /// + public class NullLLMLogger : ILLMLogger + { + public static readonly NullLLMLogger Instance = new NullLLMLogger(); + + private NullLLMLogger() { } + + public LoggingLevel Level { get; set; } = LoggingLevel.Quiet; + + public void LogVerbose(string message) { } + public void LogInfo(string message) { } + public void LogError(string message) { } + } +} diff --git a/src/StructuredLogger.LLM/Models/AgentModels.cs b/src/StructuredLogger.LLM/Models/AgentModels.cs new file mode 100644 index 000000000..5ffe33aae --- /dev/null +++ b/src/StructuredLogger.LLM/Models/AgentModels.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; + +namespace StructuredLogger.LLM +{ + /// + /// Represents the current execution phase status of an agent workflow. + /// Different from which is a Flags enum for tool availability. + /// + public enum AgentExecutionPhase + { + Idle, + Planning, + Research, + Summarization, + Complete, + Failed + } + + /// + /// Represents the status of an individual research task. + /// + public enum TaskStatus + { + NotStarted, + InProgress, + Complete, + Failed + } + + /// + /// Represents a single research task in the agent plan. + /// + public class ResearchTask + { + public string Id { get; set; } + public string Description { get; set; } + public string Goal { get; set; } + public TaskStatus Status { get; set; } + public string Findings { get; set; } + public DateTime? StartTime { get; set; } + public DateTime? EndTime { get; set; } + public string Error { get; set; } + + public TimeSpan? Duration => EndTime.HasValue && StartTime.HasValue + ? EndTime.Value - StartTime.Value + : null; + + public string StatusEmoji => Status switch + { + TaskStatus.Complete => "✓", + TaskStatus.InProgress => "⏳", + TaskStatus.Failed => "❌", + _ => "○" + }; + + public ResearchTask(string id, string description, string goal) + { + Id = id; + Description = description; + Goal = goal; + Status = TaskStatus.NotStarted; + Findings = string.Empty; + Error = string.Empty; + } + } + + /// + /// Represents the overall agent plan with multiple research tasks. + /// + public class AgentPlan + { + public string UserQuery { get; set; } + public List ResearchTasks { get; set; } + public AgentExecutionPhase Phase { get; set; } + public int CurrentTaskIndex { get; set; } + public Dictionary Findings { get; set; } + public string? PlanningThinking { get; set; } + public string? DirectAnswer { get; set; } + public string? FinalSummary { get; set; } + public DateTime StartTime { get; set; } + public DateTime? EndTime { get; set; } + public string? Error { get; set; } + + public TimeSpan? Duration => EndTime.HasValue + ? EndTime.Value - StartTime + : (DateTime.Now - StartTime); + + public ResearchTask? CurrentTask => CurrentTaskIndex >= 0 && CurrentTaskIndex < ResearchTasks.Count + ? ResearchTasks[CurrentTaskIndex] + : null; + + public int CompletedTaskCount => ResearchTasks.FindAll(t => t.Status == TaskStatus.Complete).Count; + public int FailedTaskCount => ResearchTasks.FindAll(t => t.Status == TaskStatus.Failed).Count; + + public AgentPlan(string userQuery) + { + UserQuery = userQuery; + ResearchTasks = new List(); + Phase = AgentExecutionPhase.Idle; + CurrentTaskIndex = -1; + Findings = new Dictionary(); + StartTime = DateTime.Now; + } + } + + /// + /// Event args for agent progress updates. + /// + public class AgentProgressEventArgs : EventArgs + { + public AgentExecutionPhase Phase { get; set; } + public ResearchTask? CurrentTask { get; set; } + public AgentPlan Plan { get; set; } + public string? Message { get; set; } + public bool IsError { get; set; } + + public AgentProgressEventArgs(AgentPlan plan, string? message = null, bool isError = false) + { + Plan = plan; + Phase = plan.Phase; + CurrentTask = plan.CurrentTask; + Message = message; + IsError = isError; + } + } +} diff --git a/src/StructuredLogger.LLM/Models/ChatMessageViewModel.cs b/src/StructuredLogger.LLM/Models/ChatMessageViewModel.cs new file mode 100644 index 000000000..af1e888a2 --- /dev/null +++ b/src/StructuredLogger.LLM/Models/ChatMessageViewModel.cs @@ -0,0 +1,24 @@ +using System; + +namespace StructuredLogger.LLM +{ + /// + /// Represents a chat message in the conversation. + /// Used by chat services to represent messages exchanged between user and LLM. + /// + public class ChatMessageViewModel + { + public string Role { get; set; } // "User", "Assistant", "System", "Agent" + public string Content { get; set; } + public DateTime Timestamp { get; set; } + public bool IsError { get; set; } + + public ChatMessageViewModel(string role, string content, bool isError = false) + { + Role = role; + Content = content; + Timestamp = DateTime.Now; + IsError = isError; + } + } +} diff --git a/src/StructuredLogger.LLM/Models/LLMConfiguration.cs b/src/StructuredLogger.LLM/Models/LLMConfiguration.cs new file mode 100644 index 000000000..2f88bbc76 --- /dev/null +++ b/src/StructuredLogger.LLM/Models/LLMConfiguration.cs @@ -0,0 +1,180 @@ +using System; +using StructuredLogger.LLM.Logging; + +namespace StructuredLogger.LLM +{ + /// + /// Configuration for LLM Chat integration. + /// Reads settings from environment variables. + /// Automatically detects which provider to use based on endpoint and model. + /// + public class LLMConfiguration + { + private const string LLMEndpointEnvVar = "LLM_ENDPOINT"; + private const string LLMApiKeyEnvVar = "LLM_API_KEY"; + private const string LLMModelEnvVar = "LLM_MODEL"; + + public enum ClientType + { + Unknown, + AzureOpenAI, + AzureInference, + Anthropic, + GitHubCopilot + } + + public string Endpoint { get; set; } = string.Empty; + public string ApiKey { get; set; } = string.Empty; + public string ModelName { get; set; } = string.Empty; + public ClientType Type { get; private set; } + public bool AutoSendOnEnter { get; set; } = true; + public bool AgentMode { get; set; } = true; + public LoggingLevel LoggingLevel { get; set; } = LoggingLevel.Normal; + + /// + /// List of available models fetched from the provider (e.g., GitHub Copilot). + /// Used to persist the model list across dialog reopenings. + /// + public System.Collections.Generic.List? AvailableModels { get; set; } + + public LLMConfiguration() + { + // Default constructor + AutoSendOnEnter = true; + AgentMode = true; + LoggingLevel = LoggingLevel.Normal; + } + + public void UpdateType() + { + Type = DetectClientType(Endpoint, ModelName); + } + + public bool IsConfigured + { + get + { + // Basic requirements + if (string.IsNullOrWhiteSpace(Endpoint) || string.IsNullOrWhiteSpace(ModelName)) + { + return false; + } + + // All other providers require an API key + return !string.IsNullOrWhiteSpace(ApiKey); + } + } + + public static LLMConfiguration LoadFromEnvironment() + { + var endpoint = Environment.GetEnvironmentVariable(LLMEndpointEnvVar); + var apiKey = Environment.GetEnvironmentVariable(LLMApiKeyEnvVar); + var model = Environment.GetEnvironmentVariable(LLMModelEnvVar); + + var config = new LLMConfiguration + { + Endpoint = endpoint ?? string.Empty, + ApiKey = apiKey ?? string.Empty, + ModelName = model ?? GetDefaultModelForEndpoint(endpoint) + }; + config.UpdateType(); + return config; + } + + private static string GetDefaultModelForEndpoint(string? endpoint) + { + if (string.IsNullOrWhiteSpace(endpoint)) + { + return "gpt-4"; + } + + // GitHub Copilot uses Claude Sonnet 4.5 by default + if (IsGitHubCopilotEndpoint(endpoint!)) + { + return "claude-sonnet-4.5"; + } + + return "gpt-4"; + } + + private static ClientType DetectClientType(string endpoint, string model) + { + if (string.IsNullOrWhiteSpace(endpoint)) + { + return ClientType.Unknown; + } + + // Detect GitHub Copilot + if (IsGitHubCopilotEndpoint(endpoint)) + { + return ClientType.GitHubCopilot; + } + + // Detect which client to use based on endpoint and model + if (endpoint.Contains("/anthropic/", StringComparison.OrdinalIgnoreCase) || + model.StartsWith("claude", StringComparison.OrdinalIgnoreCase)) + { + return ClientType.Anthropic; + } + + + // Check endpoint domain for Azure OpenAI Service + if (endpoint.Contains("cognitiveservices.azure.com", StringComparison.OrdinalIgnoreCase) || + endpoint.Contains("openai.azure.com", StringComparison.OrdinalIgnoreCase)) + { + return ClientType.AzureOpenAI; + } + + // Default: Azure AI Inference for other endpoints + return ClientType.AzureInference; + } + + private static bool IsGitHubCopilotEndpoint(string endpoint) + { + return endpoint.Contains("githubcopilot.com", StringComparison.OrdinalIgnoreCase) || + endpoint.Equals("github-copilot", StringComparison.OrdinalIgnoreCase) || + endpoint.Equals("copilot", StringComparison.OrdinalIgnoreCase); + } + + public string GetConfigurationStatus() + { + if (IsConfigured) + { + var provider = Type switch + { + ClientType.AzureOpenAI => "Azure OpenAI", + ClientType.AzureInference => "Azure AI Foundry/Inference", + ClientType.Anthropic => "Anthropic", + ClientType.GitHubCopilot => "GitHub Copilot", + _ => "Unknown Provider" + }; + + return $"Connected to {ModelName} at {provider}"; + } + + return "LLM not configured. Set:\n" + + " LLM_ENDPOINT (e.g., https://your-resource.openai.azure.com/ or 'github-copilot')\n" + + " LLM_API_KEY (your API key or GitHub token, optional for Copilot device flow)\n" + + " LLM_MODEL (e.g., gpt-4, claude-sonnet-4-5-2)\n\n" + + "The system will automatically detect the provider based on the endpoint.\n" + + "GUI/CLI provides additional configuration options."; + } + + /// + /// Loads configuration from persisted settings. + /// + public static LLMConfiguration LoadFromPersisted() + { + // This will be implemented in the UI layer that has access to SettingsService + return new LLMConfiguration(); + } + + /// + /// Saves configuration to persisted settings. + /// + public void SaveToPersisted() + { + // This will be implemented in the UI layer that has access to SettingsService + } + } +} diff --git a/src/StructuredLogger.LLM/Models/ToolCallInfo.cs b/src/StructuredLogger.LLM/Models/ToolCallInfo.cs new file mode 100644 index 000000000..a336d971e --- /dev/null +++ b/src/StructuredLogger.LLM/Models/ToolCallInfo.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +namespace StructuredLogger.LLM +{ + /// + /// Contains information about a tool call execution including arguments, results, and timing. + /// + public class ToolCallInfo + { + public Guid CallId { get; set; } + public string ToolName { get; set; } = string.Empty; + public string ArgumentsJson { get; set; } = string.Empty; + public string ResultText { get; set; } = string.Empty; + public DateTime StartTime { get; set; } + public DateTime? EndTime { get; set; } + public TimeSpan? Duration => EndTime.HasValue ? EndTime.Value - StartTime : null; + public bool IsError { get; set; } + public string ErrorMessage { get; set; } = string.Empty; + + /// + /// Gets a dictionary of parsed arguments for user-friendly display. + /// + public Dictionary GetParsedArguments() + { + var result = new Dictionary(); + + if (string.IsNullOrWhiteSpace(ArgumentsJson)) + { + return result; + } + + try + { + using (var doc = JsonDocument.Parse(ArgumentsJson)) + { + foreach (var property in doc.RootElement.EnumerateObject()) + { + result[property.Name] = GetJsonValueAsString(property.Value); + } + } + } + catch + { + // If parsing fails, return the raw JSON as a single entry + result["arguments"] = ArgumentsJson; + } + + return result; + } + + /// + /// Gets a user-friendly summary of arguments (truncated). + /// + public string GetArgumentsSummary(int maxLength = 100) + { + var args = GetParsedArguments(); + if (args.Count == 0) + { + return "(no arguments)"; + } + + var parts = args.Select(kvp => $"{kvp.Key}: {kvp.Value}"); + var summary = string.Join(", ", parts); + + if (summary.Length > maxLength) + { + return summary.Substring(0, maxLength - 3) + "..."; + } + + return summary; + } + + private string GetJsonValueAsString(JsonElement element) + { + switch (element.ValueKind) + { + case JsonValueKind.String: + return element.GetString() ?? string.Empty; + case JsonValueKind.Number: + return element.GetRawText(); + case JsonValueKind.True: + return "true"; + case JsonValueKind.False: + return "false"; + case JsonValueKind.Null: + return "null"; + case JsonValueKind.Array: + var arrayItems = element.EnumerateArray() + .Select(e => GetJsonValueAsString(e)) + .Take(3); + var arrayStr = string.Join(", ", arrayItems); + if (element.GetArrayLength() > 3) + { + arrayStr += "..."; + } + return $"[{arrayStr}]"; + case JsonValueKind.Object: + return "{...}"; + default: + return element.GetRawText(); + } + } + } +} diff --git a/src/StructuredLogger.LLM/Services/AgenticLLMChatService.cs b/src/StructuredLogger.LLM/Services/AgenticLLMChatService.cs new file mode 100644 index 000000000..753f063e3 --- /dev/null +++ b/src/StructuredLogger.LLM/Services/AgenticLLMChatService.cs @@ -0,0 +1,876 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Build.Logging.StructuredLogger; +using Microsoft.Extensions.AI; +using StructuredLogger.LLM.Logging; + +namespace StructuredLogger.LLM +{ + /// + /// Orchestrates agentic multi-step reasoning for complex log analysis. + /// Manages planning, research, and summarization phases. + /// Supports multiple binlog files through MultiBuildContext. + /// + public class AgenticLLMChatService : IDisposable + { + private readonly MultiBuildContext buildContext; + private readonly MultiBuildContextProvider contextProvider; + private readonly List toolContainers; + private MultiProviderLLMClient? llmClient; + private readonly LLMConfiguration configuration; + private readonly ILLMLogger? logger; + + public event EventHandler? ProgressUpdated; + public event EventHandler? MessageAdded; + public event EventHandler? ToolCallExecuting; + public event EventHandler? ToolCallExecuted; + public event EventHandler? RequestRetrying; + + public bool IsConfigured => configuration?.IsConfigured ?? false; + + public int MaxResearchTasks { get; set; } = 5; + public int MaxTokensPerTask { get; set; } = 4000; + + /// + /// Creates a new AgenticLLMChatService with multi-build support. + /// + private AgenticLLMChatService(MultiBuildContext context, LLMConfiguration config, ILLMLogger? logger) + { + this.buildContext = context ?? throw new ArgumentNullException(nameof(context)); + this.contextProvider = new MultiBuildContextProvider(context); + this.toolContainers = new List(); + this.configuration = config ?? throw new ArgumentNullException(nameof(config)); + this.logger = logger; + + // Register default tool executors with multi-build context + RegisterToolContainer(new BinlogToolExecutor(context)); + RegisterToolContainer(new EmbeddedFilesToolExecutor(context)); + RegisterToolContainer(new ListEventsToolExecutor(context)); + RegisterToolContainer(new ResultsToolExecutor()); + } + + /// + /// Creates a new AgenticLLMChatService for a single build (backward compatibility). + /// + private AgenticLLMChatService(Build build, LLMConfiguration config, ILLMLogger? logger) + : this(CreateSingleBuildContext(build), config, logger) + { + } + + private static MultiBuildContext CreateSingleBuildContext(Build build) + { + if (build == null) + { + throw new ArgumentNullException(nameof(build)); + } + var context = new MultiBuildContext(); + context.AddBuild(build); + return context; + } + + /// + /// Creates and initializes a new instance of AgenticLLMChatService with multi-build support. + /// + /// The multi-build context containing loaded builds. + /// LLM configuration. + /// Optional logger for diagnostics. + /// Cancellation token for async initialization. + /// A fully initialized AgenticLLMChatService instance. + public static async System.Threading.Tasks.Task CreateAsync( + MultiBuildContext context, + LLMConfiguration config, + ILLMLogger? logger = null, + CancellationToken cancellationToken = default) + { + var service = new AgenticLLMChatService(context, config, logger); + + await service.InitializeLLMClientAsync(cancellationToken); + + return service; + } + + /// + /// Creates and initializes a new instance of AgenticLLMChatService for a single build. + /// + /// The build to analyze. + /// LLM configuration. + /// Optional logger for diagnostics. + /// Cancellation token for async initialization. + /// A fully initialized AgenticLLMChatService instance. + public static async System.Threading.Tasks.Task CreateAsync( + Build build, + LLMConfiguration config, + ILLMLogger? logger = null, + CancellationToken cancellationToken = default) + { + var service = new AgenticLLMChatService(build, config, logger); + + await service.InitializeLLMClientAsync(cancellationToken); + + return service; + } + + private async System.Threading.Tasks.Task InitializeLLMClientAsync(CancellationToken cancellationToken) + { + if (!configuration.IsConfigured) + { + return; + } + + var client = new MultiProviderLLMClient(configuration, logger: logger); + await client.InitializeAsync(cancellationToken); + this.llmClient = client; + + SubscribeToResilienceEvents(client); + } + + private void SubscribeToResilienceEvents(MultiProviderLLMClient client) + { + if (client.ResilientClient != null) + { + client.ResilientClient.RequestRetrying += (sender, e) => RequestRetrying?.Invoke(this, e); + } + } + + /// + /// Registers an additional tool executor with this service. + /// Used to add UI-specific tools after service construction. + /// + public void RegisterToolContainer(IToolsContainer executor) + { + if (executor == null) + { + throw new ArgumentNullException(nameof(executor)); + } + + // Prevent duplicate registration + if (!toolContainers.Contains(executor)) + { + toolContainers.Add(executor); + } + } + + /// + /// Executes an agentic workflow for a user query. + /// + public async Task ExecuteAgenticWorkflowAsync(string userQuery, CancellationToken cancellationToken = default) + { + if (!IsConfigured) + { + return "LLM is not configured. Please configure the LLM settings."; + } + + var plan = new AgentPlan(userQuery); + + try + { + // Phase 1: Planning + await PlanningPhaseAsync(plan, cancellationToken); + + // Phase 2: Research + await ResearchPhaseAsync(plan, cancellationToken); + + // Phase 3: Summarization + await SummarizationPhaseAsync(plan, cancellationToken); + + plan.Phase = AgentExecutionPhase.Complete; + plan.EndTime = DateTime.Now; + RaiseProgress(plan, "Agent workflow completed successfully."); + + return plan.FinalSummary ?? "No summary generated."; + } + catch (OperationCanceledException) + { + plan.Phase = AgentExecutionPhase.Failed; + plan.Error = "Operation cancelled by user."; + RaiseProgress(plan, plan.Error, isError: true); + return plan.Error; + } + catch (Exception ex) + { + plan.Phase = AgentExecutionPhase.Failed; + plan.Error = $"Agent workflow failed: {ex.Message}"; + RaiseProgress(plan, plan.Error, isError: true); + return plan.Error; + } + } + + /// + /// Phase 1: Generate a research plan by breaking down the user query. + /// + private async System.Threading.Tasks.Task PlanningPhaseAsync(AgentPlan plan, CancellationToken cancellationToken) + { + plan.Phase = AgentExecutionPhase.Planning; + RaiseProgress(plan, "Creating research plan..."); + + // Get research tools to include their definitions in planning context + var researchTools = GetToolsForPhase(AgentPhase.Research); + var toolDescriptions = GetToolDescriptions(researchTools); + + var systemPrompt = GetPlanningSystemPrompt(toolDescriptions); + + var overview = contextProvider.GetAllBuildsOverview(); + + // Add multi-build guidance if applicable + var multiBuildGuidance = buildContext.BuildCount > 1 + ? @" + +IMPORTANT: Multiple binlog files are loaded. When creating research tasks: +- Specify which build(s) each task should investigate using buildId +- Consider whether the question applies to all builds or specific ones +- Research tasks can use buildId parameter to target specific builds +- Use ListBuilds tool to see available builds" + : ""; + + var userPrompt = $@"User Question: {plan.UserQuery} + +Build Overview: +{overview}{multiBuildGuidance} + +Analyze this question carefully. Think through: +- What information is needed to answer this question? +- What specific aspects of the build should be investigated? +- Which tools would be most appropriate for each investigation? +- Are there any ambiguities that you will not be able to resolve yourself and that absolutely need clarification from the user? + +You can use the available tools (including AskUser if the question is ambiguous, unclear or unrelated) to help you plan better. + +After your analysis, create a research plan with 1-{MaxResearchTasks} specific tasks. +End your response with the plan in JSON format: +```json +{{ + ""tasks"": [ + {{""id"": ""task1"", ""description"": ""Brief description"", ""goal"": ""Specific investigation goal""}}, + {{""id"": ""task2"", ""description"": ""Brief description"", ""goal"": ""Specific investigation goal""}} + ] +}} +```"; + + var messages = new List + { + new ChatMessage(ChatRole.System, systemPrompt), + new ChatMessage(ChatRole.User, userPrompt) + }; + + // Get tools for planning phase (including AskUser) + var planningTools = GetToolsForPhase(AgentPhase.Planning); + + var options = new ChatOptions + { + Tools = planningTools, + Temperature = 0.4f // Slightly higher to encourage thinking + }; + + var response = await llmClient!.CompleteChatAsync(messages, options, cancellationToken); + var fullResponse = response.Text?.Trim() ?? ""; + + // Separate thinking from plan JSON + var (thinking, planJson) = ExtractThinkingAndPlan(fullResponse); + + // Store the thinking + if (!string.IsNullOrWhiteSpace(thinking)) + { + plan.PlanningThinking = thinking; + RaiseMessage(new ChatMessageViewModel( + "Assistant", + $"**Planning Analysis:**\n\n{thinking}" + )); + } + + // Parse the plan + try + { + var jsonOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + var planData = JsonSerializer.Deserialize(planJson, jsonOptions); + + // Check if planning agent provided a direct answer + if (!string.IsNullOrWhiteSpace(planData?.DirectAnswer)) + { + plan.DirectAnswer = planData!.DirectAnswer; + RaiseProgress(plan, "Planning agent provided direct answer (trivial query detected)."); + RaiseMessage(new ChatMessageViewModel( + "Assistant", + $"**Direct Answer (no research needed):**\n\n{planData.DirectAnswer}" + )); + } + else if (planData?.Tasks != null && planData.Tasks.Count > 0) + { + foreach (var taskData in planData.Tasks.Take(MaxResearchTasks)) + { + plan.ResearchTasks.Add(new ResearchTask( + taskData.Id ?? $"task{plan.ResearchTasks.Count + 1}", + taskData.Description ?? "Research task", + taskData.Goal ?? "Investigate" + )); + } + + RaiseProgress(plan, $"Plan created with {plan.ResearchTasks.Count} research tasks."); + } + else + { + throw new Exception("No tasks or direct answer in plan response."); + } + } + catch (Exception ex) + { + // Fallback: Create a single generic research task + plan.ResearchTasks.Add(new ResearchTask( + "task1", + "Investigate user query", + $"Use available tools to answer: {plan.UserQuery}" + )); + RaiseProgress(plan, $"Using fallback plan (parsing failed: {ex.Message})"); + } + } + + /// + /// Phase 2: Execute each research task sequentially. + /// + private async System.Threading.Tasks.Task ResearchPhaseAsync(AgentPlan plan, CancellationToken cancellationToken) + { + plan.Phase = AgentExecutionPhase.Research; + + // Skip research if planning agent provided a direct answer + if (!string.IsNullOrWhiteSpace(plan.DirectAnswer)) + { + RaiseProgress(plan, "Skipping research phase (direct answer provided by planning agent)."); + // Store direct answer as a finding for summarization phase + plan.Findings["direct"] = plan.DirectAnswer!; + return; + } + + plan.CurrentTaskIndex = 0; + + for (int i = 0; i < plan.ResearchTasks.Count; i++) + { + plan.CurrentTaskIndex = i; + var task = plan.ResearchTasks[i]; + + task.Status = TaskStatus.InProgress; + task.StartTime = DateTime.Now; + RaiseProgress(plan, $"Researching: {task.Description}"); + + // Add message showing research task start + MessageAdded?.Invoke(this, new ChatMessageViewModel( + "Agent", + $"🔍 **Research Task {i + 1}/{plan.ResearchTasks.Count}**: {task.Description}\n\n_{task.Goal}_" + )); + + try + { + var findings = await ExecuteResearchTaskAsync(plan, task, cancellationToken); + task.Findings = findings; + task.Status = TaskStatus.Complete; + task.EndTime = DateTime.Now; + plan.Findings[task.Id] = findings; + + // Add message showing findings + MessageAdded?.Invoke(this, new ChatMessageViewModel( + "Agent", + $"✓ **Findings**: {findings}" + )); + + RaiseProgress(plan, $"Completed: {task.Description}"); + } + catch (Exception ex) + { + task.Status = TaskStatus.Failed; + task.EndTime = DateTime.Now; + task.Error = ex.Message; + plan.Findings[task.Id] = $"Task failed: {ex.Message}"; + + MessageAdded?.Invoke(this, new ChatMessageViewModel( + "Agent", + $"❌ **Task Failed**: {ex.Message}", + isError: true + )); + + RaiseProgress(plan, $"Task failed: {task.Description} - {ex.Message}"); + // Continue with other tasks + } + } + } + + /// + /// Execute a single research task. + /// + private async Task ExecuteResearchTaskAsync(AgentPlan plan, ResearchTask task, CancellationToken cancellationToken) + { + var systemPrompt = GetResearchSystemPrompt(); + + // Build context from previous findings (with length limits) + var previousFindings = new StringBuilder(); + const int maxFindingsLength = 100000; // Limit previous findings + + foreach (var kvp in plan.Findings) + { + if (kvp.Key != task.Id) // Don't include current task + { + var finding = kvp.Value; + // Truncate individual findings if too long + if (finding.Length > 20000) + { + finding = finding.Substring(0, 20000) + "... [truncated]"; + } + + previousFindings.AppendLine($"[{kvp.Key}]: {finding}"); + previousFindings.AppendLine(); + + // Stop if we've accumulated too much context + if (previousFindings.Length > maxFindingsLength) + { + previousFindings.AppendLine("[Additional findings omitted to save space]"); + break; + } + } + } + + var overview = contextProvider.GetAllBuildsOverview(); + + var userPrompt = $@"Task Goal: {task.Goal} +Task Description: {task.Description} + +Build Overview: +{overview} + +Previous Findings: +{previousFindings} + +Use the available tools to investigate and produce clear, concise findings for this task. +Focus only on this specific task goal. Output your findings as a summary."; + + var messages = new List + { + new ChatMessage(ChatRole.System, systemPrompt), + new ChatMessage(ChatRole.User, userPrompt) + }; + + var researchTools = GetToolsForPhase(AgentPhase.Research); + + var options = new ChatOptions + { + Tools = researchTools, + Temperature = 0.5f, + MaxOutputTokens = MaxTokensPerTask + }; + + var response = await llmClient!.CompleteChatAsync(messages, options, cancellationToken); + return response.Text ?? "No findings generated."; + } + + /// + /// Phase 3: Synthesize all findings and present final answer. + /// + private async System.Threading.Tasks.Task SummarizationPhaseAsync(AgentPlan plan, CancellationToken cancellationToken) + { + plan.Phase = AgentExecutionPhase.Summarization; + RaiseProgress(plan, "Synthesizing findings and preparing answer..."); + + MessageAdded?.Invoke(this, new ChatMessageViewModel( + "Agent", + "📊 **Synthesizing all findings...**" + )); + + var systemPrompt = GetSummarizationSystemPrompt(); + + // Compile all findings + var allFindings = new StringBuilder(); + + // Check if we have a direct answer from planning phase + if (!string.IsNullOrWhiteSpace(plan.DirectAnswer)) + { + allFindings.AppendLine("## Direct Answer from Planning Phase"); + allFindings.AppendLine("The planning agent determined this question could be answered directly without examining the build log:"); + allFindings.AppendLine(); + allFindings.AppendLine(plan.DirectAnswer); + allFindings.AppendLine(); + } + else + { + // Compile findings from research tasks + for (int i = 0; i < plan.ResearchTasks.Count; i++) + { + var task = plan.ResearchTasks[i]; + allFindings.AppendLine($"## Task {i + 1}: {task.Description}"); + allFindings.AppendLine($"Status: {task.Status}"); + if (task.Status == TaskStatus.Complete) + { + allFindings.AppendLine($"Findings: {task.Findings}"); + } + else if (task.Status == TaskStatus.Failed) + { + allFindings.AppendLine($"Error: {task.Error}"); + } + allFindings.AppendLine(); + } + } + + var userPrompt = $@"User Question: {plan.UserQuery} + +Research Findings: +{allFindings} + +Your task: +1. Synthesize the findings into a coherent, well-structured answer +2. Provide clear, actionable insights +3. If a direct answer was provided, enhance it with formatting and ensure it fully addresses the user's question + +Format your answer with markdown for readability."; + + var messages = new List + { + new ChatMessage(ChatRole.System, systemPrompt), + new ChatMessage(ChatRole.User, userPrompt) + }; + + // Get ALL tools for summarization + var allTools = GetToolsForPhase(AgentPhase.Summarization); + + var options = new ChatOptions + { + Tools = allTools, + Temperature = 0.7f + }; + + var response = await llmClient!.CompleteChatAsync(messages, options, cancellationToken); + plan.FinalSummary = response.Text ?? "No summary generated."; + + RaiseProgress(plan, "Summarization complete."); + } + + /// + /// Get tools appropriate for the current phase. + /// + private AIFunction[] GetToolsForPhase(AgentPhase phase) + { + try + { + var tools = new List(); + + // Enumerate all tool executors and get their tools + foreach (var executor in toolContainers) + { + foreach (var (function, applicablePhases) in executor.GetTools()) + { + // Filter by phase + if ((applicablePhases & phase) == 0) + { + continue; // Skip tools not applicable to this phase + } + + // Wrap with monitoring + var monitored = new MonitoredAIFunction(function, logger); + monitored.ToolCallStarted += OnToolCallStarted; + monitored.ToolCallCompleted += OnToolCallCompleted; + tools.Add(monitored); + } + } + + return tools.ToArray(); + } + catch (Exception ex) + { + logger?.LogError($"Error creating tools for phase {phase}: {ex.Message}"); + return Array.Empty(); + } + } + + private void OnToolCallStarted(object? sender, ToolCallInfo toolCallInfo) + { + // Raise event for UI consumption + ToolCallExecuting?.Invoke(this, toolCallInfo); + } + + private void OnToolCallCompleted(object? sender, ToolCallInfo toolCallInfo) + { + // Raise event for UI consumption + ToolCallExecuted?.Invoke(this, toolCallInfo); + } + + private void RaiseProgress(AgentPlan plan, string? message = null, bool isError = false) + { + ProgressUpdated?.Invoke(this, new AgentProgressEventArgs(plan, message, isError)); + } + + private void RaiseMessage(ChatMessageViewModel message) + { + MessageAdded?.Invoke(this, message); + } + + #region System Prompts + + private string GetPlanningSystemPrompt(string researchToolDescriptions) + { + return $@"You are an expert MSBuild log analyzer creating a research plan. + +Your task: Analyze the user's question and determine if it needs research or if you already know the answer. + +You have access to tools during planning: +- **AskUser**: Use this if the user's question is ambiguous or unclear. Don't hesitate to ask for clarification. +- Other planning tools as available + +First, think through the question: +1. What are the key aspects that need investigation? +2. What tools would be most effective for gathering information? +3. Is the question clear or ambiguous? If you are very unclear about requirements, consider using AskUser to clarify. +4. Is this a trivial question that you can answer directly based on general MSBuild knowledge WITHOUT needing to analyze the specific build log? + - If you are VERY CONFIDENT you know the answer, you can provide it directly instead of creating research tasks. + +The research agents will have access to these tools: + +{researchToolDescriptions} + +**Two Response Options:** + +**Option 1 - Research Required** (default, use when answer requires examining this specific build log): +Create specific research tasks with 1-{MaxResearchTasks} tasks. Each task should: +- Have a clear, specific goal +- Be designed to use the tools that will be available to the research agents +- Produce findings that contribute to answering the user's question + +End your response with the plan in JSON format: +```json +{{ + ""tasks"": [ + {{""id"": ""task1"", ""description"": ""Brief description"", ""goal"": ""Specific investigation goal""}}, + {{""id"": ""task2"", ""description"": ""Brief description"", ""goal"": ""Specific investigation goal""}} + ] +}} +``` + +**Option 2 - Direct Answer** (use ONLY when you are confident about the answer based on general knowledge): +Provide the answer directly. End your response with JSON format: +```json +{{ + ""directAnswer"": ""Your complete answer to the user's question"" +}} +``` +"; + } + + private string GetResearchSystemPrompt() + { + return @"You are conducting a specific research task as part of analyzing an MSBuild log. + +Use the available tools to investigate thoroughly. +Focus only on your specific task goal. +Be concise but complete in your findings. +Output your findings as a clear summary that will be used by another agent to synthesize the final answer."; + } + + private string GetSummarizationSystemPrompt() + { + var basePrompt = @"You are synthesizing research findings to answer the user's question about their MSBuild log. + +Your task: +1. Review all research findings +2. Synthesize them into a coherent, well-structured answer +3. Provide clear, actionable insights + +Format your answer with markdown for readability. +Be helpful and specific."; + + // Check if we have GUI manipulation tools available + if (HasGuiManipulationTools()) + { + basePrompt += @" + +You have access to GUI manipulation tools that can help users visualize and explore findings: +- Use tools like SelectNodeByTextAsync, SelectErrorAsync, SelectWarningAsync to navigate the UI to relevant nodes +- Use OpenTimelineAsync, OpenTracingAsync, PerformSearchAsync to switch views and help users explore data +- Use these tools proactively when they can help clarify or support your findings +- These tools make your answer interactive - leverage them to provide a better user experience + +When presenting findings, consider: +- Which errors/warnings should be highlighted for the user to see? +- What nodes or files would help illustrate the issue? +- Would timeline or tracing views provide useful context? +- Should the user see specific search results? + +Use GUI tools to make your insights actionable and immediately explorable by the user."; + } + + return basePrompt; + } + + #endregion + + #region Helper Methods + + /// + /// Generates a formatted string describing available tools based on AIFunction metadata. + /// + private string GetToolDescriptions(AIFunction[] tools) + { + var descriptions = new StringBuilder(); + + foreach (var tool in tools) + { + descriptions.AppendLine($"- {tool.Name}: {tool.Description}"); + + // Try to include parameter info from JSON schema + try + { + var schema = tool.JsonSchema; + if (schema.ValueKind == JsonValueKind.Object && + schema.TryGetProperty("properties", out var properties) && + properties.ValueKind == JsonValueKind.Object) + { + var paramNames = new List(); + foreach (var property in properties.EnumerateObject()) + { + paramNames.Add(property.Name); + } + + if (paramNames.Count > 0) + { + descriptions.AppendLine($" Parameters: {string.Join(", ", paramNames)}"); + } + } + } + catch + { + // If schema parsing fails, skip parameter details + } + } + + return descriptions.ToString(); + } + + /// + /// Checks if any registered tool containers provide GUI manipulation tools. + /// + private bool HasGuiManipulationTools() + { + return toolContainers.Any(container => container.HasGuiTools); + } + + /// + /// Extracts thinking/reasoning and plan JSON from a response. + /// Returns the thinking text and the JSON plan separately. + /// + private (string thinking, string planJson) ExtractThinkingAndPlan(string response) + { + if (string.IsNullOrWhiteSpace(response)) + { + return (string.Empty, "{}"); + } + + // Strategy: Find the last JSON block in the response + // Everything before it is thinking, the JSON block is the plan + + // First, try to find JSON in markdown code blocks + int lastJsonBlockStart = response.LastIndexOf("```json", StringComparison.OrdinalIgnoreCase); + if (lastJsonBlockStart < 0) + { + lastJsonBlockStart = response.LastIndexOf("```", StringComparison.Ordinal); + } + + if (lastJsonBlockStart >= 0) + { + // Found a code block, extract JSON from it + int jsonContentStart = response.IndexOf('\n', lastJsonBlockStart) + 1; + int jsonBlockEnd = response.IndexOf("```", jsonContentStart, StringComparison.Ordinal); + + if (jsonBlockEnd > jsonContentStart) + { + string thinking = response.Substring(0, lastJsonBlockStart).Trim(); + string planJson = response.Substring(jsonContentStart, jsonBlockEnd - jsonContentStart).Trim(); + return (thinking, planJson); + } + } + + // No markdown code block, try to find raw JSON + // Look for the last occurrence of { followed by "tasks" + int lastJsonStart = -1; + int searchPos = 0; + + while (true) + { + int pos = response.IndexOf("{", searchPos, StringComparison.Ordinal); + if (pos < 0) + { + break; + } + + // Check if this looks like our tasks JSON + int tasksPos = response.IndexOf("\"tasks\"", pos, StringComparison.Ordinal); + if (tasksPos > pos && tasksPos < pos + 50) // Within reasonable distance + { + lastJsonStart = pos; + } + + searchPos = pos + 1; + } + + if (lastJsonStart >= 0) + { + // Try to find the matching closing brace + int braceCount = 0; + int jsonEnd = -1; + + for (int i = lastJsonStart; i < response.Length; i++) + { + if (response[i] == '{') + { + braceCount++; + } + else if (response[i] == '}') + { + braceCount--; + if (braceCount == 0) + { + jsonEnd = i + 1; + break; + } + } + } + + if (jsonEnd > lastJsonStart) + { + string thinking = response.Substring(0, lastJsonStart).Trim(); + string planJson = response.Substring(lastJsonStart, jsonEnd - lastJsonStart).Trim(); + return (thinking, planJson); + } + } + + // Fallback: treat entire response as JSON, no thinking + return (string.Empty, response.Trim()); + } + + #endregion + + #region JSON Models + + private class PlanResponse + { + public List Tasks { get; set; } = new List(); + public string? DirectAnswer { get; set; } + } + + private class TaskData + { + public string Id { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string Goal { get; set; } = string.Empty; + } + + #endregion + + public void Dispose() + { + if (llmClient is IDisposable disposable) + { + disposable.Dispose(); + } + } + } +} diff --git a/src/StructuredLogger.LLM/Services/ChatHistoryService.cs b/src/StructuredLogger.LLM/Services/ChatHistoryService.cs new file mode 100644 index 000000000..62c0ad56d --- /dev/null +++ b/src/StructuredLogger.LLM/Services/ChatHistoryService.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace StructuredLogger.LLM +{ + /// + /// Persists chat history per binlog file and session under the user's local app data folder. + /// History files are stored as JSON, keyed by a hash of the binlog file path and session ID. + /// + public class ChatHistoryService + { + private static readonly string ChatHistoryFolder = Path.Combine( + GetRootPath(), "ChatHistory"); + + private readonly string historyFilePath; + private readonly string binlogFileKey; + + /// + /// The default session ID used when no explicit session is specified. + /// + public const string DefaultSessionId = "default"; + + public string SessionId { get; } + + public ChatHistoryService(string binlogFilePath, string sessionId = null) + { + if (string.IsNullOrEmpty(binlogFilePath)) + { + throw new ArgumentNullException(nameof(binlogFilePath)); + } + + SessionId = string.IsNullOrEmpty(sessionId) ? DefaultSessionId : sessionId; + binlogFileKey = ComputeFileKey(binlogFilePath); + historyFilePath = GetSessionFilePath(binlogFileKey, SessionId); + } + + /// + /// Saves the list of chat messages to disk. + /// Only User and Assistant messages should be passed in. + /// + public void Save(IEnumerable messages, string displayName = null) + { + try + { + Directory.CreateDirectory(ChatHistoryFolder); + + var data = new ChatHistoryData + { + DisplayName = displayName, + Messages = messages.ToList() + }; + + var json = JsonSerializer.Serialize(data, ChatHistoryJsonContext.Default.ChatHistoryData); + File.WriteAllText(historyFilePath, json); + } + catch + { + // Silently fail - history is non-critical + } + } + + /// + /// Loads previously saved chat messages for the bound binlog file and session. + /// Returns an empty list if no history exists. + /// + public List Load() + { + try + { + if (!File.Exists(historyFilePath)) + { + return new List(); + } + + var json = File.ReadAllText(historyFilePath); + var data = JsonSerializer.Deserialize(json, ChatHistoryJsonContext.Default.ChatHistoryData); + return data?.Messages ?? new List(); + } + catch + { + return new List(); + } + } + + /// + /// Loads the persisted display name for this session. + /// Returns null if no history or no name is saved. + /// + public string LoadDisplayName() + { + try + { + if (!File.Exists(historyFilePath)) + { + return null; + } + + var json = File.ReadAllText(historyFilePath); + var data = JsonSerializer.Deserialize(json, ChatHistoryJsonContext.Default.ChatHistoryData); + return data?.DisplayName; + } + catch + { + return null; + } + } + + /// + /// Deletes the history file for the bound binlog file and session. + /// + public void Delete() + { + try + { + if (File.Exists(historyFilePath)) + { + File.Delete(historyFilePath); + } + } + catch + { + // Silently fail + } + } + + /// + /// Lists all session IDs that have persisted history for the given binlog file. + /// + public static List ListSessions(string binlogFilePath) + { + var sessions = new List(); + try + { + if (!Directory.Exists(ChatHistoryFolder)) + { + return sessions; + } + + var key = ComputeFileKey(binlogFilePath); + var prefix = key + "_"; + var files = Directory.GetFiles(ChatHistoryFolder, prefix + "*.json"); + + foreach (var file in files) + { + var fileName = Path.GetFileNameWithoutExtension(file); + if (fileName.StartsWith(prefix)) + { + var sessionId = fileName.Substring(prefix.Length); + if (!string.IsNullOrEmpty(sessionId)) + { + sessions.Add(sessionId); + } + } + } + } + catch + { + // Silently fail + } + + return sessions; + } + + private static string GetSessionFilePath(string binlogKey, string sessionId) + { + return Path.Combine(ChatHistoryFolder, binlogKey + "_" + sessionId + ".json"); + } + + private static string ComputeFileKey(string binlogFilePath) + { + var normalized = Path.GetFullPath(binlogFilePath).ToLowerInvariant(); + using (var sha = SHA256.Create()) + { + var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(normalized)); + // Use first 16 bytes (32 hex chars) for a short but collision-resistant key + var sb = new StringBuilder(32); + for (int i = 0; i < 16; i++) + { + sb.Append(hash[i].ToString("x2")); + } + return sb.ToString(); + } + } + + private static string GetRootPath() + { + if (Environment.GetEnvironmentVariable("MSBUILDSTRUCTUREDLOG_DATA_DIR") is string dataDir + && !string.IsNullOrEmpty(dataDir)) + { + return Path.GetFullPath(dataDir); + } + + var path = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + path = Path.Combine(path, "Microsoft", "MSBuildStructuredLog"); + return path; + } + } + + /// + /// A single entry in persisted chat history. + /// + public class ChatHistoryEntry + { + [JsonPropertyName("role")] + public string Role { get; set; } = string.Empty; + + [JsonPropertyName("content")] + public string Content { get; set; } = string.Empty; + + [JsonPropertyName("timestamp")] + public DateTime Timestamp { get; set; } + } + + /// + /// Root object for the persisted chat history JSON file. + /// + public class ChatHistoryData + { + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } + + [JsonPropertyName("messages")] + public List Messages { get; set; } = new List(); + } + + /// + /// Source-generated JSON serialization context for AOT compatibility. + /// + [JsonSerializable(typeof(ChatHistoryData))] + [JsonSerializable(typeof(ChatHistoryEntry))] + [JsonSerializable(typeof(List))] + internal partial class ChatHistoryJsonContext : JsonSerializerContext + { + } +} diff --git a/src/StructuredLogger.LLM/Services/LLMChatService.cs b/src/StructuredLogger.LLM/Services/LLMChatService.cs new file mode 100644 index 000000000..53dab160c --- /dev/null +++ b/src/StructuredLogger.LLM/Services/LLMChatService.cs @@ -0,0 +1,560 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Build.Logging.StructuredLogger; +using Microsoft.Extensions.AI; +using StructuredLogger.LLM.Logging; + +namespace StructuredLogger.LLM +{ + /// + /// Main service for LLM Chat functionality. + /// Orchestrates chat history, LLM communication, and tool execution. + /// Supports multiple binlog files through MultiBuildContext. + /// + public class LLMChatService : IDisposable + { + private readonly MultiBuildContext buildContext; + private readonly MultiBuildContextProvider contextProvider; + private readonly List toolContainers; + private MultiProviderLLMClient? llmClient; + private readonly LLMConfiguration configuration; + private readonly List chatHistory; + private BaseNode? currentSelectedNode; + private readonly ILLMLogger? logger; + + // Context compaction settings + private const int MaxChatHistoryMessages = 20; // Keep recent context + private const int CompactionThreshold = 16; // Trigger compaction at this count + private const int RecentMessagesToKeep = 6; // Keep this many recent messages after compaction + + public event EventHandler? MessageAdded; + public event EventHandler? ConversationCleared; + public event EventHandler? ToolCallExecuting; + public event EventHandler? ToolCallExecuted; + public event EventHandler? RequestRetrying; + public event EventHandler? ConversationCompacted; + + public bool IsConfigured => configuration?.IsConfigured ?? false; + public string ConfigurationStatus => configuration?.GetConfigurationStatus() ?? "Not initialized"; + + /// + /// Creates a new LLMChatService with multi-build support. + /// + private LLMChatService(MultiBuildContext context, LLMConfiguration config, ILLMLogger? logger) + { + this.buildContext = context ?? throw new ArgumentNullException(nameof(context)); + this.contextProvider = new MultiBuildContextProvider(context); + this.toolContainers = new List(); + this.chatHistory = new List(); + this.configuration = config; + this.logger = logger; + + // Register default tool executors with multi-build context + RegisterToolContainer(new BinlogToolExecutor(context)); + RegisterToolContainer(new EmbeddedFilesToolExecutor(context)); + RegisterToolContainer(new ListEventsToolExecutor(context)); + RegisterToolContainer(new ResultsToolExecutor()); + } + + /// + /// Creates a new LLMChatService for a single build (backward compatibility). + /// + private LLMChatService(Build build, LLMConfiguration config, ILLMLogger? logger) + : this(CreateSingleBuildContext(build), config, logger) + { + } + + private static MultiBuildContext CreateSingleBuildContext(Build build) + { + if (build == null) + { + throw new ArgumentNullException(nameof(build)); + } + var context = new MultiBuildContext(); + context.AddBuild(build); + return context; + } + + /// + /// Creates and initializes a new instance of LLMChatService with multi-build support. + /// + /// The multi-build context containing loaded builds. + /// Optional LLM configuration. If null, loads from environment. + /// Optional logger for diagnostics. + /// Cancellation token for async initialization. + /// A fully initialized LLMChatService instance. + public static async System.Threading.Tasks.Task CreateAsync( + MultiBuildContext context, + LLMConfiguration? config = null, + ILLMLogger? logger = null, + CancellationToken cancellationToken = default) + { + var configuration = config ?? LLMConfiguration.LoadFromEnvironment(); + var service = new LLMChatService(context, configuration, logger); + + await service.InitializeLLMClientAsync(cancellationToken); + + return service; + } + + /// + /// Creates and initializes a new instance of LLMChatService for a single build. + /// + /// The build to analyze. + /// Optional LLM configuration. If null, loads from environment. + /// Optional logger for diagnostics. + /// Cancellation token for async initialization. + /// A fully initialized LLMChatService instance. + public static async System.Threading.Tasks.Task CreateAsync( + Build build, + LLMConfiguration? config = null, + ILLMLogger? logger = null, + CancellationToken cancellationToken = default) + { + var configuration = config ?? LLMConfiguration.LoadFromEnvironment(); + var service = new LLMChatService(build, configuration, logger); + + await service.InitializeLLMClientAsync(cancellationToken); + + return service; + } + + /// + /// Adds a build to the context. Can be called after service creation. + /// + public string AddBuild(Build build, string? friendlyName = null) + { + return buildContext.AddBuild(build, friendlyName); + } + + /// + /// Removes a build from the context. + /// + public void RemoveBuild(string buildId) + { + buildContext.RemoveBuild(buildId); + } + + /// + /// Sets the primary build. + /// + public void SetPrimaryBuild(string buildId) + { + buildContext.SetPrimaryBuild(buildId); + } + + /// + /// Gets info about all loaded builds. + /// + public IEnumerable GetLoadedBuilds() + { + return buildContext.GetAllBuilds(); + } + + /// + /// Registers an additional tool executor with this service. + /// Used to add UI-specific tools after service construction. + /// + public void RegisterToolContainer(IToolsContainer container) + { + if (container == null) + { + throw new ArgumentNullException(nameof(container)); + } + + // Prevent duplicate registration + if (!toolContainers.Contains(container)) + { + toolContainers.Add(container); + } + } + + private async System.Threading.Tasks.Task InitializeLLMClientAsync(CancellationToken cancellationToken) + { + if (!configuration.IsConfigured) + { + return; + } + + try + { + var client = new MultiProviderLLMClient(configuration, logger: logger); + await client.InitializeAsync(cancellationToken); + this.llmClient = client; + + SubscribeToResilienceEvents(client); + } + catch (Exception ex) + { + logger?.LogError($"Failed to initialize LLM client: {ex.Message}"); + throw; + } + } + + private void SubscribeToResilienceEvents(MultiProviderLLMClient client) + { + // Subscribe to resilience events + if (client.ResilientClient != null) + { + client.ResilientClient.RequestRetrying += (sender, e) => RequestRetrying?.Invoke(this, e); + } + } + + public void SetSelectedNode(BaseNode node) + { + currentSelectedNode = node; + } + + public void ClearConversation() + { + chatHistory.Clear(); + ConversationCleared?.Invoke(this, EventArgs.Empty); + } + + /// + /// Seeds the in-memory chat history from previously persisted entries. + /// This allows the LLM to have context from prior conversations. + /// + public void SeedChatHistory(IEnumerable entries) + { + foreach (var entry in entries) + { + var role = entry.Role == "User" ? ChatRole.User : ChatRole.Assistant; + chatHistory.Add(new ChatMessage(role, entry.Content)); + } + } + + public LLMConfiguration GetConfiguration() + { + return configuration; + } + + public async System.Threading.Tasks.Task ReconfigureAsync(LLMConfiguration newConfig, CancellationToken cancellationToken = default) + { + if (newConfig == null) + { + throw new ArgumentNullException(nameof(newConfig)); + } + + // Update configuration properties + configuration.Endpoint = newConfig.Endpoint; + configuration.ApiKey = newConfig.ApiKey; + configuration.ModelName = newConfig.ModelName; + configuration.AutoSendOnEnter = newConfig.AutoSendOnEnter; + configuration.AgentMode = newConfig.AgentMode; + configuration.UpdateType(); + + // Reinitialize with new settings + await InitializeLLMClientAsync(cancellationToken); + + // Keep chat history - don't clear + } + + private string GetSystemPrompt() + { + var overview = contextProvider.GetAllBuildsOverview(); + var buildCount = buildContext.BuildCount; + + string basePrompt; + if (buildCount > 1) + { + basePrompt = $@"You are an expert assistant helping developers analyze MSBuild build logs (.binlog files). +Multiple binlog files are loaded. Always clarify which build you're referring to using the build ID or friendly name. + +You have access to tools that can query build data including projects, targets, tasks, errors, warnings, and timing information. + +IMPORTANT: Tools accept an optional `buildId` parameter to target a specific build. If omitted, tools operate on the PRIMARY build. + +{overview} + +Guidelines for multi-build analysis: +- Always mention which build your findings come from (e.g., ""In the Tests build..."") +- If a question could apply to multiple builds, check all relevant builds +- Use ListBuilds tool to see available builds and their IDs +- Consider comparing results across builds when relevant +- Be explicit about the build context to avoid confusion"; + } + else + { + basePrompt = $@"You are an expert assistant helping developers analyze their MSBuild build log (.binlog file). +You have access to tools that can query the build data including projects, targets, tasks, errors, warnings, and timing information. +When the user asks questions, you must use the available tools to retrieve accurate information from the build log - as you do not have information about their builds in your training set. +Be concise and helpful. Format your responses clearly. + +Available context: +{overview}"; + } + + if (HasGuiManipulationTools()) + { + basePrompt += @" + +You have access to GUI manipulation tools that can help users visualize and explore their build: +- Use tools like SelectNodeByTextAsync, SelectErrorAsync, SelectWarningAsync to navigate the UI to relevant nodes +- Use OpenTimelineAsync, OpenTracingAsync, PerformSearchAsync to switch views and help users explore data +- Use these tools proactively when they can help clarify or support your findings +- These tools make your responses interactive - leverage them to provide a better user experience + +When answering questions, consider: +- Which errors/warnings should be highlighted for the user to see? +- What nodes or files would help illustrate the answer? +- Would timeline or tracing views provide useful context? +- Should the user see specific search results? + +Use GUI tools to make your insights actionable and immediately explorable by the user."; + } + + return basePrompt; + } + + /// + /// Trims chat history to stay within token limits by keeping only recent messages. + /// Used as a fallback when compaction hasn't run yet. + /// + private List GetTrimmedChatHistory() + { + if (chatHistory.Count <= MaxChatHistoryMessages) + { + return chatHistory; + } + + // Keep the most recent messages + var trimmedHistory = chatHistory + .Skip(chatHistory.Count - MaxChatHistoryMessages) + .ToList(); + + logger?.LogVerbose( + $"Chat history trimmed from {chatHistory.Count} to {trimmedHistory.Count} messages"); + + return trimmedHistory; + } + + /// + /// Compacts the conversation by summarizing older messages into a single summary. + /// This preserves context while reducing token usage. + /// + private async System.Threading.Tasks.Task CompactChatHistoryAsync(CancellationToken cancellationToken) + { + if (llmClient == null || chatHistory.Count < CompactionThreshold) + { + return; + } + + var messagesToSummarize = chatHistory.Count - RecentMessagesToKeep; + if (messagesToSummarize <= 2) + { + return; + } + + logger?.LogVerbose($"Compacting chat history: summarizing {messagesToSummarize} older messages, keeping {RecentMessagesToKeep} recent."); + + try + { + // Build the summarization request + var olderMessages = chatHistory.Take(messagesToSummarize).ToList(); + + var conversationText = string.Join("\n", olderMessages.Select(m => + { + var role = m.Role == ChatRole.User ? "User" : "Assistant"; + return $"{role}: {m.Text}"; + })); + + var summaryPrompt = new List + { + new ChatMessage(ChatRole.System, + "You are a conversation summarizer. Summarize the following conversation between a user and an assistant about MSBuild build logs. " + + "Preserve key facts, findings, errors discussed, and any conclusions. Be concise but retain all important technical details. " + + "Write the summary in third person (e.g., 'The user asked about..., The analysis found...')"), + new ChatMessage(ChatRole.User, + $"Please summarize this conversation:\n\n{conversationText}") + }; + + var options = new ChatOptions { Temperature = 0.3f }; + var response = await llmClient.CompleteChatAsync(summaryPrompt, options, cancellationToken); + var summary = response.Text ?? string.Empty; + + if (string.IsNullOrWhiteSpace(summary)) + { + return; + } + + // Replace older messages with a single summary message + var recentMessages = chatHistory.Skip(messagesToSummarize).ToList(); + chatHistory.Clear(); + chatHistory.Add(new ChatMessage(ChatRole.Assistant, + $"[Conversation Summary]\n{summary}")); + chatHistory.AddRange(recentMessages); + + logger?.LogVerbose($"Chat history compacted to {chatHistory.Count} messages (1 summary + {recentMessages.Count} recent)."); + + ConversationCompacted?.Invoke(this, summary); + } + catch (Exception ex) + { + logger?.LogError($"Failed to compact chat history: {ex.Message}"); + // Compaction failure is non-critical - fall back to simple trimming + } + } + + private AIFunction[] GetAvailableTools(AgentPhase phase = AgentPhase.All) + { + try + { + var tools = new List(); + + // Enumerate all tool executors and get their tools + foreach (var executor in toolContainers) + { + foreach (var (function, applicablePhases) in executor.GetTools()) + { + // Filter by phase + if ((applicablePhases & phase) == 0) + { + continue; // Skip tools not applicable to this phase + } + + // Wrap with monitoring + var monitoredFunction = new MonitoredAIFunction(function, logger); + monitoredFunction.ToolCallStarted += OnToolCallStarted; + monitoredFunction.ToolCallCompleted += OnToolCallCompleted; + tools.Add(monitoredFunction); + } + } + + logger?.LogVerbose($"Registered {tools.Count} tools for phase {phase}:"); + foreach (var tool in tools) + { + logger?.LogVerbose($" - {tool.Name}: {tool.Description ?? "(no description)"}"); + } + + return tools.ToArray(); + } + catch (Exception ex) + { + logger?.LogError($"Error creating tools: {ex.Message}"); + logger?.LogVerbose(ex.StackTrace ?? "(no stack trace)"); + return Array.Empty(); + } + } + + /// + /// Checks if any registered tool containers provide GUI manipulation tools. + /// + private bool HasGuiManipulationTools() + { + return toolContainers.Any(container => container.HasGuiTools); + } + + private void OnToolCallStarted(object? sender, ToolCallInfo toolCallInfo) + { + // Raise event for UI consumption + ToolCallExecuting?.Invoke(this, toolCallInfo); + } + + private void OnToolCallCompleted(object? sender, ToolCallInfo toolCallInfo) + { + // Raise event for UI consumption + ToolCallExecuted?.Invoke(this, toolCallInfo); + } + + public async Task SendMessageAsync(string userMessage, CancellationToken cancellationToken = default) + { + if (!IsConfigured) + { + var errorMsg = "LLM is not configured. Please set the required environment variables:\n" + + "- LLM_ENDPOINT (your Azure endpoint)\n" + + "- LLM_API_KEY (your API key)\n" + + "- LLM_MODEL (model name, e.g., gpt-4)\n\n" + + "Or use 'Configure' menu to configure/login."; + + MessageAdded?.Invoke(this, new ChatMessageViewModel("System", errorMsg, isError: true)); + return errorMsg; + } + + if (string.IsNullOrWhiteSpace(userMessage)) + { + return string.Empty; + } + + // Add user message to UI + MessageAdded?.Invoke(this, new ChatMessageViewModel("User", userMessage)); + + // Add to chat history + chatHistory.Add(new ChatMessage(ChatRole.User, userMessage)); + + try + { + // Compact older messages into a summary if history is getting long + await CompactChatHistoryAsync(cancellationToken); + + // Prepare messages with system prompt + var messages = new List(); + + // Add system prompt only at the start + if (chatHistory.Count == 1) + { + var systemPrompt = GetSystemPrompt(); + if (currentSelectedNode != null) + { + systemPrompt += "\n\n" + contextProvider.GetSelectedNodeContext(currentSelectedNode, buildContext.PrimaryBuildId); + } + messages.Add(new ChatMessage(ChatRole.System, systemPrompt)); + } + + // Add trimmed chat history to stay within token limits + var trimmedHistory = GetTrimmedChatHistory(); + messages.AddRange(trimmedHistory); + + // Get tools + var tools = GetAvailableTools(); + + // Send to LLM with tools + // UseFunctionInvocation() in the client builder will automatically handle tool calls + var options = new ChatOptions + { + Tools = tools, + Temperature = 0.7f + }; + + logger?.LogVerbose($"ChatOptions.Tools count: {options.Tools?.Count ?? 0}"); + + var response = await llmClient!.CompleteChatAsync( + messages, + options, + cancellationToken); + + // With UseFunctionInvocation(), the response already includes tool execution results + var finalResponse = response.Text ?? string.Empty; + + // Add assistant response to history and UI + chatHistory.Add(new ChatMessage(ChatRole.Assistant, finalResponse)); + MessageAdded?.Invoke(this, new ChatMessageViewModel("Assistant", finalResponse)); + + return finalResponse; + } + catch (OperationCanceledException) + { + var cancelMsg = "Request cancelled."; + MessageAdded?.Invoke(this, new ChatMessageViewModel("System", cancelMsg)); + return cancelMsg; + } + catch (Exception ex) + { + var errorMsg = $"Error: {ex.Message}"; + MessageAdded?.Invoke(this, new ChatMessageViewModel("System", errorMsg, isError: true)); + return errorMsg; + } + } + + public void Dispose() + { + if (llmClient is IDisposable disposable) + { + disposable.Dispose(); + } + } + } +} diff --git a/src/StructuredLogger.LLM/Services/ResultManager.cs b/src/StructuredLogger.LLM/Services/ResultManager.cs new file mode 100644 index 000000000..cee720299 --- /dev/null +++ b/src/StructuredLogger.LLM/Services/ResultManager.cs @@ -0,0 +1,315 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; + +namespace StructuredLogger.LLM +{ + /// + /// Manages in-memory storage of tool results with unique IDs. + /// Provides search capabilities within stored results and tracks metadata. + /// Thread-safe singleton implementation. + /// + public class ResultManager + { + private static readonly Lazy instance = new Lazy(() => new ResultManager()); + private readonly ConcurrentDictionary results = new ConcurrentDictionary(); + private int nextResultId = 1; + + /// + /// Gets the singleton instance of ResultManager. + /// + public static ResultManager Instance => instance.Value; + + private ResultManager() + { + } + + /// + /// Stores a result and returns its unique ID. + /// Automatically detects truncation by comparing full result with what will be returned. + /// + /// Name of the tool that generated the result + /// Arguments passed to the tool (formatted as invocation expression) + /// The complete, untruncated result + /// The result that was actually returned (may be truncated) + /// Unique ResultId for this result + public string StoreResult(string toolName, string arguments, string fullResult, string returnedResult) + { + var resultId = $"R{Interlocked.Increment(ref nextResultId):D3}"; + + bool wasTruncated = fullResult.Length != returnedResult.Length; + int percentage = 0; + + if (wasTruncated) + { + int removedChars = fullResult.Length - returnedResult.Length; + percentage = fullResult.Length > 0 ? (removedChars * 100) / fullResult.Length : 0; + } + + var info = new ResultInfo + { + ResultId = resultId, + ToolName = toolName, + Arguments = arguments, + FullResult = fullResult, + Timestamp = DateTime.Now, + OriginalLength = fullResult.Length, + WasTruncated = wasTruncated, + TruncationPercentage = percentage + }; + + results[resultId] = info; + return resultId; + } + + /// + /// Gets result information by ID. + /// + public ResultInfo? GetResult(string resultId) + { + if (results.TryGetValue(resultId, out var info)) + { + return info; + } + return null; + } + + /// + /// Lists all stored results ordered by timestamp (most recent first). + /// + public IEnumerable ListResults() + { + return results.Values.OrderByDescending(r => r.Timestamp); + } + + /// + /// Searches within a specific result using a regex pattern. + /// + /// The ID of the result to search + /// The regex pattern to search for + /// Maximum number of matches to return + /// Number of context lines before and after each match + /// Formatted search results or error message + public string SearchResult(string resultId, string regexPattern, int maxMatches = 50, int contextLines = 2) + { + // Validate result exists + if (!results.TryGetValue(resultId, out var resultInfo)) + { + return FormatInvalidResultIdError(resultId); + } + + // Validate regex pattern + Regex regex; + try + { + regex = new Regex(regexPattern, RegexOptions.IgnoreCase | RegexOptions.Multiline, TimeSpan.FromSeconds(5)); + } + catch (ArgumentException ex) + { + return $"Error: Invalid regex pattern.\n{ex.Message}\n\nExample patterns:\n - Simple text: \"Csc\"\n - Word boundary: \"\\\\bError\\\\b\"\n - Case sensitive: Use (?-i) prefix\n - Line start: \"^Target\""; + } + + // Split result into lines for context + var lines = resultInfo.FullResult.Split(new[] { '\r', '\n' }, StringSplitOptions.None); + var matches = new List<(int lineNum, string line, string matchedText)>(); + + // Find all matches + try + { + for (int i = 0; i < lines.Length; i++) + { + var match = regex.Match(lines[i]); + if (match.Success) + { + matches.Add((i + 1, lines[i], match.Value)); + if (matches.Count >= maxMatches * 2) // Get extra in case we want to show more + break; + } + } + } + catch (RegexMatchTimeoutException) + { + return "Error: Regex search timed out. Please use a simpler pattern."; + } + + // Format results + var sb = new StringBuilder(); + sb.AppendLine($"Search Results for ResultId: {resultId}"); + sb.AppendLine($"Pattern: \"{regexPattern}\""); + sb.AppendLine($"Tool: {resultInfo.ToolName}({resultInfo.Arguments})"); + sb.AppendLine(); + + if (matches.Count == 0) + { + sb.AppendLine("No matches found."); + sb.AppendLine(); + sb.AppendLine("Tips:"); + sb.AppendLine("- Check your regex pattern syntax"); + sb.AppendLine("- Search is case-insensitive by default"); + sb.AppendLine("- Try a simpler or broader pattern"); + return sb.ToString(); + } + + int displayCount = Math.Min(matches.Count, maxMatches); + sb.AppendLine($"Matches: {matches.Count} found (showing first {displayCount})"); + sb.AppendLine(); + + for (int i = 0; i < displayCount; i++) + { + var (lineNum, line, matchedText) = matches[i]; + sb.AppendLine($"--- Match {i + 1} (line {lineNum}) ---"); + + // Show context before + for (int j = Math.Max(0, lineNum - contextLines - 1); j < lineNum - 1; j++) + { + if (j < lines.Length) + { + sb.AppendLine($" {j + 1}: {TruncateLine(lines[j], 150)}"); + } + } + + // Show matched line (highlight if possible) + sb.AppendLine($"> {lineNum}: {TruncateLine(line, 200)}"); + + // Show context after + for (int j = lineNum; j < Math.Min(lines.Length, lineNum + contextLines); j++) + { + sb.AppendLine($" {j + 1}: {TruncateLine(lines[j], 150)}"); + } + + sb.AppendLine(); + } + + if (matches.Count > maxMatches) + { + sb.AppendLine($"[{matches.Count - maxMatches} more matches available. Use maxMatches parameter to see more.]"); + } + + return sb.ToString(); + } + + /// + /// Prepends metadata header to a result string. + /// + public string PrependMetadata(string resultId, string content) + { + var resultInfo = GetResult(resultId); + if (resultInfo == null) + { + return content; + } + + var sb = new StringBuilder(); + sb.AppendLine($"ResultId: {resultId}"); + + if (resultInfo.WasTruncated) + { + sb.AppendLine($"Truncated: Yes ({resultInfo.TruncationPercentage}% removed)"); + } + else + { + sb.AppendLine("Truncated: No"); + } + + sb.AppendLine(); + sb.Append(content); + + return sb.ToString(); + } + + /// + /// Clears all stored results. Useful for testing or memory management. + /// + public void Clear() + { + results.Clear(); + nextResultId = 0; + } + + private string FormatInvalidResultIdError(string requestedId) + { + var sb = new StringBuilder(); + sb.AppendLine($"Error: ResultId '{requestedId}' not found."); + sb.AppendLine(); + + if (results.IsEmpty) + { + sb.AppendLine("No results have been cataloged yet."); + sb.AppendLine("Run other tools (SearchNodes, ListEvents, etc.) to generate results."); + } + else + { + sb.AppendLine("Available ResultIds:"); + foreach (var result in results.Values.OrderBy(r => r.ResultId)) + { + sb.AppendLine($" - {result.ResultId}: {result.ToolName}"); + } + } + + return sb.ToString(); + } + + private string TruncateLine(string line, int maxLength) + { + if (string.IsNullOrEmpty(line) || line.Length <= maxLength) + return line; + + return line.Substring(0, maxLength) + "..."; + } + + /// + /// Gets the count of stored results. + /// + public int Count => results.Count; + } + + /// + /// Represents information about a stored result. + /// + public class ResultInfo + { + /// + /// Unique identifier for this result (e.g., "R001", "R002"). + /// + public string ResultId { get; set; } = ""; + + /// + /// Name of the tool that generated this result. + /// + public string ToolName { get; set; } = ""; + + /// + /// Arguments/parameters passed to the tool, formatted for display. + /// + public string Arguments { get; set; } = ""; + + /// + /// The complete, untruncated result content. + /// + public string FullResult { get; set; } = ""; + + /// + /// Timestamp when this result was stored. + /// + public DateTime Timestamp { get; set; } + + /// + /// Original length of the result in characters. + /// + public int OriginalLength { get; set; } + + /// + /// Whether this result was truncated when returned to the LLM. + /// + public bool WasTruncated { get; set; } + + /// + /// Percentage of content removed if truncated (0-100). + /// + public int TruncationPercentage { get; set; } + } +} diff --git a/src/StructuredLogger.LLM/StructuredLogger.LLM.csproj b/src/StructuredLogger.LLM/StructuredLogger.LLM.csproj new file mode 100644 index 000000000..d1a89ab6b --- /dev/null +++ b/src/StructuredLogger.LLM/StructuredLogger.LLM.csproj @@ -0,0 +1,26 @@ + + + + netstandard2.0;net8.0 + latest + enable + + + + + + + + + + + + + + + + + + + + diff --git a/src/StructuredLogger.LLM/Tools/AskUserToolExecutor.cs b/src/StructuredLogger.LLM/Tools/AskUserToolExecutor.cs new file mode 100644 index 000000000..68c09195c --- /dev/null +++ b/src/StructuredLogger.LLM/Tools/AskUserToolExecutor.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using Microsoft.Extensions.AI; + +namespace StructuredLogger.LLM +{ + /// + /// Tool executor that allows the LLM to ask clarifying questions to the user. + /// Should be used sparingly and only when absolutely necessary to resolve ambiguous requirements. + /// + public class AskUserToolExecutor : IToolsContainer + { + private readonly IUserInteraction userInteraction; + + public AskUserToolExecutor(IUserInteraction userInteraction) + { + this.userInteraction = userInteraction ?? throw new ArgumentNullException(nameof(userInteraction)); + } + + public bool HasGuiTools => false; + + public IEnumerable<(AIFunction Function, AgentPhase ApplicablePhases)> GetTools() + { + // AskUser is available in all phases, but should be used judiciously + yield return (AIFunctionFactory.Create(AskUserAsync), AgentPhase.All); + } + + [Description(@"Asks the user a clarifying question when requirements are genuinely unclear or ambiguous. + +IMPORTANT GUIDELINES: +- Use SPARINGLY and only when absolutely necessary +- Try to infer reasonable defaults before asking +- Provide sensible default options when possible +- Combine multiple related questions into one if feasible +- Avoid asking for information that can be determined from the build data + +WHEN TO USE: +- Truly ambiguous requirements (e.g., user says 'analyze the issue' but multiple distinct issues exist) +- User provides unclear references (e.g., 'that project' when multiple projects match) +- Conflicting or contradictory instructions +- Missing critical information that cannot be inferred + +WHEN NOT TO USE: +- Information available in the build log +- Standard interpretations (e.g., 'build time' means total duration) +- Common defaults (e.g., 'errors' means all errors unless specified) + +EXAMPLES OF GOOD USE: +- User asks about 'the slow project' but 5 projects have similar durations +- User says 'fix the configuration' but multiple configuration issues exist +- User requests analysis of 'the problematic target' when several targets failed +- User asks something that seems completely unrelated to the build log content + +EXAMPLES OF BAD USE: +- Asking which project when user clearly stated the name +- Asking about format preferences for standard output +- Requesting clarification on well-defined terms")] + public async System.Threading.Tasks.Task AskUserAsync( + [Description("The question to ask the user. Be clear, specific, and provide context.")] string question, + [Description("Optional array of default options to present to the user as numbered choices (e.g., ['Option 1', 'Option 2']). Leave null if asking an open-ended question.")] string[]? options = null) + { + try + { + var response = await userInteraction.AskUser(question, options); + return $"User responded: {response}"; + } + catch (Exception ex) + { + return $"Error: Unable to get user input - {ex.Message}"; + } + } + } +} diff --git a/src/StructuredLogger.LLM/Tools/BinlogToolExecutor.cs b/src/StructuredLogger.LLM/Tools/BinlogToolExecutor.cs new file mode 100644 index 000000000..f6a5f7296 --- /dev/null +++ b/src/StructuredLogger.LLM/Tools/BinlogToolExecutor.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Build.Logging.StructuredLogger; +using Microsoft.Extensions.AI; + +namespace StructuredLogger.LLM +{ + /// + /// Tool executor for searching and analyzing binlog build data. + /// Provides core binlog analysis capabilities to the LLM. + /// Supports multiple binlog files with optional buildId parameter. + /// + public class BinlogToolExecutor : IToolsContainer + { + private readonly MultiBuildContext context; + + /// + /// Creates a BinlogToolExecutor with multi-build support. + /// + /// The multi-build context containing loaded builds. + public BinlogToolExecutor(MultiBuildContext context) + { + this.context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + /// Creates a BinlogToolExecutor for a single build (backward compatibility). + /// + /// The build to analyze. + public BinlogToolExecutor(Build build) + : this(CreateSingleBuildContext(build)) + { + } + + private static MultiBuildContext CreateSingleBuildContext(Build build) + { + if (build == null) + { + throw new ArgumentNullException(nameof(build)); + } + var context = new MultiBuildContext(); + context.AddBuild(build); + return context; + } + + public bool HasGuiTools => false; + + public IEnumerable<(AIFunction Function, AgentPhase ApplicablePhases)> GetTools() + { + // Return all tools with their applicable phases + yield return (AIFunctionFactory.Create(ListBuildsAsync), AgentPhase.All); + yield return (AIFunctionFactory.Create(GetBuildSummaryAsync), AgentPhase.All); + yield return (AIFunctionFactory.Create(SearchNodesAsync), AgentPhase.Research | AgentPhase.Summarization); + yield return (AIFunctionFactory.Create(GetErrorsAndWarningsAsync), AgentPhase.All); + yield return (AIFunctionFactory.Create(GetProjectsAsync), AgentPhase.Research | AgentPhase.Summarization); + yield return (AIFunctionFactory.Create(GetProjectTargetsAsync), AgentPhase.Research | AgentPhase.Summarization); + } + + /// + /// Resolves a build from an optional buildId parameter. + /// + private Build ResolveBuild(string buildId) + { + if (string.IsNullOrEmpty(buildId)) + { + return context.GetPrimaryBuild().Build; + } + + if (!context.TryGetBuild(buildId, out var buildInfo)) + { + throw new ArgumentException($"Build '{buildId}' not found. Use ListBuilds to see available builds."); + } + + return buildInfo.Build; + } + + /// + /// Resolves a build and returns both the Build and friendly name. + /// + private (Build build, string friendlyName) ResolveBuildWithName(string buildId) + { + if (string.IsNullOrEmpty(buildId)) + { + var primary = context.GetPrimaryBuild(); + return (primary.Build, primary.FriendlyName); + } + + if (!context.TryGetBuild(buildId, out var buildInfo)) + { + throw new ArgumentException($"Build '{buildId}' not found. Use ListBuilds to see available builds."); + } + + return (buildInfo.Build, buildInfo.FriendlyName); + } + + [Description(@"Lists all loaded binlog files with their IDs, friendly names, full paths, and summary. +Use this to discover which builds are available and get their buildId for other tools. +The primary build (used when buildId is omitted) is marked with [PRIMARY].")] + public async System.Threading.Tasks.Task ListBuildsAsync() + { + return await System.Threading.Tasks.Task.Run(() => + { + var sb = new StringBuilder(); + sb.AppendLine("Loaded Builds:"); + sb.AppendLine(); + + foreach (var buildInfo in context.GetAllBuilds()) + { + var primary = buildInfo.IsPrimary ? " [PRIMARY]" : ""; + var status = buildInfo.Succeeded ? "Succeeded" : "FAILED"; + + sb.AppendLine($"Build ID: {buildInfo.BuildId}{primary}"); + sb.AppendLine($" Name: {buildInfo.FriendlyName}"); + sb.AppendLine($" Path: {buildInfo.FullPath}"); + sb.AppendLine($" Status: {status}"); + sb.AppendLine($" Duration: {buildInfo.DurationText}"); + sb.AppendLine($" Errors: {buildInfo.ErrorCount}, Warnings: {buildInfo.WarningCount}"); + sb.AppendLine(); + } + + return sb.ToString(); + }); + } + + [Description(@"Searches the build tree using advanced query syntax. Supports multiple search operators: + +BASIC SEARCH: + - Multiple words: space = AND operator (e.g., 'csc error' finds nodes with both terms) + - Exact phrase: Use double quotes (e.g., '""Copying file""' for exact match) + - Single word in quotes: Exact match, no substring (e.g., '""Build""' matches 'Build' but not 'PreBuild') + +NODE TYPE FILTERS: + - $project: Search for projects (e.g., '$project MyApp') + - $target: Search for targets (e.g., '$target Build') + - $task: Search for tasks (e.g., '$task Csc' or '$csc' shorthand) + - $error: Search for errors + - $warning: Search for warnings + - $message: Search for messages + - $property: Search for properties + - $item: Search for items + - $metadata: Search for metadata + - $copy: Search for file copy operations (e.g., '$copy MyFile.dll') + - $nuget: Search NuGet packages (e.g., '$nuget Newtonsoft') + +HIERARCHY FILTERS: + - under(FILTER): Include only results under nodes matching FILTER (e.g., 'error under($project MyApp)') + - notunder(FILTER): Exclude results under nodes matching FILTER + - project(NAME): Filter by parent project (e.g., 'Csc project(MyApp.csproj)') + - not(FILTER): Exclude results matching FILTER + +PROPERTY/ITEM FILTERS: + - name=VALUE: Match by name (e.g., '$property name=Configuration') + - value=VALUE: Match by value (e.g., '$property value=Debug') + - skipped=true/false: Filter targets by skipped status (e.g., '$target skipped=false') + +TIME-BASED FILTERS: + - start<""TIMESTAMP"": Events starting before timestamp + - start>""TIMESTAMP"": Events starting after timestamp + - end<""TIMESTAMP"": Events ending before timestamp + - end>""TIMESTAMP"": Events ending after timestamp + - Example: '$task start>""2023-11-23 14:30:00"" end<""2023-11-23 14:35:00""' + +DURATION/TIME DISPLAY: + - $time or $duration: Include duration in results, sort by duration descending + - $start: Include start time in results + - $end: Include end time in results + +EXAMPLES: + - 'error': Find all errors + - '$project MyApp': Find project named MyApp + - 'Csc $time': Find Csc task invocations with durations + - '$error under($project Core)': Find errors in Core project + - '$target skipped=false $duration': Find executed targets sorted by duration + - 'Copying file project(MyApp.csproj)': Find file copies in MyApp project + - '$copy bin\\Debug': Find all files copied to bin\Debug + - '$task start>""2023-11-23 14:30:00""': Find tasks starting after specific time + +Returns detailed information about matching nodes including type, text, parent context, duration, and error/warning details.")] + public async System.Threading.Tasks.Task SearchNodesAsync( + [Description("Search query using the syntax described above. Can be simple text or use advanced operators.")] string query, + [Description("Maximum number of results to return (default 10, increase for broader searches)")] int maxResults = 10, + [Description("Build ID to search. Omit for primary build. Use ListBuilds to see available builds.")] string buildId = null) + { + return await System.Threading.Tasks.Task.Run(() => + { + var (build, friendlyName) = ResolveBuildWithName(buildId); + var core = new BinlogToolExecutorCore(build); + var result = core.SearchNodes(query, maxResults); + + // Prefix result with build context when multiple builds exist + if (context.BuildCount > 1) + { + return $"[Results from {friendlyName}]\n{result}"; + } + return result; + }); + } + + [Description("Gets a summary of the build including status, duration, errors and warnings count. Use buildId to target a specific build.")] + public async System.Threading.Tasks.Task GetBuildSummaryAsync( + [Description("Build ID to get summary for. Omit for primary build. Use ListBuilds to see available builds.")] string buildId = null) + { + return await System.Threading.Tasks.Task.Run(() => + { + var (build, friendlyName) = ResolveBuildWithName(buildId); + var core = new BinlogToolExecutorCore(build); + var result = core.GetBuildSummary(); + + if (context.BuildCount > 1) + { + return $"[Summary for {friendlyName}]\n{result}"; + } + return result; + }); + } + + [Description("Gets all errors and warnings from the build with their details. Use buildId to target a specific build.")] + public async System.Threading.Tasks.Task GetErrorsAndWarningsAsync( + [Description("Type of messages to retrieve: 'errors', 'warnings', or 'all'")] string type = "all", + [Description("Build ID to query. Omit for primary build. Use ListBuilds to see available builds.")] string buildId = null) + { + return await System.Threading.Tasks.Task.Run(() => + { + var (build, friendlyName) = ResolveBuildWithName(buildId); + var core = new BinlogToolExecutorCore(build); + var result = core.GetErrorsAndWarnings(type); + + if (context.BuildCount > 1) + { + return $"[Errors/Warnings from {friendlyName}]\n{result}"; + } + return result; + }); + } + + [Description("Gets list of all projects built with their status and duration. Use buildId to target a specific build.")] + public async System.Threading.Tasks.Task GetProjectsAsync( + [Description("Maximum number of projects to return (default 50)")] int maxResults = 50, + [Description("Build ID to query. Omit for primary build. Use ListBuilds to see available builds.")] string buildId = null) + { + return await System.Threading.Tasks.Task.Run(() => + { + var (build, friendlyName) = ResolveBuildWithName(buildId); + var core = new BinlogToolExecutorCore(build); + var result = core.GetProjects(maxResults); + + if (context.BuildCount > 1) + { + return $"[Projects from {friendlyName}]\n{result}"; + } + return result; + }); + } + + [Description("Gets targets executed in a specific project. Use buildId to target a specific build.")] + public async System.Threading.Tasks.Task GetProjectTargetsAsync( + [Description("Name of the project to get targets for")] string projectName, + [Description("Build ID to query. Omit for primary build. Use ListBuilds to see available builds.")] string buildId = null) + { + return await System.Threading.Tasks.Task.Run(() => + { + var (build, friendlyName) = ResolveBuildWithName(buildId); + var core = new BinlogToolExecutorCore(build); + var result = core.GetProjectTargets(projectName); + + if (context.BuildCount > 1) + { + return $"[Targets from {friendlyName}]\n{result}"; + } + return result; + }); + } + } +} diff --git a/src/StructuredLogger.LLM/Tools/BinlogToolExecutorCore.cs b/src/StructuredLogger.LLM/Tools/BinlogToolExecutorCore.cs new file mode 100644 index 000000000..829c313a3 --- /dev/null +++ b/src/StructuredLogger.LLM/Tools/BinlogToolExecutorCore.cs @@ -0,0 +1,361 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using Microsoft.Build.Logging.StructuredLogger; + +namespace StructuredLogger.LLM +{ + /// + /// Core implementation of binlog tool execution logic. + /// Separated from the IToolExecutor implementation for reuse. + /// + internal class BinlogToolExecutorCore + { + private readonly Build build; + private const int MaxOutputTokensPerTool = 3000; // Roughly 12,000 characters + + public BinlogToolExecutorCore(Build build) + { + this.build = build ?? throw new ArgumentNullException(nameof(build)); + } + + public string GetBuildSummary() + { + var sb = new StringBuilder(); + sb.AppendLine($"Build Status: {(build.Succeeded ? "Succeeded" : "Failed")}"); + sb.AppendLine($"Duration: {build.DurationText}"); + sb.AppendLine($"Start Time: {build.StartTime}"); + sb.AppendLine($"End Time: {build.EndTime}"); + + if (!string.IsNullOrEmpty(build.LogFilePath)) + { + sb.AppendLine($"Log File: {build.LogFilePath}"); + } + + if (!string.IsNullOrEmpty(build.MSBuildVersion)) + { + sb.AppendLine($"MSBuild Version: {build.MSBuildVersion}"); + } + + // Count errors and warnings + int errorCount = 0; + int warningCount = 0; + int projectCount = 0; + + build.VisitAllChildren(node => + { + if (node is Error) + { + errorCount++; + } + else if (node is Warning) + { + warningCount++; + } + else if (node is Project) + { + projectCount++; + } + }); + + sb.AppendLine($"Projects Built: {projectCount}"); + sb.AppendLine($"Total Errors: {errorCount}"); + sb.AppendLine($"Total Warnings: {warningCount}"); + + return sb.ToString(); + } + + public string SearchNodes(string query, int maxResults = 10) + { + if (string.IsNullOrWhiteSpace(query)) + { + return "Error: Search query cannot be empty."; + } + + try + { + // Use the sophisticated search infrastructure from StructuredLogViewer + var search = new StructuredLogViewer.Search( + new[] { build }, + build.StringTable.Instances, + maxResults, + markResultsInTree: false); + + var searchResults = search.FindNodes(query, CancellationToken.None); + var resultsList = searchResults.ToList(); + + if (resultsList.Count == 0) + { + return $"No nodes found matching '{query}'."; + } + + var sb = new StringBuilder(); + sb.AppendLine($"Found {resultsList.Count} matching nodes (max {maxResults}):"); + sb.AppendLine(); + + foreach (var result in resultsList) + { + var node = result.Node; + var nodeType = node.GetType().Name; + var nodeText = node.ToString(); + + sb.AppendLine($"[{nodeType}] {nodeText}"); + + // Show matched fields for better context + if (result.WordsInFields.Count > 0) + { + foreach (var (field, match) in result.WordsInFields) + { + if (field != nodeText) + { + sb.AppendLine($" Matched: '{match}' in '{field}'"); + } + } + } + + // Show parent context (project/target) + var project = node.GetNearestParent(); + if (project != null && node != project) + { + sb.AppendLine($" Project: {project.Name}"); + } + + var target = node.GetNearestParent(); + if (target != null && node != target && (project == null || target != (object)project)) + { + sb.AppendLine($" Target: {target.Name}"); + } + + // Show duration if available + if (node is TimedNode timedNode && timedNode.Duration.TotalMilliseconds > 0) + { + sb.AppendLine($" Duration: {timedNode.DurationText}"); + } + + // Show error/warning details + if (node is Error error) + { + if (!string.IsNullOrEmpty(error.Code)) + { + sb.AppendLine($" Code: {error.Code}"); + } + if (!string.IsNullOrEmpty(error.File)) + { + sb.AppendLine($" File: {error.File}:{error.LineNumber}"); + } + } + else if (node is Warning warning) + { + if (!string.IsNullOrEmpty(warning.Code)) + { + sb.AppendLine($" Code: {warning.Code}"); + } + if (!string.IsNullOrEmpty(warning.File)) + { + sb.AppendLine($" File: {warning.File}:{warning.LineNumber}"); + } + } + + sb.AppendLine(); + } + + if (resultsList.Count >= maxResults) + { + sb.AppendLine($"(Showing first {maxResults} results. Refine your query for more specific results.)"); + } + + return sb.ToString(); + } + catch (Exception ex) + { + return $"Error performing search: {ex.Message}\n\nQuery: '{query}'"; + } + } + + public string GetErrorsAndWarnings(string type = "all") + { + var sb = new StringBuilder(); + var errors = new List(); + var warnings = new List(); + + build.VisitAllChildren(node => + { + if (node is Error error) + { + errors.Add(error); + } + else if (node is Warning warning) + { + warnings.Add(warning); + } + }); + + bool showErrors = type.Equals("all", StringComparison.OrdinalIgnoreCase) || + type.Equals("errors", StringComparison.OrdinalIgnoreCase); + bool showWarnings = type.Equals("all", StringComparison.OrdinalIgnoreCase) || + type.Equals("warnings", StringComparison.OrdinalIgnoreCase); + + if (showErrors && errors.Any()) + { + sb.AppendLine($"=== Errors ({errors.Count}) ==="); + foreach (var error in errors.Take(20)) + { + sb.AppendLine($"[{error.Code}] {error.ToString()}"); + if (!string.IsNullOrEmpty(error.File)) + { + sb.AppendLine($" File: {error.File}:{error.LineNumber}"); + } + var project = error.GetNearestParent(); + if (project != null) + { + sb.AppendLine($" Project: {project.Name}"); + } + sb.AppendLine(); + } + if (errors.Count > 20) + { + sb.AppendLine($"... and {errors.Count - 20} more errors"); + } + } + else if (showErrors) + { + sb.AppendLine("No errors found."); + } + + if (showWarnings && warnings.Any()) + { + sb.AppendLine($"\n=== Warnings ({warnings.Count}) ==="); + foreach (var warning in warnings.Take(20)) + { + sb.AppendLine($"[{warning.Code}] {warning.ToString()}"); + if (!string.IsNullOrEmpty(warning.File)) + { + sb.AppendLine($" File: {warning.File}:{warning.LineNumber}"); + } + var project = warning.GetNearestParent(); + if (project != null) + { + sb.AppendLine($" Project: {project.Name}"); + } + sb.AppendLine(); + } + if (warnings.Count > 20) + { + sb.AppendLine($"... and {warnings.Count - 20} more warnings"); + } + } + else if (showWarnings) + { + sb.AppendLine("No warnings found."); + } + + return sb.ToString(); + } + + public string GetProjects(int maxResults = 50) + { + var sb = new StringBuilder(); + var projects = new List(); + + build.VisitAllChildren(p => projects.Add(p)); + + if (!projects.Any()) + { + return "No projects found in the build."; + } + + sb.AppendLine($"=== Projects ({projects.Count} total, showing first {Math.Min(maxResults, projects.Count)}) ==="); + foreach (var project in projects.Take(maxResults)) + { + sb.AppendLine($"{project.Name}"); + sb.AppendLine($" Duration: {project.DurationText}"); + if (!string.IsNullOrEmpty(project.ProjectFile)) + { + sb.AppendLine($" File: {project.ProjectFile}"); + } + + // Count errors/warnings in this project + int projErrors = 0; + int projWarnings = 0; + project.VisitAllChildren(node => + { + if (node is Error) + { + projErrors++; + } + else if (node is Warning) + { + projWarnings++; + } + }); + + if (projErrors > 0 || projWarnings > 0) + { + sb.AppendLine($" Errors: {projErrors}, Warnings: {projWarnings}"); + } + sb.AppendLine(); + } + + if (projects.Count > maxResults) + { + sb.AppendLine($"... and {projects.Count - maxResults} more projects"); + } + + return sb.ToString(); + } + + public string GetProjectTargets(string projectName) + { + if (string.IsNullOrWhiteSpace(projectName)) + { + return "Error: Project name cannot be empty."; + } + + Project? project = null; + build.VisitAllChildren(p => + { + if (p.Name != null && p.Name.IndexOf(projectName, StringComparison.OrdinalIgnoreCase) >= 0) + { + project = p; + } + }); + + if (project == null) + { + return $"Project '{projectName}' not found."; + } + + var sb = new StringBuilder(); + sb.AppendLine($"=== Targets in {project.Name} ==="); + + var targets = new List(); + project.VisitAllChildren(t => targets.Add(t)); + + if (!targets.Any()) + { + return $"No targets found in project {project.Name}"; + } + + foreach (var target in targets.OrderByDescending(t => t.Duration)) + { + sb.AppendLine($"{target.Name}"); + sb.AppendLine($" Duration: {target.DurationText}"); + + if (target is TreeNode treeNode && treeNode.HasChildren) + { + var tasks = new List(); + target.VisitAllChildren(t => tasks.Add(t)); + if (tasks.Any()) + { + sb.AppendLine($" Tasks: {tasks.Count}"); + } + } + sb.AppendLine(); + } + + return sb.ToString(); + } + } +} diff --git a/src/StructuredLogger.LLM/Tools/EmbeddedFilesToolExecutor.cs b/src/StructuredLogger.LLM/Tools/EmbeddedFilesToolExecutor.cs new file mode 100644 index 000000000..a82f48d04 --- /dev/null +++ b/src/StructuredLogger.LLM/Tools/EmbeddedFilesToolExecutor.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Build.Logging.StructuredLogger; +using Microsoft.Extensions.AI; + +namespace StructuredLogger.LLM +{ + /// + /// Tool executor for accessing and searching embedded source files in the binlog. + /// Applicable primarily during Research phase when investigating code details. + /// Supports multiple binlog files with optional buildId parameter. + /// + public class EmbeddedFilesToolExecutor : IToolsContainer + { + private readonly MultiBuildContext context; + + /// + /// Creates an EmbeddedFilesToolExecutor with multi-build support. + /// + /// The multi-build context containing loaded builds. + public EmbeddedFilesToolExecutor(MultiBuildContext context) + { + this.context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + /// Creates an EmbeddedFilesToolExecutor for a single build (backward compatibility). + /// + /// The build to analyze. + public EmbeddedFilesToolExecutor(Build build) + : this(CreateSingleBuildContext(build)) + { + } + + private static MultiBuildContext CreateSingleBuildContext(Build build) + { + if (build == null) + { + throw new ArgumentNullException(nameof(build)); + } + var ctx = new MultiBuildContext(); + ctx.AddBuild(build); + return ctx; + } + + /// + /// Resolves a build and returns both the Build and friendly name. + /// + private (Build build, string friendlyName) ResolveBuildWithName(string buildId) + { + if (string.IsNullOrEmpty(buildId)) + { + var primary = context.GetPrimaryBuild(); + return (primary.Build, primary.FriendlyName); + } + + if (!context.TryGetBuild(buildId, out var buildInfo)) + { + throw new ArgumentException($"Build '{buildId}' not found. Use ListBuilds to see available builds."); + } + + return (buildInfo.Build, buildInfo.FriendlyName); + } + + public bool HasGuiTools => false; + + public IEnumerable<(AIFunction Function, AgentPhase ApplicablePhases)> GetTools() + { + // Return all tools with their applicable phases + yield return (AIFunctionFactory.Create(ListEmbeddedFilesAsync), AgentPhase.Research | AgentPhase.Summarization); + yield return (AIFunctionFactory.Create(SearchEmbeddedFilesAsync), AgentPhase.Research | AgentPhase.Summarization); + yield return (AIFunctionFactory.Create(ReadEmbeddedFileLinesAsync), AgentPhase.Research | AgentPhase.Summarization); + } + + [Description("Lists all embedded files in the binlog with their paths. Optionally filters by regex pattern. Use buildId to target a specific build.")] + public async System.Threading.Tasks.Task ListEmbeddedFilesAsync( + [Description("Optional regex pattern to filter file paths")] string pathPattern = null, + [Description("Maximum number of files to return (default 100)")] int maxResults = 100, + [Description("Build ID to query. Omit for primary build. Use ListBuilds to see available builds.")] string buildId = null) + { + return await System.Threading.Tasks.Task.Run(() => + { + var (build, friendlyName) = ResolveBuildWithName(buildId); + var core = new EmbeddedFilesToolExecutorCore(build); + var result = core.ListEmbeddedFiles(pathPattern, maxResults); + + if (context.BuildCount > 1) + { + return $"[Embedded files from {friendlyName}]\n{result}"; + } + return result; + }); + } + + [Description("Searches for text patterns within embedded files using regex. Use buildId to target a specific build.")] + public async System.Threading.Tasks.Task SearchEmbeddedFilesAsync( + [Description("Regex pattern to search for in file contents")] string searchPattern, + [Description("Optional regex pattern to filter which files to search")] string filePathPattern = null, + [Description("Maximum number of matches to return")] int maxMatches = 20, + [Description("Build ID to query. Omit for primary build. Use ListBuilds to see available builds.")] string buildId = null) + { + return await System.Threading.Tasks.Task.Run(() => + { + var (build, friendlyName) = ResolveBuildWithName(buildId); + var core = new EmbeddedFilesToolExecutorCore(build); + var result = core.SearchEmbeddedFiles(searchPattern, filePathPattern, maxMatches); + + if (context.BuildCount > 1) + { + return $"[Search results from {friendlyName}]\n{result}"; + } + return result; + }); + } + + [Description("Reads a specific range of lines from an embedded file. Use buildId to target a specific build.")] + public async System.Threading.Tasks.Task ReadEmbeddedFileLinesAsync( + [Description("Full path of the embedded file to read")] string filePath, + [Description("Starting line number (1-based)")] int startLine = 1, + [Description("Ending line number (1-based, -1 for end of file)")] int endLine = -1, + [Description("Maximum number of lines to return")] int maxLines = 100, + [Description("Build ID to query. Omit for primary build. Use ListBuilds to see available builds.")] string buildId = null) + { + return await System.Threading.Tasks.Task.Run(() => + { + var (build, friendlyName) = ResolveBuildWithName(buildId); + var core = new EmbeddedFilesToolExecutorCore(build); + var result = core.ReadEmbeddedFileLines(filePath, startLine, endLine, maxLines); + + if (context.BuildCount > 1) + { + return $"[File from {friendlyName}]\n{result}"; + } + return result; + }); + } + } +} diff --git a/src/StructuredLogger.LLM/Tools/EmbeddedFilesToolExecutorCore.cs b/src/StructuredLogger.LLM/Tools/EmbeddedFilesToolExecutorCore.cs new file mode 100644 index 000000000..6b9c87d72 --- /dev/null +++ b/src/StructuredLogger.LLM/Tools/EmbeddedFilesToolExecutorCore.cs @@ -0,0 +1,301 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.Build.Logging.StructuredLogger; + +namespace StructuredLogger.LLM +{ + /// + /// Core implementation of embedded files tool execution logic. + /// + internal class EmbeddedFilesToolExecutorCore + { + private readonly Build build; + private const int MaxOutputTokensPerTool = 3000; + + public EmbeddedFilesToolExecutorCore(Build build) + { + this.build = build ?? throw new ArgumentNullException(nameof(build)); + } + + public string ListEmbeddedFiles(string? pathPattern = null, int maxResults = 100) + { + var sourceFiles = build.SourceFiles; + + if (sourceFiles == null || sourceFiles.Count == 0) + { + return "No embedded files found in this binlog. The binlog may not have been created with /bl:embed or may not include source files."; + } + + var sb = new StringBuilder(); + sb.AppendLine($"=== Embedded Files ==="); + + Regex? regex = null; + if (!string.IsNullOrWhiteSpace(pathPattern)) + { + try + { + regex = new Regex(pathPattern, RegexOptions.IgnoreCase | RegexOptions.Compiled); + } + catch (ArgumentException ex) + { + return $"Error: Invalid regex pattern '{pathPattern}': {ex.Message}"; + } + } + + var matchingFiles = sourceFiles.AsEnumerable(); + + if (regex != null) + { + matchingFiles = matchingFiles.Where(f => regex.IsMatch(f.FullPath)); + } + + var filesList = matchingFiles.ToList(); + + if (filesList.Count == 0) + { + if (regex != null) + { + return $"No embedded files found matching pattern '{pathPattern}'. Total embedded files: {sourceFiles.Count}"; + } + return "No embedded files found."; + } + + sb.AppendLine($"Total files: {sourceFiles.Count}"); + if (regex != null) + { + sb.AppendLine($"Matching pattern '{pathPattern}': {filesList.Count}"); + } + sb.AppendLine(); + + // Group files by extension for better readability + var groupedByExtension = filesList + .GroupBy(f => System.IO.Path.GetExtension(f.FullPath).ToLowerInvariant()) + .OrderByDescending(g => g.Count()); + + foreach (var group in groupedByExtension) + { + string extension = string.IsNullOrEmpty(group.Key) ? "(no extension)" : group.Key; + sb.AppendLine($"[{extension}] ({group.Count()} files):"); + + int filesShown = 0; + foreach (var file in group.OrderBy(f => f.FullPath)) + { + if (filesShown >= maxResults) break; + int lineCount = file.Text.Split('\n').Length; + sb.AppendLine($" - {file.FullPath} ({lineCount} lines)"); + filesShown++; + } + + if (group.Count() > filesShown) + { + sb.AppendLine($" ... and {group.Count() - filesShown} more {extension} files"); + } + sb.AppendLine(); + } + + return sb.ToString(); + } + + public string SearchEmbeddedFiles(string searchPattern, string? filePathPattern = null, int maxMatches = 20) + { + if (string.IsNullOrWhiteSpace(searchPattern)) + { + return "Error: Search pattern cannot be empty."; + } + + var sourceFiles = build.SourceFiles; + + if (sourceFiles == null || sourceFiles.Count == 0) + { + return "No embedded files found in this binlog."; + } + + Regex searchRegex; + try + { + searchRegex = new Regex(searchPattern, RegexOptions.IgnoreCase | RegexOptions.Compiled); + } + catch (ArgumentException ex) + { + return $"Error: Invalid search pattern '{searchPattern}': {ex.Message}"; + } + + Regex? filePathRegex = null; + if (!string.IsNullOrWhiteSpace(filePathPattern)) + { + try + { + filePathRegex = new Regex(filePathPattern, RegexOptions.IgnoreCase | RegexOptions.Compiled); + } + catch (ArgumentException ex) + { + return $"Error: Invalid file path pattern '{filePathPattern}': {ex.Message}"; + } + } + + var sb = new StringBuilder(); + sb.AppendLine($"=== Search Results for '{searchPattern}' ==="); + if (filePathRegex != null) + { + sb.AppendLine($"File filter: '{filePathPattern}'"); + } + sb.AppendLine(); + + var results = new List<(string filePath, int lineNumber, string line, string context)>(); + int filesSearched = 0; + + foreach (var file in sourceFiles) + { + if (filePathRegex != null && !filePathRegex.IsMatch(file.FullPath)) + { + continue; + } + + filesSearched++; + var lines = file.Text.Split('\n'); + + for (int i = 0; i < lines.Length && results.Count < maxMatches; i++) + { + var line = lines[i]; + if (searchRegex.IsMatch(line)) + { + // Get context (1 line before and after) + var contextLines = new List(); + if (i > 0) + { + contextLines.Add($" {i}: {lines[i - 1].TrimEnd()}"); + } + contextLines.Add($"> {i + 1}: {line.TrimEnd()}"); // Current line (1-based) + if (i < lines.Length - 1) + { + contextLines.Add($" {i + 2}: {lines[i + 1].TrimEnd()}"); + } + + results.Add((file.FullPath, i + 1, line.TrimEnd(), string.Join("\n", contextLines))); + } + } + + if (results.Count >= maxMatches) + { + break; + } + } + + sb.AppendLine($"Files searched: {filesSearched}"); + sb.AppendLine($"Matches found: {results.Count}"); + sb.AppendLine(); + + if (results.Count == 0) + { + return sb.ToString() + $"No matches found for pattern '{searchPattern}'."; + } + + foreach (var result in results) + { + sb.AppendLine($"File: {result.filePath}"); + sb.AppendLine($"Line {result.lineNumber}:"); + sb.AppendLine(result.context); + sb.AppendLine(); + } + + if (results.Count >= maxMatches) + { + sb.AppendLine($"(Results limited to {maxMatches} matches. Use maxMatches parameter to see more.)"); + } + + return sb.ToString(); + } + + public string ReadEmbeddedFileLines(string filePath, int startLine = 1, int endLine = -1, int maxLines = 100) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + return "Error: File path cannot be empty."; + } + + var sourceFiles = build.SourceFiles; + + if (sourceFiles == null || sourceFiles.Count == 0) + { + return "No embedded files found in this binlog."; + } + + // Find the file (case-insensitive) + var file = sourceFiles.FirstOrDefault(f => + f.FullPath.Equals(filePath, StringComparison.OrdinalIgnoreCase)); + + if (file == null) + { + // Try partial match + var partialMatches = sourceFiles + .Where(f => f.FullPath.IndexOf(filePath, StringComparison.OrdinalIgnoreCase) >= 0) + .Take(5) + .ToList(); + + if (partialMatches.Any()) + { + var sb = new StringBuilder(); + sb.AppendLine($"File '{filePath}' not found. Did you mean one of these?"); + foreach (var match in partialMatches) + { + sb.AppendLine($" - {match.FullPath}"); + } + return sb.ToString(); + } + + return $"File '{filePath}' not found in embedded files. Use ListEmbeddedFiles to see available files."; + } + + var lines = file.Text.Split('\n'); + int totalLines = lines.Length; + + if (startLine < 1) + { + startLine = 1; + } + + if (endLine == -1 || endLine > totalLines) + { + endLine = totalLines; + } + + if (startLine > totalLines) + { + return $"Error: Start line {startLine} is beyond file length ({totalLines} lines)."; + } + + if (startLine > endLine) + { + return $"Error: Start line ({startLine}) cannot be greater than end line ({endLine})."; + } + + // Apply maxLines limit + int requestedLines = endLine - startLine + 1; + if (requestedLines > maxLines) + { + endLine = startLine + maxLines - 1; + } + + var result = new StringBuilder(); + result.AppendLine($"=== {file.FullPath} ==="); + result.AppendLine($"Lines {startLine}-{endLine} of {totalLines}"); + result.AppendLine(); + + for (int i = startLine - 1; i < endLine && i < totalLines; i++) + { + result.AppendLine($"{i + 1,6}: {lines[i].TrimEnd()}"); + } + + if (requestedLines > maxLines) + { + result.AppendLine(); + result.AppendLine($"(Output limited to {maxLines} lines. Requested {requestedLines} lines.)"); + } + + return result.ToString(); + } + } +} diff --git a/src/StructuredLogger.LLM/Tools/ListEventsToolExecutor.cs b/src/StructuredLogger.LLM/Tools/ListEventsToolExecutor.cs new file mode 100644 index 000000000..aad52478a --- /dev/null +++ b/src/StructuredLogger.LLM/Tools/ListEventsToolExecutor.cs @@ -0,0 +1,288 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text; +using Microsoft.Build.Logging.StructuredLogger; +using Microsoft.Extensions.AI; + +namespace StructuredLogger.LLM +{ + /// + /// Tool for listing and filtering build events with detailed information. + /// Provides chronological access to projects, targets, tasks, errors, warnings, and messages. + /// Supports multiple binlog files with optional buildId parameter. + /// + public class ListEventsToolExecutor : IToolsContainer + { + private readonly MultiBuildContext context; + + /// + /// Creates a ListEventsToolExecutor with multi-build support. + /// + /// The multi-build context containing loaded builds. + public ListEventsToolExecutor(MultiBuildContext context) + { + this.context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + /// Creates a ListEventsToolExecutor for a single build (backward compatibility). + /// + /// The build to analyze. + public ListEventsToolExecutor(Build build) + : this(CreateSingleBuildContext(build)) + { + } + + private static MultiBuildContext CreateSingleBuildContext(Build build) + { + if (build == null) + { + throw new ArgumentNullException(nameof(build)); + } + var ctx = new MultiBuildContext(); + ctx.AddBuild(build); + return ctx; + } + + /// + /// Resolves a build and returns both the Build and friendly name. + /// + private (Build build, string friendlyName) ResolveBuildWithName(string buildId) + { + if (string.IsNullOrEmpty(buildId)) + { + var primary = context.GetPrimaryBuild(); + return (primary.Build, primary.FriendlyName); + } + + if (!context.TryGetBuild(buildId, out var buildInfo)) + { + throw new ArgumentException($"Build '{buildId}' not found. Use ListBuilds to see available builds."); + } + + return (buildInfo.Build, buildInfo.FriendlyName); + } + + public bool HasGuiTools => false; + + public IEnumerable<(AIFunction Function, AgentPhase ApplicablePhases)> GetTools() + { + yield return (AIFunctionFactory.Create(ListEventsAsync), AgentPhase.Research | AgentPhase.Summarization); + } + + [Description(@"Lists build events from the binlog with detailed filtering and pagination. + +This tool provides access to the chronological sequence of build events (projects, targets, +tasks, errors, warnings, messages) with rich filtering capabilities. + +KEY CONCEPTS: +- Events are chronologically ordered by start time (unless sorted differently) +- Each event includes timestamp, duration (for timed events), and context (parent project/target) +- Large builds may have thousands of events - use filters to narrow results + +EVENT TYPES: +- Project: MSBuild project execution +- Target: Target execution within a project +- Task: Individual task (Csc, Copy, etc.) execution +- Error: Build errors with code, file, line number +- Warning: Build warnings with code, file, line number +- Message: Build messages and output + +FILTERING OPTIONS: + +Time-based filters: + - startAfter/startBefore: Filter by event start time (format: 'yyyy-MM-dd HH:mm:ss' or 'yyyy-MM-ddTHH:mm:ss') + - endAfter/endBefore: Filter by event end time + - minDuration/maxDuration: Filter by event duration (format: 'HH:mm:ss' or seconds as number) + +Context-based filters: + - projectName: Filter by project name (partial match, case-insensitive) + - projectPath: Filter by exact project file path + - targetName: Filter by target name (partial match) + - taskName: Filter by task name (e.g., 'Csc', 'Copy') + +Content-based filters: + - searchText: Search in event text and properties (case-insensitive) + - errorCode/warningCode: Filter diagnostics by code (e.g., 'CS0103', 'MSB3644') + +Status filters: + - succeeded: true for succeeded only, false for failed only, null for all + - includeSkipped: Include skipped targets (default: true) + - includeLowRelevance: Include low-relevance messages (default: false) + +Pagination and sorting: + - maxResults: Limit number of results (default 50, recommended max 200) + - skip: Skip first N results for pagination + - sortBy: 'startTime' (default), 'duration', 'name' + - descending: true for descending order (default: false) + +USAGE EXAMPLES: + +1. Find long-running tasks: + eventTypes: ['Task'], minDuration: '00:00:05', sortBy: 'duration', descending: true + +2. Errors in specific time range: + eventTypes: ['Error'], startAfter: '2024-01-02 10:30:00', projectName: 'MyApp' + +3. All compiler invocations: + eventTypes: ['Task'], taskName: 'Csc' + +4. Target and its tasks: + eventTypes: ['Target', 'Task'], targetName: 'CoreCompile', projectName: 'MyApp' + +5. Build timeline overview: + eventTypes: ['Project', 'Target'], sortBy: 'startTime' + +TIPS: +- Start with broader filters and narrow down based on results +- Use time filters when investigating specific build phases +- Combine event types for related analysis (e.g., Target + Task) +- Use pagination (skip/maxResults) for large result sets +- Sort by duration to find performance bottlenecks + +Use buildId parameter to query a specific build when multiple binlogs are loaded. +")] + public async System.Threading.Tasks.Task ListEventsAsync( + [Description("Array of event types to include: 'Project', 'Target', 'Task', 'Error', 'Warning', 'Message'. Defaults to all types if not specified.")] + string[] eventTypes = null, + + [Description("Filter events that started after this time (format: 'yyyy-MM-dd HH:mm:ss' or 'yyyy-MM-ddTHH:mm:ss')")] + string startAfter = null, + + [Description("Filter events that started before this time")] + string startBefore = null, + + [Description("Filter events that ended after this time")] + string endAfter = null, + + [Description("Filter events that ended before this time")] + string endBefore = null, + + [Description("Filter events with duration >= this value (format: 'HH:mm:ss' or seconds as string)")] + string minDuration = null, + + [Description("Filter events with duration <= this value")] + string maxDuration = null, + + [Description("Filter by project name (partial match, case-insensitive)")] + string projectName = null, + + [Description("Filter by exact project file path")] + string projectPath = null, + + [Description("Filter by target name (partial match, case-insensitive)")] + string targetName = null, + + [Description("Filter by task name (e.g., 'Csc', 'Copy', 'MSBuild')")] + string taskName = null, + + [Description("Search text in event content (case-insensitive)")] + string searchText = null, + + [Description("Filter errors by code (e.g., 'CS0103')")] + string errorCode = null, + + [Description("Filter warnings by code (e.g., 'MSB3644')")] + string warningCode = null, + + [Description("Filter by success status: true for succeeded, false for failed, null for all")] + bool? succeeded = null, + + [Description("Include skipped targets in results (default: true)")] + bool includeSkipped = true, + + [Description("Include low-relevance messages in results (default: false)")] + bool includeLowRelevance = false, + + [Description("Maximum number of results to return (default: 50)")] + int maxResults = 50, + + [Description("Number of results to skip for pagination (default: 0)")] + int skip = 0, + + [Description("Sort results by: 'startTime' (default), 'duration', 'name'")] + string sortBy = "startTime", + + [Description("Sort in descending order (default: false)")] + bool descending = false, + + [Description("Build ID to query. Omit for primary build. Use ListBuilds to see available builds.")] + string buildId = null) + { + return await System.Threading.Tasks.Task.Run(() => + { + try + { + var (build, friendlyName) = ResolveBuildWithName(buildId); + var core = new ListEventsToolExecutorCore(build); + + var filters = new EventFilters + { + EventTypes = eventTypes, + StartAfter = ParseDateTime(startAfter), + StartBefore = ParseDateTime(startBefore), + EndAfter = ParseDateTime(endAfter), + EndBefore = ParseDateTime(endBefore), + MinDuration = ParseTimeSpan(minDuration), + MaxDuration = ParseTimeSpan(maxDuration), + ProjectName = projectName, + ProjectPath = projectPath, + TargetName = targetName, + TaskName = taskName, + SearchText = searchText, + ErrorCode = errorCode, + WarningCode = warningCode, + Succeeded = succeeded, + IncludeSkipped = includeSkipped, + IncludeLowRelevance = includeLowRelevance, + MaxResults = maxResults, + Skip = skip, + SortBy = sortBy, + Descending = descending + }; + + var result = core.ListEvents(filters); + + if (context.BuildCount > 1) + { + return $"[Events from {friendlyName}]\n{result}"; + } + return result; + } + catch (Exception ex) + { + return $"Error executing ListEvents: {ex.Message}"; + } + }); + } + + private DateTime? ParseDateTime(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + if (DateTime.TryParse(value, out var result)) + return result; + + throw new ArgumentException($"Invalid date/time format: '{value}'. Use 'yyyy-MM-dd HH:mm:ss' or 'yyyy-MM-ddTHH:mm:ss'"); + } + + private TimeSpan? ParseTimeSpan(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + // Try parsing as TimeSpan (HH:mm:ss format) + if (TimeSpan.TryParse(value, out var result)) + return result; + + // Try parsing as seconds + if (double.TryParse(value, out var seconds)) + return TimeSpan.FromSeconds(seconds); + + throw new ArgumentException($"Invalid duration format: '{value}'. Use 'HH:mm:ss' or seconds as number"); + } + } +} diff --git a/src/StructuredLogger.LLM/Tools/ListEventsToolExecutorCore.cs b/src/StructuredLogger.LLM/Tools/ListEventsToolExecutorCore.cs new file mode 100644 index 000000000..b098b1cc5 --- /dev/null +++ b/src/StructuredLogger.LLM/Tools/ListEventsToolExecutorCore.cs @@ -0,0 +1,578 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.Build.Logging.StructuredLogger; + +namespace StructuredLogger.LLM +{ + /// + /// Core implementation of event listing and filtering logic. + /// + internal class ListEventsToolExecutorCore + { + private readonly Build build; + private const int MaxOutputTokensPerTool = 3000; // Roughly 12,000 characters + + public ListEventsToolExecutorCore(Build build) + { + this.build = build ?? throw new ArgumentNullException(nameof(build)); + } + + public string ListEvents(EventFilters filters) + { + // Validate inputs + ValidateFilters(filters); + + // Collect matching events + var events = CollectEvents(filters); + + // Apply sorting + events = SortEvents(events, filters.SortBy, filters.Descending); + + // Get total count before pagination + int totalCount = events.Count; + + // Apply pagination + events = events.Skip(filters.Skip).Take(filters.MaxResults).ToList(); + + // Format output + return FormatEvents(events, totalCount, filters); + } + + private void ValidateFilters(EventFilters filters) + { + if (filters.MaxResults < 1 || filters.MaxResults > 1000) + { + throw new ArgumentException("maxResults must be between 1 and 1000"); + } + + if (filters.Skip < 0) + { + throw new ArgumentException("skip must be >= 0"); + } + + if (filters.StartAfter.HasValue && filters.StartBefore.HasValue && + filters.StartAfter.Value >= filters.StartBefore.Value) + { + throw new ArgumentException("startAfter must be before startBefore"); + } + + if (filters.EndAfter.HasValue && filters.EndBefore.HasValue && + filters.EndAfter.Value >= filters.EndBefore.Value) + { + throw new ArgumentException("endAfter must be before endBefore"); + } + + if (filters.MinDuration.HasValue && filters.MaxDuration.HasValue && + filters.MinDuration.Value >= filters.MaxDuration.Value) + { + throw new ArgumentException("minDuration must be less than maxDuration"); + } + + var validSortBy = new[] { "starttime", "duration", "name" }; + if (!validSortBy.Contains(filters.SortBy.ToLowerInvariant())) + { + throw new ArgumentException($"sortBy must be one of: {string.Join(", ", validSortBy)}"); + } + } + + private List CollectEvents(EventFilters filters) + { + var events = new List(); + + // Determine which types to include + var includeTypes = GetIncludedTypes(filters.EventTypes); + + build.VisitAllChildren(node => + { + var eventInfo = CreateEventInfo(node, includeTypes, filters); + if (eventInfo != null) + { + events.Add(eventInfo); + } + }); + + return events; + } + + private HashSet GetIncludedTypes(string[]? eventTypes) + { + if (eventTypes == null || eventTypes.Length == 0) + { + return new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Project", "Target", "Task", "Error", "Warning", "Message" + }; + } + + return new HashSet(eventTypes, StringComparer.OrdinalIgnoreCase); + } + + private EventInfo? CreateEventInfo(BaseNode node, HashSet includeTypes, EventFilters filters) + { + EventInfo? info = null; + + // Check event type and create appropriate info + if (node is Project project && includeTypes.Contains("Project")) + { + info = CreateProjectInfo(project); + } + else if (node is Target target && includeTypes.Contains("Target")) + { + info = CreateTargetInfo(target); + } + else if (node is Task task && includeTypes.Contains("Task")) + { + info = CreateTaskInfo(task); + } + else if (node is Error error && includeTypes.Contains("Error")) + { + info = CreateErrorInfo(error); + } + else if (node is Warning warning && includeTypes.Contains("Warning")) + { + info = CreateWarningInfo(warning); + } + else if (node is Message message && includeTypes.Contains("Message")) + { + info = CreateMessageInfo(message); + } + + // Apply filters + if (info != null && !PassesFilters(info, filters)) + { + return null; + } + + return info; + } + + private EventInfo CreateProjectInfo(Project project) + { + return new EventInfo + { + Type = "Project", + Name = project.Name, + StartTime = project.StartTime, + EndTime = project.EndTime, + Duration = project.Duration, + Node = project, + Details = new Dictionary + { + ["Path"] = project.ProjectFile ?? "", + ["Framework"] = project.TargetFramework ?? "", + ["Configuration"] = project.Configuration ?? "", + ["Platform"] = project.Platform ?? "", + ["Status"] = project is TimedNode tn && tn.EndTime != default ? "Completed" : "In Progress" + } + }; + } + + private EventInfo CreateTargetInfo(Target target) + { + return new EventInfo + { + Type = "Target", + Name = target.Name, + StartTime = target.StartTime, + EndTime = target.EndTime, + Duration = target.Duration, + Node = target, + Details = new Dictionary + { + ["Project"] = target.Project?.Name ?? "Unknown", + ["Status"] = target.Succeeded ? "Succeeded" : "Failed", + ["Skipped"] = target.Skipped.ToString(), + ["Reason"] = target.ParentTarget ?? target.TargetBuiltReason.ToString(), + ["SourceFile"] = target.SourceFilePath ?? "" + } + }; + } + + private EventInfo CreateTaskInfo(Task task) + { + return new EventInfo + { + Type = "Task", + Name = task.Name, + StartTime = task.StartTime, + EndTime = task.EndTime, + Duration = task.Duration, + Node = task, + Details = new Dictionary + { + ["Target"] = task.GetNearestParent()?.Name ?? "Unknown", + ["Project"] = task.GetNearestParent()?.Name ?? "Unknown", + ["Assembly"] = task.FromAssembly ?? "", + ["SourceFile"] = task.SourceFilePath ?? "", + ["LineNumber"] = task.LineNumber?.ToString() ?? "", + ["CommandLine"] = TruncateString(task.CommandLineArguments, 200) ?? "" + } + }; + } + + private EventInfo CreateErrorInfo(Error error) + { + return new EventInfo + { + Type = "Error", + Name = error.Code ?? "Error", + StartTime = error.Timestamp, + EndTime = error.Timestamp, + Duration = TimeSpan.Zero, + Node = error, + Details = new Dictionary + { + ["Code"] = error.Code ?? "", + ["Message"] = error.Text ?? "", + ["File"] = error.File ?? "", + ["Line"] = error.LineNumber.ToString(), + ["Column"] = error.ColumnNumber.ToString(), + ["Project"] = error.GetNearestParent()?.Name ?? "Unknown", + ["ProjectFile"] = error.ProjectFile ?? "" + } + }; + } + + private EventInfo CreateWarningInfo(Warning warning) + { + return new EventInfo + { + Type = "Warning", + Name = warning.Code ?? "Warning", + StartTime = warning.Timestamp, + EndTime = warning.Timestamp, + Duration = TimeSpan.Zero, + Node = warning, + Details = new Dictionary + { + ["Code"] = warning.Code ?? "", + ["Message"] = warning.Text ?? "", + ["File"] = warning.File ?? "", + ["Line"] = warning.LineNumber.ToString(), + ["Column"] = warning.ColumnNumber.ToString(), + ["Project"] = warning.GetNearestParent()?.Name ?? "Unknown", + ["ProjectFile"] = warning.ProjectFile ?? "" + } + }; + } + + private EventInfo CreateMessageInfo(Message message) + { + var isLowRelevance = message is IHasRelevance relevance && relevance.IsLowRelevance; + + return new EventInfo + { + Type = "Message", + Name = TruncateString(message.Text, 80) ?? "", + StartTime = message.Timestamp, + EndTime = message.Timestamp, + Duration = TimeSpan.Zero, + Node = message, + Details = new Dictionary + { + ["Text"] = message.Text ?? "", + ["Project"] = message.GetNearestParent()?.Name ?? "Unknown", + ["Target"] = message.GetNearestParent()?.Name ?? "", + ["LowRelevance"] = isLowRelevance.ToString() + } + }; + } + + private bool PassesFilters(EventInfo info, EventFilters filters) + { + // Time filters + if (filters.StartAfter.HasValue && info.StartTime < filters.StartAfter.Value) + return false; + + if (filters.StartBefore.HasValue && info.StartTime >= filters.StartBefore.Value) + return false; + + if (filters.EndAfter.HasValue && info.EndTime < filters.EndAfter.Value) + return false; + + if (filters.EndBefore.HasValue && info.EndTime >= filters.EndBefore.Value) + return false; + + // Duration filters + if (filters.MinDuration.HasValue && info.Duration < filters.MinDuration.Value) + return false; + + if (filters.MaxDuration.HasValue && info.Duration > filters.MaxDuration.Value) + return false; + + // Context filters + if (!string.IsNullOrWhiteSpace(filters.ProjectName)) + { + string? projectName; + var projectNameVal = info.Details.TryGetValue("Project", out projectName!) ? projectName : null; + if (projectNameVal == null) + { + projectNameVal = (info.Node as Project)?.Name; + } + if (projectNameVal == null || projectNameVal.IndexOf(filters.ProjectName, StringComparison.OrdinalIgnoreCase) < 0) + return false; + } + + if (!string.IsNullOrWhiteSpace(filters.ProjectPath)) + { + string? projectPath; + if (!info.Details.TryGetValue("Path", out projectPath!)) + { + info.Details.TryGetValue("ProjectFile", out projectPath!); + } + if (!string.Equals(projectPath, filters.ProjectPath, StringComparison.OrdinalIgnoreCase)) + return false; + } + + if (!string.IsNullOrWhiteSpace(filters.TargetName)) + { + string? targetName; + var targetNameVal = info.Details.TryGetValue("Target", out targetName!) ? targetName : null; + if (targetNameVal == null) + { + targetNameVal = (info.Node as Target)?.Name; + } + if (targetNameVal == null || targetNameVal.IndexOf(filters.TargetName, StringComparison.OrdinalIgnoreCase) < 0) + return false; + } + + if (!string.IsNullOrWhiteSpace(filters.TaskName)) + { + if (info.Type != "Task" || info.Name.IndexOf(filters.TaskName, StringComparison.OrdinalIgnoreCase) < 0) + return false; + } + + // Content filters + if (!string.IsNullOrWhiteSpace(filters.SearchText)) + { + var searchIn = info.Name + " " + string.Join(" ", info.Details.Values); + if (searchIn.IndexOf(filters.SearchText, StringComparison.OrdinalIgnoreCase) < 0) + return false; + } + + if (!string.IsNullOrWhiteSpace(filters.ErrorCode)) + { + string? errorCode; + if (info.Type != "Error" || !info.Details.TryGetValue("Code", out errorCode!) || + !string.Equals(errorCode, filters.ErrorCode, StringComparison.OrdinalIgnoreCase)) + return false; + } + + if (!string.IsNullOrWhiteSpace(filters.WarningCode)) + { + string? warningCode; + if (info.Type != "Warning" || !info.Details.TryGetValue("Code", out warningCode!) || + !string.Equals(warningCode, filters.WarningCode, StringComparison.OrdinalIgnoreCase)) + return false; + } + + // Status filters + if (filters.Succeeded.HasValue) + { + bool succeeded = false; + if (info.Node is Project p) + succeeded = true; // Project nodes don't have Succeeded property in all cases + else if (info.Node is Target t) + succeeded = t.Succeeded; + else if (info.Type == "Error") + succeeded = false; + else + return true; // Non-applicable filter + + if (succeeded != filters.Succeeded.Value) + return false; + } + + string? skippedValue; + if (!filters.IncludeSkipped && info.Details.TryGetValue("Skipped", out skippedValue!) && skippedValue == "True") + return false; + + string? lowRelevanceValue; + if (!filters.IncludeLowRelevance && info.Details.TryGetValue("LowRelevance", out lowRelevanceValue!) && lowRelevanceValue == "True") + return false; + + return true; + } + + private List SortEvents(List events, string sortBy, bool descending) + { + IOrderedEnumerable sorted; + + switch (sortBy.ToLowerInvariant()) + { + case "duration": + sorted = descending + ? events.OrderByDescending(e => e.Duration) + : events.OrderBy(e => e.Duration); + break; + + case "name": + sorted = descending + ? events.OrderByDescending(e => e.Name) + : events.OrderBy(e => e.Name); + break; + + case "starttime": + default: + sorted = descending + ? events.OrderByDescending(e => e.StartTime) + : events.OrderBy(e => e.StartTime); + break; + } + + return sorted.ToList(); + } + + private string FormatEvents(List events, int totalCount, EventFilters filters) + { + var sb = new StringBuilder(); + + // Header + sb.AppendLine($"=== Build Events ==="); + sb.AppendLine($"Total matching: {totalCount}"); + sb.AppendLine($"Showing: {events.Count} events (skip: {filters.Skip}, max: {filters.MaxResults})"); + if (filters.EventTypes != null && filters.EventTypes.Length > 0) + { + sb.AppendLine($"Filtered by types: {string.Join(", ", filters.EventTypes!)}"); + } + sb.AppendLine(); + + // Events + int displayIndex = 1; + foreach (var evt in events) + { + sb.AppendLine($"Event #{displayIndex + filters.Skip}: {evt.Type}"); + sb.AppendLine($" Name: {evt.Name}"); + + // Time information (for timed events) + if (evt.Duration > TimeSpan.Zero || evt.StartTime != default) + { + if (evt.StartTime != default) + { + sb.AppendLine($" Start: {evt.StartTime:yyyy-MM-dd HH:mm:ss.fff}"); + } + if (evt.EndTime != default && evt.EndTime != evt.StartTime) + { + sb.AppendLine($" End: {evt.EndTime:yyyy-MM-dd HH:mm:ss.fff}"); + } + if (evt.Duration > TimeSpan.Zero) + { + sb.AppendLine($" Duration: {FormatDuration(evt.Duration)}"); + } + } + else if (evt.StartTime != default) + { + sb.AppendLine($" Timestamp: {evt.StartTime:yyyy-MM-dd HH:mm:ss.fff}"); + } + + // Details + foreach (var detail in evt.Details) + { + if (!string.IsNullOrWhiteSpace(detail.Value) && detail.Value != "0" && detail.Value != "Unknown") + { + sb.AppendLine($" {detail.Key}: {detail.Value}"); + } + } + + sb.AppendLine(); + displayIndex++; + } + + // Footer + if (totalCount > events.Count + filters.Skip) + { + sb.AppendLine($"[{totalCount - events.Count - filters.Skip} more events available. Use skip={filters.Skip + events.Count} to see next page.]"); + } + + if (totalCount == 0) + { + sb.AppendLine("No events match the specified filters."); + sb.AppendLine(); + sb.AppendLine("Tips:"); + sb.AppendLine("- Try removing or relaxing some filters"); + sb.AppendLine("- Check time ranges are correct"); + sb.AppendLine("- Verify names match actual project/target/task names"); + } + + return sb.ToString(); + } + + private string FormatDuration(TimeSpan duration) + { + if (duration.TotalSeconds < 1) + { + return $"{duration.TotalMilliseconds:F0}ms"; + } + else if (duration.TotalMinutes < 1) + { + return $"{duration.TotalSeconds:F2}s"; + } + else + { + return $"{(int)duration.TotalMinutes}m {duration.Seconds}s"; + } + } + + private string? TruncateString(string? value, int maxLength) + { + if (value == null || value.Length <= maxLength) + return value; + + return value.Substring(0, maxLength) + "..."; + } + + private string TruncateIfNeeded(string result) + { + const int maxChars = MaxOutputTokensPerTool * 4; // Conservative estimate + if (result.Length > maxChars) + { + return result.Substring(0, maxChars) + "\n\n[Output truncated due to length. Use more specific filters to narrow results.]"; + } + return result; + } + } + + /// + /// Represents filters for event listing. + /// + internal class EventFilters + { + public string[]? EventTypes { get; set; } + public DateTime? StartAfter { get; set; } + public DateTime? StartBefore { get; set; } + public DateTime? EndAfter { get; set; } + public DateTime? EndBefore { get; set; } + public TimeSpan? MinDuration { get; set; } + public TimeSpan? MaxDuration { get; set; } + public string? ProjectName { get; set; } + public string? ProjectPath { get; set; } + public string? TargetName { get; set; } + public string? TaskName { get; set; } + public string? SearchText { get; set; } + public string? ErrorCode { get; set; } + public string? WarningCode { get; set; } + public bool? Succeeded { get; set; } + public bool IncludeSkipped { get; set; } = true; + public bool IncludeLowRelevance { get; set; } = false; + public int MaxResults { get; set; } = 50; + public int Skip { get; set; } = 0; + public string SortBy { get; set; } = "startTime"; + public bool Descending { get; set; } = false; + } + + /// + /// Represents information about a build event. + /// + internal class EventInfo + { + public string Type { get; set; } = ""; + public string Name { get; set; } = ""; + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public TimeSpan Duration { get; set; } + public BaseNode Node { get; set; } = null!; + public Dictionary Details { get; set; } = new Dictionary(); + } +} diff --git a/src/StructuredLogger.LLM/Tools/MonitoredAIFunction.cs b/src/StructuredLogger.LLM/Tools/MonitoredAIFunction.cs new file mode 100644 index 000000000..55347fdaf --- /dev/null +++ b/src/StructuredLogger.LLM/Tools/MonitoredAIFunction.cs @@ -0,0 +1,420 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using StructuredLogger.LLM.Logging; + +namespace StructuredLogger.LLM +{ + /// + /// Wraps an AIFunction to monitor and capture tool call executions. + /// Raises events when tool calls start and complete, including timing and result information. + /// Also catalogs results in ResultManager for later search and retrieval. + /// + public class MonitoredAIFunction : AIFunction + { + private readonly AIFunction innerFunction; + private readonly ResultManager resultManager; + private readonly ILLMLogger? logger; + private const int MaxOutputTokensPerTool = 3000; // Roughly 12,000 characters + + // Tools that should NOT be cataloged + private static readonly HashSet NonCatalogedTools = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "ListResults", + "SearchResult", + "GetBuildSummary" // Small, typically not truncated + }; + + public event EventHandler? ToolCallStarted; + public event EventHandler? ToolCallCompleted; + + public MonitoredAIFunction(AIFunction innerFunction, ILLMLogger? logger = null) + { + this.innerFunction = innerFunction ?? throw new ArgumentNullException(nameof(innerFunction)); + this.resultManager = ResultManager.Instance; + this.logger = logger; + } + + // Delegate properties to inner function + public override string Name => innerFunction.Name; + public override string Description => innerFunction.Description; + public override JsonElement JsonSchema => innerFunction.JsonSchema; + public override MethodInfo? UnderlyingMethod => innerFunction.UnderlyingMethod; + public override JsonElement? ReturnJsonSchema => innerFunction.ReturnJsonSchema; + public override JsonSerializerOptions JsonSerializerOptions => innerFunction.JsonSerializerOptions; + public override IReadOnlyDictionary AdditionalProperties => innerFunction.AdditionalProperties; + + protected override async ValueTask InvokeCoreAsync( + AIFunctionArguments arguments, + CancellationToken cancellationToken) + { + var toolCallInfo = new ToolCallInfo + { + CallId = Guid.NewGuid(), + ToolName = Name, + StartTime = DateTime.Now + }; + + // Remap arguments if needed (handle LLM providing slightly wrong parameter names) + var correctedArguments = RemapArgumentsIfNeeded(arguments); + + // Serialize arguments for display (using corrected arguments) + try + { + var argsDict = new Dictionary(); + foreach (var arg in correctedArguments) + { + argsDict[arg.Key] = arg.Value; + } + toolCallInfo.ArgumentsJson = JsonSerializer.Serialize(argsDict); + } + catch (Exception ex) + { + logger?.LogError($"Failed to serialize arguments: {ex.Message}"); + toolCallInfo.ArgumentsJson = "{}"; + } + + // Raise start event + ToolCallStarted?.Invoke(this, toolCallInfo); + + try + { + // Invoke the actual function with corrected arguments + var result = await innerFunction.InvokeAsync(correctedArguments, cancellationToken); + + toolCallInfo.EndTime = DateTime.Now; + + // Handle string results - catalog and potentially truncate + if (result is string stringResult && !NonCatalogedTools.Contains(Name)) + { + var (catalogedResult, resultId) = CatalogAndTruncateResult(Name, correctedArguments, stringResult); + toolCallInfo.ResultText = catalogedResult; + result = catalogedResult; + } + else + { + toolCallInfo.ResultText = result?.ToString() ?? "(no result)"; + } + + // Raise completion event + ToolCallCompleted?.Invoke(this, toolCallInfo); + + return result; + } + catch (Exception ex) + { + toolCallInfo.EndTime = DateTime.Now; + toolCallInfo.IsError = true; + toolCallInfo.ErrorMessage = ex.Message; + toolCallInfo.ResultText = $"Error: {ex.Message}"; + + // Raise completion event even for errors + ToolCallCompleted?.Invoke(this, toolCallInfo); + + throw; + } + } + + /// + /// Remaps argument names if the LLM provided slightly incorrect names. + /// Handles cases like "path" instead of "filePath" by checking substring matches. + /// Prioritizes mandatory parameters over optional ones. + /// + private AIFunctionArguments RemapArgumentsIfNeeded(AIFunctionArguments arguments) + { + // Get expected parameter names from the function's JSON schema + var (expectedParams, requiredParams) = GetExpectedParameterNames(); + if (expectedParams.Count == 0) + { + // No schema available, return arguments as-is + return arguments; + } + + var providedKeys = new HashSet(arguments.Keys, StringComparer.OrdinalIgnoreCase); + var expectedKeys = new HashSet(expectedParams, StringComparer.OrdinalIgnoreCase); + + // Check if all provided keys are already correct + if (providedKeys.IsSubsetOf(expectedKeys)) + { + return arguments; + } + + // Find keys that need remapping + var remappings = new Dictionary(); // provided -> expected + var unmatchedProvided = new HashSet(providedKeys); + var unmatchedExpected = new HashSet(expectedKeys); + + // Remove exact matches + foreach (var key in providedKeys.ToList()) + { + if (expectedKeys.Contains(key)) + { + unmatchedProvided.Remove(key); + unmatchedExpected.Remove(key); + } + } + + // Try to find fuzzy matches for remaining arguments + foreach (var providedKey in unmatchedProvided.ToList()) + { + // First, try to find matches in required parameters only + var requiredMatches = FindPotentialMatches(providedKey, unmatchedExpected, requiredParams, requiredOnly: true); + + if (requiredMatches.Count == 1) + { + // Single unambiguous match in required parameters - use it + var expectedKey = requiredMatches[0]; + remappings[providedKey] = expectedKey; + unmatchedExpected.Remove(expectedKey); + unmatchedProvided.Remove(providedKey); + + logger?.LogVerbose( + $"[MonitoredAIFunction] Remapping argument '{providedKey}' -> '{expectedKey}' (required) for function '{Name}'"); + } + else if (requiredMatches.Count == 0) + { + // No match in required parameters, check all parameters + var allMatches = FindPotentialMatches(providedKey, unmatchedExpected, requiredParams, requiredOnly: false); + + if (allMatches.Count == 1) + { + // Unambiguous match found in optional parameters + var expectedKey = allMatches[0]; + remappings[providedKey] = expectedKey; + unmatchedExpected.Remove(expectedKey); + unmatchedProvided.Remove(providedKey); + + logger?.LogVerbose( + $"[MonitoredAIFunction] Remapping argument '{providedKey}' -> '{expectedKey}' (optional) for function '{Name}'"); + } + else if (allMatches.Count > 1) + { + logger?.LogVerbose( + $"[MonitoredAIFunction] Ambiguous matches for '{providedKey}': {string.Join(", ", allMatches)} - not remapping"); + } + } + else + { + // Multiple matches in required parameters - ambiguous + logger?.LogVerbose( + $"[MonitoredAIFunction] Ambiguous required matches for '{providedKey}': {string.Join(", ", requiredMatches)} - not remapping"); + } + } + + // Final fallback: if exactly one unmatched provided arg and one unmatched expected arg remain, match them + if (unmatchedProvided.Count == 1 && unmatchedExpected.Count == 1) + { + var providedKey = unmatchedProvided.First(); + var expectedKey = unmatchedExpected.First(); + remappings[providedKey] = expectedKey; + + logger?.LogVerbose( + $"[MonitoredAIFunction] Remapping last remaining argument '{providedKey}' -> '{expectedKey}' for function '{Name}'"); + } + + // If no remappings needed, return original + if (remappings.Count == 0) + { + return arguments; + } + + // Create new AIFunctionArguments with remapped keys + var correctedArgs = new AIFunctionArguments + { + Services = arguments.Services, + Context = arguments.Context + }; + + foreach (var kvp in arguments) + { + var keyToUse = remappings.ContainsKey(kvp.Key) ? remappings[kvp.Key] : kvp.Key; + correctedArgs[keyToUse] = kvp.Value; + } + + return correctedArgs; + } + + /// + /// Finds potential matches for a provided parameter name among expected parameters. + /// Uses substring matching in both directions. + /// Can optionally filter to only required parameters. + /// + private List FindPotentialMatches(string providedKey, HashSet expectedKeys, + HashSet requiredKeys, bool requiredOnly) + { + var matches = new List(); + + foreach (var expectedKey in expectedKeys) + { + // If filtering to required only, skip optional parameters + if (requiredOnly && !requiredKeys.Contains(expectedKey)) + { + continue; + } + + // Check if one is a substring of the other (case-insensitive) + if (providedKey.IndexOf(expectedKey, StringComparison.OrdinalIgnoreCase) >= 0 || + expectedKey.IndexOf(providedKey, StringComparison.OrdinalIgnoreCase) >= 0) + { + matches.Add(expectedKey); + } + } + + return matches; + } + + /// + /// Extracts expected parameter names and required parameter names from the function's JSON schema. + /// Returns (allParameters, requiredParameters). + /// + private (List allParams, HashSet requiredParams) GetExpectedParameterNames() + { + var paramNames = new List(); + var requiredNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + try + { + var schema = innerFunction.JsonSchema; + + // Check if schema has properties + if (schema.ValueKind == JsonValueKind.Object) + { + if (schema.TryGetProperty("properties", out var properties) && + properties.ValueKind == JsonValueKind.Object) + { + foreach (var property in properties.EnumerateObject()) + { + paramNames.Add(property.Name); + } + } + + // Check if schema has required array + if (schema.TryGetProperty("required", out var required) && + required.ValueKind == JsonValueKind.Array) + { + foreach (var element in required.EnumerateArray()) + { + if (element.ValueKind == JsonValueKind.String) + { + var str = element.GetString(); + if (str != null) + { + requiredNames.Add(str); + } + } + } + } + } + } + catch (Exception ex) + { + logger?.LogError($"Failed to parse function schema: {ex.Message}"); + } + + return (paramNames, requiredNames); + } + + /// + /// Catalogs a result in ResultManager, truncates if needed, and prepends metadata. + /// Returns the (potentially truncated) result with metadata and the ResultId. + /// + private (string resultWithMetadata, string resultId) CatalogAndTruncateResult( + string toolName, AIFunctionArguments arguments, string fullResult) + { + try + { + // Format arguments as invocation expression + string argsExpression = FormatArgumentsExpression(arguments); + + // Determine if truncation is needed + const int maxChars = MaxOutputTokensPerTool * 4; + string returnedResult; + bool needsTruncation = fullResult.Length > maxChars; + + if (needsTruncation) + { + returnedResult = fullResult.Substring(0, maxChars) + + "\n\n[Output truncated. Use more specific queries or filters. Or use SearchResult to find specific content in this untruncated result.]"; + } + else + { + returnedResult = fullResult; + } + + // Store in ResultManager (it will calculate truncation percentage) + string resultId = resultManager.StoreResult(toolName, argsExpression, fullResult, returnedResult); + + // Prepend metadata to the returned result + string resultWithMetadata = resultManager.PrependMetadata(resultId, returnedResult); + + return (resultWithMetadata, resultId); + } + catch (Exception ex) + { + logger?.LogError($"Failed to catalog result: {ex.Message}"); + // Return original result without metadata if cataloging fails + return (fullResult, string.Empty); + } + } + + /// + /// Formats arguments as a readable invocation expression. + /// Example: query="*restore*", maxResults=10 + /// + private string FormatArgumentsExpression(AIFunctionArguments arguments) + { + var parts = new List(); + + foreach (var arg in arguments) + { + var value = arg.Value; + string formattedValue; + + if (value == null) + { + formattedValue = "null"; + } + else if (value is string stringValue) + { + // Escape quotes in string values + formattedValue = $"\"{stringValue.Replace("\"", "\"\"")}\""; + } + else if (value is bool boolValue) + { + formattedValue = boolValue ? "true" : "false"; + } + else if (value is System.Collections.IEnumerable enumerable && !(value is string)) + { + // Format arrays/lists + var items = new List(); + foreach (var item in enumerable) + { + if (item is string s) + { + items.Add($"\"{s}\""); + } + else + { + items.Add(item?.ToString() ?? "null"); + } + } + formattedValue = $"[{string.Join(", ", items)}]"; + } + else + { + formattedValue = value.ToString() ?? "null"; + } + + parts.Add($"{arg.Key}={formattedValue}"); + } + + return string.Join(", ", parts); + } + } +} diff --git a/src/StructuredLogger.LLM/Tools/ResultsToolExecutor.cs b/src/StructuredLogger.LLM/Tools/ResultsToolExecutor.cs new file mode 100644 index 000000000..4fee3ed75 --- /dev/null +++ b/src/StructuredLogger.LLM/Tools/ResultsToolExecutor.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text; +using Microsoft.Extensions.AI; + +namespace StructuredLogger.LLM +{ + /// + /// Tool executor for managing and searching cataloged results. + /// Provides access to previously generated tool results that may have been truncated. + /// + public class ResultsToolExecutor : IToolsContainer + { + private readonly ResultManager resultManager; + + public ResultsToolExecutor() + { + this.resultManager = ResultManager.Instance; + } + + public bool HasGuiTools => false; + + public IEnumerable<(AIFunction Function, AgentPhase ApplicablePhases)> GetTools() + { + // ListResults is useful in all phases to see what data is available + yield return (AIFunctionFactory.Create(ListResultsAsync), AgentPhase.All); + + // SearchResult is primarily for research when diving into specific data + yield return (AIFunctionFactory.Create(SearchResultAsync), AgentPhase.Research | AgentPhase.Summarization); + } + + [Description(@"Lists all cataloged results from previous tool invocations. + +Shows ResultId, truncation status, tool invocation, timestamp, and size for each cataloged result. +Use to discover what data has been retrieved and find ResultIds for SearchResult. + +Results cataloged: SearchNodes, GetErrorsAndWarnings, GetProjects, GetProjectTargets, ListEvents, ListEmbeddedFiles, GetEmbeddedFile, SearchEmbeddedFiles. +Not cataloged: ListResults, SearchResult, GetBuildSummary.")] + public async System.Threading.Tasks.Task ListResultsAsync() + { + return await System.Threading.Tasks.Task.Run(() => ListResults()); + } + + [Description(@"Searches within a cataloged result using regex patterns (case-insensitive). + +Searches the FULL, UNTRUNCATED content - useful when results were truncated. + +Parameters: +- resultId: From ListResults (e.g., ""R001"") +- searchPattern: Regex pattern (e.g., ""Csc"", ""^Target"", ""error.*failed"", ""\\bNuGet\\b"") +- maxMatches: Max results (default 50) + +Returns matching lines with context. Invalid patterns or ResultIds return helpful errors. + +Common patterns: +- Simple: ""Csc"" (contains text) +- Line start: ""^Target"" +- Multiple: ""(Error|Warning)"" +- Word boundary: ""\\bNuGet\\b"" +- Numbers: ""Duration: [0-9]+\\.[0-9]+s"" + +Tip: Start simple, refine based on results. Escape special chars: . * + ? [ ] ( ) { } ^ $ | \\")] + public async System.Threading.Tasks.Task SearchResultAsync( + [Description("ResultId to search within (e.g., 'R001'). Use ListResults to see available IDs.")] string resultId, + [Description("Regex pattern to search for (case-insensitive). Example: 'error|warning' or '^Target.*Build'")] string searchPattern, + [Description("Maximum number of matches to return (default 50)")] int maxMatches = 50) + { + return await System.Threading.Tasks.Task.Run(() => SearchResult(resultId, searchPattern, maxMatches)); + } + + private string ListResults() + { + var results = resultManager.ListResults().ToList(); + + if (!results.Any()) + { + return @"No results have been cataloged yet. + +Results are automatically cataloged when you use other tools like: +- SearchNodesAsync +- GetErrorsAndWarningsAsync +- GetProjectsAsync +- ListEventsAsync +- ListEmbeddedFilesAsync +- GetEmbeddedFileAsync +- SearchEmbeddedFilesAsync + +Run one of these tools first, then use ListResults to see what's available."; + } + + var sb = new StringBuilder(); + sb.AppendLine("Cataloged Results:"); + sb.AppendLine(); + + foreach (var result in results) + { + sb.AppendLine($"ResultId: {result.ResultId}"); + + if (result.WasTruncated) + { + sb.AppendLine($"Truncated: Yes ({result.TruncationPercentage}% removed)"); + } + else + { + sb.AppendLine("Truncated: No"); + } + + // Format the invocation expression + string invocation; + if (string.IsNullOrWhiteSpace(result.Arguments)) + { + invocation = $"{result.ToolName}()"; + } + else + { + invocation = $"{result.ToolName}({result.Arguments})"; + } + sb.AppendLine($"Invocation: {invocation}"); + + sb.AppendLine($"Timestamp: {result.Timestamp:yyyy-MM-dd HH:mm:ss}"); + + if (result.WasTruncated) + { + int displayedLength = result.OriginalLength - (result.OriginalLength * result.TruncationPercentage / 100); + sb.AppendLine($"Size: {displayedLength:N0} characters (original: {result.OriginalLength:N0})"); + } + else + { + sb.AppendLine($"Size: {result.OriginalLength:N0} characters"); + } + + sb.AppendLine(); + } + + sb.AppendLine($"Total: {results.Count} result{(results.Count == 1 ? "" : "s")} cataloged"); + sb.AppendLine(); + sb.AppendLine("Use SearchResult to search within any of these results."); + + return sb.ToString(); + } + + private string SearchResult(string resultId, string searchPattern, int maxMatches) + { + // Validate inputs + if (string.IsNullOrWhiteSpace(resultId)) + { + return "Error: resultId parameter is required. Use ListResults to see available IDs."; + } + + if (string.IsNullOrWhiteSpace(searchPattern)) + { + return "Error: searchPattern parameter is required. Provide a regex pattern to search for."; + } + + if (maxMatches < 1 || maxMatches > 500) + { + return "Error: maxMatches must be between 1 and 500."; + } + + // Delegate to ResultManager + string result = resultManager.SearchResult(resultId, searchPattern, maxMatches, contextLines: 2); + + // Truncate if result is too large (using same threshold as MonitoredAIFunction: 12,000 chars) + const int maxChars = 12000; + if (result.Length > maxChars) + { + int truncatedChars = result.Length - maxChars; + int truncationPercent = (int)((truncatedChars / (double)result.Length) * 100); + + result = result.Substring(0, maxChars) + + $"\n\n... [TRUNCATED: {truncatedChars:N0} characters removed ({truncationPercent}%)]\n" + + $"The search returned too many matches. Consider:\n" + + $"- Using a more specific regex pattern\n" + + $"- Reducing maxMatches parameter\n" + + $"- Searching within a more specific ResultId\n" + + $"Full results are stored internally but not displayed due to size."; + } + + return result; + } + } +} diff --git a/src/StructuredLogger.Tests/App.config b/src/StructuredLogger.Tests/App.config index 0e9db0406..8da6ff591 100644 --- a/src/StructuredLogger.Tests/App.config +++ b/src/StructuredLogger.Tests/App.config @@ -27,7 +27,7 @@ - + diff --git a/src/TaskRunner/app.config b/src/TaskRunner/app.config index 654282a27..ea6b614a3 100644 --- a/src/TaskRunner/app.config +++ b/src/TaskRunner/app.config @@ -10,9 +10,21 @@ --> + + + + - + + + + + + + + + @@ -20,32 +32,60 @@ - + + + + + + + + + - + + + + + - + - + + + + + + + + + + + + + - + + + + + \ No newline at end of file