1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
|
using System.Text.Json;
using Agent.Core.Ollama;
using Agent.Core.Prompts;
using Agent.Core.Sessions;
using Agent.Core.Skills;
using Agent.Core.Tasks;
using Agent.Core.Tools;
using Agent.Planner;
namespace Agent.Core.Runtime;
/// <summary>
/// Builds an initial TaskGraph from LLM structured output (Planning phase).
/// The Planner determines requires_external_effect per-task during Planning.
/// Pure analysis tasks get a TaskGraph with at least 1 Task (RequiresExternalEffect=false).
/// </summary>
public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings settings)
{
private readonly IOllamaClient _ollama = ollama;
private readonly AgentRuntimeSettings _settings = settings;
/// <summary>
/// Generates an initial TaskGraph for the given request via Ollama structured output.
/// Returns a TaskGraph stored in the session, keyed by the MCP Task ID.
/// </summary>
public async Task<TaskGraph> PlanAsync(Guid taskId, Guid sessionId, AgentSession session, string request, double? temperature, IReadOnlyList<string>? images, CancellationToken cancellationToken)
{
// Build a Planning prompt with skill catalog and sandbox capability summary.
IReadOnlyList<AgentSkill> selectedSkills = await SelectSkillsAsync(request, cancellationToken).ConfigureAwait(false);
var messages = new List<OllamaMessage>
{
new("system", FixedPrompt.Load()),
new("system", _settings.UserSystemPrompt),
new("system", $"Respond in locale: {_settings.Locale}."),
new("system", ComposeSkillCatalog(_settings.Skills)),
new("system", _settings.SandboxCapabilitySummary),
new("system", BuildPlanningInstruction()),
new("user", request, images)
};
// Use structured output to get a plan.
OllamaResult result;
try
{
result = await _ollama.ChatStructuredAsync(messages, 0.1, PlanSchema.Element, cancellationToken).ConfigureAwait(false);
}
catch
{
// Fallback: single-task plan with conservative estimates.
return CreateFallbackGraph(request);
}
PlanResult? plan = ParsePlan(result.Content);
if (plan is null || plan.Tasks.Count == 0)
return CreateFallbackGraph(request);
TaskGraph graph;
try
{
graph = BuildTaskGraph(plan, request);
}
catch
{
return CreateFallbackGraph(request);
}
session.TaskGraphs[taskId] = graph;
return graph;
}
/// <summary>
/// Replans after a policy deny or task failure by adding/replacing tasks in the existing TaskGraph.
/// Completed tasks are immutable; only incomplete tasks are modified.
/// </summary>
public async Task<TaskGraph> ReplanAsync(Guid taskId, Guid sessionId, AgentSession session, string request, double? temperature, string replanReason, CancellationToken cancellationToken)
{
if (!session.TaskGraphs.TryGetValue(taskId, out TaskGraph? existing))
return await PlanAsync(taskId, sessionId, session, request, temperature, null, cancellationToken).ConfigureAwait(false);
var messages = new List<OllamaMessage>
{
new("system", FixedPrompt.Load()),
new("system", _settings.UserSystemPrompt),
new("system", $"Respond in locale: {_settings.Locale}."),
new("system", _settings.SandboxCapabilitySummary),
new("system", BuildReplanInstruction(existing, replanReason)),
new("user", request)
};
OllamaResult result;
try
{
result = await _ollama.ChatStructuredAsync(messages, 0.1, PlanSchema.Element, cancellationToken).ConfigureAwait(false);
}
catch
{
return existing;
}
PlanResult? plan = ParsePlan(result.Content);
if (plan is null || plan.Tasks.Count == 0) return existing;
// Build a new graph, preserving completed tasks from the existing graph.
return RebuildTaskGraph(existing, plan, request);
}
private TaskGraph BuildTaskGraph(PlanResult plan, string originalRequest)
{
var graph = new TaskGraph(32);
var idMap = new List<Guid>();
for (int i = 0; i < plan.Tasks.Count; i++)
{
PlannedTask pt = plan.Tasks[i];
TokenBudgetClass budgetClass = ParseBudgetClass(pt.BudgetClass);
TokenEstimate estimate = CreateEstimate(pt, budgetClass);
// Map dependency indices to Guids.
var dependencies = new HashSet<Guid>();
foreach (int depIdx in pt.Dependencies)
{
if (depIdx >= 0 && depIdx < idMap.Count)
dependencies.Add(idMap[depIdx]);
}
string description = string.IsNullOrWhiteSpace(pt.Description) ? originalRequest : pt.Description;
try
{
PlannerTask task = graph.AddTask(description, budgetClass, estimate, dependencies, pt.RequiresExternalEffect);
idMap.Add(task.Id);
}
catch (PlannerValidationException)
{
// If a task fails validation (e.g., bad estimate, cycle), fall back to single-task graph.
return CreateFallbackGraph(originalRequest);
}
}
// If all tasks were filtered out, fall back.
if (graph.Count == 0) return CreateFallbackGraph(originalRequest);
return graph;
}
private TaskGraph RebuildTaskGraph(TaskGraph existing, PlanResult plan, string originalRequest)
{
// Collect completed task descriptions to skip them in the new graph.
var completedDescriptions = new HashSet<string>(
existing.Tasks.Where(static t => t.State == PlannerTaskState.Completed).Select(static t => t.Description),
StringComparer.Ordinal);
var graph = new TaskGraph(32);
var idMap = new List<Guid>();
for (int i = 0; i < plan.Tasks.Count; i++)
{
PlannedTask pt = plan.Tasks[i];
// Skip tasks that are already completed in the existing graph.
if (completedDescriptions.Contains(pt.Description))
{
// Find the completed task and add it as completed to the new graph.
PlannerTask? completedTask = existing.Tasks.FirstOrDefault(t => t.Description == pt.Description);
if (completedTask is not null)
{
TokenBudgetClass budgetClass = ParseBudgetClass(pt.BudgetClass);
TokenEstimate estimate = CreateEstimate(pt, budgetClass);
var deps = new HashSet<Guid>();
foreach (int depIdx in pt.Dependencies)
if (depIdx >= 0 && depIdx < idMap.Count) deps.Add(idMap[depIdx]);
PlannerTask added = graph.AddTask(pt.Description, budgetClass, estimate, deps, pt.RequiresExternalEffect);
graph.SetState(added.Id, PlannerTaskState.Completed);
idMap.Add(added.Id);
continue;
}
}
TokenBudgetClass bc = ParseBudgetClass(pt.BudgetClass);
TokenEstimate est = CreateEstimate(pt, bc);
var dependencies = new HashSet<Guid>();
foreach (int depIdx in pt.Dependencies)
if (depIdx >= 0 && depIdx < idMap.Count) dependencies.Add(idMap[depIdx]);
string description = string.IsNullOrWhiteSpace(pt.Description) ? originalRequest : pt.Description;
PlannerTask task = graph.AddTask(description, bc, est, dependencies, pt.RequiresExternalEffect);
idMap.Add(task.Id);
}
return graph;
}
private TaskGraph CreateFallbackGraph(string request)
{
var graph = new TaskGraph(32);
TokenEstimate estimate = new(512, 2048, 8192);
graph.AddTask(request, TokenBudgetClass.K8, estimate, requiresExternalEffect: false);
return graph;
}
private static TokenBudgetClass ParseBudgetClass(string value)
{
try { return TokenBudgetClassExtensions.Parse(value); }
catch { return TokenBudgetClass.K4; }
}
private static TokenEstimate CreateEstimate(PlannedTask pt, TokenBudgetClass budgetClass)
{
long o = Math.Max(1, pt.OptimisticTokens);
long m = Math.Max(o, pt.MostLikelyTokens);
long p = Math.Max(m, pt.PessimisticTokens);
long max = budgetClass.MaxTokens();
if (p > max) p = max;
if (m > p) m = p;
if (o > m) o = m;
return new TokenEstimate(o, m, p);
}
private static PlanResult? ParsePlan(string content)
{
try
{
return JsonSerializer.Deserialize<PlanResult>(content, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
}
catch
{
return null;
}
}
private async Task<IReadOnlyList<AgentSkill>> SelectSkillsAsync(string request, CancellationToken cancellationToken)
{
if (_settings.Skills.Count == 0) return [];
string catalog = string.Join("\n", _settings.Skills.Select(static s => $"- {s.Name}: {s.Description}"));
var selectionMessages = new List<OllamaMessage>
{
new("system", $"Select skills relevant to the user request. Available skills:\n{catalog}\n\nReturn a JSON object with a 'selected_skills' array containing skill names. Select only relevant skills. If none are relevant, return an empty array."),
new("user", request)
};
string schema = """{"type":"object","properties":{"selected_skills":{"type":"array","items":{"type":"string"}}},"required":["selected_skills"]}""";
using JsonDocument formatDoc = JsonDocument.Parse(schema);
try
{
OllamaResult result = await _ollama.ChatStructuredAsync(selectionMessages, 0.0, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false);
return ParseSelectedSkills(result.Content);
}
catch
{
return [];
}
}
private IReadOnlyList<AgentSkill> ParseSelectedSkills(string content)
{
try
{
using JsonDocument doc = JsonDocument.Parse(content);
if (!doc.RootElement.TryGetProperty("selected_skills", out JsonElement skillsElement)) return [];
var selectedNames = new HashSet<string>(StringComparer.Ordinal);
foreach (JsonElement item in skillsElement.EnumerateArray())
{
string? name = item.GetString();
if (!string.IsNullOrWhiteSpace(name)) selectedNames.Add(name);
}
var valid = new List<AgentSkill>();
foreach (AgentSkill skill in _settings.Skills)
{
if (selectedNames.Contains(skill.Name))
{
valid.Add(skill);
if (valid.Count >= 10) break;
}
}
return valid;
}
catch
{
return [];
}
}
private static string ComposeSkillCatalog(IReadOnlyList<AgentSkill> skills)
{
if (skills.Count == 0) return "No skills available.";
var lines = new List<string> { "Available skills:" };
foreach (AgentSkill skill in skills)
lines.Add($"- {skill.Name}: {skill.Description}");
return string.Join("\n", lines);
}
private static string BuildPlanningInstruction() =>
"""
You are a Task Planner. Decompose the user request into a TaskGraph of 1-32 tasks.
Each task must have:
- description: a clear, self-contained sub-task description
- dependencies: array of task indices (0-based) that must complete before this task
- budget_class: one of 1K, 2K, 4K, 8K, 16K, 32K, 64K, 128K (token budget for this task)
- optimistic_tokens, most_likely_tokens, pessimistic_tokens: PERT estimates (0 < O <= M <= P <= budget)
- requires_external_effect: true if this task requires a verifiable external effect (file edit, process run, etc.)
For pure analysis or informational tasks, requires_external_effect should be false.
For tasks that modify files, run processes, or produce verifiable side effects, requires_external_effect should be true.
A single task is valid for simple requests.
""";
private static string BuildReplanInstruction(TaskGraph existing, string reason)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("You are a Task Planner performing replan. The previous plan encountered an issue.");
sb.AppendLine($"Replan reason: {reason}");
sb.AppendLine("Current task states:");
foreach (PlannerTask t in existing.Tasks)
{
sb.AppendLine($" - [{t.State}] {t.Description} (requires_external_effect={t.RequiresExternalEffect})");
}
sb.AppendLine();
sb.AppendLine("Completed tasks must be preserved. Modify only incomplete/failed tasks.");
sb.AppendLine("Return a complete revised plan including completed tasks.");
return sb.ToString();
}
}
|