start implementation on new architecture

This commit is contained in:
2024-11-01 09:56:30 +01:00
parent ee089e5be7
commit 2c1e134c6e
43 changed files with 2978 additions and 619 deletions

View File

@@ -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()

View File

@@ -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
View 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
View 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
View 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)

View File

@@ -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
View 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()

View File

@@ -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
View 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

View File

@@ -1,5 +0,0 @@
from typing import NamedTuple
class InferenceResult(NamedTuple):
reasoning: str
actions: str

52
sia/parse_error_entry.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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()

View File

@@ -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('&', '&amp;') \
.replace('<', '&lt;') \
.replace('>', '&gt;') \
.replace('"', '&quot;') \
.replace("'", '&apos;')
# Otherwise wrap in CDATA
return f'<![CDATA[{text_str}]]>'

66
sia/web_io_buffer.py Normal file
View 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
View 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
View 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
View 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()