diff --git a/.codex/skills/mcaf-adr-writing/SKILL.md b/.codex/skills/mcaf-adr-writing/SKILL.md
index 7f547d0..9fb1c75 100644
--- a/.codex/skills/mcaf-adr-writing/SKILL.md
+++ b/.codex/skills/mcaf-adr-writing/SKILL.md
@@ -65,17 +65,20 @@ compatibility: "Requires repository write access; produces Markdown ADRs with Me
Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work.
-1. Plan first (mandatory):
+1. Brainstorm first (mandatory):
- analyze current state
- - define target outcome, constraints, and risks
- - write a detailed execution plan
+ - define the problem, target outcome, constraints, and risks
+ - generate options and think through trade-offs before committing
+ - capture the recommended direction and open questions
+2. Plan second (mandatory):
+ - write a detailed execution plan from the chosen direction
- list final validation skills to run at the end, with order and reason
-2. Execute one planned step and produce a concrete delta.
-3. Review the result and capture findings with actionable next fixes.
-4. Apply fixes in small batches and rerun the relevant checks or review steps.
-5. Update the plan after each iteration.
-6. Repeat until outcomes are acceptable or only explicit exceptions remain.
-7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
+3. Execute one planned step and produce a concrete delta.
+4. Review the result and capture findings with actionable next fixes.
+5. Apply fixes in small batches and rerun the relevant checks or review steps.
+6. Update the plan after each iteration.
+7. Repeat until outcomes are acceptable or only explicit exceptions remain.
+8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
### Required Result Format
diff --git a/.codex/skills/mcaf-agile-delivery/SKILL.md b/.codex/skills/mcaf-agile-delivery/SKILL.md
index 15628f3..d7f21b6 100644
--- a/.codex/skills/mcaf-agile-delivery/SKILL.md
+++ b/.codex/skills/mcaf-agile-delivery/SKILL.md
@@ -62,17 +62,20 @@ compatibility: "Requires repository access only when the repo stores delivery do
Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work.
-1. Plan first (mandatory):
+1. Brainstorm first (mandatory):
- analyze current state
- - define target outcome, constraints, and risks
- - write a detailed execution plan
+ - define the problem, target outcome, constraints, and risks
+ - generate options and think through trade-offs before committing
+ - capture the recommended direction and open questions
+2. Plan second (mandatory):
+ - write a detailed execution plan from the chosen direction
- list final validation skills to run at the end, with order and reason
-2. Execute one planned step and produce a concrete delta.
-3. Review the result and capture findings with actionable next fixes.
-4. Apply fixes in small batches and rerun the relevant checks or review steps.
-5. Update the plan after each iteration.
-6. Repeat until outcomes are acceptable or only explicit exceptions remain.
-7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
+3. Execute one planned step and produce a concrete delta.
+4. Review the result and capture findings with actionable next fixes.
+5. Apply fixes in small batches and rerun the relevant checks or review steps.
+6. Update the plan after each iteration.
+7. Repeat until outcomes are acceptable or only explicit exceptions remain.
+8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
### Required Result Format
diff --git a/.codex/skills/mcaf-architecture-overview/SKILL.md b/.codex/skills/mcaf-architecture-overview/SKILL.md
index 51cf144..31e8c38 100644
--- a/.codex/skills/mcaf-architecture-overview/SKILL.md
+++ b/.codex/skills/mcaf-architecture-overview/SKILL.md
@@ -63,17 +63,20 @@ compatibility: "Requires repository write access; produces Markdown docs with Me
Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work.
-1. Plan first (mandatory):
+1. Brainstorm first (mandatory):
- analyze current state
- - define target outcome, constraints, and risks
- - write a detailed execution plan
+ - define the problem, target outcome, constraints, and risks
+ - generate options and think through trade-offs before committing
+ - capture the recommended direction and open questions
+2. Plan second (mandatory):
+ - write a detailed execution plan from the chosen direction
- list final validation skills to run at the end, with order and reason
-2. Execute one planned step and produce a concrete delta.
-3. Review the result and capture findings with actionable next fixes.
-4. Apply fixes in small batches and rerun the relevant checks or review steps.
-5. Update the plan after each iteration.
-6. Repeat until outcomes are acceptable or only explicit exceptions remain.
-7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
+3. Execute one planned step and produce a concrete delta.
+4. Review the result and capture findings with actionable next fixes.
+5. Apply fixes in small batches and rerun the relevant checks or review steps.
+6. Update the plan after each iteration.
+7. Repeat until outcomes are acceptable or only explicit exceptions remain.
+8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
### Required Result Format
diff --git a/.codex/skills/mcaf-ci-cd/SKILL.md b/.codex/skills/mcaf-ci-cd/SKILL.md
index b34c37f..26d19f7 100644
--- a/.codex/skills/mcaf-ci-cd/SKILL.md
+++ b/.codex/skills/mcaf-ci-cd/SKILL.md
@@ -72,17 +72,20 @@ compatibility: "Requires repository access; may update CI workflows, pipeline do
Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work.
-1. Plan first (mandatory):
+1. Brainstorm first (mandatory):
- analyze current state
- - define target outcome, constraints, and risks
- - write a detailed execution plan
+ - define the problem, target outcome, constraints, and risks
+ - generate options and think through trade-offs before committing
+ - capture the recommended direction and open questions
+2. Plan second (mandatory):
+ - write a detailed execution plan from the chosen direction
- list final validation skills to run at the end, with order and reason
-2. Execute one planned step and produce a concrete delta.
-3. Review the result and capture findings with actionable next fixes.
-4. Apply fixes in small batches and rerun the relevant checks or review steps.
-5. Update the plan after each iteration.
-6. Repeat until outcomes are acceptable or only explicit exceptions remain.
-7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
+3. Execute one planned step and produce a concrete delta.
+4. Review the result and capture findings with actionable next fixes.
+5. Apply fixes in small batches and rerun the relevant checks or review steps.
+6. Update the plan after each iteration.
+7. Repeat until outcomes are acceptable or only explicit exceptions remain.
+8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
### Required Result Format
@@ -98,7 +101,7 @@ For setup-only requests with no execution, return `status: configured` and exact
## Load References
- read `references/ci-cd.md` first
-- for .NET quality gates, use `mcaf-dotnet-quality-ci`
+- for .NET quality gates, use the external `mcaf-dotnet-quality-ci` skill from [managedcode/dotnet-skills](https://github.com/managedcode/dotnet-skills)
## Example Requests
diff --git a/.codex/skills/mcaf-code-review/SKILL.md b/.codex/skills/mcaf-code-review/SKILL.md
index f595391..85e53da 100644
--- a/.codex/skills/mcaf-code-review/SKILL.md
+++ b/.codex/skills/mcaf-code-review/SKILL.md
@@ -64,17 +64,20 @@ compatibility: "Requires repository access; may update PR templates, review docs
Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work.
-1. Plan first (mandatory):
+1. Brainstorm first (mandatory):
- analyze current state
- - define target outcome, constraints, and risks
- - write a detailed execution plan
+ - define the problem, target outcome, constraints, and risks
+ - generate options and think through trade-offs before committing
+ - capture the recommended direction and open questions
+2. Plan second (mandatory):
+ - write a detailed execution plan from the chosen direction
- list final validation skills to run at the end, with order and reason
-2. Execute one planned step and produce a concrete delta.
-3. Review the result and capture findings with actionable next fixes.
-4. Apply fixes in small batches and rerun the relevant checks or review steps.
-5. Update the plan after each iteration.
-6. Repeat until outcomes are acceptable or only explicit exceptions remain.
-7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
+3. Execute one planned step and produce a concrete delta.
+4. Review the result and capture findings with actionable next fixes.
+5. Apply fixes in small batches and rerun the relevant checks or review steps.
+6. Update the plan after each iteration.
+7. Repeat until outcomes are acceptable or only explicit exceptions remain.
+8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
### Required Result Format
diff --git a/.codex/skills/mcaf-devex/SKILL.md b/.codex/skills/mcaf-devex/SKILL.md
index 401a445..b6784c8 100644
--- a/.codex/skills/mcaf-devex/SKILL.md
+++ b/.codex/skills/mcaf-devex/SKILL.md
@@ -63,17 +63,20 @@ compatibility: "Requires repository access; may update docs, task runners, devco
Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work.
-1. Plan first (mandatory):
+1. Brainstorm first (mandatory):
- analyze current state
- - define target outcome, constraints, and risks
- - write a detailed execution plan
+ - define the problem, target outcome, constraints, and risks
+ - generate options and think through trade-offs before committing
+ - capture the recommended direction and open questions
+2. Plan second (mandatory):
+ - write a detailed execution plan from the chosen direction
- list final validation skills to run at the end, with order and reason
-2. Execute one planned step and produce a concrete delta.
-3. Review the result and capture findings with actionable next fixes.
-4. Apply fixes in small batches and rerun the relevant checks or review steps.
-5. Update the plan after each iteration.
-6. Repeat until outcomes are acceptable or only explicit exceptions remain.
-7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
+3. Execute one planned step and produce a concrete delta.
+4. Review the result and capture findings with actionable next fixes.
+5. Apply fixes in small batches and rerun the relevant checks or review steps.
+6. Update the plan after each iteration.
+7. Repeat until outcomes are acceptable or only explicit exceptions remain.
+8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
### Required Result Format
diff --git a/.codex/skills/mcaf-documentation/SKILL.md b/.codex/skills/mcaf-documentation/SKILL.md
index 7292f05..1cfdaf4 100644
--- a/.codex/skills/mcaf-documentation/SKILL.md
+++ b/.codex/skills/mcaf-documentation/SKILL.md
@@ -59,17 +59,20 @@ compatibility: "Requires repository write access; updates docs and documentation
Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work.
-1. Plan first (mandatory):
+1. Brainstorm first (mandatory):
- analyze current state
- - define target outcome, constraints, and risks
- - write a detailed execution plan
+ - define the problem, target outcome, constraints, and risks
+ - generate options and think through trade-offs before committing
+ - capture the recommended direction and open questions
+2. Plan second (mandatory):
+ - write a detailed execution plan from the chosen direction
- list final validation skills to run at the end, with order and reason
-2. Execute one planned step and produce a concrete delta.
-3. Review the result and capture findings with actionable next fixes.
-4. Apply fixes in small batches and rerun the relevant checks or review steps.
-5. Update the plan after each iteration.
-6. Repeat until outcomes are acceptable or only explicit exceptions remain.
-7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
+3. Execute one planned step and produce a concrete delta.
+4. Review the result and capture findings with actionable next fixes.
+5. Apply fixes in small batches and rerun the relevant checks or review steps.
+6. Update the plan after each iteration.
+7. Repeat until outcomes are acceptable or only explicit exceptions remain.
+8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
### Required Result Format
diff --git a/.codex/skills/mcaf-feature-spec/SKILL.md b/.codex/skills/mcaf-feature-spec/SKILL.md
index 05b4560..39d8be7 100644
--- a/.codex/skills/mcaf-feature-spec/SKILL.md
+++ b/.codex/skills/mcaf-feature-spec/SKILL.md
@@ -65,17 +65,20 @@ compatibility: "Requires repository write access; produces Markdown docs with Me
Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work.
-1. Plan first (mandatory):
+1. Brainstorm first (mandatory):
- analyze current state
- - define target outcome, constraints, and risks
- - write a detailed execution plan
+ - define the problem, target outcome, constraints, and risks
+ - generate options and think through trade-offs before committing
+ - capture the recommended direction and open questions
+2. Plan second (mandatory):
+ - write a detailed execution plan from the chosen direction
- list final validation skills to run at the end, with order and reason
-2. Execute one planned step and produce a concrete delta.
-3. Review the result and capture findings with actionable next fixes.
-4. Apply fixes in small batches and rerun the relevant checks or review steps.
-5. Update the plan after each iteration.
-6. Repeat until outcomes are acceptable or only explicit exceptions remain.
-7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
+3. Execute one planned step and produce a concrete delta.
+4. Review the result and capture findings with actionable next fixes.
+5. Apply fixes in small batches and rerun the relevant checks or review steps.
+6. Update the plan after each iteration.
+7. Repeat until outcomes are acceptable or only explicit exceptions remain.
+8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
### Required Result Format
diff --git a/.codex/skills/mcaf-human-review-planning/SKILL.md b/.codex/skills/mcaf-human-review-planning/SKILL.md
index 3b294e6..5e14585 100644
--- a/.codex/skills/mcaf-human-review-planning/SKILL.md
+++ b/.codex/skills/mcaf-human-review-planning/SKILL.md
@@ -84,17 +84,20 @@ compatibility: "Requires repository read access; may write a `HUMAN_REVIEW_PLAN.
Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work.
-1. Plan first (mandatory):
+1. Brainstorm first (mandatory):
- analyze current state
- - define target outcome, constraints, and risks
- - write a detailed execution plan
+ - define the problem, target outcome, constraints, and risks
+ - generate options and think through trade-offs before committing
+ - capture the recommended direction and open questions
+2. Plan second (mandatory):
+ - write a detailed execution plan from the chosen direction
- list final validation skills to run at the end, with order and reason
-2. Execute one planned step and produce a concrete delta.
-3. Review the result and capture findings with actionable next fixes.
-4. Apply fixes in small batches and rerun the relevant checks or review steps.
-5. Update the plan after each iteration.
-6. Repeat until outcomes are acceptable or only explicit exceptions remain.
-7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
+3. Execute one planned step and produce a concrete delta.
+4. Review the result and capture findings with actionable next fixes.
+5. Apply fixes in small batches and rerun the relevant checks or review steps.
+6. Update the plan after each iteration.
+7. Repeat until outcomes are acceptable or only explicit exceptions remain.
+8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
### Required Result Format
diff --git a/.codex/skills/mcaf-ml-ai-delivery/SKILL.md b/.codex/skills/mcaf-ml-ai-delivery/SKILL.md
index 0a01e66..f71894a 100644
--- a/.codex/skills/mcaf-ml-ai-delivery/SKILL.md
+++ b/.codex/skills/mcaf-ml-ai-delivery/SKILL.md
@@ -58,17 +58,20 @@ compatibility: "Requires repository access when ML/AI docs, experiments, or deli
Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work.
-1. Plan first (mandatory):
+1. Brainstorm first (mandatory):
- analyze current state
- - define target outcome, constraints, and risks
- - write a detailed execution plan
+ - define the problem, target outcome, constraints, and risks
+ - generate options and think through trade-offs before committing
+ - capture the recommended direction and open questions
+2. Plan second (mandatory):
+ - write a detailed execution plan from the chosen direction
- list final validation skills to run at the end, with order and reason
-2. Execute one planned step and produce a concrete delta.
-3. Review the result and capture findings with actionable next fixes.
-4. Apply fixes in small batches and rerun the relevant checks or review steps.
-5. Update the plan after each iteration.
-6. Repeat until outcomes are acceptable or only explicit exceptions remain.
-7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
+3. Execute one planned step and produce a concrete delta.
+4. Review the result and capture findings with actionable next fixes.
+5. Apply fixes in small batches and rerun the relevant checks or review steps.
+6. Update the plan after each iteration.
+7. Repeat until outcomes are acceptable or only explicit exceptions remain.
+8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
### Required Result Format
diff --git a/.codex/skills/mcaf-nfr/SKILL.md b/.codex/skills/mcaf-nfr/SKILL.md
index 245cd4c..675cf1e 100644
--- a/.codex/skills/mcaf-nfr/SKILL.md
+++ b/.codex/skills/mcaf-nfr/SKILL.md
@@ -58,17 +58,20 @@ compatibility: "Requires repository access when NFRs are documented in feature d
Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work.
-1. Plan first (mandatory):
+1. Brainstorm first (mandatory):
- analyze current state
- - define target outcome, constraints, and risks
- - write a detailed execution plan
+ - define the problem, target outcome, constraints, and risks
+ - generate options and think through trade-offs before committing
+ - capture the recommended direction and open questions
+2. Plan second (mandatory):
+ - write a detailed execution plan from the chosen direction
- list final validation skills to run at the end, with order and reason
-2. Execute one planned step and produce a concrete delta.
-3. Review the result and capture findings with actionable next fixes.
-4. Apply fixes in small batches and rerun the relevant checks or review steps.
-5. Update the plan after each iteration.
-6. Repeat until outcomes are acceptable or only explicit exceptions remain.
-7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
+3. Execute one planned step and produce a concrete delta.
+4. Review the result and capture findings with actionable next fixes.
+5. Apply fixes in small batches and rerun the relevant checks or review steps.
+6. Update the plan after each iteration.
+7. Repeat until outcomes are acceptable or only explicit exceptions remain.
+8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
### Required Result Format
diff --git a/.codex/skills/mcaf-observability/SKILL.md b/.codex/skills/mcaf-observability/SKILL.md
index 3c047ae..32530f1 100644
--- a/.codex/skills/mcaf-observability/SKILL.md
+++ b/.codex/skills/mcaf-observability/SKILL.md
@@ -63,17 +63,20 @@ compatibility: "Requires repository access; may update code, dashboards-as-code,
Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work.
-1. Plan first (mandatory):
+1. Brainstorm first (mandatory):
- analyze current state
- - define target outcome, constraints, and risks
- - write a detailed execution plan
+ - define the problem, target outcome, constraints, and risks
+ - generate options and think through trade-offs before committing
+ - capture the recommended direction and open questions
+2. Plan second (mandatory):
+ - write a detailed execution plan from the chosen direction
- list final validation skills to run at the end, with order and reason
-2. Execute one planned step and produce a concrete delta.
-3. Review the result and capture findings with actionable next fixes.
-4. Apply fixes in small batches and rerun the relevant checks or review steps.
-5. Update the plan after each iteration.
-6. Repeat until outcomes are acceptable or only explicit exceptions remain.
-7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
+3. Execute one planned step and produce a concrete delta.
+4. Review the result and capture findings with actionable next fixes.
+5. Apply fixes in small batches and rerun the relevant checks or review steps.
+6. Update the plan after each iteration.
+7. Repeat until outcomes are acceptable or only explicit exceptions remain.
+8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
### Required Result Format
diff --git a/.codex/skills/mcaf-security-baseline/SKILL.md b/.codex/skills/mcaf-security-baseline/SKILL.md
index 846a067..f3e7c54 100644
--- a/.codex/skills/mcaf-security-baseline/SKILL.md
+++ b/.codex/skills/mcaf-security-baseline/SKILL.md
@@ -64,17 +64,20 @@ compatibility: "Requires repository access; may update security docs, ADRs, and
Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work.
-1. Plan first (mandatory):
+1. Brainstorm first (mandatory):
- analyze current state
- - define target outcome, constraints, and risks
- - write a detailed execution plan
+ - define the problem, target outcome, constraints, and risks
+ - generate options and think through trade-offs before committing
+ - capture the recommended direction and open questions
+2. Plan second (mandatory):
+ - write a detailed execution plan from the chosen direction
- list final validation skills to run at the end, with order and reason
-2. Execute one planned step and produce a concrete delta.
-3. Review the result and capture findings with actionable next fixes.
-4. Apply fixes in small batches and rerun the relevant checks or review steps.
-5. Update the plan after each iteration.
-6. Repeat until outcomes are acceptable or only explicit exceptions remain.
-7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
+3. Execute one planned step and produce a concrete delta.
+4. Review the result and capture findings with actionable next fixes.
+5. Apply fixes in small batches and rerun the relevant checks or review steps.
+6. Update the plan after each iteration.
+7. Repeat until outcomes are acceptable or only explicit exceptions remain.
+8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
### Required Result Format
diff --git a/.codex/skills/mcaf-solid-maintainability/SKILL.md b/.codex/skills/mcaf-solid-maintainability/SKILL.md
index 289101e..045c933 100644
--- a/.codex/skills/mcaf-solid-maintainability/SKILL.md
+++ b/.codex/skills/mcaf-solid-maintainability/SKILL.md
@@ -70,17 +70,20 @@ compatibility: "Requires repository write access; uses maintainability limits fr
Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work.
-1. Plan first (mandatory):
+1. Brainstorm first (mandatory):
- analyze current state
- - define target outcome, constraints, and risks
- - write a detailed execution plan
+ - define the problem, target outcome, constraints, and risks
+ - generate options and think through trade-offs before committing
+ - capture the recommended direction and open questions
+2. Plan second (mandatory):
+ - write a detailed execution plan from the chosen direction
- list final validation skills to run at the end, with order and reason
-2. Execute one planned step and produce a concrete delta.
-3. Review the result and capture findings with actionable next fixes.
-4. Apply fixes in small batches and rerun the relevant checks or review steps.
-5. Update the plan after each iteration.
-6. Repeat until outcomes are acceptable or only explicit exceptions remain.
-7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
+3. Execute one planned step and produce a concrete delta.
+4. Review the result and capture findings with actionable next fixes.
+5. Apply fixes in small batches and rerun the relevant checks or review steps.
+6. Update the plan after each iteration.
+7. Repeat until outcomes are acceptable or only explicit exceptions remain.
+8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
### Required Result Format
diff --git a/.codex/skills/mcaf-solution-governance/SKILL.md b/.codex/skills/mcaf-solution-governance/SKILL.md
index 47db57d..6f58fff 100644
--- a/.codex/skills/mcaf-solution-governance/SKILL.md
+++ b/.codex/skills/mcaf-solution-governance/SKILL.md
@@ -76,17 +76,20 @@ compatibility: "Requires repository write access; updates root or local `AGENTS.
Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work.
-1. Plan first (mandatory):
+1. Brainstorm first (mandatory):
- analyze current state
- - define target outcome, constraints, and risks
- - write a detailed execution plan
+ - define the problem, target outcome, constraints, and risks
+ - generate options and think through trade-offs before committing
+ - capture the recommended direction and open questions
+2. Plan second (mandatory):
+ - write a detailed execution plan from the chosen direction
- list final validation skills to run at the end, with order and reason
-2. Execute one planned step and produce a concrete delta.
-3. Review the result and capture findings with actionable next fixes.
-4. Apply fixes in small batches and rerun the relevant checks or review steps.
-5. Update the plan after each iteration.
-6. Repeat until outcomes are acceptable or only explicit exceptions remain.
-7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
+3. Execute one planned step and produce a concrete delta.
+4. Review the result and capture findings with actionable next fixes.
+5. Apply fixes in small batches and rerun the relevant checks or review steps.
+6. Update the plan after each iteration.
+7. Repeat until outcomes are acceptable or only explicit exceptions remain.
+8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
### Required Result Format
diff --git a/.codex/skills/mcaf-solution-governance/references/dotnet-agents-pattern.md b/.codex/skills/mcaf-solution-governance/references/dotnet-agents-pattern.md
index a5f1fd4..67abfff 100644
--- a/.codex/skills/mcaf-solution-governance/references/dotnet-agents-pattern.md
+++ b/.codex/skills/mcaf-solution-governance/references/dotnet-agents-pattern.md
@@ -1,6 +1,7 @@
# .NET AGENTS Pattern
Use this reference when the solution stack is .NET and the root or local `AGENTS.md` needs concrete command and tooling guidance.
+The `.NET` skill bundle itself lives in [managedcode/dotnet-skills](https://github.com/managedcode/dotnet-skills), not in this repository.
## Root AGENTS.md Expectations
@@ -27,7 +28,7 @@ Coverage command depends on the runner model:
- `coverage`: `dotnet test MySolution.sln --coverlet`
```
-In `Global Skills`, list:
+In `Global Skills`, list the installed `.NET` skills from the external repository:
- `mcaf-dotnet`
- `mcaf-dotnet-features` when modern C# feature choice matters in this repo
diff --git a/.codex/skills/mcaf-solution-governance/references/project-agents-template.md b/.codex/skills/mcaf-solution-governance/references/project-agents-template.md
index 9d5c067..363b098 100644
--- a/.codex/skills/mcaf-solution-governance/references/project-agents-template.md
+++ b/.codex/skills/mcaf-solution-governance/references/project-agents-template.md
@@ -41,7 +41,8 @@ For .NET projects also document:
- `...`
- `...`
-For .NET projects this usually includes:
+For .NET projects, install the needed `.NET` skills from [managedcode/dotnet-skills](https://github.com/managedcode/dotnet-skills).
+The local skill list usually includes:
- `mcaf-testing`
- exactly one of `mcaf-dotnet-xunit`, `mcaf-dotnet-tunit`, or `mcaf-dotnet-mstest`
diff --git a/.codex/skills/mcaf-source-control/SKILL.md b/.codex/skills/mcaf-source-control/SKILL.md
index bbcf6af..0529d9a 100644
--- a/.codex/skills/mcaf-source-control/SKILL.md
+++ b/.codex/skills/mcaf-source-control/SKILL.md
@@ -59,17 +59,20 @@ compatibility: "Requires repository access; may update contribution docs, AGENTS
Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work.
-1. Plan first (mandatory):
+1. Brainstorm first (mandatory):
- analyze current state
- - define target outcome, constraints, and risks
- - write a detailed execution plan
+ - define the problem, target outcome, constraints, and risks
+ - generate options and think through trade-offs before committing
+ - capture the recommended direction and open questions
+2. Plan second (mandatory):
+ - write a detailed execution plan from the chosen direction
- list final validation skills to run at the end, with order and reason
-2. Execute one planned step and produce a concrete delta.
-3. Review the result and capture findings with actionable next fixes.
-4. Apply fixes in small batches and rerun the relevant checks or review steps.
-5. Update the plan after each iteration.
-6. Repeat until outcomes are acceptable or only explicit exceptions remain.
-7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
+3. Execute one planned step and produce a concrete delta.
+4. Review the result and capture findings with actionable next fixes.
+5. Apply fixes in small batches and rerun the relevant checks or review steps.
+6. Update the plan after each iteration.
+7. Repeat until outcomes are acceptable or only explicit exceptions remain.
+8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
### Required Result Format
diff --git a/.codex/skills/mcaf-testing/SKILL.md b/.codex/skills/mcaf-testing/SKILL.md
index c5788fb..39a40d7 100644
--- a/.codex/skills/mcaf-testing/SKILL.md
+++ b/.codex/skills/mcaf-testing/SKILL.md
@@ -43,7 +43,7 @@ compatibility: "Requires the repository’s build and test tooling; uses command
- new or changed tests
- related suite
- broader regressions
-4. When the stack is .NET, use `mcaf-dotnet` as the orchestration skill when the task spans code, tests, and verification, and route framework mechanics through exactly one matching skill:
+4. When the stack is .NET, use the external `.NET` skills from [managedcode/dotnet-skills](https://github.com/managedcode/dotnet-skills), use `mcaf-dotnet` as the orchestration skill when the task spans code, tests, and verification, and route framework mechanics through exactly one matching skill:
- `mcaf-dotnet-xunit`
- `mcaf-dotnet-tunit`
- `mcaf-dotnet-mstest`
@@ -72,17 +72,20 @@ compatibility: "Requires the repository’s build and test tooling; uses command
Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work.
-1. Plan first (mandatory):
+1. Brainstorm first (mandatory):
- analyze current state
- - define target outcome, constraints, and risks
- - write a detailed execution plan
+ - define the problem, target outcome, constraints, and risks
+ - generate options and think through trade-offs before committing
+ - capture the recommended direction and open questions
+2. Plan second (mandatory):
+ - write a detailed execution plan from the chosen direction
- list final validation skills to run at the end, with order and reason
-2. Execute one planned step and produce a concrete delta.
-3. Review the result and capture findings with actionable next fixes.
-4. Apply fixes in small batches and rerun the relevant checks or review steps.
-5. Update the plan after each iteration.
-6. Repeat until outcomes are acceptable or only explicit exceptions remain.
-7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
+3. Execute one planned step and produce a concrete delta.
+4. Review the result and capture findings with actionable next fixes.
+5. Apply fixes in small batches and rerun the relevant checks or review steps.
+6. Update the plan after each iteration.
+7. Repeat until outcomes are acceptable or only explicit exceptions remain.
+8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
### Required Result Format
@@ -99,8 +102,8 @@ For setup-only requests with no execution, return `status: configured` and exact
- read `references/test-planning.md` first
- open `references/automated-testing.md` for deeper strategy and trade-offs
-- for broader .NET implementation flow, use `mcaf-dotnet`
-- for .NET framework-specific mechanics, use exactly one of `mcaf-dotnet-xunit`, `mcaf-dotnet-tunit`, or `mcaf-dotnet-mstest`
+- for broader .NET implementation flow, use the external `mcaf-dotnet` skill from [managedcode/dotnet-skills](https://github.com/managedcode/dotnet-skills)
+- for .NET framework-specific mechanics, use exactly one external skill from [managedcode/dotnet-skills](https://github.com/managedcode/dotnet-skills): `mcaf-dotnet-xunit`, `mcaf-dotnet-tunit`, or `mcaf-dotnet-mstest`
## Example Requests
diff --git a/.codex/skills/mcaf-ui-ux/SKILL.md b/.codex/skills/mcaf-ui-ux/SKILL.md
index d10525a..4cc7d21 100644
--- a/.codex/skills/mcaf-ui-ux/SKILL.md
+++ b/.codex/skills/mcaf-ui-ux/SKILL.md
@@ -57,17 +57,20 @@ compatibility: "Requires repository access when UI docs, component guidance, or
Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work.
-1. Plan first (mandatory):
+1. Brainstorm first (mandatory):
- analyze current state
- - define target outcome, constraints, and risks
- - write a detailed execution plan
+ - define the problem, target outcome, constraints, and risks
+ - generate options and think through trade-offs before committing
+ - capture the recommended direction and open questions
+2. Plan second (mandatory):
+ - write a detailed execution plan from the chosen direction
- list final validation skills to run at the end, with order and reason
-2. Execute one planned step and produce a concrete delta.
-3. Review the result and capture findings with actionable next fixes.
-4. Apply fixes in small batches and rerun the relevant checks or review steps.
-5. Update the plan after each iteration.
-6. Repeat until outcomes are acceptable or only explicit exceptions remain.
-7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
+3. Execute one planned step and produce a concrete delta.
+4. Review the result and capture findings with actionable next fixes.
+5. Apply fixes in small batches and rerun the relevant checks or review steps.
+6. Update the plan after each iteration.
+7. Repeat until outcomes are acceptable or only explicit exceptions remain.
+8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path.
### Required Result Format
diff --git a/.github/workflows/gh-pages-deploy.yml b/.github/workflows/gh-pages-deploy.yml
new file mode 100644
index 0000000..4c72d09
--- /dev/null
+++ b/.github/workflows/gh-pages-deploy.yml
@@ -0,0 +1,89 @@
+name: Deploy GitHub Pages
+
+on:
+ release:
+ types: [published]
+ push:
+ branches:
+ - main
+ paths:
+ - 'gh-pages/**'
+ - '.github/workflows/gh-pages-deploy.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ build:
+ name: Build Pages
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Fetch Latest Release Info
+ id: release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ release_info=$(gh api repos/${{ github.repository }}/releases/latest 2>/dev/null || echo '{}')
+
+ if [[ "$release_info" == "{}" ]] || [[ -z "$(echo "$release_info" | jq -r '.tag_name // empty')" ]]; then
+ echo "No releases found, using default values"
+ echo "version=1.0.0" >> "$GITHUB_OUTPUT"
+ echo "macos_url=https://github.com/${{ github.repository }}/releases" >> "$GITHUB_OUTPUT"
+ echo "windows_url=https://github.com/${{ github.repository }}/releases" >> "$GITHUB_OUTPUT"
+ echo "linux_url=https://github.com/${{ github.repository }}/releases" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ version=$(echo "$release_info" | jq -r '.tag_name' | sed 's/^v//')
+ echo "version=${version}" >> "$GITHUB_OUTPUT"
+
+ macos_url=$(echo "$release_info" | jq -r '.assets[] | select(.name | contains("macos")) | .browser_download_url' | head -n 1)
+ windows_url=$(echo "$release_info" | jq -r '.assets[] | select(.name | contains("windows")) | .browser_download_url' | head -n 1)
+ linux_url=$(echo "$release_info" | jq -r '.assets[] | select(.name | contains("linux")) | .browser_download_url' | head -n 1)
+
+ releases_url="https://github.com/${{ github.repository }}/releases/latest"
+ echo "macos_url=${macos_url:-$releases_url}" >> "$GITHUB_OUTPUT"
+ echo "windows_url=${windows_url:-$releases_url}" >> "$GITHUB_OUTPUT"
+ echo "linux_url=${linux_url:-$releases_url}" >> "$GITHUB_OUTPUT"
+
+ - name: Prepare Site
+ run: |
+ mkdir -p ./site
+
+ cp ./gh-pages/index.html ./site/index.html
+
+ sed -i "s|{{VERSION}}|${{ steps.release.outputs.version }}|g" ./site/index.html
+ sed -i "s|{{MACOS_URL}}|${{ steps.release.outputs.macos_url }}|g" ./site/index.html
+ sed -i "s|{{WINDOWS_URL}}|${{ steps.release.outputs.windows_url }}|g" ./site/index.html
+ sed -i "s|{{LINUX_URL}}|${{ steps.release.outputs.linux_url }}|g" ./site/index.html
+
+ - name: Setup Pages
+ uses: actions/configure-pages@v5
+
+ - name: Upload Artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: ./site
+
+ deploy:
+ name: Deploy to GitHub Pages
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ needs: build
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
index b50ac44..e559031 100644
--- a/.gitignore
+++ b/.gitignore
@@ -112,6 +112,10 @@ artifacts/
*.svclog
*.scc
+# Local planning and metrics scratch files
+CodeMetricsConfig.txt
+*.plan.md
+
# Chutzpah Test files
_Chutzpah*
diff --git a/AGENTS.md b/AGENTS.md
index cb08f64..34a8714 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -22,9 +22,6 @@ This file defines how AI agents work in this solution.
- Projects or modules with local `AGENTS.md` files:
- `DotPilot`
- `DotPilot.Core`
- - `DotPilot.Runtime`
- - `DotPilot.Runtime.Host`
- - `DotPilot.ReleaseTool`
- `DotPilot.Tests`
- `DotPilot.UITests`
- Shared solution artifacts:
@@ -143,24 +140,65 @@ For this app:
- prefer the newest stable `.NET 10` and `C#` language features that are supported by the pinned SDK and do not weaken readability, determinism, or analyzability
- the repo-root lowercase `.editorconfig` is the source of truth for formatting, naming, style, and analyzer severity
- local and CI build commands must pass `-warnaserror`; warnings are not an acceptable "green" build state in this repository
+- do not run unit tests, UI tests, or coverage commands unless the user explicitly asks for test execution in the current turn; structural refactors and exploratory work should stop at code organization until the operator requests verification
- do not run parallel `dotnet` or `MSBuild` work that shares the same checkout, target outputs, or NuGet package cache; the multi-target Uno app must build serially in CI to avoid `Uno.Resizetizer` file-lock failures
- do not commit user-specific local paths, usernames, or machine-specific identifiers in tests, docs, snapshots, or fixtures; use neutral synthetic values so the repo stays portable and does not leak personal machine details
+- keep local planning artifacts and analyzer scratch files out of git history: ignore `*.plan.md` and `CodeMetricsConfig.txt`, and do not commit or re-stage them because they are operator-local working files
- quality gates should prefer analyzer-backed build failures over separate one-off CI tools; for overloaded methods and maintainability drift, enable build-time analyzers such as `CA1502` instead of adding a formatting-only gate
- `Directory.Build.props` owns the shared analyzer and warning policy for future projects
- `Directory.Packages.props` owns centrally managed package versions
- `global.json` pins the .NET SDK and Uno SDK version used by the app and tests
- `DotPilot/DotPilot.csproj` keeps `GenerateDocumentationFile=true` with `CS1591` suppressed so `IDE0005` stays enforceable in CI across all target frameworks without inventing command-line-only build flags
+- solution folders in `DotPilot.slnx` are allowed when they provide stable project categories such as `Libraries` and `Tests`; use them to keep the IDE readable, but keep project directory names, `.csproj` names, and namespaces honest to the real extracted subsystem
+- project extraction must stay structurally honest: once a subsystem becomes its own DLL, keep its files, namespaces, local `AGENTS.md`, and direct app references inside that subsystem instead of leaving half of it behind in `DotPilot.Core`
+- when a library already names the subsystem, do not add a duplicate same-name root folder inside it; expose its real slices directly from the project root instead of nesting them again under another copy of the subsystem name
- architecture work must keep a vertical-slice shape: each feature owns its contracts, orchestration, and tests behind clear boundaries instead of growing a shared horizontal service layer
-- keep the Uno app project presentation-only; domain, runtime host, orchestration, integrations, and persistence code must live in separate class-library projects so UI composition does not mix with feature implementation
+- `DotPilot.Core` is the default home for non-UI code, but once a feature becomes large enough to deserve an architectural boundary, extract it into its own DLL instead of bloating `DotPilot.Core`
+- do not create or reintroduce generic project, folder, namespace, or product language named `Runtime` unless the user explicitly asks for that exact boundary; the default non-UI home is `DotPilot.Core`, and vague runtime naming is considered architectural noise in this repo
+- every new large feature DLL must reference `DotPilot.Core` for shared abstractions and contracts, and the desktop app should reference that feature DLL explicitly instead of dragging the feature back into the UI project
+- when a feature slice grows beyond a few files, split it into responsibility-based subfolders that mirror the slice's real concerns such as chat, drafting, providers, persistence, settings, or tests; do not leave large flat file dumps that force unrelated code to coexist in one directory
+- do not hide multiple real features under one umbrella folder such as `AgentSessions` when the code actually belongs to distinct features like `Chat`, `AgentBuilder`, `Settings`, `Providers`, or `Workspace`; use explicit feature roots and keep logs, models, services, and tests under the feature that owns them
+- inside each feature root, keep structural subfolders explicit: models go under `Models`, configuration and defaults under `Configuration` or `Composition`, views under `Views`, view-models under `ViewModels`, diagnostics under `Diagnostics`, and service/runtime types under a responsibility-specific folder; do not leave those file kinds mixed together at the feature root
+- when a feature exposes commands, public interfaces, or specialized runtime contracts, put them in visible folders such as `Commands`, `Interfaces`, and other role-specific folders; do not bury them inside a generic `Contracts` folder where their role disappears from the tree
+- keep the Uno app project presentation-only; domain, host, orchestration, integrations, and persistence code must live outside the UI project in class-library code so UI composition does not mix with feature implementation
+- UI-facing shell and application-configuration types belong in `DotPilot`; `DotPilot.Core` stays the shared non-UI contract/application layer, while any future large subsystem should move into its own DLL only when it earns a clear architectural boundary
+- UI-only preferences, keyboard behavior options, and other shell interaction settings belong in `DotPilot`, not in `DotPilot.Core`; `Core` must not own models or contracts that exist only to drive presentation behavior
- when the user asks to implement an epic, the delivery branch and PR must cover all of that epic's direct child issues that belong to the requested scope, not just one child issue with a partial close-out
- epic implementation PRs must include automated tests for every direct child issue they claim to cover, plus the broader runtime and UI regressions required by the touched flows
- do not claim an epic is implemented unless every direct child issue in the requested scope is both realized in code and covered by automated tests; partial coverage is not an acceptable close-out
- structure both `DotPilot.Tests` and `DotPilot.UITests` by vertical slice and explicit harness boundaries; do not keep test files in one flat project-root pile
-- the first embedded Orleans runtime cut must use `UseLocalhostClustering` together with in-memory Orleans grain storage and in-memory reminders; do not introduce remote clustering or external durable stores until a later backlog item explicitly requires them, and keep durable resume/replay outside Orleans storage until the cluster topology is intentionally upgraded
- GitHub is the backlog, not the product: use issues and PRs only to drive task scope and traceability, and never copy GitHub issue text, labels, workflow language, or tracker metadata into production code, runtime snapshots, or user-facing UI
- never claim an epic is complete until its current GitHub scope is verified against the live issue graph; check which issues are real children versus issues that merely depend on the epic or belong to a different parent epic
- Desktop responsiveness is a product requirement: avoid synchronous probe, filesystem, network, or process work on UI-facing construction and navigation paths so the app stays fast and immediately reactive
+- Prefer a thin desktop presentation layer over UI-owned orchestration: long-running work, background coordination, and durable session state should live in `DotPilot.Core` services and persistence boundaries, while the Uno UI mainly renders state and forwards operator commands
+- Uno controls and page code-behind must not cast `DataContext` to concrete view-model/model types or invoke orchestration methods directly; route framework events through bindable commands, attached behaviors, dependency properties, or other presentation-safe seams so the view stays decoupled from runtime logic
- Do not invent a repo-specific product framing such as "workbench" unless the active issue or feature spec explicitly uses it; implement the app features described in the backlog instead of turning internal implementation language into the product narrative
+- The primary product IA is a desktop chat client for local agents: session list, active session transcript, terminal-like streaming activity, agent management, and provider settings must be the default mental model instead of workbench, issue-tracking, domain-browser, or toolchain-center concepts
+- Agent creation must be prompt-first: the default operator flow is to describe the desired agent in natural language and let the product generate a draft agent definition, prompt, description, and tool set instead of forcing low-level manual configuration first
+- When the product creates or configures agents, workflows, or similar runtime assets from operator intent, route that intent through a built-in system agent or equivalent orchestration tool that understands the available providers, tools, and policies instead of scattering that decision logic across UI forms
+- The product must always have a sensible default system agent path: a fresh app state should not leave the operator without an obvious usable agent, and runtime defaults should pick an available provider/model combination or degrade to the deterministic debug agent without blocking the UI
+- The deterministic debug provider is an internal fallback, not an operator-facing authoring choice: do not surface it as a selectable provider or suggested model in the `New agent` creation flow; if no real provider is enabled or installed, send the operator to provider settings instead of defaulting the form to debug
+- Do not invent agent roles, tool catalogs, skill catalogs, or capability tags in code or UI unless the product has a real backing registry and runtime path for them; absent a real implementation, leave those selections out of the product surface.
+- User-facing UI must not expose backlog numbers, issue labels, workstream labels, "workbench", "domain", or similar internal planning and architecture language unless a feature explicitly exists to show source-control metadata
+- Provider integrations should stay SDK-first: when Codex, Claude Code, GitHub Copilot, or debug/test providers already expose an `IChatClient`-style abstraction, build agent orchestration on top of that instead of inventing parallel request/result wrappers without a clear gap
+- Do not leave Uno binding on reflection fallback: when the shell binds to view models or option models, annotate or shape those types so the generated metadata provider can resolve them without runtime reflection warnings or performance loss
+- Persist app models and durable session state through `SQLite` plus `EF Core` when the data must survive restarts; do not keep the core chat/session experience trapped in seed data or transient in-memory catalogs
+- When agent conversations must survive restarts, persist the full `AgentSession` plus chat history through an Agent Framework history/storage provider backed by a local desktop folder; do not reduce durable conversation state to transcript text rows only
+- Do not add cache layers for provider CLIs, model catalogs, workspace projections, or similar environment state unless the user explicitly asks for caching; prefer direct reads from the current source of truth
+- Current repository policy is stricter than the default: do not keep provider-status caches, workspace/session in-memory mirrors, or app-preference caches at all unless the user explicitly asks to bring a specific cache back
+- The current explicit exception is startup readiness hydration: the app may show a splash/loading state at launch, probe installed provider CLIs and related metadata once during that startup window, and then reuse that startup snapshot until an explicit refresh or provider-setting change invalidates it
+- Provider CLI probing must not rerun as a side effect of ordinary screen binding or MVUX state re-evaluation; normal shell loads should share one in-flight probe and only reprobe on explicit refresh or provider-setting changes
+- Expected cancellation from state re-evaluation or navigation must not be logged as a product failure; reserve failure logs for real errors, not superseded async loads
+- Runtime and orchestration flows must emit structured `ILogger` logs for provider readiness, agent creation, session creation, send execution, and failure paths; ad hoc console-only startup traces are not enough to debug the product
+- When the runtime uses `Microsoft Agent Framework`, prefer agent or run-scope middleware for detailed lifecycle logging and correlation instead of scattering ad hoc logging around UI callbacks or provider shims
+- UI-facing view models must stay projection-only: do not keep orchestration, provider probing, session loading pipelines, or other runtime coordination in the Uno presentation layer when the same work can live in `DotPilot.Core` services
+- Desktop navigation and tab/menu switching must stay structurally simple: do not introduce background refresh loops or in-memory cache projections as a default optimization path
+- Agent-management UX must be proven end-to-end: prompt-first creation, default-agent availability, provider enable/disable or readiness changes, and starting a chat with an agent all require real `DotPilot.UITests` coverage instead of unit-only verification
+- The desktop app is one shell, not three unrelated page layouts: keep one stable left navigation rail/app chrome across chat, agents, and providers, and switch the main content state instead of rebuilding different sidebars per screen
+- Shell geometry must stay visually stable across primary screens: do not let the left rail width, footer block, nav button sizing, or page chrome jump between `Chat`, `Agents`, and `Providers`
+- Do not fill the shell with duplicated explanatory copy or hardcoded decorative text blocks that restate the same information in multiple panes; prefer concise, task-oriented labels and let the main content carry the active workflow
+- Do not ship placeholder-looking UI chrome such as ASCII pseudo-icons, swollen capsule buttons, or duplicated provider/agent cards just to make state visible; use consistent desktop controls and one clear source of truth per surface
+- Do not keep legacy product slices alive during a rewrite: when `Workbench`, `ToolchainCenter`, legacy runtime demos, or similar prototype surfaces are being replaced, remove them instead of leaving a parallel legacy path in the codebase
- GitHub Actions workflows must use descriptive names and filenames that reflect their purpose; do not use a generic `ci.yml` catch-all because build validation and release automation are separate operator flows
- GitHub Actions must be split into at least one validation workflow for normal builds/tests and one release workflow for CI-driven version resolution, release-note generation, desktop publishing, and GitHub Release publication
- meaningful GitHub review comments must be evaluated and fixed when they still apply even if the original PR was closed; closed review threads are not a reason to ignore valid engineering feedback
@@ -174,6 +212,7 @@ For this app:
- prefer MIT-licensed GitHub and NuGet dependencies when they materially accelerate delivery and align with the current architecture
- prefer official `.NET` AI evaluation libraries under `Microsoft.Extensions.AI.Evaluation*` for response-quality, tool-usage, and safety evaluation instead of custom or third-party evaluation stacks by default
- prefer `Microsoft Agent Framework` telemetry and observability patterns with OpenTelemetry-first instrumentation and optional Azure Monitor or Foundry export later
+- Treat the built-in MCP server as the canonical capability surface of `dotPilot`: operator-visible actions and automations should be exposed as properly defined MCP tools on the app-owned server, and agents must discover and invoke those tools through the shared MCP gateway instead of bypassing it with ad hoc internal calls
### Project AGENTS Policy
@@ -308,6 +347,7 @@ Local `AGENTS.md` files may tighten these values, but they must not loosen them
- Every class, object, module, and service MUST have a clear single responsibility and explicit boundaries.
- SOLID is mandatory.
- SRP and strong cohesion are mandatory for files, types, and functions.
+- project structure must also follow SOLID and cohesion rules: keep related code together, keep unrelated responsibilities apart, and introduce a project boundary when a feature has become too large or too independent to stay clean inside `DotPilot.Core`
- Prefer composition over inheritance unless inheritance is explicitly justified.
- Large files, types, functions, and deep nesting are design smells. Split them or document a justified exception under `exception_policy`.
- Hardcoded values are forbidden.
@@ -348,9 +388,11 @@ Ask first:
### Likes
+- Keep regression coverage tied to real operator flows: when agent creation changes, tests should cover creating an agent, choosing a valid provider model, and sending at least one message through the resulting session path.
- Follow the canonical MCAF tutorial when bootstrapping or upgrading the agent workflow.
- Commit cohesive code-change batches promptly while debugging, especially before switching focus or starting long verification runs, so the branch state stays inspectable and pushable.
- After opening or updating a PR, create a fresh working branch before continuing with the next slice of work so follow-up changes do not pile onto the already-reviewed branch.
+- When one requested slice is complete and verified, commit it before switching to the next GitHub issue so each backlog step stays isolated and reviewable.
- Keep `DotPilot` feeling like a fast desktop control plane: startup, navigation, and visible UI reactions should be prompt, and agents should remove unnecessary waits instead of normalizing slow web-style loading behavior.
- Keep the root `AGENTS.md` at the repository root.
- Keep the repo-local agent skill directory limited to current `mcaf-*` skills.
@@ -363,7 +405,7 @@ Ask first:
- Keep validation and release GitHub Actions separate, with descriptive names and filenames instead of a generic `ci.yml`.
- Keep the validation workflow focused on build and automated test feedback, and keep release responsibilities in a dedicated workflow that bumps versioning, publishes desktop artifacts, and creates the GitHub Release with feature notes.
- Keep `dotPilot` positioned as a general agent control plane, not a coding-only shell.
-- Reuse the current Uno desktop shell direction instead of replacing it with a wholly different layout when evolving the product.
+- Keep the visible product direction aligned with desktop chat apps such as Codex and Claude: sessions first, chat first, streaming first, with repo and git actions as optional utilities inside a session instead of the primary navigation model.
- Keep provider integrations SDK-first where good typed SDKs already exist.
- Keep evaluation and observability aligned with official Microsoft `.NET` AI guidance when building agent-quality and trust features.
@@ -378,3 +420,5 @@ Ask first:
- Switching desktop Uno pages into stacked or mobile-style responsive layouts during resize work unless the user explicitly asks for a different composition; desktop pages must stay desktop-first and protect geometry through sizing constraints instead.
- Adding extra UI-test orchestration complexity when the actual goal is simply to run the tests and get an honest pass or fail result.
- Planning `MLXSharp` into the first product wave before it is ready for real use.
+- Letting internal implementation labels such as `Workbench`, issue numbers, or architecture slice names leak into the visible product wording or navigation when the app should behave like a clean desktop chat client.
+- Leaving deprecated product slices, pages, view models, or contracts in place "for later cleanup" after the replacement direction is already chosen.
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 3a8467b..9c70724 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -11,18 +11,24 @@
+
+
-
-
-
+
+
+
+
+
+
+
-
+
\ No newline at end of file
diff --git a/DotPilot.Core/AGENTS.md b/DotPilot.Core/AGENTS.md
index 668f8e6..987b72f 100644
--- a/DotPilot.Core/AGENTS.md
+++ b/DotPilot.Core/AGENTS.md
@@ -1,32 +1,54 @@
# AGENTS.md
Project: `DotPilot.Core`
-Stack: `.NET 10`, class library, feature-aligned contracts and provider-independent runtime foundations
+Stack: `.NET 10`, class library, non-UI contracts, orchestration, persistence, and shared application/domain code
## Purpose
-- This project owns non-UI contracts, typed identifiers, and feature slices that must stay independent from the Uno presentation host.
-- It provides the stable public shapes for runtime, orchestration, providers, and shell configuration so UI and future runtime implementations can evolve without circular coupling.
+- This project is the default non-UI home and must stay independent from the Uno presentation host.
+- It owns shared application/domain code for agent-centric features until a slice becomes big enough to earn its own DLL.
## Entry Points
- `DotPilot.Core.csproj`
-- `Features/ApplicationShell/AppConfig.cs`
-- `Features/ControlPlaneDomain/*`
-- `Features/RuntimeCommunication/*`
-- `Features/RuntimeFoundation/*`
+- `AgentBuilder/{Configuration,Models,Services}/*`
+- `ChatSessions/{Commands,Configuration,Contracts,Diagnostics,Execution,Interfaces,Models,Persistence}/*`
+- `ControlPlaneDomain/{Identifiers,Contracts,Models,Policies}/*`
+- `HttpDiagnostics/DebugHttpHandler.cs`
+- `Providers/{Configuration,Infrastructure,Interfaces,Models,Services}/*`
+- `Workspace/{Interfaces,Services}/*`
## Boundaries
- Keep this project free of `Uno Platform`, XAML, brushes, and page/view-model concerns.
+- Keep app-shell, app-host, and application-configuration types out of this project; those belong in `DotPilot`.
+- Do not keep UI-only preference models or shell interaction settings here; if a setting exists only to control presentation behavior such as composer key handling, it belongs in `DotPilot`.
+- Do not add cache-specific abstractions, services, or snapshot layers here by default; `DotPilot.Core` should read from the real source of truth unless the user explicitly asks for a cache boundary.
+- Keep provider readiness, workspace/session projections, and similar environment state uncached by default; do not add in-memory mirrors or snapshot caches unless the user explicitly asks for that tradeoff.
+- Do not introduce fabricated role enums, hardcoded tool catalogs, skill catalogs, or encoded capability tags for agents unless the product has a real backing registry and runtime implementation for them.
- Organize code by vertical feature slice, not by shared horizontal folders such as generic `Services` or `Helpers`.
-- Prefer stable contracts, typed identifiers, and public interfaces here; concrete runtime integrations can live in separate libraries.
+- `DotPilot.Core` is the default non-UI home, not a permanent dumping ground: when a feature becomes large enough to justify its own architectural boundary, extract it into a dedicated DLL that references `DotPilot.Core`
+- do not introduce a generic `Runtime` naming layer inside this project or split code out into a vaguely named runtime assembly unless the user explicitly asks for that boundary; keep non-UI logic in explicit feature slices under `DotPilot.Core`
+- when a feature is extracted out of `DotPilot.Core`, keep `DotPilot.Core` as the abstraction/shared-contract layer and make the desktop app reference the new feature DLL explicitly
+- do not leave extracted subsystem contracts half-inside `DotPilot.Core`; when a future subsystem is split into its own DLL, its feature-facing interfaces and implementation seams should move with it
+- keep feature-specific heavy infrastructure out of this project once it becomes its own subsystem; `DotPilot.Core` should stay cohesive instead of half-owning an extracted runtime
+- Do not collect unrelated code under an umbrella directory such as `AgentSessions`; split session, workspace, settings, providers, and host code into explicit feature roots when the surface grows.
+- Keep `ControlPlaneDomain` explicit too: identifiers belong under `Identifiers`, participant/provider/session DTOs under `Contracts`, cross-flow state under `Models`, and policy shapes under `Policies` instead of leaving one flat dump.
+- Keep contract-centric slices explicit inside each feature root: commands live under `Commands`, public DTO shapes live under `Contracts`, public service seams live under `Interfaces`, state records or enums live under `Models`, diagnostics under `Diagnostics`, and persistence under `Persistence`.
+- When a slice exposes `Commands` and `Results`, use the solution-standard `ManagedCode.Communication` primitives instead of hand-rolled command/result record types.
+- Keep the top level readable as two kinds of folders:
+ - shared/domain folders such as `ControlPlaneDomain` and `Workspace`
+ - operational/system folders such as `AgentBuilder`, `ChatSessions`, `Providers`, and `HttpDiagnostics`
+- keep this structure SOLID at the folder and project level too: cohesive feature slices stay together, but once a slice becomes too large or too independent, it should graduate into its own project instead of turning `DotPilot.Core` into mud
- Keep provider-independent testing seams real and deterministic so CI can validate core flows without external CLIs.
+- Keep provider readiness probing explicit and coalesced: ordinary workspace reads may share one in-flight CLI probe, but normal navigation must not fan out into repeated PATH/version probing loops.
+- The approved caching exception in this project is startup readiness hydration: Core may keep one startup-owned provider/CLI snapshot after the initial splash-time probe, but it must invalidate that snapshot on explicit refresh or provider preference changes instead of drifting into a long-lived opaque cache layer.
+- Treat superseded async loads as cancellation, not failure; Core services should not emit error-level noise for expected state invalidation or navigation churn.
## Local Commands
- `build-core`: `dotnet build DotPilot.Core/DotPilot.Core.csproj`
-- `test-core`: `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter FullyQualifiedName~RuntimeFoundation`
+- `test-core`: `dotnet test DotPilot.Tests/DotPilot.Tests.csproj`
## Applicable Skills
@@ -38,5 +60,5 @@ Stack: `.NET 10`, class library, feature-aligned contracts and provider-independ
## Local Risks Or Protected Areas
-- These contracts will become shared dependencies across future slices, so naming drift or unclear boundaries will amplify quickly.
-- Avoid baking provider-specific assumptions into the core runtime contracts unless an ADR or feature spec explicitly requires them.
+- This project is now the full non-UI stack, so naming drift or folder chaos will spread quickly across contracts, providers, persistence, and runtime logic.
+- Avoid baking UI assumptions into this project, and avoid baking low-level CLI-process details into the contract-facing folders when provider/session terms are enough.
diff --git a/DotPilot.Core/AgentBuilder/Configuration/AgentSessionDefaults.cs b/DotPilot.Core/AgentBuilder/Configuration/AgentSessionDefaults.cs
new file mode 100644
index 0000000..22440d5
--- /dev/null
+++ b/DotPilot.Core/AgentBuilder/Configuration/AgentSessionDefaults.cs
@@ -0,0 +1,76 @@
+using System.Globalization;
+using System.Text;
+using DotPilot.Core.Providers;
+
+namespace DotPilot.Core.AgentBuilder;
+
+public static class AgentSessionDefaults
+{
+ public const string SystemAgentName = "dotPilot System Agent";
+ public const string SystemAgentDescription = "Built-in local agent for desktop chat, prompt-to-agent drafting, and deterministic fallback workflows.";
+
+ private const string MissionPrefix = "Mission:";
+ private const string StructuredPromptClosingInstruction =
+ "Be explicit about actions and keep the operator informed.";
+
+ public static string SystemAgentPrompt { get; } =
+ CreateStructuredSystemPrompt(
+ SystemAgentName,
+ SystemAgentDescription);
+
+ public static bool IsSystemAgent(string agentName)
+ {
+ return string.Equals(agentName, SystemAgentName, StringComparison.Ordinal);
+ }
+
+ public static string GetDefaultModel(AgentProviderKind kind)
+ {
+ return AgentSessionProviderCatalog.Get(kind).DefaultModelName;
+ }
+
+ public static string CreateAgentDescription(string systemPrompt)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(systemPrompt);
+
+ foreach (var line in systemPrompt.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ if (line.StartsWith(MissionPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ return line[MissionPrefix.Length..].Trim();
+ }
+ }
+
+ var normalized = string.Join(
+ ' ',
+ systemPrompt
+ .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
+
+ var sentenceEndIndex = normalized.IndexOf('.', StringComparison.Ordinal);
+ if (sentenceEndIndex <= 0)
+ {
+ return normalized;
+ }
+
+ return normalized[..(sentenceEndIndex + 1)];
+ }
+
+ public static string CreateStructuredSystemPrompt(
+ string name,
+ string description)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+ ArgumentException.ThrowIfNullOrWhiteSpace(description);
+
+ var normalizedDescription = description.Trim();
+ if (!normalizedDescription.EndsWith('.'))
+ {
+ normalizedDescription += ".";
+ }
+
+ StringBuilder builder = new();
+ builder.AppendLine(CultureInfo.InvariantCulture, $"You are {name}.");
+ builder.AppendLine(CultureInfo.InvariantCulture, $"{MissionPrefix} {normalizedDescription}");
+ builder.Append(StructuredPromptClosingInstruction);
+ return builder.ToString().Trim();
+ }
+}
diff --git a/DotPilot.Core/AgentBuilder/Models/AgentPromptDraft.cs b/DotPilot.Core/AgentBuilder/Models/AgentPromptDraft.cs
new file mode 100644
index 0000000..e8b7de7
--- /dev/null
+++ b/DotPilot.Core/AgentBuilder/Models/AgentPromptDraft.cs
@@ -0,0 +1,10 @@
+
+namespace DotPilot.Core.AgentBuilder;
+
+public sealed record AgentPromptDraft(
+ string Prompt,
+ string Name,
+ string Description,
+ AgentProviderKind ProviderKind,
+ string ModelName,
+ string SystemPrompt);
diff --git a/DotPilot.Core/AgentBuilder/Services/AgentPromptDraftGenerator.cs b/DotPilot.Core/AgentBuilder/Services/AgentPromptDraftGenerator.cs
new file mode 100644
index 0000000..bed989c
--- /dev/null
+++ b/DotPilot.Core/AgentBuilder/Services/AgentPromptDraftGenerator.cs
@@ -0,0 +1,239 @@
+using System.Globalization;
+using DotPilot.Core.Providers;
+using Microsoft.Extensions.Logging;
+
+namespace DotPilot.Core.AgentBuilder;
+
+public sealed class AgentPromptDraftGenerator(
+ IAgentProviderStatusReader providerStatusReader,
+ ILogger logger)
+{
+ private const string ManualPrompt = "Build manually";
+ private const string ManualAgentName = "New agent";
+ private const string ManualDescription = "Local desktop assistant for reusable chat and workflow sessions.";
+ private const string PromptPrefixCreate = "create ";
+ private const string PromptPrefixBuild = "build ";
+ private const string PromptPrefixMake = "make ";
+ private const string PromptPrefixIWant = "i want ";
+ private static readonly string[] PromptPrefixes =
+ [
+ PromptPrefixCreate,
+ PromptPrefixBuild,
+ PromptPrefixMake,
+ PromptPrefixIWant,
+ ];
+ private static readonly char[] NameSplitCharacters =
+ [
+ ' ',
+ ',',
+ '.',
+ ';',
+ ':',
+ '-',
+ '/',
+ '\\',
+ ];
+ private static readonly string[] NoiseTerms =
+ [
+ "an",
+ "a",
+ "agent",
+ "assistant",
+ "that",
+ "which",
+ "who",
+ "for",
+ "to",
+ "with",
+ "the",
+ "and",
+ "can",
+ ];
+
+ public async ValueTask CreateManualDraftAsync(CancellationToken cancellationToken)
+ {
+ var provider = await ResolvePreferredProviderAsync(ManualPrompt, cancellationToken);
+ var draft = new AgentPromptDraft(
+ ManualPrompt,
+ ManualAgentName,
+ ManualDescription,
+ provider.Kind,
+ provider.SuggestedModelName,
+ CreateSystemPrompt(ManualAgentName, ManualDescription));
+
+ AgentPromptDraftGeneratorLog.ManualDraftCreated(logger, draft.ProviderKind, draft.ModelName);
+ return draft;
+ }
+
+ public async ValueTask GenerateAsync(string prompt, CancellationToken cancellationToken)
+ {
+ var normalizedPrompt = NormalizePrompt(prompt);
+ var provider = await ResolvePreferredProviderAsync(normalizedPrompt, cancellationToken);
+ var description = CreateDescription(normalizedPrompt);
+ var name = CreateName(normalizedPrompt);
+ var draft = new AgentPromptDraft(
+ normalizedPrompt,
+ name,
+ description,
+ provider.Kind,
+ provider.SuggestedModelName,
+ CreateSystemPrompt(name, description));
+
+ AgentPromptDraftGeneratorLog.GeneratedDraft(
+ logger,
+ draft.Name,
+ draft.ProviderKind,
+ draft.ModelName);
+ return draft;
+ }
+
+ private async ValueTask ResolvePreferredProviderAsync(string prompt, CancellationToken cancellationToken)
+ {
+ var providers = await providerStatusReader.ReadAsync(cancellationToken);
+ var creatableProviders = providers
+ .Where(static provider => provider.CanCreateAgents)
+ .ToDictionary(static provider => provider.Kind);
+
+ foreach (var candidate in ResolveProviderPreferences(prompt))
+ {
+ if (creatableProviders.TryGetValue(candidate, out var provider))
+ {
+ return provider;
+ }
+ }
+
+ return providers.FirstOrDefault(static provider => provider.Kind == AgentProviderKind.Debug)
+ ?? new ProviderStatusDescriptor(
+ AgentSessionDeterministicIdentity.CreateProviderId("debug"),
+ AgentProviderKind.Debug,
+ "Debug Provider",
+ "debug",
+ AgentProviderStatus.Disabled,
+ "Provider is disabled for local agent creation.",
+ AgentSessionDefaults.GetDefaultModel(AgentProviderKind.Debug),
+ [AgentSessionDefaults.GetDefaultModel(AgentProviderKind.Debug)],
+ AgentSessionDefaults.GetDefaultModel(AgentProviderKind.Debug),
+ false,
+ false,
+ [],
+ []);
+ }
+
+ private static IEnumerable ResolveProviderPreferences(string prompt)
+ {
+ if (ContainsAny(prompt, "review", "pull request", "repository", "repo", "code", "commit", "branch", "diff"))
+ {
+ yield return AgentProviderKind.Codex;
+ yield return AgentProviderKind.GitHubCopilot;
+ yield return AgentProviderKind.ClaudeCode;
+ yield return AgentProviderKind.Debug;
+ yield break;
+ }
+
+ if (ContainsAny(prompt, "research", "search", "summarize", "summary", "writing", "content", "docs", "analysis"))
+ {
+ yield return AgentProviderKind.ClaudeCode;
+ yield return AgentProviderKind.Codex;
+ yield return AgentProviderKind.GitHubCopilot;
+ yield return AgentProviderKind.Debug;
+ yield break;
+ }
+
+ yield return AgentProviderKind.Codex;
+ yield return AgentProviderKind.ClaudeCode;
+ yield return AgentProviderKind.GitHubCopilot;
+ yield return AgentProviderKind.Debug;
+ }
+
+ private static string NormalizePrompt(string prompt)
+ {
+ var normalized = string.Join(
+ ' ',
+ (prompt ?? string.Empty)
+ .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
+
+ if (string.IsNullOrWhiteSpace(normalized))
+ {
+ throw new InvalidOperationException("Describe the agent you want before generating a draft.");
+ }
+
+ return normalized;
+ }
+
+ private static string CreateDescription(string prompt)
+ {
+ var normalized = prompt.Trim();
+ if (!normalized.EndsWith('.'))
+ {
+ normalized += ".";
+ }
+
+ return normalized;
+ }
+
+ private static string CreateName(string prompt)
+ {
+ var normalized = prompt.Trim();
+ foreach (var prefix in PromptPrefixes)
+ {
+ if (normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ {
+ normalized = normalized[prefix.Length..];
+ break;
+ }
+ }
+
+ var nameWords = normalized
+ .Split(NameSplitCharacters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Where(word => !NoiseTerms.Contains(word, StringComparer.OrdinalIgnoreCase))
+ .Take(3)
+ .Select(ToTitleCase)
+ .ToArray();
+
+ if (nameWords.Length == 0)
+ {
+ return ManualAgentName;
+ }
+
+ var baseName = string.Join(" ", nameWords);
+ return baseName.EndsWith("Agent", StringComparison.Ordinal)
+ ? baseName
+ : string.Create(CultureInfo.InvariantCulture, $"{baseName} Agent");
+ }
+
+ private static string CreateSystemPrompt(
+ string name,
+ string description)
+ {
+ return AgentSessionDefaults.CreateStructuredSystemPrompt(name, description);
+ }
+
+ private static bool ContainsAny(string value, params string[] candidates)
+ {
+ return candidates.Any(candidate => value.Contains(candidate, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static string ToTitleCase(string value)
+ {
+ return CultureInfo.InvariantCulture.TextInfo.ToTitleCase(value.ToLowerInvariant());
+ }
+}
+
+internal static partial class AgentPromptDraftGeneratorLog
+{
+ [LoggerMessage(
+ EventId = 1400,
+ Level = LogLevel.Information,
+ Message = "Created manual agent draft. Provider={ProviderKind} Model={ModelName}.")]
+ public static partial void ManualDraftCreated(ILogger logger, AgentProviderKind providerKind, string modelName);
+
+ [LoggerMessage(
+ EventId = 1401,
+ Level = LogLevel.Information,
+ Message = "Generated prompt-based agent draft. Name={AgentName} Provider={ProviderKind} Model={ModelName}.")]
+ public static partial void GeneratedDraft(
+ ILogger logger,
+ string agentName,
+ AgentProviderKind providerKind,
+ string modelName);
+}
diff --git a/DotPilot.Core/ChatSessions/Commands/CreateAgentProfileCommand.cs b/DotPilot.Core/ChatSessions/Commands/CreateAgentProfileCommand.cs
new file mode 100644
index 0000000..da0c42c
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Commands/CreateAgentProfileCommand.cs
@@ -0,0 +1,43 @@
+using ManagedCode.Communication.Commands;
+
+namespace DotPilot.Core.ChatSessions.Commands;
+
+public sealed class CreateAgentProfileCommand : Command
+{
+ private readonly Payload payload;
+
+ public CreateAgentProfileCommand(
+ string name,
+ AgentProviderKind providerKind,
+ string modelName,
+ string systemPrompt,
+ string description = "")
+ : this(new Payload(name, providerKind, modelName, systemPrompt, description))
+ {
+ }
+
+ private CreateAgentProfileCommand(Payload payload)
+ : base(Guid.CreateVersion7(), nameof(CreateAgentProfileCommand), payload)
+ {
+ this.payload = payload;
+ Value = payload;
+ }
+
+ public string Name => payload.Name;
+
+ public AgentProviderKind ProviderKind => payload.ProviderKind;
+
+ public string ModelName => payload.ModelName;
+
+ public string SystemPrompt => payload.SystemPrompt;
+
+ public string Description => payload.Description;
+
+ [GenerateSerializer]
+ public sealed record Payload(
+ [property: Id(0)] string Name,
+ [property: Id(1)] AgentProviderKind ProviderKind,
+ [property: Id(2)] string ModelName,
+ [property: Id(3)] string SystemPrompt,
+ [property: Id(4)] string Description);
+}
diff --git a/DotPilot.Core/ChatSessions/Commands/CreateSessionCommand.cs b/DotPilot.Core/ChatSessions/Commands/CreateSessionCommand.cs
new file mode 100644
index 0000000..f342ed8
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Commands/CreateSessionCommand.cs
@@ -0,0 +1,32 @@
+using DotPilot.Core.ControlPlaneDomain;
+using ManagedCode.Communication.Commands;
+
+namespace DotPilot.Core.ChatSessions.Commands;
+
+public sealed class CreateSessionCommand : Command
+{
+ private readonly Payload payload;
+
+ public CreateSessionCommand(
+ string title,
+ AgentProfileId agentProfileId)
+ : this(new Payload(title, agentProfileId))
+ {
+ }
+
+ private CreateSessionCommand(Payload payload)
+ : base(Guid.CreateVersion7(), nameof(CreateSessionCommand), payload)
+ {
+ this.payload = payload;
+ Value = payload;
+ }
+
+ public string Title => payload.Title;
+
+ public AgentProfileId AgentProfileId => payload.AgentProfileId;
+
+ [GenerateSerializer]
+ public sealed record Payload(
+ [property: Id(0)] string Title,
+ [property: Id(1)] AgentProfileId AgentProfileId);
+}
diff --git a/DotPilot.Core/ChatSessions/Commands/SendSessionMessageCommand.cs b/DotPilot.Core/ChatSessions/Commands/SendSessionMessageCommand.cs
new file mode 100644
index 0000000..b6ef9d4
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Commands/SendSessionMessageCommand.cs
@@ -0,0 +1,34 @@
+using System.Globalization;
+using DotPilot.Core.ControlPlaneDomain;
+using ManagedCode.Communication.Commands;
+
+namespace DotPilot.Core.ChatSessions.Commands;
+
+public sealed class SendSessionMessageCommand : Command
+{
+ private readonly Payload payload;
+
+ public SendSessionMessageCommand(
+ SessionId sessionId,
+ string message)
+ : this(new Payload(sessionId, message))
+ {
+ }
+
+ private SendSessionMessageCommand(Payload payload)
+ : base(Guid.CreateVersion7(), nameof(SendSessionMessageCommand), payload)
+ {
+ this.payload = payload;
+ Value = payload;
+ base.SessionId = payload.SessionId.Value.ToString("N", CultureInfo.InvariantCulture);
+ }
+
+ public new SessionId SessionId => payload.SessionId;
+
+ public string Message => payload.Message;
+
+ [GenerateSerializer]
+ public sealed record Payload(
+ [property: Id(0)] SessionId SessionId,
+ [property: Id(1)] string Message);
+}
diff --git a/DotPilot.Core/ChatSessions/Commands/UpdateAgentProfileCommand.cs b/DotPilot.Core/ChatSessions/Commands/UpdateAgentProfileCommand.cs
new file mode 100644
index 0000000..a38f8a2
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Commands/UpdateAgentProfileCommand.cs
@@ -0,0 +1,48 @@
+using DotPilot.Core.ControlPlaneDomain;
+using ManagedCode.Communication.Commands;
+
+namespace DotPilot.Core.ChatSessions.Commands;
+
+public sealed class UpdateAgentProfileCommand : Command
+{
+ private readonly Payload payload;
+
+ public UpdateAgentProfileCommand(
+ AgentProfileId agentId,
+ string name,
+ AgentProviderKind providerKind,
+ string modelName,
+ string systemPrompt,
+ string description = "")
+ : this(new Payload(agentId, name, providerKind, modelName, systemPrompt, description))
+ {
+ }
+
+ private UpdateAgentProfileCommand(Payload payload)
+ : base(Guid.CreateVersion7(), nameof(UpdateAgentProfileCommand), payload)
+ {
+ this.payload = payload;
+ Value = payload;
+ }
+
+ public AgentProfileId AgentId => payload.AgentId;
+
+ public string Name => payload.Name;
+
+ public AgentProviderKind ProviderKind => payload.ProviderKind;
+
+ public string ModelName => payload.ModelName;
+
+ public string SystemPrompt => payload.SystemPrompt;
+
+ public string Description => payload.Description;
+
+ [GenerateSerializer]
+ public sealed record Payload(
+ [property: Id(0)] AgentProfileId AgentId,
+ [property: Id(1)] string Name,
+ [property: Id(2)] AgentProviderKind ProviderKind,
+ [property: Id(3)] string ModelName,
+ [property: Id(4)] string SystemPrompt,
+ [property: Id(5)] string Description);
+}
diff --git a/DotPilot.Core/ChatSessions/Commands/UpdateProviderPreferenceCommand.cs b/DotPilot.Core/ChatSessions/Commands/UpdateProviderPreferenceCommand.cs
new file mode 100644
index 0000000..488d529
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Commands/UpdateProviderPreferenceCommand.cs
@@ -0,0 +1,31 @@
+using ManagedCode.Communication.Commands;
+
+namespace DotPilot.Core.ChatSessions.Commands;
+
+public sealed class UpdateProviderPreferenceCommand : Command
+{
+ private readonly Payload payload;
+
+ public UpdateProviderPreferenceCommand(
+ AgentProviderKind providerKind,
+ bool isEnabled)
+ : this(new Payload(providerKind, isEnabled))
+ {
+ }
+
+ private UpdateProviderPreferenceCommand(Payload payload)
+ : base(Guid.CreateVersion7(), nameof(UpdateProviderPreferenceCommand), payload)
+ {
+ this.payload = payload;
+ Value = payload;
+ }
+
+ public AgentProviderKind ProviderKind => payload.ProviderKind;
+
+ public bool IsEnabled => payload.IsEnabled;
+
+ [GenerateSerializer]
+ public sealed record Payload(
+ [property: Id(0)] AgentProviderKind ProviderKind,
+ [property: Id(1)] bool IsEnabled);
+}
diff --git a/DotPilot.Core/ChatSessions/Configuration/AgentSessionServiceCollectionExtensions.cs b/DotPilot.Core/ChatSessions/Configuration/AgentSessionServiceCollectionExtensions.cs
new file mode 100644
index 0000000..8fcba6c
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Configuration/AgentSessionServiceCollectionExtensions.cs
@@ -0,0 +1,51 @@
+using DotPilot.Core.AgentBuilder;
+using DotPilot.Core.Providers;
+using DotPilot.Core.Workspace;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DotPilot.Core.ChatSessions;
+
+public static class AgentSessionServiceCollectionExtensions
+{
+ public static IServiceCollection AddAgentSessions(
+ this IServiceCollection services,
+ AgentSessionStorageOptions? storageOptions = null)
+ {
+ services.AddLogging();
+ services.AddSingleton(storageOptions ?? new AgentSessionStorageOptions());
+ services.AddDbContextFactory(ConfigureDbContext);
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ return services;
+ }
+
+ private static void ConfigureDbContext(IServiceProvider serviceProvider, DbContextOptionsBuilder builder)
+ {
+ var storageOptions = serviceProvider.GetRequiredService();
+
+ if (OperatingSystem.IsBrowser() || storageOptions.UseInMemoryDatabase)
+ {
+ builder.UseInMemoryDatabase(storageOptions.InMemoryDatabaseName);
+ return;
+ }
+
+ var databasePath = AgentSessionStoragePaths.ResolveDatabasePath(storageOptions);
+ var databaseDirectory = Path.GetDirectoryName(databasePath);
+ if (!string.IsNullOrWhiteSpace(databaseDirectory))
+ {
+ Directory.CreateDirectory(databaseDirectory);
+ }
+
+ builder.UseSqlite($"Data Source={databasePath}");
+ }
+}
diff --git a/DotPilot.Core/ChatSessions/Contracts/AgentSessionContracts.cs b/DotPilot.Core/ChatSessions/Contracts/AgentSessionContracts.cs
new file mode 100644
index 0000000..1d91973
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Contracts/AgentSessionContracts.cs
@@ -0,0 +1,68 @@
+using DotPilot.Core.ControlPlaneDomain;
+
+namespace DotPilot.Core.ChatSessions.Contracts;
+
+public sealed record ProviderActionDescriptor(
+ string Label,
+ string Summary,
+ string Command);
+
+public sealed record ProviderDetailDescriptor(
+ string Label,
+ string Value);
+
+public sealed record ProviderStatusDescriptor(
+ ProviderId Id,
+ AgentProviderKind Kind,
+ string DisplayName,
+ string CommandName,
+ AgentProviderStatus Status,
+ string StatusSummary,
+ string SuggestedModelName,
+ IReadOnlyList SupportedModelNames,
+ string? InstalledVersion,
+ bool IsEnabled,
+ bool CanCreateAgents,
+ IReadOnlyList Details,
+ IReadOnlyList Actions);
+
+public sealed record AgentProfileSummary(
+ AgentProfileId Id,
+ string Name,
+ string Description,
+ AgentProviderKind ProviderKind,
+ string ProviderDisplayName,
+ string ModelName,
+ string SystemPrompt,
+ DateTimeOffset CreatedAt);
+
+public sealed record SessionListItem(
+ SessionId Id,
+ string Title,
+ string Preview,
+ string StatusSummary,
+ DateTimeOffset UpdatedAt,
+ AgentProfileId PrimaryAgentId,
+ string PrimaryAgentName,
+ string ProviderDisplayName);
+
+public sealed record SessionStreamEntry(
+ string Id,
+ SessionId SessionId,
+ SessionStreamEntryKind Kind,
+ string Author,
+ string Text,
+ DateTimeOffset Timestamp,
+ AgentProfileId? AgentProfileId = null,
+ string? AccentLabel = null);
+
+public sealed record SessionTranscriptSnapshot(
+ SessionListItem Session,
+ IReadOnlyList Entries,
+ IReadOnlyList Participants);
+
+public sealed record AgentWorkspaceSnapshot(
+ IReadOnlyList Sessions,
+ IReadOnlyList Agents,
+ IReadOnlyList Providers,
+ SessionId? SelectedSessionId);
diff --git a/DotPilot.Core/ChatSessions/Contracts/SessionActivityContracts.cs b/DotPilot.Core/ChatSessions/Contracts/SessionActivityContracts.cs
new file mode 100644
index 0000000..1a43e25
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Contracts/SessionActivityContracts.cs
@@ -0,0 +1,20 @@
+using DotPilot.Core.ControlPlaneDomain;
+
+namespace DotPilot.Core.ChatSessions.Contracts;
+
+public sealed record SessionActivityDescriptor(
+ SessionId SessionId,
+ string SessionTitle,
+ AgentProfileId AgentProfileId,
+ string AgentName,
+ string ProviderDisplayName);
+
+public sealed record SessionActivitySnapshot(
+ bool HasActiveSessions,
+ int ActiveSessionCount,
+ IReadOnlyList ActiveSessions,
+ SessionId? SessionId,
+ string SessionTitle,
+ AgentProfileId? AgentProfileId,
+ string AgentName,
+ string ProviderDisplayName);
diff --git a/DotPilot.Core/ChatSessions/Diagnostics/AgentExecutionLoggingMiddleware.ChatClient.cs b/DotPilot.Core/ChatSessions/Diagnostics/AgentExecutionLoggingMiddleware.ChatClient.cs
new file mode 100644
index 0000000..a6b549e
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Diagnostics/AgentExecutionLoggingMiddleware.ChatClient.cs
@@ -0,0 +1,186 @@
+using System.Diagnostics;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging;
+
+namespace DotPilot.Core.ChatSessions;
+
+internal sealed partial class AgentExecutionLoggingMiddleware
+{
+ private IChatClient WrapChatClient(IChatClient chatClient, AgentRunLogContext runContext)
+ {
+ ArgumentNullException.ThrowIfNull(chatClient);
+
+ return chatClient
+ .AsBuilder()
+ .Use(
+ getResponseFunc: (messages, options, innerChatClient, cancellationToken) =>
+ LogChatResponseAsync(
+ runContext,
+ messages,
+ options,
+ innerChatClient,
+ cancellationToken),
+ getStreamingResponseFunc: (messages, options, innerChatClient, cancellationToken) =>
+ LogStreamingChatResponseAsync(
+ runContext,
+ messages,
+ options,
+ innerChatClient,
+ cancellationToken))
+ .Build();
+ }
+
+ private async Task LogChatResponseAsync(
+ AgentRunLogContext runContext,
+ IEnumerable messages,
+ ChatOptions? options,
+ IChatClient innerChatClient,
+ CancellationToken cancellationToken)
+ {
+ var materializedMessages = MaterializeMessages(messages);
+ using var scope = BeginScope(runContext);
+ var stopwatch = Stopwatch.StartNew();
+ var isInfoEnabled = logger.IsEnabled(LogLevel.Information);
+
+ if (isInfoEnabled)
+ {
+ var toolCount = CountTools(options);
+ AgentRuntimeConversationFactoryLog.ChatClientRequestStarted(
+ logger,
+ runContext.RunId,
+ runContext.SessionId,
+ runContext.AgentId,
+ false,
+ runContext.ModelName,
+ materializedMessages.Count,
+ toolCount);
+ }
+
+ try
+ {
+ var response = await innerChatClient.GetResponseAsync(
+ materializedMessages,
+ options,
+ cancellationToken);
+
+ if (isInfoEnabled)
+ {
+ var outputCount = response.Messages.Count;
+ var characterCount = CountMessageCharacters(response.Messages);
+ AgentRuntimeConversationFactoryLog.ChatClientRequestCompleted(
+ logger,
+ runContext.RunId,
+ runContext.SessionId,
+ runContext.AgentId,
+ false,
+ outputCount,
+ characterCount,
+ stopwatch.Elapsed.TotalMilliseconds);
+ }
+
+ return response;
+ }
+ catch (Exception exception)
+ {
+ AgentRuntimeConversationFactoryLog.ChatClientRequestFailed(
+ logger,
+ exception,
+ runContext.RunId,
+ runContext.SessionId,
+ runContext.AgentId,
+ false);
+ throw;
+ }
+ }
+
+ private async IAsyncEnumerable LogStreamingChatResponseAsync(
+ AgentRunLogContext runContext,
+ IEnumerable messages,
+ ChatOptions? options,
+ IChatClient innerChatClient,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var materializedMessages = MaterializeMessages(messages);
+ using var scope = BeginScope(runContext);
+ var stopwatch = Stopwatch.StartNew();
+ var updateCount = 0;
+ var totalCharacters = 0;
+ var loggedMessageId = false;
+ var isInfoEnabled = logger.IsEnabled(LogLevel.Information);
+
+ if (isInfoEnabled)
+ {
+ var toolCount = CountTools(options);
+ AgentRuntimeConversationFactoryLog.ChatClientRequestStarted(
+ logger,
+ runContext.RunId,
+ runContext.SessionId,
+ runContext.AgentId,
+ true,
+ runContext.ModelName,
+ materializedMessages.Count,
+ toolCount);
+ }
+
+ await using var enumerator = innerChatClient.GetStreamingResponseAsync(
+ materializedMessages,
+ options,
+ cancellationToken)
+ .GetAsyncEnumerator(cancellationToken);
+
+ while (true)
+ {
+ ChatResponseUpdate update;
+ try
+ {
+ if (!await enumerator.MoveNextAsync())
+ {
+ break;
+ }
+
+ update = enumerator.Current;
+ }
+ catch (Exception exception)
+ {
+ AgentRuntimeConversationFactoryLog.ChatClientRequestFailed(
+ logger,
+ exception,
+ runContext.RunId,
+ runContext.SessionId,
+ runContext.AgentId,
+ true);
+ throw;
+ }
+
+ updateCount++;
+ totalCharacters += update.Text?.Length ?? 0;
+
+ if (isInfoEnabled && !loggedMessageId && !string.IsNullOrWhiteSpace(update.MessageId))
+ {
+ loggedMessageId = true;
+ AgentRuntimeConversationFactoryLog.ChatClientFirstUpdateObserved(
+ logger,
+ runContext.RunId,
+ runContext.SessionId,
+ runContext.AgentId,
+ update.MessageId,
+ update.Text?.Length ?? 0);
+ }
+
+ yield return update;
+ }
+
+ if (isInfoEnabled)
+ {
+ AgentRuntimeConversationFactoryLog.ChatClientRequestCompleted(
+ logger,
+ runContext.RunId,
+ runContext.SessionId,
+ runContext.AgentId,
+ true,
+ updateCount,
+ totalCharacters,
+ stopwatch.Elapsed.TotalMilliseconds);
+ }
+ }
+}
diff --git a/DotPilot.Core/ChatSessions/Diagnostics/AgentExecutionLoggingMiddleware.cs b/DotPilot.Core/ChatSessions/Diagnostics/AgentExecutionLoggingMiddleware.cs
new file mode 100644
index 0000000..5955f9c
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Diagnostics/AgentExecutionLoggingMiddleware.cs
@@ -0,0 +1,237 @@
+using System.Diagnostics;
+using System.Globalization;
+using DotPilot.Core.ControlPlaneDomain;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging;
+
+namespace DotPilot.Core.ChatSessions;
+
+internal sealed partial class AgentExecutionLoggingMiddleware(
+ ILogger logger)
+{
+ public AIAgent AttachAgentRunLogging(
+ AIAgent agent,
+ AgentExecutionDescriptor descriptor)
+ {
+ ArgumentNullException.ThrowIfNull(agent);
+ ArgumentNullException.ThrowIfNull(descriptor);
+
+ AgentRuntimeConversationFactoryLog.AgentMiddlewareConfigured(
+ logger,
+ descriptor.AgentId,
+ descriptor.AgentName,
+ descriptor.ProviderKind);
+
+ return agent
+ .AsBuilder()
+ .Use(
+ runFunc: (messages, session, options, innerAgent, cancellationToken) =>
+ LogAgentRunAsync(
+ descriptor,
+ messages,
+ session,
+ options,
+ innerAgent,
+ cancellationToken),
+ runStreamingFunc: (messages, session, options, innerAgent, cancellationToken) =>
+ LogAgentRunStreamingAsync(
+ descriptor,
+ messages,
+ session,
+ options,
+ innerAgent,
+ cancellationToken))
+ .Build();
+ }
+
+ public AgentExecutionRunConfiguration CreateRunConfiguration(
+ AgentExecutionDescriptor descriptor,
+ SessionId sessionId)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+
+ var runContext = new AgentRunLogContext(
+ Guid.CreateVersion7().ToString("N", CultureInfo.InvariantCulture),
+ sessionId.Value.ToString("N", CultureInfo.InvariantCulture),
+ descriptor.AgentId,
+ descriptor.AgentName,
+ descriptor.ProviderKind,
+ descriptor.ProviderDisplayName,
+ descriptor.ModelName);
+
+ var options = new ChatClientAgentRunOptions(chatOptions: null)
+ {
+ AdditionalProperties = CreateAdditionalProperties(runContext),
+ ChatClientFactory = chatClient => WrapChatClient(chatClient, runContext),
+ };
+
+ AgentRuntimeConversationFactoryLog.RunScopedChatLoggingConfigured(
+ logger,
+ runContext.RunId,
+ sessionId,
+ descriptor.AgentId,
+ descriptor.ProviderKind,
+ descriptor.ModelName);
+
+ return new AgentExecutionRunConfiguration(runContext, options);
+ }
+
+ private async Task LogAgentRunAsync(
+ AgentExecutionDescriptor descriptor,
+ IEnumerable messages,
+ AgentSession? session,
+ AgentRunOptions? options,
+ AIAgent innerAgent,
+ CancellationToken cancellationToken)
+ {
+ var materializedMessages = MaterializeMessages(messages);
+ var runContext = ResolveRunContext(descriptor, options);
+ using var scope = BeginScope(runContext);
+ var stopwatch = Stopwatch.StartNew();
+ var isInfoEnabled = logger.IsEnabled(LogLevel.Information);
+
+ if (isInfoEnabled)
+ {
+ AgentRuntimeConversationFactoryLog.AgentRunStarted(
+ logger,
+ runContext.RunId,
+ runContext.SessionId,
+ runContext.AgentId,
+ runContext.AgentName,
+ runContext.ProviderKind,
+ false,
+ materializedMessages.Count);
+ }
+
+ try
+ {
+ var response = await innerAgent.RunAsync(
+ materializedMessages,
+ session,
+ options,
+ cancellationToken);
+
+ if (isInfoEnabled)
+ {
+ var outputCount = response.Messages.Count;
+ var characterCount = CountMessageCharacters(response.Messages);
+ AgentRuntimeConversationFactoryLog.AgentRunCompleted(
+ logger,
+ runContext.RunId,
+ runContext.SessionId,
+ runContext.AgentId,
+ false,
+ outputCount,
+ characterCount,
+ stopwatch.Elapsed.TotalMilliseconds);
+ }
+
+ return response;
+ }
+ catch (Exception exception)
+ {
+ AgentRuntimeConversationFactoryLog.AgentRunFailed(
+ logger,
+ exception,
+ runContext.RunId,
+ runContext.SessionId,
+ runContext.AgentId,
+ false);
+ throw;
+ }
+ }
+
+ private async IAsyncEnumerable LogAgentRunStreamingAsync(
+ AgentExecutionDescriptor descriptor,
+ IEnumerable messages,
+ AgentSession? session,
+ AgentRunOptions? options,
+ AIAgent innerAgent,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var materializedMessages = MaterializeMessages(messages);
+ var runContext = ResolveRunContext(descriptor, options);
+ using var scope = BeginScope(runContext);
+ var stopwatch = Stopwatch.StartNew();
+ var updateCount = 0;
+ var totalCharacters = 0;
+ var loggedMessageId = false;
+ var isInfoEnabled = logger.IsEnabled(LogLevel.Information);
+
+ if (isInfoEnabled)
+ {
+ AgentRuntimeConversationFactoryLog.AgentRunStarted(
+ logger,
+ runContext.RunId,
+ runContext.SessionId,
+ runContext.AgentId,
+ runContext.AgentName,
+ runContext.ProviderKind,
+ true,
+ materializedMessages.Count);
+ }
+
+ await using var enumerator = innerAgent.RunStreamingAsync(
+ materializedMessages,
+ session,
+ options,
+ cancellationToken)
+ .GetAsyncEnumerator(cancellationToken);
+
+ while (true)
+ {
+ AgentResponseUpdate update;
+ try
+ {
+ if (!await enumerator.MoveNextAsync())
+ {
+ break;
+ }
+
+ update = enumerator.Current;
+ }
+ catch (Exception exception)
+ {
+ AgentRuntimeConversationFactoryLog.AgentRunFailed(
+ logger,
+ exception,
+ runContext.RunId,
+ runContext.SessionId,
+ runContext.AgentId,
+ true);
+ throw;
+ }
+
+ updateCount++;
+ totalCharacters += update.Text?.Length ?? 0;
+
+ if (isInfoEnabled && !loggedMessageId && !string.IsNullOrWhiteSpace(update.MessageId))
+ {
+ loggedMessageId = true;
+ AgentRuntimeConversationFactoryLog.AgentRunFirstUpdateObserved(
+ logger,
+ runContext.RunId,
+ runContext.SessionId,
+ runContext.AgentId,
+ update.MessageId,
+ update.Text?.Length ?? 0);
+ }
+
+ yield return update;
+ }
+
+ if (isInfoEnabled)
+ {
+ AgentRuntimeConversationFactoryLog.AgentRunCompleted(
+ logger,
+ runContext.RunId,
+ runContext.SessionId,
+ runContext.AgentId,
+ true,
+ updateCount,
+ totalCharacters,
+ stopwatch.Elapsed.TotalMilliseconds);
+ }
+ }
+}
diff --git a/DotPilot.Core/ChatSessions/Diagnostics/AgentExecutionLoggingRuntimeLog.cs b/DotPilot.Core/ChatSessions/Diagnostics/AgentExecutionLoggingRuntimeLog.cs
new file mode 100644
index 0000000..d77582a
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Diagnostics/AgentExecutionLoggingRuntimeLog.cs
@@ -0,0 +1,148 @@
+using DotPilot.Core.ControlPlaneDomain;
+using Microsoft.Extensions.Logging;
+
+namespace DotPilot.Core.ChatSessions;
+
+internal static partial class AgentRuntimeConversationFactoryLog
+{
+ [LoggerMessage(
+ EventId = 1106,
+ Level = LogLevel.Information,
+ Message = "Configured agent run middleware. AgentId={AgentId} AgentName={AgentName} Provider={ProviderKind}.")]
+ public static partial void AgentMiddlewareConfigured(
+ ILogger logger,
+ Guid agentId,
+ string agentName,
+ AgentProviderKind providerKind);
+
+ [LoggerMessage(
+ EventId = 1107,
+ Level = LogLevel.Information,
+ Message = "Configured run-scoped chat logging. RunId={RunId} SessionId={SessionId} AgentId={AgentId} Provider={ProviderKind} Model={ModelName}.")]
+ public static partial void RunScopedChatLoggingConfigured(
+ ILogger logger,
+ string runId,
+ SessionId sessionId,
+ Guid agentId,
+ AgentProviderKind providerKind,
+ string modelName);
+
+ [LoggerMessage(
+ EventId = 1108,
+ Level = LogLevel.Information,
+ Message = "Agent run started. RunId={RunId} SessionId={SessionId} AgentId={AgentId} AgentName={AgentName} Provider={ProviderKind} IsStreaming={IsStreaming} MessageCount={MessageCount}.")]
+ public static partial void AgentRunStarted(
+ ILogger logger,
+ string runId,
+ string sessionId,
+ Guid agentId,
+ string agentName,
+ AgentProviderKind providerKind,
+ bool isStreaming,
+ int messageCount);
+
+ [LoggerMessage(
+ EventId = 1109,
+ Level = LogLevel.Information,
+ Message = "Agent run completed. RunId={RunId} SessionId={SessionId} AgentId={AgentId} IsStreaming={IsStreaming} OutputCount={OutputCount} CharacterCount={CharacterCount} ElapsedMilliseconds={ElapsedMilliseconds}.")]
+ public static partial void AgentRunCompleted(
+ ILogger logger,
+ string runId,
+ string sessionId,
+ Guid agentId,
+ bool isStreaming,
+ int outputCount,
+ int characterCount,
+ double elapsedMilliseconds);
+
+ [LoggerMessage(
+ EventId = 1110,
+ Level = LogLevel.Error,
+ Message = "Agent run failed. RunId={RunId} SessionId={SessionId} AgentId={AgentId} IsStreaming={IsStreaming}.")]
+ public static partial void AgentRunFailed(
+ ILogger logger,
+ Exception exception,
+ string runId,
+ string sessionId,
+ Guid agentId,
+ bool isStreaming);
+
+ [LoggerMessage(
+ EventId = 1111,
+ Level = LogLevel.Information,
+ Message = "Observed first agent streaming update. RunId={RunId} SessionId={SessionId} AgentId={AgentId} MessageId={MessageId} CharacterCount={CharacterCount}.")]
+ public static partial void AgentRunFirstUpdateObserved(
+ ILogger logger,
+ string runId,
+ string sessionId,
+ Guid agentId,
+ string messageId,
+ int characterCount);
+
+ [LoggerMessage(
+ EventId = 1112,
+ Level = LogLevel.Information,
+ Message = "Chat client request started. RunId={RunId} SessionId={SessionId} AgentId={AgentId} IsStreaming={IsStreaming} Model={ModelName} MessageCount={MessageCount} ToolCount={ToolCount}.")]
+ public static partial void ChatClientRequestStarted(
+ ILogger logger,
+ string runId,
+ string sessionId,
+ Guid agentId,
+ bool isStreaming,
+ string modelName,
+ int messageCount,
+ int toolCount);
+
+ [LoggerMessage(
+ EventId = 1113,
+ Level = LogLevel.Information,
+ Message = "Chat client request completed. RunId={RunId} SessionId={SessionId} AgentId={AgentId} IsStreaming={IsStreaming} OutputCount={OutputCount} CharacterCount={CharacterCount} ElapsedMilliseconds={ElapsedMilliseconds}.")]
+ public static partial void ChatClientRequestCompleted(
+ ILogger logger,
+ string runId,
+ string sessionId,
+ Guid agentId,
+ bool isStreaming,
+ int outputCount,
+ int characterCount,
+ double elapsedMilliseconds);
+
+ [LoggerMessage(
+ EventId = 1114,
+ Level = LogLevel.Error,
+ Message = "Chat client request failed. RunId={RunId} SessionId={SessionId} AgentId={AgentId} IsStreaming={IsStreaming}.")]
+ public static partial void ChatClientRequestFailed(
+ ILogger logger,
+ Exception exception,
+ string runId,
+ string sessionId,
+ Guid agentId,
+ bool isStreaming);
+
+ [LoggerMessage(
+ EventId = 1115,
+ Level = LogLevel.Information,
+ Message = "Observed first chat client streaming update. RunId={RunId} SessionId={SessionId} AgentId={AgentId} MessageId={MessageId} CharacterCount={CharacterCount}.")]
+ public static partial void ChatClientFirstUpdateObserved(
+ ILogger logger,
+ string runId,
+ string sessionId,
+ Guid agentId,
+ string messageId,
+ int characterCount);
+}
+
+internal static partial class AgentSessionServiceLog
+{
+ [LoggerMessage(
+ EventId = 1219,
+ Level = LogLevel.Information,
+ Message = "Prepared correlated agent run. SessionId={SessionId} AgentId={AgentId} RunId={RunId} Provider={ProviderKind} Model={ModelName}.")]
+ public static partial void SendRunPrepared(
+ ILogger logger,
+ SessionId sessionId,
+ Guid agentId,
+ string runId,
+ AgentProviderKind providerKind,
+ string modelName);
+}
diff --git a/DotPilot.Core/ChatSessions/Diagnostics/AgentSessionRuntimeLog.cs b/DotPilot.Core/ChatSessions/Diagnostics/AgentSessionRuntimeLog.cs
new file mode 100644
index 0000000..4566644
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Diagnostics/AgentSessionRuntimeLog.cs
@@ -0,0 +1,345 @@
+using DotPilot.Core.ControlPlaneDomain;
+using Microsoft.Extensions.Logging;
+
+namespace DotPilot.Core.ChatSessions;
+
+internal static partial class AgentProviderStatusReaderLog
+{
+ [LoggerMessage(
+ EventId = 1000,
+ Level = LogLevel.Information,
+ Message = "Reading provider readiness state from local sources.")]
+ public static partial void ReadStarted(ILogger logger);
+
+ [LoggerMessage(
+ EventId = 1001,
+ Level = LogLevel.Information,
+ Message = "Provider readiness state read for {ProviderCount} providers in {ElapsedMilliseconds} ms.")]
+ public static partial void ReadCompleted(ILogger logger, int providerCount, double elapsedMilliseconds);
+
+ [LoggerMessage(
+ EventId = 1002,
+ Level = LogLevel.Information,
+ Message = "Provider probe completed. Provider={ProviderKind} Status={Status} Enabled={IsEnabled} CanCreateAgents={CanCreateAgents} InstalledVersion={InstalledVersion} ExecutablePath={ExecutablePath}.")]
+ public static partial void ProbeCompleted(
+ ILogger logger,
+ AgentProviderKind providerKind,
+ AgentProviderStatus status,
+ bool isEnabled,
+ bool canCreateAgents,
+ string installedVersion,
+ string executablePath);
+
+ [LoggerMessage(
+ EventId = 1003,
+ Level = LogLevel.Error,
+ Message = "Provider readiness state read failed.")]
+ public static partial void ReadFailed(ILogger logger, Exception exception);
+}
+
+internal static partial class AgentRuntimeConversationFactoryLog
+{
+ [LoggerMessage(
+ EventId = 1100,
+ Level = LogLevel.Information,
+ Message = "Initializing runtime conversation state. SessionId={SessionId} AgentId={AgentId}.")]
+ public static partial void InitializeStarted(ILogger logger, SessionId sessionId, Guid agentId);
+
+ [LoggerMessage(
+ EventId = 1101,
+ Level = LogLevel.Information,
+ Message = "Loaded persisted runtime conversation state. SessionId={SessionId} AgentId={AgentId}.")]
+ public static partial void SessionLoaded(ILogger logger, SessionId sessionId, Guid agentId);
+
+ [LoggerMessage(
+ EventId = 1102,
+ Level = LogLevel.Information,
+ Message = "Created new runtime conversation session. SessionId={SessionId} AgentId={AgentId}.")]
+ public static partial void SessionCreated(ILogger logger, SessionId sessionId, Guid agentId);
+
+ [LoggerMessage(
+ EventId = 1103,
+ Level = LogLevel.Information,
+ Message = "Persisted runtime conversation state. SessionId={SessionId} AgentId={AgentId}.")]
+ public static partial void SessionSaved(ILogger logger, SessionId sessionId, string agentId);
+
+ [LoggerMessage(
+ EventId = 1104,
+ Level = LogLevel.Information,
+ Message = "Created runtime chat agent. AgentId={AgentId} Name={AgentName} Provider={ProviderKind}.")]
+ public static partial void AgentRuntimeCreated(
+ ILogger logger,
+ Guid agentId,
+ string agentName,
+ AgentProviderKind providerKind);
+
+ [LoggerMessage(
+ EventId = 1105,
+ Level = LogLevel.Information,
+ Message = "Using transient runtime conversation path. SessionId={SessionId} AgentId={AgentId}.")]
+ public static partial void TransientRuntimeConversation(ILogger logger, SessionId sessionId, Guid agentId);
+}
+
+internal static partial class AgentSessionServiceLog
+{
+ [LoggerMessage(
+ EventId = 1200,
+ Level = LogLevel.Information,
+ Message = "Initializing local agent session store.")]
+ public static partial void InitializationStarted(ILogger logger);
+
+ [LoggerMessage(
+ EventId = 1201,
+ Level = LogLevel.Information,
+ Message = "Local agent session store initialized.")]
+ public static partial void InitializationCompleted(ILogger logger);
+
+ [LoggerMessage(
+ EventId = 1217,
+ Level = LogLevel.Information,
+ Message = "Enabled default provider preference. Provider={ProviderKind}.")]
+ public static partial void DefaultProviderEnabled(ILogger logger, AgentProviderKind providerKind);
+
+ [LoggerMessage(
+ EventId = 1218,
+ Level = LogLevel.Information,
+ Message = "Seeded default system agent. AgentId={AgentId} Provider={ProviderKind} Model={ModelName}.")]
+ public static partial void DefaultAgentSeeded(
+ ILogger logger,
+ Guid agentId,
+ AgentProviderKind providerKind,
+ string modelName);
+
+ [LoggerMessage(
+ EventId = 1219,
+ Level = LogLevel.Information,
+ Message = "Normalized legacy agent profile. AgentId={AgentId} Provider={ProviderKind} Model={ModelName}.")]
+ public static partial void LegacyAgentProfileNormalized(
+ ILogger logger,
+ Guid agentId,
+ AgentProviderKind providerKind,
+ string modelName);
+
+ [LoggerMessage(
+ EventId = 1202,
+ Level = LogLevel.Information,
+ Message = "Loaded workspace snapshot. Sessions={SessionCount} Agents={AgentCount} Providers={ProviderCount}.")]
+ public static partial void WorkspaceLoaded(ILogger logger, int sessionCount, int agentCount, int providerCount);
+
+ [LoggerMessage(
+ EventId = 1220,
+ Level = LogLevel.Error,
+ Message = "Workspace snapshot load failed.")]
+ public static partial void WorkspaceLoadFailed(ILogger logger, Exception exception);
+
+ [LoggerMessage(
+ EventId = 1203,
+ Level = LogLevel.Information,
+ Message = "Loaded session transcript. SessionId={SessionId} EntryCount={EntryCount} ParticipantCount={ParticipantCount}.")]
+ public static partial void SessionLoaded(ILogger logger, SessionId sessionId, int entryCount, int participantCount);
+
+ [LoggerMessage(
+ EventId = 1204,
+ Level = LogLevel.Information,
+ Message = "Session transcript was requested but not found. SessionId={SessionId}.")]
+ public static partial void SessionNotFound(ILogger logger, SessionId sessionId);
+
+ [LoggerMessage(
+ EventId = 1221,
+ Level = LogLevel.Error,
+ Message = "Session transcript load failed. SessionId={SessionId}.")]
+ public static partial void SessionLoadFailed(ILogger logger, Exception exception, SessionId sessionId);
+
+ [LoggerMessage(
+ EventId = 1205,
+ Level = LogLevel.Information,
+ Message = "Creating agent profile. Name={AgentName} Provider={ProviderKind}.")]
+ public static partial void AgentCreationStarted(
+ ILogger logger,
+ string agentName,
+ AgentProviderKind providerKind);
+
+ [LoggerMessage(
+ EventId = 1206,
+ Level = LogLevel.Information,
+ Message = "Created agent profile. AgentId={AgentId} Name={AgentName} Provider={ProviderKind}.")]
+ public static partial void AgentCreated(
+ ILogger logger,
+ Guid agentId,
+ string agentName,
+ AgentProviderKind providerKind);
+
+ [LoggerMessage(
+ EventId = 1207,
+ Level = LogLevel.Error,
+ Message = "Agent profile creation failed. Name={AgentName} Provider={ProviderKind}.")]
+ public static partial void AgentCreationFailed(
+ ILogger logger,
+ Exception exception,
+ string agentName,
+ AgentProviderKind providerKind);
+
+ [LoggerMessage(
+ EventId = 1224,
+ Level = LogLevel.Information,
+ Message = "Updating agent profile. AgentId={AgentId} Name={AgentName} Provider={ProviderKind}.")]
+ public static partial void AgentUpdateStarted(
+ ILogger logger,
+ AgentProfileId agentId,
+ string agentName,
+ AgentProviderKind providerKind);
+
+ [LoggerMessage(
+ EventId = 1225,
+ Level = LogLevel.Information,
+ Message = "Updated agent profile. AgentId={AgentId} Name={AgentName} Provider={ProviderKind}.")]
+ public static partial void AgentUpdated(
+ ILogger logger,
+ Guid agentId,
+ string agentName,
+ AgentProviderKind providerKind);
+
+ [LoggerMessage(
+ EventId = 1226,
+ Level = LogLevel.Error,
+ Message = "Agent profile update failed. AgentId={AgentId} Name={AgentName} Provider={ProviderKind}.")]
+ public static partial void AgentUpdateFailed(
+ ILogger logger,
+ Exception exception,
+ AgentProfileId agentId,
+ string agentName,
+ AgentProviderKind providerKind);
+
+ [LoggerMessage(
+ EventId = 1208,
+ Level = LogLevel.Information,
+ Message = "Creating session. Title={SessionTitle} AgentId={AgentId}.")]
+ public static partial void SessionCreationStarted(ILogger logger, string sessionTitle, AgentProfileId agentId);
+
+ [LoggerMessage(
+ EventId = 1209,
+ Level = LogLevel.Information,
+ Message = "Created session. SessionId={SessionId} Title={SessionTitle} AgentId={AgentId}.")]
+ public static partial void SessionCreated(
+ ILogger logger,
+ SessionId sessionId,
+ string sessionTitle,
+ AgentProfileId agentId);
+
+ [LoggerMessage(
+ EventId = 1222,
+ Level = LogLevel.Error,
+ Message = "Session creation failed. Title={SessionTitle} AgentId={AgentId}.")]
+ public static partial void SessionCreationFailed(
+ ILogger logger,
+ Exception exception,
+ string sessionTitle,
+ AgentProfileId agentId);
+
+ [LoggerMessage(
+ EventId = 1210,
+ Level = LogLevel.Information,
+ Message = "Updated provider preference. Provider={ProviderKind} IsEnabled={IsEnabled}.")]
+ public static partial void ProviderPreferenceUpdated(
+ ILogger logger,
+ AgentProviderKind providerKind,
+ bool isEnabled);
+
+ [LoggerMessage(
+ EventId = 1211,
+ Level = LogLevel.Error,
+ Message = "Provider preference update failed. Provider={ProviderKind} IsEnabled={IsEnabled}.")]
+ public static partial void ProviderPreferenceUpdateFailed(
+ ILogger logger,
+ Exception exception,
+ AgentProviderKind providerKind,
+ bool isEnabled);
+
+ [LoggerMessage(
+ EventId = 1212,
+ Level = LogLevel.Information,
+ Message = "Starting session send. SessionId={SessionId} AgentId={AgentId} Provider={ProviderKind}.")]
+ public static partial void SendStarted(
+ ILogger logger,
+ SessionId sessionId,
+ Guid agentId,
+ AgentProviderKind providerKind);
+
+ [LoggerMessage(
+ EventId = 1213,
+ Level = LogLevel.Warning,
+ Message = "Session send blocked because provider is disabled. SessionId={SessionId} Provider={ProviderKind}.")]
+ public static partial void SendBlockedDisabled(
+ ILogger logger,
+ SessionId sessionId,
+ AgentProviderKind providerKind);
+
+ [LoggerMessage(
+ EventId = 1214,
+ Level = LogLevel.Warning,
+ Message = "Session send blocked because provider runtime is not wired. SessionId={SessionId} Provider={ProviderKind}.")]
+ public static partial void SendBlockedNotWired(
+ ILogger logger,
+ SessionId sessionId,
+ AgentProviderKind providerKind);
+
+ [LoggerMessage(
+ EventId = 1215,
+ Level = LogLevel.Information,
+ Message = "Completed session send. SessionId={SessionId} AgentId={AgentId} AssistantCharacters={AssistantCharacterCount}.")]
+ public static partial void SendCompleted(
+ ILogger logger,
+ SessionId sessionId,
+ Guid agentId,
+ int assistantCharacterCount);
+
+ [LoggerMessage(
+ EventId = 1216,
+ Level = LogLevel.Error,
+ Message = "Session send failed. SessionId={SessionId} AgentId={AgentId}.")]
+ public static partial void SendFailed(ILogger logger, Exception exception, SessionId sessionId, Guid agentId);
+}
+
+internal static partial class StartupWorkspaceHydrationLog
+{
+ [LoggerMessage(
+ EventId = 1300,
+ Level = LogLevel.Information,
+ Message = "Starting startup workspace hydration.")]
+ public static partial void HydrationStarted(ILogger logger);
+
+ [LoggerMessage(
+ EventId = 1301,
+ Level = LogLevel.Information,
+ Message = "Startup workspace hydration completed.")]
+ public static partial void HydrationCompleted(ILogger logger);
+
+ [LoggerMessage(
+ EventId = 1302,
+ Level = LogLevel.Error,
+ Message = "Startup workspace hydration failed.")]
+ public static partial void HydrationFailed(ILogger logger, Exception exception);
+}
+
+internal static partial class SessionActivityMonitorLog
+{
+ [LoggerMessage(
+ EventId = 1303,
+ Level = LogLevel.Information,
+ Message = "Marked session activity as live. SessionId={SessionId} AgentId={AgentId} ActiveSessionCount={ActiveSessionCount}.")]
+ public static partial void ActivityStarted(
+ ILogger logger,
+ Guid sessionId,
+ Guid agentId,
+ int activeSessionCount);
+
+ [LoggerMessage(
+ EventId = 1304,
+ Level = LogLevel.Information,
+ Message = "Released session live activity. SessionId={SessionId} AgentId={AgentId} ActiveSessionCount={ActiveSessionCount}.")]
+ public static partial void ActivityCompleted(
+ ILogger logger,
+ Guid sessionId,
+ Guid agentId,
+ int activeSessionCount);
+}
diff --git a/DotPilot.Core/ChatSessions/Execution/AgentRuntimeConversationFactory.cs b/DotPilot.Core/ChatSessions/Execution/AgentRuntimeConversationFactory.cs
new file mode 100644
index 0000000..f7322b9
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Execution/AgentRuntimeConversationFactory.cs
@@ -0,0 +1,194 @@
+using System.Globalization;
+using DotPilot.Core.ControlPlaneDomain;
+using DotPilot.Core.Providers;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace DotPilot.Core.ChatSessions;
+
+internal sealed class AgentRuntimeConversationFactory(
+ AgentSessionStorageOptions storageOptions,
+ AgentExecutionLoggingMiddleware executionLoggingMiddleware,
+ LocalCodexThreadStateStore codexThreadStateStore,
+ LocalAgentSessionStateStore sessionStateStore,
+ IServiceProvider serviceProvider,
+ TimeProvider timeProvider,
+ ILogger logger)
+{
+ public async ValueTask InitializeAsync(
+ AgentProfileRecord agentRecord,
+ SessionId sessionId,
+ CancellationToken cancellationToken)
+ {
+ AgentRuntimeConversationFactoryLog.InitializeStarted(logger, sessionId, agentRecord.Id);
+ if (ShouldUseTransientRuntimeConversation(agentRecord))
+ {
+ AgentRuntimeConversationFactoryLog.TransientRuntimeConversation(logger, sessionId, agentRecord.Id);
+ return;
+ }
+
+ var runtimeSession = await LoadOrCreateAsync(agentRecord, sessionId, cancellationToken);
+ await sessionStateStore.SaveAsync(runtimeSession.Agent, runtimeSession.Session, sessionId, cancellationToken);
+ if (logger.IsEnabled(LogLevel.Information))
+ {
+ var agentRuntimeId = agentRecord.Id.ToString("N", CultureInfo.InvariantCulture);
+ AgentRuntimeConversationFactoryLog.SessionSaved(
+ logger,
+ sessionId,
+ agentRuntimeId);
+ }
+ }
+
+ public async ValueTask LoadOrCreateAsync(
+ AgentProfileRecord agentRecord,
+ SessionId sessionId,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(agentRecord);
+
+ var useTransientConversation = ShouldUseTransientRuntimeConversation(agentRecord);
+ var historyProvider = new FolderChatHistoryProvider(
+ serviceProvider.GetRequiredService());
+ var descriptor = CreateExecutionDescriptor(agentRecord);
+ var agent = CreateAgent(agentRecord, descriptor, historyProvider, sessionId);
+ if (useTransientConversation)
+ {
+ var transientSession = await CreateNewSessionAsync(agent, sessionId, cancellationToken);
+ AgentRuntimeConversationFactoryLog.TransientRuntimeConversation(logger, sessionId, agentRecord.Id);
+ return new RuntimeConversationContext(agent, transientSession, descriptor, IsTransient: true);
+ }
+
+ var session = await sessionStateStore.TryLoadAsync(agent, sessionId, cancellationToken);
+ if (session is null)
+ {
+ session = await CreateNewSessionAsync(agent, sessionId, cancellationToken);
+ await sessionStateStore.SaveAsync(agent, session, sessionId, cancellationToken);
+ AgentRuntimeConversationFactoryLog.SessionCreated(logger, sessionId, agentRecord.Id);
+ }
+ else
+ {
+ AgentRuntimeConversationFactoryLog.SessionLoaded(logger, sessionId, agentRecord.Id);
+ }
+
+ FolderChatHistoryProvider.BindToSession(session, sessionId);
+ return new RuntimeConversationContext(agent, session, descriptor);
+ }
+
+ public ValueTask SaveAsync(
+ RuntimeConversationContext runtimeContext,
+ SessionId sessionId,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(runtimeContext);
+ if (runtimeContext.IsTransient)
+ {
+ return ValueTask.CompletedTask;
+ }
+
+ AgentRuntimeConversationFactoryLog.SessionSaved(logger, sessionId, runtimeContext.Agent.Id);
+ return sessionStateStore.SaveAsync(runtimeContext.Agent, runtimeContext.Session, sessionId, cancellationToken);
+ }
+
+ private bool ShouldUseTransientRuntimeConversation(AgentProfileRecord agentRecord)
+ {
+ ArgumentNullException.ThrowIfNull(agentRecord);
+
+ var providerKind = (AgentProviderKind)agentRecord.ProviderKind;
+ return storageOptions.PreferTransientRuntimeConversation ||
+ (OperatingSystem.IsBrowser() && providerKind == AgentProviderKind.Debug);
+ }
+
+ private static async ValueTask CreateNewSessionAsync(
+ AIAgent agent,
+ SessionId sessionId,
+ CancellationToken cancellationToken)
+ {
+ var session = await agent.CreateSessionAsync(cancellationToken);
+ FolderChatHistoryProvider.BindToSession(session, sessionId);
+ return session;
+ }
+
+ private AIAgent CreateAgent(
+ AgentProfileRecord agentRecord,
+ AgentExecutionDescriptor descriptor,
+ FolderChatHistoryProvider historyProvider,
+ SessionId sessionId)
+ {
+ var loggerFactory = serviceProvider.GetService();
+ var options = new ChatClientAgentOptions
+ {
+ Id = agentRecord.Id.ToString("N", CultureInfo.InvariantCulture),
+ Name = agentRecord.Name,
+ Description = descriptor.ProviderDisplayName,
+ ChatHistoryProvider = historyProvider,
+ UseProvidedChatClientAsIs = true,
+ ChatOptions = new ChatOptions
+ {
+ Instructions = agentRecord.SystemPrompt,
+ ModelId = agentRecord.ModelName,
+ },
+ };
+
+ AgentRuntimeConversationFactoryLog.AgentRuntimeCreated(
+ logger,
+ agentRecord.Id,
+ agentRecord.Name,
+ descriptor.ProviderKind);
+
+ var agent = CreateChatClient(
+ descriptor.ProviderKind,
+ descriptor.ProviderDisplayName,
+ agentRecord.Name,
+ sessionId,
+ agentRecord.ModelName)
+ .AsAIAgent(options, loggerFactory, serviceProvider);
+
+ return executionLoggingMiddleware.AttachAgentRunLogging(agent, descriptor);
+ }
+
+ private static AgentExecutionDescriptor CreateExecutionDescriptor(AgentProfileRecord agentRecord)
+ {
+ var providerProfile = AgentSessionProviderCatalog.Get((AgentProviderKind)agentRecord.ProviderKind);
+ return new AgentExecutionDescriptor(
+ agentRecord.Id,
+ agentRecord.Name,
+ providerProfile.Kind,
+ providerProfile.DisplayName,
+ agentRecord.ModelName);
+ }
+
+ [System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Performance",
+ "CA1859:Use concrete types when possible for improved performance",
+ Justification = "The runtime conversation factory intentionally preserves the IChatClient abstraction across provider-backed chat clients.")]
+ private IChatClient CreateChatClient(
+ AgentProviderKind providerKind,
+ string providerDisplayName,
+ string agentName,
+ SessionId sessionId,
+ string modelName)
+ {
+ if (providerKind == AgentProviderKind.Debug)
+ {
+ return new DebugChatClient(agentName, timeProvider);
+ }
+
+ if (providerKind == AgentProviderKind.Codex)
+ {
+ return new CodexChatClient(
+ sessionId,
+ agentName,
+ modelName,
+ codexThreadStateStore,
+ timeProvider);
+ }
+
+ throw new InvalidOperationException(
+ string.Format(
+ CultureInfo.InvariantCulture,
+ AgentSessionService.LiveExecutionUnavailableCompositeFormat,
+ providerDisplayName));
+ }
+}
diff --git a/DotPilot.Core/ChatSessions/Execution/AgentSessionService.cs b/DotPilot.Core/ChatSessions/Execution/AgentSessionService.cs
new file mode 100644
index 0000000..5d053f6
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Execution/AgentSessionService.cs
@@ -0,0 +1,1070 @@
+using DotPilot.Core.AgentBuilder;
+using DotPilot.Core.ControlPlaneDomain;
+using DotPilot.Core.Providers;
+using ManagedCode.Communication;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace DotPilot.Core.ChatSessions;
+
+internal sealed class AgentSessionService(
+ IDbContextFactory dbContextFactory,
+ AgentExecutionLoggingMiddleware executionLoggingMiddleware,
+ ISessionActivityMonitor sessionActivityMonitor,
+ IAgentProviderStatusReader providerStatusReader,
+ AgentRuntimeConversationFactory runtimeConversationFactory,
+ TimeProvider timeProvider,
+ ILogger logger)
+ : IAgentSessionService, IDisposable
+{
+ private const string NotYetImplementedFormat = "{0} live CLI execution is not wired yet in this slice.";
+ private const string SessionReadyText = "Session created. Send the first message to start the workflow.";
+ private const string UserAuthor = "You";
+ private const string ToolAuthor = "Tool";
+ private const string StatusAuthor = "System";
+ private const string DisabledProviderSendText = "The provider for this agent is disabled. Re-enable it in settings before sending.";
+ private const string ToolAccentLabel = "tool";
+ private const string StatusAccentLabel = "status";
+ private const string ErrorAccentLabel = "error";
+ internal static readonly System.Text.CompositeFormat LiveExecutionUnavailableCompositeFormat =
+ System.Text.CompositeFormat.Parse(NotYetImplementedFormat);
+ private readonly SemaphoreSlim _initializationGate = new(1, 1);
+ private bool _initialized;
+
+ public async ValueTask> GetWorkspaceAsync(CancellationToken cancellationToken)
+ {
+ return await LoadWorkspaceAsync(forceRefreshProviders: false, cancellationToken);
+ }
+
+ public async ValueTask> RefreshWorkspaceAsync(CancellationToken cancellationToken)
+ {
+ return await LoadWorkspaceAsync(forceRefreshProviders: true, cancellationToken);
+ }
+
+ private async ValueTask> LoadWorkspaceAsync(
+ bool forceRefreshProviders,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ await EnsureInitializedAsync(cancellationToken);
+
+ await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+ var agents = await dbContext.AgentProfiles
+ .ToListAsync(cancellationToken);
+ agents = OrderAgents(agents);
+ var sessions = (await dbContext.Sessions
+ .ToListAsync(cancellationToken))
+ .OrderByDescending(record => record.UpdatedAt)
+ .ToList();
+ var entries = (await dbContext.SessionEntries
+ .ToListAsync(cancellationToken))
+ .OrderBy(record => record.Timestamp)
+ .ToList();
+
+ var agentsById = agents.ToDictionary(record => record.Id);
+ var sessionItems = sessions
+ .Select(record => MapSessionListItem(record, agentsById, entries))
+ .ToArray();
+ var providers = await GetProviderStatusesAsync(forceRefreshProviders, cancellationToken);
+
+ AgentSessionServiceLog.WorkspaceLoaded(
+ logger,
+ sessionItems.Length,
+ agents.Count,
+ providers.Count);
+
+ return Result.Succeed(new AgentWorkspaceSnapshot(
+ sessionItems,
+ agents.Select(MapAgentSummary).ToArray(),
+ providers,
+ sessionItems.Length > 0 ? sessionItems[0].Id : null));
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception exception)
+ {
+ AgentSessionServiceLog.WorkspaceLoadFailed(logger, exception);
+ return Result.Fail(exception);
+ }
+ }
+
+ public async ValueTask> GetSessionAsync(SessionId sessionId, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await EnsureInitializedAsync(cancellationToken);
+
+ await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+ var session = await dbContext.Sessions
+ .FirstOrDefaultAsync(record => record.Id == sessionId.Value, cancellationToken);
+ if (session is null)
+ {
+ AgentSessionServiceLog.SessionNotFound(logger, sessionId);
+ return Result.FailNotFound($"Session '{sessionId}' was not found.");
+ }
+
+ var agents = await dbContext.AgentProfiles
+ .Where(record => record.Id == session.PrimaryAgentProfileId)
+ .ToListAsync(cancellationToken);
+ var agentsById = agents.ToDictionary(record => record.Id);
+ var entries = (await dbContext.SessionEntries
+ .Where(record => record.SessionId == sessionId.Value)
+ .ToListAsync(cancellationToken))
+ .OrderBy(record => record.Timestamp)
+ .ToList();
+
+ var snapshot = new SessionTranscriptSnapshot(
+ MapSessionListItem(session, agentsById, entries),
+ entries.Select(MapEntry).ToArray(),
+ agents.Select(MapAgentSummary).ToArray());
+
+ AgentSessionServiceLog.SessionLoaded(
+ logger,
+ sessionId,
+ snapshot.Entries.Count,
+ snapshot.Participants.Count);
+
+ return Result.Succeed(snapshot);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception exception)
+ {
+ AgentSessionServiceLog.SessionLoadFailed(logger, exception, sessionId);
+ return Result.Fail(exception);
+ }
+ }
+
+ public async ValueTask> CreateAgentAsync(
+ CreateAgentProfileCommand command,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(command);
+ await EnsureInitializedAsync(cancellationToken);
+ var agentName = command.Name.Trim();
+ var modelName = command.ModelName.Trim();
+ var systemPrompt = command.SystemPrompt.Trim();
+ var description = ResolveAgentDescription(command.Description, systemPrompt);
+ AgentSessionServiceLog.AgentCreationStarted(logger, agentName, command.ProviderKind);
+
+ try
+ {
+ await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+ var providers = await GetProviderStatusesAsync(forceRefresh: false, cancellationToken);
+ var provider = providers.First(status => status.Kind == command.ProviderKind);
+ if (!provider.CanCreateAgents)
+ {
+ return Result.FailForbidden(provider.StatusSummary);
+ }
+
+ var createdAt = timeProvider.GetUtcNow();
+ var record = new AgentProfileRecord
+ {
+ Id = Guid.CreateVersion7(),
+ Name = agentName,
+ Description = description,
+ Role = AgentProfileSchemaDefaults.DefaultRole,
+ ProviderKind = (int)command.ProviderKind,
+ ModelName = modelName,
+ SystemPrompt = systemPrompt,
+ CapabilitiesJson = AgentProfileSchemaDefaults.EmptyCapabilitiesJson,
+ CreatedAt = createdAt,
+ };
+
+ dbContext.AgentProfiles.Add(record);
+ await dbContext.SaveChangesAsync(cancellationToken);
+
+ AgentSessionServiceLog.AgentCreated(
+ logger,
+ record.Id,
+ record.Name,
+ command.ProviderKind);
+
+ return Result.Succeed(MapAgentSummary(record));
+ }
+ catch (Exception exception)
+ {
+ AgentSessionServiceLog.AgentCreationFailed(logger, exception, agentName, command.ProviderKind);
+ return Result.Fail(exception);
+ }
+ }
+
+ public async ValueTask> UpdateAgentAsync(
+ UpdateAgentProfileCommand command,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(command);
+ await EnsureInitializedAsync(cancellationToken);
+ var agentName = command.Name.Trim();
+ var modelName = command.ModelName.Trim();
+ var systemPrompt = command.SystemPrompt.Trim();
+ var description = ResolveAgentDescription(command.Description, systemPrompt);
+ AgentSessionServiceLog.AgentUpdateStarted(logger, command.AgentId, agentName, command.ProviderKind);
+
+ try
+ {
+ await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+ var record = await dbContext.AgentProfiles
+ .FirstOrDefaultAsync(agent => agent.Id == command.AgentId.Value, cancellationToken);
+ if (record is null)
+ {
+ return Result.FailNotFound($"Agent '{command.AgentId}' was not found.");
+ }
+
+ var providers = await GetProviderStatusesAsync(forceRefresh: false, cancellationToken);
+ var provider = providers.First(status => status.Kind == command.ProviderKind);
+ if (!provider.CanCreateAgents)
+ {
+ return Result.FailForbidden(provider.StatusSummary);
+ }
+
+ record.Name = agentName;
+ record.Description = description;
+ record.ProviderKind = (int)command.ProviderKind;
+ record.ModelName = modelName;
+ record.SystemPrompt = systemPrompt;
+
+ await dbContext.SaveChangesAsync(cancellationToken);
+
+ AgentSessionServiceLog.AgentUpdated(
+ logger,
+ record.Id,
+ record.Name,
+ command.ProviderKind);
+
+ return Result.Succeed(MapAgentSummary(record));
+ }
+ catch (Exception exception)
+ {
+ AgentSessionServiceLog.AgentUpdateFailed(logger, exception, command.AgentId, agentName, command.ProviderKind);
+ return Result.Fail(exception);
+ }
+ }
+
+ public async ValueTask> CreateSessionAsync(
+ CreateSessionCommand command,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(command);
+ try
+ {
+ await EnsureInitializedAsync(cancellationToken);
+ var sessionTitle = command.Title.Trim();
+ AgentSessionServiceLog.SessionCreationStarted(logger, sessionTitle, command.AgentProfileId);
+
+ await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+ var agent = await dbContext.AgentProfiles
+ .FirstOrDefaultAsync(record => record.Id == command.AgentProfileId.Value, cancellationToken);
+ if (agent is null)
+ {
+ return Result.FailNotFound($"Agent '{command.AgentProfileId}' was not found.");
+ }
+
+ var now = timeProvider.GetUtcNow();
+ var sessionId = SessionId.New();
+ var session = new SessionRecord
+ {
+ Id = sessionId.Value,
+ Title = sessionTitle,
+ PrimaryAgentProfileId = agent.Id,
+ CreatedAt = now,
+ UpdatedAt = now,
+ };
+
+ dbContext.Sessions.Add(session);
+ dbContext.SessionEntries.Add(CreateEntryRecord(sessionId, SessionStreamEntryKind.Status, StatusAuthor, SessionReadyText, now, accentLabel: StatusAccentLabel));
+ await dbContext.SaveChangesAsync(cancellationToken);
+
+ AgentSessionServiceLog.SessionCreated(logger, sessionId, session.Title, command.AgentProfileId);
+
+ var reloaded = await GetSessionAsync(sessionId, cancellationToken);
+ return reloaded.IsSuccess
+ ? reloaded
+ : Result.Fail("SessionCreationFailed", "Created session could not be reloaded.");
+ }
+ catch (Exception exception)
+ {
+ AgentSessionServiceLog.SessionCreationFailed(logger, exception, command.Title, command.AgentProfileId);
+ return Result.Fail(exception);
+ }
+ }
+
+ public async ValueTask> UpdateProviderAsync(
+ UpdateProviderPreferenceCommand command,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(command);
+ await EnsureInitializedAsync(cancellationToken);
+ try
+ {
+ await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+ var record = await dbContext.ProviderPreferences
+ .FirstOrDefaultAsync(preference => preference.ProviderKind == (int)command.ProviderKind, cancellationToken);
+ if (record is null)
+ {
+ record = new ProviderPreferenceRecord
+ {
+ ProviderKind = (int)command.ProviderKind,
+ };
+ dbContext.ProviderPreferences.Add(record);
+ }
+
+ record.IsEnabled = command.IsEnabled;
+ record.UpdatedAt = timeProvider.GetUtcNow();
+ await dbContext.SaveChangesAsync(cancellationToken);
+
+ InvalidateProviderStatusSnapshot();
+ var providers = await GetProviderStatusesAsync(forceRefresh: true, cancellationToken);
+ var provider = providers.First(status => status.Kind == command.ProviderKind);
+
+ AgentSessionServiceLog.ProviderPreferenceUpdated(logger, command.ProviderKind, command.IsEnabled);
+
+ return Result.Succeed(provider);
+ }
+ catch (Exception exception)
+ {
+ AgentSessionServiceLog.ProviderPreferenceUpdateFailed(
+ logger,
+ exception,
+ command.ProviderKind,
+ command.IsEnabled);
+ return Result.Fail(exception);
+ }
+ }
+
+ public async IAsyncEnumerable> SendMessageAsync(
+ SendSessionMessageCommand command,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(command);
+ Result initializeResult;
+ try
+ {
+ await EnsureInitializedAsync(cancellationToken);
+ initializeResult = Result.Succeed();
+ }
+ catch (Exception exception)
+ {
+ AgentSessionServiceLog.SendFailed(logger, exception, command.SessionId, Guid.Empty);
+ initializeResult = Result.Fail(exception);
+ }
+
+ if (initializeResult.IsFailed)
+ {
+ yield return Result.Fail(initializeResult.Problem!);
+ yield break;
+ }
+
+ LocalAgentSessionDbContext? dbContext = null;
+ Result dbContextResult;
+ try
+ {
+ dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+ dbContextResult = Result.Succeed();
+ }
+ catch (Exception exception)
+ {
+ AgentSessionServiceLog.SendFailed(logger, exception, command.SessionId, Guid.Empty);
+ dbContextResult = Result.Fail(exception);
+ }
+
+ if (dbContextResult.IsFailed || dbContext is null)
+ {
+ yield return Result.Fail(dbContextResult.Problem!);
+ yield break;
+ }
+
+ await using (dbContext)
+ {
+ Result<(SessionRecord Session, AgentProfileRecord Agent, AgentSessionProviderProfile ProviderProfile, DateTimeOffset Timestamp)> contextResult;
+ try
+ {
+ var sessionRecord = await dbContext.Sessions
+ .FirstOrDefaultAsync(record => record.Id == command.SessionId.Value, cancellationToken);
+ if (sessionRecord is null)
+ {
+ contextResult = Result<(SessionRecord, AgentProfileRecord, AgentSessionProviderProfile, DateTimeOffset)>.FailNotFound(
+ $"Session '{command.SessionId}' was not found.");
+ }
+ else
+ {
+ var agentRecord = await dbContext.AgentProfiles
+ .FirstOrDefaultAsync(record => record.Id == sessionRecord.PrimaryAgentProfileId, cancellationToken);
+ if (agentRecord is null)
+ {
+ contextResult = Result<(SessionRecord, AgentProfileRecord, AgentSessionProviderProfile, DateTimeOffset)>.FailNotFound(
+ $"Agent '{sessionRecord.PrimaryAgentProfileId}' was not found.");
+ }
+ else
+ {
+ contextResult = Result<(SessionRecord, AgentProfileRecord, AgentSessionProviderProfile, DateTimeOffset)>.Succeed((
+ sessionRecord,
+ agentRecord,
+ AgentSessionProviderCatalog.Get((AgentProviderKind)agentRecord.ProviderKind),
+ timeProvider.GetUtcNow()));
+ }
+ }
+ }
+ catch (Exception exception)
+ {
+ AgentSessionServiceLog.SendFailed(logger, exception, command.SessionId, Guid.Empty);
+ contextResult = Result<(SessionRecord, AgentProfileRecord, AgentSessionProviderProfile, DateTimeOffset)>.Fail(exception);
+ }
+
+ if (contextResult.IsFailed)
+ {
+ yield return Result.Fail(contextResult.Problem!);
+ yield break;
+ }
+
+ var (session, agent, providerProfile, now) = contextResult.Value;
+ AgentSessionServiceLog.SendStarted(
+ logger,
+ command.SessionId,
+ agent.Id,
+ providerProfile.Kind);
+
+ Result userEntryResult;
+ try
+ {
+ var userEntry = CreateEntryRecord(command.SessionId, SessionStreamEntryKind.UserMessage, UserAuthor, command.Message.Trim(), now);
+ dbContext.SessionEntries.Add(userEntry);
+ session.UpdatedAt = now;
+ await dbContext.SaveChangesAsync(cancellationToken);
+ userEntryResult = Result.Succeed(userEntry);
+ }
+ catch (Exception exception)
+ {
+ AgentSessionServiceLog.SendFailed(logger, exception, command.SessionId, agent.Id);
+ userEntryResult = Result.Fail(exception);
+ }
+
+ if (userEntryResult.IsFailed)
+ {
+ yield return Result.Fail(userEntryResult.Problem!);
+ yield break;
+ }
+
+ yield return Result.Succeed(MapEntry(userEntryResult.Value));
+
+ var statusEntry = CreateEntryRecord(
+ command.SessionId,
+ SessionStreamEntryKind.Status,
+ StatusAuthor,
+ $"Running {agent.Name} with {providerProfile.DisplayName}.",
+ timeProvider.GetUtcNow(),
+ accentLabel: StatusAccentLabel);
+ Result statusEntryResult;
+ try
+ {
+ dbContext.SessionEntries.Add(statusEntry);
+ session.UpdatedAt = statusEntry.Timestamp;
+ await dbContext.SaveChangesAsync(cancellationToken);
+ statusEntryResult = Result.Succeed(MapEntry(statusEntry));
+ }
+ catch (Exception exception)
+ {
+ AgentSessionServiceLog.SendFailed(logger, exception, command.SessionId, agent.Id);
+ statusEntryResult = Result.Fail(exception);
+ }
+
+ if (statusEntryResult.IsFailed)
+ {
+ yield return statusEntryResult;
+ yield break;
+ }
+
+ yield return statusEntryResult;
+
+ Result providerStatusResult;
+ try
+ {
+ var providerStatuses = await GetProviderStatusesAsync(forceRefresh: false, cancellationToken);
+ providerStatusResult = Result.Succeed(
+ providerStatuses.First(status => status.Kind == providerProfile.Kind));
+ }
+ catch (Exception exception)
+ {
+ AgentSessionServiceLog.SendFailed(logger, exception, command.SessionId, agent.Id);
+ providerStatusResult = Result.Fail(exception);
+ }
+
+ if (providerStatusResult.IsFailed)
+ {
+ yield return Result.Fail(providerStatusResult.Problem!);
+ yield break;
+ }
+
+ var providerStatus = providerStatusResult.Value;
+ if (!providerStatus.IsEnabled)
+ {
+ AgentSessionServiceLog.SendBlockedDisabled(logger, command.SessionId, providerProfile.Kind);
+ var disabledEntry = CreateEntryRecord(
+ command.SessionId,
+ SessionStreamEntryKind.Error,
+ StatusAuthor,
+ DisabledProviderSendText,
+ timeProvider.GetUtcNow(),
+ accentLabel: ErrorAccentLabel);
+ dbContext.SessionEntries.Add(disabledEntry);
+ session.UpdatedAt = disabledEntry.Timestamp;
+ await dbContext.SaveChangesAsync(cancellationToken);
+
+ yield return Result.Succeed(MapEntry(disabledEntry));
+ yield break;
+ }
+
+ if (!providerStatus.CanCreateAgents)
+ {
+ var unavailableEntry = CreateEntryRecord(
+ command.SessionId,
+ SessionStreamEntryKind.Error,
+ StatusAuthor,
+ providerStatus.StatusSummary,
+ timeProvider.GetUtcNow(),
+ accentLabel: ErrorAccentLabel);
+ dbContext.SessionEntries.Add(unavailableEntry);
+ session.UpdatedAt = unavailableEntry.Timestamp;
+ await dbContext.SaveChangesAsync(cancellationToken);
+
+ yield return Result.Succeed(MapEntry(unavailableEntry));
+ yield break;
+ }
+
+ if (!providerProfile.SupportsLiveExecution)
+ {
+ AgentSessionServiceLog.SendBlockedNotWired(logger, command.SessionId, providerProfile.Kind);
+ var notImplementedEntry = CreateEntryRecord(
+ command.SessionId,
+ SessionStreamEntryKind.Error,
+ StatusAuthor,
+ string.Format(
+ System.Globalization.CultureInfo.InvariantCulture,
+ LiveExecutionUnavailableCompositeFormat,
+ providerProfile.DisplayName),
+ timeProvider.GetUtcNow(),
+ accentLabel: ErrorAccentLabel);
+ dbContext.SessionEntries.Add(notImplementedEntry);
+ session.UpdatedAt = notImplementedEntry.Timestamp;
+ await dbContext.SaveChangesAsync(cancellationToken);
+
+ yield return Result.Succeed(MapEntry(notImplementedEntry));
+ yield break;
+ }
+
+ using var liveActivity = sessionActivityMonitor.BeginActivity(
+ new SessionActivityDescriptor(
+ command.SessionId,
+ session.Title,
+ new AgentProfileId(agent.Id),
+ agent.Name,
+ providerProfile.DisplayName));
+
+ Result runtimeConversationResult;
+ try
+ {
+ runtimeConversationResult = Result.Succeed(
+ await runtimeConversationFactory.LoadOrCreateAsync(agent, command.SessionId, cancellationToken));
+ }
+ catch (Exception exception)
+ {
+ AgentSessionServiceLog.SendFailed(logger, exception, command.SessionId, agent.Id);
+ runtimeConversationResult = Result.Fail(exception);
+ }
+
+ if (runtimeConversationResult.IsFailed)
+ {
+ yield return Result.Fail(runtimeConversationResult.Problem!);
+ yield break;
+ }
+
+ var runtimeConversation = runtimeConversationResult.Value;
+ var toolStartEntry = CreateEntryRecord(
+ command.SessionId,
+ SessionStreamEntryKind.ToolStarted,
+ ToolAuthor,
+ CreateToolStartText(providerProfile),
+ timeProvider.GetUtcNow(),
+ agentProfileId: new AgentProfileId(agent.Id),
+ accentLabel: ToolAccentLabel);
+ Result toolStartEntryResult;
+ try
+ {
+ dbContext.SessionEntries.Add(toolStartEntry);
+ session.UpdatedAt = toolStartEntry.Timestamp;
+ await dbContext.SaveChangesAsync(cancellationToken);
+ toolStartEntryResult = Result.Succeed(MapEntry(toolStartEntry));
+ }
+ catch (Exception exception)
+ {
+ AgentSessionServiceLog.SendFailed(logger, exception, command.SessionId, agent.Id);
+ toolStartEntryResult = Result.Fail(exception);
+ }
+
+ if (toolStartEntryResult.IsFailed)
+ {
+ yield return toolStartEntryResult;
+ yield break;
+ }
+
+ yield return toolStartEntryResult;
+
+ string? streamedMessageId = null;
+ SessionEntryRecord? streamedAssistantEntry = null;
+ var accumulated = new System.Text.StringBuilder();
+ var runConfiguration = executionLoggingMiddleware.CreateRunConfiguration(
+ runtimeConversation.Descriptor,
+ command.SessionId);
+ AgentSessionServiceLog.SendRunPrepared(
+ logger,
+ command.SessionId,
+ agent.Id,
+ runConfiguration.Context.RunId,
+ providerProfile.Kind,
+ agent.ModelName);
+
+ await using var updateEnumerator = runtimeConversation.Agent.RunStreamingAsync(
+ command.Message.Trim(),
+ runtimeConversation.Session,
+ runConfiguration.Options,
+ cancellationToken)
+ .GetAsyncEnumerator(cancellationToken);
+
+ while (true)
+ {
+ Microsoft.Agents.AI.AgentResponseUpdate? update = null;
+ Result nextUpdateResult;
+ try
+ {
+ var hasNext = await updateEnumerator.MoveNextAsync();
+ if (hasNext)
+ {
+ update = updateEnumerator.Current;
+ }
+
+ nextUpdateResult = Result.Succeed(hasNext);
+ }
+ catch (Exception exception)
+ {
+ AgentSessionServiceLog.SendFailed(logger, exception, command.SessionId, agent.Id);
+ nextUpdateResult = Result.Fail(exception);
+ }
+
+ if (nextUpdateResult.IsFailed)
+ {
+ yield return Result.Fail(nextUpdateResult.Problem!);
+ yield break;
+ }
+
+ if (!nextUpdateResult.Value)
+ {
+ break;
+ }
+
+ if (string.IsNullOrEmpty(update?.Text))
+ {
+ continue;
+ }
+
+ streamedMessageId ??= string.IsNullOrWhiteSpace(update.MessageId)
+ ? Guid.CreateVersion7().ToString("N", System.Globalization.CultureInfo.InvariantCulture)
+ : update.MessageId;
+ accumulated.Append(update.Text);
+ Result streamedEntryResult;
+ try
+ {
+ var timestamp = update.CreatedAt ?? timeProvider.GetUtcNow();
+ if (streamedAssistantEntry is null)
+ {
+ streamedAssistantEntry = new SessionEntryRecord
+ {
+ Id = streamedMessageId,
+ SessionId = command.SessionId.Value,
+ AgentProfileId = agent.Id,
+ Kind = (int)SessionStreamEntryKind.AssistantMessage,
+ Author = agent.Name,
+ Text = accumulated.ToString(),
+ Timestamp = timestamp,
+ };
+ dbContext.SessionEntries.Add(streamedAssistantEntry);
+ }
+ else
+ {
+ streamedAssistantEntry.Text = accumulated.ToString();
+ streamedAssistantEntry.Timestamp = timestamp;
+ }
+
+ session.UpdatedAt = timestamp;
+ await dbContext.SaveChangesAsync(cancellationToken);
+ streamedEntryResult = Result.Succeed(MapEntry(streamedAssistantEntry));
+ }
+ catch (Exception exception)
+ {
+ AgentSessionServiceLog.SendFailed(logger, exception, command.SessionId, agent.Id);
+ streamedEntryResult = Result.Fail(exception);
+ }
+
+ if (streamedEntryResult.IsFailed)
+ {
+ yield return streamedEntryResult;
+ yield break;
+ }
+
+ yield return streamedEntryResult;
+ }
+
+ Result completionResult;
+ try
+ {
+ await runtimeConversationFactory.SaveAsync(runtimeConversation, command.SessionId, cancellationToken);
+
+ if (streamedAssistantEntry is null)
+ {
+ streamedAssistantEntry = new SessionEntryRecord
+ {
+ Id = streamedMessageId ?? Guid.CreateVersion7().ToString("N", System.Globalization.CultureInfo.InvariantCulture),
+ SessionId = command.SessionId.Value,
+ AgentProfileId = agent.Id,
+ Kind = (int)SessionStreamEntryKind.AssistantMessage,
+ Author = agent.Name,
+ Text = accumulated.ToString(),
+ Timestamp = timeProvider.GetUtcNow(),
+ };
+ dbContext.SessionEntries.Add(streamedAssistantEntry);
+ }
+ else
+ {
+ streamedAssistantEntry.Text = accumulated.ToString();
+ streamedAssistantEntry.Timestamp = timeProvider.GetUtcNow();
+ }
+
+ var toolDoneEntry = CreateEntryRecord(
+ command.SessionId,
+ SessionStreamEntryKind.ToolCompleted,
+ ToolAuthor,
+ CreateToolDoneText(providerProfile),
+ timeProvider.GetUtcNow(),
+ agentProfileId: new AgentProfileId(agent.Id),
+ accentLabel: ToolAccentLabel);
+
+ dbContext.SessionEntries.Add(toolDoneEntry);
+ session.UpdatedAt = toolDoneEntry.Timestamp;
+ await dbContext.SaveChangesAsync(cancellationToken);
+
+ AgentSessionServiceLog.SendCompleted(logger, command.SessionId, agent.Id, accumulated.Length);
+ completionResult = Result.Succeed(MapEntry(toolDoneEntry));
+ }
+ catch (Exception exception)
+ {
+ AgentSessionServiceLog.SendFailed(logger, exception, command.SessionId, agent.Id);
+ completionResult = Result.Fail(exception);
+ }
+
+ yield return completionResult;
+ }
+ }
+
+ private async Task EnsureInitializedAsync(CancellationToken cancellationToken)
+ {
+ if (_initialized)
+ {
+ return;
+ }
+
+ await _initializationGate.WaitAsync(cancellationToken);
+ try
+ {
+ if (_initialized)
+ {
+ return;
+ }
+
+ AgentSessionServiceLog.InitializationStarted(logger);
+ await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+ await dbContext.Database.EnsureCreatedAsync(cancellationToken);
+ await AgentProfileSchemaCompatibilityEnsurer.EnsureAsync(dbContext, cancellationToken);
+ await EnsureDefaultProviderAndAgentAsync(dbContext, cancellationToken);
+ _initialized = true;
+ AgentSessionServiceLog.InitializationCompleted(logger);
+ }
+ finally
+ {
+ _initializationGate.Release();
+ }
+ }
+
+ private async Task EnsureDefaultProviderAndAgentAsync(
+ LocalAgentSessionDbContext dbContext,
+ CancellationToken cancellationToken)
+ {
+ var hasEnabledProvider = await dbContext.ProviderPreferences
+ .AnyAsync(record => record.IsEnabled, cancellationToken);
+ if (!hasEnabledProvider)
+ {
+ await EnsureProviderEnabledAsync(dbContext, AgentProviderKind.Debug, cancellationToken);
+ }
+
+ await NormalizeLegacyAgentProfilesAsync(dbContext, cancellationToken);
+
+ var hasAgents = await dbContext.AgentProfiles.AnyAsync(cancellationToken);
+ if (hasAgents)
+ {
+ return;
+ }
+
+ var providerKind = await ResolveSeedProviderKindAsync(dbContext, cancellationToken);
+ if (providerKind == AgentProviderKind.Debug)
+ {
+ await EnsureProviderEnabledAsync(dbContext, providerKind, cancellationToken);
+ }
+
+ var record = new AgentProfileRecord
+ {
+ Id = Guid.CreateVersion7(),
+ Name = AgentSessionDefaults.SystemAgentName,
+ Description = AgentSessionDefaults.SystemAgentDescription,
+ Role = AgentProfileSchemaDefaults.DefaultRole,
+ ProviderKind = (int)providerKind,
+ ModelName = AgentSessionDefaults.GetDefaultModel(providerKind),
+ SystemPrompt = AgentSessionDefaults.SystemAgentPrompt,
+ CapabilitiesJson = AgentProfileSchemaDefaults.EmptyCapabilitiesJson,
+ CreatedAt = timeProvider.GetUtcNow(),
+ };
+
+ dbContext.AgentProfiles.Add(record);
+ await dbContext.SaveChangesAsync(cancellationToken);
+ AgentSessionServiceLog.DefaultAgentSeeded(logger, record.Id, providerKind, record.ModelName);
+ }
+
+ private async Task EnsureProviderEnabledAsync(
+ LocalAgentSessionDbContext dbContext,
+ AgentProviderKind providerKind,
+ CancellationToken cancellationToken)
+ {
+ var preference = await dbContext.ProviderPreferences
+ .FirstOrDefaultAsync(
+ record => record.ProviderKind == (int)providerKind,
+ cancellationToken);
+
+ if (preference is null)
+ {
+ preference = new ProviderPreferenceRecord
+ {
+ ProviderKind = (int)providerKind,
+ };
+ dbContext.ProviderPreferences.Add(preference);
+ }
+
+ if (preference.IsEnabled)
+ {
+ return;
+ }
+
+ preference.IsEnabled = true;
+ preference.UpdatedAt = timeProvider.GetUtcNow();
+ AgentSessionServiceLog.DefaultProviderEnabled(logger, providerKind);
+ await dbContext.SaveChangesAsync(cancellationToken);
+ }
+
+ private static async ValueTask ResolveSeedProviderKindAsync(
+ LocalAgentSessionDbContext dbContext,
+ CancellationToken cancellationToken)
+ {
+ var enabledProviderKinds = await dbContext.ProviderPreferences
+ .Where(record => record.IsEnabled)
+ .Select(record => (AgentProviderKind)record.ProviderKind)
+ .ToArrayAsync(cancellationToken);
+
+ foreach (var providerKind in enabledProviderKinds)
+ {
+ var profile = AgentSessionProviderCatalog.Get(providerKind);
+ if (!profile.SupportsLiveExecution || profile.IsBuiltIn)
+ {
+ continue;
+ }
+
+ if (!string.IsNullOrWhiteSpace(AgentSessionCommandProbe.ResolveExecutablePath(profile.CommandName)))
+ {
+ return providerKind;
+ }
+ }
+
+ return AgentProviderKind.Debug;
+ }
+
+ private async ValueTask> GetProviderStatusesAsync(
+ bool forceRefresh,
+ CancellationToken cancellationToken)
+ {
+ return forceRefresh
+ ? await providerStatusReader.RefreshAsync(cancellationToken)
+ : await providerStatusReader.ReadAsync(cancellationToken);
+ }
+
+ private void InvalidateProviderStatusSnapshot()
+ {
+ providerStatusReader.Invalidate();
+ }
+
+ private async Task NormalizeLegacyAgentProfilesAsync(
+ LocalAgentSessionDbContext dbContext,
+ CancellationToken cancellationToken)
+ {
+ var legacyDebugModel = AgentSessionDefaults.GetDefaultModel(AgentProviderKind.Debug);
+ var legacyAgents = await dbContext.AgentProfiles
+ .Where(record =>
+ (record.ProviderKind != (int)AgentProviderKind.Debug &&
+ record.ModelName == legacyDebugModel) ||
+ string.IsNullOrWhiteSpace(record.Description))
+ .ToListAsync(cancellationToken);
+ if (legacyAgents.Count == 0)
+ {
+ return;
+ }
+
+ foreach (var agent in legacyAgents)
+ {
+ var providerKind = (AgentProviderKind)agent.ProviderKind;
+ if (agent.ProviderKind != (int)AgentProviderKind.Debug &&
+ agent.ModelName == legacyDebugModel)
+ {
+ agent.ModelName = AgentSessionDefaults.GetDefaultModel(providerKind);
+ AgentSessionServiceLog.LegacyAgentProfileNormalized(logger, agent.Id, providerKind, agent.ModelName);
+ }
+
+ if (string.IsNullOrWhiteSpace(agent.Description))
+ {
+ agent.Description = ResolveAgentDescription(string.Empty, agent.SystemPrompt);
+ }
+ }
+
+ await dbContext.SaveChangesAsync(cancellationToken);
+ }
+
+ private static SessionEntryRecord CreateEntryRecord(
+ SessionId sessionId,
+ SessionStreamEntryKind kind,
+ string author,
+ string text,
+ DateTimeOffset timestamp,
+ AgentProfileId? agentProfileId = null,
+ string? accentLabel = null)
+ {
+ return new SessionEntryRecord
+ {
+ Id = Guid.CreateVersion7().ToString("N", System.Globalization.CultureInfo.InvariantCulture),
+ SessionId = sessionId.Value,
+ AgentProfileId = agentProfileId?.Value,
+ Kind = (int)kind,
+ Author = author,
+ Text = text,
+ Timestamp = timestamp,
+ AccentLabel = accentLabel,
+ };
+ }
+
+ private static string CreateToolStartText(AgentSessionProviderProfile providerProfile)
+ {
+ return providerProfile.Kind == AgentProviderKind.Debug
+ ? "Preparing local debug workflow."
+ : string.Create(
+ System.Globalization.CultureInfo.InvariantCulture,
+ $"Launching {providerProfile.DisplayName} in the local playground.");
+ }
+
+ private static string CreateToolDoneText(AgentSessionProviderProfile providerProfile)
+ {
+ return providerProfile.Kind == AgentProviderKind.Debug
+ ? "Debug workflow finished."
+ : string.Create(
+ System.Globalization.CultureInfo.InvariantCulture,
+ $"{providerProfile.DisplayName} turn finished.");
+ }
+
+ private static SessionStreamEntry MapEntry(SessionEntryRecord record)
+ {
+ return new SessionStreamEntry(
+ record.Id,
+ new SessionId(record.SessionId),
+ (SessionStreamEntryKind)record.Kind,
+ record.Author,
+ record.Text,
+ record.Timestamp,
+ record.AgentProfileId is Guid agentProfileId ? new AgentProfileId(agentProfileId) : null,
+ record.AccentLabel);
+ }
+
+ private static SessionListItem MapSessionListItem(
+ SessionRecord record,
+ Dictionary agentsById,
+ IReadOnlyList entries)
+ {
+ var agent = agentsById[record.PrimaryAgentProfileId];
+ var preview = entries
+ .Where(entry => entry.SessionId == record.Id)
+ .OrderByDescending(entry => entry.Timestamp)
+ .Select(entry => entry.Text)
+ .FirstOrDefault() ?? string.Empty;
+ var providerProfile = AgentSessionProviderCatalog.Get((AgentProviderKind)agent.ProviderKind);
+
+ return new SessionListItem(
+ new SessionId(record.Id),
+ record.Title,
+ preview,
+ providerProfile.DisplayName,
+ record.UpdatedAt,
+ new AgentProfileId(agent.Id),
+ agent.Name,
+ providerProfile.DisplayName);
+ }
+
+ private static AgentProfileSummary MapAgentSummary(AgentProfileRecord record)
+ {
+ var providerProfile = AgentSessionProviderCatalog.Get((AgentProviderKind)record.ProviderKind);
+ return new AgentProfileSummary(
+ new AgentProfileId(record.Id),
+ record.Name,
+ string.IsNullOrWhiteSpace(record.Description)
+ ? ResolveAgentDescription(string.Empty, record.SystemPrompt)
+ : record.Description,
+ (AgentProviderKind)record.ProviderKind,
+ providerProfile.DisplayName,
+ record.ModelName,
+ record.SystemPrompt,
+ record.CreatedAt);
+ }
+
+ private static List OrderAgents(IReadOnlyList agents)
+ {
+ var hasNonSystemAgents = agents.Any(record => !AgentSessionDefaults.IsSystemAgent(record.Name));
+
+ return agents
+ .OrderBy(record => hasNonSystemAgents && AgentSessionDefaults.IsSystemAgent(record.Name) ? 1 : 0)
+ .ThenByDescending(record => record.CreatedAt)
+ .ThenBy(record => record.Name, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ public void Dispose()
+ {
+ _initializationGate.Dispose();
+ }
+
+ private static string ResolveAgentDescription(string description, string systemPrompt)
+ {
+ var normalizedDescription = description.Trim();
+ if (!string.IsNullOrWhiteSpace(normalizedDescription))
+ {
+ return normalizedDescription;
+ }
+
+ return string.IsNullOrWhiteSpace(systemPrompt)
+ ? string.Empty
+ : AgentSessionDefaults.CreateAgentDescription(systemPrompt);
+ }
+}
diff --git a/DotPilot.Core/ChatSessions/Execution/CodexChatClient.cs b/DotPilot.Core/ChatSessions/Execution/CodexChatClient.cs
new file mode 100644
index 0000000..30c4c28
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Execution/CodexChatClient.cs
@@ -0,0 +1,326 @@
+using System.Globalization;
+using System.Runtime.CompilerServices;
+using System.Security.Cryptography;
+using System.Text;
+using DotPilot.Core.ControlPlaneDomain;
+using ManagedCode.CodexSharpSDK.Client;
+using ManagedCode.CodexSharpSDK.Configuration;
+using ManagedCode.CodexSharpSDK.Models;
+using Microsoft.Extensions.AI;
+
+namespace DotPilot.Core.ChatSessions;
+
+internal sealed class CodexChatClient(
+ SessionId sessionId,
+ string agentName,
+ string fallbackModelName,
+ LocalCodexThreadStateStore threadStateStore,
+ TimeProvider timeProvider) : IChatClient
+{
+ private const string ContinuePrompt = "Continue the conversation.";
+ private readonly SemaphoreSlim _initializationGate = new(1, 1);
+ private CodexClient? _client;
+ private CodexThread? _thread;
+ private LocalCodexThreadState? _threadState;
+ private bool _disposed;
+
+ public async Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var prompt = ResolveLatestUserPrompt(messages);
+ var instructions = options?.Instructions ?? string.Empty;
+ var turnContext = await EnsureThreadAsync(options?.ModelId, instructions, cancellationToken);
+ var result = await turnContext.Thread.RunAsync(
+ BuildInputs(prompt, instructions, turnContext.State.InstructionsSeeded),
+ new TurnOptions
+ {
+ CancellationToken = cancellationToken,
+ });
+
+ await MarkInstructionsSeededAsync(turnContext.State, cancellationToken);
+
+ var timestamp = timeProvider.GetUtcNow();
+ var message = new ChatMessage(ChatRole.Assistant, ResolveResponseText(result))
+ {
+ AuthorName = agentName,
+ CreatedAt = timestamp,
+ MessageId = Guid.CreateVersion7().ToString("N", CultureInfo.InvariantCulture),
+ };
+
+ return new ChatResponse(message)
+ {
+ CreatedAt = timestamp,
+ };
+ }
+
+ public async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var prompt = ResolveLatestUserPrompt(messages);
+ var instructions = options?.Instructions ?? string.Empty;
+ var turnContext = await EnsureThreadAsync(options?.ModelId, instructions, cancellationToken);
+ var streamed = await turnContext.Thread.RunStreamedAsync(
+ BuildInputs(prompt, instructions, turnContext.State.InstructionsSeeded),
+ new TurnOptions
+ {
+ CancellationToken = cancellationToken,
+ });
+
+ await MarkInstructionsSeededAsync(turnContext.State, cancellationToken);
+
+ var messageId = Guid.CreateVersion7().ToString("N", CultureInfo.InvariantCulture);
+ Dictionary observedTextLengths = [];
+
+ await foreach (var evt in streamed.Events.WithCancellation(cancellationToken))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (evt is TurnFailedEvent failed)
+ {
+ throw new InvalidOperationException(failed.Error.Message);
+ }
+
+ if (!TryCreateAssistantUpdate(evt, observedTextLengths, messageId, out var update))
+ {
+ continue;
+ }
+
+ update.AuthorName = agentName;
+ update.CreatedAt = timeProvider.GetUtcNow();
+ yield return update;
+ }
+ }
+
+ public object? GetService(Type serviceType, object? serviceKey = null)
+ {
+ ArgumentNullException.ThrowIfNull(serviceType);
+ return serviceType == typeof(IChatClient) ? this : null;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _thread?.Dispose();
+ _client?.Dispose();
+ _initializationGate.Dispose();
+ _disposed = true;
+ }
+
+ private async Task EnsureThreadAsync(
+ string? requestedModelName,
+ string systemInstructions,
+ CancellationToken cancellationToken)
+ {
+ var normalizedModelName = string.IsNullOrWhiteSpace(requestedModelName)
+ ? fallbackModelName
+ : requestedModelName.Trim();
+ var normalizedPromptHash = ComputeHash(systemInstructions);
+
+ await _initializationGate.WaitAsync(cancellationToken);
+ try
+ {
+ ThrowIfDisposed();
+
+ if (_thread is not null && _threadState is not null)
+ {
+ return new CodexTurnContext(_thread, _threadState);
+ }
+
+ _client ??= new CodexClient(new CodexOptions());
+
+ var persistedState = await threadStateStore.TryLoadAsync(sessionId, cancellationToken);
+ var workingDirectory = ResolveWorkingDirectory(persistedState);
+ Directory.CreateDirectory(workingDirectory);
+
+ if (CanResumeThread(persistedState, normalizedModelName, normalizedPromptHash))
+ {
+ var resumableState = persistedState!;
+ try
+ {
+ _thread = _client.ResumeThread(
+ resumableState.ThreadId,
+ CreateThreadOptions(normalizedModelName, workingDirectory));
+ _threadState = resumableState;
+ return new CodexTurnContext(_thread, _threadState);
+ }
+ catch
+ {
+ }
+ }
+
+ _thread = _client.StartThread(CreateThreadOptions(normalizedModelName, workingDirectory));
+ _threadState = new LocalCodexThreadState(
+ _thread.Id ?? Guid.CreateVersion7().ToString("N", CultureInfo.InvariantCulture),
+ workingDirectory,
+ normalizedModelName,
+ normalizedPromptHash,
+ InstructionsSeeded: false);
+ await threadStateStore.SaveAsync(sessionId, _threadState, cancellationToken);
+ return new CodexTurnContext(_thread, _threadState);
+ }
+ finally
+ {
+ _initializationGate.Release();
+ }
+ }
+
+ private async Task MarkInstructionsSeededAsync(
+ LocalCodexThreadState state,
+ CancellationToken cancellationToken)
+ {
+ if (state.InstructionsSeeded)
+ {
+ return;
+ }
+
+ var updatedState = state with
+ {
+ InstructionsSeeded = true,
+ };
+
+ await _initializationGate.WaitAsync(cancellationToken);
+ try
+ {
+ _threadState = updatedState;
+ await threadStateStore.SaveAsync(sessionId, updatedState, cancellationToken);
+ }
+ finally
+ {
+ _initializationGate.Release();
+ }
+ }
+
+ private string ResolveWorkingDirectory(LocalCodexThreadState? persistedState)
+ {
+ return string.IsNullOrWhiteSpace(persistedState?.WorkingDirectory)
+ ? threadStateStore.ResolvePlaygroundDirectory(sessionId)
+ : persistedState.WorkingDirectory;
+ }
+
+ private static bool CanResumeThread(
+ LocalCodexThreadState? persistedState,
+ string modelName,
+ string systemPromptHash)
+ {
+ return persistedState is not null &&
+ !string.IsNullOrWhiteSpace(persistedState.ThreadId) &&
+ string.Equals(persistedState.ModelName, modelName, StringComparison.Ordinal) &&
+ string.Equals(persistedState.SystemPromptHash, systemPromptHash, StringComparison.Ordinal);
+ }
+
+ private static ThreadOptions CreateThreadOptions(string modelName, string workingDirectory)
+ {
+ return new ThreadOptions
+ {
+ ApprovalPolicy = ApprovalMode.Never,
+ Model = modelName,
+ SandboxMode = SandboxMode.WorkspaceWrite,
+ SkipGitRepoCheck = true,
+ WorkingDirectory = workingDirectory,
+ };
+ }
+
+ private static IReadOnlyList BuildInputs(
+ string prompt,
+ string systemInstructions,
+ bool instructionsSeeded)
+ {
+ var text = instructionsSeeded || string.IsNullOrWhiteSpace(systemInstructions)
+ ? prompt
+ : string.Create(
+ CultureInfo.InvariantCulture,
+ $$"""
+ Follow these system instructions for this entire session:
+ {{systemInstructions.Trim()}}
+
+ Operator request:
+ {{prompt}}
+ """);
+
+ return [new TextInput(text)];
+ }
+
+ private static string ResolveLatestUserPrompt(IEnumerable messages)
+ {
+ var prompt = messages
+ .LastOrDefault(static message => message.Role == ChatRole.User)
+ ?.Text
+ ?.Trim();
+
+ return string.IsNullOrWhiteSpace(prompt) ? ContinuePrompt : prompt;
+ }
+
+ private static string ResolveResponseText(RunResult result)
+ {
+ if (!string.IsNullOrWhiteSpace(result.FinalResponse))
+ {
+ return result.FinalResponse;
+ }
+
+ return result.Items
+ .OfType()
+ .Select(static item => item.Text)
+ .LastOrDefault(static text => !string.IsNullOrWhiteSpace(text))
+ ?? string.Empty;
+ }
+
+ private static bool TryCreateAssistantUpdate(
+ ThreadEvent evt,
+ Dictionary observedTextLengths,
+ string messageId,
+ out ChatResponseUpdate update)
+ {
+ update = null!;
+ var item = evt switch
+ {
+ ItemUpdatedEvent updated => updated.Item,
+ ItemCompletedEvent completed => completed.Item,
+ _ => null,
+ };
+
+ if (item is not AgentMessageItem assistantMessageItem)
+ {
+ return false;
+ }
+
+ var textKey = string.IsNullOrWhiteSpace(assistantMessageItem.Id)
+ ? messageId
+ : assistantMessageItem.Id;
+ var currentText = assistantMessageItem.Text ?? string.Empty;
+ observedTextLengths.TryGetValue(textKey, out var knownLength);
+ if (currentText.Length <= knownLength)
+ {
+ return false;
+ }
+
+ observedTextLengths[textKey] = currentText.Length;
+ update = new ChatResponseUpdate(ChatRole.Assistant, currentText[knownLength..])
+ {
+ MessageId = messageId,
+ };
+ return true;
+ }
+
+ private static string ComputeHash(string value)
+ {
+ var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty));
+ return Convert.ToHexString(bytes);
+ }
+
+ private void ThrowIfDisposed()
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ }
+
+ private readonly record struct CodexTurnContext(CodexThread Thread, LocalCodexThreadState State);
+}
diff --git a/DotPilot.Core/ChatSessions/Execution/DebugChatClient.cs b/DotPilot.Core/ChatSessions/Execution/DebugChatClient.cs
new file mode 100644
index 0000000..53d1443
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Execution/DebugChatClient.cs
@@ -0,0 +1,115 @@
+using System.Runtime.CompilerServices;
+using Microsoft.Extensions.AI;
+
+namespace DotPilot.Core.ChatSessions;
+
+internal sealed class DebugChatClient(string agentName, TimeProvider timeProvider) : IChatClient
+{
+ private const int DefaultChunkDelayMilliseconds = 45;
+ private const int BrowserChunkDelayMilliseconds = 400;
+ private const string FallbackPrompt = "the latest request";
+ private const string Newline = "\n";
+
+ public Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var responseText = CreateResponseText(messages);
+ var timestamp = timeProvider.GetUtcNow();
+ var message = new ChatMessage(ChatRole.Assistant, responseText)
+ {
+ AuthorName = agentName,
+ CreatedAt = timestamp,
+ MessageId = Guid.CreateVersion7().ToString("N", System.Globalization.CultureInfo.InvariantCulture),
+ };
+
+ var response = new ChatResponse(message)
+ {
+ CreatedAt = timestamp,
+ };
+
+ return Task.FromResult(response);
+ }
+
+ public async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var responseText = CreateResponseText(messages);
+ var messageId = Guid.CreateVersion7().ToString("N", System.Globalization.CultureInfo.InvariantCulture);
+
+ foreach (var chunk in SplitIntoChunks(responseText))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ await Task.Delay(GetChunkDelayMilliseconds(), cancellationToken);
+
+ yield return new ChatResponseUpdate(ChatRole.Assistant, chunk)
+ {
+ AuthorName = agentName,
+ CreatedAt = timeProvider.GetUtcNow(),
+ MessageId = messageId,
+ };
+ }
+ }
+
+ public object? GetService(Type serviceType, object? serviceKey = null)
+ {
+ ArgumentNullException.ThrowIfNull(serviceType);
+ return serviceType == typeof(IChatClient) ? this : null;
+ }
+
+ public void Dispose()
+ {
+ }
+
+ private static IEnumerable SplitIntoChunks(string text)
+ {
+ var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ var chunk = new List(4);
+
+ foreach (var word in words)
+ {
+ chunk.Add(word);
+ if (chunk.Count < 4)
+ {
+ continue;
+ }
+
+ yield return string.Join(' ', chunk) + " ";
+ chunk.Clear();
+ }
+
+ if (chunk.Count > 0)
+ {
+ yield return string.Join(' ', chunk);
+ }
+ }
+
+ private static string CreateResponseText(IEnumerable messages)
+ {
+ var prompt = messages
+ .LastOrDefault(message => message.Role == ChatRole.User)
+ ?.Text
+ ?.Trim();
+
+ var effectivePrompt = string.IsNullOrWhiteSpace(prompt) ? FallbackPrompt : prompt;
+ return string.Join(
+ Newline,
+ [
+ $"Debug provider received: {effectivePrompt}",
+ "This response is deterministic so the desktop shell and UI tests can validate streaming behavior.",
+ "Tool activity is simulated inline before the final assistant answer completes.",
+ ]);
+ }
+
+ private static int GetChunkDelayMilliseconds()
+ {
+ return OperatingSystem.IsBrowser()
+ ? BrowserChunkDelayMilliseconds
+ : DefaultChunkDelayMilliseconds;
+ }
+}
diff --git a/DotPilot.Core/ChatSessions/Execution/SessionActivityMonitor.cs b/DotPilot.Core/ChatSessions/Execution/SessionActivityMonitor.cs
new file mode 100644
index 0000000..954e57e
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Execution/SessionActivityMonitor.cs
@@ -0,0 +1,150 @@
+using DotPilot.Core.ControlPlaneDomain;
+using Microsoft.Extensions.Logging;
+
+namespace DotPilot.Core.ChatSessions;
+
+internal sealed class SessionActivityMonitor(ILogger logger) : ISessionActivityMonitor
+{
+ private static readonly SessionActivitySnapshot EmptySnapshot = new(
+ false,
+ 0,
+ [],
+ null,
+ string.Empty,
+ null,
+ string.Empty,
+ string.Empty);
+
+ private readonly Lock _gate = new();
+ private readonly List _leases = [];
+ private SessionActivitySnapshot _current = EmptySnapshot;
+
+ public SessionActivitySnapshot Current
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return _current;
+ }
+ }
+ }
+
+ public event EventHandler? StateChanged;
+
+ public IDisposable BeginActivity(SessionActivityDescriptor descriptor)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+
+ ActivityLease lease;
+ int activeSessionCount;
+ lock (_gate)
+ {
+ lease = new ActivityLease(this, descriptor);
+ _leases.Add(lease);
+ activeSessionCount = UpdateSnapshotUnsafe().ActiveSessionCount;
+ }
+
+ SessionActivityMonitorLog.ActivityStarted(
+ logger,
+ descriptor.SessionId.Value,
+ descriptor.AgentProfileId.Value,
+ activeSessionCount);
+ StateChanged?.Invoke(this, EventArgs.Empty);
+ return lease;
+ }
+
+ private void EndActivity(ActivityLease lease)
+ {
+ ArgumentNullException.ThrowIfNull(lease);
+
+ int activeSessionCount;
+ lock (_gate)
+ {
+ var index = _leases.IndexOf(lease);
+ if (index < 0)
+ {
+ return;
+ }
+
+ _leases.RemoveAt(index);
+ activeSessionCount = UpdateSnapshotUnsafe().ActiveSessionCount;
+ }
+
+ SessionActivityMonitorLog.ActivityCompleted(
+ logger,
+ lease.Descriptor.SessionId.Value,
+ lease.Descriptor.AgentProfileId.Value,
+ activeSessionCount);
+ StateChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ private SessionActivitySnapshot UpdateSnapshotUnsafe()
+ {
+ if (_leases.Count == 0)
+ {
+ _current = EmptySnapshot;
+ return _current;
+ }
+
+ var activeSessions = GetActiveSessionsUnsafe();
+ _current = CreateSnapshot(activeSessions, _leases[^1].Descriptor);
+ return _current;
+ }
+
+ private List GetActiveSessionsUnsafe()
+ {
+ if (_leases.Count == 0)
+ {
+ return [];
+ }
+
+ HashSet seen = [];
+ List descriptors = [];
+ for (var index = _leases.Count - 1; index >= 0; index--)
+ {
+ var descriptor = _leases[index].Descriptor;
+ if (!seen.Add(descriptor.SessionId))
+ {
+ continue;
+ }
+
+ descriptors.Add(descriptor);
+ }
+
+ descriptors.Reverse();
+ return descriptors;
+ }
+
+ private static SessionActivitySnapshot CreateSnapshot(
+ List activeSessions,
+ SessionActivityDescriptor latestSession)
+ {
+ return new SessionActivitySnapshot(
+ true,
+ activeSessions.Count,
+ activeSessions,
+ latestSession.SessionId,
+ latestSession.SessionTitle,
+ latestSession.AgentProfileId,
+ latestSession.AgentName,
+ latestSession.ProviderDisplayName);
+ }
+
+ private sealed class ActivityLease(SessionActivityMonitor owner, SessionActivityDescriptor descriptor) : IDisposable
+ {
+ private int _disposed;
+
+ public SessionActivityDescriptor Descriptor { get; } = descriptor;
+
+ public void Dispose()
+ {
+ if (Interlocked.Exchange(ref _disposed, 1) != 0)
+ {
+ return;
+ }
+
+ owner.EndActivity(this);
+ }
+ }
+}
diff --git a/DotPilot.Core/ChatSessions/Interfaces/IAgentSessionService.cs b/DotPilot.Core/ChatSessions/Interfaces/IAgentSessionService.cs
new file mode 100644
index 0000000..c27be34
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Interfaces/IAgentSessionService.cs
@@ -0,0 +1,33 @@
+using DotPilot.Core.ControlPlaneDomain;
+using ManagedCode.Communication;
+
+namespace DotPilot.Core.ChatSessions.Interfaces;
+
+public interface IAgentSessionService
+{
+ ValueTask> GetWorkspaceAsync(CancellationToken cancellationToken);
+
+ ValueTask> RefreshWorkspaceAsync(CancellationToken cancellationToken);
+
+ ValueTask> GetSessionAsync(SessionId sessionId, CancellationToken cancellationToken);
+
+ ValueTask> CreateAgentAsync(
+ CreateAgentProfileCommand command,
+ CancellationToken cancellationToken);
+
+ ValueTask> UpdateAgentAsync(
+ UpdateAgentProfileCommand command,
+ CancellationToken cancellationToken);
+
+ ValueTask> CreateSessionAsync(
+ CreateSessionCommand command,
+ CancellationToken cancellationToken);
+
+ ValueTask> UpdateProviderAsync(
+ UpdateProviderPreferenceCommand command,
+ CancellationToken cancellationToken);
+
+ IAsyncEnumerable> SendMessageAsync(
+ SendSessionMessageCommand command,
+ CancellationToken cancellationToken);
+}
diff --git a/DotPilot.Core/ChatSessions/Interfaces/ISessionActivityMonitor.cs b/DotPilot.Core/ChatSessions/Interfaces/ISessionActivityMonitor.cs
new file mode 100644
index 0000000..5df5bd6
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Interfaces/ISessionActivityMonitor.cs
@@ -0,0 +1,10 @@
+namespace DotPilot.Core.ChatSessions;
+
+public interface ISessionActivityMonitor
+{
+ SessionActivitySnapshot Current { get; }
+
+ event EventHandler? StateChanged;
+
+ IDisposable BeginActivity(SessionActivityDescriptor descriptor);
+}
diff --git a/DotPilot.Core/ChatSessions/Models/AgentExecutionLoggingModels.cs b/DotPilot.Core/ChatSessions/Models/AgentExecutionLoggingModels.cs
new file mode 100644
index 0000000..a25565f
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Models/AgentExecutionLoggingModels.cs
@@ -0,0 +1,118 @@
+using System.Globalization;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+
+namespace DotPilot.Core.ChatSessions;
+
+internal sealed partial class AgentExecutionLoggingMiddleware
+{
+ private const string SessionIdPropertyName = "dotpilot.session.id";
+ private const string RunIdPropertyName = "dotpilot.run.id";
+ private const string AgentIdPropertyName = "dotpilot.agent.id";
+ private const string AgentNamePropertyName = "dotpilot.agent.name";
+ private const string ProviderKindPropertyName = "dotpilot.provider.kind";
+ private const string ProviderDisplayNamePropertyName = "dotpilot.provider.display_name";
+ private const string ModelNamePropertyName = "dotpilot.model.name";
+ private const string UnknownValue = "";
+ private const string NoneValue = "";
+
+ private static IReadOnlyList MaterializeMessages(IEnumerable messages)
+ {
+ ArgumentNullException.ThrowIfNull(messages);
+ return messages as IReadOnlyList ?? messages.ToArray();
+ }
+
+ private static int CountMessageCharacters(IEnumerable messages)
+ {
+ var total = 0;
+ foreach (var message in messages)
+ {
+ total += message.Text?.Length ?? 0;
+ }
+
+ return total;
+ }
+
+ private static int CountTools(ChatOptions? options)
+ {
+ return options?.Tools?.Count ?? 0;
+ }
+
+ private static AdditionalPropertiesDictionary CreateAdditionalProperties(AgentRunLogContext runContext)
+ {
+ return new AdditionalPropertiesDictionary
+ {
+ [RunIdPropertyName] = runContext.RunId,
+ [SessionIdPropertyName] = runContext.SessionId,
+ [AgentIdPropertyName] = runContext.AgentId.ToString("N", CultureInfo.InvariantCulture),
+ [AgentNamePropertyName] = runContext.AgentName,
+ [ProviderKindPropertyName] = runContext.ProviderKind.ToString(),
+ [ProviderDisplayNamePropertyName] = runContext.ProviderDisplayName,
+ [ModelNamePropertyName] = runContext.ModelName,
+ };
+ }
+
+ private static AgentRunLogContext ResolveRunContext(
+ AgentExecutionDescriptor descriptor,
+ AgentRunOptions? options)
+ {
+ var properties = options?.AdditionalProperties;
+ return new AgentRunLogContext(
+ GetAdditionalProperty(properties, RunIdPropertyName, NoneValue),
+ GetAdditionalProperty(properties, SessionIdPropertyName, UnknownValue),
+ descriptor.AgentId,
+ GetAdditionalProperty(properties, AgentNamePropertyName, descriptor.AgentName),
+ descriptor.ProviderKind,
+ GetAdditionalProperty(properties, ProviderDisplayNamePropertyName, descriptor.ProviderDisplayName),
+ GetAdditionalProperty(properties, ModelNamePropertyName, descriptor.ModelName));
+ }
+
+ private IDisposable? BeginScope(AgentRunLogContext runContext)
+ {
+ return logger.BeginScope(new Dictionary
+ {
+ [RunIdPropertyName] = runContext.RunId,
+ [SessionIdPropertyName] = runContext.SessionId,
+ [AgentIdPropertyName] = runContext.AgentId.ToString("N", CultureInfo.InvariantCulture),
+ [AgentNamePropertyName] = runContext.AgentName,
+ [ProviderKindPropertyName] = runContext.ProviderKind.ToString(),
+ [ProviderDisplayNamePropertyName] = runContext.ProviderDisplayName,
+ [ModelNamePropertyName] = runContext.ModelName,
+ });
+ }
+
+ private static string GetAdditionalProperty(
+ AdditionalPropertiesDictionary? properties,
+ string key,
+ string fallback)
+ {
+ if (properties is not null &&
+ properties.TryGetValue(key, out var value) &&
+ value is not null)
+ {
+ return Convert.ToString(value, CultureInfo.InvariantCulture) ?? fallback;
+ }
+
+ return fallback;
+ }
+}
+
+internal sealed record AgentExecutionDescriptor(
+ Guid AgentId,
+ string AgentName,
+ AgentProviderKind ProviderKind,
+ string ProviderDisplayName,
+ string ModelName);
+
+internal sealed record AgentRunLogContext(
+ string RunId,
+ string SessionId,
+ Guid AgentId,
+ string AgentName,
+ AgentProviderKind ProviderKind,
+ string ProviderDisplayName,
+ string ModelName);
+
+internal sealed record AgentExecutionRunConfiguration(
+ AgentRunLogContext Context,
+ ChatClientAgentRunOptions Options);
diff --git a/DotPilot.Core/ChatSessions/Models/AgentSessionStates.cs b/DotPilot.Core/ChatSessions/Models/AgentSessionStates.cs
new file mode 100644
index 0000000..97ff9d1
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Models/AgentSessionStates.cs
@@ -0,0 +1,28 @@
+namespace DotPilot.Core.ChatSessions.Models;
+
+public enum AgentProviderKind
+{
+ Debug,
+ Codex,
+ ClaudeCode,
+ GitHubCopilot,
+}
+
+public enum AgentProviderStatus
+{
+ Ready,
+ RequiresSetup,
+ Disabled,
+ Unsupported,
+ Error,
+}
+
+public enum SessionStreamEntryKind
+{
+ UserMessage,
+ AssistantMessage,
+ ToolStarted,
+ ToolCompleted,
+ Status,
+ Error,
+}
diff --git a/DotPilot.Core/ChatSessions/Models/RuntimeConversationContext.cs b/DotPilot.Core/ChatSessions/Models/RuntimeConversationContext.cs
new file mode 100644
index 0000000..95ffd9c
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Models/RuntimeConversationContext.cs
@@ -0,0 +1,9 @@
+using Microsoft.Agents.AI;
+
+namespace DotPilot.Core.ChatSessions;
+
+internal sealed record RuntimeConversationContext(
+ AIAgent Agent,
+ AgentSession Session,
+ AgentExecutionDescriptor Descriptor,
+ bool IsTransient = false);
diff --git a/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentProfileSchemaDefaults.cs b/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentProfileSchemaDefaults.cs
new file mode 100644
index 0000000..f09d3ca
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentProfileSchemaDefaults.cs
@@ -0,0 +1,9 @@
+namespace DotPilot.Core.ChatSessions;
+
+internal static class AgentProfileSchemaDefaults
+{
+ // Legacy SQLite stores still require these columns even though the current UI no longer edits them.
+ public const int DefaultRole = 4;
+
+ public const string EmptyCapabilitiesJson = "[]";
+}
diff --git a/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentSessionJsonSerializerContext.cs b/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentSessionJsonSerializerContext.cs
new file mode 100644
index 0000000..263d5c9
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentSessionJsonSerializerContext.cs
@@ -0,0 +1,8 @@
+using System.Text.Json.Serialization;
+
+namespace DotPilot.Core.ChatSessions;
+
+[JsonSerializable(typeof(string[]))]
+internal sealed partial class AgentSessionJsonSerializerContext : JsonSerializerContext
+{
+}
diff --git a/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentSessionSerialization.cs b/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentSessionSerialization.cs
new file mode 100644
index 0000000..45e221f
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentSessionSerialization.cs
@@ -0,0 +1,18 @@
+using System.Text.Json;
+using System.Text.Json.Serialization.Metadata;
+
+namespace DotPilot.Core.ChatSessions;
+
+internal static class AgentSessionSerialization
+{
+ public static JsonSerializerOptions Options { get; } = CreateOptions();
+
+ private static JsonSerializerOptions CreateOptions()
+ {
+ return new JsonSerializerOptions
+ {
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
+ WriteIndented = false,
+ };
+ }
+}
diff --git a/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentSessionStorageOptions.cs b/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentSessionStorageOptions.cs
new file mode 100644
index 0000000..28892b5
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentSessionStorageOptions.cs
@@ -0,0 +1,18 @@
+namespace DotPilot.Core.ChatSessions;
+
+public sealed class AgentSessionStorageOptions
+{
+ public bool UseInMemoryDatabase { get; init; }
+
+ public bool PreferTransientRuntimeConversation { get; init; }
+
+ public string InMemoryDatabaseName { get; init; } = "DotPilotAgentSessions";
+
+ public string? DatabasePath { get; init; }
+
+ public string? RuntimeSessionDirectoryPath { get; init; }
+
+ public string? ChatHistoryDirectoryPath { get; init; }
+
+ public string? PlaygroundDirectoryPath { get; init; }
+}
diff --git a/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentSessionStoragePaths.cs b/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentSessionStoragePaths.cs
new file mode 100644
index 0000000..5c27981
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentSessionStoragePaths.cs
@@ -0,0 +1,67 @@
+using DotPilot.Core.ControlPlaneDomain;
+
+namespace DotPilot.Core.ChatSessions;
+
+internal static class AgentSessionStoragePaths
+{
+ private const string AppFolderName = "DotPilot";
+ private const string DatabaseFileName = "dotpilot-agent-sessions.db";
+ private const string RuntimeSessionsFolderName = "agent-runtime-sessions";
+ private const string ChatHistoryFolderName = "agent-chat-history";
+ private const string PlaygroundFolderName = "agent-playgrounds";
+
+ public static string ResolveDatabasePath(AgentSessionStorageOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ if (!string.IsNullOrWhiteSpace(options.DatabasePath))
+ {
+ return options.DatabasePath;
+ }
+
+ return Path.Combine(GetAppStorageRoot(), DatabaseFileName);
+ }
+
+ public static string ResolveRuntimeSessionDirectory(AgentSessionStorageOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return string.IsNullOrWhiteSpace(options.RuntimeSessionDirectoryPath)
+ ? Path.Combine(GetAppStorageRoot(), RuntimeSessionsFolderName)
+ : options.RuntimeSessionDirectoryPath;
+ }
+
+ public static string ResolveChatHistoryDirectory(AgentSessionStorageOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return string.IsNullOrWhiteSpace(options.ChatHistoryDirectoryPath)
+ ? Path.Combine(GetAppStorageRoot(), ChatHistoryFolderName)
+ : options.ChatHistoryDirectoryPath;
+ }
+
+ public static string ResolvePlaygroundRootDirectory(AgentSessionStorageOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return string.IsNullOrWhiteSpace(options.PlaygroundDirectoryPath)
+ ? Path.Combine(GetAppStorageRoot(), PlaygroundFolderName)
+ : options.PlaygroundDirectoryPath;
+ }
+
+ public static string ResolvePlaygroundDirectory(
+ AgentSessionStorageOptions options,
+ SessionId sessionId)
+ {
+ return Path.Combine(
+ ResolvePlaygroundRootDirectory(options),
+ sessionId.Value.ToString("N", System.Globalization.CultureInfo.InvariantCulture));
+ }
+
+ private static string GetAppStorageRoot()
+ {
+ return Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ AppFolderName);
+ }
+}
diff --git a/DotPilot.Core/ChatSessions/Persistence/Models/FolderChatHistoryState.cs b/DotPilot.Core/ChatSessions/Persistence/Models/FolderChatHistoryState.cs
new file mode 100644
index 0000000..f757f6e
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Persistence/Models/FolderChatHistoryState.cs
@@ -0,0 +1,6 @@
+namespace DotPilot.Core.ChatSessions;
+
+internal sealed class FolderChatHistoryState
+{
+ public string? StorageKey { get; set; }
+}
diff --git a/DotPilot.Core/ChatSessions/Persistence/Models/LocalAgentSessionRecords.cs b/DotPilot.Core/ChatSessions/Persistence/Models/LocalAgentSessionRecords.cs
new file mode 100644
index 0000000..7aa2f1e
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Persistence/Models/LocalAgentSessionRecords.cs
@@ -0,0 +1,63 @@
+namespace DotPilot.Core.ChatSessions;
+
+internal sealed class AgentProfileRecord
+{
+ public Guid Id { get; set; }
+
+ public string Name { get; set; } = string.Empty;
+
+ public string Description { get; set; } = string.Empty;
+
+ public int Role { get; set; }
+
+ public int ProviderKind { get; set; }
+
+ public string ModelName { get; set; } = string.Empty;
+
+ public string SystemPrompt { get; set; } = string.Empty;
+
+ public string CapabilitiesJson { get; set; } = AgentProfileSchemaDefaults.EmptyCapabilitiesJson;
+
+ public DateTimeOffset CreatedAt { get; set; }
+}
+
+internal sealed class SessionRecord
+{
+ public Guid Id { get; set; }
+
+ public string Title { get; set; } = string.Empty;
+
+ public Guid PrimaryAgentProfileId { get; set; }
+
+ public DateTimeOffset CreatedAt { get; set; }
+
+ public DateTimeOffset UpdatedAt { get; set; }
+}
+
+internal sealed class SessionEntryRecord
+{
+ public string Id { get; set; } = string.Empty;
+
+ public Guid SessionId { get; set; }
+
+ public Guid? AgentProfileId { get; set; }
+
+ public int Kind { get; set; }
+
+ public string Author { get; set; } = string.Empty;
+
+ public string Text { get; set; } = string.Empty;
+
+ public string? AccentLabel { get; set; }
+
+ public DateTimeOffset Timestamp { get; set; }
+}
+
+internal sealed class ProviderPreferenceRecord
+{
+ public int ProviderKind { get; set; }
+
+ public bool IsEnabled { get; set; }
+
+ public DateTimeOffset UpdatedAt { get; set; }
+}
diff --git a/DotPilot.Core/ChatSessions/Persistence/Models/LocalCodexThreadState.cs b/DotPilot.Core/ChatSessions/Persistence/Models/LocalCodexThreadState.cs
new file mode 100644
index 0000000..77c8954
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Persistence/Models/LocalCodexThreadState.cs
@@ -0,0 +1,8 @@
+namespace DotPilot.Core.ChatSessions;
+
+internal sealed record LocalCodexThreadState(
+ string ThreadId,
+ string WorkingDirectory,
+ string ModelName,
+ string SystemPromptHash,
+ bool InstructionsSeeded);
diff --git a/DotPilot.Core/ChatSessions/Persistence/Services/AgentProfileSchemaCompatibilityEnsurer.cs b/DotPilot.Core/ChatSessions/Persistence/Services/AgentProfileSchemaCompatibilityEnsurer.cs
new file mode 100644
index 0000000..55fe86b
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Persistence/Services/AgentProfileSchemaCompatibilityEnsurer.cs
@@ -0,0 +1,88 @@
+using System.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace DotPilot.Core.ChatSessions;
+
+internal static class AgentProfileSchemaCompatibilityEnsurer
+{
+ private const string AgentProfilesTableName = "AgentProfiles";
+ private const string DescriptionColumnName = "Description";
+ private const string RoleColumnName = "Role";
+ private const string CapabilitiesJsonColumnName = "CapabilitiesJson";
+
+ public static async Task EnsureAsync(LocalAgentSessionDbContext dbContext, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(dbContext);
+
+ if (!dbContext.Database.IsSqlite())
+ {
+ return;
+ }
+
+ var existingColumns = await ReadColumnNamesAsync(dbContext, cancellationToken);
+ if (!existingColumns.Contains(DescriptionColumnName, StringComparer.OrdinalIgnoreCase))
+ {
+ await dbContext.Database.ExecuteSqlRawAsync(
+ $"""
+ ALTER TABLE "{AgentProfilesTableName}"
+ ADD COLUMN "{DescriptionColumnName}" TEXT NOT NULL DEFAULT '';
+ """,
+ cancellationToken);
+ }
+
+ if (!existingColumns.Contains(RoleColumnName, StringComparer.OrdinalIgnoreCase))
+ {
+ await dbContext.Database.ExecuteSqlRawAsync(
+ $"""
+ ALTER TABLE "{AgentProfilesTableName}"
+ ADD COLUMN "{RoleColumnName}" INTEGER NOT NULL DEFAULT {AgentProfileSchemaDefaults.DefaultRole};
+ """,
+ cancellationToken);
+ }
+
+ if (!existingColumns.Contains(CapabilitiesJsonColumnName, StringComparer.OrdinalIgnoreCase))
+ {
+ await dbContext.Database.ExecuteSqlRawAsync(
+ $"""
+ ALTER TABLE "{AgentProfilesTableName}"
+ ADD COLUMN "{CapabilitiesJsonColumnName}" TEXT NOT NULL DEFAULT '{AgentProfileSchemaDefaults.EmptyCapabilitiesJson}';
+ """,
+ cancellationToken);
+ }
+ }
+
+ private static async Task> ReadColumnNamesAsync(
+ LocalAgentSessionDbContext dbContext,
+ CancellationToken cancellationToken)
+ {
+ var connection = dbContext.Database.GetDbConnection();
+ var shouldCloseConnection = connection.State != ConnectionState.Open;
+ if (shouldCloseConnection)
+ {
+ await connection.OpenAsync(cancellationToken);
+ }
+
+ try
+ {
+ await using var command = connection.CreateCommand();
+ command.CommandText = $"""PRAGMA table_info("{AgentProfilesTableName}")""";
+
+ HashSet columns = new(StringComparer.OrdinalIgnoreCase);
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken);
+ var nameOrdinal = reader.GetOrdinal("name");
+ while (await reader.ReadAsync(cancellationToken))
+ {
+ columns.Add(reader.GetString(nameOrdinal));
+ }
+
+ return columns;
+ }
+ finally
+ {
+ if (shouldCloseConnection)
+ {
+ await connection.CloseAsync();
+ }
+ }
+ }
+}
diff --git a/DotPilot.Core/ChatSessions/Persistence/Services/FolderChatHistoryProvider.cs b/DotPilot.Core/ChatSessions/Persistence/Services/FolderChatHistoryProvider.cs
new file mode 100644
index 0000000..d818dc7
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Persistence/Services/FolderChatHistoryProvider.cs
@@ -0,0 +1,101 @@
+using System.Globalization;
+using DotPilot.Core.ControlPlaneDomain;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+
+namespace DotPilot.Core.ChatSessions;
+
+internal sealed class FolderChatHistoryProvider(LocalAgentChatHistoryStore chatHistoryStore)
+ : ChatHistoryProvider(
+ provideOutputMessageFilter: static messages => messages,
+ storeInputRequestMessageFilter: static messages => messages,
+ storeInputResponseMessageFilter: static messages => messages)
+{
+ private const string ProviderStateKey = "DotPilot.AgentSessionHistory";
+ private static readonly ProviderSessionState SessionState = new(
+ static _ => new FolderChatHistoryState(),
+ ProviderStateKey,
+ AgentSessionSerialization.Options);
+
+ public static void BindToSession(AgentSession session, SessionId sessionId)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+
+ var state = SessionState.GetOrInitializeState(session);
+ state.StorageKey = sessionId.Value.ToString("N", CultureInfo.InvariantCulture);
+ SessionState.SaveState(session, state);
+ }
+
+ protected override async ValueTask> ProvideChatHistoryAsync(
+ InvokingContext context,
+ CancellationToken cancellationToken)
+ {
+ if (context.Session is null)
+ {
+ return [];
+ }
+
+ var storageKey = GetStorageKey(context.Session);
+ return storageKey is null
+ ? []
+ : await chatHistoryStore.LoadAsync(storageKey, cancellationToken);
+ }
+
+ protected override async ValueTask StoreChatHistoryAsync(
+ InvokedContext context,
+ CancellationToken cancellationToken)
+ {
+ if (context.Session is null)
+ {
+ return;
+ }
+
+ var storageKey = GetStorageKey(context.Session);
+ if (storageKey is null)
+ {
+ return;
+ }
+
+ var existing = await chatHistoryStore.LoadAsync(storageKey, cancellationToken);
+ var knownMessageKeys = existing
+ .Select(CreateMessageKey)
+ .ToHashSet(StringComparer.Ordinal);
+ var responseMessages = context.ResponseMessages ?? [];
+ var newMessages = context.RequestMessages
+ .Concat(responseMessages)
+ .Where(message => knownMessageKeys.Add(CreateMessageKey(message)))
+ .ToArray();
+ if (newMessages.Length == 0)
+ {
+ return;
+ }
+
+ await chatHistoryStore.AppendAsync(
+ storageKey,
+ newMessages,
+ cancellationToken);
+ }
+
+ private static string? GetStorageKey(AgentSession session)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+
+ var state = SessionState.GetOrInitializeState(session);
+ return string.IsNullOrWhiteSpace(state.StorageKey) ? null : state.StorageKey;
+ }
+
+ private static string CreateMessageKey(ChatMessage message)
+ {
+ ArgumentNullException.ThrowIfNull(message);
+
+ return string.IsNullOrWhiteSpace(message.MessageId)
+ ? string.Format(
+ System.Globalization.CultureInfo.InvariantCulture,
+ "{0}|{1}|{2:O}|{3}",
+ message.Role,
+ message.AuthorName,
+ message.CreatedAt,
+ message.Text)
+ : message.MessageId;
+ }
+}
diff --git a/DotPilot.Core/ChatSessions/Persistence/Services/LocalAgentChatHistoryStore.cs b/DotPilot.Core/ChatSessions/Persistence/Services/LocalAgentChatHistoryStore.cs
new file mode 100644
index 0000000..6cad909
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Persistence/Services/LocalAgentChatHistoryStore.cs
@@ -0,0 +1,91 @@
+using System.Text.Json;
+using Microsoft.Extensions.AI;
+
+namespace DotPilot.Core.ChatSessions;
+
+internal sealed class LocalAgentChatHistoryStore(AgentSessionStorageOptions storageOptions)
+{
+ private const string FileExtension = ".json";
+ private const string TempSuffix = ".tmp";
+ private readonly Dictionary _memoryHistory = [];
+
+ public async ValueTask> LoadAsync(
+ string storageKey,
+ CancellationToken cancellationToken)
+ {
+ var path = GetPath(storageKey);
+ if (UseTransientStore())
+ {
+ return _memoryHistory.GetValueOrDefault(path) ?? [];
+ }
+
+ if (!File.Exists(path))
+ {
+ return [];
+ }
+
+ await using var stream = File.OpenRead(path);
+ var messages = await JsonSerializer.DeserializeAsync(
+ stream,
+ AgentSessionSerialization.Options,
+ cancellationToken);
+
+ return messages ?? [];
+ }
+
+ public async ValueTask AppendAsync(
+ string storageKey,
+ IEnumerable messages,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(messages);
+
+ var existing = await LoadAsync(storageKey, cancellationToken);
+ var combined = existing.Concat(messages).ToArray();
+ var path = GetPath(storageKey);
+ if (UseTransientStore())
+ {
+ _memoryHistory[path] = combined;
+ return;
+ }
+
+ await WriteAsync(path, combined, cancellationToken);
+ }
+
+ private string GetPath(string storageKey)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(storageKey);
+
+ var directory = AgentSessionStoragePaths.ResolveChatHistoryDirectory(storageOptions);
+ return Path.Combine(directory, storageKey + FileExtension);
+ }
+
+ private static async ValueTask WriteAsync(
+ string path,
+ ChatMessage[] payload,
+ CancellationToken cancellationToken)
+ {
+ var directory = Path.GetDirectoryName(path);
+ if (!string.IsNullOrWhiteSpace(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ var tempPath = path + TempSuffix;
+ await using (var stream = File.Create(tempPath))
+ {
+ await JsonSerializer.SerializeAsync(
+ stream,
+ payload,
+ AgentSessionSerialization.Options,
+ cancellationToken);
+ }
+
+ File.Move(tempPath, path, overwrite: true);
+ }
+
+ private bool UseTransientStore()
+ {
+ return storageOptions.UseInMemoryDatabase || OperatingSystem.IsBrowser();
+ }
+}
diff --git a/DotPilot.Core/ChatSessions/Persistence/Services/LocalAgentSessionStateStore.cs b/DotPilot.Core/ChatSessions/Persistence/Services/LocalAgentSessionStateStore.cs
new file mode 100644
index 0000000..4032c52
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Persistence/Services/LocalAgentSessionStateStore.cs
@@ -0,0 +1,101 @@
+using System.Globalization;
+using System.Text.Json;
+using DotPilot.Core.ControlPlaneDomain;
+using Microsoft.Agents.AI;
+
+namespace DotPilot.Core.ChatSessions;
+
+internal sealed class LocalAgentSessionStateStore(AgentSessionStorageOptions storageOptions)
+{
+ private const string FileExtension = ".json";
+ private const string TempSuffix = ".tmp";
+ private readonly Dictionary _memorySessions = [];
+
+ public async ValueTask TryLoadAsync(
+ AIAgent agent,
+ SessionId sessionId,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(agent);
+
+ var path = GetPath(sessionId);
+ string? payload;
+ if (UseTransientStore())
+ {
+ payload = _memorySessions.GetValueOrDefault(path);
+ }
+ else
+ {
+ if (!File.Exists(path))
+ {
+ return null;
+ }
+
+ payload = await File.ReadAllTextAsync(path, cancellationToken);
+ }
+
+ if (string.IsNullOrWhiteSpace(payload))
+ {
+ return null;
+ }
+
+ using var document = JsonDocument.Parse(payload);
+ return await agent.DeserializeSessionAsync(
+ document.RootElement.Clone(),
+ AgentSessionSerialization.Options,
+ cancellationToken);
+ }
+
+ public async ValueTask SaveAsync(
+ AIAgent agent,
+ AgentSession session,
+ SessionId sessionId,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(agent);
+ ArgumentNullException.ThrowIfNull(session);
+
+ var serialized = await agent.SerializeSessionAsync(
+ session,
+ AgentSessionSerialization.Options,
+ cancellationToken);
+ var path = GetPath(sessionId);
+ var payload = serialized.GetRawText();
+ if (UseTransientStore())
+ {
+ _memorySessions[path] = payload;
+ return;
+ }
+
+ await WriteTextAsync(path, payload, cancellationToken);
+ }
+
+ private string GetPath(SessionId sessionId)
+ {
+ var directory = AgentSessionStoragePaths.ResolveRuntimeSessionDirectory(storageOptions);
+ return Path.Combine(
+ directory,
+ sessionId.Value.ToString("N", CultureInfo.InvariantCulture) + FileExtension);
+ }
+
+ private static async ValueTask WriteTextAsync(
+ string path,
+ string payload,
+ CancellationToken cancellationToken)
+ {
+ var directory = Path.GetDirectoryName(path);
+ if (!string.IsNullOrWhiteSpace(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ var tempPath = path + TempSuffix;
+ await File.WriteAllTextAsync(tempPath, payload, cancellationToken);
+ File.Move(tempPath, path, overwrite: true);
+ }
+
+ private bool UseTransientStore()
+ {
+ return storageOptions.UseInMemoryDatabase || OperatingSystem.IsBrowser();
+ }
+}
diff --git a/DotPilot.Core/ChatSessions/Persistence/Services/LocalCodexThreadStateStore.cs b/DotPilot.Core/ChatSessions/Persistence/Services/LocalCodexThreadStateStore.cs
new file mode 100644
index 0000000..c3c8160
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Persistence/Services/LocalCodexThreadStateStore.cs
@@ -0,0 +1,82 @@
+using System.Text.Json;
+using DotPilot.Core.ControlPlaneDomain;
+
+namespace DotPilot.Core.ChatSessions;
+
+internal sealed class LocalCodexThreadStateStore(AgentSessionStorageOptions storageOptions)
+{
+ private const string StateFileName = "codex-thread.json";
+ private const string TempSuffix = ".tmp";
+ private readonly Dictionary _memoryStates = [];
+
+ public async ValueTask TryLoadAsync(
+ SessionId sessionId,
+ CancellationToken cancellationToken)
+ {
+ var path = GetPath(sessionId);
+ if (UseTransientStore())
+ {
+ return _memoryStates.GetValueOrDefault(path);
+ }
+
+ if (!File.Exists(path))
+ {
+ return null;
+ }
+
+ await using var stream = File.OpenRead(path);
+ return await JsonSerializer.DeserializeAsync(
+ stream,
+ AgentSessionSerialization.Options,
+ cancellationToken);
+ }
+
+ public async ValueTask SaveAsync(
+ SessionId sessionId,
+ LocalCodexThreadState state,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(state);
+
+ var path = GetPath(sessionId);
+ if (UseTransientStore())
+ {
+ _memoryStates[path] = state;
+ return;
+ }
+
+ var directory = Path.GetDirectoryName(path);
+ if (!string.IsNullOrWhiteSpace(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ var tempPath = path + TempSuffix;
+ await using (var stream = File.Create(tempPath))
+ {
+ await JsonSerializer.SerializeAsync(
+ stream,
+ state,
+ AgentSessionSerialization.Options,
+ cancellationToken);
+ }
+
+ File.Move(tempPath, path, overwrite: true);
+ }
+
+ public string ResolvePlaygroundDirectory(SessionId sessionId)
+ {
+ return AgentSessionStoragePaths.ResolvePlaygroundDirectory(storageOptions, sessionId);
+ }
+
+ private string GetPath(SessionId sessionId)
+ {
+ var directory = ResolvePlaygroundDirectory(sessionId);
+ return Path.Combine(directory, StateFileName);
+ }
+
+ private bool UseTransientStore()
+ {
+ return storageOptions.UseInMemoryDatabase || OperatingSystem.IsBrowser();
+ }
+}
diff --git a/DotPilot.Core/ChatSessions/Persistence/Storage/LocalAgentSessionDbContext.cs b/DotPilot.Core/ChatSessions/Persistence/Storage/LocalAgentSessionDbContext.cs
new file mode 100644
index 0000000..0a88d7b
--- /dev/null
+++ b/DotPilot.Core/ChatSessions/Persistence/Storage/LocalAgentSessionDbContext.cs
@@ -0,0 +1,55 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace DotPilot.Core.ChatSessions;
+
+internal sealed class LocalAgentSessionDbContext(DbContextOptions options)
+ : DbContext(options)
+{
+ public DbSet AgentProfiles => Set();
+
+ public DbSet Sessions => Set();
+
+ public DbSet SessionEntries => Set();
+
+ public DbSet ProviderPreferences => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(record => record.Id);
+ entity.Property(record => record.Name).IsRequired();
+ entity.Property(record => record.Description)
+ .HasDefaultValue(string.Empty)
+ .IsRequired();
+ entity.Property(record => record.Role)
+ .HasDefaultValue(AgentProfileSchemaDefaults.DefaultRole);
+ entity.Property(record => record.ModelName).IsRequired();
+ entity.Property(record => record.SystemPrompt).IsRequired();
+ entity.Property(record => record.CapabilitiesJson)
+ .HasDefaultValue(AgentProfileSchemaDefaults.EmptyCapabilitiesJson)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(record => record.Id);
+ entity.Property(record => record.Title).IsRequired();
+ entity.HasIndex(record => record.UpdatedAt);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(record => record.Id);
+ entity.Property(record => record.Author).IsRequired();
+ entity.Property(record => record.Text).IsRequired();
+ entity.HasIndex(record => new { record.SessionId, record.Timestamp });
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(record => record.ProviderKind);
+ entity.Property(record => record.ProviderKind).ValueGeneratedNever();
+ });
+ }
+}
diff --git a/DotPilot.Core/Features/ControlPlaneDomain/ParticipantContracts.cs b/DotPilot.Core/ControlPlaneDomain/Contracts/ParticipantContracts.cs
similarity index 91%
rename from DotPilot.Core/Features/ControlPlaneDomain/ParticipantContracts.cs
rename to DotPilot.Core/ControlPlaneDomain/Contracts/ParticipantContracts.cs
index 4bb177c..1466999 100644
--- a/DotPilot.Core/Features/ControlPlaneDomain/ParticipantContracts.cs
+++ b/DotPilot.Core/ControlPlaneDomain/Contracts/ParticipantContracts.cs
@@ -1,4 +1,4 @@
-namespace DotPilot.Core.Features.ControlPlaneDomain;
+namespace DotPilot.Core.ControlPlaneDomain;
[GenerateSerializer]
public sealed record WorkspaceDescriptor
@@ -26,18 +26,15 @@ public sealed record AgentProfileDescriptor
public string Name { get; init; } = string.Empty;
[Id(2)]
- public AgentRoleKind Role { get; init; }
-
- [Id(3)]
public ProviderId? ProviderId { get; init; }
- [Id(4)]
+ [Id(3)]
public ModelRuntimeId? ModelRuntimeId { get; init; }
- [Id(5)]
+ [Id(4)]
public ToolCapabilityId[] ToolCapabilityIds { get; init; } = [];
- [Id(6)]
+ [Id(5)]
public string[] Tags { get; init; } = [];
}
diff --git a/DotPilot.Core/Features/ControlPlaneDomain/ProviderAndToolContracts.cs b/DotPilot.Core/ControlPlaneDomain/Contracts/ProviderAndToolContracts.cs
similarity index 96%
rename from DotPilot.Core/Features/ControlPlaneDomain/ProviderAndToolContracts.cs
rename to DotPilot.Core/ControlPlaneDomain/Contracts/ProviderAndToolContracts.cs
index 8aa0232..1da6ab9 100644
--- a/DotPilot.Core/Features/ControlPlaneDomain/ProviderAndToolContracts.cs
+++ b/DotPilot.Core/ControlPlaneDomain/Contracts/ProviderAndToolContracts.cs
@@ -1,4 +1,4 @@
-namespace DotPilot.Core.Features.ControlPlaneDomain;
+namespace DotPilot.Core.ControlPlaneDomain;
[GenerateSerializer]
public sealed record ToolCapabilityDescriptor
diff --git a/DotPilot.Core/Features/ControlPlaneDomain/SessionExecutionContracts.cs b/DotPilot.Core/ControlPlaneDomain/Contracts/SessionExecutionContracts.cs
similarity index 98%
rename from DotPilot.Core/Features/ControlPlaneDomain/SessionExecutionContracts.cs
rename to DotPilot.Core/ControlPlaneDomain/Contracts/SessionExecutionContracts.cs
index 6a3e83d..f3671a7 100644
--- a/DotPilot.Core/Features/ControlPlaneDomain/SessionExecutionContracts.cs
+++ b/DotPilot.Core/ControlPlaneDomain/Contracts/SessionExecutionContracts.cs
@@ -1,4 +1,4 @@
-namespace DotPilot.Core.Features.ControlPlaneDomain;
+namespace DotPilot.Core.ControlPlaneDomain;
[GenerateSerializer]
public sealed record SessionDescriptor
diff --git a/DotPilot.Core/Features/ControlPlaneDomain/ControlPlaneIdentifiers.cs b/DotPilot.Core/ControlPlaneDomain/Identifiers/ControlPlaneIdentifiers.cs
similarity index 98%
rename from DotPilot.Core/Features/ControlPlaneDomain/ControlPlaneIdentifiers.cs
rename to DotPilot.Core/ControlPlaneDomain/Identifiers/ControlPlaneIdentifiers.cs
index 3799537..3e42123 100644
--- a/DotPilot.Core/Features/ControlPlaneDomain/ControlPlaneIdentifiers.cs
+++ b/DotPilot.Core/ControlPlaneDomain/Identifiers/ControlPlaneIdentifiers.cs
@@ -1,6 +1,6 @@
using System.Globalization;
-namespace DotPilot.Core.Features.ControlPlaneDomain;
+namespace DotPilot.Core.ControlPlaneDomain;
[GenerateSerializer]
public readonly record struct WorkspaceId([property: Id(0)] Guid Value)
diff --git a/DotPilot.Core/Features/ControlPlaneDomain/ControlPlaneStates.cs b/DotPilot.Core/ControlPlaneDomain/Models/ControlPlaneStates.cs
similarity index 86%
rename from DotPilot.Core/Features/ControlPlaneDomain/ControlPlaneStates.cs
rename to DotPilot.Core/ControlPlaneDomain/Models/ControlPlaneStates.cs
index 5318f6a..907ceed 100644
--- a/DotPilot.Core/Features/ControlPlaneDomain/ControlPlaneStates.cs
+++ b/DotPilot.Core/ControlPlaneDomain/Models/ControlPlaneStates.cs
@@ -1,4 +1,4 @@
-namespace DotPilot.Core.Features.ControlPlaneDomain;
+namespace DotPilot.Core.ControlPlaneDomain;
public enum SessionPhase
{
@@ -27,16 +27,6 @@ public enum ApprovalState
Rejected,
}
-public enum AgentRoleKind
-{
- Coding,
- Research,
- Analyst,
- Reviewer,
- Operator,
- Orchestrator,
-}
-
public enum FleetExecutionMode
{
SingleAgent,
diff --git a/DotPilot.Core/Features/ControlPlaneDomain/PolicyContracts.cs b/DotPilot.Core/ControlPlaneDomain/Policies/PolicyContracts.cs
similarity index 90%
rename from DotPilot.Core/Features/ControlPlaneDomain/PolicyContracts.cs
rename to DotPilot.Core/ControlPlaneDomain/Policies/PolicyContracts.cs
index c66747e..f443cfe 100644
--- a/DotPilot.Core/Features/ControlPlaneDomain/PolicyContracts.cs
+++ b/DotPilot.Core/ControlPlaneDomain/Policies/PolicyContracts.cs
@@ -1,4 +1,4 @@
-namespace DotPilot.Core.Features.ControlPlaneDomain;
+namespace DotPilot.Core.ControlPlaneDomain;
[GenerateSerializer]
public sealed record PolicyDescriptor
diff --git a/DotPilot.Core/DotPilot.Core.csproj b/DotPilot.Core/DotPilot.Core.csproj
index af712af..60719cc 100644
--- a/DotPilot.Core/DotPilot.Core.csproj
+++ b/DotPilot.Core/DotPilot.Core.csproj
@@ -7,7 +7,18 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/DotPilot.Core/Features/ApplicationShell/AppConfig.cs b/DotPilot.Core/Features/ApplicationShell/AppConfig.cs
deleted file mode 100644
index 36188ea..0000000
--- a/DotPilot.Core/Features/ApplicationShell/AppConfig.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace DotPilot.Core.Features.ApplicationShell;
-
-public sealed record AppConfig
-{
- public string? Environment { get; init; }
-}
diff --git a/DotPilot.Core/Features/RuntimeCommunication/CommunicationPrimitives.cs b/DotPilot.Core/Features/RuntimeCommunication/CommunicationPrimitives.cs
deleted file mode 100644
index f883b2e..0000000
--- a/DotPilot.Core/Features/RuntimeCommunication/CommunicationPrimitives.cs
+++ /dev/null
@@ -1,93 +0,0 @@
-using System.Collections.ObjectModel;
-using System.Diagnostics.CodeAnalysis;
-
-namespace ManagedCode.Communication;
-
-public sealed class Problem
-{
- private readonly Dictionary> _validationErrors = new(StringComparer.Ordinal);
-
- private Problem(string errorCode, string detail, int statusCode)
- {
- ArgumentException.ThrowIfNullOrWhiteSpace(errorCode);
- ArgumentException.ThrowIfNullOrWhiteSpace(detail);
-
- ErrorCode = errorCode;
- Detail = detail;
- StatusCode = statusCode;
- }
-
- public string ErrorCode { get; }
-
- public string Detail { get; }
-
- public int StatusCode { get; }
-
- public IReadOnlyDictionary> ValidationErrors => new ReadOnlyDictionary>(_validationErrors);
-
- public static Problem Create(TCode code, string detail, int statusCode)
- where TCode : struct, Enum
- {
- return new Problem(code.ToString(), detail, statusCode);
- }
-
- public void AddValidationError(string fieldName, string errorMessage)
- {
- ArgumentException.ThrowIfNullOrWhiteSpace(fieldName);
- ArgumentException.ThrowIfNullOrWhiteSpace(errorMessage);
-
- if (_validationErrors.TryGetValue(fieldName, out var existingErrors))
- {
- _validationErrors[fieldName] = [.. existingErrors, errorMessage];
- return;
- }
-
- _validationErrors[fieldName] = [errorMessage];
- }
-
- public bool HasErrorCode(TCode code)
- where TCode : struct, Enum
- {
- return string.Equals(ErrorCode, code.ToString(), StringComparison.Ordinal);
- }
-
- public bool InvalidField(string fieldName)
- {
- ArgumentException.ThrowIfNullOrWhiteSpace(fieldName);
- return _validationErrors.ContainsKey(fieldName);
- }
-}
-
-[SuppressMessage(
- "Design",
- "CA1000:Do not declare static members on generic types",
- Justification = "The result contract intentionally exposes static success/failure factories to preserve the existing lightweight communication API.")]
-public sealed class Result
-{
- private Result(T? value, Problem? problem)
- {
- Value = value;
- Problem = problem;
- }
-
- public T? Value { get; }
-
- public Problem? Problem { get; }
-
- public bool IsSuccess => Problem is null;
-
- public bool IsFailed => !IsSuccess;
-
- public bool HasProblem => Problem is not null;
-
- public static Result Succeed(T value)
- {
- return new Result(value, problem: null);
- }
-
- public static Result Fail(Problem problem)
- {
- ArgumentNullException.ThrowIfNull(problem);
- return new Result(value: default, problem);
- }
-}
diff --git a/DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblemCode.cs b/DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblemCode.cs
deleted file mode 100644
index 0e51348..0000000
--- a/DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblemCode.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-namespace DotPilot.Core.Features.RuntimeCommunication;
-
-public enum RuntimeCommunicationProblemCode
-{
- PromptRequired,
- ProviderUnavailable,
- ProviderAuthenticationRequired,
- ProviderMisconfigured,
- ProviderOutdated,
- RuntimeHostUnavailable,
- OrchestrationUnavailable,
- PolicyRejected,
- SessionArchiveMissing,
- ResumeCheckpointMissing,
- SessionArchiveCorrupted,
-}
diff --git a/DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblems.cs b/DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblems.cs
deleted file mode 100644
index 91d2176..0000000
--- a/DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblems.cs
+++ /dev/null
@@ -1,130 +0,0 @@
-using System.Globalization;
-using System.Net;
-using DotPilot.Core.Features.ControlPlaneDomain;
-using ManagedCode.Communication;
-
-namespace DotPilot.Core.Features.RuntimeCommunication;
-
-public static class RuntimeCommunicationProblems
-{
- private const string PromptField = "Prompt";
- private const string PromptRequiredDetail = "Prompt is required before the runtime can execute a turn.";
- private const string ProviderUnavailableFormat = "{0} is unavailable in the current environment.";
- private const string ProviderAuthenticationRequiredFormat = "{0} requires authentication before the runtime can execute a turn.";
- private const string ProviderMisconfiguredFormat = "{0} is misconfigured and cannot execute a runtime turn.";
- private const string ProviderOutdatedFormat = "{0} is outdated and must be updated before the runtime can execute a turn.";
- private const string RuntimeHostUnavailableDetail = "The embedded runtime host is unavailable for the requested operation.";
- private const string OrchestrationUnavailableDetail = "The orchestration runtime is unavailable for the requested operation.";
- private const string PolicyRejectedFormat = "The requested action was rejected by policy: {0}.";
- private const string SessionArchiveMissingFormat = "No persisted runtime session archive exists for session {0}.";
- private const string ResumeCheckpointMissingFormat = "Session {0} does not have a checkpoint that can be resumed.";
- private const string SessionArchiveCorruptedFormat = "Session {0} has corrupted persisted runtime state.";
-
- public static Problem InvalidPrompt()
- {
- var problem = Problem.Create(
- RuntimeCommunicationProblemCode.PromptRequired,
- PromptRequiredDetail,
- (int)HttpStatusCode.BadRequest);
-
- problem.AddValidationError(PromptField, PromptRequiredDetail);
- return problem;
- }
-
- public static Problem ProviderUnavailable(ProviderConnectionStatus status, string providerDisplayName)
- {
- ArgumentException.ThrowIfNullOrWhiteSpace(providerDisplayName);
-
- return status switch
- {
- ProviderConnectionStatus.Available => throw new ArgumentOutOfRangeException(nameof(status), status, "Available status does not map to a problem."),
- ProviderConnectionStatus.Unavailable => CreateProblem(
- RuntimeCommunicationProblemCode.ProviderUnavailable,
- ProviderUnavailableFormat,
- providerDisplayName,
- HttpStatusCode.ServiceUnavailable),
- ProviderConnectionStatus.RequiresAuthentication => CreateProblem(
- RuntimeCommunicationProblemCode.ProviderAuthenticationRequired,
- ProviderAuthenticationRequiredFormat,
- providerDisplayName,
- HttpStatusCode.Unauthorized),
- ProviderConnectionStatus.Misconfigured => CreateProblem(
- RuntimeCommunicationProblemCode.ProviderMisconfigured,
- ProviderMisconfiguredFormat,
- providerDisplayName,
- HttpStatusCode.FailedDependency),
- ProviderConnectionStatus.Outdated => CreateProblem(
- RuntimeCommunicationProblemCode.ProviderOutdated,
- ProviderOutdatedFormat,
- providerDisplayName,
- HttpStatusCode.PreconditionFailed),
- _ => throw new ArgumentOutOfRangeException(nameof(status), status, "Unknown provider status."),
- };
- }
-
- public static Problem RuntimeHostUnavailable()
- {
- return Problem.Create(
- RuntimeCommunicationProblemCode.RuntimeHostUnavailable,
- RuntimeHostUnavailableDetail,
- (int)HttpStatusCode.ServiceUnavailable);
- }
-
- public static Problem OrchestrationUnavailable()
- {
- return Problem.Create(
- RuntimeCommunicationProblemCode.OrchestrationUnavailable,
- OrchestrationUnavailableDetail,
- (int)HttpStatusCode.ServiceUnavailable);
- }
-
- public static Problem PolicyRejected(string policyName)
- {
- ArgumentException.ThrowIfNullOrWhiteSpace(policyName);
-
- return CreateProblem(
- RuntimeCommunicationProblemCode.PolicyRejected,
- PolicyRejectedFormat,
- policyName,
- HttpStatusCode.Forbidden);
- }
-
- public static Problem SessionArchiveMissing(SessionId sessionId)
- {
- return CreateProblem(
- RuntimeCommunicationProblemCode.SessionArchiveMissing,
- SessionArchiveMissingFormat,
- sessionId.ToString(),
- HttpStatusCode.NotFound);
- }
-
- public static Problem ResumeCheckpointMissing(SessionId sessionId)
- {
- return CreateProblem(
- RuntimeCommunicationProblemCode.ResumeCheckpointMissing,
- ResumeCheckpointMissingFormat,
- sessionId.ToString(),
- HttpStatusCode.Conflict);
- }
-
- public static Problem SessionArchiveCorrupted(SessionId sessionId)
- {
- return CreateProblem(
- RuntimeCommunicationProblemCode.SessionArchiveCorrupted,
- SessionArchiveCorruptedFormat,
- sessionId.ToString(),
- HttpStatusCode.InternalServerError);
- }
-
- private static Problem CreateProblem(
- RuntimeCommunicationProblemCode code,
- string detailFormat,
- string value,
- HttpStatusCode statusCode)
- {
- return Problem.Create(
- code,
- string.Format(CultureInfo.InvariantCulture, detailFormat, value),
- (int)statusCode);
- }
-}
diff --git a/DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeHostContracts.cs b/DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeHostContracts.cs
deleted file mode 100644
index a3f14f2..0000000
--- a/DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeHostContracts.cs
+++ /dev/null
@@ -1,75 +0,0 @@
-using DotPilot.Core.Features.ControlPlaneDomain;
-
-namespace DotPilot.Core.Features.RuntimeFoundation;
-
-public enum EmbeddedRuntimeHostState
-{
- Stopped,
- Starting,
- Running,
-}
-
-public enum EmbeddedRuntimeClusteringMode
-{
- Localhost,
-}
-
-public enum EmbeddedRuntimeStorageMode
-{
- InMemory,
-}
-
-public sealed record EmbeddedRuntimeGrainDescriptor(
- string Name,
- string Summary);
-
-public sealed record EmbeddedRuntimeHostSnapshot(
- EmbeddedRuntimeHostState State,
- EmbeddedRuntimeClusteringMode ClusteringMode,
- EmbeddedRuntimeStorageMode GrainStorageMode,
- EmbeddedRuntimeStorageMode ReminderStorageMode,
- string ClusterId,
- string ServiceId,
- int SiloPort,
- int GatewayPort,
- IReadOnlyList Grains);
-
-public interface IEmbeddedRuntimeHostCatalog
-{
- EmbeddedRuntimeHostSnapshot GetSnapshot();
-}
-
-public interface ISessionGrain : IGrainWithStringKey
-{
- ValueTask GetAsync();
-
- ValueTask UpsertAsync(SessionDescriptor session);
-}
-
-public interface IWorkspaceGrain : IGrainWithStringKey
-{
- ValueTask GetAsync();
-
- ValueTask UpsertAsync(WorkspaceDescriptor workspace);
-}
-
-public interface IFleetGrain : IGrainWithStringKey
-{
- ValueTask GetAsync();
-
- ValueTask UpsertAsync(FleetDescriptor fleet);
-}
-
-public interface IPolicyGrain : IGrainWithStringKey
-{
- ValueTask GetAsync();
-
- ValueTask UpsertAsync(PolicyDescriptor policy);
-}
-
-public interface IArtifactGrain : IGrainWithStringKey
-{
- ValueTask GetAsync();
-
- ValueTask UpsertAsync(ArtifactDescriptor artifact);
-}
diff --git a/DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyContracts.cs b/DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyContracts.cs
deleted file mode 100644
index 5c53ae0..0000000
--- a/DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyContracts.cs
+++ /dev/null
@@ -1,32 +0,0 @@
-namespace DotPilot.Core.Features.RuntimeFoundation;
-
-public sealed record EmbeddedRuntimeTrafficTransitionDescriptor(
- string Source,
- string Target,
- IReadOnlyList SourceMethods,
- IReadOnlyList TargetMethods,
- bool IsReentrant);
-
-public sealed record EmbeddedRuntimeTrafficPolicySnapshot(
- int IssueNumber,
- string IssueLabel,
- string Summary,
- string MermaidDiagram,
- IReadOnlyList AllowedTransitions);
-
-public sealed record EmbeddedRuntimeTrafficProbe(
- Type SourceGrainType,
- string SourceMethod,
- Type TargetGrainType,
- string TargetMethod);
-
-public sealed record EmbeddedRuntimeTrafficDecision(
- bool IsAllowed,
- string MermaidDiagram);
-
-public interface IEmbeddedRuntimeTrafficPolicyCatalog
-{
- EmbeddedRuntimeTrafficPolicySnapshot GetSnapshot();
-
- EmbeddedRuntimeTrafficDecision Evaluate(EmbeddedRuntimeTrafficProbe probe);
-}
diff --git a/DotPilot.Core/Features/RuntimeFoundation/IAgentRuntimeClient.cs b/DotPilot.Core/Features/RuntimeFoundation/IAgentRuntimeClient.cs
deleted file mode 100644
index 8eb6db8..0000000
--- a/DotPilot.Core/Features/RuntimeFoundation/IAgentRuntimeClient.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-using DotPilot.Core.Features.ControlPlaneDomain;
-using ManagedCode.Communication;
-
-namespace DotPilot.Core.Features.RuntimeFoundation;
-
-public interface IAgentRuntimeClient
-{
- ValueTask> ExecuteAsync(AgentTurnRequest request, CancellationToken cancellationToken);
-
- ValueTask> ResumeAsync(AgentTurnResumeRequest request, CancellationToken cancellationToken);
-
- ValueTask> GetSessionArchiveAsync(SessionId sessionId, CancellationToken cancellationToken);
-}
diff --git a/DotPilot.Core/Features/RuntimeFoundation/IRuntimeFoundationCatalog.cs b/DotPilot.Core/Features/RuntimeFoundation/IRuntimeFoundationCatalog.cs
deleted file mode 100644
index 3a4d8db..0000000
--- a/DotPilot.Core/Features/RuntimeFoundation/IRuntimeFoundationCatalog.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace DotPilot.Core.Features.RuntimeFoundation;
-
-public interface IRuntimeFoundationCatalog
-{
- RuntimeFoundationSnapshot GetSnapshot();
-}
diff --git a/DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationContracts.cs b/DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationContracts.cs
deleted file mode 100644
index d273416..0000000
--- a/DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationContracts.cs
+++ /dev/null
@@ -1,31 +0,0 @@
-using DotPilot.Core.Features.ControlPlaneDomain;
-
-namespace DotPilot.Core.Features.RuntimeFoundation;
-
-public sealed record RuntimeSliceDescriptor(
- int IssueNumber,
- string IssueLabel,
- string Name,
- string Summary,
- RuntimeSliceState State);
-
-public sealed record RuntimeFoundationSnapshot(
- string EpicLabel,
- string Summary,
- string DeterministicClientName,
- string DeterministicProbePrompt,
- IReadOnlyList Slices,
- IReadOnlyList Providers);
-
-public sealed record AgentTurnRequest(
- SessionId SessionId,
- AgentProfileId AgentProfileId,
- string Prompt,
- AgentExecutionMode Mode,
- ProviderConnectionStatus ProviderStatus = ProviderConnectionStatus.Available);
-
-public sealed record AgentTurnResult(
- string Summary,
- SessionPhase NextPhase,
- ApprovalState ApprovalState,
- IReadOnlyList ProducedArtifacts);
diff --git a/DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationIssues.cs b/DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationIssues.cs
deleted file mode 100644
index e3792e1..0000000
--- a/DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationIssues.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-namespace DotPilot.Core.Features.RuntimeFoundation;
-
-public static class RuntimeFoundationIssues
-{
- private const string IssuePrefix = "#";
-
- public const int EmbeddedAgentRuntimeHostEpic = 12;
- public const int DomainModel = 22;
- public const int CommunicationContracts = 23;
- public const int EmbeddedOrleansHost = 24;
- public const int AgentFrameworkRuntime = 25;
- public const int GrainTrafficPolicy = 26;
- public const int SessionPersistence = 27;
-
- public static string FormatIssueLabel(int issueNumber) => string.Concat(IssuePrefix, issueNumber);
-}
diff --git a/DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationStates.cs b/DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationStates.cs
deleted file mode 100644
index 2256cd2..0000000
--- a/DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationStates.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-namespace DotPilot.Core.Features.RuntimeFoundation;
-
-public enum RuntimeSliceState
-{
- Planned,
- Sequenced,
- ReadyForImplementation,
-}
-
-public enum AgentExecutionMode
-{
- Plan,
- Execute,
- Review,
-}
diff --git a/DotPilot.Core/Features/RuntimeFoundation/RuntimeSessionArchiveContracts.cs b/DotPilot.Core/Features/RuntimeFoundation/RuntimeSessionArchiveContracts.cs
deleted file mode 100644
index 99e6d49..0000000
--- a/DotPilot.Core/Features/RuntimeFoundation/RuntimeSessionArchiveContracts.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-using DotPilot.Core.Features.ControlPlaneDomain;
-
-namespace DotPilot.Core.Features.RuntimeFoundation;
-
-public sealed record AgentTurnResumeRequest(
- SessionId SessionId,
- ApprovalState ApprovalState,
- string Summary);
-
-public sealed record RuntimeSessionReplayEntry(
- string Kind,
- string Summary,
- SessionPhase Phase,
- ApprovalState ApprovalState,
- DateTimeOffset RecordedAt);
-
-public sealed record RuntimeSessionArchive(
- SessionId SessionId,
- string WorkflowSessionId,
- SessionPhase Phase,
- ApprovalState ApprovalState,
- DateTimeOffset UpdatedAt,
- string? CheckpointId,
- IReadOnlyList Replay,
- IReadOnlyList Artifacts);
diff --git a/DotPilot.Core/Features/ToolchainCenter/IToolchainCenterCatalog.cs b/DotPilot.Core/Features/ToolchainCenter/IToolchainCenterCatalog.cs
deleted file mode 100644
index 9b8e4a8..0000000
--- a/DotPilot.Core/Features/ToolchainCenter/IToolchainCenterCatalog.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace DotPilot.Core.Features.ToolchainCenter;
-
-public interface IToolchainCenterCatalog
-{
- ToolchainCenterSnapshot GetSnapshot();
-}
diff --git a/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterContracts.cs b/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterContracts.cs
deleted file mode 100644
index dae02b9..0000000
--- a/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterContracts.cs
+++ /dev/null
@@ -1,64 +0,0 @@
-using DotPilot.Core.Features.ControlPlaneDomain;
-
-namespace DotPilot.Core.Features.ToolchainCenter;
-
-public sealed record ToolchainCenterWorkstreamDescriptor(
- int IssueNumber,
- string SectionLabel,
- string Name,
- string Summary);
-
-public sealed record ToolchainActionDescriptor(
- string Title,
- string Summary,
- ToolchainActionKind Kind,
- bool IsPrimary,
- bool IsEnabled);
-
-public sealed record ToolchainDiagnosticDescriptor(
- string Name,
- ToolchainDiagnosticStatus Status,
- string Summary);
-
-public sealed record ToolchainConfigurationEntry(
- string Name,
- string ValueDisplay,
- string Summary,
- ToolchainConfigurationKind Kind,
- ToolchainConfigurationStatus Status,
- bool IsSensitive);
-
-public sealed record ToolchainPollingDescriptor(
- TimeSpan RefreshInterval,
- DateTimeOffset LastRefreshAt,
- DateTimeOffset NextRefreshAt,
- ToolchainPollingStatus Status,
- string Summary);
-
-public sealed record ToolchainProviderSnapshot(
- int IssueNumber,
- string SectionLabel,
- ProviderDescriptor Provider,
- string ExecutablePath,
- string InstalledVersion,
- ToolchainReadinessState ReadinessState,
- string ReadinessSummary,
- ToolchainVersionStatus VersionStatus,
- string VersionSummary,
- ToolchainAuthStatus AuthStatus,
- string AuthSummary,
- ToolchainHealthStatus HealthStatus,
- string HealthSummary,
- IReadOnlyList Actions,
- IReadOnlyList Diagnostics,
- IReadOnlyList Configuration,
- ToolchainPollingDescriptor Polling);
-
-public sealed record ToolchainCenterSnapshot(
- string EpicLabel,
- string Summary,
- IReadOnlyList Workstreams,
- IReadOnlyList Providers,
- ToolchainPollingDescriptor BackgroundPolling,
- int ReadyProviderCount,
- int AttentionRequiredProviderCount);
diff --git a/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterIssues.cs b/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterIssues.cs
deleted file mode 100644
index cf65cfa..0000000
--- a/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterIssues.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-namespace DotPilot.Core.Features.ToolchainCenter;
-
-public static class ToolchainCenterIssues
-{
- public const int ToolchainCenterEpic = 14;
- public const int ToolchainCenterUi = 33;
- public const int CodexReadiness = 34;
- public const int ClaudeCodeReadiness = 35;
- public const int GitHubCopilotReadiness = 36;
- public const int ConnectionDiagnostics = 37;
- public const int ProviderConfiguration = 38;
- public const int BackgroundPolling = 39;
-
- private const string IssueLabelFormat = "ISSUE #{0}";
- private static readonly System.Text.CompositeFormat IssueLabelCompositeFormat =
- System.Text.CompositeFormat.Parse(IssueLabelFormat);
-
- public static string FormatIssueLabel(int issueNumber) =>
- string.Format(System.Globalization.CultureInfo.InvariantCulture, IssueLabelCompositeFormat, issueNumber);
-}
diff --git a/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterStates.cs b/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterStates.cs
deleted file mode 100644
index ff1b206..0000000
--- a/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterStates.cs
+++ /dev/null
@@ -1,71 +0,0 @@
-namespace DotPilot.Core.Features.ToolchainCenter;
-
-public enum ToolchainReadinessState
-{
- Missing,
- ActionRequired,
- Limited,
- Ready,
-}
-
-public enum ToolchainVersionStatus
-{
- Missing,
- Unknown,
- Detected,
- UpdateAvailable,
-}
-
-public enum ToolchainAuthStatus
-{
- Missing,
- Unknown,
- Connected,
-}
-
-public enum ToolchainHealthStatus
-{
- Blocked,
- Warning,
- Healthy,
-}
-
-public enum ToolchainDiagnosticStatus
-{
- Blocked,
- Failed,
- Warning,
- Ready,
- Passed,
-}
-
-public enum ToolchainConfigurationKind
-{
- Secret,
- EnvironmentVariable,
- Setting,
-}
-
-public enum ToolchainConfigurationStatus
-{
- Missing,
- Partial,
- Configured,
-}
-
-public enum ToolchainActionKind
-{
- Install,
- Connect,
- Update,
- TestConnection,
- Troubleshoot,
- OpenDocs,
-}
-
-public enum ToolchainPollingStatus
-{
- Idle,
- Healthy,
- Warning,
-}
diff --git a/DotPilot.Core/Features/Workbench/IWorkbenchCatalog.cs b/DotPilot.Core/Features/Workbench/IWorkbenchCatalog.cs
deleted file mode 100644
index 3680349..0000000
--- a/DotPilot.Core/Features/Workbench/IWorkbenchCatalog.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace DotPilot.Core.Features.Workbench;
-
-public interface IWorkbenchCatalog
-{
- WorkbenchSnapshot GetSnapshot();
-}
diff --git a/DotPilot.Core/Features/Workbench/WorkbenchDocumentContracts.cs b/DotPilot.Core/Features/Workbench/WorkbenchDocumentContracts.cs
deleted file mode 100644
index 8bec47e..0000000
--- a/DotPilot.Core/Features/Workbench/WorkbenchDocumentContracts.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-namespace DotPilot.Core.Features.Workbench;
-
-public sealed record WorkbenchDiffLine(
- WorkbenchDiffLineKind Kind,
- string Content);
-
-public sealed record WorkbenchDocumentDescriptor(
- string RelativePath,
- string Title,
- string LanguageLabel,
- string RendererLabel,
- string StatusSummary,
- bool IsReadOnly,
- string PreviewContent,
- IReadOnlyList DiffLines);
diff --git a/DotPilot.Core/Features/Workbench/WorkbenchInspectorContracts.cs b/DotPilot.Core/Features/Workbench/WorkbenchInspectorContracts.cs
deleted file mode 100644
index 7c877a7..0000000
--- a/DotPilot.Core/Features/Workbench/WorkbenchInspectorContracts.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-namespace DotPilot.Core.Features.Workbench;
-
-public sealed record WorkbenchArtifactDescriptor(
- string Name,
- string Kind,
- string Status,
- string RelativePath,
- string Summary);
-
-public sealed record WorkbenchLogEntry(
- string Timestamp,
- string Level,
- string Source,
- string Message);
diff --git a/DotPilot.Core/Features/Workbench/WorkbenchIssues.cs b/DotPilot.Core/Features/Workbench/WorkbenchIssues.cs
deleted file mode 100644
index bc0de38..0000000
--- a/DotPilot.Core/Features/Workbench/WorkbenchIssues.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-namespace DotPilot.Core.Features.Workbench;
-
-public static class WorkbenchIssues
-{
- private const string IssuePrefix = "#";
-
- public const int DesktopWorkbenchEpic = 13;
- public const int PrimaryShell = 28;
- public const int RepositoryTree = 29;
- public const int DocumentSurface = 30;
- public const int ArtifactDock = 31;
- public const int SettingsShell = 32;
-
- public static string FormatIssueLabel(int issueNumber) => string.Concat(IssuePrefix, issueNumber);
-}
diff --git a/DotPilot.Core/Features/Workbench/WorkbenchModes.cs b/DotPilot.Core/Features/Workbench/WorkbenchModes.cs
deleted file mode 100644
index f6aeada..0000000
--- a/DotPilot.Core/Features/Workbench/WorkbenchModes.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-namespace DotPilot.Core.Features.Workbench;
-
-public enum WorkbenchDocumentViewMode
-{
- Preview,
- DiffReview,
-}
-
-public enum WorkbenchDiffLineKind
-{
- Context,
- Added,
- Removed,
-}
-
-public enum WorkbenchInspectorSection
-{
- Artifacts,
- Logs,
-}
-
-public enum WorkbenchSessionEntryKind
-{
- Operator,
- Agent,
- System,
-}
diff --git a/DotPilot.Core/Features/Workbench/WorkbenchRepositoryContracts.cs b/DotPilot.Core/Features/Workbench/WorkbenchRepositoryContracts.cs
deleted file mode 100644
index 1bfab34..0000000
--- a/DotPilot.Core/Features/Workbench/WorkbenchRepositoryContracts.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-namespace DotPilot.Core.Features.Workbench;
-
-public sealed record WorkbenchRepositoryNode(
- string RelativePath,
- string DisplayLabel,
- string Name,
- int Depth,
- bool IsDirectory,
- bool CanOpen);
diff --git a/DotPilot.Core/Features/Workbench/WorkbenchSessionContracts.cs b/DotPilot.Core/Features/Workbench/WorkbenchSessionContracts.cs
deleted file mode 100644
index 801a80a..0000000
--- a/DotPilot.Core/Features/Workbench/WorkbenchSessionContracts.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-namespace DotPilot.Core.Features.Workbench;
-
-public sealed record WorkbenchSessionEntry(
- string Title,
- string Timestamp,
- string Summary,
- WorkbenchSessionEntryKind Kind);
diff --git a/DotPilot.Core/Features/Workbench/WorkbenchSettingsContracts.cs b/DotPilot.Core/Features/Workbench/WorkbenchSettingsContracts.cs
deleted file mode 100644
index 6395eb1..0000000
--- a/DotPilot.Core/Features/Workbench/WorkbenchSettingsContracts.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-namespace DotPilot.Core.Features.Workbench;
-
-public static class WorkbenchSettingsCategoryKeys
-{
- public const string Toolchains = "toolchains";
- public const string Providers = "providers";
- public const string Policies = "policies";
- public const string Storage = "storage";
-}
-
-public sealed record WorkbenchSettingEntry(
- string Name,
- string Value,
- string Summary,
- bool IsSensitive,
- bool IsActionable);
-
-public sealed record WorkbenchSettingsCategory(
- string Key,
- string Title,
- string Summary,
- IReadOnlyList Entries);
diff --git a/DotPilot.Core/Features/Workbench/WorkbenchSnapshot.cs b/DotPilot.Core/Features/Workbench/WorkbenchSnapshot.cs
deleted file mode 100644
index 9312cc6..0000000
--- a/DotPilot.Core/Features/Workbench/WorkbenchSnapshot.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-namespace DotPilot.Core.Features.Workbench;
-
-public sealed record WorkbenchSnapshot(
- string WorkspaceName,
- string WorkspaceRoot,
- string SearchPlaceholder,
- string SessionTitle,
- string SessionStage,
- string SessionSummary,
- IReadOnlyList SessionEntries,
- IReadOnlyList RepositoryNodes,
- IReadOnlyList Documents,
- IReadOnlyList Artifacts,
- IReadOnlyList Logs,
- IReadOnlyList SettingsCategories);
diff --git a/DotPilot.Core/GlobalUsings.cs b/DotPilot.Core/GlobalUsings.cs
new file mode 100644
index 0000000..cb5c6e1
--- /dev/null
+++ b/DotPilot.Core/GlobalUsings.cs
@@ -0,0 +1,6 @@
+global using DotPilot.Core.ChatSessions.Commands;
+global using DotPilot.Core.ChatSessions.Contracts;
+global using DotPilot.Core.ChatSessions.Interfaces;
+global using DotPilot.Core.ChatSessions.Models;
+global using DotPilot.Core.Providers.Interfaces;
+global using DotPilot.Core.Workspace.Interfaces;
diff --git a/DotPilot.Core/Providers/Configuration/AgentSessionProviderCatalog.cs b/DotPilot.Core/Providers/Configuration/AgentSessionProviderCatalog.cs
new file mode 100644
index 0000000..87c8577
--- /dev/null
+++ b/DotPilot.Core/Providers/Configuration/AgentSessionProviderCatalog.cs
@@ -0,0 +1,85 @@
+
+namespace DotPilot.Core.Providers;
+
+internal static class AgentSessionProviderCatalog
+{
+ private const string DebugDisplayName = "Debug Provider";
+ private const string DebugCommandName = "debug";
+ private const string DebugModelName = "debug-echo";
+ private const string DebugInstallCommand = "built-in";
+
+ private const string CodexDisplayName = "Codex";
+ private const string CodexCommandName = "codex";
+ private const string CodexModelName = "gpt-5";
+ private const string CodexInstallCommand = "npm install -g @openai/codex";
+
+ private const string ClaudeDisplayName = "Claude Code";
+ private const string ClaudeCommandName = "claude";
+ private const string ClaudeModelName = "claude-sonnet-4-5";
+ private const string ClaudeInstallCommand = "npm install -g @anthropic-ai/claude-code";
+
+ private const string CopilotDisplayName = "GitHub Copilot";
+ private const string CopilotCommandName = "copilot";
+ private const string CopilotModelName = "gpt-5";
+ private const string CopilotInstallCommand = "npm install -g @github/copilot";
+
+ private static readonly IReadOnlyList DebugModels =
+ [
+ DebugModelName,
+ ];
+
+ private static readonly IReadOnlyList CodexModels =
+ [
+ CodexModelName,
+ ];
+
+ private static readonly IReadOnlyList ClaudeModels =
+ [
+ "claude-opus-4-6",
+ "claude-opus-4-5",
+ ClaudeModelName,
+ "claude-haiku-4-5",
+ "claude-sonnet-4",
+ ];
+
+ private static readonly IReadOnlyList CopilotModels =
+ [
+ "claude-sonnet-4.6",
+ "claude-sonnet-4.5",
+ "claude-haiku-4.5",
+ "claude-opus-4.6",
+ "claude-opus-4.6-fast",
+ "claude-opus-4.5",
+ "claude-sonnet-4",
+ "gemini-3-pro-preview",
+ "gpt-5.4",
+ "gpt-5.3-codex",
+ "gpt-5.2-codex",
+ "gpt-5.2",
+ "gpt-5.1-codex-max",
+ "gpt-5.1-codex",
+ "gpt-5.1",
+ "gpt-5.1-codex-mini",
+ "gpt-5-mini",
+ "gpt-4.1",
+ ];
+
+ private static readonly IReadOnlyDictionary ProfilesByKind =
+ CreateProfiles()
+ .ToDictionary(profile => profile.Kind);
+
+ public static IReadOnlyList All => [.. ProfilesByKind.Values];
+
+ public static AgentSessionProviderProfile Get(AgentProviderKind kind) => ProfilesByKind[kind];
+
+ private static IReadOnlyList CreateProfiles()
+ {
+ return
+ [
+ new(AgentProviderKind.Debug, DebugDisplayName, DebugCommandName, DebugModelName, DebugModels, DebugInstallCommand, true, true),
+ new(AgentProviderKind.Codex, CodexDisplayName, CodexCommandName, CodexModelName, CodexModels, CodexInstallCommand, false, true),
+ new(AgentProviderKind.ClaudeCode, ClaudeDisplayName, ClaudeCommandName, ClaudeModelName, ClaudeModels, ClaudeInstallCommand, false, false),
+ new(AgentProviderKind.GitHubCopilot, CopilotDisplayName, CopilotCommandName, CopilotModelName, CopilotModels, CopilotInstallCommand, false, false),
+ ];
+ }
+}
diff --git a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainCommandProbe.cs b/DotPilot.Core/Providers/Infrastructure/AgentSessionCommandProbe.cs
similarity index 72%
rename from DotPilot.Runtime/Features/ToolchainCenter/ToolchainCommandProbe.cs
rename to DotPilot.Core/Providers/Infrastructure/AgentSessionCommandProbe.cs
index e2e7468..d9f5c2d 100644
--- a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainCommandProbe.cs
+++ b/DotPilot.Core/Providers/Infrastructure/AgentSessionCommandProbe.cs
@@ -1,62 +1,75 @@
using System.Diagnostics;
+using System.Runtime.InteropServices;
-namespace DotPilot.Runtime.Features.ToolchainCenter;
+namespace DotPilot.Core.Providers;
-internal static class ToolchainCommandProbe
+internal static class AgentSessionCommandProbe
{
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(2);
private static readonly TimeSpan RedirectDrainTimeout = TimeSpan.FromSeconds(1);
private const string VersionSeparator = "version";
private const string EmptyOutput = "";
- public static string? ResolveExecutablePath(string commandName) =>
- RuntimeFoundation.ProviderToolchainProbe.ResolveExecutablePath(commandName);
-
- public static string ReadVersion(string executablePath, IReadOnlyList arguments)
- => ProbeVersion(executablePath, arguments).Version;
-
- public static ToolchainVersionProbeResult ProbeVersion(string executablePath, IReadOnlyList arguments)
+ public static string? ResolveExecutablePath(string commandName)
{
- ArgumentException.ThrowIfNullOrWhiteSpace(executablePath);
- ArgumentNullException.ThrowIfNull(arguments);
+ if (OperatingSystem.IsBrowser())
+ {
+ return null;
+ }
- var execution = Execute(executablePath, arguments);
- if (!execution.Succeeded)
+ var searchPaths = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty)
+ .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ foreach (var searchPath in searchPaths)
{
- return ToolchainVersionProbeResult.Missing with { Launched = execution.Launched };
+ foreach (var candidate in EnumerateCandidates(searchPath, commandName))
+ {
+ if (File.Exists(candidate))
+ {
+ return candidate;
+ }
+ }
}
- var output = string.IsNullOrWhiteSpace(execution.StandardOutput)
- ? execution.StandardError
- : execution.StandardOutput;
+ return null;
+ }
+ public static string ReadVersion(string executablePath, IReadOnlyList arguments)
+ {
+ var output = ReadOutput(executablePath, arguments);
var firstLine = output
.Split(Environment.NewLine, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault();
if (string.IsNullOrWhiteSpace(firstLine))
{
- return ToolchainVersionProbeResult.Missing with { Launched = execution.Launched };
+ return EmptyOutput;
}
var separatorIndex = firstLine.IndexOf(VersionSeparator, StringComparison.OrdinalIgnoreCase);
- var version = separatorIndex >= 0
+ return separatorIndex >= 0
? firstLine[(separatorIndex + VersionSeparator.Length)..].Trim(' ', ':')
: firstLine.Trim();
-
- return new(execution.Launched, version);
}
- public static bool CanExecute(string executablePath, IReadOnlyList arguments)
+ public static string ReadOutput(string executablePath, IReadOnlyList arguments)
{
- ArgumentException.ThrowIfNullOrWhiteSpace(executablePath);
- ArgumentNullException.ThrowIfNull(arguments);
+ var execution = Execute(executablePath, arguments);
+ if (!execution.Succeeded)
+ {
+ return EmptyOutput;
+ }
- return Execute(executablePath, arguments).Succeeded;
+ return string.IsNullOrWhiteSpace(execution.StandardOutput)
+ ? execution.StandardError
+ : execution.StandardOutput;
}
private static ToolchainCommandExecution Execute(string executablePath, IReadOnlyList arguments)
{
+ ArgumentException.ThrowIfNullOrWhiteSpace(executablePath);
+ ArgumentNullException.ThrowIfNull(arguments);
+
var startInfo = new ProcessStartInfo
{
FileName = executablePath,
@@ -111,6 +124,22 @@ private static ToolchainCommandExecution Execute(string executablePath, IReadOnl
}
}
+ private static IEnumerable EnumerateCandidates(string searchPath, string commandName)
+ {
+ yield return Path.Combine(searchPath, commandName);
+
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ yield break;
+ }
+
+ foreach (var extension in (Environment.GetEnvironmentVariable("PATHEXT") ?? ".EXE;.CMD;.BAT")
+ .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ yield return Path.Combine(searchPath, string.Concat(commandName, extension));
+ }
+ }
+
private static Task ObserveRedirectedStream(Task readTask)
{
_ = readTask.ContinueWith(
@@ -150,7 +179,6 @@ private static void TryTerminate(Process process)
}
catch
{
- // Best-effort cleanup only.
}
}
@@ -165,15 +193,9 @@ private static void WaitForTermination(Process process)
}
catch
{
- // Best-effort cleanup only.
}
}
- public readonly record struct ToolchainVersionProbeResult(bool Launched, string Version)
- {
- public static ToolchainVersionProbeResult Missing => new(false, EmptyOutput);
- }
-
private readonly record struct ToolchainCommandExecution(bool Launched, bool Succeeded, string StandardOutput, string StandardError)
{
public static ToolchainCommandExecution LaunchFailed => new(false, false, EmptyOutput, EmptyOutput);
diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationDeterministicIdentity.cs b/DotPilot.Core/Providers/Infrastructure/AgentSessionDeterministicIdentity.cs
similarity index 50%
rename from DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationDeterministicIdentity.cs
rename to DotPilot.Core/Providers/Infrastructure/AgentSessionDeterministicIdentity.cs
index a06f8a4..48dc273 100644
--- a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationDeterministicIdentity.cs
+++ b/DotPilot.Core/Providers/Infrastructure/AgentSessionDeterministicIdentity.cs
@@ -1,22 +1,14 @@
using System.Security.Cryptography;
using System.Text;
-using DotPilot.Core.Features.ControlPlaneDomain;
+using DotPilot.Core.ControlPlaneDomain;
-namespace DotPilot.Runtime.Features.RuntimeFoundation;
+namespace DotPilot.Core.Providers;
-internal static class RuntimeFoundationDeterministicIdentity
+internal static class AgentSessionDeterministicIdentity
{
- private const string ArtifactSeedPrefix = "runtime-foundation-artifact";
- private const string ProviderSeedPrefix = "runtime-foundation-provider";
+ private const string ProviderSeedPrefix = "agent-session-provider";
private const string SeedSeparator = "|";
- public static DateTimeOffset ArtifactCreatedAt { get; } = new(2026, 3, 13, 0, 0, 0, TimeSpan.Zero);
-
- public static ArtifactId CreateArtifactId(SessionId sessionId, string artifactName)
- {
- return new(CreateGuid(string.Concat(ArtifactSeedPrefix, SeedSeparator, sessionId, SeedSeparator, artifactName)));
- }
-
public static ProviderId CreateProviderId(string commandName)
{
return new(CreateGuid(string.Concat(ProviderSeedPrefix, SeedSeparator, commandName)));
diff --git a/DotPilot.Core/Providers/Interfaces/IAgentProviderStatusReader.cs b/DotPilot.Core/Providers/Interfaces/IAgentProviderStatusReader.cs
new file mode 100644
index 0000000..25ae3e4
--- /dev/null
+++ b/DotPilot.Core/Providers/Interfaces/IAgentProviderStatusReader.cs
@@ -0,0 +1,10 @@
+namespace DotPilot.Core.Providers.Interfaces;
+
+public interface IAgentProviderStatusReader
+{
+ ValueTask> ReadAsync(CancellationToken cancellationToken);
+
+ ValueTask> RefreshAsync(CancellationToken cancellationToken);
+
+ void Invalidate();
+}
diff --git a/DotPilot.Core/Providers/Models/AgentSessionProviderProfile.cs b/DotPilot.Core/Providers/Models/AgentSessionProviderProfile.cs
new file mode 100644
index 0000000..9e93e5e
--- /dev/null
+++ b/DotPilot.Core/Providers/Models/AgentSessionProviderProfile.cs
@@ -0,0 +1,12 @@
+
+namespace DotPilot.Core.Providers;
+
+internal sealed record AgentSessionProviderProfile(
+ AgentProviderKind Kind,
+ string DisplayName,
+ string CommandName,
+ string DefaultModelName,
+ IReadOnlyList SupportedModelNames,
+ string InstallCommand,
+ bool IsBuiltIn,
+ bool SupportsLiveExecution);
diff --git a/DotPilot.Core/Providers/Models/ProviderCliMetadataSnapshot.cs b/DotPilot.Core/Providers/Models/ProviderCliMetadataSnapshot.cs
new file mode 100644
index 0000000..9b3ba79
--- /dev/null
+++ b/DotPilot.Core/Providers/Models/ProviderCliMetadataSnapshot.cs
@@ -0,0 +1,6 @@
+namespace DotPilot.Core.Providers;
+
+internal sealed record ProviderCliMetadataSnapshot(
+ string? InstalledVersion,
+ string? SuggestedModelName,
+ IReadOnlyList SupportedModels);
diff --git a/DotPilot.Core/Providers/Models/ProviderStatusProbeResult.cs b/DotPilot.Core/Providers/Models/ProviderStatusProbeResult.cs
new file mode 100644
index 0000000..d848994
--- /dev/null
+++ b/DotPilot.Core/Providers/Models/ProviderStatusProbeResult.cs
@@ -0,0 +1,6 @@
+
+namespace DotPilot.Core.Providers;
+
+internal sealed record ProviderStatusProbeResult(
+ ProviderStatusDescriptor Descriptor,
+ string? ExecutablePath);
diff --git a/DotPilot.Core/Providers/Services/AgentProviderStatusReader.cs b/DotPilot.Core/Providers/Services/AgentProviderStatusReader.cs
new file mode 100644
index 0000000..c3e5c94
--- /dev/null
+++ b/DotPilot.Core/Providers/Services/AgentProviderStatusReader.cs
@@ -0,0 +1,168 @@
+using System.Diagnostics;
+using DotPilot.Core.ChatSessions;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace DotPilot.Core.Providers;
+
+internal sealed class AgentProviderStatusReader(
+ IDbContextFactory dbContextFactory,
+ ILogger logger)
+ : IAgentProviderStatusReader
+{
+ private const string MissingValue = "";
+ private readonly object activeReadSync = new();
+ private IReadOnlyList? cachedSnapshot;
+ private Task>? activeReadTask;
+ private long activeReadGeneration = -1;
+ private long snapshotGeneration;
+
+ public async ValueTask> ReadAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ var readTask = GetOrStartActiveRead(forceRefresh: false);
+ return await readTask.WaitAsync(cancellationToken);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception exception)
+ {
+ AgentProviderStatusReaderLog.ReadFailed(logger, exception);
+ throw;
+ }
+ }
+
+ public async ValueTask> RefreshAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ var readTask = GetOrStartActiveRead(forceRefresh: true);
+ return await readTask.WaitAsync(cancellationToken);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception exception)
+ {
+ AgentProviderStatusReaderLog.ReadFailed(logger, exception);
+ throw;
+ }
+ }
+
+ public void Invalidate()
+ {
+ lock (activeReadSync)
+ {
+ snapshotGeneration++;
+ cachedSnapshot = null;
+ }
+ }
+
+ private Task> GetOrStartActiveRead(bool forceRefresh)
+ {
+ Task>? activeTask;
+ long generation;
+
+ lock (activeReadSync)
+ {
+ if (!forceRefresh && cachedSnapshot is { } snapshot)
+ {
+ return Task.FromResult(snapshot);
+ }
+
+ if (!forceRefresh &&
+ activeReadTask is { IsCompleted: false } &&
+ activeReadGeneration == snapshotGeneration)
+ {
+ return activeReadTask;
+ }
+
+ if (forceRefresh)
+ {
+ snapshotGeneration++;
+ cachedSnapshot = null;
+ }
+
+ generation = snapshotGeneration;
+ activeReadGeneration = generation;
+ activeReadTask = CreateReadTask(generation);
+ activeTask = activeReadTask;
+ }
+
+ return activeTask;
+ }
+
+ private Task> CreateReadTask(long generation)
+ {
+ Task