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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StructuredLogViewer/Controls/AgentProgressPanel.xaml.cs b/src/StructuredLogViewer/Controls/AgentProgressPanel.xaml.cs
new file mode 100644
index 000000000..ea4a5b809
--- /dev/null
+++ b/src/StructuredLogViewer/Controls/AgentProgressPanel.xaml.cs
@@ -0,0 +1,127 @@
+using System;
+using System.Collections.ObjectModel;
+using System.Windows;
+using System.Windows.Controls;
+using StructuredLogger.LLM;
+
+namespace StructuredLogViewer.Controls
+{
+ public partial class AgentProgressPanel : UserControl
+ {
+ private readonly ObservableCollection tasks;
+ private bool isCollapsed = false;
+ private double expandedHeight = 150;
+
+ public AgentProgressPanel()
+ {
+ InitializeComponent();
+ tasks = new ObservableCollection();
+ tasksPanel.ItemsSource = tasks;
+ this.Visibility = Visibility.Collapsed;
+ }
+
+ public void UpdateProgress(AgentProgressEventArgs args)
+ {
+ Dispatcher.Invoke(() =>
+ {
+ // Show panel if hidden
+ if (this.Visibility != Visibility.Visible)
+ {
+ this.Visibility = Visibility.Visible;
+ }
+
+ var plan = args.Plan;
+
+ // Update header
+ headerText.Text = $"Agent Mode: {plan.Phase}";
+ if (plan.Phase == AgentExecutionPhase.Research && plan.CurrentTask != null)
+ {
+ headerText.Text += $" (Task {plan.CurrentTaskIndex + 1}/{plan.ResearchTasks.Count})";
+ }
+
+ // Update progress message
+ if (!string.IsNullOrEmpty(args.Message))
+ {
+ progressMessage.Text = args.Message;
+ progressMessage.Foreground = args.IsError
+ ? System.Windows.Media.Brushes.Red
+ : System.Windows.Media.Brushes.Black;
+ }
+
+ // Update tasks list
+ tasks.Clear();
+ foreach (var task in plan.ResearchTasks)
+ {
+ tasks.Add(task);
+ }
+
+ // Update progress bar
+ if (plan.ResearchTasks.Count > 0)
+ {
+ var progress = (double)plan.CompletedTaskCount / plan.ResearchTasks.Count * 100;
+ progressBar.Value = progress;
+ }
+
+ // Update status text
+ var duration = plan.Duration?.ToString(@"mm\:ss") ?? "0:00";
+ statusText.Text = $"{plan.CompletedTaskCount}/{plan.ResearchTasks.Count} complete";
+
+ if (plan.FailedTaskCount > 0)
+ {
+ statusText.Text += $" ({plan.FailedTaskCount} failed)";
+ }
+
+ statusText.Text += $" • {duration}";
+
+ // Hide panel when complete
+ if (plan.Phase == AgentExecutionPhase.Complete || plan.Phase == AgentExecutionPhase.Failed)
+ {
+ // Keep visible for a few seconds, then fade out
+ var timer = new System.Windows.Threading.DispatcherTimer
+ {
+ Interval = TimeSpan.FromSeconds(5)
+ };
+ timer.Tick += (s, e) =>
+ {
+ this.Visibility = Visibility.Collapsed;
+ timer.Stop();
+ };
+ timer.Start();
+ }
+ });
+ }
+
+ public void Clear()
+ {
+ Dispatcher.Invoke(() =>
+ {
+ tasks.Clear();
+ progressMessage.Text = "Ready";
+ statusText.Text = "Ready";
+ progressBar.Value = 0;
+ this.Visibility = Visibility.Collapsed;
+ });
+ }
+
+ private void CollapseButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (isCollapsed)
+ {
+ // Expand
+ contentPanel.Visibility = Visibility.Visible;
+ collapseButton.Content = "▲";
+ this.Height = expandedHeight;
+ isCollapsed = false;
+ }
+ else
+ {
+ // Collapse
+ expandedHeight = this.ActualHeight;
+ contentPanel.Visibility = Visibility.Collapsed;
+ collapseButton.Content = "▼";
+ this.Height = double.NaN; // Auto height
+ isCollapsed = true;
+ }
+ }
+ }
+}
diff --git a/src/StructuredLogViewer/Controls/BuildControl.xaml b/src/StructuredLogViewer/Controls/BuildControl.xaml
index 01200d1e4..7566d2988 100644
--- a/src/StructuredLogViewer/Controls/BuildControl.xaml
+++ b/src/StructuredLogViewer/Controls/BuildControl.xaml
@@ -10,7 +10,15 @@
d:DataContext="{d:DesignInstance structuredLogger:Build}"
d:DesignHeight="300" d:DesignWidth="300">
-
+
+
+
+
+
+
+
+
+
@@ -183,4 +191,22 @@
+
+
+
+
+
+
+
+
diff --git a/src/StructuredLogViewer/Controls/BuildControl.xaml.cs b/src/StructuredLogViewer/Controls/BuildControl.xaml.cs
index 521c41d1f..104e8d8e0 100644
--- a/src/StructuredLogViewer/Controls/BuildControl.xaml.cs
+++ b/src/StructuredLogViewer/Controls/BuildControl.xaml.cs
@@ -29,6 +29,9 @@ public partial class BuildControl : UserControl
public TreeViewItem SelectedTreeViewItem { get; private set; }
public string LogFilePath => Build?.LogFilePath;
+ // Event fired when LLM chat initialization completes
+ public event EventHandler LLMChatInitialized;
+
private SourceFileResolver sourceFileResolver;
private ArchiveFileResolver archiveFile => sourceFileResolver.ArchiveFile;
private PreprocessedFileManager preprocessedFileManager;
@@ -433,6 +436,71 @@ on the node will navigate to the corresponding source code associated with the n
navigationHelper.OpenFileRequested += filePath => DisplayFile(filePath);
centralTabControl.SelectionChanged += CentralTabControl_SelectionChanged;
+
+ // LLM chat will be initialized lazily when first shown via ToggleLLMChat
+ }
+
+ private bool llmChatInitialized = false;
+
+ private async System.Threading.Tasks.Task InitializeLLMChatAsync()
+ {
+ if (llmChatInitialized)
+ {
+ return; // Already initialized
+ }
+
+ try
+ {
+ await System.Threading.Tasks.Task.Run(() =>
+ {
+ try
+ {
+ // Initialize LLM chat control on UI thread with BuildControl reference
+ Dispatcher.Invoke(() => llmChatControl.Initialize(Build, this));
+
+ // Notify success on UI thread
+ Dispatcher.Invoke(() =>
+ {
+ llmChatInitialized = true;
+ LLMChatInitialized?.Invoke(this, true);
+ });
+ }
+ catch (Exception ex)
+ {
+ // Log initialization failure and notify on UI thread
+ System.Diagnostics.Debug.WriteLine($"LLM chat initialization failed: {ex.Message}");
+ Dispatcher.Invoke(() => LLMChatInitialized?.Invoke(this, false));
+ }
+ });
+ }
+ catch (Exception ex)
+ {
+ // Log failure to start LLM chat initialization task
+ System.Diagnostics.Debug.WriteLine($"Failed to start LLM chat initialization: {ex.Message}");
+ LLMChatInitialized?.Invoke(this, false);
+ }
+ }
+
+ public void ToggleLLMChat(bool show)
+ {
+ if (show)
+ {
+ // Initialize LLM chat control on first show (lazy initialization)
+ if (!llmChatInitialized)
+ {
+ _ = InitializeLLMChatAsync();
+ }
+
+ llmChatColumn.Width = new GridLength(400);
+ llmChatBorder.Visibility = Visibility.Visible;
+ llmSplitter.Visibility = Visibility.Visible;
+ }
+ else
+ {
+ llmChatColumn.Width = new GridLength(0);
+ llmChatBorder.Visibility = Visibility.Collapsed;
+ llmSplitter.Visibility = Visibility.Collapsed;
+ }
}
public void Dispose()
@@ -1546,6 +1614,12 @@ private void TreeView_SelectedItemChanged(object sender, RoutedPropertyChangedEv
UpdateBreadcrumb(item);
UpdateProjectContext(item);
UpdateFindContent();
+
+ // Update LLM chat context
+ if (item is BaseNode node)
+ {
+ llmChatControl.SetSelectedNode(node);
+ }
}
}
diff --git a/src/StructuredLogViewer/Controls/GuiUserInteraction.cs b/src/StructuredLogViewer/Controls/GuiUserInteraction.cs
new file mode 100644
index 000000000..18e76e125
--- /dev/null
+++ b/src/StructuredLogViewer/Controls/GuiUserInteraction.cs
@@ -0,0 +1,42 @@
+using System;
+using System.Threading.Tasks;
+using System.Windows;
+using StructuredLogger.LLM;
+
+namespace StructuredLogViewer.Controls
+{
+ ///
+ /// GUI-based implementation of IUserInteraction for StructuredLogViewer.
+ /// Uses the chat panel to display questions and collect responses from the user.
+ ///
+ public class GuiUserInteraction : IUserInteraction
+ {
+ private readonly LLMChatControl chatControl;
+
+ public GuiUserInteraction(LLMChatControl chatControl)
+ {
+ this.chatControl = chatControl ?? throw new ArgumentNullException(nameof(chatControl));
+ }
+
+ public async Task AskUser(string question, string[]? options = null)
+ {
+ if (string.IsNullOrWhiteSpace(question))
+ {
+ throw new ArgumentException("Question cannot be null or empty", nameof(question));
+ }
+
+ // Must be invoked on UI thread
+ if (chatControl.Dispatcher.CheckAccess())
+ {
+ return await chatControl.AskUserForInput(question, options);
+ }
+ else
+ {
+ return await chatControl.Dispatcher.InvokeAsync(async () =>
+ {
+ return await chatControl.AskUserForInput(question, options);
+ }).Task.Unwrap();
+ }
+ }
+ }
+}
diff --git a/src/StructuredLogViewer/Controls/LLMChatControl.xaml b/src/StructuredLogViewer/Controls/LLMChatControl.xaml
new file mode 100644
index 000000000..492410042
--- /dev/null
+++ b/src/StructuredLogViewer/Controls/LLMChatControl.xaml
@@ -0,0 +1,381 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StructuredLogViewer/Controls/LLMChatControl.xaml.cs b/src/StructuredLogViewer/Controls/LLMChatControl.xaml.cs
new file mode 100644
index 000000000..e66dad252
--- /dev/null
+++ b/src/StructuredLogViewer/Controls/LLMChatControl.xaml.cs
@@ -0,0 +1,1483 @@
+using System;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.IO;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using Microsoft.Build.Logging.StructuredLogger;
+using Microsoft.Win32;
+using StructuredLogger.LLM;
+using StructuredLogger.LLM.Logging;
+using StructuredLogViewer.LLM;
+
+namespace StructuredLogViewer.Controls
+{
+ ///
+ /// Template selector for choosing between regular messages and tool call messages.
+ ///
+ public class ChatMessageTemplateSelector : DataTemplateSelector
+ {
+ public DataTemplate RegularMessageTemplate { get; set; }
+ public DataTemplate ToolCallMessageTemplate { get; set; }
+ public DataTemplate QuestionMessageTemplate { get; set; }
+
+ public override DataTemplate SelectTemplate(object item, DependencyObject container)
+ {
+ if (item is ChatMessageDisplay message)
+ {
+ if (message.IsQuestion)
+ {
+ return QuestionMessageTemplate;
+ }
+ return message.IsToolCall ? ToolCallMessageTemplate : RegularMessageTemplate;
+ }
+ return RegularMessageTemplate;
+ }
+ }
+
+ ///
+ /// View model for displaying chat messages in the UI
+ ///
+ public class ChatMessageDisplay : INotifyPropertyChanged
+ {
+ public string Role { get; set; }
+ public string Content { get; set; }
+ public bool IsError { get; set; }
+
+ // Tool call support
+ public bool IsToolCall { get; set; }
+ public ToolCallViewModel ToolCallData { get; set; }
+
+ // Question/Answer support
+ public bool IsQuestion { get; set; }
+ public string[] QuestionOptions { get; set; }
+ public Action OnAnswerProvided { get; set; }
+
+ public Brush RoleBackground
+ {
+ get
+ {
+ if (IsError)
+ {
+ return new SolidColorBrush(Color.FromRgb(255, 240, 240));
+ }
+
+ if (IsToolCall)
+ {
+ return new SolidColorBrush(Color.FromRgb(245, 245, 250));
+ }
+
+ return Role switch
+ {
+ "User" => new SolidColorBrush(Color.FromRgb(230, 240, 255)),
+ "Assistant" => new SolidColorBrush(Color.FromRgb(240, 255, 240)),
+ _ => new SolidColorBrush(Color.FromRgb(250, 250, 250))
+ };
+ }
+ }
+
+ public Brush RoleBorder
+ {
+ get
+ {
+ if (IsError)
+ {
+ return new SolidColorBrush(Color.FromRgb(255, 200, 200));
+ }
+
+ if (IsToolCall)
+ {
+ return new SolidColorBrush(Color.FromRgb(180, 180, 200));
+ }
+
+ return Role switch
+ {
+ "User" => new SolidColorBrush(Color.FromRgb(180, 200, 255)),
+ "Assistant" => new SolidColorBrush(Color.FromRgb(180, 255, 180)),
+ _ => new SolidColorBrush(Color.FromRgb(200, 200, 200))
+ };
+ }
+ }
+
+ public Brush RoleForeground => new SolidColorBrush(Color.FromRgb(60, 60, 60));
+ public Brush ContentForeground => IsError ?
+ new SolidColorBrush(Color.FromRgb(180, 0, 0)) :
+ new SolidColorBrush(Color.FromRgb(40, 40, 40));
+
+ public event PropertyChangedEventHandler PropertyChanged;
+ protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+ }
+
+ ///
+ /// Simple display model for attached binlog chips in the UI.
+ ///
+ public class AttachedBinlogInfo
+ {
+ public string BuildId { get; set; }
+ public string FileName { get; set; } // Just the filename for display
+ public string FullPath { get; set; } // Full path for tooltip
+ }
+
+ ///
+ /// Represents an independent chat session with its own conversation history and LLM context.
+ ///
+ public class ChatSession : INotifyPropertyChanged
+ {
+ private string displayName;
+
+ public string SessionId { get; }
+ public ObservableCollection Messages { get; }
+ public LLMChatService ChatService { get; set; }
+ public AgenticLLMChatService AgenticChatService { get; set; }
+ public ChatHistoryService HistoryService { get; set; }
+
+ ///
+ /// Whether the display name has been set from user content (vs. default "Chat N").
+ ///
+ public bool HasGeneratedTitle { get; set; }
+
+ public string DisplayName
+ {
+ get => displayName;
+ set
+ {
+ displayName = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DisplayName)));
+ }
+ }
+
+ public ChatSession(string sessionId, string name)
+ {
+ SessionId = sessionId;
+ displayName = name;
+ Messages = new ObservableCollection();
+ }
+
+ ///
+ /// Generates a short title from the first user message.
+ ///
+ public void GenerateTitleFromMessage(string userMessage)
+ {
+ if (HasGeneratedTitle || string.IsNullOrWhiteSpace(userMessage))
+ {
+ return;
+ }
+
+ // Clean up the message: collapse whitespace, trim
+ var title = userMessage.Replace('\n', ' ').Replace('\r', ' ').Trim();
+
+ // Truncate to a reasonable length for a dropdown
+ const int maxLength = 40;
+ if (title.Length > maxLength)
+ {
+ // Try to break at a word boundary
+ var truncated = title.Substring(0, maxLength);
+ var lastSpace = truncated.LastIndexOf(' ');
+ if (lastSpace > maxLength / 2)
+ {
+ truncated = truncated.Substring(0, lastSpace);
+ }
+ title = truncated + "…";
+ }
+
+ DisplayName = title;
+ HasGeneratedTitle = true;
+ }
+
+ public event PropertyChangedEventHandler PropertyChanged;
+ }
+
+ public partial class LLMChatControl : UserControl
+ {
+ private CancellationTokenSource cancellationTokenSource;
+ private LLMConfiguration currentConfig;
+ private bool isInitialized;
+ private ChatWindowLogger chatLogger;
+ private TaskCompletionSource waitingForUserResponse;
+ private ChatMessageDisplay currentQuestionMessage;
+
+ // Multi-binlog support
+ private MultiBuildContext buildContext;
+ private readonly ObservableCollection attachedBinlogs;
+
+ // Multi-chat session support
+ private readonly ObservableCollection chatSessions;
+ private ChatSession activeSession;
+ private int nextSessionNumber = 1;
+ private bool isSwitchingSession;
+
+ public Build Build { get; private set; }
+ public BuildControl BuildControl { get; private set; }
+
+ // Convenience accessors for the active session's services
+ private LLMChatService chatService => activeSession?.ChatService;
+ private AgenticLLMChatService agenticChatService => activeSession?.AgenticChatService;
+ private ChatHistoryService chatHistoryService => activeSession?.HistoryService;
+ private ObservableCollection messages => activeSession?.Messages;
+
+ public LLMChatControl()
+ {
+ InitializeComponent();
+ chatSessions = new ObservableCollection();
+ attachedBinlogs = new ObservableCollection();
+ sessionSelector.ItemsSource = chatSessions;
+ attachedFilesList.ItemsSource = attachedBinlogs;
+ }
+
+ public void Initialize(Build build, BuildControl buildControl)
+ {
+ if (isInitialized)
+ {
+ return; // Already initialized
+ }
+
+ Build = build ?? throw new ArgumentNullException(nameof(build));
+ BuildControl = buildControl;
+
+ // Create multi-build context with the implicit build (from viewer)
+ // This is the primary build - additional binlogs can be attached via UI
+ buildContext = new MultiBuildContext();
+ buildContext.AddBuild(build); // This is the primary/implicit build
+ // Note: We don't show the implicit build in attachedBinlogs list
+ // Only additional attached binlogs are shown in the UI
+
+ // Create chat window logger
+ chatLogger = new ChatWindowLogger((message, isError) =>
+ {
+ Dispatcher.Invoke(() =>
+ {
+ AddMessage(new ChatMessageDisplay
+ {
+ Role = "System",
+ Content = message,
+ IsError = isError
+ });
+ });
+ }, LoggingLevel.Normal);
+
+ // Load configuration from persisted settings or environment
+ currentConfig = LLMConfigurationDialog.LoadPersistedConfiguration();
+ chatLogger.Level = currentConfig.LoggingLevel;
+
+ // Discover existing sessions from persisted history, or create a default one
+ var binlogPath = build.LogFilePath;
+ var existingSessionIds = !string.IsNullOrEmpty(binlogPath)
+ ? ChatHistoryService.ListSessions(binlogPath)
+ : new System.Collections.Generic.List();
+
+ if (existingSessionIds.Count == 0)
+ {
+ // Create default first session
+ CreateNewSession("Chat 1");
+ }
+ else
+ {
+ // Restore existing sessions
+ foreach (var sessionId in existingSessionIds)
+ {
+ var session = CreateSessionObject(sessionId, sessionId);
+ chatSessions.Add(session);
+ nextSessionNumber = Math.Max(nextSessionNumber, ExtractSessionNumber(sessionId) + 1);
+ }
+ }
+
+ // Select first session
+ isSwitchingSession = true;
+ sessionSelector.SelectedIndex = 0;
+ isSwitchingSession = false;
+ ActivateSession(chatSessions[0]);
+
+ isInitialized = true;
+
+ // Initialize agent mode toggle from config (default is true)
+ agentModeToggle.IsChecked = currentConfig.AgentMode;
+ UpdateAgentModeUI();
+
+ // Initialize model selector
+ PopulateModelSelector();
+
+ // The session's services are created asynchronously in ActivateSession.
+ // Welcome/restore messages and status are shown after services are ready.
+ }
+
+ private async System.Threading.Tasks.Task CreateLLMServicesAsync(ChatSession session = null)
+ {
+ if (Build == null || buildContext == null)
+ {
+ return;
+ }
+
+ var targetSession = session ?? activeSession;
+ if (targetSession == null)
+ {
+ return;
+ }
+
+ // Dispose old services if they exist
+ LLMChatService? oldChatService = targetSession.ChatService;
+ AgenticLLMChatService? oldAgenticChatService = targetSession.AgenticChatService;
+
+ // Create new chat service with multi-build context
+ var newChatService = await LLMChatService.CreateAsync(buildContext, null, chatLogger);
+ newChatService.MessageAdded += OnMessageAdded;
+ newChatService.ConversationCleared += OnConversationCleared;
+ newChatService.ConversationCompacted += OnConversationCompacted;
+ newChatService.ToolCallExecuting += OnToolCallExecuting;
+ newChatService.ToolCallExecuted += OnToolCallExecuted;
+ newChatService.RequestRetrying += OnRequestRetrying;
+
+ // Register UI interaction tools if BuildControl is available
+ // Note: UI tools operate on primary build only (the one open in viewer)
+ if (BuildControl != null)
+ {
+ var uiInteractionExecutor = new BinlogUIInteractionExecutor(Build, BuildControl);
+ newChatService.RegisterToolContainer(uiInteractionExecutor);
+ }
+
+ // Register AskUser tool if enabled
+ if (SettingsService.LLMEnableAskUser)
+ {
+ var askUserExecutor = new AskUserToolExecutor(new GuiUserInteraction(this));
+ newChatService.RegisterToolContainer(askUserExecutor);
+ }
+
+ // Reconfigure with current config if available
+ if (currentConfig != null)
+ {
+ await newChatService.ReconfigureAsync(currentConfig);
+ }
+
+ targetSession.ChatService = newChatService;
+
+ // Seed in-memory chat history from persisted entries so the LLM has context
+ var persistedHistory = targetSession.HistoryService?.Load();
+ if (persistedHistory != null && persistedHistory.Count > 0)
+ {
+ newChatService.SeedChatHistory(persistedHistory);
+ }
+
+ // Create agentic service if configured
+ if (currentConfig?.IsConfigured == true)
+ {
+ var newAgenticService = await AgenticLLMChatService.CreateAsync(buildContext, currentConfig, chatLogger);
+
+ // Register UI interaction tools for agentic service
+ if (BuildControl != null)
+ {
+ var uiInteractionExecutor = new BinlogUIInteractionExecutor(Build, BuildControl);
+ newAgenticService.RegisterToolContainer(uiInteractionExecutor);
+ }
+
+ // Register AskUser tool if enabled
+ if (SettingsService.LLMEnableAskUser)
+ {
+ var askUserExecutor = new AskUserToolExecutor(new GuiUserInteraction(this));
+ newAgenticService.RegisterToolContainer(askUserExecutor);
+ }
+
+ newAgenticService.ProgressUpdated += OnAgentProgressUpdated;
+ newAgenticService.MessageAdded += OnMessageAdded;
+ newAgenticService.ToolCallExecuting += OnToolCallExecuting;
+ newAgenticService.ToolCallExecuted += OnToolCallExecuted;
+ newAgenticService.RequestRetrying += OnRequestRetrying;
+
+ targetSession.AgenticChatService = newAgenticService;
+ }
+
+ _ = System.Threading.Tasks.Task.Delay(1000).ContinueWith(t => { oldChatService?.Dispose(); oldAgenticChatService?.Dispose(); });
+ }
+
+ public void SetSelectedNode(BaseNode node)
+ {
+ chatService?.SetSelectedNode(node);
+ }
+
+ private void AddWelcomeMessage()
+ {
+ var welcomeMsg = "Welcome to LLM Chat! I can help you analyze this MSBuild binlog.\n\n" +
+ "You can ask me about:\n" +
+ "• Build errors and warnings\n" +
+ "• Project and target information\n" +
+ "• Build duration and performance\n" +
+ "• Specific tasks or failures\n\n";
+
+ if (currentConfig?.AgentMode == true)
+ {
+ welcomeMsg += "**Agent Mode is ON** 🤖\n" +
+ "I'll break down complex questions into research tasks for thorough analysis.\n\n";
+ }
+
+ welcomeMsg += "📎 **Multi-Binlog Support**: Use the attachment button to add more binlog files for comparison.\n\n";
+
+ welcomeMsg += "Try asking: \"What errors occurred?\" or \"Show me the build summary\"";
+
+ AddMessage(new ChatMessageDisplay
+ {
+ Role = "System",
+ Content = welcomeMsg
+ });
+ }
+
+ ///
+ /// Restores chat history from persisted storage, or shows a welcome message if no history exists.
+ ///
+ private void RestoreChatHistory()
+ {
+ var history = chatHistoryService?.Load();
+ if (history == null || history.Count == 0)
+ {
+ AddWelcomeMessage();
+ return;
+ }
+
+ // Generate title from the first user message in history
+ var firstUserMessage = history.FirstOrDefault(h => h.Role == "User");
+ if (firstUserMessage != null)
+ {
+ activeSession?.GenerateTitleFromMessage(firstUserMessage.Content);
+ }
+
+ foreach (var entry in history)
+ {
+ AddMessage(new ChatMessageDisplay
+ {
+ Role = entry.Role,
+ Content = entry.Content
+ });
+ }
+ }
+
+ ///
+ /// Saves the current User and Assistant messages to persisted chat history.
+ ///
+ private void SaveChatHistory()
+ {
+ if (chatHistoryService == null)
+ {
+ return;
+ }
+
+ var entries = messages
+ .Where(m => m.Role == "User" || m.Role == "Assistant")
+ .Select(m => new ChatHistoryEntry
+ {
+ Role = m.Role,
+ Content = m.Content,
+ Timestamp = DateTime.Now
+ })
+ .ToList();
+
+ chatHistoryService.Save(entries, activeSession?.DisplayName);
+ }
+
+ #region Session Management
+
+ private ChatSession CreateSessionObject(string sessionId, string displayName)
+ {
+ var session = new ChatSession(sessionId, displayName);
+ var binlogPath = Build?.LogFilePath;
+ if (!string.IsNullOrEmpty(binlogPath))
+ {
+ session.HistoryService = new ChatHistoryService(binlogPath, sessionId);
+
+ // Restore persisted display name if available
+ var persistedName = session.HistoryService.LoadDisplayName();
+ if (!string.IsNullOrEmpty(persistedName))
+ {
+ session.DisplayName = persistedName;
+ session.HasGeneratedTitle = true;
+ }
+ }
+ return session;
+ }
+
+ private ChatSession CreateNewSession(string displayName = null)
+ {
+ var sessionId = "Chat " + nextSessionNumber;
+ var name = displayName ?? sessionId;
+ nextSessionNumber++;
+
+ var session = CreateSessionObject(sessionId, name);
+ chatSessions.Add(session);
+ return session;
+ }
+
+ private void ActivateSession(ChatSession session)
+ {
+ if (session == null || session == activeSession)
+ {
+ return;
+ }
+
+ // Save current session's history before switching
+ SaveChatHistory();
+
+ activeSession = session;
+
+ // Bind the UI to this session's messages
+ messagesPanel.ItemsSource = session.Messages;
+
+ // Create LLM services for this session if not yet created
+ if (session.ChatService == null)
+ {
+ // Show loading overlay while services initialize
+ ShowLoadingOverlay("Loading chat…");
+
+ _ = CreateLLMServicesAsync(session).ContinueWith(t =>
+ {
+ if (t.IsFaulted)
+ {
+ var exception = t.Exception?.GetBaseException();
+ Dispatcher.Invoke(() =>
+ {
+ HideLoadingOverlay();
+ if (exception is UnauthorizedAccessException)
+ {
+ SettingsService.ClearLLMConfiguration();
+ AddMessage(new ChatMessageDisplay
+ {
+ Role = "System",
+ Content = $"⚠️ Authentication Error\n\n{exception.Message}\n\n" +
+ "Your saved credentials have been cleared. Please click 'Configure' to re-authenticate.",
+ IsError = true
+ });
+ }
+ else
+ {
+ AddMessage(new ChatMessageDisplay
+ {
+ Role = "System",
+ Content = $"Failed to initialize LLM services: {exception?.Message}",
+ IsError = true
+ });
+ }
+ });
+ }
+ else
+ {
+ Dispatcher.Invoke(() =>
+ {
+ // Restore or show welcome after services are ready
+ if (session.Messages.Count == 0)
+ {
+ RestoreChatHistory();
+ }
+ HideLoadingOverlay();
+ });
+ }
+ });
+ }
+ else
+ {
+ // Services already created, hide loading immediately
+ HideLoadingOverlay();
+ }
+
+ // Clear agent progress for the newly activated session
+ agentProgressPanel.Clear();
+
+ // Update delete button state
+ deleteSessionButton.IsEnabled = chatSessions.Count > 1;
+ }
+
+ private void SessionSelector_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (isSwitchingSession || sessionSelector.SelectedItem is not ChatSession selected)
+ {
+ return;
+ }
+
+ ActivateSession(selected);
+ }
+
+ private void NewSessionButton_Click(object sender, RoutedEventArgs e)
+ {
+ var session = CreateNewSession();
+
+ isSwitchingSession = true;
+ sessionSelector.SelectedItem = session;
+ isSwitchingSession = false;
+
+ ActivateSession(session);
+
+ // Show welcome for new session
+ if (chatService?.IsConfigured == true && session.Messages.Count == 0)
+ {
+ AddWelcomeMessage();
+ }
+ }
+
+ private void DeleteSessionButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (chatSessions.Count <= 1 || activeSession == null)
+ {
+ return;
+ }
+
+ var sessionToDelete = activeSession;
+ var index = chatSessions.IndexOf(sessionToDelete);
+
+ // Delete persisted history
+ sessionToDelete.HistoryService?.Delete();
+
+ // Dispose services
+ sessionToDelete.ChatService?.Dispose();
+ sessionToDelete.AgenticChatService?.Dispose();
+
+ // Remove and select another session
+ chatSessions.Remove(sessionToDelete);
+
+ var newIndex = Math.Min(index, chatSessions.Count - 1);
+ isSwitchingSession = true;
+ sessionSelector.SelectedIndex = newIndex;
+ isSwitchingSession = false;
+ ActivateSession(chatSessions[newIndex]);
+
+ deleteSessionButton.IsEnabled = chatSessions.Count > 1;
+ }
+
+ private static int ExtractSessionNumber(string sessionId)
+ {
+ // Try to extract number from "Chat N" pattern
+ if (sessionId.StartsWith("Chat ") && int.TryParse(sessionId.Substring(5), out var num))
+ {
+ return num;
+ }
+ return 0;
+ }
+
+ #endregion
+
+ private void OnAgentProgressUpdated(object sender, AgentProgressEventArgs e)
+ {
+ agentProgressPanel.UpdateProgress(e);
+ }
+
+ private void OnMessageAdded(object sender, ChatMessageViewModel e)
+ {
+ Dispatcher.InvokeAsync(() =>
+ {
+ AddMessage(new ChatMessageDisplay
+ {
+ Role = e.Role,
+ Content = e.Content,
+ IsError = e.IsError
+ });
+ });
+ }
+
+ private void OnToolCallExecuting(object sender, ToolCallInfo toolCallInfo)
+ {
+ Dispatcher.InvokeAsync(() =>
+ {
+ // Show status banner indicating tool is executing
+ ShowStatus($"Executing tool: {toolCallInfo.ToolName} (In Progress...)");
+
+ AddMessage(new ChatMessageDisplay
+ {
+ Role = "Tool",
+ IsToolCall = true,
+ ToolCallData = new ToolCallViewModel(toolCallInfo),
+ Content = string.Empty // Not used for tool calls
+ });
+ });
+ }
+
+ private void OnToolCallExecuted(object sender, ToolCallInfo toolCallInfo)
+ {
+ Dispatcher.InvokeAsync(() =>
+ {
+ // Clear the status banner since tool execution is complete
+ HideStatus();
+
+ // Try to find an existing in-progress message with the same CallId
+ var existingMessage = messages.FirstOrDefault(m =>
+ m.IsToolCall &&
+ m.ToolCallData != null &&
+ m.ToolCallData.CallId == toolCallInfo.CallId);
+
+ if (existingMessage != null)
+ {
+ // Update the existing message with completion data
+ existingMessage.ToolCallData.UpdateWithCompletion(toolCallInfo);
+ }
+ else
+ {
+ // No existing message found (shouldn't happen, but handle gracefully)
+ AddMessage(new ChatMessageDisplay
+ {
+ Role = "Tool",
+ IsToolCall = true,
+ ToolCallData = new ToolCallViewModel(toolCallInfo),
+ Content = string.Empty
+ });
+ }
+ });
+ }
+
+ private void OnConversationCleared(object sender, EventArgs e)
+ {
+ Dispatcher.InvokeAsync(() =>
+ {
+ messages.Clear();
+ AddWelcomeMessage();
+ });
+ }
+
+ private void OnConversationCompacted(object sender, string summary)
+ {
+ Dispatcher.InvokeAsync(() =>
+ {
+ ShowStatus("Context compacted — older messages summarized to save tokens.");
+ SaveChatHistory();
+ });
+ }
+
+ private void OnRequestRetrying(object sender, ResilienceEventArgs e)
+ {
+ Dispatcher.InvokeAsync(() =>
+ {
+ ShowStatus($"{e.Message} (attempt {e.Attempt}/{e.MaxAttempts})");
+ });
+ }
+
+ private void AddMessage(ChatMessageDisplay message)
+ {
+ if (messages == null)
+ {
+ return;
+ }
+
+ messages.Add(message);
+
+ // Scroll to bottom
+ Dispatcher.InvokeAsync(() =>
+ {
+ chatScrollViewer.ScrollToBottom();
+ }, System.Windows.Threading.DispatcherPriority.Background);
+ }
+
+ ///
+ /// Asks the user for input via the chat interface.
+ /// Used by the AskUser tool to clarify ambiguous requirements.
+ ///
+ public async Task AskUserForInput(string question, string[]? options = null)
+ {
+ // Create a completion source to wait for user response
+ waitingForUserResponse = new TaskCompletionSource();
+
+ // Display the question in the chat with interactive options
+ currentQuestionMessage = new ChatMessageDisplay
+ {
+ Role = "Assistant",
+ Content = $"🤔 **Clarification Needed**\n\n{question}",
+ IsQuestion = true,
+ QuestionOptions = options,
+ OnAnswerProvided = (answer) =>
+ {
+ // User provided an answer
+ waitingForUserResponse?.TrySetResult(answer);
+ }
+ };
+
+ AddMessage(currentQuestionMessage);
+
+ // Enable input for user to respond and show Send button
+ inputTextBox.IsEnabled = true;
+ sendButton.Visibility = Visibility.Visible;
+ cancelButton.Visibility = Visibility.Collapsed;
+ inputTextBox.Focus();
+
+ // Wait for the user to respond asynchronously
+ string response;
+ try
+ {
+ response = await waitingForUserResponse.Task;
+
+ // Display the user's response in the chat
+ AddMessage(new ChatMessageDisplay
+ {
+ Role = "User",
+ Content = response
+ });
+
+ // Clear the question state
+ currentQuestionMessage = null;
+ waitingForUserResponse = null;
+
+ // Restore to "in progress" state - disable input and show Cancel button
+ inputTextBox.IsEnabled = false;
+ sendButton.Visibility = Visibility.Collapsed;
+ cancelButton.Visibility = Visibility.Visible;
+ }
+ catch (Exception ex)
+ {
+ // If user input fails, log and return empty string and restore UI state
+ chatLogger?.LogError($"Failed to get user input: {ex.Message}");
+ response = string.Empty;
+ inputTextBox.IsEnabled = false;
+ sendButton.Visibility = Visibility.Collapsed;
+ cancelButton.Visibility = Visibility.Visible;
+ }
+
+ return response ?? string.Empty;
+ }
+
+ private void ShowStatus(string status, bool isError = false)
+ {
+ statusText.Text = status;
+ statusBar.Visibility = Visibility.Visible;
+
+ if (isError)
+ {
+ statusBar.Background = new SolidColorBrush(Color.FromRgb(255, 240, 240));
+ }
+ else
+ {
+ statusBar.Background = (Brush)FindResource("Theme_InfoBarBackground");
+ }
+ }
+
+ private void HideStatus()
+ {
+ statusBar.Visibility = Visibility.Collapsed;
+ }
+
+ private void ShowLoadingOverlay(string text = "Loading chat…")
+ {
+ loadingText.Text = text;
+ loadingOverlay.Visibility = Visibility.Visible;
+ }
+
+ private void HideLoadingOverlay()
+ {
+ loadingOverlay.Visibility = Visibility.Collapsed;
+ }
+
+ private void OptionButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button button && button.Tag is string option)
+ {
+ // User clicked an option button
+ currentQuestionMessage?.OnAnswerProvided?.Invoke(option);
+ }
+ }
+
+ private void SetQueryInProgress()
+ {
+ sendButton.Visibility = Visibility.Collapsed;
+ cancelButton.Visibility = Visibility.Visible;
+ inputTextBox.IsEnabled = false;
+ agentModeToggle.IsEnabled = false;
+ }
+
+ private void SetQueryIdle()
+ {
+ sendButton.Visibility = Visibility.Visible;
+ cancelButton.Visibility = Visibility.Collapsed;
+ inputTextBox.IsEnabled = true;
+ agentModeToggle.IsEnabled = true;
+ inputTextBox.Focus();
+ }
+
+ private async void SendButton_Click(object sender, RoutedEventArgs e)
+ {
+ await SendMessageAsync();
+ }
+
+ private void CancelButton_Click(object sender, RoutedEventArgs e)
+ {
+ // Cancel the current operation
+ cancellationTokenSource?.Cancel();
+
+ // Dismount all events from current services
+ if (chatService != null)
+ {
+ chatService.MessageAdded -= OnMessageAdded;
+ chatService.ConversationCleared -= OnConversationCleared;
+ chatService.ToolCallExecuting -= OnToolCallExecuting;
+ chatService.ToolCallExecuted -= OnToolCallExecuted;
+ chatService.RequestRetrying -= OnRequestRetrying;
+ }
+
+ if (agenticChatService != null)
+ {
+ agenticChatService.ProgressUpdated -= OnAgentProgressUpdated;
+ agenticChatService.MessageAdded -= OnMessageAdded;
+ agenticChatService.ToolCallExecuting -= OnToolCallExecuting;
+ agenticChatService.ToolCallExecuted -= OnToolCallExecuted;
+ agenticChatService.RequestRetrying -= OnRequestRetrying;
+ }
+
+ // Recreate service instances to prevent any late events
+ _ = CreateLLMServicesAsync();
+
+ // Update UI state
+ SetQueryIdle();
+
+ // Clear agent progress
+ agentProgressPanel.Clear();
+
+ ShowStatus("Request cancelled", isError: false);
+
+ // Hide status after 2 seconds
+ var timer = new System.Windows.Threading.DispatcherTimer
+ {
+ Interval = TimeSpan.FromSeconds(2)
+ };
+ timer.Tick += (s, args) =>
+ {
+ HideStatus();
+ timer.Stop();
+ };
+ timer.Start();
+ }
+
+ private void InputTextBox_PreviewKeyDown(object sender, KeyEventArgs e)
+ {
+ // Get current configuration to check AutoSendOnEnter setting
+ var config = chatService?.GetConfiguration();
+ bool autoSendEnabled = config?.AutoSendOnEnter ?? true;
+
+ if (autoSendEnabled)
+ {
+ // Send on Enter (without Shift)
+ if (e.Key == Key.Enter && !Keyboard.IsKeyDown(Key.LeftShift) && !Keyboard.IsKeyDown(Key.RightShift))
+ {
+ e.Handled = true;
+ _ = SendMessageAsync();
+ }
+ }
+ // If autoSend is disabled, Enter will create a new line (default TextBox behavior)
+ }
+
+ private async System.Threading.Tasks.Task SendMessageAsync()
+ {
+ if (chatService == null || !chatService.IsConfigured)
+ {
+ ShowStatus("LLM is not configured", isError: true);
+ return;
+ }
+
+ var message = inputTextBox.Text?.Trim();
+ if (string.IsNullOrWhiteSpace(message))
+ {
+ return;
+ }
+
+ // Clear input
+ inputTextBox.Text = string.Empty;
+
+ // Auto-generate session title from the first user message
+ activeSession?.GenerateTitleFromMessage(message);
+
+ // Check if we're waiting for a response to a question
+ if (waitingForUserResponse != null)
+ {
+ // User is answering a question
+ currentQuestionMessage?.OnAnswerProvided?.Invoke(message);
+ return;
+ }
+
+ // Cancel any existing operation
+ cancellationTokenSource?.Cancel();
+ cancellationTokenSource = new CancellationTokenSource();
+
+ // Set query in progress and show cancel button
+ SetQueryInProgress();
+ ShowStatus(currentConfig?.AgentMode == true ? "Agent thinking..." : "Thinking...");
+
+ try
+ {
+ if (currentConfig?.AgentMode == true && agenticChatService != null)
+ {
+ // Use agent mode
+ AddMessage(new ChatMessageDisplay
+ {
+ Role = "User",
+ Content = message
+ });
+
+ var response = await agenticChatService.ExecuteAgenticWorkflowAsync(
+ message,
+ cancellationTokenSource.Token);
+
+ AddMessage(new ChatMessageDisplay
+ {
+ Role = "Assistant",
+ Content = response
+ });
+ }
+ else
+ {
+ // Use regular interactive mode
+ await chatService.SendMessageAsync(message, cancellationTokenSource.Token);
+ }
+
+ HideStatus();
+ SaveChatHistory();
+ }
+ catch (OperationCanceledException)
+ {
+ ShowStatus("Request cancelled", isError: false);
+ // Hide status after 2 seconds
+ var timer = new System.Windows.Threading.DispatcherTimer
+ {
+ Interval = TimeSpan.FromSeconds(2)
+ };
+ timer.Tick += (s, args) =>
+ {
+ HideStatus();
+ timer.Stop();
+ };
+ timer.Start();
+ }
+ catch (UnauthorizedAccessException uaEx)
+ {
+ // GitHub token expired or invalid - clear persisted config and prompt user
+ SettingsService.ClearLLMConfiguration();
+
+ AddMessage(new ChatMessageDisplay
+ {
+ Role = "System",
+ Content = $"⚠️ Authentication Error\n\n{uaEx.Message}\n\n" +
+ "Your saved credentials have been cleared. Please click 'Configure' to re-authenticate.",
+ IsError = true
+ });
+
+ ShowStatus("Authentication failed - please reconfigure", isError: true);
+ }
+ catch (Exception ex)
+ {
+ ShowStatus($"Error: {ex.Message}", isError: true);
+ }
+ finally
+ {
+ SetQueryIdle();
+ }
+ }
+
+ private void ClearButton_Click(object sender, RoutedEventArgs e)
+ {
+ // Cancel any ongoing operation
+ cancellationTokenSource?.Cancel();
+
+ // Update state immediately
+ SetQueryIdle();
+
+ // Clear the conversation
+ chatService?.ClearConversation();
+ messages.Clear();
+
+ // Delete persisted chat history
+ chatHistoryService?.Delete();
+
+ // Clear agent progress
+ agentProgressPanel.Clear();
+
+ // Add welcome message back
+ if (chatService?.IsConfigured == true)
+ {
+ AddWelcomeMessage();
+ }
+
+ HideStatus();
+ inputTextBox.Text = string.Empty;
+ }
+
+ ///
+ /// Attach additional binlog file(s) for multi-build comparison.
+ ///
+ private void AttachBinlog_Click(object sender, RoutedEventArgs e)
+ {
+ var openFileDialog = new OpenFileDialog
+ {
+ Filter = "Binary Log Files (*.binlog)|*.binlog",
+ Title = "Attach Additional Binlog",
+ Multiselect = true
+ };
+
+ if (openFileDialog.ShowDialog() == true)
+ {
+ foreach (var filePath in openFileDialog.FileNames)
+ {
+ // Skip if already attached (check by full path)
+ if (buildContext?.GetAllBuilds().Any(b =>
+ b.FullPath?.Equals(filePath, StringComparison.OrdinalIgnoreCase) == true) == true)
+ {
+ AddMessage(new ChatMessageDisplay
+ {
+ Role = "System",
+ Content = $"'{Path.GetFileName(filePath)}' is already loaded.",
+ IsError = false
+ });
+ continue;
+ }
+
+ try
+ {
+ var build = BinaryLog.ReadBuild(filePath);
+ var buildId = buildContext.AddBuild(build);
+
+ // Add to visible list (only additional binlogs shown, not the implicit one)
+ attachedBinlogs.Add(new AttachedBinlogInfo
+ {
+ BuildId = buildId,
+ FileName = Path.GetFileName(filePath),
+ FullPath = filePath
+ });
+
+ AddMessage(new ChatMessageDisplay
+ {
+ Role = "System",
+ Content = $"📎 Attached: {Path.GetFileName(filePath)} [{buildId}]\n" +
+ $"You can now compare and analyze multiple builds."
+ });
+ }
+ catch (Exception ex)
+ {
+ AddMessage(new ChatMessageDisplay
+ {
+ Role = "System",
+ Content = $"Failed to load '{Path.GetFileName(filePath)}': {ex.Message}",
+ IsError = true
+ });
+ }
+ }
+ }
+ }
+
+ ///
+ /// Detach/remove an attached binlog.
+ ///
+ private void DetachBinlog_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button btn && btn.Tag is string buildId)
+ {
+ // Remove from context
+ try
+ {
+ buildContext?.RemoveBuild(buildId);
+
+ // Remove from UI list
+ var item = attachedBinlogs.FirstOrDefault(b => b.BuildId == buildId);
+ if (item != null)
+ {
+ attachedBinlogs.Remove(item);
+ AddMessage(new ChatMessageDisplay
+ {
+ Role = "System",
+ Content = $"Detached: {item.FileName}"
+ });
+ }
+ }
+ catch (InvalidOperationException ex)
+ {
+ // Cannot remove primary build
+ AddMessage(new ChatMessageDisplay
+ {
+ Role = "System",
+ Content = ex.Message,
+ IsError = true
+ });
+ }
+ }
+ }
+
+ private void AgentModeToggle_Click(object sender, RoutedEventArgs e)
+ {
+ // Update config
+ if (currentConfig != null)
+ {
+ currentConfig.AgentMode = agentModeToggle.IsChecked == true;
+ }
+
+ // Update UI
+ UpdateAgentModeUI();
+
+ // Add notification message
+ var modeMessage = currentConfig?.AgentMode == true
+ ? "🤖 **Agent Mode Enabled**\n\nI'll now break down complex questions into research tasks for thorough analysis."
+ : "💬 **Interactive Mode**\n\nBack to single-turn conversations.";
+
+ AddMessage(new ChatMessageDisplay
+ {
+ Role = "System",
+ Content = modeMessage
+ });
+ }
+
+ private void UpdateAgentModeUI()
+ {
+ var isEnabled = currentConfig?.AgentMode == true;
+ agentModeToggle.Content = isEnabled ? "🤖" : "💬";
+ agentModeToggle.FontWeight = isEnabled ? FontWeights.Bold : FontWeights.Normal;
+ agentModeToggle.ToolTip = isEnabled
+ ? "Agent Mode ON: Multi-step reasoning for complex queries (click to disable)"
+ : "Interactive Mode: Single-turn conversations (click to enable agent mode)";
+ }
+
+ private bool isPopulatingModels;
+
+ private void PopulateModelSelector()
+ {
+ isPopulatingModels = true;
+ try
+ {
+ modelSelector.Items.Clear();
+
+ var models = currentConfig?.AvailableModels;
+ var currentModel = currentConfig?.ModelName ?? "";
+
+ if (models != null && models.Count > 0)
+ {
+ foreach (var model in models)
+ {
+ modelSelector.Items.Add(model);
+ }
+
+ // Ensure current model is in the list
+ if (!string.IsNullOrEmpty(currentModel) && !models.Contains(currentModel))
+ {
+ modelSelector.Items.Insert(0, currentModel);
+ }
+ }
+ else if (!string.IsNullOrEmpty(currentModel))
+ {
+ // No available models list, just show the current one
+ modelSelector.Items.Add(currentModel);
+ }
+
+ // Select current model
+ if (!string.IsNullOrEmpty(currentModel))
+ {
+ modelSelector.SelectedItem = currentModel;
+ }
+ }
+ finally
+ {
+ isPopulatingModels = false;
+ }
+ }
+
+ private async void ModelSelector_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (isPopulatingModels || modelSelector.SelectedItem is not string selectedModel)
+ {
+ return;
+ }
+
+ if (currentConfig == null || selectedModel == currentConfig.ModelName)
+ {
+ return;
+ }
+
+ var oldModel = currentConfig.ModelName;
+ currentConfig.ModelName = selectedModel;
+ currentConfig.UpdateType();
+
+ try
+ {
+ // Reconfigure the chat service with the new model
+ if (chatService != null)
+ {
+ await chatService.ReconfigureAsync(currentConfig);
+ }
+
+ // Persist the model change
+ SettingsService.LLMModel = selectedModel;
+
+ AddMessage(new ChatMessageDisplay
+ {
+ Role = "System",
+ Content = $"Model switched to **{selectedModel}**"
+ });
+ }
+ catch (Exception ex)
+ {
+ // Revert on failure
+ currentConfig.ModelName = oldModel;
+ currentConfig.UpdateType();
+
+ isPopulatingModels = true;
+ modelSelector.SelectedItem = oldModel;
+ isPopulatingModels = false;
+
+ ShowStatus($"Failed to switch model: {ex.Message}", isError: true);
+ }
+ }
+
+ private void ConfigureButton_Click(object sender, RoutedEventArgs e)
+ {
+ // Get current configuration
+ var configForDialog = this.currentConfig ?? LLMConfigurationDialog.LoadPersistedConfiguration();
+ var wasConfigured = chatService?.IsConfigured ?? false;
+ var oldAgentMode = configForDialog.AgentMode;
+
+ // Show configuration dialog
+ var dialog = new LLMConfigurationDialog(configForDialog, chatLogger)
+ {
+ Owner = Window.GetWindow(this)
+ };
+
+ if (dialog.ShowDialog() == true)
+ {
+ try
+ {
+ // Check if configuration actually changed
+ bool endpointChanged = configForDialog.Endpoint != dialog.Endpoint;
+ bool modelChanged = configForDialog.ModelName != dialog.Model;
+ bool apiKeyChanged = configForDialog.ApiKey != dialog.ApiKey;
+ bool autoSendChanged = configForDialog.AutoSendOnEnter != dialog.AutoSendOnEnter;
+ bool agentModeChanged = configForDialog.AgentMode != dialog.AgentMode;
+ bool loggingLevelChanged = configForDialog.LoggingLevel != dialog.LoggingLevel;
+
+ // Services only need to be recreated if model settings changed
+ bool needsServiceRecreation = endpointChanged || modelChanged || apiKeyChanged;
+
+ bool hasChanges = needsServiceRecreation || autoSendChanged || agentModeChanged || loggingLevelChanged;
+
+ if (!hasChanges)
+ {
+ // No changes made
+ return;
+ }
+
+ // Store old model name for message
+ var oldModel = configForDialog.ModelName;
+
+ // Create new configuration with user-provided values
+ var newConfig = new LLMConfiguration
+ {
+ Endpoint = dialog.Endpoint,
+ ModelName = dialog.Model,
+ ApiKey = dialog.ApiKey,
+ AutoSendOnEnter = dialog.AutoSendOnEnter,
+ AgentMode = dialog.AgentMode,
+ LoggingLevel = dialog.LoggingLevel
+ };
+ newConfig.UpdateType();
+
+ // Preserve available models if endpoint hasn't changed, otherwise clear them
+ if (endpointChanged)
+ {
+ newConfig.AvailableModels = null;
+ }
+ else
+ {
+ // Copy from either dialog (if newly fetched) or existing config
+ newConfig.AvailableModels = dialog.AvailableModels ?? configForDialog.AvailableModels;
+ }
+
+ // Update current config reference
+ currentConfig = newConfig;
+
+ // Update logger level if it changed
+ if (loggingLevelChanged && chatLogger != null)
+ {
+ chatLogger.Level = dialog.LoggingLevel;
+ }
+
+ // Only recreate services if model settings actually changed
+ if (needsServiceRecreation)
+ {
+ _ = CreateLLMServicesAsync();
+ }
+ else if (chatService != null)
+ {
+ // Just reconfigure existing service with new settings
+ _ = chatService.ReconfigureAsync(newConfig);
+ }
+
+ // Update agent mode toggle enablement
+ if (newConfig.IsConfigured)
+ {
+ agentModeToggle.IsEnabled = true;
+ }
+
+ // Update agent mode toggle UI to match new config
+ agentModeToggle.IsChecked = newConfig.AgentMode;
+ UpdateAgentModeUI();
+
+ // Update model selector to reflect new config
+ PopulateModelSelector();
+
+ // Notify about agent mode change if it changed
+ if (agentModeChanged && oldAgentMode != newConfig.AgentMode)
+ {
+ var modeMessage = newConfig.AgentMode
+ ? "🤖 **Agent Mode Enabled**\n\nI'll now break down complex questions into research tasks for thorough analysis."
+ : "💬 **Interactive Mode**\n\nBack to single-turn conversations.";
+
+ AddMessage(new ChatMessageDisplay
+ {
+ Role = "System",
+ Content = modeMessage
+ });
+ }
+
+ if (chatService?.IsConfigured == true)
+ {
+ sendButton.IsEnabled = true;
+
+ // Add configuration change message to chat
+ string changeMessage;
+ if (!wasConfigured)
+ {
+ // Transitioning from unconfigured to configured - show welcome
+ AddWelcomeMessage();
+ changeMessage = $"LLM configured: {newConfig.ModelName}";
+ }
+ else if (modelChanged)
+ {
+ changeMessage = $"Model changed from {oldModel} to {newConfig.ModelName}";
+ }
+ else
+ {
+ changeMessage = "LLM configuration updated";
+ }
+
+ AddMessage(new ChatMessageDisplay
+ {
+ Role = "System",
+ Content = changeMessage
+ });
+
+ ShowStatus("Configuration updated successfully!");
+
+ // Hide status after 2 seconds
+ var timer = new System.Windows.Threading.DispatcherTimer
+ {
+ Interval = TimeSpan.FromSeconds(2)
+ };
+ timer.Tick += (s, args) =>
+ {
+ HideStatus();
+ timer.Stop();
+ };
+ timer.Start();
+ }
+ else
+ {
+ ShowStatus("Configuration failed. Please check your settings.", isError: true);
+ sendButton.IsEnabled = false;
+ }
+ }
+ catch (Exception ex)
+ {
+ ShowStatus($"Configuration error: {ex.Message}", isError: true);
+ sendButton.IsEnabled = false;
+ }
+ }
+ }
+ }
+}
diff --git a/src/StructuredLogViewer/Controls/LLMConfigurationDialog.xaml b/src/StructuredLogViewer/Controls/LLMConfigurationDialog.xaml
new file mode 100644
index 000000000..1971c0f7c
--- /dev/null
+++ b/src/StructuredLogViewer/Controls/LLMConfigurationDialog.xaml
@@ -0,0 +1,198 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StructuredLogViewer/Controls/LLMConfigurationDialog.xaml.cs b/src/StructuredLogViewer/Controls/LLMConfigurationDialog.xaml.cs
new file mode 100644
index 000000000..ce6a2bf96
--- /dev/null
+++ b/src/StructuredLogViewer/Controls/LLMConfigurationDialog.xaml.cs
@@ -0,0 +1,481 @@
+using System;
+using System.Linq;
+using System.Net.Http;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Controls;
+using StructuredLogViewer.Dialogs;
+using StructuredLogger.LLM;
+using StructuredLogger.LLM.Clients.GitHub;
+using StructuredLogger.LLM.Logging;
+
+namespace StructuredLogViewer.Controls
+{
+ public partial class LLMConfigurationDialog : Window
+ {
+ private bool isApiKeyVisible = false;
+ private bool shouldPersist = false;
+ private string initialEndpoint;
+ private readonly ILLMLogger? logger;
+
+ public string Endpoint { get; private set; }
+ public string Model { get; private set; }
+ public string ApiKey { get; private set; }
+ public bool AutoSendOnEnter { get; private set; }
+ public bool AgentMode { get; private set; }
+ public bool EnableAskUser { get; private set; }
+ public LoggingLevel LoggingLevel { get; private set; }
+ public System.Collections.Generic.List? AvailableModels { get; private set; }
+ public bool ShouldPersist => shouldPersist;
+
+ ///
+ /// Loads LLM configuration from persisted settings.
+ /// Returns configuration from SettingsService if available, otherwise from environment variables.
+ ///
+ public static LLMConfiguration LoadPersistedConfiguration()
+ {
+ var config = new LLMConfiguration();
+
+ // Try to load from persisted settings first
+ var persistedEndpoint = SettingsService.LLMEndpoint;
+ if (!string.IsNullOrEmpty(persistedEndpoint))
+ {
+ config.Endpoint = persistedEndpoint;
+ config.ModelName = SettingsService.LLMModel ?? string.Empty;
+
+ // Decrypt API key
+ var encryptedKey = SettingsService.LLMApiKeyEncrypted;
+ config.ApiKey = DecryptString(encryptedKey, null) ?? string.Empty;
+
+ config.AutoSendOnEnter = SettingsService.LLMAutoSendOnEnter;
+ config.AgentMode = SettingsService.LLMAgentMode;
+ config.LoggingLevel = (LoggingLevel)SettingsService.LLMLoggingLevel;
+
+ var modelsString = SettingsService.LLMAvailableModels;
+ if (!string.IsNullOrEmpty(modelsString))
+ {
+ config.AvailableModels = modelsString.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
+ .Select(m => m.Trim())
+ .ToList();
+ }
+
+ config.UpdateType();
+ return config;
+ }
+
+ // Fallback to environment variables
+ return LLMConfiguration.LoadFromEnvironment();
+ }
+
+ public LLMConfigurationDialog(LLMConfiguration currentConfig, ILLMLogger? logger = null)
+ {
+ InitializeComponent();
+ this.logger = logger;
+
+ // Pre-populate with current configuration
+ if (currentConfig != null)
+ {
+ initialEndpoint = currentConfig.Endpoint ?? "";
+ endpointTextBox.Text = currentConfig.Endpoint ?? "";
+
+ // Restore available models if present
+ if (currentConfig.AvailableModels != null && currentConfig.AvailableModels.Count > 0)
+ {
+ modelComboBox.Items.Clear();
+ foreach (var model in currentConfig.AvailableModels)
+ {
+ modelComboBox.Items.Add(model);
+ }
+ modelComboBox.IsEditable = false;
+ modelComboBox.SelectedItem = currentConfig.ModelName;
+ }
+ else
+ {
+ // No available models - start with editable textbox
+ modelComboBox.IsEditable = true;
+ modelComboBox.Text = currentConfig.ModelName ?? "";
+ }
+
+ if (!string.IsNullOrWhiteSpace(currentConfig.ApiKey))
+ {
+ apiKeyPasswordBox.Password = currentConfig.ApiKey;
+ apiKeyTextBox.Text = currentConfig.ApiKey;
+ }
+
+ autoSendOnEnterCheckBox.IsChecked = currentConfig.AutoSendOnEnter;
+ agentModeCheckBox.IsChecked = currentConfig.AgentMode;
+ enableAskUserCheckBox.IsChecked = SettingsService.LLMEnableAskUser;
+ loggingLevelComboBox.SelectedIndex = (int)currentConfig.LoggingLevel;
+ }
+ else
+ {
+ initialEndpoint = "";
+ // Default to editable textbox
+ modelComboBox.IsEditable = true;
+ loggingLevelComboBox.SelectedIndex = 1; // Default to Normal
+ }
+
+ // Focus on first empty field
+ Loaded += (s, e) =>
+ {
+ if (string.IsNullOrWhiteSpace(endpointTextBox.Text))
+ {
+ endpointTextBox.Focus();
+ }
+ else if (string.IsNullOrWhiteSpace(modelComboBox.Text))
+ {
+ modelComboBox.Focus();
+ }
+ else
+ {
+ apiKeyPasswordBox.Focus();
+ }
+ };
+ }
+
+ private void EndpointTextBox_TextChanged(object sender, TextChangedEventArgs e)
+ {
+ // If the endpoint was changed from its initial value, make model editable
+ // and clear prepopulated models
+ var currentEndpoint = endpointTextBox.Text?.Trim() ?? "";
+ if (!string.Equals(currentEndpoint, initialEndpoint, StringComparison.OrdinalIgnoreCase))
+ {
+ // User changed the endpoint - switch to free text mode
+ var currentModelText = modelComboBox.IsEditable ? modelComboBox.Text : (modelComboBox.SelectedItem as string);
+
+ modelComboBox.Items.Clear();
+ modelComboBox.IsEditable = true;
+ modelComboBox.Text = currentModelText ?? "";
+ AvailableModels = null;
+ }
+ }
+
+ private void ToggleApiKeyVisibility_Click(object sender, RoutedEventArgs e)
+ {
+ isApiKeyVisible = !isApiKeyVisible;
+
+ if (isApiKeyVisible)
+ {
+ // Show plain text
+ apiKeyTextBox.Text = apiKeyPasswordBox.Password;
+ apiKeyPasswordBox.Visibility = Visibility.Collapsed;
+ apiKeyTextBox.Visibility = Visibility.Visible;
+ toggleApiKeyButton.Content = "🙈";
+ }
+ else
+ {
+ // Show password box
+ apiKeyPasswordBox.Password = apiKeyTextBox.Text;
+ apiKeyTextBox.Visibility = Visibility.Collapsed;
+ apiKeyPasswordBox.Visibility = Visibility.Visible;
+ toggleApiKeyButton.Content = "👁";
+ }
+ }
+
+ private void SaveButton_Click(object sender, RoutedEventArgs e)
+ {
+ ValidateAndSaveSettings(false);
+ }
+
+ private void ApplyButton_Click(object sender, RoutedEventArgs e)
+ {
+ ValidateAndSaveSettings(false);
+ }
+
+ private void ApplyAndPersistButton_Click(object sender, RoutedEventArgs e)
+ {
+ ValidateAndSaveSettings(true);
+ }
+
+ private void ValidateAndSaveSettings(bool persist)
+ {
+ // Validate inputs
+ Endpoint = endpointTextBox.Text?.Trim();
+ Model = modelComboBox.IsEditable ? modelComboBox.Text?.Trim() : (modelComboBox.SelectedItem as string)?.Trim();
+ ApiKey = isApiKeyVisible ? apiKeyTextBox.Text?.Trim() : apiKeyPasswordBox.Password?.Trim();
+ AutoSendOnEnter = autoSendOnEnterCheckBox.IsChecked ?? true;
+ AgentMode = agentModeCheckBox.IsChecked ?? true;
+ EnableAskUser = enableAskUserCheckBox.IsChecked ?? true;
+ LoggingLevel = (LoggingLevel)loggingLevelComboBox.SelectedIndex;
+ shouldPersist = persist;
+
+ if (string.IsNullOrWhiteSpace(Endpoint))
+ {
+ MessageBox.Show("Please enter an endpoint URL.", "Validation Error",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ endpointTextBox.Focus();
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(Model))
+ {
+ MessageBox.Show("Please enter or select a model/deployment name.", "Validation Error",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ modelComboBox.Focus();
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(ApiKey))
+ {
+ // Check if it's GitHub Copilot - API key is optional for OAuth flow
+ var isGitHubCopilot = Endpoint?.Contains("github", StringComparison.OrdinalIgnoreCase) == true ||
+ Endpoint?.Equals("github-copilot", StringComparison.OrdinalIgnoreCase) == true;
+
+ if (!isGitHubCopilot)
+ {
+ MessageBox.Show("Please enter an API key.", "Validation Error",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ if (isApiKeyVisible)
+ {
+ apiKeyTextBox.Focus();
+ }
+ else
+ {
+ apiKeyPasswordBox.Focus();
+ }
+
+ return;
+ }
+ }
+
+ // If persisting, save to SettingsService
+ if (persist)
+ {
+ SettingsService.LLMEndpoint = Endpoint;
+ SettingsService.LLMModel = Model;
+ SettingsService.LLMApiKeyEncrypted = EncryptString(ApiKey);
+ SettingsService.LLMAutoSendOnEnter = AutoSendOnEnter;
+ SettingsService.LLMAgentMode = AgentMode;
+ SettingsService.LLMEnableAskUser = EnableAskUser;
+ SettingsService.LLMLoggingLevel = (int)LoggingLevel;
+
+ // Store available models as comma-separated list
+ if (AvailableModels != null && AvailableModels.Count > 0)
+ {
+ SettingsService.LLMAvailableModels = string.Join(",", AvailableModels);
+ }
+ else
+ {
+ SettingsService.LLMAvailableModels = null;
+ }
+ }
+
+ DialogResult = true;
+ }
+
+ private async void GitHubLoginButton_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ githubLoginButton.IsEnabled = false;
+ githubLoginButton.Content = "⏳ Authenticating...";
+
+ GitHubDeviceCodeDialog? deviceDialog = null;
+
+ // Create device code callback
+ void DeviceCodeCallback(string userCode, string verificationUrl)
+ {
+ Dispatcher.Invoke(() =>
+ {
+ deviceDialog = new GitHubDeviceCodeDialog(userCode, verificationUrl);
+ deviceDialog.Owner = this;
+ deviceDialog.Show();
+ });
+ }
+
+ // Start authentication
+ var authenticator = new GitHubDeviceFlowAuthenticator(DeviceCodeCallback);
+ var githubToken = await authenticator.AuthenticateAsync();
+
+ // Close device dialog on success
+ if (deviceDialog != null)
+ {
+ Dispatcher.Invoke(() =>
+ {
+ deviceDialog.CloseWithSuccess();
+ });
+ }
+
+ // Set the token in the API key field
+ if (isApiKeyVisible)
+ {
+ apiKeyTextBox.Text = githubToken;
+ }
+ else
+ {
+ apiKeyPasswordBox.Password = githubToken;
+ }
+
+ // Populate the GitHub Copilot endpoint URL
+ endpointTextBox.Text = "https://api.githubcopilot.com";
+ initialEndpoint = "https://api.githubcopilot.com";
+
+ // Try to fetch models from GitHub Copilot API
+ githubLoginButton.Content = "⏳ Loading models...";
+ bool modelsLoaded = await TryLoadGitHubCopilotModelsAsync(githubToken);
+
+ if (modelsLoaded)
+ {
+ githubLoginButton.Content = "✓ Logged In";
+ }
+ else
+ {
+ // If models couldn't be loaded, keep textbox editable
+ modelComboBox.IsEditable = true;
+ if (string.IsNullOrWhiteSpace(modelComboBox.Text))
+ {
+ modelComboBox.Text = "claude-sonnet-4.5";
+ }
+ githubLoginButton.Content = "✓ Logged In";
+ }
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(
+ $"GitHub authentication failed:\n\n{ex.Message}",
+ "Authentication Error",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error);
+
+ githubLoginButton.Content = "🔑 GitHub Login";
+ githubLoginButton.IsEnabled = true;
+ }
+ }
+
+ private async Task TryLoadGitHubCopilotModelsAsync(string githubToken)
+ {
+ try
+ {
+ // Create token provider to get Copilot token
+ var tokenProvider = new GitHubCopilotTokenProvider(githubToken);
+ var copilotToken = await tokenProvider.GetCopilotTokenAsync();
+
+ // Fetch models from API
+ using var httpClient = new HttpClient();
+ var request = new HttpRequestMessage(HttpMethod.Get, $"{copilotToken.BaseUrl}/models");
+
+ // Add all required Copilot API headers (same as GitHubCopilotChatClient)
+ request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {copilotToken.Token}");
+ request.Headers.TryAddWithoutValidation("Accept", "application/json");
+ request.Headers.TryAddWithoutValidation("User-Agent", "GitHubCopilotChat/0.35.0");
+ request.Headers.TryAddWithoutValidation("Editor-Version", "vscode/1.107.0");
+ request.Headers.TryAddWithoutValidation("Editor-Plugin-Version", "copilot-chat/0.35.0");
+ request.Headers.TryAddWithoutValidation("Copilot-Integration-Id", "vscode-chat");
+ request.Headers.TryAddWithoutValidation("openai-intent", "conversation-panel");
+ request.Headers.TryAddWithoutValidation("x-request-id", Guid.NewGuid().ToString());
+ request.Headers.TryAddWithoutValidation("X-Initiator", "user");
+
+ var response = await httpClient.SendAsync(request);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ // Models endpoint returned error - could be permission issue or invalid token
+ logger?.LogInfo($"GitHub Copilot models endpoint returned: {response.StatusCode}");
+ return false;
+ }
+
+ var json = await response.Content.ReadAsStringAsync();
+ var modelsResponse = System.Text.Json.JsonSerializer.Deserialize(json);
+
+ if (modelsResponse?.Data == null || modelsResponse.Data.Count == 0)
+ {
+ return false;
+ }
+
+ // Filter models: only include those enabled by policy and model picker
+ var availableModels = modelsResponse.Data
+ .Where(m => m.ModelPickerEnabled &&
+ m.Policy != null &&
+ m.Policy.State == "enabled")
+ .ToList();
+
+ if (availableModels.Count == 0)
+ {
+ return false;
+ }
+
+ // Successfully fetched models - populate dropdown
+ Dispatcher.Invoke(() =>
+ {
+ modelComboBox.Items.Clear();
+
+ foreach (var model in availableModels)
+ {
+ modelComboBox.Items.Add(model.Id);
+ }
+
+ // Select default model (prefer Claude Sonnet 4.5)
+ if (modelComboBox.Items.Contains("claude-sonnet-4.5"))
+ {
+ modelComboBox.SelectedItem = "claude-sonnet-4.5";
+ }
+ else if (modelComboBox.Items.Count > 0)
+ {
+ modelComboBox.SelectedIndex = 0;
+ }
+
+ modelComboBox.IsEditable = false;
+
+ // Store the available models list for persistence
+ AvailableModels = availableModels.Select(m => m.Id).ToList();
+ });
+
+ return true;
+ }
+ catch (Exception ex)
+ {
+ // Log failure to load GitHub Copilot models (could be network, auth, or format issue)
+ logger?.LogError($"Failed to load GitHub Copilot models: {ex.Message}");
+ return false;
+ }
+ }
+
+ ///
+ /// Encrypts a string using DPAPI (Data Protection API) for the current user.
+ ///
+ private string? EncryptString(string? plainText)
+ {
+ if (string.IsNullOrEmpty(plainText))
+ {
+ return null;
+ }
+
+ try
+ {
+ byte[] plainBytes = Encoding.UTF8.GetBytes(plainText);
+ byte[] encryptedBytes = ProtectedData.Protect(plainBytes, null, DataProtectionScope.CurrentUser);
+ return Convert.ToBase64String(encryptedBytes);
+ }
+ catch (Exception ex)
+ {
+ logger?.LogError($"Failed to encrypt string: {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// Decrypts a string that was encrypted using DPAPI.
+ ///
+ private static string? DecryptString(string? encryptedText, ILLMLogger? logger)
+ {
+ if (string.IsNullOrEmpty(encryptedText))
+ {
+ return null;
+ }
+
+ try
+ {
+ byte[] encryptedBytes = Convert.FromBase64String(encryptedText);
+ byte[] plainBytes = ProtectedData.Unprotect(encryptedBytes, null, DataProtectionScope.CurrentUser);
+ return Encoding.UTF8.GetString(plainBytes);
+ }
+ catch (Exception ex)
+ {
+ logger?.LogError($"Failed to decrypt string: {ex.Message}");
+ return null;
+ }
+ }
+ }
+}
diff --git a/src/StructuredLogViewer/Controls/MarkdownTextBlock.cs b/src/StructuredLogViewer/Controls/MarkdownTextBlock.cs
new file mode 100644
index 000000000..a391917b9
--- /dev/null
+++ b/src/StructuredLogViewer/Controls/MarkdownTextBlock.cs
@@ -0,0 +1,371 @@
+using System;
+using System.Text.RegularExpressions;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Documents;
+using System.Windows.Media;
+
+namespace StructuredLogViewer.Controls
+{
+ ///
+ /// A TextBlock-like control that renders Markdown formatting.
+ /// Supports: **bold**, *italic*, `code`, code blocks with ```, headings (#, ##, etc.), and lists (*, -, numbered)
+ ///
+ public class MarkdownTextBlock : RichTextBox
+ {
+ private static readonly Regex BoldRegex = new Regex(@"\*\*(.+?)\*\*", RegexOptions.Compiled);
+ private static readonly Regex ItalicRegex = new Regex(@"\*(.+?)\*", RegexOptions.Compiled);
+ private static readonly Regex InlineCodeRegex = new Regex(@"`(.+?)`", RegexOptions.Compiled);
+ private static readonly Regex CodeBlockRegex = new Regex(@"```[\w]*\n?(.*?)\n?```", RegexOptions.Compiled | RegexOptions.Singleline);
+ private static readonly Regex HeadingRegex = new Regex(@"^(#{1,6})\s+(.+)$", RegexOptions.Compiled);
+ private static readonly Regex UnorderedListRegex = new Regex(@"^[\*\-\+]\s+(.+)$", RegexOptions.Compiled);
+ private static readonly Regex OrderedListRegex = new Regex(@"^\d+\.\s+(.+)$", RegexOptions.Compiled);
+
+ public static readonly DependencyProperty MarkdownTextProperty =
+ DependencyProperty.Register(
+ nameof(MarkdownText),
+ typeof(string),
+ typeof(MarkdownTextBlock),
+ new PropertyMetadata(string.Empty, OnMarkdownTextChanged));
+
+ public string MarkdownText
+ {
+ get => (string)GetValue(MarkdownTextProperty);
+ set => SetValue(MarkdownTextProperty, value);
+ }
+
+ public MarkdownTextBlock()
+ {
+ IsReadOnly = true;
+ IsReadOnlyCaretVisible = false;
+ BorderThickness = new Thickness(0);
+ Background = Brushes.Transparent;
+ VerticalScrollBarVisibility = ScrollBarVisibility.Disabled;
+ HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled;
+ Cursor = System.Windows.Input.Cursors.Arrow;
+ }
+
+ private static void OnMarkdownTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is MarkdownTextBlock control)
+ {
+ control.RenderMarkdown(e.NewValue as string ?? string.Empty);
+ }
+ }
+
+ private void RenderMarkdown(string markdown)
+ {
+ if (string.IsNullOrEmpty(markdown))
+ {
+ Document = new FlowDocument();
+ return;
+ }
+
+ var flowDoc = new FlowDocument();
+ flowDoc.PagePadding = new Thickness(0);
+
+ // Process the text line by line to handle different block elements
+ var lines = markdown.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None);
+ bool inCodeBlock = false;
+ string codeBlockContent = "";
+ List currentList = null;
+ bool isOrderedList = false;
+
+ for (int i = 0; i < lines.Length; i++)
+ {
+ var line = lines[i];
+
+ // Check for code block markers
+ if (line.TrimStart().StartsWith("```"))
+ {
+ if (inCodeBlock)
+ {
+ // End of code block - add it
+ if (!string.IsNullOrEmpty(codeBlockContent))
+ {
+ AddCodeBlock(flowDoc, codeBlockContent.TrimEnd('\n', '\r'));
+ codeBlockContent = "";
+ }
+ inCodeBlock = false;
+ currentList = null; // Close any open list
+ }
+ else
+ {
+ // Start of code block
+ inCodeBlock = true;
+ currentList = null; // Close any open list
+ }
+ continue;
+ }
+
+ if (inCodeBlock)
+ {
+ // Accumulate code block content
+ codeBlockContent += line + "\n";
+ }
+ else
+ {
+ // Check for headings
+ var headingMatch = HeadingRegex.Match(line);
+ if (headingMatch.Success)
+ {
+ currentList = null; // Close any open list
+ var level = headingMatch.Groups[1].Value.Length;
+ var headingText = headingMatch.Groups[2].Value;
+ AddHeading(flowDoc, headingText, level);
+ continue;
+ }
+
+ // Check for unordered list items
+ var unorderedListMatch = UnorderedListRegex.Match(line);
+ if (unorderedListMatch.Success)
+ {
+ if (currentList == null || isOrderedList)
+ {
+ currentList = new List();
+ currentList.MarkerStyle = System.Windows.TextMarkerStyle.Disc;
+ currentList.Margin = new Thickness(0, 4, 0, 4);
+ currentList.Padding = new Thickness(20, 0, 0, 0);
+ flowDoc.Blocks.Add(currentList);
+ isOrderedList = false;
+ }
+ var listItemText = unorderedListMatch.Groups[1].Value;
+ AddListItem(currentList, listItemText);
+ continue;
+ }
+
+ // Check for ordered list items
+ var orderedListMatch = OrderedListRegex.Match(line);
+ if (orderedListMatch.Success)
+ {
+ if (currentList == null || !isOrderedList)
+ {
+ currentList = new List();
+ currentList.MarkerStyle = System.Windows.TextMarkerStyle.Decimal;
+ currentList.Margin = new Thickness(0, 4, 0, 4);
+ currentList.Padding = new Thickness(20, 0, 0, 0);
+ flowDoc.Blocks.Add(currentList);
+ isOrderedList = true;
+ }
+ var listItemText = orderedListMatch.Groups[1].Value;
+ AddListItem(currentList, listItemText);
+ continue;
+ }
+
+ // Regular paragraph
+ if (!string.IsNullOrWhiteSpace(line))
+ {
+ currentList = null; // Close any open list
+ var paragraph = new Paragraph();
+ paragraph.Margin = new Thickness(0, 4, 0, 4);
+ ProcessInlineFormatting(paragraph, line);
+ flowDoc.Blocks.Add(paragraph);
+ }
+ else
+ {
+ // Empty line closes the current list
+ currentList = null;
+ }
+ }
+ }
+
+ // Handle unclosed code block
+ if (inCodeBlock && !string.IsNullOrEmpty(codeBlockContent))
+ {
+ AddCodeBlock(flowDoc, codeBlockContent.TrimEnd('\n', '\r'));
+ }
+
+ Document = flowDoc;
+ }
+
+ private void ProcessInlineFormatting(Paragraph paragraph, string text)
+ {
+ if (string.IsNullOrEmpty(text))
+ {
+ return;
+ }
+
+ var currentText = text;
+ var currentIndex = 0;
+
+ // Process inline code first (highest priority)
+ var codeMatches = InlineCodeRegex.Matches(currentText);
+ if (codeMatches.Count > 0)
+ {
+ foreach (Match match in codeMatches)
+ {
+ // Add text before match
+ if (match.Index > currentIndex)
+ {
+ ProcessBoldAndItalic(paragraph, currentText.Substring(currentIndex, match.Index - currentIndex));
+ }
+
+ // Add inline code
+ AddInlineCode(paragraph, match.Groups[1].Value);
+ currentIndex = match.Index + match.Length;
+ }
+
+ // Add remaining text
+ if (currentIndex < currentText.Length)
+ {
+ ProcessBoldAndItalic(paragraph, currentText.Substring(currentIndex));
+ }
+ }
+ else
+ {
+ ProcessBoldAndItalic(paragraph, currentText);
+ }
+ }
+
+ private void ProcessBoldAndItalic(Paragraph paragraph, string text)
+ {
+ if (string.IsNullOrEmpty(text))
+ {
+ return;
+ }
+
+ var currentText = text;
+ var currentIndex = 0;
+
+ // Process bold
+ var boldMatches = BoldRegex.Matches(currentText);
+ if (boldMatches.Count > 0)
+ {
+ foreach (Match match in boldMatches)
+ {
+ // Add text before match
+ if (match.Index > currentIndex)
+ {
+ ProcessItalic(paragraph, currentText.Substring(currentIndex, match.Index - currentIndex));
+ }
+
+ // Add bold text
+ var bold = new Bold(new Run(match.Groups[1].Value));
+ paragraph.Inlines.Add(bold);
+ currentIndex = match.Index + match.Length;
+ }
+
+ // Add remaining text
+ if (currentIndex < currentText.Length)
+ {
+ ProcessItalic(paragraph, currentText.Substring(currentIndex));
+ }
+ }
+ else
+ {
+ ProcessItalic(paragraph, currentText);
+ }
+ }
+
+ private void ProcessItalic(Paragraph paragraph, string text)
+ {
+ if (string.IsNullOrEmpty(text))
+ {
+ return;
+ }
+
+ var currentText = text;
+ var currentIndex = 0;
+
+ var italicMatches = ItalicRegex.Matches(currentText);
+ if (italicMatches.Count > 0)
+ {
+ foreach (Match match in italicMatches)
+ {
+ // Add text before match
+ if (match.Index > currentIndex)
+ {
+ paragraph.Inlines.Add(new Run(currentText.Substring(currentIndex, match.Index - currentIndex)));
+ }
+
+ // Add italic text
+ var italic = new Italic(new Run(match.Groups[1].Value));
+ paragraph.Inlines.Add(italic);
+ currentIndex = match.Index + match.Length;
+ }
+
+ // Add remaining text
+ if (currentIndex < currentText.Length)
+ {
+ paragraph.Inlines.Add(new Run(currentText.Substring(currentIndex)));
+ }
+ }
+ else
+ {
+ paragraph.Inlines.Add(new Run(currentText));
+ }
+ }
+
+ private void AddInlineCode(Paragraph paragraph, string code)
+ {
+ var run = new Run(code)
+ {
+ FontFamily = new FontFamily("Consolas, Courier New, monospace"),
+ Background = new SolidColorBrush(Color.FromRgb(240, 240, 240)),
+ Foreground = new SolidColorBrush(Color.FromRgb(200, 0, 0))
+ };
+ paragraph.Inlines.Add(run);
+ }
+
+ private void AddCodeBlock(FlowDocument flowDoc, string code)
+ {
+ var codeBlock = new Paragraph(new Run(code))
+ {
+ FontFamily = new FontFamily("Consolas, Courier New, monospace"),
+ Background = new SolidColorBrush(Color.FromRgb(245, 245, 245)),
+ Foreground = new SolidColorBrush(Color.FromRgb(60, 60, 60)),
+ Padding = new Thickness(8),
+ Margin = new Thickness(0, 4, 0, 4),
+ BorderBrush = new SolidColorBrush(Color.FromRgb(220, 220, 220)),
+ BorderThickness = new Thickness(1)
+ };
+
+ flowDoc.Blocks.Add(codeBlock);
+ }
+
+ private void AddHeading(FlowDocument flowDoc, string text, int level)
+ {
+ var paragraph = new Paragraph();
+ paragraph.Margin = new Thickness(0, level == 1 ? 8 : 6, 0, 4);
+
+ var run = new Run(text);
+ run.FontWeight = FontWeights.Bold;
+
+ // Set font size based on heading level
+ switch (level)
+ {
+ case 1:
+ run.FontSize = 20;
+ break;
+ case 2:
+ run.FontSize = 18;
+ break;
+ case 3:
+ run.FontSize = 16;
+ break;
+ case 4:
+ run.FontSize = 14;
+ break;
+ case 5:
+ run.FontSize = 12;
+ break;
+ case 6:
+ run.FontSize = 11;
+ break;
+ }
+
+ paragraph.Inlines.Add(run);
+ flowDoc.Blocks.Add(paragraph);
+ }
+
+ private void AddListItem(List list, string text)
+ {
+ var listItem = new ListItem();
+ var paragraph = new Paragraph();
+ paragraph.Margin = new Thickness(0, 2, 0, 2);
+ ProcessInlineFormatting(paragraph, text);
+ listItem.Blocks.Add(paragraph);
+ list.ListItems.Add(listItem);
+ }
+ }
+}
diff --git a/src/StructuredLogViewer/Dialogs/GitHubDeviceCodeDialog.xaml b/src/StructuredLogViewer/Dialogs/GitHubDeviceCodeDialog.xaml
new file mode 100644
index 000000000..5582bf26b
--- /dev/null
+++ b/src/StructuredLogViewer/Dialogs/GitHubDeviceCodeDialog.xaml
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ To use GitHub Copilot, please complete the authentication process:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/StructuredLogViewer/Dialogs/GitHubDeviceCodeDialog.xaml.cs b/src/StructuredLogViewer/Dialogs/GitHubDeviceCodeDialog.xaml.cs
new file mode 100644
index 000000000..813d60153
--- /dev/null
+++ b/src/StructuredLogViewer/Dialogs/GitHubDeviceCodeDialog.xaml.cs
@@ -0,0 +1,158 @@
+using System;
+using System.Diagnostics;
+using System.Windows;
+using System.Windows.Threading;
+
+namespace StructuredLogViewer.Dialogs
+{
+ ///
+ /// Dialog for displaying GitHub OAuth device code authentication flow.
+ ///
+ public partial class GitHubDeviceCodeDialog : Window
+ {
+ private readonly string userCode;
+ private readonly string verificationUrl;
+ private DispatcherTimer autoCloseTimer;
+
+ public GitHubDeviceCodeDialog(string userCode, string verificationUrl)
+ {
+ InitializeComponent();
+
+ this.userCode = userCode;
+ this.verificationUrl = verificationUrl;
+
+ userCodeText.Text = userCode;
+ verificationUrlText.Text = verificationUrl;
+
+ // Auto-close after 5 minutes (device codes typically expire after 15 minutes)
+ autoCloseTimer = new DispatcherTimer
+ {
+ Interval = TimeSpan.FromMinutes(5)
+ };
+ autoCloseTimer.Tick += (s, e) =>
+ {
+ autoCloseTimer.Stop();
+ DialogResult = false;
+ Close();
+ };
+ autoCloseTimer.Start();
+
+ // Automatically copy code to clipboard on show
+ Loaded += (s, e) =>
+ {
+ try
+ {
+ Clipboard.SetText(userCode);
+ statusText.Text = "Code copied to clipboard! Waiting for authentication...";
+ }
+ catch
+ {
+ // Clipboard operations can fail in some environments
+ }
+ };
+ }
+
+ private void CopyCode_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ Clipboard.SetText(userCode);
+ statusText.Text = "Code copied to clipboard!";
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(
+ $"Failed to copy to clipboard: {ex.Message}",
+ "Error",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error);
+ }
+ }
+
+ private void OpenBrowser_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = verificationUrl,
+ UseShellExecute = true
+ });
+ statusText.Text = "Browser opened. Please complete authentication.";
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(
+ $"Failed to open browser: {ex.Message}\n\nPlease manually navigate to:\n{verificationUrl}",
+ "Error",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error);
+ }
+ }
+
+ private void Close_Click(object sender, RoutedEventArgs e)
+ {
+ autoCloseTimer?.Stop();
+ Close();
+ }
+
+ protected override void OnClosed(EventArgs e)
+ {
+ base.OnClosed(e);
+ autoCloseTimer?.Stop();
+ }
+
+ ///
+ /// Updates the status text from an external thread.
+ ///
+ public void UpdateStatus(string status)
+ {
+ Dispatcher.BeginInvoke(new Action(() =>
+ {
+ statusText.Text = status;
+ }));
+ }
+
+ ///
+ /// Closes the dialog from an external thread with success.
+ ///
+ public void CloseWithSuccess()
+ {
+ Dispatcher.BeginInvoke(new Action(() =>
+ {
+ autoCloseTimer?.Stop();
+ statusText.Text = "Authentication successful!";
+
+ // Wait a moment to show the success message, then close
+ var closeTimer = new DispatcherTimer
+ {
+ Interval = TimeSpan.FromSeconds(1.5)
+ };
+ closeTimer.Tick += (s, e) =>
+ {
+ closeTimer.Stop();
+ Close();
+ };
+ closeTimer.Start();
+ }));
+ }
+
+ ///
+ /// Closes the dialog from an external thread with error.
+ ///
+ public void CloseWithError(string errorMessage)
+ {
+ Dispatcher.BeginInvoke(new Action(() =>
+ {
+ autoCloseTimer?.Stop();
+ statusText.Text = $"Error: {errorMessage}";
+ MessageBox.Show(
+ errorMessage,
+ "Authentication Failed",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error);
+ Close();
+ }));
+ }
+ }
+}
diff --git a/src/StructuredLogViewer/LLM/BinlogUIInteractionExecutor.cs b/src/StructuredLogViewer/LLM/BinlogUIInteractionExecutor.cs
new file mode 100644
index 000000000..7561f9055
--- /dev/null
+++ b/src/StructuredLogViewer/LLM/BinlogUIInteractionExecutor.cs
@@ -0,0 +1,533 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Build.Logging.StructuredLogger;
+using Microsoft.Extensions.AI;
+using StructuredLogger.LLM;
+using StructuredLogViewer.Controls;
+
+namespace StructuredLogViewer.LLM
+{
+ ///
+ /// UI interaction tool executor that enables LLM to manipulate the WPF viewer.
+ /// Provides tools for navigation, selection, and view switching.
+ /// This is UI-specific and should only be used in StructuredLogViewer, not in CLI.
+ ///
+ public class BinlogUIInteractionExecutor : IToolsContainer
+ {
+ private readonly Build build;
+ private readonly BuildControl buildControl;
+
+ public BinlogUIInteractionExecutor(Build build, BuildControl buildControl)
+ {
+ this.build = build ?? throw new ArgumentNullException(nameof(build));
+ this.buildControl = buildControl ?? throw new ArgumentNullException(nameof(buildControl));
+ }
+
+ ///
+ /// Indicates that this container provides GUI manipulation tools.
+ ///
+ public bool HasGuiTools => true;
+
+ public IEnumerable<(AIFunction Function, StructuredLogger.LLM.AgentPhase ApplicablePhases)> GetTools()
+ {
+ // Return all UI interaction tools - these are only applicable during summarization phase
+ var phase = StructuredLogger.LLM.AgentPhase.Summarization;
+
+ yield return (AIFunctionFactory.Create(SelectNodeByTextAsync), phase);
+ yield return (AIFunctionFactory.Create(SelectErrorAsync), phase);
+ yield return (AIFunctionFactory.Create(SelectWarningAsync), phase);
+ yield return (AIFunctionFactory.Create(SelectProjectAsync), phase);
+ yield return (AIFunctionFactory.Create(OpenFileAsync), phase);
+ yield return (AIFunctionFactory.Create(OpenTimelineAsync), phase);
+ yield return (AIFunctionFactory.Create(OpenTracingAsync), phase);
+ yield return (AIFunctionFactory.Create(PerformSearchAsync), phase);
+ yield return (AIFunctionFactory.Create(OpenPropertiesAndItemsAsync), phase);
+ yield return (AIFunctionFactory.Create(OpenFindInFilesAsync), phase);
+ yield return (AIFunctionFactory.Create(FocusSearchAsync), phase);
+ }
+
+ [Description("Selects and navigates to a specific node in the tree view by searching for it. This highlights the node and shows it in the tree.")]
+ public async System.Threading.Tasks.Task SelectNodeByTextAsync(
+ [Description("Text to search for in node names or content")] string searchText,
+ [Description("Optional: Type of node to search for (e.g., 'Project', 'Target', 'Task', 'Error', 'Warning')")] string nodeType = null)
+ {
+ if (string.IsNullOrWhiteSpace(searchText))
+ {
+ return "Error: Search text cannot be empty.";
+ }
+
+ // Perform expensive search on background thread
+ var foundNode = await System.Threading.Tasks.Task.Run(() =>
+ {
+ BaseNode result = null;
+ build.VisitAllChildren(node =>
+ {
+ if (result != null) return; // Already found one
+
+ // Check node type if specified
+ if (!string.IsNullOrEmpty(nodeType))
+ {
+ var actualType = node.GetType().Name;
+ if (!actualType.Equals(nodeType, StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+ }
+
+ // Check if text matches
+ var nodeText = node.ToString();
+ if (nodeText != null && nodeText.IndexOf(searchText, StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ result = node;
+ }
+ });
+ return result;
+ }).ConfigureAwait(false);
+
+ if (foundNode == null)
+ {
+ return $"No node found matching '{searchText}'" +
+ (string.IsNullOrEmpty(nodeType) ? "" : $" of type '{nodeType}'");
+ }
+
+ // Navigate to the node on the UI thread
+ try
+ {
+ await buildControl.Dispatcher.InvokeAsync(() =>
+ {
+ buildControl.SelectItem(foundNode);
+ });
+
+ return $"Selected {foundNode.GetType().Name}: {foundNode.ToString()}";
+ }
+ catch (Exception ex)
+ {
+ return $"Error selecting node: {ex.Message}";
+ }
+ }
+
+ [Description("Selects and displays a specific error in the tree view by its index or error code.")]
+ public async System.Threading.Tasks.Task SelectErrorAsync(
+ [Description("Index of the error (1-based) or error code (e.g., 'CS1234')")] string errorIdentifier)
+ {
+ if (string.IsNullOrWhiteSpace(errorIdentifier))
+ {
+ return "Error: Error identifier cannot be empty.";
+ }
+
+ // Collect errors on background thread
+ var result = await System.Threading.Tasks.Task.Run(() =>
+ {
+ var errorList = new List();
+ build.VisitAllChildren(e => errorList.Add(e));
+
+ if (errorList.Count == 0)
+ {
+ return (errorList, (Error)null, "No errors found in the build.");
+ }
+
+ Error target = null;
+
+ // Try parsing as index (1-based)
+ if (int.TryParse(errorIdentifier, out int index))
+ {
+ if (index < 1 || index > errorList.Count)
+ {
+ return (errorList, (Error)null, $"Error index {index} is out of range. Build has {errorList.Count} error(s).");
+ }
+ target = errorList[index - 1];
+ }
+ else
+ {
+ // Search by error code
+ target = errorList.FirstOrDefault(e =>
+ e.Code != null && e.Code.Equals(errorIdentifier, StringComparison.OrdinalIgnoreCase));
+
+ if (target == null)
+ {
+ return (errorList, (Error)null, $"No error found with code '{errorIdentifier}'.");
+ }
+ }
+
+ return (errorList, target, (string)null);
+ }).ConfigureAwait(false);
+
+ var errors = result.Item1;
+ var targetError = result.Item2;
+ var errorMessage = result.Item3;
+
+ if (errorMessage != null)
+ {
+ return errorMessage;
+ }
+
+ try
+ {
+ await buildControl.Dispatcher.InvokeAsync(() =>
+ {
+ buildControl.SelectItem(targetError);
+ });
+
+ return $"Selected error: [{targetError.Code}] {targetError.ToString()}" +
+ (string.IsNullOrEmpty(targetError.File) ? "" : $"\nFile: {targetError.File}:{targetError.LineNumber}");
+ }
+ catch (Exception ex)
+ {
+ return $"Error selecting error node: {ex.Message}";
+ }
+ }
+
+ [Description("Selects and displays a specific warning in the tree view by its index or warning code.")]
+ public async System.Threading.Tasks.Task SelectWarningAsync(
+ [Description("Index of the warning (1-based) or warning code (e.g., 'CS0168')")] string warningIdentifier)
+ {
+ if (string.IsNullOrWhiteSpace(warningIdentifier))
+ {
+ return "Error: Warning identifier cannot be empty.";
+ }
+
+ // Collect warnings on background thread
+ var result = await System.Threading.Tasks.Task.Run(() =>
+ {
+ var warningList = new List();
+ build.VisitAllChildren(w => warningList.Add(w));
+
+ if (warningList.Count == 0)
+ {
+ return (warningList, (Warning)null, "No warnings found in the build.");
+ }
+
+ Warning target = null;
+
+ // Try parsing as index (1-based)
+ if (int.TryParse(warningIdentifier, out int index))
+ {
+ if (index < 1 || index > warningList.Count)
+ {
+ return (warningList, (Warning)null, $"Warning index {index} is out of range. Build has {warningList.Count} warning(s).");
+ }
+ target = warningList[index - 1];
+ }
+ else
+ {
+ // Search by warning code
+ target = warningList.FirstOrDefault(w =>
+ w.Code != null && w.Code.Equals(warningIdentifier, StringComparison.OrdinalIgnoreCase));
+
+ if (target == null)
+ {
+ return (warningList, (Warning)null, $"No warning found with code '{warningIdentifier}'.");
+ }
+ }
+
+ return (warningList, target, (string)null);
+ }).ConfigureAwait(false);
+
+ var warnings = result.Item1;
+ var targetWarning = result.Item2;
+ var errorMessage = result.Item3;
+
+ if (errorMessage != null)
+ {
+ return errorMessage;
+ }
+
+ try
+ {
+ await buildControl.Dispatcher.InvokeAsync(() =>
+ {
+ buildControl.SelectItem(targetWarning);
+ });
+
+ return $"Selected warning: [{targetWarning.Code}] {targetWarning.ToString()}" +
+ (string.IsNullOrEmpty(targetWarning.File) ? "" : $"\nFile: {targetWarning.File}:{targetWarning.LineNumber}");
+ }
+ catch (Exception ex)
+ {
+ return $"Error selecting warning node: {ex.Message}";
+ }
+ }
+
+ [Description("Selects and displays a specific project in the tree view by name or partial name match.")]
+ public async System.Threading.Tasks.Task SelectProjectAsync(
+ [Description("Name or partial name of the project")] string projectName)
+ {
+ if (string.IsNullOrWhiteSpace(projectName))
+ {
+ return "Error: Project name cannot be empty.";
+ }
+
+ // Search for project on background thread
+ var foundProject = await System.Threading.Tasks.Task.Run(() =>
+ {
+ Project result = null;
+ build.VisitAllChildren(p =>
+ {
+ if (result != null) return;
+ if (p.Name != null && p.Name.IndexOf(projectName, StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ result = p;
+ }
+ });
+ return result;
+ }).ConfigureAwait(false);
+
+ if (foundProject == null)
+ {
+ return $"No project found matching '{projectName}'.";
+ }
+
+ try
+ {
+ await buildControl.Dispatcher.InvokeAsync(() =>
+ {
+ buildControl.SelectItem(foundProject);
+ });
+
+ return $"Selected project: {foundProject.Name}";
+ }
+ catch (Exception ex)
+ {
+ return $"Error selecting project: {ex.Message}";
+ }
+ }
+
+ [Description("Opens a source file in the document viewer. Useful for viewing files referenced in errors, warnings, or tasks.")]
+ public async System.Threading.Tasks.Task OpenFileAsync(
+ [Description("Full path or partial path/filename to open")] string filePath,
+ [Description("Optional: Line number to navigate to (1-based)")] int lineNumber = 0)
+ {
+ if (string.IsNullOrWhiteSpace(filePath))
+ {
+ return "Error: File path cannot be empty.";
+ }
+
+ try
+ {
+ bool success = false;
+ await buildControl.Dispatcher.InvokeAsync(() =>
+ {
+ success = buildControl.DisplayFile(filePath, lineNumber);
+ });
+
+ if (success)
+ {
+ return $"Opened file: {filePath}" + (lineNumber > 0 ? $" at line {lineNumber}" : "");
+ }
+ else
+ {
+ return $"Could not open file: {filePath}. File may not be embedded in the binlog or path may be incorrect.";
+ }
+ }
+ catch (Exception ex)
+ {
+ return $"Error opening file: {ex.Message}";
+ }
+ }
+
+ [Description("Opens the Timeline view and navigates to a specific timed node (project, target, or task).")]
+ public async System.Threading.Tasks.Task OpenTimelineAsync(
+ [Description("Optional: Name of project, target, or task to highlight in timeline")] string nodeName = null)
+ {
+ try
+ {
+ // Search for node on background thread if needed
+ TimedNode foundNode = null;
+ if (!string.IsNullOrWhiteSpace(nodeName))
+ {
+ foundNode = await System.Threading.Tasks.Task.Run(() =>
+ {
+ TimedNode result = null;
+ build.VisitAllChildren(node =>
+ {
+ if (result != null) return;
+
+ var nodeText = node.ToString();
+ if (nodeText != null && nodeText.IndexOf(nodeName, StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ result = node;
+ }
+ });
+ return result;
+ }).ConfigureAwait(false);
+ }
+
+ await buildControl.Dispatcher.InvokeAsync(() =>
+ {
+ if (foundNode != null)
+ {
+ buildControl.SelectItem(foundNode);
+ }
+ buildControl.GoToTimeLine();
+ });
+
+ return string.IsNullOrWhiteSpace(nodeName)
+ ? "Opened Timeline view"
+ : $"Opened Timeline view for: {nodeName}";
+ }
+ catch (Exception ex)
+ {
+ return $"Error opening timeline: {ex.Message}";
+ }
+ }
+
+ [Description("Opens the Tracing view and navigates to a specific timed node for detailed performance analysis.")]
+ public async System.Threading.Tasks.Task OpenTracingAsync(
+ [Description("Optional: Name of project, target, or task to analyze in tracing view")] string nodeName = null)
+ {
+ try
+ {
+ // Search for node on background thread if needed
+ TimedNode foundNode = null;
+ if (!string.IsNullOrWhiteSpace(nodeName))
+ {
+ foundNode = await System.Threading.Tasks.Task.Run(() =>
+ {
+ TimedNode result = null;
+ build.VisitAllChildren(node =>
+ {
+ if (result != null) return;
+
+ var nodeText = node.ToString();
+ if (nodeText != null && nodeText.IndexOf(nodeName, StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ result = node;
+ }
+ });
+ return result;
+ }).ConfigureAwait(false);
+ }
+
+ await buildControl.Dispatcher.InvokeAsync(() =>
+ {
+ if (foundNode != null)
+ {
+ buildControl.SelectItem(foundNode);
+ }
+ buildControl.GoToTracing();
+ });
+
+ return string.IsNullOrWhiteSpace(nodeName)
+ ? "Opened Tracing view"
+ : $"Opened Tracing view for: {nodeName}";
+ }
+ catch (Exception ex)
+ {
+ return $"Error opening tracing: {ex.Message}";
+ }
+ }
+
+ [Description("Performs a search in the build log and optionally selects the first result.")]
+ public async System.Threading.Tasks.Task PerformSearchAsync(
+ [Description("Search query text")] string searchText,
+ [Description("Whether to automatically select the first search result")] bool selectFirst = true)
+ {
+ if (string.IsNullOrWhiteSpace(searchText))
+ {
+ return "Error: Search text cannot be empty.";
+ }
+
+ try
+ {
+ await buildControl.Dispatcher.InvokeAsync(() =>
+ {
+ buildControl.SelectSearchTab(searchText);
+ });
+
+ return $"Performed search for: '{searchText}'" +
+ (selectFirst ? " and selected first result" : "");
+ }
+ catch (Exception ex)
+ {
+ return $"Error performing search: {ex.Message}";
+ }
+ }
+
+ [Description("Opens the Properties and Items tab to view MSBuild properties and items for the selected or specified project.")]
+ public async System.Threading.Tasks.Task OpenPropertiesAndItemsAsync(
+ [Description("Optional: Project name to set context for")] string projectName = null)
+ {
+ try
+ {
+ // Search for project on background thread if needed
+ Project foundProject = null;
+ if (!string.IsNullOrWhiteSpace(projectName))
+ {
+ foundProject = await System.Threading.Tasks.Task.Run(() =>
+ {
+ Project result = null;
+ build.VisitAllChildren(p =>
+ {
+ if (result != null) return;
+ if (p.Name != null && p.Name.IndexOf(projectName, StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ result = p;
+ }
+ });
+ return result;
+ }).ConfigureAwait(false);
+ }
+
+ await buildControl.Dispatcher.InvokeAsync(() =>
+ {
+ if (foundProject != null)
+ {
+ buildControl.SelectItem(foundProject);
+ }
+ buildControl.SelectPropertiesAndItemsTab();
+ });
+
+ return string.IsNullOrWhiteSpace(projectName)
+ ? "Opened Properties and Items view"
+ : $"Opened Properties and Items view for project: {projectName}";
+ }
+ catch (Exception ex)
+ {
+ return $"Error opening Properties and Items: {ex.Message}";
+ }
+ }
+
+ [Description("Opens the Find in Files tab for full-text search across embedded files.")]
+ public async System.Threading.Tasks.Task OpenFindInFilesAsync(
+ [Description("Optional: Initial search text to populate")] string searchText = null)
+ {
+ try
+ {
+ await buildControl.Dispatcher.InvokeAsync(() =>
+ {
+ buildControl.SelectFindInFilesTab(searchText);
+ });
+
+ return string.IsNullOrWhiteSpace(searchText)
+ ? "Opened Find in Files view"
+ : $"Opened Find in Files view with search: '{searchText}'";
+ }
+ catch (Exception ex)
+ {
+ return $"Error opening Find in Files: {ex.Message}";
+ }
+ }
+
+ [Description("Focuses the main search box at the top of the window, ready for user input.")]
+ public async System.Threading.Tasks.Task FocusSearchAsync()
+ {
+ try
+ {
+ await buildControl.Dispatcher.InvokeAsync(() =>
+ {
+ buildControl.FocusSearch();
+ });
+
+ return "Focused the search box";
+ }
+ catch (Exception ex)
+ {
+ return $"Error focusing search: {ex.Message}";
+ }
+ }
+ }
+}
diff --git a/src/StructuredLogViewer/LLM/ToolCallViewModel.cs b/src/StructuredLogViewer/LLM/ToolCallViewModel.cs
new file mode 100644
index 000000000..7e0ec8ee5
--- /dev/null
+++ b/src/StructuredLogViewer/LLM/ToolCallViewModel.cs
@@ -0,0 +1,211 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using StructuredLogger.LLM;
+
+namespace StructuredLogViewer.LLM
+{
+ ///
+ /// View model for displaying tool call information in the UI.
+ /// Provides user-friendly formatting and collapsible display support.
+ ///
+ public class ToolCallViewModel : INotifyPropertyChanged
+ {
+ private bool isExpanded;
+ private bool isInProgress;
+ private string resultText;
+ private TimeSpan? duration;
+ private bool isError;
+ private string errorMessage;
+
+ public ToolCallViewModel(ToolCallInfo toolCallInfo)
+ {
+ if (toolCallInfo == null)
+ {
+ throw new ArgumentNullException(nameof(toolCallInfo));
+ }
+
+ CallId = toolCallInfo.CallId;
+ ToolName = toolCallInfo.ToolName;
+ StartTime = toolCallInfo.StartTime;
+ duration = toolCallInfo.Duration;
+ isError = toolCallInfo.IsError;
+ errorMessage = toolCallInfo.ErrorMessage;
+ resultText = toolCallInfo.ResultText;
+
+ // If no end time, this is an in-progress call
+ isInProgress = !toolCallInfo.EndTime.HasValue;
+
+ // Parse arguments for structured display
+ ParsedArguments = toolCallInfo.GetParsedArguments();
+ ArgumentsSummary = toolCallInfo.GetArgumentsSummary(80);
+ }
+
+ public Guid CallId { get; }
+ public string ToolName { get; }
+ public string ArgumentsSummary { get; }
+ public Dictionary ParsedArguments { get; }
+ public DateTime StartTime { get; }
+
+ public string ResultText
+ {
+ get => resultText;
+ private set
+ {
+ if (resultText != value)
+ {
+ resultText = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(FormattedResult));
+ }
+ }
+ }
+
+ public TimeSpan? Duration
+ {
+ get => duration;
+ private set
+ {
+ if (duration != value)
+ {
+ duration = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(DurationText));
+ }
+ }
+ }
+
+ public bool IsError
+ {
+ get => isError;
+ private set
+ {
+ if (isError != value)
+ {
+ isError = value;
+ OnPropertyChanged();
+ }
+ }
+ }
+
+ public string ErrorMessage
+ {
+ get => errorMessage;
+ private set
+ {
+ if (errorMessage != value)
+ {
+ errorMessage = value;
+ OnPropertyChanged();
+ }
+ }
+ }
+
+ public bool IsInProgress
+ {
+ get => isInProgress;
+ private set
+ {
+ if (isInProgress != value)
+ {
+ isInProgress = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(HeaderText));
+ OnPropertyChanged(nameof(DurationText));
+ }
+ }
+ }
+
+ public bool IsExpanded
+ {
+ get => isExpanded;
+ set
+ {
+ if (isExpanded != value)
+ {
+ isExpanded = value;
+ OnPropertyChanged();
+ }
+ }
+ }
+
+ ///
+ /// Gets the header text shown in collapsed state.
+ ///
+ public string HeaderText => IsInProgress
+ ? $"⏳ {ToolName}: {ArgumentsSummary} (In Progress...)"
+ : $"🔧 {ToolName}: {ArgumentsSummary}";
+
+ ///
+ /// Gets formatted duration text.
+ ///
+ public string DurationText => IsInProgress
+ ? "In progress..."
+ : (Duration.HasValue ? $"{Duration.Value.TotalMilliseconds:F0}ms" : "N/A");
+
+ ///
+ /// Updates this view model with completion data from a ToolCallInfo.
+ ///
+ public void UpdateWithCompletion(ToolCallInfo completedCallInfo)
+ {
+ if (completedCallInfo.CallId != CallId)
+ {
+ throw new InvalidOperationException("CallId mismatch when updating tool call");
+ }
+
+ IsInProgress = false;
+ Duration = completedCallInfo.Duration;
+ IsError = completedCallInfo.IsError;
+ ErrorMessage = completedCallInfo.ErrorMessage;
+ ResultText = completedCallInfo.ResultText ?? "(no result)";
+ }
+
+ ///
+ /// Gets formatted arguments for display.
+ ///
+ public string FormattedArguments
+ {
+ get
+ {
+ if (ParsedArguments == null || ParsedArguments.Count == 0)
+ {
+ return "(no arguments)";
+ }
+
+ return string.Join("\n", ParsedArguments.Select(kvp => $"{kvp.Key}: {kvp.Value}"));
+ }
+ }
+
+ ///
+ /// Gets formatted result text with truncation if too long.
+ ///
+ public string FormattedResult
+ {
+ get
+ {
+ if (string.IsNullOrWhiteSpace(ResultText))
+ {
+ return "(no result)";
+ }
+
+ // Truncate very long results
+ const int maxLength = 5000;
+ if (ResultText.Length > maxLength)
+ {
+ return ResultText.Substring(0, maxLength) + "\n\n... (truncated)";
+ }
+
+ return ResultText;
+ }
+ }
+
+ public event PropertyChangedEventHandler PropertyChanged;
+
+ protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+ }
+}
diff --git a/src/StructuredLogViewer/MSBuildStructuredLogViewer.nuspec b/src/StructuredLogViewer/MSBuildStructuredLogViewer.nuspec
index 1f556c240..073aa3cd6 100644
--- a/src/StructuredLogViewer/MSBuildStructuredLogViewer.nuspec
+++ b/src/StructuredLogViewer/MSBuildStructuredLogViewer.nuspec
@@ -21,8 +21,14 @@
+
+
+
+
+
+
@@ -35,6 +41,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -53,24 +72,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
diff --git a/src/StructuredLogViewer/MainWindow.xaml b/src/StructuredLogViewer/MainWindow.xaml
index 983098f0b..2d1a2ae66 100644
--- a/src/StructuredLogViewer/MainWindow.xaml
+++ b/src/StructuredLogViewer/MainWindow.xaml
@@ -1,4 +1,4 @@
-
-
- Exception:
-
+
+ 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