New web interface, move llm engine to separate process

This commit is contained in:
2025-05-20 09:43:17 +02:00
parent 895a533e01
commit d4a4902b94
137 changed files with 4850 additions and 3503 deletions

212
sia/llm_engine.py Normal file
View File

@@ -0,0 +1,212 @@
from typing import Iterator
from .util import pretty_print_element
import subprocess
import sys
import xml.etree.ElementTree as ET
class LlmEngine:
"""
LlmEngine manages communication with LLM engine subprocesses.
Each LLM type runs in its own subprocess with a tailored environment.
"""
EOT = '\x04' # EOT character (ASCII 4) as bytes
def __init__(self, executable_path: str, action_schema_path: str):
"""
Initialize the LLM engine subprocess.
Args:
executable_path (str): Path to the LLM engine executable
action_schema_path (str): Path to the XML action schema
"""
self.executable_path = executable_path
self.action_schema_path = action_schema_path
self.action_schema = open(action_schema_path, 'r').read()
self.process = None
self.restart()
def _read_until_eot(self) -> str:
"""
Read from subprocess stdout until EOT character.
Returns:
str: Complete response without the EOT character
"""
response = []
while True:
# Read available data
data = self.process.stdout.read(1024) # Read up to 1024 bytes at a time
if not data: # process died
self.restart()
raise RuntimeError(f"LLM subprocess terminated unexpectedly")
data = data.decode('utf-8')
# Check if EOT is in the data
if self.EOT in data:
eot_index = data.index(self.EOT)
response.append(data[:eot_index]) # Add data before EOT
break
else:
response.append(data)
return "".join(response)
def token_limit(self) -> int:
"""
Get the maximum token limit of the LLM.
Returns:
int: Maximum token limit
"""
self.process.stdin.write(b"<token_limit/>\n")
self.process.stdin.flush()
response = self._read_until_eot()
return int(response.strip())
def token_count(self, system_prompt: str, main_context: ET.Element, prefix: str = "") -> int:
"""
Count the number of tokens in the prompt.
Args:
system_prompt (str): System prompt text
main_context (ET.Element): Main context as ElementTree
prefix (str): Optional prefix for continuing generation
Returns:
int: Token count
"""
# Create the XML document
root = ET.Element("token_count")
# Add system prompt
system_prompt_elem = ET.SubElement(root, "system")
system_prompt_elem.text = self._append_action_schema(system_prompt)
# Add context element
context_elem = ET.SubElement(root, "context")
context_elem.text = pretty_print_element(main_context)
# Add prefix if provided
if prefix:
prefix_elem = ET.SubElement(root, "prefix")
prefix_elem.text = prefix
# Send to subprocess - convert to bytes
xml_str = ET.tostring(root, encoding='utf-8')
self.process.stdin.write(xml_str + b"\n")
self.process.stdin.flush()
# Read response
response = self._read_until_eot()
return int(response.strip())
def infer(self, system_prompt: str, main_context: ET.Element, prefix: str = "") -> Iterator[str]:
"""
Generate text from the LLM.
Args:
system_prompt (str): System prompt text
main_context (ET.Element): Main context as ElementTree
prefix (str): Optional prefix for continuing generation
Returns:
Iterator[str]: Generated text, yielded as it's produced
"""
# Create the XML document
root = ET.Element("infer_xml")
# Add action schema path
schema_path_elem = ET.SubElement(root, "schema")
schema_path_elem.text = self.action_schema_path
# Add system prompt in CDATA
system_prompt_elem = ET.SubElement(root, "system")
system_prompt_elem.text = self._append_action_schema(system_prompt)
# Add context element
context_elem = ET.SubElement(root, "context")
context_elem.text = pretty_print_element(main_context)
# Add prefix if provided
if prefix:
prefix_elem = ET.SubElement(root, "prefix")
prefix_elem.text = prefix
# Send to subprocess - convert to bytes
xml_str = ET.tostring(root, encoding='utf-8')
self.process.stdin.write(xml_str + b"\n")
self.process.stdin.flush()
while True:
# Read available data
data = self.process.stdout.read(1024) # Read up to 1024 bytes at a time
if not data: # Process died
self.restart()
raise RuntimeError("LLM subprocess terminated unexpectedly")
data = data.decode('utf-8')
if self.EOT in data:
eot_index = data.index(self.EOT)
if eot_index > 0:
yield data[:eot_index]
break
else:
yield data
def restart(self):
"""Start the LLM engine subprocess."""
# Ensure any existing process is terminated
if self.process:
self._terminate_process()
# Start the subprocess with pipes for stdin/stdout and direct stderr
self.process = subprocess.Popen(
["/bin/bash", "-c", self.executable_path],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=sys.stderr,
text=False, # Use binary mode to avoid buffering and encoding issues
bufsize=0,
)
# Check if the process started successfully
if self.process.poll() is not None:
raise RuntimeError(f"Failed to start LLM engine at {self.executable_path}")
def _terminate_process(self):
"""Terminate the LLM engine subprocess safely."""
if self.process:
try:
self.process.stdin.close()
self.process.stdout.close()
self.process.terminate()
try:
self.process.wait(timeout=5) # Wait for process to terminate
except subprocess.TimeoutExpired:
# Force kill if termination takes too long
self.process.kill()
self.process.wait(timeout=2)
finally:
self.process = None
def _append_action_schema(self, system_prompt: str) -> str:
"""
Append the action schema to the system prompt.
Args:
system_prompt (str): Original system prompt
Returns:
str: Updated system prompt with action schema
"""
return f"{system_prompt}\n\n--- Action Schema ---\n{self.action_schema}"
def __del__(self):
"""Cleanup when the object is destroyed."""
self._terminate_process()