V18 / 8d896b8a86e5
ReleaseRuntime.cs
Interfaces/Gui/LilithGui.Worker/ReleaseRuntime.cs
Changed in this versionCS
using System.Collections;
using System.Reflection;
using System.Text.Json;
using System.Xml.Linq;
using static Lilith.Gui.Worker.ReflectionApi;
namespace Lilith.Gui.Worker;
internal sealed partial class ReleaseRuntime(Assembly core,Assembly agentAssembly,string backend,string state,Action<string,object?> emit) : IAsyncDisposable
{
private object? _agent,_provider;
private bool _attached,_speech,_kanbanAttached;
private bool _desktopResearchAttached;
private readonly List<object> _trace=[];
private readonly HashSet<string> _reportedTools=new(StringComparer.Ordinal);
private void CaptureToolResults(bool publish=true)
{
if(Get(_agent,"History") is not IEnumerable history)return;
int index=0;
foreach(var message in history.Cast<object>())
{
index++;
if(Get(message,"Role")?.ToString()!="tool")continue;
string? id=Get(message,"ToolCallId")?.ToString();
if(string.IsNullOrEmpty(id))id="legacy-"+index+"-"+Get(message,"ToolName");
if(!_reportedTools.Add(id))continue;
if(!publish)continue; // Loading saved evidence is not a new execution.
string output=Get(message,"Content")?.ToString()??"";
emit("tool",new{id,name=Get(message,"ToolName")?.ToString()??"tool",kind="Result",text=output[..Math.Min(6000,output.Length)],truncated=output.Length>6000});
}
}
private readonly object _traceLock=new();
private static string Text(JsonElement p,string key,string fallback="")=>p.TryGetProperty(key,out var v)?v.ToString():fallback;
private Type Type(string name)=>core.GetType(name)??throw new NotSupportedException("Feature unavailable in this release: "+name);
private void Trace(object value){lock(_traceLock){_trace.Add(value);if(_trace.Count>1000)_trace.RemoveAt(0);}emit("trace",value);}
private void Log(object value)
{
AttachProvider();
string sender=Get(value,"Sender")?.ToString()??"System",text=Get(value,"Description")?.ToString()??"";
if(text.StartsWith("Speech unavailable:",StringComparison.Ordinal)){_speech=false;emit("speech",new{enabled=false,message=text});}
// Legacy console descriptions contain their own labels. Keep raw text for
// legacy message detection, but expose one sender and an unlabelled body.
string traceSender=sender.Equals("System",StringComparison.OrdinalIgnoreCase)?"System":sender;
string traceText=text;
while(traceText.StartsWith(traceSender+": ",StringComparison.OrdinalIgnoreCase))
traceText=traceText[(traceSender.Length+2)..];
if(traceSender.StartsWith("Subagent ",StringComparison.Ordinal)&&traceText.StartsWith(traceSender+" ",StringComparison.Ordinal))
traceText=traceText[(traceSender.Length+1)..];
Trace(new{sender=traceSender,text=traceText,at=Get(value,"Timestamp")});
string name=Get(_provider,"AgentName")?.ToString()??"Lilith";
if(sender is "Scheduler" or "Calendar")emit("message",new{role="notification",name=sender,text,at=DateTimeOffset.UtcNow});
if(sender==name&&text.StartsWith(name+": ",StringComparison.Ordinal))
{
emit("message",new{role="assistant",name,text=text[(name.Length+2)..],at=DateTimeOffset.UtcNow});
emit("status",new{state=_speech?"speaking":"idle",text=_speech?"Speaking":"Ready"});
}
}
private void AttachProvider()
{
if(_agent is null)return;
_provider??=_agent.GetType().GetField("_llmclient",BindingFlags.Instance|BindingFlags.NonPublic)?.GetValue(_agent);
if(_provider is null)return;
if(!_desktopResearchAttached&&Environment.GetEnvironmentVariable("LILITH_DESKTOP_BROWSER")=="1"&&core.GetType("Lilith.Core.WebResearch.IResearchBrowser") is {} browserInterface)
{
_desktopResearchAttached=true;
var browser=DispatchProxy.Create(browserInterface,typeof(DesktopResearchBridge));
var bridge=(DesktopResearchBridge)browser;
bridge.Emit=emit;
bridge.Options=core.GetType("Lilith.Core.WebResearch.BrowserResearchOptions")!.GetMethod("Load")!.Invoke(null,null);
_provider.GetType().GetMethod("ConfigureResearchBrowser")!.Invoke(_provider,[browser]);
}
if(!_attached)
{
_attached=true;
Attach(_provider,"ScenarioProgress",progress=>emit("scenario",progress));
Attach(_provider,"SubagentOutput",output=>emit("subagent",output));
Attach(_provider,"WebsiteScreenshotsReady",images=>emit("images",images));
}
var kanban=Get(_provider,"Kanban");var store=Get(kanban,"Store");
if(store is not null&&!_kanbanAttached){_kanbanAttached=true;Attach(store,"Changed",board=>{emit("kanban",board);if(Get(board,"State")?.ToString() is "waiting_plan" or "waiting_test" or "waiting_publication")emit("approvals_invalidated",new{});});}
}
internal async Task<object?> DispatchAsync(string action,JsonElement payload,CancellationToken ct)
{
if(action=="boot")return await Boot(payload);
if(_agent is null||Get(_agent,"IsBooted") is not true)throw new InvalidOperationException("Start Lilith first.");
AttachProvider();
return action switch
{
"chat"=>await Chat(Text(payload,"text"),Text(payload,"clientId"),ct),
"command"=>await Command(Text(payload,"text"),ct),
"panel"=>await Panel(Text(payload,"panel"),payload,ct),
"scenario"=>await Scenario(Text(payload,"name"),payload,ct),
"history"=>await Call(_agent,"ListConversations"),
"approvals"=>await Approvals(ct),
"approval_decide"=>await DecideApproval(payload,ct),
_=>throw new ArgumentException("Unknown GUI action.")
};
}
private async Task<object> Boot(JsonElement p)
{
if(_agent is not null)throw new InvalidOperationException("Restart the worker to change boot settings.");
Directory.CreateDirectory(state);Directory.CreateDirectory(Path.Combine(state,"Workspace"));
string voiceDirectory=Path.Combine(backend,"voices");
if(Directory.Exists(voiceDirectory))
{
// Resolve the selected release's installed voice assets, not the GUI worker's folder.
var voices=Assembly.Load("KokoroSharp").GetType("KokoroSharp.KokoroVoiceManager");
voices?.GetMethod("LoadVoicesFromPath",BindingFlags.Public|BindingFlags.Static)?.Invoke(null,[voiceDirectory]);
}
string model=Text(p,"model","gemma4"),provider=Text(p,"provider","Ollama"),name=Text(p,"name","Lilith");
if(model.Length is <1 or >150||name.Length is <1 or >60||provider is not("Ollama" or "LMStudio"))throw new ArgumentException("Choose a provider, model and name.");
_speech=p.TryGetProperty("speech",out var speech)&&speech.GetBoolean();
object tts=Create(Type("Lilith.Core.Kokoro.KokoroTtsOptions"),new Dictionary<string,object?>{{"Enabled",_speech},{"WaitForTts",true}});
var agentType=agentAssembly.GetType("Lilith.Agent.Lilith")!;
string prompt=agentType.GetField("DefaultSystemPrompt")?.GetRawConstantValue()?.ToString()??"You are Lilith, a helpful local assistant.";
prompt=prompt.Replace("You are Lilith","You are "+name,StringComparison.Ordinal);
object credentials=Create(agentAssembly.GetType("Lilith.Agent.LilithCredentials")!,new Dictionary<string,object?>{{"Model",model},{"SystemPrompt",prompt},{"AgentName",name},{"Provider",provider},{"Tts",tts}});
var constructor=agentType.GetConstructors().Single(c=>c.GetParameters().Length==2);
var logger=new EventForwarder(Log).Create(constructor.GetParameters()[0].ParameterType);
_agent=constructor.Invoke([logger,credentials]);
if (p.TryGetProperty("contextTokens", out var contextTokens))
ContextBoundFactory.Configure(agentAssembly, _agent, contextTokens.GetInt32());
var workspaceProperty=agentType.GetProperty("WorkspaceOptions");
if(workspaceProperty?.CanWrite==true)
{
string mode=Text(p,"mode","Ask");if(mode is not("Ask" or "Yolo"))throw new ArgumentException("Unknown access mode.");
workspaceProperty.SetValue(_agent,Create(workspaceProperty.PropertyType,new Dictionary<string,object?>{{"RootDirectory",Path.Combine(state,"Workspace")},{"Mode",mode}}));
}
emit("status",new{state="starting",text="Starting "+provider+" / "+model});
if(core.GetType("Lilith.Core.Scenarios.ScenarioExecutor") is not null)emit("catalog",ScenarioLibrary());
await Call(_agent,"BootAsync",new Dictionary<string,object?>{{"startServerIfNotRunning",false}});
CaptureToolResults();
AttachProvider();var capabilities=Capabilities();emit("ready",new{capabilities,model,provider,name,speech=_speech,mode=Get(Get(Get(_provider,"Workspace"),"Options"),"Mode")?.ToString(),context=Get(_agent,"ContextMax"),profile=Get(Get(_provider,"Personality"),"Current"),backendVersion=core.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion});
return new{capabilities,message="Ready"};
}
private async Task<object?> Chat(string text,string clientId,CancellationToken ct)
{
if(string.IsNullOrWhiteSpace(text)||text.Length>16000)throw new ArgumentException("Message must contain 1–16000 characters.");
emit("message",new{role="user",name="You",text,id=clientId,at=DateTimeOffset.UtcNow});
if(text.StartsWith('/'))return await Command(text,ct);
emit("status",new{state="thinking",text="Thinking"});
try{await Call(_agent!,"ChatAsync",new Dictionary<string,object?>{{"input",text},{"cancellationToken",ct}});}
finally{CaptureToolResults();}
return new{message="Reply complete.",profile=Get(Get(_provider,"Personality"),"Current")};
}
private async Task<object?> Command(string text,CancellationToken ct)
{
if(!text.StartsWith('/')||text.Contains('\n')||text.Length>1000)throw new ArgumentException("Use one registered chat command.");
emit("status",new{state="working",text="Running host command"});
var commands=Get(_agent,"ChatCommands")??throw new NotSupportedException("Chat commands are unavailable in this snapshot.");
var result=await Call(commands,"ExecuteAsync",new Dictionary<string,object?>{{"input",text},{"cancellationToken",ct}});
if(text.Equals("/new",StringComparison.OrdinalIgnoreCase))_reportedTools.Clear();
if(text.StartsWith("/history",StringComparison.OrdinalIgnoreCase))CaptureToolResults(false);
emit("profile",Get(Get(_provider,"Personality"),"Current"));
emit("command",result);return result;
}
private async Task<object?> Scenario(string name,JsonElement p,CancellationToken ct)
{
emit("status",new{state="working",text="Scenario · "+name});
var allowed=new[]{"memory_save","memory_read","memory_search","memory_forget","calendar_create","calendar_update","calendar_delete","calendar_read","calendar_search","calendar_agenda","workspace_list","workspace_read","workspace_save","workspace_mkdir","workspace_search","workspace_bin","workspace_restore","schedule_list","schedule_peek","schedule_create","schedule_cancel","schedule_delete","profile_read","voices_list","name_change","voice_change","sex_change","personality_change","email_accounts","email_check","email_read","email_briefing","email_sections","message_channels","message_check","message_status","message_send","home_devices","home_state","home_on","home_off","home_brightness","home_color","kanban_status"};
if(!allowed.Contains(name,StringComparer.Ordinal)&&(!System.Text.RegularExpressions.Regex.IsMatch(name,"^[A-Za-z][A-Za-z0-9_-]{0,63}$")||!File.Exists(Path.Combine(backend,"Scenarios",name+".xml"))))
throw new ArgumentException("Only actual saved scenarios from this release may run. Browser-supplied executable XML is not accepted.");
var inputs=new Dictionary<string,string>();
if(p.TryGetProperty("inputs",out var values))foreach(var item in values.EnumerateObject())
{if(item.Value.ValueKind!=JsonValueKind.String)throw new ArgumentException("Scenario inputs must be strings.");inputs.Add(item.Name,item.Value.GetString()!);}
try
{
var result=await Call(_provider!,"RunScenarioAsync",new Dictionary<string,object?>{{"scenarioName",name},{"inputs",inputs},{"cancellationToken",ct}});
emit("profile",Get(Get(_provider,"Personality"),"Current"));
emit("receipt",new{name,result});return result;
}
catch(Exception ex) when(ex.GetBaseException() is InvalidDataException failure&&failure.Message.StartsWith("Configured scenario did not complete: ",StringComparison.Ordinal))
{
// Older application-dispatch APIs reject a legitimate approval stop as
// incomplete. Recognize only a receipt verified against its owning service.
const string prefix="Configured scenario did not complete: ";
using var response=JsonDocument.Parse(ex.GetBaseException().Message[prefix.Length..]);
var data=response.RootElement;
if(data.GetProperty("status").GetString()!="needs_approval"||!data.TryGetProperty("result",out var receipt))throw;
string? proposal=receipt.TryGetProperty("ProposalId",out var id)?id.GetString():null;
if(string.IsNullOrWhiteSpace(proposal))throw;
string service;
if(name.StartsWith("calendar_",StringComparison.Ordinal))
{
var pending=await Service("Calendar","Lilith.Core.Calendar.CalendarRequest","pending",ct);
if(Get(pending,"Data") is not IEnumerable list||!list.Cast<object>().Any(item=>Get(item,"Id")?.ToString()==proposal))throw;
service="calendar";
}
else if(name.StartsWith("schedule_",StringComparison.Ordinal))
{
var pending=await Service("Schedules","Lilith.Core.Scheduling.ScheduleRequest","pending",ct);
if(Get(pending,"Data") is not IEnumerable list||!list.Cast<object>().Any(item=>Get(item,"Id")?.ToString()==proposal))throw;
service="schedules";
}
else if(name.StartsWith("workspace_",StringComparison.Ordinal)||name.StartsWith("memory_",StringComparison.Ordinal))
{
var actual=await Call(Get(_provider,"Workspace")!,"Review",new Dictionary<string,object?>{{"id",proposal}});
if(Get(actual,"Id")?.ToString()!=proposal)throw;
service="workspace";
}
else throw;
var result=new{status="needs_approval",service,proposalId=proposal,receipt=receipt.Clone()};
emit("receipt",new{name,result});return result;
}
}
private object[] Capabilities()
{
var list=new List<object>();
void Add(string id,string label,int version,bool available){if(available)list.Add(new{id,label,introduced=version});}
Add("tools","Tools",2,core.GetType("Lilith.Core.Tools.ILilithTool") is not null);
Add("scenarios","Scenarios",3,core.GetType("Lilith.Core.Scenarios.ScenarioExecutor") is not null);
Add("workspace","Workspace",4,Get(_provider,"Workspace") is not null);
Add("schedules","Timers & cron",5,Get(_provider,"Schedules") is not null);
Add("memory","Memory",7,File.Exists(Path.Combine(backend,"Scenarios","memory_read.xml")));
Add("research","Web research",8,core.GetType("Lilith.Core.WebResearch.SeleniumResearchBrowser") is not null);
Add("personality","Personality",9,Get(_provider,"Personality") is not null);
Add("calendar","Calendar",10,Get(_provider,"Calendar") is not null);
Add("projects","Projects",11,core.GetType("Lilith.Core.Projects.ProjectGeneration") is not null);
Add("email","Email",13,core.GetType("Lilith.Core.Email.EmailClient") is not null);
Add("messaging","Channels",14,core.GetType("Lilith.Core.Messaging.MessageBridgeClient") is not null);
Add("home","Home",15,Get(_provider,"Home") is not null);
Add("kanban","Kanban",16,Get(_provider,"Kanban") is not null);
return list.ToArray();
}
private async Task<object?> Panel(string panel,JsonElement p,CancellationToken ct)
{
Trace(new{sender="GUI",text="Read "+panel+" view from the selected backend.",at=DateTimeOffset.UtcNow});
switch(panel)
{
case "history":return await Call(_agent!,"ListConversations");
case "conversation":return Get(_agent,"History");
case "traces":lock(_traceLock)return _trace.ToArray();
case "personality":
var catalog=Type("Lilith.Core.Personality.PersonalityCatalog");
return new{profile=Get(Get(_provider,"Personality"),"Current"),styles=catalog.GetProperty("Styles",BindingFlags.Public|BindingFlags.Static)?.GetValue(null),
voices=catalog.GetMethod("Voices",BindingFlags.Public|BindingFlags.Static)?.Invoke(null,[Get(Get(Get(_provider,"Personality"),"Default"),"Voice")?.ToString()??"af_heart"])};
case "calendar":
var calendar=ReadOwnedState("Calendar","calendar.json");
return calendar is {} saved?new{entries=new{Data=saved.GetProperty("Entries")},pending=new{Data=saved.GetProperty("Pending")}}
:new{entries=await Service("Calendar","Lilith.Core.Calendar.CalendarRequest","search",ct),pending=await Service("Calendar","Lilith.Core.Calendar.CalendarRequest","pending",ct)};
case "schedules":return await Service("Schedules","Lilith.Core.Scheduling.ScheduleRequest","list",ct);
case "workspace":return await Workspace("list",Text(p,"path"),ct);
case "memory":return await Memory(ct);
case "scenarios":return ScenarioLibrary();
case "kanban":return await Call(Get(Get(_provider,"Kanban"),"Store")??throw new NotSupportedException("Kanban unavailable."),"List");
case "projects":
var workspace=Get(_provider,"Workspace")??throw new NotSupportedException("Workspace unavailable.");
var drafts=core.GetType("Lilith.Core.Projects.ProjectDraftStore");
if(drafts is not null)return await Call(Create(drafts,new Dictionary<string,object?>{{"workspace",workspace}}),"List");
string projectRoot=Path.Combine(state,"Workspace");
return Directory.Exists(projectRoot)?Directory.EnumerateDirectories(projectRoot).Where(d=>!Path.GetFileName(d).StartsWith('.')&&File.Exists(Path.Combine(d,"lilith-project.json"))).Take(100).Select(d=>(object)new{Name=Path.GetFileName(d),State="published",Kind="managed project"}).ToArray():[];
case "tools":
var registered=_provider?.GetType().GetProperty("RegisteredTools",BindingFlags.Instance|BindingFlags.NonPublic)?.GetValue(_provider) as IEnumerable;
return registered?.Cast<object>().Select(pair=>{var tool=Get(pair,"Value");return new{name=Get(tool,"Name"),description=Get(tool,"Description")?.ToString()?[..Math.Min(250,Get(tool,"Description")!.ToString()!.Length)]};}).ToArray()??[];
case "home":return await Call(Get(_provider,"Home")??throw new NotSupportedException("Home unavailable."),"ExecuteAsync",new Dictionary<string,object?>{{"action","devices"},{"ct",ct}});
case "email":return new{configured=!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("LILITH_EMAIL_API_TOKEN")),mode=Get(_provider,"EmailMode"),message="Checking a mailbox is an explicit action. Credentials stay in the email service."};
case "messaging":return new{configured=!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("LILITH_MESSAGES_API_TOKEN")),message="Connections and incoming messages are fetched only when you request them."};
case "research":return new{message="Ask a research question in chat. Search and reading traces appear here as actual scenario events."};
default:throw new NotSupportedException("Panel unavailable in this release.");
}
}
private JsonElement? ReadOwnedState(string service,string filename)
{
string? root=Get(Get(Get(_provider,service),"Options"),"RootDirectory")?.ToString();
if(root is null)throw new NotSupportedException(service+" unavailable.");
string path=Path.GetFullPath(Path.Combine(root,filename)),scope=Path.GetFullPath(state)+Path.DirectorySeparatorChar;
if(!path.StartsWith(scope,StringComparison.OrdinalIgnoreCase))throw new IOException("GUI state is outside the isolated version directory.");
string? check=path;
while(check is not null&&check.StartsWith(scope,StringComparison.OrdinalIgnoreCase))
{
if((File.Exists(check)||Directory.Exists(check))&&(File.GetAttributes(check)&FileAttributes.ReparsePoint)!=0)throw new IOException("Linked state paths are not read by the GUI.");
check=Path.GetDirectoryName(check);
}
if(!File.Exists(path))return null;if(new FileInfo(path).Length>2000000)throw new IOException("Oversized state snapshot.");
using var doc=JsonDocument.Parse(File.ReadAllText(path));return doc.RootElement.Clone();
}
private async Task<object?> Service(string property,string requestType,string operation,CancellationToken ct)
{
object service=Get(_provider,property)??throw new NotSupportedException(property+" unavailable.");
object request=Create(Type(requestType),new Dictionary<string,object?>{{"Operation",operation}});
return await Call(service,"Execute",new Dictionary<string,object?>{{"request",request}});
}
private async Task<object?> Workspace(string operation,string path,CancellationToken ct)
{
object request=Create(Type("Lilith.Core.Workspace.WorkspaceRequest"),new Dictionary<string,object?>{{"Operation",operation},{"Path",path}});
return await Call(Get(_provider,"Workspace")??throw new NotSupportedException("Workspace unavailable."),"ExecuteAsync",new Dictionary<string,object?>{{"request",request},{"ct",ct}});
}
private async Task<object> Memory(CancellationToken ct)
{
string directory=Path.Combine(state,"Workspace","memory");
object? result=Directory.Exists(directory)?await Workspace("list","memory",ct):null;
var facts=new List<object>();
if(Directory.Exists(directory))foreach(string file in Directory.EnumerateFiles(directory,"*.txt").Take(100))
{
if((File.GetAttributes(file)&FileAttributes.ReparsePoint)!=0)continue;
if(new FileInfo(file).Length>32000)continue;string value=await File.ReadAllTextAsync(file,ct);
facts.Add(new{key=Path.GetFileNameWithoutExtension(file),value=value[..Math.Min(6000,value.Length)]});
}
return new{facts,listing=result};
}
private object[] ScenarioLibrary()
{
string root=Path.Combine(backend,"Scenarios");if(!Directory.Exists(root))return [];
return Directory.EnumerateFiles(root,"*.xml").Order().Select(path=>
{
string xml=File.ReadAllText(path);var doc=XDocument.Parse(xml);var scenario=doc.Root!;
return (object)new{id=(string?)scenario.Attribute("id")??Path.GetFileNameWithoutExtension(path),title=(string?)scenario.Attribute("title"),xml,
inputs=scenario.Element("inputs")?.Elements("input").Select(i=>new{name=(string?)i.Attribute("name"),required=(string?)i.Attribute("required"),value=(string?)i.Attribute("default")}).ToArray(),
steps=scenario.Element("steps")?.Descendants().Where(e=>e.Name.LocalName is "step" or "run" or "tool" or "if" or "foreach" or "result").Select((e,index)=>new{index=index+1,kind=e.Name.LocalName,target=(string?)e.Attribute("scenario")??(string?)e.Attribute("name"),text=e.Value[..Math.Min(400,e.Value.Length)]}).ToArray()};
}).ToArray();
}
public async ValueTask DisposeAsync(){if(_agent is IAsyncDisposable disposable)await disposable.DisposeAsync();}
}