start implementation on new architecture
This commit is contained in:
@@ -1,6 +1,4 @@
|
||||
from .agent_core import AgentCore
|
||||
from .llm_engine import LlmEngine
|
||||
from .docker_module import DockerModule
|
||||
|
||||
def main():
|
||||
"""Main entry point for the SIA application."""
|
||||
@@ -13,13 +11,7 @@ def main():
|
||||
with open(action_schema_path, 'r') as f:
|
||||
action_schema = f.read()
|
||||
|
||||
agent_core = AgentCore(
|
||||
system_prompt=f"{system_prompt}{action_schema}",
|
||||
action_schema=action_schema,
|
||||
docker_module=DockerModule(),
|
||||
llm_engine=LlmEngine(model_path=model_path)
|
||||
)
|
||||
agent_core.run_iteration()
|
||||
print("todo")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,51 +0,0 @@
|
||||
from itertools import tee
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
from .docker_module import DockerModule
|
||||
from .llm_engine import LlmEngine
|
||||
from .context_template import generate_context
|
||||
from .util import get_valid_root_elements, split_response
|
||||
|
||||
class AgentCore:
|
||||
"""
|
||||
Core orchestration class for SIA that manages the interaction between
|
||||
different modules and runs the main agent loop.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
system_prompt: str,
|
||||
action_schema: str,
|
||||
docker_module: DockerModule,
|
||||
llm_engine: LlmEngine
|
||||
):
|
||||
"""
|
||||
Initialize the AgentCore with required components.
|
||||
|
||||
Args:
|
||||
system_prompt: System prompt to use for the LLM
|
||||
action_schema_path: Path to the XML schema defining valid actions
|
||||
docker_module: DockerModule instance
|
||||
llm_engine: LLmEngine instance
|
||||
"""
|
||||
self.system_prompt = system_prompt
|
||||
self.action_schema = action_schema
|
||||
self.docker_module = docker_module
|
||||
self.llm_engine = llm_engine
|
||||
|
||||
self.valid_elements = get_valid_root_elements(self.action_schema)
|
||||
|
||||
def run_iteration(self) -> None:
|
||||
"""Run a single iteration of the main agent loop."""
|
||||
containers = self.docker_module.get_all_container_statuses()
|
||||
context = generate_context(containers)
|
||||
tokens = self.llm_engine.infer(
|
||||
self.system_prompt,
|
||||
context
|
||||
)
|
||||
print_tokens, response_tokens = tee(tokens)
|
||||
for token in print_tokens:
|
||||
print(token, end="", flush=True)
|
||||
response_tokens = ''.join(response_tokens)
|
||||
result = split_response(response_tokens, self.valid_elements)
|
||||
print(f"result: {result}")
|
||||
175
sia/background_entry.py
Normal file
175
sia/background_entry.py
Normal file
@@ -0,0 +1,175 @@
|
||||
from datetime import datetime
|
||||
import subprocess
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Optional
|
||||
|
||||
from .entry import Entry
|
||||
from .util import escape_text_for_xml
|
||||
|
||||
class BackgroundEntry(Entry):
|
||||
"""
|
||||
Entry type for long-running background processes.
|
||||
|
||||
Attributes:
|
||||
script: The script/command to execute
|
||||
stdout: Captured standard output since last update
|
||||
stderr: Captured standard error since last update
|
||||
process: The running subprocess.Popen instance
|
||||
_accumulated_stdout: Total stdout collected so far
|
||||
_accumulated_stderr: Total stderr collected so far
|
||||
_exit_code: Exit code when process completes
|
||||
"""
|
||||
|
||||
def __init__(self, script: str, id: str, timestamp: datetime):
|
||||
"""
|
||||
Initialize a new background entry.
|
||||
|
||||
Args:
|
||||
script: The script/command to execute
|
||||
id: Unique identifier for this entry
|
||||
timestamp: Creation timestamp for this entry
|
||||
"""
|
||||
super().__init__(id, timestamp)
|
||||
self.script = script
|
||||
self.stdout = ""
|
||||
self.stderr = ""
|
||||
self.process: Optional[subprocess.Popen] = None
|
||||
self._accumulated_stdout = ""
|
||||
self._accumulated_stderr = ""
|
||||
self._exit_code: Optional[int] = None
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""
|
||||
Clean up the background process if it's still running.
|
||||
Ensures process is terminated and file handles are closed.
|
||||
"""
|
||||
self._cleanup_process()
|
||||
|
||||
def _cleanup_process(self):
|
||||
"""Clean up process and file handles"""
|
||||
if self.process is not None:
|
||||
try:
|
||||
# Close file handles if they're open
|
||||
if self.process.stdout:
|
||||
self.process.stdout.close()
|
||||
if self.process.stderr:
|
||||
self.process.stderr.close()
|
||||
|
||||
# Terminate process if it's still running
|
||||
if self.process.poll() is None:
|
||||
self.process.terminate()
|
||||
try:
|
||||
self.process.wait(timeout=1.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
self.process.wait()
|
||||
except:
|
||||
pass # Ignore cleanup errors
|
||||
finally:
|
||||
self.process = None
|
||||
|
||||
def update(self) -> None:
|
||||
"""
|
||||
Start the process if not running and collect any new output.
|
||||
Updates stdout and stderr with any new output since last update.
|
||||
"""
|
||||
try:
|
||||
# Reset current output buffers
|
||||
self.stdout = ""
|
||||
self.stderr = ""
|
||||
|
||||
# Start process if not running
|
||||
if self.process is None and self._exit_code is None:
|
||||
self.process = subprocess.Popen(
|
||||
self.script,
|
||||
shell=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1 # Line buffered
|
||||
)
|
||||
return # Return after starting to allow process to generate output
|
||||
|
||||
if self.process is None:
|
||||
return # Process already completed
|
||||
|
||||
# Check if process has finished
|
||||
exit_code = self.process.poll()
|
||||
if exit_code is not None:
|
||||
# Process has terminated, collect remaining output
|
||||
try:
|
||||
remaining_out, remaining_err = self.process.communicate(timeout=0.1)
|
||||
self.stdout = remaining_out
|
||||
self.stderr = remaining_err
|
||||
self._accumulated_stdout += remaining_out
|
||||
self._accumulated_stderr += remaining_err
|
||||
except subprocess.TimeoutExpired:
|
||||
pass # Process didn't finish communicating, try again next update
|
||||
|
||||
self._exit_code = exit_code
|
||||
self._cleanup_process()
|
||||
return
|
||||
|
||||
# Read stdout if available
|
||||
if self.process.stdout:
|
||||
while True:
|
||||
try:
|
||||
line = self.process.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
self.stdout += line
|
||||
self._accumulated_stdout += line
|
||||
except:
|
||||
break
|
||||
|
||||
# Read stderr if available
|
||||
if self.process.stderr:
|
||||
while True:
|
||||
try:
|
||||
line = self.process.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
self.stderr += line
|
||||
self._accumulated_stderr += line
|
||||
except:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
# Handle any errors
|
||||
error_msg = f"Error handling background process: {str(e)}"
|
||||
self.stderr = error_msg
|
||||
self._accumulated_stderr += f"\n{error_msg}"
|
||||
self._exit_code = -1
|
||||
self._cleanup_process()
|
||||
|
||||
def generate_context(self) -> ET.Element:
|
||||
"""
|
||||
Generate an XML Element representing this background entry.
|
||||
|
||||
Returns:
|
||||
ET.Element: XML element containing the entry's data
|
||||
"""
|
||||
# Create root element with appropriate status
|
||||
element = ET.Element("background", {"id": self.id})
|
||||
|
||||
if self.process is not None:
|
||||
element.set("pid", str(self.process.pid))
|
||||
elif self._exit_code is not None:
|
||||
element.set("exit_code", str(self._exit_code))
|
||||
|
||||
# Add script as CDATA or escaped text
|
||||
element.text = escape_text_for_xml(self.script)
|
||||
|
||||
# Add stdout element with accumulated output
|
||||
stdout_elem = ET.SubElement(element, "stdout")
|
||||
stdout_elem.text = escape_text_for_xml(self._accumulated_stdout)
|
||||
|
||||
# Add stderr element with accumulated output
|
||||
stderr_elem = ET.SubElement(element, "stderr")
|
||||
stderr_elem.text = escape_text_for_xml(self._accumulated_stderr)
|
||||
|
||||
return element
|
||||
|
||||
def __del__(self):
|
||||
"""Ensure process is terminated and file handles are closed when entry is destroyed"""
|
||||
self._cleanup_process()
|
||||
25
sia/command.py
Normal file
25
sia/command.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .working_memory import WorkingMemory
|
||||
from .command_result import CommandResult
|
||||
|
||||
class Command(ABC):
|
||||
"""
|
||||
Abstract base class for all commands that can be executed on working memory.
|
||||
Commands represent immediate actions that modify the system state.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def execute(self, memory: 'WorkingMemory') -> 'CommandResult':
|
||||
"""
|
||||
Execute the command on the given working memory.
|
||||
|
||||
Args:
|
||||
memory: WorkingMemory instance to execute command on
|
||||
|
||||
Returns:
|
||||
CommandResult: Result of command execution
|
||||
"""
|
||||
pass
|
||||
35
sia/command_result.py
Normal file
35
sia/command_result.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
"""
|
||||
Result of a command execution.
|
||||
|
||||
Attributes:
|
||||
message: Optional message describing the result
|
||||
success: Whether the command executed successfully
|
||||
should_stop: Whether the agent should stop after this command
|
||||
"""
|
||||
message: str
|
||||
success: bool = True
|
||||
should_stop: bool = False
|
||||
|
||||
@staticmethod
|
||||
def success() -> 'CommandResult':
|
||||
"""Create a successful command result."""
|
||||
return CommandResult(message="", success=True, should_stop=False)
|
||||
|
||||
@staticmethod
|
||||
def failure(message: str) -> 'CommandResult':
|
||||
"""
|
||||
Create a failed command result.
|
||||
|
||||
Args:
|
||||
message: Description of the failure
|
||||
"""
|
||||
return CommandResult(message=message, success=False, should_stop=False)
|
||||
|
||||
@staticmethod
|
||||
def stop() -> 'CommandResult':
|
||||
"""Create a command result indicating the agent should stop."""
|
||||
return CommandResult(message="", success=True, should_stop=True)
|
||||
@@ -1,40 +0,0 @@
|
||||
from bs4 import BeautifulSoup
|
||||
from typing import List, Dict
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
def generate_context(containers: List[Dict[str, any]]) -> str:
|
||||
"""Generate full context XML string.
|
||||
|
||||
Args:
|
||||
containers: List of container status dictionaries
|
||||
|
||||
Returns:
|
||||
Formatted context XML string
|
||||
"""
|
||||
context_elem = ET.Element("context")
|
||||
context_elem.append(format_containers_xml(containers))
|
||||
return BeautifulSoup(ET.tostring(context_elem, encoding='unicode'), "xml").prettify()
|
||||
|
||||
def format_containers_xml(containers: List[Dict[str, any]]) -> ET.Element:
|
||||
"""Format container statuses as XML string.
|
||||
|
||||
Args:
|
||||
containers: List of dictionaries containing container information
|
||||
|
||||
Returns:
|
||||
ElementTree.Element representing the containers
|
||||
"""
|
||||
containers_elem = ET.Element("containers")
|
||||
|
||||
for container in containers:
|
||||
container_elem = ET.SubElement(containers_elem, "container")
|
||||
container_elem.set("name", container['name'])
|
||||
container_elem.set("status", container['status'])
|
||||
container_elem.set("started_at", container['started_at'])
|
||||
container_elem.set("stdout", str(container['stdout_size']))
|
||||
container_elem.set("stderr", str(container['stderr_size']))
|
||||
if 'exit_code' in container.keys():
|
||||
container_elem.set("exit_code", str(container['exit_code']))
|
||||
if 'error' in container.keys():
|
||||
container_elem.set("error", container['error'])
|
||||
return containers_elem
|
||||
42
sia/delete_command.py
Normal file
42
sia/delete_command.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from .command import Command
|
||||
from .command_result import CommandResult
|
||||
from .working_memory import WorkingMemory
|
||||
|
||||
class DeleteCommand(Command):
|
||||
"""
|
||||
Command to delete an entry from working memory.
|
||||
Ensures proper cleanup of entry resources before removal.
|
||||
|
||||
Attributes:
|
||||
id: Unique identifier of entry to delete
|
||||
"""
|
||||
|
||||
def __init__(self, id: str):
|
||||
"""
|
||||
Initialize delete command.
|
||||
|
||||
Args:
|
||||
id: Unique identifier of entry to delete
|
||||
"""
|
||||
self.id = id
|
||||
|
||||
def execute(self, memory: WorkingMemory) -> CommandResult:
|
||||
"""
|
||||
Delete the specified entry from working memory.
|
||||
Performs cleanup on the entry before removal.
|
||||
|
||||
Args:
|
||||
memory: WorkingMemory instance to delete entry from
|
||||
|
||||
Returns:
|
||||
CommandResult: Success if entry was found and deleted,
|
||||
failure with message if entry not found
|
||||
"""
|
||||
entry = memory.get_entry(self.id)
|
||||
if entry is None:
|
||||
return CommandResult.failure(f"Entry with id '{self.id}' not found")
|
||||
|
||||
# Perform cleanup before removing
|
||||
entry.cleanup()
|
||||
memory.remove_entry(self.id)
|
||||
return CommandResult.success()
|
||||
@@ -1,271 +0,0 @@
|
||||
from docker.models.containers import Container
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
import docker
|
||||
import logging
|
||||
import requests
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
class DockerModule:
|
||||
"""
|
||||
Module for handling Docker container operations in SIA.
|
||||
Manages container lifecycle, I/O operations, and status monitoring.
|
||||
"""
|
||||
|
||||
def _start_docker_engine():
|
||||
"""
|
||||
Start the Docker engine
|
||||
"""
|
||||
subprocess.run(['/etc/init.d/docker', 'start'])
|
||||
time.sleep(1)
|
||||
|
||||
def __init__(self):
|
||||
DockerModule._start_docker_engine()
|
||||
self.client = docker.DockerClient(base_url='unix://var/run/docker.sock')
|
||||
self.containers: Dict[str, (Container, docker.APIClient.AttachSocket)] = {}
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exec_type, exec_value, traceback):
|
||||
del self
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup all resources before shutdown."""
|
||||
try:
|
||||
# Stop and remove all containers
|
||||
for (container, socket) in self.containers.values():
|
||||
self._wait_and_stop(container, socket, 0)
|
||||
# Close the Docker client
|
||||
if hasattr(self, 'client'):
|
||||
self.client.close()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during cleanup: {str(e)}")
|
||||
|
||||
def _wait_and_stop(self, container: Container, socket, timeout: int) -> Tuple[int, str]:
|
||||
"""Helper method for waiting on long- and short-lived containers."""
|
||||
status_code = None
|
||||
if timeout > 0:
|
||||
try:
|
||||
result = container.wait(timeout=timeout/1000)
|
||||
status_code = result.get('StatusCode')
|
||||
except requests.exceptions.ReadTimeout as e:
|
||||
container.stop()
|
||||
status_code = -1
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
container.stop()
|
||||
status_code = -1
|
||||
else:
|
||||
container.stop()
|
||||
status_code = -1
|
||||
try:
|
||||
socket.close()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error closing socket: {str(e)}")
|
||||
output = container.logs(stdout=True, stderr=True).decode('utf-8')
|
||||
try:
|
||||
container.remove(force=True)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error removing container: {str(e)}")
|
||||
return status_code, output
|
||||
|
||||
def start_container(
|
||||
self,
|
||||
image: str,
|
||||
name: Optional[str] = None,
|
||||
timeout: int = -1,
|
||||
command: Optional[str] = None,
|
||||
arguments: Optional[List[str]] = None,
|
||||
volumes: Optional[Dict[str, str]] = None,
|
||||
ports: Optional[Dict[str, str]] = None,
|
||||
environment: Optional[Dict[str, str]] = None,
|
||||
) -> Optional[str]:
|
||||
"""Start a new Docker container with the specified configuration.
|
||||
|
||||
Args:
|
||||
image: Docker image to use
|
||||
name: Unique container name for long running containers
|
||||
timeout: Timeout in milliseconds for short running containers
|
||||
command: Main command to run in container
|
||||
arguments: List of command line arguments
|
||||
volumes: Dictionary mapping host paths to container paths
|
||||
ports: Dictionary mapping host ports to container ports
|
||||
environment: Dictionary of environment variables and values
|
||||
|
||||
Returns:
|
||||
For short-lived containers (with timeout): Container output
|
||||
For long-running containers: None
|
||||
"""
|
||||
# Validate inputs
|
||||
if name is None and timeout < 0:
|
||||
raise ValueError("Either name or timeout must be provided")
|
||||
cmd = []
|
||||
if command:
|
||||
cmd.append(command)
|
||||
if arguments:
|
||||
cmd.extend(arguments)
|
||||
volume_binds = {}
|
||||
if volumes:
|
||||
for host_path, container_path in volumes.items():
|
||||
volume_binds[host_path] = {'bind': container_path, 'mode': 'rw'}
|
||||
port_bindings = {}
|
||||
if ports:
|
||||
for host_port, container_port in ports.items():
|
||||
port_bindings[container_port] = host_port
|
||||
self.client.images.pull(image)
|
||||
container = self.client.containers.create(
|
||||
image,
|
||||
command=cmd if cmd else None,
|
||||
name=name,
|
||||
detach=True,
|
||||
volumes=volume_binds,
|
||||
ports=port_bindings,
|
||||
environment=environment,
|
||||
tty=False,
|
||||
stdin_open=True
|
||||
)
|
||||
socket = container.attach_socket(params={
|
||||
'stdin': 1,
|
||||
'stdout': 1,
|
||||
'stderr': 1,
|
||||
'stream': 1,
|
||||
'demux': True
|
||||
})
|
||||
container.start()
|
||||
if timeout >= 0:
|
||||
# For short-lived containers
|
||||
return self._wait_and_stop(container, socket, timeout)[1]
|
||||
else:
|
||||
# For long-running containers
|
||||
self.containers[name] = (container, socket)
|
||||
|
||||
def write_container_stdin(self, name: str, data: str) -> None:
|
||||
"""Write data to a container's standard input.
|
||||
|
||||
Args:
|
||||
name: Name of the target container
|
||||
data: Data to write to stdin
|
||||
|
||||
Raises:
|
||||
KeyError: If container name not found
|
||||
docker.errors.APIError: If write operation fails
|
||||
"""
|
||||
if name not in self.containers:
|
||||
raise KeyError(f"Container {name} not found")
|
||||
|
||||
try:
|
||||
_, socket = self.containers[name]
|
||||
socket._sock.send(data.encode('utf-8'))
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error writing to container {name} stdin: {str(e)}")
|
||||
raise
|
||||
|
||||
def read_container_stdout(self, name: str, n: int = -1) -> str:
|
||||
"""Read from a container's standard output buffer.
|
||||
|
||||
Args:
|
||||
name: Name of the container
|
||||
n: Number of bytes to read; -1 means read all available
|
||||
|
||||
Returns:
|
||||
Data read from stdout
|
||||
|
||||
Raises:
|
||||
KeyError: If container name not found
|
||||
docker.errors.APIError: If read operation fails
|
||||
"""
|
||||
if name not in self.containers:
|
||||
raise KeyError(f"Container {name} not found")
|
||||
|
||||
try:
|
||||
container, socket = self.containers[name]
|
||||
logs = container.logs(stdout=True, stderr=False, tail=n if n > 0 else 'all')
|
||||
return logs.decode('utf-8')
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error reading container {name} stdout: {str(e)}")
|
||||
raise
|
||||
|
||||
def read_container_stderr(self, name: str, n: int = -1) -> str:
|
||||
"""Read from a container's standard error buffer.
|
||||
|
||||
Args:
|
||||
name: Name of the container
|
||||
n: Number of bytes to read; -1 means read all available
|
||||
|
||||
Returns:
|
||||
Data read from stderr
|
||||
|
||||
Raises:
|
||||
KeyError: If container name not found
|
||||
docker.errors.APIError: If read operation fails
|
||||
"""
|
||||
if name not in self.containers:
|
||||
raise KeyError(f"Container {name} not found")
|
||||
|
||||
try:
|
||||
container, socket = self.containers[name]
|
||||
logs = container.logs(stdout=False, stderr=True, tail=n if n > 0 else 'all')
|
||||
return logs.decode('utf-8')
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error reading container {name} stderr: {str(e)}")
|
||||
raise
|
||||
|
||||
def wait_container(self, name: str, timeout: int) -> Tuple[int, str]:
|
||||
"""Wait for a container to finish execution.
|
||||
|
||||
Args:
|
||||
name: Name of the container to wait for
|
||||
timeout: Time to wait in milliseconds
|
||||
|
||||
Returns:
|
||||
Tuple of (exit_code, output)
|
||||
"""
|
||||
if name not in self.containers:
|
||||
raise KeyError(f"Container {name} not found")
|
||||
|
||||
container, socket = self.containers.pop(name)
|
||||
return self._wait_and_stop(container, socket, timeout)
|
||||
|
||||
def get_container_status(self, name: str) -> Dict[str, any]:
|
||||
"""Get current status information for a container.
|
||||
|
||||
Args:
|
||||
name: Name of the container
|
||||
|
||||
Returns:
|
||||
Dictionary with container status information
|
||||
|
||||
Raises:
|
||||
KeyError: If container name not found
|
||||
docker.errors.APIError: If status check fails
|
||||
"""
|
||||
if name not in self.containers:
|
||||
raise KeyError(f"Container {name} not found")
|
||||
|
||||
try:
|
||||
container, socket = self.containers[name]
|
||||
container.reload() # Refresh container info
|
||||
|
||||
return {
|
||||
'name': name,
|
||||
'status': container.status,
|
||||
'started_at': container.attrs['State']['StartedAt'],
|
||||
'exit_code': container.attrs['State']['ExitCode'],
|
||||
'error': container.attrs['State']['Error'],
|
||||
'stdout_size': len(container.logs(stdout=True, stderr=False)),
|
||||
'stderr_size': len(container.logs(stdout=False, stderr=True))
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting status for container {name}: {str(e)}")
|
||||
raise
|
||||
|
||||
def get_all_container_statuses(self) -> List[Dict[str, any]]:
|
||||
"""Get current status information for all containers.
|
||||
Returns:
|
||||
List of dictionaries with container status information
|
||||
"""
|
||||
statuses = []
|
||||
for name, (container, socket) in self.containers.items():
|
||||
statuses.append(self.get_container_status(name))
|
||||
return statuses
|
||||
50
sia/entry.py
Normal file
50
sia/entry.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
class Entry(ABC):
|
||||
"""
|
||||
Abstract base class for all entry types in the working memory.
|
||||
|
||||
Attributes:
|
||||
id: Unique identifier for the entry
|
||||
timestamp: When the entry was created
|
||||
"""
|
||||
|
||||
def __init__(self, id: str, timestamp: datetime):
|
||||
"""
|
||||
Initialize a new entry with provided id and timestamp.
|
||||
|
||||
Args:
|
||||
id: Unique identifier for this entry
|
||||
timestamp: Creation timestamp for this entry
|
||||
"""
|
||||
self.id = id
|
||||
self.timestamp = timestamp
|
||||
|
||||
@abstractmethod
|
||||
def update(self) -> None:
|
||||
"""
|
||||
Update the entry's state.
|
||||
Must be implemented by concrete classes.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def generate_context(self) -> ET.Element:
|
||||
"""
|
||||
Generate an XML Element representing this entry's context.
|
||||
Must be implemented by concrete classes.
|
||||
|
||||
Returns:
|
||||
ET.Element: XML element containing the entry's data
|
||||
"""
|
||||
pass
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""
|
||||
Clean up any resources used by this entry.
|
||||
Should be overridden by classes that need cleanup.
|
||||
Default implementation does nothing.
|
||||
"""
|
||||
pass
|
||||
@@ -1,5 +0,0 @@
|
||||
from typing import NamedTuple
|
||||
|
||||
class InferenceResult(NamedTuple):
|
||||
reasoning: str
|
||||
actions: str
|
||||
52
sia/parse_error_entry.py
Normal file
52
sia/parse_error_entry.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from datetime import datetime
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from .entry import Entry
|
||||
from .util import escape_text_for_xml
|
||||
|
||||
class ParseErrorEntry(Entry):
|
||||
"""
|
||||
Entry type for parse and validation errors.
|
||||
|
||||
Attributes:
|
||||
content: The original content that failed to parse
|
||||
error: The error message describing what went wrong
|
||||
"""
|
||||
|
||||
def __init__(self, content: str, error: str, id: str, timestamp: datetime):
|
||||
"""
|
||||
Initialize a new parse error entry.
|
||||
|
||||
Args:
|
||||
content: Original content that failed to parse
|
||||
error: Error message describing the failure
|
||||
id: Unique identifier for this entry
|
||||
timestamp: Creation timestamp for this entry
|
||||
"""
|
||||
super().__init__(id, timestamp)
|
||||
self.content = content
|
||||
self.error = error
|
||||
|
||||
def update(self) -> None:
|
||||
"""No update needed for parse error entries."""
|
||||
pass
|
||||
|
||||
def generate_context(self) -> ET.Element:
|
||||
"""
|
||||
Generate an XML Element representing this parse error entry.
|
||||
|
||||
Returns:
|
||||
ET.Element: XML element containing the entry's data
|
||||
"""
|
||||
# Create root element
|
||||
element = ET.Element("parse_error", {"id": self.id})
|
||||
|
||||
# Add error subelement
|
||||
error_elem = ET.SubElement(element, "error")
|
||||
error_elem.text = escape_text_for_xml(self.error)
|
||||
|
||||
# Add content subelement
|
||||
content_elem = ET.SubElement(element, "content")
|
||||
content_elem.text = escape_text_for_xml(self.content)
|
||||
|
||||
return element
|
||||
54
sia/read_entry.py
Normal file
54
sia/read_entry.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from datetime import datetime
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from .entry import Entry
|
||||
from .util import escape_text_for_xml
|
||||
|
||||
class ReadEntry(Entry):
|
||||
"""
|
||||
Entry type for reading content from standard input.
|
||||
|
||||
Attributes:
|
||||
content: Content read from stdin, empty until first update
|
||||
io_buffer: Buffer to use for IO operations
|
||||
_read: Whether content has been read
|
||||
"""
|
||||
|
||||
def __init__(self, io_buffer, id: str, timestamp: datetime):
|
||||
"""
|
||||
Initialize a new read entry.
|
||||
|
||||
Args:
|
||||
io_buffer: Buffer to use for IO operations
|
||||
id: Unique identifier for this entry
|
||||
timestamp: Creation timestamp for this entry
|
||||
"""
|
||||
super().__init__(id, timestamp)
|
||||
self.content = ""
|
||||
self.io_buffer = io_buffer
|
||||
self._read = False
|
||||
|
||||
def update(self) -> None:
|
||||
"""
|
||||
Read from stdin if not already read.
|
||||
Uses the provided IO buffer for the actual read operation.
|
||||
"""
|
||||
if not self._read:
|
||||
self.content = self.io_buffer.read()
|
||||
self._read = True
|
||||
|
||||
def generate_context(self) -> ET.Element:
|
||||
"""
|
||||
Generate an XML Element representing this read entry.
|
||||
|
||||
Returns:
|
||||
ET.Element: XML element containing the entry's data
|
||||
"""
|
||||
# Create root element
|
||||
element = ET.Element("read_stdin", {"id": self.id})
|
||||
|
||||
# Add content as CDATA or escaped text if content has been read
|
||||
if self._read:
|
||||
element.text = escape_text_for_xml(self.content)
|
||||
|
||||
return element
|
||||
44
sia/reasoning_entry.py
Normal file
44
sia/reasoning_entry.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from datetime import datetime
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from .entry import Entry
|
||||
from .util import escape_text_for_xml
|
||||
|
||||
class ReasoningEntry(Entry):
|
||||
"""
|
||||
Entry type for agent reasoning steps.
|
||||
|
||||
Attributes:
|
||||
content: The reasoning text
|
||||
"""
|
||||
|
||||
def __init__(self, content: str, id: str, timestamp: datetime):
|
||||
"""
|
||||
Initialize a new reasoning entry.
|
||||
|
||||
Args:
|
||||
content: The reasoning text
|
||||
id: Unique identifier for this entry
|
||||
timestamp: Creation timestamp for this entry
|
||||
"""
|
||||
super().__init__(id, timestamp)
|
||||
self.content = content
|
||||
|
||||
def update(self) -> None:
|
||||
"""No update needed for reasoning entries."""
|
||||
pass
|
||||
|
||||
def generate_context(self) -> ET.Element:
|
||||
"""
|
||||
Generate an XML Element representing this reasoning entry.
|
||||
|
||||
Returns:
|
||||
ET.Element: XML element containing the entry's data
|
||||
"""
|
||||
# Create root element
|
||||
element = ET.Element("reasoning", {"id": self.id})
|
||||
|
||||
# Add content as CDATA or escaped text
|
||||
element.text = escape_text_for_xml(self.content)
|
||||
|
||||
return element
|
||||
90
sia/repeat_entry.py
Normal file
90
sia/repeat_entry.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from datetime import datetime
|
||||
import subprocess
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Optional
|
||||
|
||||
from .entry import Entry
|
||||
from .util import escape_text_for_xml
|
||||
|
||||
class RepeatEntry(Entry):
|
||||
"""
|
||||
Entry type for scripts that are executed on every update.
|
||||
|
||||
Attributes:
|
||||
script: The script/command to execute
|
||||
stdout: Captured standard output from most recent execution
|
||||
stderr: Captured standard error from most recent execution
|
||||
exit_code: Process exit code from most recent execution
|
||||
"""
|
||||
|
||||
def __init__(self, script: str, id: str, timestamp: datetime):
|
||||
"""
|
||||
Initialize a new repeat entry.
|
||||
|
||||
Args:
|
||||
script: The script/command to execute
|
||||
id: Unique identifier for this entry
|
||||
timestamp: Creation timestamp for this entry
|
||||
"""
|
||||
super().__init__(id, timestamp)
|
||||
self.script = script
|
||||
self.stdout = ""
|
||||
self.stderr = ""
|
||||
self.exit_code: Optional[int] = None
|
||||
|
||||
def update(self) -> None:
|
||||
"""
|
||||
Execute the script and update the output.
|
||||
Captures stdout, stderr and exit code from each execution.
|
||||
"""
|
||||
try:
|
||||
# Run script and capture output
|
||||
process = subprocess.run(
|
||||
self.script,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
# Store results
|
||||
self.stdout = process.stdout
|
||||
self.stderr = process.stderr
|
||||
self.exit_code = process.returncode
|
||||
|
||||
except subprocess.SubprocessError as e:
|
||||
# Handle subprocess errors by capturing the error message
|
||||
self.stdout = ""
|
||||
self.stderr = str(e)
|
||||
self.exit_code = -1
|
||||
|
||||
except Exception as e:
|
||||
# Handle any other errors
|
||||
self.stdout = ""
|
||||
self.stderr = f"Error executing script: {str(e)}"
|
||||
self.exit_code = -1
|
||||
|
||||
def generate_context(self) -> ET.Element:
|
||||
"""
|
||||
Generate an XML Element representing this repeat entry.
|
||||
|
||||
Returns:
|
||||
ET.Element: XML element containing the entry's data
|
||||
"""
|
||||
# Create root element
|
||||
element = ET.Element("repeat", {
|
||||
"id": self.id,
|
||||
"exit_code": str(self.exit_code) if self.exit_code is not None else "-1"
|
||||
})
|
||||
|
||||
# Add script as CDATA or escaped text
|
||||
element.text = escape_text_for_xml(self.script)
|
||||
|
||||
# Add stdout element
|
||||
stdout_elem = ET.SubElement(element, "stdout")
|
||||
stdout_elem.text = escape_text_for_xml(self.stdout)
|
||||
|
||||
# Add stderr element
|
||||
stderr_elem = ET.SubElement(element, "stderr")
|
||||
stderr_elem.text = escape_text_for_xml(self.stderr)
|
||||
|
||||
return element
|
||||
101
sia/single_shot_entry.py
Normal file
101
sia/single_shot_entry.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from datetime import datetime
|
||||
import subprocess
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Optional
|
||||
|
||||
from .entry import Entry
|
||||
from .util import escape_text_for_xml
|
||||
|
||||
class SingleShotEntry(Entry):
|
||||
"""
|
||||
Entry type for one-time script executions.
|
||||
|
||||
Attributes:
|
||||
script: The script/command to execute
|
||||
stdout: Captured standard output from script execution
|
||||
stderr: Captured standard error from script execution
|
||||
exit_code: Process exit code after completion
|
||||
_executed: Whether the script has been executed
|
||||
"""
|
||||
|
||||
def __init__(self, script: str, id: str, timestamp: datetime):
|
||||
"""
|
||||
Initialize a new single shot entry.
|
||||
|
||||
Args:
|
||||
script: The script/command to execute
|
||||
id: Unique identifier for this entry
|
||||
timestamp: Creation timestamp for this entry
|
||||
"""
|
||||
super().__init__(id, timestamp)
|
||||
self.script = script
|
||||
self.stdout = ""
|
||||
self.stderr = ""
|
||||
self.exit_code: Optional[int] = None
|
||||
self._executed = False
|
||||
|
||||
def update(self) -> None:
|
||||
"""
|
||||
Execute the script if not already executed.
|
||||
Captures stdout, stderr and exit code.
|
||||
"""
|
||||
if self._executed:
|
||||
return
|
||||
|
||||
try:
|
||||
# Run script and capture output
|
||||
process = subprocess.run(
|
||||
self.script,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
# Store results
|
||||
self.stdout = process.stdout
|
||||
self.stderr = process.stderr
|
||||
self.exit_code = process.returncode
|
||||
|
||||
except subprocess.SubprocessError as e:
|
||||
# Handle subprocess errors by capturing the error message
|
||||
self.stdout = ""
|
||||
self.stderr = str(e)
|
||||
self.exit_code = -1
|
||||
|
||||
except Exception as e:
|
||||
# Handle any other errors
|
||||
self.stdout = ""
|
||||
self.stderr = f"Error executing script: {str(e)}"
|
||||
self.exit_code = -1
|
||||
|
||||
self._executed = True
|
||||
|
||||
def generate_context(self) -> ET.Element:
|
||||
"""
|
||||
Generate an XML Element representing this single shot entry.
|
||||
|
||||
Returns:
|
||||
ET.Element: XML element containing the entry's data
|
||||
"""
|
||||
# Create root element
|
||||
element = ET.Element("single_shot", {
|
||||
"id": self.id,
|
||||
})
|
||||
|
||||
# Add script as CDATA or escaped text
|
||||
element.text = escape_text_for_xml(self.script)
|
||||
|
||||
# Only add output elements if script has executed
|
||||
if self._executed:
|
||||
# Add stdout element if there is output
|
||||
stdout_elem = ET.SubElement(element, "stdout")
|
||||
stdout_elem.text = escape_text_for_xml(self.stdout)
|
||||
|
||||
# Add stderr element if there are errors
|
||||
stderr_elem = ET.SubElement(element, "stderr")
|
||||
stderr_elem.text = escape_text_for_xml(self.stderr)
|
||||
|
||||
# Add exit code attribute after execution
|
||||
element.set("exit_code", str(self.exit_code))
|
||||
|
||||
return element
|
||||
66
sia/standard_io_buffer.py
Normal file
66
sia/standard_io_buffer.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import sys
|
||||
|
||||
class StandardIOBuffer:
|
||||
"""
|
||||
IOBuffer implementation that uses system standard input/output.
|
||||
|
||||
This class provides direct access to stdin/stdout for IO operations.
|
||||
It implements a basic line-based input buffer to handle cases where
|
||||
multiple reads are needed to process all available input.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the standard IO buffer."""
|
||||
self._input_buffer: str = ""
|
||||
|
||||
def read(self) -> str:
|
||||
"""
|
||||
Read available input from stdin.
|
||||
|
||||
If there is buffered input from a previous read, return that first.
|
||||
Otherwise, try to read new input from stdin if available.
|
||||
|
||||
Returns:
|
||||
str: Content read from stdin, or empty string if no input available
|
||||
"""
|
||||
# Return and clear any existing buffered input
|
||||
if self._input_buffer:
|
||||
content = self._input_buffer
|
||||
self._input_buffer = ""
|
||||
return content
|
||||
|
||||
# Check if there's input available
|
||||
if not sys.stdin.isatty() and sys.stdin.readable():
|
||||
try:
|
||||
content = sys.stdin.read()
|
||||
if content:
|
||||
return content
|
||||
except Exception:
|
||||
pass # Ignore any read errors
|
||||
|
||||
return ""
|
||||
|
||||
def write(self, content: str) -> None:
|
||||
"""
|
||||
Write content to stdout.
|
||||
|
||||
Args:
|
||||
content: String content to write
|
||||
"""
|
||||
if not content:
|
||||
return
|
||||
|
||||
try:
|
||||
sys.stdout.write(content)
|
||||
sys.stdout.flush()
|
||||
except Exception:
|
||||
pass # Ignore write errors
|
||||
|
||||
def buffer_length(self) -> int:
|
||||
"""
|
||||
Get the current length of buffered input.
|
||||
|
||||
Returns:
|
||||
int: Number of characters in the input buffer
|
||||
"""
|
||||
return len(self._input_buffer)
|
||||
30
sia/stop_command.py
Normal file
30
sia/stop_command.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from .command import Command
|
||||
from .command_result import CommandResult
|
||||
from .working_memory import WorkingMemory
|
||||
|
||||
class StopCommand(Command):
|
||||
"""
|
||||
Command to stop the agent.
|
||||
Performs cleanup on all entries and clears working memory.
|
||||
"""
|
||||
|
||||
def execute(self, memory: WorkingMemory) -> CommandResult:
|
||||
"""
|
||||
Signal that the agent should stop.
|
||||
Cleans up all entries and clears the working memory.
|
||||
|
||||
Args:
|
||||
memory: WorkingMemory instance to clear
|
||||
|
||||
Returns:
|
||||
CommandResult: Stop result
|
||||
"""
|
||||
# Get a copy of entries to iterate over
|
||||
entries = memory.get_entries()
|
||||
|
||||
# Clean up each entry individually
|
||||
for entry in entries:
|
||||
entry.cleanup()
|
||||
memory.remove_entry(entry.id)
|
||||
|
||||
return CommandResult.stop()
|
||||
75
sia/util.py
75
sia/util.py
@@ -1,52 +1,6 @@
|
||||
from typing import Iterator
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from .inference_result import InferenceResult
|
||||
|
||||
def get_valid_root_elements(schema: str) -> set:
|
||||
"""
|
||||
Extract valid root element names from the XML schema.
|
||||
|
||||
Args:
|
||||
schema: XML schema string
|
||||
|
||||
Returns:
|
||||
set: Set of valid root element names
|
||||
"""
|
||||
try:
|
||||
schema = schema.strip()
|
||||
ET.register_namespace('xs', 'http://www.w3.org/2001/XMLSchema')
|
||||
root = ET.fromstring(schema)
|
||||
ns = {'xs': 'http://www.w3.org/2001/XMLSchema'}
|
||||
elements = root.findall(".//xs:element", ns)
|
||||
return {elem.get('name') for elem in elements if elem.get('name')}
|
||||
except ET.ParseError as e:
|
||||
print(f"Error parsing schema: {e}")
|
||||
return set()
|
||||
|
||||
def split_response(response: str, valid_elements: set) -> InferenceResult:
|
||||
"""
|
||||
Split the response into reasoning and actions based on valid XML elements.
|
||||
|
||||
Args:
|
||||
response: Raw response string from the model
|
||||
valid_elements: Set of valid root element names from the schema
|
||||
|
||||
Returns:
|
||||
InferenceResult: Tuple containing reasoning and actions
|
||||
"""
|
||||
elements_pattern = '|'.join(map(re.escape, valid_elements))
|
||||
pattern = f"<({elements_pattern})[^>]*>"
|
||||
matches = list(re.finditer(pattern, response))
|
||||
if not matches:
|
||||
return InferenceResult(response.strip(), "")
|
||||
last_match = matches[-1]
|
||||
split_point = last_match.start()
|
||||
reasoning = response[:split_point].strip()
|
||||
actions = response[split_point:].strip()
|
||||
return InferenceResult(reasoning, actions)
|
||||
|
||||
def stop_before_value(iterator: Iterator[str], stop_value: str) -> Iterator[str]:
|
||||
"""
|
||||
Creates an iterator that yields values from the input iterator
|
||||
@@ -66,4 +20,31 @@ def stop_before_value(iterator: Iterator[str], stop_value: str) -> Iterator[str]
|
||||
if split_point > 0:
|
||||
yield item[:split_point]
|
||||
break
|
||||
yield item
|
||||
yield item
|
||||
|
||||
def escape_text_for_xml(text: str) -> str:
|
||||
"""
|
||||
Convert any text value to a properly escaped XML string.
|
||||
|
||||
Args:
|
||||
text: Text to escape, can be string, number, or None
|
||||
|
||||
Returns:
|
||||
str: XML-escaped string representation
|
||||
"""
|
||||
if text is None:
|
||||
return ''
|
||||
|
||||
# Convert numbers to strings
|
||||
text_str = str(text)
|
||||
|
||||
# Standard XML escaping if string contains CDATA end sequence
|
||||
if ']]>' in text_str:
|
||||
return text_str.replace('&', '&') \
|
||||
.replace('<', '<') \
|
||||
.replace('>', '>') \
|
||||
.replace('"', '"') \
|
||||
.replace("'", ''')
|
||||
|
||||
# Otherwise wrap in CDATA
|
||||
return f'<![CDATA[{text_str}]]>'
|
||||
66
sia/web_io_buffer.py
Normal file
66
sia/web_io_buffer.py
Normal file
@@ -0,0 +1,66 @@
|
||||
class WebIOBuffer:
|
||||
"""
|
||||
IOBuffer implementation for web interface communication.
|
||||
|
||||
This class manages input/output through memory buffers instead of system IO,
|
||||
allowing for asynchronous communication through a web interface. It maintains
|
||||
separate buffers for stdin and stdout to simulate bidirectional communication.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize empty input and output buffers."""
|
||||
self._stdin_buffer = ""
|
||||
self._stdout_buffer = ""
|
||||
|
||||
def read(self) -> str:
|
||||
"""
|
||||
Read and clear all available input from the stdin buffer.
|
||||
|
||||
Returns:
|
||||
str: Content from the stdin buffer, or empty string if buffer is empty
|
||||
"""
|
||||
content = self._stdin_buffer
|
||||
self._stdin_buffer = ""
|
||||
return content
|
||||
|
||||
def write(self, content: str) -> None:
|
||||
"""
|
||||
Append content to the stdout buffer.
|
||||
|
||||
Args:
|
||||
content: String content to append to the buffer
|
||||
"""
|
||||
if content:
|
||||
self._stdout_buffer += content
|
||||
|
||||
def buffer_length(self) -> int:
|
||||
"""
|
||||
Get the current length of the stdin buffer.
|
||||
|
||||
Returns:
|
||||
int: Number of characters in the stdin buffer
|
||||
"""
|
||||
return len(self._stdin_buffer)
|
||||
|
||||
def append_stdin(self, content: str) -> None:
|
||||
"""
|
||||
Append content to the stdin buffer.
|
||||
|
||||
Args:
|
||||
content: String content to append to the stdin buffer
|
||||
"""
|
||||
if content:
|
||||
self._stdin_buffer += content
|
||||
|
||||
def get_stdout(self) -> str:
|
||||
"""
|
||||
Get the current content of the stdout buffer.
|
||||
|
||||
Returns:
|
||||
str: Current content of the stdout buffer
|
||||
"""
|
||||
return self._stdout_buffer
|
||||
|
||||
def clear_stdout(self) -> None:
|
||||
"""Clear the stdout buffer."""
|
||||
self._stdout_buffer = ""
|
||||
116
sia/working_memory.py
Normal file
116
sia/working_memory.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from typing import List, Optional
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from .entry import Entry
|
||||
|
||||
class WorkingMemory:
|
||||
"""
|
||||
Manages a collection of entries that represent the current state of the system.
|
||||
|
||||
The working memory stores different types of entries (scripts, reasoning, errors, etc.)
|
||||
and provides methods to add, remove, and update them. It also generates XML context
|
||||
representing the current state.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize an empty working memory."""
|
||||
self._entries: List[Entry] = []
|
||||
|
||||
def add_entry(self, entry: Entry) -> None:
|
||||
"""
|
||||
Add a new entry to working memory.
|
||||
|
||||
Args:
|
||||
entry: Entry object to add
|
||||
"""
|
||||
if not isinstance(entry, Entry):
|
||||
raise TypeError("Entry must be an instance of Entry class")
|
||||
self._entries.append(entry)
|
||||
|
||||
def remove_entry(self, id: str) -> None:
|
||||
"""
|
||||
Remove an entry from working memory by its ID.
|
||||
Ensures cleanup is performed before removal.
|
||||
|
||||
Args:
|
||||
id: Unique identifier of entry to remove
|
||||
"""
|
||||
entry = self.get_entry(id)
|
||||
if entry is not None:
|
||||
entry.cleanup()
|
||||
self._entries = [e for e in self._entries if e.id != id]
|
||||
|
||||
def clear(self) -> None:
|
||||
"""
|
||||
Remove all entries from working memory.
|
||||
Ensures cleanup is performed on all entries.
|
||||
"""
|
||||
for entry in self._entries:
|
||||
entry.cleanup()
|
||||
self._entries.clear()
|
||||
|
||||
def __del__(self):
|
||||
"""
|
||||
Clean up all entries when memory is deleted.
|
||||
"""
|
||||
if hasattr(self, '_entries'): # Check in case of partial initialization
|
||||
self.clear()
|
||||
|
||||
def get_entry(self, id: str) -> Optional[Entry]:
|
||||
"""
|
||||
Get an entry by its ID.
|
||||
|
||||
Args:
|
||||
id: Unique identifier of entry to retrieve
|
||||
|
||||
Returns:
|
||||
Entry if found, None otherwise
|
||||
"""
|
||||
for entry in self._entries:
|
||||
if entry.id == id:
|
||||
return entry
|
||||
return None
|
||||
|
||||
def get_entries(self) -> List[Entry]:
|
||||
"""
|
||||
Get all entries in working memory.
|
||||
|
||||
Returns:
|
||||
List[Entry]: List of all entries
|
||||
"""
|
||||
return self._entries.copy() # Return a copy to prevent external modification
|
||||
|
||||
def get_entries_count(self) -> int:
|
||||
"""
|
||||
Get the total number of entries.
|
||||
|
||||
Returns:
|
||||
int: Number of entries in working memory
|
||||
"""
|
||||
return len(self._entries)
|
||||
|
||||
def get_entries_by_type(self, entry_type: type) -> List[Entry]:
|
||||
"""
|
||||
Get all entries of a specific type.
|
||||
|
||||
Args:
|
||||
entry_type: Type of entries to retrieve
|
||||
|
||||
Returns:
|
||||
List[Entry]: List of entries matching the specified type
|
||||
"""
|
||||
return [e for e in self._entries if isinstance(e, entry_type)]
|
||||
|
||||
def update(self) -> None:
|
||||
"""Update all entries in working memory."""
|
||||
for entry in self._entries:
|
||||
entry.update()
|
||||
|
||||
def generate_context(self) -> List[ET.Element]:
|
||||
"""
|
||||
Generate XML Elements representing all entries.
|
||||
|
||||
Returns:
|
||||
List[ET.Element]: List of XML elements for each entry
|
||||
"""
|
||||
return [entry.generate_context() for entry in self._entries]
|
||||
54
sia/write_entry.py
Normal file
54
sia/write_entry.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from datetime import datetime
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from .entry import Entry
|
||||
from .util import escape_text_for_xml
|
||||
|
||||
class WriteEntry(Entry):
|
||||
"""
|
||||
Entry type for writing content to standard output.
|
||||
|
||||
Attributes:
|
||||
content: Text content to write to stdout
|
||||
io_buffer: Buffer to use for IO operations
|
||||
_written: Whether the content has been written
|
||||
"""
|
||||
|
||||
def __init__(self, content: str, io_buffer, id: str, timestamp: datetime):
|
||||
"""
|
||||
Initialize a new write entry.
|
||||
|
||||
Args:
|
||||
content: Text to write to stdout
|
||||
io_buffer: Buffer to use for IO operations
|
||||
id: Unique identifier for this entry
|
||||
timestamp: Creation timestamp for this entry
|
||||
"""
|
||||
super().__init__(id, timestamp)
|
||||
self.content = content
|
||||
self.io_buffer = io_buffer
|
||||
self._written = False
|
||||
|
||||
def update(self) -> None:
|
||||
"""
|
||||
Write the content to stdout if not already written.
|
||||
Uses the provided IO buffer for the actual write operation.
|
||||
"""
|
||||
if not self._written:
|
||||
self.io_buffer.write(self.content)
|
||||
self._written = True
|
||||
|
||||
def generate_context(self) -> ET.Element:
|
||||
"""
|
||||
Generate an XML Element representing this write entry.
|
||||
|
||||
Returns:
|
||||
ET.Element: XML element containing the entry's data
|
||||
"""
|
||||
# Create root element
|
||||
element = ET.Element("write_stdout", {"id": self.id})
|
||||
|
||||
# Add content as CDATA or escaped text
|
||||
element.text = escape_text_for_xml(self.content)
|
||||
|
||||
return element
|
||||
97
sia/xml_validator.py
Normal file
97
sia/xml_validator.py
Normal file
@@ -0,0 +1,97 @@
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Optional, Set
|
||||
|
||||
class XMLValidator:
|
||||
"""
|
||||
Validates XML content against a schema.
|
||||
|
||||
Attributes:
|
||||
_schema: The parsed XML schema to validate against
|
||||
_valid_root_elements: Set of valid root element names from schema
|
||||
"""
|
||||
|
||||
def __init__(self, schema: str):
|
||||
"""
|
||||
Initialize validator with XML schema.
|
||||
|
||||
Args:
|
||||
schema: XML schema string
|
||||
"""
|
||||
# Register namespace used in schema
|
||||
ET.register_namespace('xs', 'http://www.w3.org/2001/XMLSchema')
|
||||
|
||||
try:
|
||||
# Parse schema
|
||||
self._schema = ET.fromstring(schema.strip())
|
||||
|
||||
# Extract valid root elements
|
||||
ns = {'xs': 'http://www.w3.org/2001/XMLSchema'}
|
||||
elements = self._schema.findall(".//xs:element", ns)
|
||||
self._valid_root_elements = {elem.get('name') for elem in elements if elem.get('name')}
|
||||
|
||||
except ET.ParseError as e:
|
||||
raise ValueError(f"Invalid schema: {e}")
|
||||
|
||||
def validate(self, xml: str) -> Optional[str]:
|
||||
"""
|
||||
Validate XML content against the schema.
|
||||
|
||||
Args:
|
||||
xml: XML string to validate
|
||||
|
||||
Returns:
|
||||
str: Error message if validation fails, None if validation succeeds
|
||||
"""
|
||||
try:
|
||||
# Parse XML
|
||||
root = ET.fromstring(xml.strip())
|
||||
|
||||
# Check root element is valid
|
||||
if root.tag not in self._valid_root_elements:
|
||||
return f"Invalid root element: {root.tag}. Expected one of: {sorted(self._valid_root_elements)}"
|
||||
|
||||
# Get schema definition for this element
|
||||
ns = {'xs': 'http://www.w3.org/2001/XMLSchema'}
|
||||
element_schema = self._schema.find(f".//xs:element[@name='{root.tag}']", ns)
|
||||
if element_schema is None:
|
||||
return f"Schema definition not found for element: {root.tag}"
|
||||
|
||||
# Validate attributes if complex type defined
|
||||
complex_type = element_schema.find('xs:complexType', ns)
|
||||
if complex_type is not None:
|
||||
# Check required attributes
|
||||
for attr in complex_type.findall('.//xs:attribute[@use="required"]', ns):
|
||||
attr_name = attr.get('name')
|
||||
if attr_name not in root.attrib:
|
||||
return f"Missing required attribute '{attr_name}' on element '{root.tag}'"
|
||||
|
||||
# Check attribute types
|
||||
for attr_name, attr_value in root.attrib.items():
|
||||
attr_schema = complex_type.find(f'.//xs:attribute[@name="{attr_name}"]', ns)
|
||||
if attr_schema is None:
|
||||
return f"Unexpected attribute '{attr_name}' on element '{root.tag}'"
|
||||
|
||||
attr_type = attr_schema.get('type')
|
||||
if attr_type == 'xs:string':
|
||||
continue # All string values are valid
|
||||
elif attr_type == 'xs:integer':
|
||||
try:
|
||||
int(attr_value)
|
||||
except ValueError:
|
||||
return f"Invalid integer value '{attr_value}' for attribute '{attr_name}'"
|
||||
|
||||
return None # Validation successful
|
||||
|
||||
except ET.ParseError as e:
|
||||
return f"Invalid XML: {e}"
|
||||
except Exception as e:
|
||||
return f"Validation error: {e}"
|
||||
|
||||
def get_valid_root_elements(self) -> Set[str]:
|
||||
"""
|
||||
Get set of valid root element names from schema.
|
||||
|
||||
Returns:
|
||||
Set[str]: Set of valid root element names
|
||||
"""
|
||||
return self._valid_root_elements.copy()
|
||||
Reference in New Issue
Block a user