V17 / d6d373561692
RequestThinking.cs
Lilith/Core/Thinking/RequestThinking.cs
Changed in this versionCS
using System.Text.Json;
using Lilith.Core.Providers;
using Lilith.Core.Scenarios;
namespace Lilith.Core.Thinking;
public sealed record ThinkingChoice(string Kind,string Group,string Scenario,Dictionary<string,string> Inputs,string Question);
internal sealed class RequestThinking(ProviderClient provider,string? root)
{
private readonly ScenarioFileService _files=new(root);
private static readonly Dictionary<string,(string Description,string[] Scenarios)> Groups=new(StringComparer.Ordinal)
{
["conversation"]=("An explicit request to run the saved greeting scenario. Ordinary conversational replies use chat.",["greeting"]),
["memory"]=("The user's name, home location, preferences and durable facts. Never assistant identity.",["memory_save","memory_read","memory_search","memory_forget"]),
["identity"]=("The assistant's name, voice, sex and personality. Never the user's name.",["name_change","voice_change","sex_change","personality_change","profile_read","voices_list"]),
["workspace"]=("Local files, notes, folders, search and bin.",["workspace_save","workspace_read","workspace_search","workspace_list","workspace_mkdir","workspace_move","workspace_bin","workspace_restore","workspace_bin_list","workspace_pending","file_branch_demo"]),
["time"]=("Actual current clock, timers, cron schedules, calendar events and weather.",["current_time","schedule_create","schedule_update","schedule_cancel","schedule_delete","schedule_list","schedule_peek","schedule_pending","calendar_create","calendar_update","calendar_delete","calendar_read","calendar_search","calendar_agenda","weather"]),
["research"]=("Questions needing current facts, verification or public web research.",["web_research"]),
["projects"]=("Generate or edit a website/console project, project requirements and Kanban planning. Cannot approve execution.",["project_website","project_console","project_edit","project_backups","project_restore","kanban_project","kanban_status"]),
["communications"]=("Read or brief email, manage local spam sections; inspect messaging channels or prepare an outgoing message.",["email_accounts","email_check","email_read","email_briefing","email_sections","email_spam","message_channels","message_check","message_status","message_send"]),
["home"]=("Inspect configured home devices or change power, brightness and colour.",["home_devices","home_state","home_on","home_off","home_brightness","home_color"])
};
private object[] Catalog(string group)=>Groups[group].Scenarios.Where(n=>File.Exists(Path.Combine(_files.RootDirectory,n+".xml"))).Select(n=>
{
var definition=ScenarioXmlParser.Parse(_files.LoadScenarioXml(n));
return (object)new{name=n,title=definition.Title,inputs=definition.Inputs.Select(i=>new{name=i.Name,required=i.Required,defaultValue=i.Value})};
}).ToArray();
private async Task<string> Run(string name,Dictionary<string,string> inputs,CancellationToken ct)
{
var result=await new ScenarioExecutor(provider,_files).RunXmlAsync(ScenarioFileService.BindXml(_files.LoadScenarioXml(name),inputs),ct);
if(result.ExitMode!="copy")throw new InvalidDataException("Thinking scenarios must use exact-copy exits.");
return result.Output;
}
private static T Parse<T>(string text)
{
text=text.Trim();if(text.StartsWith("```")){int newline=text.IndexOf('\n');if(newline<0)throw new InvalidDataException("Missing structured decision.");text=text[(newline+1)..];if(text.TrimEnd().EndsWith("```"))text=text.TrimEnd()[..^3];}
return JsonSerializer.Deserialize<T>(text,new JsonSerializerOptions{PropertyNameCaseInsensitive=true})??throw new InvalidDataException("Empty thinking decision.");
}
internal async Task<ThinkingChoice> Plan(string request,CancellationToken ct)
{
if(string.IsNullOrWhiteSpace(request)||request.Length>16000)throw new ArgumentException("Request must contain 1–16000 characters.");
string context=JsonSerializer.Serialize(provider.History.Where(m=>m.Role is "user" or "assistant").TakeLast(4).Select(m=>new{m.Role,text=m.Content[..Math.Min(1000,m.Content.Length)]}));
string groups=JsonSerializer.Serialize(Groups.Select(g=>new{group=g.Key,description=g.Value.Description}));
string feedback="";
for(int attempt=0;attempt<3;attempt++)
{
ct.ThrowIfCancellationRequested();
try
{
var route=Parse<Route>(await Run("thinking_route",new(){["request"]=request,["context"]=context,["catalog"]=groups,["feedback"]=feedback},ct));
if(route.Kind=="clarify")return Clarification(route.Question);
if(route.Kind=="chat")return new("chat","","",[],"");
if(route.Kind!="specialist"||!Groups.ContainsKey(route.Group))throw new InvalidDataException("Choose an available capability group.");
string catalog=JsonSerializer.Serialize(Catalog(route.Group));
var choice=Parse<ThinkingChoice>(await Run("thinking_specialist",new(){["request"]=request,["context"]=context,["group"]=route.Group,["catalog"]=catalog,["feedback"]=feedback},ct));
if(choice.Kind=="clarify")return Clarification(choice.Question);
if(choice.Kind!="scenario"||!Groups[route.Group].Scenarios.Contains(choice.Scenario,StringComparer.Ordinal))throw new InvalidDataException("Choose one public scenario from the selected group.");
if(choice.Inputs is null)throw new InvalidDataException("Inputs must be a dictionary of strings.");
ScenarioFileService.BindXml(_files.LoadScenarioXml(choice.Scenario),choice.Inputs);
var review=Parse<Review>(await Run("thinking_review",new(){["request"]=request,["context"]=context,["catalog"]=catalog,["plan"]=JsonSerializer.Serialize(choice)},ct));
if(review.Approved)return choice with{Group=route.Group};
feedback=review.Feedback??"Review the requested intent and input values again.";
}
catch(Exception ex) when(ex is JsonException or InvalidDataException or ArgumentException or FileNotFoundException)
{feedback=ex.Message[..Math.Min(1000,ex.Message.Length)];}
}
return Clarification("I couldn't prepare a validated action. Please clarify the specific change or question; nothing has been executed.");
}
private static ThinkingChoice Clarification(string? question)=>new("clarify","","",[],string.IsNullOrWhiteSpace(question)?"What specific action would you like me to take?":question[..Math.Min(1200,question.Length)]);
internal Task<string> Reply(string request,CancellationToken ct)=>Run("thinking_reply",new(){["request"]=request,["context"]=JsonSerializer.Serialize(provider.History.Where(m=>m.Role is "user" or "assistant").TakeLast(4).Select(m=>new{m.Role,text=m.Content[..Math.Min(1000,m.Content.Length)]})),["identity"]=JsonSerializer.Serialize(new{name=provider.AgentName,profile=provider.Personality?.Current})},ct);
private sealed record Review(bool Approved,string? Feedback);
private sealed record Route(string Kind,string Group,string Question);
}