New web interface, move llm engine to separate process

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

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
**/_unsloth_temporary_saved_buffers/ **/_unsloth_temporary_saved_buffers/
.env .env
__pycache__/ __pycache__/
build/
cache/ cache/
collect.txt collect.txt
data/ data/

View File

@@ -49,7 +49,7 @@ RUN mkdir -p \
/root/desktop \ /root/desktop \
/root/venvs /root/venvs
# ITB tool setup # Tool ITB setup
FROM base AS itb-env FROM base AS itb-env
RUN python3 -m venv /root/venvs/itb --system-site-packages RUN python3 -m venv /root/venvs/itb --system-site-packages
COPY ./tools/itb/setup.py /root/sia/tools/itb/setup.py COPY ./tools/itb/setup.py /root/sia/tools/itb/setup.py
@@ -58,15 +58,6 @@ RUN --mount=type=cache,target=/root/.cache/pip \
/root/venvs/itb/bin/pip install -r *.egg-info/requires.txt /root/venvs/itb/bin/pip install -r *.egg-info/requires.txt
RUN rm -rf *.egg-info/ RUN rm -rf *.egg-info/
# Train tool setup
FROM base AS train-env
RUN python3 -m venv /root/venvs/train --system-site-packages
COPY ./tools/train/setup.py /root/sia/tools/train/setup.py
RUN /root/venvs/train/bin/python /root/sia/tools/train/setup.py egg_info
RUN --mount=type=cache,target=/root/.cache/pip \
/root/venvs/train/bin/pip install -r *.egg-info/requires.txt
RUN rm -rf *.egg-info/
# SIA core setup # SIA core setup
FROM base AS sia-env FROM base AS sia-env
RUN python3 -m venv /root/venvs/sia --system-site-packages RUN python3 -m venv /root/venvs/sia --system-site-packages
@@ -110,7 +101,6 @@ RUN npm run build
FROM base FROM base
COPY --from=itb-env /root/venvs/itb /root/venvs/itb COPY --from=itb-env /root/venvs/itb /root/venvs/itb
COPY --from=train-env /root/venvs/train /root/venvs/train
COPY --from=sia-env /root/venvs/sia /root/venvs/sia COPY --from=sia-env /root/venvs/sia /root/venvs/sia
COPY --from=notebook-env /root/venvs/notebook /root/venvs/notebook COPY --from=notebook-env /root/venvs/notebook /root/venvs/notebook
COPY --from=web-build /app/dist /root/static/ COPY --from=web-build /app/dist /root/static/

View File

@@ -127,21 +127,17 @@ lint-rust: ## Lint Rust code
# ================== # ==================
.PHONY: test .PHONY: test
test: test-python test-rust ## Run all tests test: test-sia test-xml_schema_validator
.PHONY: test-python .PHONY: test-sia
test-python: ## Run Python tests test-sia:
@echo "Running Python tests..." @echo "Running Python tests..."
python -m pytest -xvs /root/venvs/sia/bin/python -m unittest discover -s test -p "*_test.py"
.PHONY: test-rust .PHONY: test-xml_schema_validator
test-rust: ## Run Rust tests test-xml_schema_validator:
@echo "Running Rust tests..." @echo "Running Rust tests..."
for dir in $(RUST_DIRS); do \ cd lib/xml_schema_validator && cargo test
if [ -f "$$dir/Cargo.toml" ]; then \
(cd "$$dir" && cargo test); \
fi; \
done
# ================== # ==================
# Build targets # Build targets
@@ -183,4 +179,4 @@ help: ## Show this help message
.PHONY: collect .PHONY: collect
collect: ## Run collect.sh script collect: ## Run collect.sh script
@echo "Running collect.sh script..." @echo "Running collect.sh script..."
./scripts/collect.sh -s core -s lib -s webui -o collect.txt ./scripts/collect.sh -s core -s tests

View File

@@ -311,18 +311,51 @@ Or xml comments used for inline reasoning are not saved after parsing.
LLM engine subprocesses receive input as XML documents containing paths to required files and the context. LLM engine subprocesses receive input as XML documents containing paths to required files and the context.
**Example input to LLM engine subprocess:** ##### Example interaction with the LLM engine subprocess:
**Core -> LLM engine**
```xml ```xml
<input> <token_limit/>
<system_prompt_path>/root/sia/system_prompt.md</system_prompt_path> ```
<action_schema_path>/root/sia/action_schema.xsd</action_schema_path>
<context> **LLM engine -> Core**
<context time="2024-10-18T12:00:00Z" memory_used="9556302234" memory_total="17179869184">
<!-- Working memory entries --> ```
</context> 1024\u0004
</context> ```
<prefix><!-- Optional existing text to continue --></prefix>
</input> **Core -> LLM engine**
```xml
<token_count>
<schema>/root/sia/action_schema.xsd</schema>
<system><![CDATA[...]]></system>
<context><![CDATA[...]]></context>
</token_count>
```
**LLM engine -> Core**
```
405\u0004
```
**Core -> LLM engine**
```xml
<infer_xml>
<schema>/root/sia/action_schema.xsd</schema>
<system><![CDATA[...]]></system>
<context><![CDATA[...]]></context>
<prefix><![CDATA[...]]></prefix>
</infer_xml>
```
**LLM engine -> Core**
```xml
<reasoning>...</reasoning>
``` ```
Though the LLM can output any text, the goal is to output valid xml. Though the LLM can output any text, the goal is to output valid xml.
@@ -336,13 +369,6 @@ This character was chosen because:
- It's a single byte, making it efficient to process - It's a single byte, making it efficient to process
- It's standard across all platforms - It's standard across all platforms
**Example output from LLM engine subprocess:**
```
<reasoning>
I should check the current state of the system and see if there are any pending tasks.
</reasoning>\u0004
```
The communication protocol between the SIA agent and LLM engine subprocesses has been designed with simplicity as the primary goal. The communication protocol between the SIA agent and LLM engine subprocesses has been designed with simplicity as the primary goal.
Opting for a minimal approach that: Opting for a minimal approach that:
@@ -453,16 +479,19 @@ classDiagram
-monitor_loop() void -monitor_loop() void
} }
class LLMEngine { class LlmEngine {
+LLMEngine(executable_path str) +LlmEngine(executable_path str, action_schema_path str)
+infer(system_prompt str, main_context str, prefix str) Iterator~str~ +infer(system_prompt str, main_context ET, prefix str) Iterator~str~
+token_count(system_prompt str, main_context ET, prefix str) int
+token_limit() int
+restart()
} }
class BaseAgent { class BaseAgent {
<<abstract>> <<abstract>>
-working_memory: WorkingMemory -working_memory: WorkingMemory
-metrics: SystemMetrics -metrics: SystemMetrics
-llm: LLMEngine -llm: LlmEngine
-parser: ResponseParser -parser: ResponseParser
-validator: XMLValidator -validator: XMLValidator
-action_schema: str -action_schema: str
@@ -522,7 +551,7 @@ classDiagram
} }
SystemMetrics "1" --* "1" BaseAgent SystemMetrics "1" --* "1" BaseAgent
LLMEngine "1" --* "1" BaseAgent LlmEngine "1" --* "1" BaseAgent
XMLValidator "1" --* "1" BaseAgent XMLValidator "1" --* "1" BaseAgent
BaseAgent "1" *-- "1" IOBuffer BaseAgent "1" *-- "1" IOBuffer
BaseAgent "1" *-- "1" WorkingMemory BaseAgent "1" *-- "1" WorkingMemory
@@ -560,7 +589,7 @@ classDiagram
<<abstract>> <<abstract>>
-working_memory: WorkingMemory -working_memory: WorkingMemory
-metrics: SystemMetrics -metrics: SystemMetrics
-llm: LLMEngine -llm: LlmEngine
-parser: ResponseParser -parser: ResponseParser
-validator: XMLValidator -validator: XMLValidator
-action_schema: str -action_schema: str

View File

@@ -0,0 +1,13 @@
[build-system]
requires = ["setuptools>=42", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "llm_engine_utils"
version = "0.1.0"
requires-python = ">=3.8"
[project.optional-dependencies]
dataset = [
"torch>=4.0.0",
]

View File

@@ -0,0 +1,7 @@
try:
from . import dataset
except ImportError:
pass
from . import iterators
from . import protocol
from .llm_engine import LlmEngine

View File

@@ -0,0 +1,61 @@
from typing import Iterator
def stop_before_value(iterator: Iterator[str], stop_value: str) -> Iterator[str]:
"""
Creates an iterator that yields values from the input iterator
until it encounters the stop_value (exclusive).
Args:
iterator: The source iterator
stop_value: The value to stop before
Yields:
Values from the iterator until stop_value is encountered
If stop_value is part of an item, yields the part before stop_value
"""
for item in iterator:
if stop_value in item:
split_point = item.index(stop_value)
if split_point > 0:
yield item[:split_point]
break
yield item
def skip_prefix(iterator: Iterator[str], prefix: str) -> Iterator[str]:
"""
Creates an iterator that skips a prefix from the input iterator
and yields only the content after the prefix.
Args:
iterator: The source iterator
prefix: The prefix to skip
Yields:
Values from the iterator after the prefix has been fully skipped
"""
if not prefix:
# If no prefix to skip, yield everything
yield from iterator
return
prefix_remaining = prefix
for item in iterator:
if prefix_remaining:
# If the item starts with the remaining prefix
if prefix_remaining.startswith(item):
# Skip this item entirely
prefix_remaining = prefix_remaining[len(item):]
continue
elif item.startswith(prefix_remaining):
# Yield only the part after the prefix
yield item[len(prefix_remaining):]
prefix_remaining = ""
else:
# Item doesn't match prefix pattern, yield everything
# This is unexpected but we handle it gracefully
yield item
prefix_remaining = ""
else:
# No prefix remaining, yield all content
yield item

View File

@@ -0,0 +1,16 @@
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Iterator
class LlmEngine(ABC):
@abstractmethod
def infer_xml(self, schema: Path, system: str, context: str, prefix: str) -> Iterator[str]:
pass
@abstractmethod
def token_count(self, system: str, context: str) -> int:
pass
@abstractmethod
def token_limit(self) -> int:
pass

View File

@@ -0,0 +1,87 @@
"""
Protocol handler for SIA LLM engine subprocess communication.
This module provides tools to parse and handle the XML-based communication
protocol between the SIA core and LLM engine subprocesses as described
in the SIA README.
"""
from .llm_engine import LlmEngine
from io import StringIO
from pathlib import Path
from typing import Optional
import sys
import xml.etree.ElementTree as ET
# ASCII End of Transmission character used to signal the end of a response
EOT = '\u0004'
def read_command() -> Optional[ET.Element]:
"""
Read an XML command from stdin.
Returns:
ET.Element: The parsed XML element or None if EOF was reached.
"""
buffer = StringIO()
while True:
char = sys.stdin.read(1)
if not char: # EOF
return None
buffer.write(char)
content = buffer.getvalue()
# Try to parse once we have a potentially complete XML document
if content.endswith('>'):
try:
return ET.fromstring(content)
except ET.ParseError:
# Not a complete XML document yet, continue reading
pass
def process(engine: LlmEngine) -> None:
"""
Run the adapter loop, processing commands from stdin and writing responses to stdout.
"""
while True:
print("reading command", file=sys.stderr)
command = read_command()
if command is None:
print("EOF", file=sys.stderr)
# End of input stream, exit
return
if command.tag == 'token_limit':
print("token_limit", file=sys.stderr)
limit = engine.token_limit()
sys.stdout.write(str(limit))
sys.stdout.write(EOT)
sys.stdout.flush()
elif command.tag == 'token_count':
print("token_count", file=sys.stderr)
system = command.find('system').text
context = command.find('context').text
count = engine.token_count(system, context)
sys.stdout.write(str(count))
sys.stdout.write(EOT)
sys.stdout.flush()
elif command.tag == 'infer_xml':
print("infer_xml", file=sys.stderr)
try:
schema = Path(command.find('schema').text)
system = command.find('system').text
context = command.find('context').text
prefix = command.find('prefix').text if command.find('prefix') is not None else None
for chunk in engine.infer_xml(schema, system, context, prefix):
sys.stdout.write(chunk)
sys.stdout.flush()
sys.stdout.write(EOT)
sys.stdout.flush()
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.stdout.write(EOT)
sys.stdout.flush()

View File

@@ -5,6 +5,7 @@ description = "XML Schema validation library"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"maturin>=1.0,<2.0", "maturin>=1.0,<2.0",
"torch>=2.0.0",
] ]
optional-dependencies.dev = [ optional-dependencies.dev = [
"pytest>=8", "pytest>=8",

View File

@@ -1,5 +1,6 @@
from transformers import AutoTokenizer from transformers import AutoTokenizer
from xml_schema_validator._rs import XmlLogitsProcessorCore from xml_schema_validator._rs import XmlLogitsProcessorCore
import sys
class LlamaCppLogitsProcessor: class LlamaCppLogitsProcessor:
""" """
@@ -57,7 +58,7 @@ class LlamaCppLogitsProcessor:
) )
marker_start = template_text.find("XML_LOGITS_PROCESSOR_MARKER") + len("XML_LOGITS_PROCESSOR_MARKER") marker_start = template_text.find("XML_LOGITS_PROCESSOR_MARKER") + len("XML_LOGITS_PROCESSOR_MARKER")
start_marker = template_text[marker_start:] start_marker = template_text[marker_start:]
print("schema validator start marker:", start_marker) print("schema validator start marker:", start_marker, file=sys.stderr)
return start_marker return start_marker
def get_processor(self): def get_processor(self):

91
llm_config.toml Normal file
View File

@@ -0,0 +1,91 @@
gemma = """/root/venvs/gemma_infer/bin/gemma_infer --model /root/models/gemma3_1b.gguf --tokenizer /root/models/gemma3_1b_tokenizer"""
#mistral = """/root/venvs/mistral_infer/bin/mistral_infer"""
default = """#!/bin/bash
# Mock LLM engine for testing
# This script simulates an LLM engine subprocess that responds to the three commands:
# <token_limit/>, <token_count>...</token_count>, and <infer_xml>...</infer_xml>
# Function to read XML input until a complete closing tag is found
read_xml_input() {
local input=""
local line
local char_count=0
# Read until we get a complete XML command
while IFS= read -r line; do
input="${input}${line}"
char_count=$((char_count + ${#line}))
# Debug the actual content (first 30 chars)
if [[ "$input" == *"<token_limit/>"* ]]; then
printf "1024"
printf "\\004" # EOT character (hex 04)
return
fi
if [[ "$input" == *"<token_count>"*"</token_count>"* ]]; then
printf "405"
printf "\\004" # EOT character (hex 04)
return
fi
if [[ "$input" == *"<infer_xml>"*"</infer_xml>"* ]]; then
generate_response
return
fi
done
}
# Function to generate a response token by token
generate_response() {
printf "<"
sleep 0.3
printf "reason"
sleep 0.3
printf "ing"
sleep 0.3
printf ">"
sleep 0.3
printf "This"
sleep 0.3
printf " is"
sleep 0.3
printf " a"
sleep 0.3
printf " test"
sleep 0.3
printf " response."
sleep 0.3
printf "</"
sleep 0.3
printf "reason"
sleep 0.3
printf "ing"
sleep 0.3
printf ">"
sleep 0.3
printf "\\004"
}
# Main loop - keep reading input and responding
iteration=0
while true; do
iteration=$((iteration + 1))
read_xml_input
done"""

View File

@@ -5,7 +5,7 @@ set -euo pipefail
declare -A FILTER_SETS=( declare -A FILTER_SETS=(
["py"]="-f .*(\\.py|requirements.txt)$" ["py"]="-f .*(\\.py|requirements.txt)$"
["rust"]="-f .*\\.(rs|toml)$" ["rust"]="-f .*\\.(rs|toml)$"
["web"]="-f .*\\.(js|jsx|json|css|html)$" ["web"]="-f .*\\.(js|jsx|json|css|html|ts|tsx|md)$"
["doc"]="-f .*\\.md$" ["doc"]="-f .*\\.md$"
["deploy"]="-f .*(Dockerfile|akefile|\\.toml|\\.sh|\\.xsd|\\.yaml)$" ["deploy"]="-f .*(Dockerfile|akefile|\\.toml|\\.sh|\\.xsd|\\.yaml)$"

View File

@@ -1,28 +1,46 @@
#!/bin/bash #!/bin/bash
SIA_INSTALL_NO_NOTEBOOK=1
#SIA_INSTALL_NO_CORE=1
SIA_INSTALL_NO_ITB=1
SIA_INSTALL_NO_MISTRAL_INFER=1
#SIA_INSTALL_NO_GEMMA_INFER=1
SIA_INSTALL_NO_TRAIN=1
if [ -z "${SIA_INSTALL_NO_NOTEBOOK}" ]; then
echo "Installing venv for running notebooks..."
python3 -m venv /root/venvs/notebook
/root/venvs/notebook/bin/pip install jupyter ipykernel ipywidgets
/root/venvs/notebook/bin/ipython kernel install --name=notebook
fi
if [ -z "${SIA_INSTALL_NO_CORE}" ]; then
echo "Installing SIA core..."
python3 -m venv /root/venvs/sia
/root/venvs/sia/bin/pip install -e /root/sia
fi
if [ -z "${SIA_INSTALL_NO_ITB}" ]; then if [ -z "${SIA_INSTALL_NO_ITB}" ]; then
echo "Installing ITB tool..." echo "Installing ITB tool..."
python3 -m venv /root/venvs/itb python3 -m venv /root/venvs/itb
/root/venvs/itb/bin/pip install -e /root/sia/tools/itb /root/venvs/itb/bin/pip install -e /root/sia/tools/itb
fi fi
if [ -z "${SIA_INSTALL_NO_MISTRAL_INFER}" ]; then
echo "Installing venv for mistral inference..."
python3 -m venv /root/venvs/mistral_infer
/root/venvs/mistral_infer/bin/pip install -e /root/sia/tools/mistral_infer
fi
if [ -z "${SIA_INSTALL_NO_GEMMA_INFER}" ]; then
echo "Installing venv for gemma inference..."
python3 -m venv /root/venvs/gemma_infer
/root/venvs/gemma_infer/bin/pip install -e /root/sia/tools/gemma_infer
fi
if [ -z "${SIA_INSTALL_NO_TRAIN}" ]; then if [ -z "${SIA_INSTALL_NO_TRAIN}" ]; then
echo "Installing Train tool..." echo "Installing Train tool..."
python3 -m venv /root/venvs/train python3 -m venv /root/venvs/train
/root/venvs/train/bin/pip install -e /root/sia/tools/train /root/venvs/train/bin/pip install -e /root/sia/tools/train
/root/venvs/train/bin/ipython kernel install --name=train /root/venvs/train/bin/ipython kernel install --name=train
fi
if [ -z "${SIA_INSTALL_NO_CORE}" ]; then
echo "Installing SIA core..."
python3 -m venv /root/venvs/sia
/root/venvs/sia/bin/pip install -e /root/sia
/root/venvs/sia/bin/ipython kernel install --name=sia
fi
if [ -z "${SIA_INSTALL_NO_NOTEBOOK}"]; then
echo "Installing venv for running notebooks..."
python3 -m venv /root/venvs/notebook
/root/venvs/notebook/bin/pip install jupyter ipykernel ipywidgets
/root/venvs/notebook/bin/ipython kernel install --name=notebook
fi fi

View File

@@ -10,25 +10,12 @@ setup(
], ],
}, },
install_requires=[ install_requires=[
'accelerate>=0.26.0',
'aiohttp>=3.8.0', 'aiohttp>=3.8.0',
'bitsandbytes>=0.45',
'dotenv-python>=0.0.1',
'huggingface_hub>=0.16.0',
'ipykernel>=6.0.0', 'ipykernel>=6.0.0',
'ipywidgets>=8.0.0', 'ipywidgets>=8.0.0',
'lxml>=4.9.0', 'lxml>=4.9.0',
'mistral-common>=1.0.0',
'mistralai>=0.0.7',
'openai>=1.0.0',
'psutil>=5.9.0', 'psutil>=5.9.0',
'python-dotenv>=1.0.0', 'python-dotenv>=1.0.0',
'tiktoken>=0.4.0',
'torch>=2.0.0',
'transformers>=4.30.0',
'trl>=0.7.8',
'unsloth>=2025.3',
'vllm==0.8.2',
'xml_schema_validator @ file:///root/sia/lib/xml_schema_validator', 'xml_schema_validator @ file:///root/sia/lib/xml_schema_validator',
], ],
python_requires='>=3.10', python_requires='>=3.10',

View File

@@ -2,20 +2,16 @@ from aiohttp import web
import asyncio import asyncio
from .auto_approver import AutoApprover from .auto_approver import AutoApprover
from .chat_io_buffer import ChatIOBuffer
from .config import Config from .config import Config
from .iteration_logger import IterationLogger from .iteration_logger import IterationLogger
from .llm_engine.hf_llm_engine import HfLlmEngine from .llm_engine import LlmEngine
from .llm_engine.local_llm_engine import LocalLlmEngine
from .llm_engine.mistral_llm_engine import MistralLlmEngine
from .llm_engine.openai_llm_engine import OpenAILlmEngine
from .llm_engine.qwq_llm_engine import QwQLlmEngine
from .response_parser import ResponseParser from .response_parser import ResponseParser
from .system_metrics import SystemMetrics from .system_metrics import SystemMetrics
from .web.api import Api from .web.api import Api
from .web.static import Static from .web.static import Static
from .web.websockets import Websockets from .web.websockets import Websockets
from .web_agent import WebAgent from .web_agent import WebAgent
from .web_io_buffer import WebIOBuffer
from .working_memory import WorkingMemory from .working_memory import WorkingMemory
class Main: class Main:
@@ -30,49 +26,17 @@ class Main:
# Initialize LLM engines based on config # Initialize LLM engines based on config
self._llms = {} self._llms = {}
if config.local_enabled: # Use the config.llms property which returns only enabled LLMs
self._llms['local'] = LocalLlmEngine( for llm_name, executable_path in config.llms.items():
config.local_model, self._llms[llm_name] = LlmEngine(
config.local_temperature, executable_path=executable_path,
config.local_token_limit, action_schema_path=str(config.action_schema)
self._action_schema,
config.local_api_key,
)
if config.openai_enabled:
self._llms['openai'] = OpenAILlmEngine(
config.openai_model,
config.openai_temperature,
config.openai_token_limit,
config.openai_api_key,
)
if config.hf_enabled:
self._llms['hf'] = HfLlmEngine(
config.hf_model,
config.hf_temperature,
config.hf_api_key,
)
if config.mistral_enabled:
self._llms['mistral'] = MistralLlmEngine(
config.mistral_model,
config.mistral_temperature,
config.mistral_token_limit,
config.mistral_api_key,
)
if config.qwq_enabled:
self._llms['qwq'] = QwQLlmEngine(
config.qwq_model,
config.qwq_temperature,
self._action_schema,
) )
if not self._llms: if not self._llms:
raise ValueError("No LLM engines enabled in configuration") raise ValueError("No LLM engines enabled in configuration")
self._io_buffer = WebIOBuffer() self._io_buffer = ChatIOBuffer()
self._working_memory = WorkingMemory() self._working_memory = WorkingMemory()
self._agent = WebAgent( self._agent = WebAgent(
system_prompt=self._system_prompt, system_prompt=self._system_prompt,

View File

@@ -1,13 +1,14 @@
from dataclasses import dataclass
from threading import Thread, Event from threading import Thread, Event
from typing import Callable, TypeAlias, TypedDict from typing import Callable, TypeAlias
from .web_agent import WebAgent, LlmState from .web_agent import WebAgent, AgentState
class AutoApproverConfig(TypedDict): @dataclass
class AutoApproverConfig:
context_enabled: bool context_enabled: bool
response_enabled: bool response_enabled: bool
context_timeout: float context_timeout: float
response_timeout: float response_timeout: float
llm_name: str
ConfigChangeHandler: TypeAlias = Callable[[AutoApproverConfig], None] ConfigChangeHandler: TypeAlias = Callable[[AutoApproverConfig], None]
@@ -21,48 +22,47 @@ class AutoApprover:
Initialize auto approver with a WebAgent instance. Initialize auto approver with a WebAgent instance.
""" """
self.agent = agent self.agent = agent
self._llm_name = next(iter(agent.llms.keys())) self._config = AutoApproverConfig(
self._context_timeout = 5.0 context_enabled=False,
self._response_timeout = 10.0 response_enabled=False,
self._context_enabled = False context_timeout=1.0,
self._response_enabled = False response_timeout=1.0,
)
self._stop_event = Event() self._stop_event = Event()
self._context_thread: Thread | None = None self._context_thread: Thread | None = None
self._response_thread: Thread | None = None self._response_thread: Thread | None = None
self._config_change_handlers: list[ConfigChangeHandler] = [] self._config_change_handlers: list[ConfigChangeHandler] = []
self.agent.add_llm_change_handler(self._handle_llm_state_change) self._previous_state = agent.state
self.agent.add_context_change_handler(self._handle_context_change) self.agent.add_state_change_handler(self._handle_state_change)
@property @property
def config(self) -> AutoApproverConfig: def config(self) -> AutoApproverConfig:
return AutoApproverConfig( return self._config
context_enabled=self._context_enabled,
response_enabled=self._response_enabled,
context_timeout=self._context_timeout,
response_timeout=self._response_timeout,
llm_name=self._llm_name
)
def set_config(self, config: AutoApproverConfig) -> None: @config.setter
if config['llm_name'] not in self.agent.llms: def config(self, config: AutoApproverConfig) -> None:
raise ValueError(f"Unknown LLM: {config['llm_name']}") if config == self._config:
return
notify_config_change = self._notify_config_change if config.context_timeout != self._config.context_timeout:
self._notify_config_change = lambda: None if config.context_enabled and self._config.context_enabled:
raise ValueError("Cannot change context timeout while enabled")
try: if config.response_timeout != self._config.response_timeout:
self.context_enabled = False if config.response_enabled and self._config.response_enabled:
self.response_enabled = False raise ValueError("Cannot change response timeout while enabled")
self._stop_context_thread()
self.context_timeout = config['context_timeout'] self._stop_response_thread()
self.response_timeout = config['response_timeout'] # If both context and response are enabled, start response approval only.
self.llm_name = config['llm_name'] # Better to run inference with a full prefix than approve an emmpty response.
if config.response_enabled and not self._config.response_enabled:
self.context_enabled = config['context_enabled'] # response approval was just enabled
self.response_enabled = config['response_enabled'] self._config = config
finally: self._start_response_thread()
self._notify_config_change = notify_config_change elif config.context_enabled and not self._config.context_enabled:
# context approval was just enabled
self._config = config
self._start_context_thread()
self._config = config
self._notify_config_change() self._notify_config_change()
def add_config_change_handler(self, handler: ConfigChangeHandler) -> None: def add_config_change_handler(self, handler: ConfigChangeHandler) -> None:
@@ -73,81 +73,17 @@ class AutoApprover:
for handler in self._config_change_handlers: for handler in self._config_change_handlers:
handler(current_config) handler(current_config)
@property def _handle_state_change(self, state: AgentState) -> None:
def context_timeout(self) -> float: if state == AgentState.IDLE:
return self._context_timeout if self._previous_state == AgentState.INFERENCE:
# finished inference
@context_timeout.setter if self._config.response_enabled:
def context_timeout(self, timeout: float) -> None: self._start_response_thread()
if self._context_enabled: elif self._previous_state == AgentState.PROCESSING_RESPONSE:
raise ValueError("Cannot change timeout while auto-approval is enabled") # finished processing response
self._context_timeout = timeout if self._config.context_enabled:
self._notify_config_change() self._start_context_thread()
self._previous_state = state
@property
def response_timeout(self) -> float:
return self._response_timeout
@response_timeout.setter
def response_timeout(self, timeout: float) -> None:
if self._response_enabled:
raise ValueError("Cannot change timeout while auto-approval is enabled")
self._response_timeout = timeout
self._notify_config_change()
@property
def context_enabled(self) -> bool:
return self._context_enabled
@context_enabled.setter
def context_enabled(self, enabled: bool) -> None:
if enabled == self._context_enabled:
return
self._context_enabled = enabled
self._stop_context_thread()
if enabled and self.agent.llms[self._llm_name] == LlmState.IDLE:
self._start_context_thread()
self._notify_config_change()
@property
def response_enabled(self) -> bool:
return self._response_enabled
@response_enabled.setter
def response_enabled(self, enabled: bool) -> None:
if enabled == self._response_enabled:
return
self._response_enabled = enabled
self._stop_response_thread()
if enabled and self.agent.llms[self._llm_name] == LlmState.IDLE:
self._start_response_thread()
self._notify_config_change()
@property
def llm_name(self) -> str:
return self._llm_name
@llm_name.setter
def llm_name(self, name: str) -> None:
if name not in self.agent.llms:
raise ValueError(f"Unknown LLM: {name}")
self._llm_name = name
self._notify_config_change()
def _handle_llm_state_change(self, llm_name: str, state: LlmState) -> None:
if llm_name != self._llm_name:
return
if state == LlmState.IDLE and self._response_enabled:
self._start_response_thread()
else:
self._stop_response_thread()
def _handle_context_change(self, context: str, generated: bool) -> None:
if generated and self._context_enabled:
self._start_context_thread()
else:
self._stop_context_thread()
def _stop_context_thread(self) -> None: def _stop_context_thread(self) -> None:
if self._context_thread: if self._context_thread:
@@ -170,13 +106,13 @@ class AutoApprover:
self._response_thread.start() self._response_thread.start()
def _context_approval_thread(self) -> None: def _context_approval_thread(self) -> None:
if self._stop_event.wait(self._context_timeout): if self._stop_event.wait(self._config.context_timeout):
return return
if self._context_enabled: if self._config.context_enabled:
self.agent.run_inference(self._llm_name) self.agent.run_inference()
def _response_approval_thread(self) -> None: def _response_approval_thread(self) -> None:
if self._stop_event.wait(self._response_timeout): if self._stop_event.wait(self._config.response_timeout):
return return
if self._response_enabled: if self._config.response_enabled:
self.agent.approve_response() self.agent.approve_response()

View File

@@ -4,7 +4,6 @@ import xml.etree.ElementTree as ET
from .llm_engine import LlmEngine from .llm_engine import LlmEngine
from .response_parser import ResponseParser from .response_parser import ResponseParser
from .system_metrics import SystemMetrics from .system_metrics import SystemMetrics
from .util import pretty_print_element
from .working_memory import WorkingMemory from .working_memory import WorkingMemory
class BaseAgent(ABC): class BaseAgent(ABC):
@@ -36,8 +35,13 @@ class BaseAgent(ABC):
def system_prompt(self) -> str: def system_prompt(self) -> str:
"""Get the system prompt.""" """Get the system prompt."""
return f"{self._system_prompt}\n{self._action_schema}" return f"{self._system_prompt}\n{self._action_schema}"
@property
def working_memory(self) -> WorkingMemory:
"""Get the working memory."""
return self._working_memory
def _compile_context(self, llmEngine: LlmEngine) -> str: def _compile_context(self, llmEngine: LlmEngine) -> ET:
""" """
Compile the current context for LLM inference. Compile the current context for LLM inference.
Includes system metrics and working memory entries. Includes system metrics and working memory entries.
@@ -60,15 +64,13 @@ class BaseAgent(ABC):
for entry in memory_context: for entry in memory_context:
context.append(entry) context.append(entry)
context_str = pretty_print_element(context)
# Calculate token usage percentage # Calculate token usage percentage
token_count = llmEngine.token_count(self.system_prompt, context_str) token_count = llmEngine.token_count(self.system_prompt, context)
token_limit = llmEngine.token_limit() token_limit = llmEngine.token_limit()
context_usage = (float(token_count) / float(token_limit)) * 100.0 context_usage = (float(token_count) / float(token_limit)) * 100.0
# Update context usage metric # Update context usage metric
context.set("context", f"{str(round(context_usage, 2))}%") context.set("context", f"{str(round(context_usage, 2))}%")
return pretty_print_element(context) return context

102
sia/chat_io_buffer.py Normal file
View File

@@ -0,0 +1,102 @@
from dataclasses import dataclass
from typing import List, Callable
from enum import Enum
import datetime
from .io_buffer import IOBuffer
from .util import format_timestamp
class MessageType(Enum):
USER = "user"
ASSISTANT = "assistant"
@dataclass
class ChatMessage:
id: str # Formatted timestamp
content: str
message_type: MessageType
class ChatIOBuffer(IOBuffer):
def __init__(self):
self._messages: List[ChatMessage] = []
self._last_read_timestamp: str = format_timestamp(datetime.datetime.now())
self._change_handlers: List[Callable] = []
@property
def messages(self) -> List[ChatMessage]:
return self._messages.copy()
@property
def last_read_timestamp(self) -> str:
return self._last_read_timestamp
def read(self) -> str:
unread_messages = self._unread_messages()
self._last_read_timestamp = self._format_now()
self._notify_handlers([], [])
return unread_messages
def write(self, content: str) -> None:
message = ChatMessage(
id=self._format_now(),
content=content,
message_type=MessageType.ASSISTANT
)
self._messages.append(message)
self._notify_handlers(
new_messages=[message],
deleted_message_ids=[]
)
def buffer_length(self) -> int:
return len(self._unread_messages())
def add_user_message(self, content: str):
message = ChatMessage(
id=self._format_now(),
content=content,
message_type=MessageType.USER
)
self._messages.append(message)
self._notify_handlers(
new_messages=[message],
deleted_message_ids=[]
)
def delete_message(self, message_id: str):
for i, msg in enumerate(self._messages):
if msg.id == message_id:
self._messages.pop(i)
self._notify_handlers(
new_messages=[],
deleted_message_ids=[message_id]
)
break
def clear(self) -> None:
deleted_ids = [msg.id for msg in self._messages]
self._messages.clear()
self._last_read_timestamp = self._format_now()
self._notify_handlers(
new_messages=[],
deleted_message_ids=deleted_ids
)
def add_change_handler(self, handler: Callable[[
List[ChatMessage], # new messages
List[str], # deleted message ids
str # last read timestamp
], None]) -> None:
self._change_handlers.append(handler)
def _notify_handlers(self, new_messages: List[ChatMessage],
deleted_message_ids: List[str]) -> None:
for handler in self._change_handlers:
handler(new_messages, deleted_message_ids, self._last_read_timestamp)
def _unread_messages(self) -> List[ChatMessage]:
return "\n".join(msg.content for msg in self._messages
if msg.message_type == MessageType.USER and msg.id > self._last_read_timestamp)
def _format_now(self) -> str:
return format_timestamp(datetime.datetime.now())

View File

@@ -1,9 +1,10 @@
from dataclasses import dataclass from dataclasses import dataclass
from dotenv import load_dotenv from dotenv import load_dotenv
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Dict
import argparse import argparse
import os import os
import tomli as tomllib
@dataclass @dataclass
class Config: class Config:
@@ -59,170 +60,27 @@ class Config:
parser.add_argument( parser.add_argument(
'--static-files', '--static-files',
type=Path, type=Path,
default=self._parse_optional_path('SIA_STATIC_FILES', '/root/static/'), default=os.getenv('SIA_STATIC_FILES', '/root/static/'),
help='Path to static web files (default: /root/static/, env: SIA_STATIC_FILES)' help='Path to static web files (default: /root/static/, env: SIA_STATIC_FILES)'
) )
# Local LLM configuration # LLM configuration
parser.add_argument( parser.add_argument(
'--local-enable', '--llm-config',
action='store_true', type=Path,
default=self._parse_bool_env('SIA_LOCAL_ENABLED', False), default=os.getenv('SIA_LLM_CONFIG', '/root/sia/llm_config.toml'),
help='Enable local LLM engine (env: SIA_LOCAL_ENABLED)' help='Path to TOML configuration file for LLMs (default: /root/sia/llm_config.toml, env: SIA_LLM_CONFIG)'
)
parser.add_argument(
'--local-model',
type=str,
default=os.getenv('SIA_LOCAL_MODEL', '/root/models/current'),
help='Path to local model directory (default: /root/models/current, env: SIA_LOCAL_MODEL)'
)
parser.add_argument(
'--local-temperature',
type=float,
default=float(os.getenv('SIA_LOCAL_TEMPERATURE', '0.1')),
help='Local LLM temperature (default: 0.1, env: SIA_LOCAL_TEMPERATURE)'
)
parser.add_argument(
'--local-token-limit',
type=int,
default=int(os.getenv('SIA_LOCAL_TOKEN_LIMIT', '2048')),
help='Local LLM token limit (env: SIA_LOCAL_TOKEN_LIMIT)'
)
parser.add_argument(
'--local-api-key',
type=str,
default=os.getenv('SIA_LOCAL_API_KEY'),
help='API key for local models (env: SIA_LOCAL_API_KEY)'
)
# OpenAI configuration
parser.add_argument(
'--openai-enable',
action='store_true',
default=self._parse_bool_env('SIA_OPENAI_ENABLED', False),
help='Enable OpenAI LLM engine (env: SIA_OPENAI_ENABLED)'
)
parser.add_argument(
'--openai-model',
type=str,
default=os.getenv('SIA_OPENAI_MODEL', 'gpt-4o'),
help='OpenAI model name (default: gpt-4o, env: SIA_OPENAI_MODEL)'
)
parser.add_argument(
'--openai-temperature',
type=float,
default=float(os.getenv('SIA_OPENAI_TEMPERATURE', '0.1')),
help='OpenAI temperature (default: 0.1, env: SIA_OPENAI_TEMPERATURE)'
)
parser.add_argument(
'--openai-token-limit',
type=int,
default=int(os.getenv('SIA_OPENAI_TOKEN_LIMIT', '4096')),
help='OpenAI token limit (env: SIA_OPENAI_TOKEN_LIMIT)'
)
parser.add_argument(
'--openai-api-key',
type=str,
default=os.getenv('SIA_OPENAI_API_KEY'),
help='OpenAI API key (env: SIA_OPENAI_API_KEY)'
)
# Hugging Face configuration
parser.add_argument(
'--hf-enable',
action='store_true',
default=self._parse_bool_env('SIA_HF_ENABLED', False),
help='Enable Hugging Face LLM engine (env: SIA_HF_ENABLED)'
)
parser.add_argument(
'--hf-model',
type=str,
default=os.getenv('SIA_HF_MODEL'),
help='Hugging Face model name (env: SIA_HF_MODEL)'
)
parser.add_argument(
'--hf-temperature',
type=float,
default=float(os.getenv('SIA_HF_TEMPERATURE', '0.1')),
help='Hugging Face temperature (default: 0.1, env: SIA_HF_TEMPERATURE)'
)
parser.add_argument(
'--hf-api-key',
type=str,
default=os.getenv('SIA_HF_API_KEY'),
help='Hugging Face API key (env: SIA_HF_API_KEY)'
)
# Mistral configuration
parser.add_argument(
'--mistral-enable',
action='store_true',
default=self._parse_bool_env('SIA_MISTRAL_ENABLED', False),
help='Enable Mistral LLM engine (env: SIA_MISTRAL_ENABLED)'
)
parser.add_argument(
'--mistral-model',
type=str,
default=os.getenv('SIA_MISTRAL_MODEL'),
help='Mistral model name (env: SIA_MISTRAL_MODEL)'
)
parser.add_argument(
'--mistral-temperature',
type=float,
default=float(os.getenv('SIA_MISTRAL_TEMPERATURE', '0.1')),
help='Mistral temperature (default: 0.1, env: SIA_MISTRAL_TEMPERATURE)'
)
parser.add_argument(
'--mistral-token-limit',
type=int,
default=int(os.getenv('SIA_MISTRAL_TOKEN_LIMIT', '4096')),
help='Mistral token limit (env: SIA_MISTRAL_TOKEN_LIMIT)'
)
parser.add_argument(
'--mistral-api-key',
type=str,
default=os.getenv('SIA_MISTRAL_API_KEY'),
help='Mistral API key (env: SIA_MISTRAL_API_KEY)'
)
# QwQ configuration
parser.add_argument(
'--qwq-enable',
action='store_true',
default=self._parse_bool_env('SIA_QWQ_ENABLED', False),
help='Enable QwQ LLM engine (env: SIA_QWQ_ENABLED)'
)
parser.add_argument(
'--qwq-model',
type=str,
default=os.getenv('SIA_QWQ_MODEL', '/root/models/current'),
help='Path to QwQ model or HF model ID (default: /root/models/current, env: SIA_QWQ_MODEL)'
)
parser.add_argument(
'--qwq-temperature',
type=float,
default=float(os.getenv('SIA_QWQ_TEMPERATURE', '0.6')),
help='QwQ temperature (default: 0.1, env: SIA_QWQ_TEMPERATURE)'
)
parser.add_argument(
'--qwq-token-limit',
type=int,
default=int(os.getenv('SIA_QWQ_TOKEN_LIMIT', '4096')),
help='QwQ token limit (0 for model default, env: SIA_QWQ_TOKEN_LIMIT)'
) )
self.args = parser.parse_args() self.args = parser.parse_args()
with open(self.llm_config, "rb") as f:
self._llm_configs = tomllib.load(f)
def _parse_bool_env(self, env_var: str, default: bool) -> bool: def _parse_bool_env(self, env_var: str, default: bool) -> bool:
val = os.getenv(env_var) val = os.getenv(env_var)
if val is None: if val is None:
return default return default
return val.lower() in ('true', '1', 'yes', 'on') return val.lower() in ('true', '1', 'yes', 'on')
def _parse_optional_path(self, env_var: str, default: Optional[Path]) -> Optional[Path]:
val = os.getenv(env_var)
if val is None:
return default
return Path(val)
# Core properties # Core properties
@property @property
@@ -258,100 +116,12 @@ class Config:
def static_files(self) -> Path: def static_files(self) -> Path:
return self.args.static_files return self.args.static_files
# Local LLM properties # LLM properties
@property @property
def local_enabled(self) -> bool: def llm_config(self) -> Path:
return self.args.local_enable return self.args.llm_config
@property
def local_model(self) -> str:
return self.args.local_model
@property
def local_temperature(self) -> float:
return self.args.local_temperature
@property
def local_token_limit(self) -> int:
return self.args.local_token_limit
@property @property
def local_api_key(self) -> Optional[str]: def llms(self) -> Dict[str, str]:
return self.args.local_api_key """Get only the enabled LLM configurations."""
return self._llm_configs
# OpenAI properties
@property
def openai_enabled(self) -> bool:
return self.args.openai_enable
@property
def openai_model(self) -> str:
return self.args.openai_model
@property
def openai_temperature(self) -> float:
return self.args.openai_temperature
@property
def openai_token_limit(self) -> int:
return self.args.openai_token_limit
@property
def openai_api_key(self) -> Optional[str]:
return self.args.openai_api_key
# Hugging Face properties
@property
def hf_enabled(self) -> bool:
return self.args.hf_enable
@property
def hf_model(self) -> str:
return self.args.hf_model
@property
def hf_temperature(self) -> float:
return self.args.hf_temperature
@property
def hf_api_key(self) -> Optional[str]:
return self.args.hf_api_key
# Mistral properties
@property
def mistral_enabled(self) -> bool:
return self.args.mistral_enable
@property
def mistral_model(self) -> str:
return self.args.mistral_model
@property
def mistral_temperature(self) -> float:
return self.args.mistral_temperature
@property
def mistral_token_limit(self) -> int:
return self.args.mistral_token_limit
@property
def mistral_api_key(self) -> Optional[str]:
return self.args.mistral_api_key
# QwQ properties
@property
def qwq_enabled(self) -> bool:
return self.args.qwq_enable
@property
def qwq_model(self) -> str:
return self.args.qwq_model
@property
def qwq_temperature(self) -> float:
return self.args.qwq_temperature
@property
def qwq_token_limit(self) -> Optional[int]:
# Return None if 0 to use model default
return self.args.qwq_token_limit if self.args.qwq_token_limit > 0 else None

View File

@@ -23,7 +23,7 @@ class IterationLogger:
def log_iteration( def log_iteration(
self, self,
timestamp: datetime, timestamp: datetime,
context: str, context: ET,
response: str, response: str,
): ):
""" """
@@ -41,8 +41,7 @@ class IterationLogger:
root.set("system_prompt_hash", self._system_prompt_hash) root.set("system_prompt_hash", self._system_prompt_hash)
root.set("action_schema_hash", self._action_schema_hash) root.set("action_schema_hash", self._action_schema_hash)
context_elem = ET.SubElement(root, "context") root.append(context)
context_elem.text = context
response_elem = ET.SubElement(root, "response") response_elem = ET.SubElement(root, "response")
response_elem.text = response response_elem.text = response

212
sia/llm_engine.py Normal file
View File

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

View File

@@ -1,15 +0,0 @@
from typing import Callable, Iterator
from abc import ABC, abstractmethod
class LlmEngine(ABC):
@abstractmethod
def infer(self, system_prompt: str, main_context: str, continuation_text: str, should_stop: Callable[[], bool] = lambda: False) -> Iterator[str]:
pass
@abstractmethod
def token_count(self, system_prompt: str, main_context: str) -> int:
pass
@abstractmethod
def token_limit(self) -> int:
pass

View File

@@ -1,67 +1,5 @@
import datetime import datetime
import xml.dom.minidom
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from typing import Iterator, Optional
def stop_before_value(iterator: Iterator[str], stop_value: str) -> Iterator[str]:
"""
Creates an iterator that yields values from the input iterator
until it encounters the stop_value (exclusive).
Args:
iterator: The source iterator
stop_value: The value to stop before
Yields:
Values from the iterator until stop_value is encountered
If stop_value is part of an item, yields the part before stop_value
"""
for item in iterator:
if stop_value in item:
split_point = item.index(stop_value)
if split_point > 0:
yield item[:split_point]
break
yield item
def skip_prefix(iterator: Iterator[str], prefix: str) -> Iterator[str]:
"""
Creates an iterator that skips a prefix from the input iterator
and yields only the content after the prefix.
Args:
iterator: The source iterator
prefix: The prefix to skip
Yields:
Values from the iterator after the prefix has been fully skipped
"""
if not prefix:
# If no prefix to skip, yield everything
yield from iterator
return
prefix_remaining = prefix
for item in iterator:
if prefix_remaining:
# If the item starts with the remaining prefix
if prefix_remaining.startswith(item):
# Skip this item entirely
prefix_remaining = prefix_remaining[len(item):]
continue
elif item.startswith(prefix_remaining):
# Yield only the part after the prefix
yield item[len(prefix_remaining):]
prefix_remaining = ""
else:
# Item doesn't match prefix pattern, yield everything
# This is unexpected but we handle it gracefully
yield item
prefix_remaining = ""
else:
# No prefix remaining, yield all content
yield item
def pretty_print_element(elem: ET.Element, level: int = 0, max_line: int = 80) -> str: def pretty_print_element(elem: ET.Element, level: int = 0, max_line: int = 80) -> str:
"""Convert ElementTree element to pretty-printed string with custom formatting.""" """Convert ElementTree element to pretty-printed string with custom formatting."""

View File

@@ -1,14 +1,14 @@
from pathlib import Path
from aiohttp import web from aiohttp import web
import json from pathlib import Path
import asyncio import asyncio
import json
from ..auto_approver import AutoApprover from ..auto_approver import AutoApprover, AutoApproverConfig
from ..chat_io_buffer import ChatIOBuffer
from ..entry.entry_factory import EntryFactory from ..entry.entry_factory import EntryFactory
from ..iteration_parser import IterationParser from ..iteration_parser import IterationParser
from ..web_agent import WebAgent from ..web_agent import WebAgent
from ..web_io_buffer import WebIOBuffer
from ..working_memory import WorkingMemory from ..working_memory import WorkingMemory
class Api: class Api:
@@ -17,7 +17,7 @@ class Api:
work_dir: Path, work_dir: Path,
app: web.Application, app: web.Application,
agent: WebAgent, agent: WebAgent,
io_buffer: WebIOBuffer, io_buffer: ChatIOBuffer,
working_memory: WorkingMemory, working_memory: WorkingMemory,
auto_approver: AutoApprover auto_approver: AutoApprover
): ):
@@ -32,23 +32,24 @@ class Api:
def _init_routes(self): def _init_routes(self):
"""Initialize REST API and WebSocket routes.""" """Initialize REST API and WebSocket routes."""
self._app.router.add_post("/api/inference/{llm}", self._run_inference) self._app.router.add_get("/api/llms", self._get_llms)
self._app.router.add_post("/api/inference/{llm}/stop", self._stop_inference) self._app.router.add_get("/api/llms/active", self._get_active_llm)
self._app.router.add_post("/api/llms/active", self._set_active_llm)
self._app.router.add_post("/api/inference", self._run_inference)
self._app.router.add_post("/api/inference/stop", self._stop_inference)
self._app.router.add_get("/api/response", self._get_response)
self._app.router.add_post("/api/response", self._set_response) self._app.router.add_post("/api/response", self._set_response)
self._app.router.add_post("/api/response/approve", self._approve_response) self._app.router.add_post("/api/response/approve", self._approve_response)
self._app.router.add_get("/api/response", self._get_response)
self._app.router.add_post("/api/context", self._modify_context) self._app.router.add_post("/api/chat/message", self._add_chat_message)
self._app.router.add_post("/api/input", self._send_input) self._app.router.add_delete("/api/chat/message/{id}", self._delete_chat_message)
self._app.router.add_post("/api/clear", self._clear_output) self._app.router.add_delete("/api/chat", self._clear_chat)
self._app.router.add_get("/api/output/{llm}", self._get_output)
self._app.router.add_get("/api/llms", self._get_llms) self._app.router.add_get("/api/auto_approver", self._get_auto_approver_config)
self._app.router.add_get("/api/auto_approver/config", self._get_auto_approver_config) self._app.router.add_post("/api/auto_approver", self._set_auto_approver_config)
self._app.router.add_post("/api/auto_approver/config", self._set_auto_approver_config)
self._app.router.add_post("/api/auto_approver/context_enabled", self._set_context_enabled)
self._app.router.add_post("/api/auto_approver/response_enabled", self._set_response_enabled)
self._app.router.add_post("/api/auto_approver/context_timeout", self._set_context_timeout)
self._app.router.add_post("/api/auto_approver/response_timeout", self._set_response_timeout)
self._app.router.add_post("/api/auto_approver/llm", self._set_llm_name)
self._app.router.add_get("/api/memory", self._get_memory) self._app.router.add_get("/api/memory", self._get_memory)
self._app.router.add_post("/api/memory/entry", self._create_entry) self._app.router.add_post("/api/memory/entry", self._create_entry)
self._app.router.add_put("/api/memory/entry/{id}", self._save_entry) self._app.router.add_put("/api/memory/entry/{id}", self._save_entry)
@@ -57,16 +58,33 @@ class Api:
self._app.router.add_post("/api/memory/entry/{id}/update", self._update_entry) self._app.router.add_post("/api/memory/entry/{id}/update", self._update_entry)
self._app.router.add_post("/api/memory/load_iteration", self._load_iteration) self._app.router.add_post("/api/memory/load_iteration", self._load_iteration)
async def _run_inference(self, request: web.Request) -> web.Response: async def _get_llms(self, request: web.Request) -> web.Response:
"""Start inference on specified LLM.""" return web.Response(
text=json.dumps(
[{"name": name} for name in self._agent.llms]
),
content_type="application/json"
)
async def _get_active_llm(self, request: web.Request) -> web.Response:
return web.Response(
text=json.dumps(
{"active_llm": self._agent.active_llm}
),
content_type="application/json"
)
async def _set_active_llm(self, request: web.Request) -> web.Response:
try: try:
llm_name = request.match_info["llm"]
data = await request.json() data = await request.json()
text = data.get("response") self._agent.active_llm = data["active_llm"]
context = data.get("context") return web.Response(status=200)
self._agent._response_buffer.set_text(text) except Exception as e:
self._agent.modify_context(context) return web.Response(status=400, text=str(e))
await asyncio.get_event_loop().run_in_executor(None, self._agent.run_inference, llm_name)
async def _run_inference(self, request: web.Request) -> web.Response:
try:
await asyncio.get_event_loop().run_in_executor(None, self._agent.run_inference)
return web.Response(status=200) return web.Response(status=200)
except Exception as e: except Exception as e:
return web.Response(status=400, text=str(e)) return web.Response(status=400, text=str(e))
@@ -74,29 +92,7 @@ class Api:
async def _stop_inference(self, request: web.Request) -> web.Response: async def _stop_inference(self, request: web.Request) -> web.Response:
"""Stop inference on specified LLM.""" """Stop inference on specified LLM."""
try: try:
llm_name = request.match_info["llm"] self._agent.stop_inference()
self._agent.stop_inference(llm_name)
return web.Response(status=200)
except Exception as e:
return web.Response(status=400, text=str(e))
async def _set_response(self, request: web.Request) -> web.Response:
"""Edit the shared response buffer"""
try:
data = await request.json()
text = data.get("response")
self._agent._response_buffer.set_text(text)
return web.Response(status=200)
except Exception as e:
return web.Response(status=400, text=str(e))
async def _approve_response(self, request: web.Request) -> web.Response:
"""Approve current buffer content"""
data = await request.json()
try:
response = data.get("response")
self._agent.response_buffer.set_text(response)
self._agent.approve_response()
return web.Response(status=200) return web.Response(status=200)
except Exception as e: except Exception as e:
return web.Response(status=400, text=str(e)) return web.Response(status=400, text=str(e))
@@ -110,53 +106,50 @@ class Api:
content_type="application/json" content_type="application/json"
) )
async def _modify_context(self, request: web.Request) -> web.Response: async def _set_response(self, request: web.Request) -> web.Response:
"""Modify the current context.""" """Edit the shared response buffer"""
try: try:
data = await request.json() data = await request.json()
context = data.get("context") text = data.get("response")
self._agent.modify_context(context) self._agent._response_buffer.set_text(text)
return web.Response(status=200) return web.Response(status=200)
except Exception as e: except Exception as e:
return web.Response(status=400, text=str(e)) return web.Response(status=400, text=str(e))
async def _send_input(self, request: web.Request) -> web.Response: async def _approve_response(self, request: web.Request) -> web.Response:
"""Send input to the IO buffer.""" """Approve current buffer content"""
try: try:
data = await request.json() self._agent.approve_response()
input_text = data.get("input")
self._io_buffer.append_stdin(input_text)
return web.Response(status=200) return web.Response(status=200)
except Exception as e: except Exception as e:
return web.Response(status=400, text=str(e)) return web.Response(status=400, text=str(e))
async def _clear_output(self, request: web.Request) -> web.Response: async def _add_chat_message(self, request: web.Request) -> web.Response:
"""Clear the stdout buffer.""" """Add a chat message"""
self._io_buffer.clear_stdout()
return web.Response(status=200)
async def _get_output(self, request: web.Request) -> web.Response:
"""Get complete output for specified LLM."""
try: try:
llm_name = request.match_info["llm"] data = await request.json()
output = self._agent.get_output(llm_name) message = data.get("message")
return web.Response( self._io_buffer.add_user_message(message)
text=json.dumps({"output": output}), return web.Response(status=200)
content_type="application/json" except Exception as e:
) return web.Response(status=400, text=str(e))
async def _delete_chat_message(self, request: web.Request) -> web.Response:
"""Delete a chat message"""
try:
message_id = request.match_info["id"]
self._io_buffer.delete_message(message_id)
return web.Response(status=200)
except Exception as e:
return web.Response(status=400, text=str(e))
async def _clear_chat(self, request: web.Request) -> web.Response:
"""Clear chat messages"""
try:
self._io_buffer.clear()
return web.Response(status=200)
except Exception as e: except Exception as e:
return web.Response(status=400, text=str(e)) return web.Response(status=400, text=str(e))
async def _get_llms(self, request: web.Request) -> web.Response:
"""Get all LLMs and their current states."""
states = self._agent.llms
return web.Response(
text=json.dumps(
[{"name": name, "state": state.name}
for name, state in states.items()]
),
content_type="application/json"
)
async def _get_auto_approver_config(self, request: web.Request) -> web.Response: async def _get_auto_approver_config(self, request: web.Request) -> web.Response:
"""Get current auto approver configuration.""" """Get current auto approver configuration."""
@@ -169,7 +162,7 @@ class Api:
"""Update auto approver configuration.""" """Update auto approver configuration."""
try: try:
data = await request.json() data = await request.json()
self._auto_approver.set_config(data) self._auto_approver.config = AutoApproverConfig(**data)
return web.Response(status=200) return web.Response(status=200)
except Exception as e: except Exception as e:
return web.Response(status=400, text=str(e)) return web.Response(status=400, text=str(e))
@@ -219,18 +212,6 @@ class Api:
except Exception as e: except Exception as e:
return web.Response(status=400, text=str(e)) return web.Response(status=400, text=str(e))
async def _set_llm_name(self, request: web.Request) -> web.Response:
"""Set LLM name for auto-approval."""
try:
data = await request.json()
name = data.get("name")
if name is None:
return web.Response(status=400, text="Missing name parameter")
self._auto_approver.llm_name = name
return web.Response(status=200)
except Exception as e:
return web.Response(status=400, text=str(e))
async def _get_memory(self, request: web.Request) -> web.Response: async def _get_memory(self, request: web.Request) -> web.Response:
"""Get complete working memory state.""" """Get complete working memory state."""
entries = self._working_memory.get_entries() entries = self._working_memory.get_entries()
@@ -304,13 +285,12 @@ class Api:
if not content: if not content:
return web.Response(status=400, text="Missing content in request body") return web.Response(status=400, text="Missing content in request body")
(context, response, entries) = IterationParser.parse_iteration(content, self._work_dir, self._io_buffer) (_context, response, entries) = IterationParser.parse_iteration(content, self._work_dir, self._io_buffer)
for entry in entries: for entry in entries:
self._working_memory.add_entry(entry) self._working_memory.add_entry(entry)
self._agent.modify_context(context, True)
self._agent.response_buffer.set_text(response) self._agent.response_buffer.set_text(response)
return web.Response(status=200) return web.Response(status=200)
except Exception as e: except Exception as e:
return web.Response(status=400, text=str(e)) return web.Response(status=400, text=str(e))

View File

@@ -1,4 +1,5 @@
from aiohttp import web, WSMsgType from aiohttp import web, WSMsgType
from dataclasses import asdict
from typing import Dict, Set from typing import Dict, Set
from ..auto_approver import AutoApprover, AutoApproverConfig from ..auto_approver import AutoApprover, AutoApproverConfig
@@ -28,7 +29,7 @@ class AutoApproverWebSocket:
async def _handle_config_change(self, config: AutoApproverConfig): async def _handle_config_change(self, config: AutoApproverConfig):
"""Handle config changes from the AutoApprover.""" """Handle config changes from the AutoApprover."""
await self._broadcast_message({ await self._broadcast_message({
"config": config "config": asdict(config)
}) })
async def handle_connection(self, request: web.Request) -> web.WebSocketResponse: async def handle_connection(self, request: web.Request) -> web.WebSocketResponse:
@@ -41,7 +42,7 @@ class AutoApproverWebSocket:
try: try:
# Send initial config # Send initial config
await ws.send_json({ await ws.send_json({
"config": self._auto_approver.config "config": asdict(self._auto_approver.config)
}) })
async for msg in ws: async for msg in ws:

64
sia/web/chat_websocket.py Normal file
View File

@@ -0,0 +1,64 @@
from aiohttp import web, WSMsgType
from typing import Dict, Set
from .util import wrap_async
from ..chat_io_buffer import ChatIOBuffer
class ChatWebSocket:
def __init__(self, io_buffer: ChatIOBuffer):
self._io_buffer = io_buffer
self._clients: Set[web.WebSocketResponse] = set()
self._io_buffer.add_change_handler(wrap_async(self._handle_io_buffer_change))
async def _broadcast_message(self, message: Dict):
"""Broadcast message to all connected clients."""
disconnected = set()
for ws in self._clients:
try:
await ws.send_json(message)
except ConnectionResetError:
disconnected.add(ws)
self._clients -= disconnected
async def _handle_io_buffer_change(self, new_messages, deleted_message_ids, last_read_timestamp):
"""Handle changes to the ChatIOBuffer."""
await self._broadcast_message({
"messages": [
{
"id": message.id,
"content": message.content,
"message_type": message.message_type.value
}
for message in new_messages
],
"deleted_message_ids": deleted_message_ids,
"last_read_timestamp": last_read_timestamp
})
async def handle_connection(self, request: web.Request) -> web.WebSocketResponse:
"""Handle new WebSocket connections."""
ws = web.WebSocketResponse(heartbeat=30)
await ws.prepare(request)
try:
# Send initial state
await ws.send_json({
"messages": [
{
"id": message.id,
"content": message.content,
"message_type": message.message_type.value
}
for message in self._io_buffer.messages
],
"last_read_timestamp": self._io_buffer.last_read_timestamp
})
self._clients.add(ws)
async for msg in ws:
if msg.type == WSMsgType.ERROR:
print(f"WebSocket connection closed with error: {ws.exception()}")
finally:
self._clients.remove(ws)
return ws

View File

@@ -1,55 +0,0 @@
from aiohttp import web, WSMsgType
from typing import Dict, Set
from .util import wrap_async
from ..web_agent import WebAgent
class ContextWebSocket:
"""
WebSocket handler for context changes.
Broadcasts context updates to all connected clients.
"""
def __init__(self, agent: WebAgent):
self._agent = agent
self._clients: Set[web.WebSocketResponse] = set()
self._agent.add_context_change_handler(wrap_async(self._handle_context_change))
async def _broadcast_message(self, message: Dict):
"""Broadcast message to all connected clients."""
disconnected = set()
for ws in self._clients:
try:
await ws.send_json(message)
except ConnectionResetError:
disconnected.add(ws)
self._clients -= disconnected
async def _handle_context_change(self, context: str, generated: bool):
"""Handle context changes from the WebAgent."""
await self._broadcast_message({
"context": context,
"generated": generated
})
async def handle_connection(self, request: web.Request) -> web.WebSocketResponse:
"""Handle new WebSocket connections."""
ws = web.WebSocketResponse(heartbeat=30)
await ws.prepare(request)
self._clients.add(ws)
try:
# Send initial context
await ws.send_json({
"context": self._agent.context,
"generated": True
})
async for msg in ws:
if msg.type == WSMsgType.ERROR:
print(f"WebSocket connection closed with error: {ws.exception()}")
finally:
self._clients.remove(ws)
return ws

View File

@@ -1,8 +1,6 @@
from aiohttp import web, WSMsgType from aiohttp import web, WSMsgType
from typing import Dict, Set from typing import Dict, Set
from ..entry import Entry
from ..web_agent import WebAgent
from ..working_memory import WorkingMemory from ..working_memory import WorkingMemory
from .util import wrap_async from .util import wrap_async

View File

@@ -2,18 +2,19 @@ from aiohttp import web, WSMsgType
from typing import Dict, Set from typing import Dict, Set
from .util import wrap_async from .util import wrap_async
from ..web_agent import WebAgent, LlmState from ..web_agent import WebAgent, AgentState
class LlmWebSocket: class StateWebSocket:
""" """
WebSocket handler for LLM state changes. WebSocket handler for agent state changes.
Broadcasts state updates to all connected clients. Broadcasts state updates to all connected clients.
""" """
def __init__(self, agent: WebAgent): def __init__(self, agent: WebAgent):
self._agent = agent self._agent = agent
self._clients: Set[web.WebSocketResponse] = set() self._clients: Set[web.WebSocketResponse] = set()
self._agent.add_llm_change_handler(wrap_async(self._handle_state_change)) self._agent.add_state_change_handler(wrap_async(self._handle_change))
self._agent.add_selected_llm_change_handler(wrap_async(self._handle_change))
async def _broadcast_message(self, message: Dict): async def _broadcast_message(self, message: Dict):
"""Broadcast message to all connected clients.""" """Broadcast message to all connected clients."""
@@ -24,12 +25,12 @@ class LlmWebSocket:
except ConnectionResetError: except ConnectionResetError:
disconnected.add(ws) disconnected.add(ws)
self._clients -= disconnected self._clients -= disconnected
async def _handle_state_change(self, llm_name: str, new_state: LlmState): async def _handle_change(self, arg):
"""Handle state changes from the WebAgent.""" """Handle changes to the active LLM."""
await self._broadcast_message({ await self._broadcast_message({
"llm": llm_name, "state": self._agent.state.name,
"state": new_state.name "active_llm": self._agent.active_llm
}) })
async def handle_connection(self, request: web.Request) -> web.WebSocketResponse: async def handle_connection(self, request: web.Request) -> web.WebSocketResponse:
@@ -38,16 +39,12 @@ class LlmWebSocket:
await ws.prepare(request) await ws.prepare(request)
try: try:
# Send initial states for all LLMs # Send initial state
states = self._agent.llms await ws.send_json({
for llm_name, state in states.items(): "state": self._agent.state.name,
await ws.send_json({ "active_llm": self._agent.active_llm
"llm": llm_name, })
"state": state.name
})
self._clients.add(ws) self._clients.add(ws)
async for msg in ws: async for msg in ws:
if msg.type == WSMsgType.ERROR: if msg.type == WSMsgType.ERROR:
print(f"WebSocket connection closed with error: {ws.exception()}") print(f"WebSocket connection closed with error: {ws.exception()}")

View File

@@ -1,28 +1,25 @@
from aiohttp import web from aiohttp import web
from ..auto_approver import AutoApprover from ..auto_approver import AutoApprover
from ..chat_io_buffer import ChatIOBuffer
from ..web_agent import WebAgent from ..web_agent import WebAgent
from ..web_io_buffer import WebIOBuffer
from ..working_memory import WorkingMemory from ..working_memory import WorkingMemory
from .auto_approver_websocket import AutoApproverWebSocket from .auto_approver_websocket import AutoApproverWebSocket
from .context_websocket import ContextWebSocket from .chat_websocket import ChatWebSocket
from .llm_websocket import LlmWebSocket
from .memory_websocket import MemoryWebSocket from .memory_websocket import MemoryWebSocket
from .response_websocket import ResponseWebSocket from .response_websocket import ResponseWebSocket
from .stdout_websocket import StdoutWebSocket from .state_websocket import StateWebSocket
class Websockets: class Websockets:
def __init__(self, app: web.Application, agent: WebAgent, io_buffer: WebIOBuffer, auto_approver: AutoApprover, working_memory: WorkingMemory): def __init__(self, app: web.Application, agent: WebAgent, io_buffer: ChatIOBuffer, auto_approver: AutoApprover, working_memory: WorkingMemory):
self._auto_approver_ws = AutoApproverWebSocket(auto_approver) self._auto_approver_ws = AutoApproverWebSocket(auto_approver)
self._context_ws = ContextWebSocket(agent) self._chat_ws = ChatWebSocket(io_buffer)
self._llm_ws = LlmWebSocket(agent)
self._memory_ws = MemoryWebSocket(working_memory) self._memory_ws = MemoryWebSocket(working_memory)
self._response_ws = ResponseWebSocket(agent) self._response_ws = ResponseWebSocket(agent)
self._stdout_ws = StdoutWebSocket(io_buffer) self._state_ws = StateWebSocket(agent)
app.router.add_get("/ws/auto_approver", self._auto_approver_ws.handle_connection) app.router.add_get("/ws/auto_approver", self._auto_approver_ws.handle_connection)
app.router.add_get("/ws/context", self._context_ws.handle_connection) app.router.add_get("/ws/chat", self._chat_ws.handle_connection)
app.router.add_get("/ws/llms", self._llm_ws.handle_connection)
app.router.add_get("/ws/memory", self._memory_ws.handle_connection) app.router.add_get("/ws/memory", self._memory_ws.handle_connection)
app.router.add_get("/ws/response", self._response_ws.handle_connection) app.router.add_get("/ws/response", self._response_ws.handle_connection)
app.router.add_get("/ws/stdout", self._stdout_ws.handle_connection) app.router.add_get("/ws/state", self._state_ws.handle_connection)

View File

@@ -1,13 +1,10 @@
from collections import defaultdict
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import Enum, auto from enum import Enum, auto
from sys import exit from sys import exit
from threading import Lock from typing import Callable, Dict, List
from typing import Callable, Dict, List, Optional
from .base_agent import BaseAgent from .base_agent import BaseAgent
from .command import Command from .command import Command
from .command_result import CommandResult
from .iteration_logger import IterationLogger from .iteration_logger import IterationLogger
from .llm_engine import LlmEngine from .llm_engine import LlmEngine
from .response_buffer import ResponseBuffer from .response_buffer import ResponseBuffer
@@ -15,9 +12,10 @@ from .response_parser import ResponseParser
from .system_metrics import SystemMetrics from .system_metrics import SystemMetrics
from .working_memory import WorkingMemory from .working_memory import WorkingMemory
class LlmState(Enum): class AgentState(Enum):
IDLE = auto() IDLE = auto()
INFERENCE = auto() INFERENCE = auto()
PROCESSING_RESPONSE = auto()
class WebAgent(BaseAgent): class WebAgent(BaseAgent):
def __init__( def __init__(
@@ -37,97 +35,81 @@ class WebAgent(BaseAgent):
metrics, metrics,
parser parser
) )
self._llms = llms self._llms = llms
self._selected_llm = list(llms.keys())[0]
self._state: AgentState = AgentState.IDLE
self._iteration_logger = iteration_logger self._iteration_logger = iteration_logger
self._llm_states: Dict[str, LlmState] = {name: LlmState.IDLE for name in llms}
self._response_buffer: ResponseBuffer = ResponseBuffer() self._response_buffer: ResponseBuffer = ResponseBuffer()
self._validation_error: Optional[str] = None
self._command_result: Optional[CommandResult] = None
self._context = self._compile_context(next(iter(self._llms.values())))
self._stop_flags: Dict[str, bool] = {name: False for name in llms}
# Locks self._state_change_handlers: List[Callable[[AgentState], None]] = []
self._llm_lock = Lock() self._selected_llm_change_handlers: List[Callable[[str], None]] = []
self._output_lock = Lock()
# Event handlers
self._llm_change_handlers: List[Callable[[str, LlmState], None]] = []
self._token_handlers: List[Callable[[str, str], None]] = []
self._context_change_handlers: List[Callable[[str, bool], None]] = []
# Change handlers self._update_compiled_context()
self._working_memory.add_change_handler(self._handle_memory_update) self._working_memory.add_change_handler(self._update_compiled_context)
@property
def llms(self) -> Dict[str, LlmState]:
"""Get current state of all LLMs"""
with self._llm_lock:
return self._llm_states.copy()
@property @property
def response_buffer(self) -> ResponseBuffer: def response_buffer(self) -> ResponseBuffer:
return self._response_buffer return self._response_buffer
@property @property
def context(self) -> str: def llms(self) -> List[str]:
return self._context return self._llms.keys()
@property @property
def command_result(self) -> Optional[CommandResult]: def active_llm(self) -> str:
return self._command_result return self._selected_llm
@active_llm.setter
def active_llm(self, llm_name: str) -> None:
if llm_name not in self._llms:
raise ValueError(f"Invalid LLM name: {llm_name}")
if self._selected_llm == llm_name:
return
self._selected_llm = llm_name
self._update_compiled_context()
self._notify_selected_llm_change()
def add_selected_llm_change_handler(self, handler: Callable[[str], None]) -> None:
self._selected_llm_change_handlers.append(handler)
@property @property
def validation_error(self) -> Optional[str]: def state(self) -> AgentState:
return self._validation_error return self._state
def add_llm_change_handler(self, handler: Callable[[str, LlmState], None]) -> None: def add_state_change_handler(self, handler: Callable[[AgentState], None]) -> None:
"""Add handler for LLM state changes""" self._state_change_handlers.append(handler)
if handler not in self._llm_change_handlers:
self._llm_change_handlers.append(handler)
def add_context_change_handler(self, handler: Callable[[str, bool], None]) -> None: def run_inference(self) -> None:
"""Add handler for context changes"""
if handler not in self._context_change_handlers:
self._context_change_handlers.append(handler)
def modify_context(self, context: str, generated: bool = False) -> None:
"""Update context and reset all LLM states"""
with self._llm_lock:
self._context = context
for handler in self._context_change_handlers:
handler(context, generated)
def run_inference(self, llm_name: str) -> None:
"""Start inference on specified LLM""" """Start inference on specified LLM"""
if llm_name not in self._llms: if self._state != AgentState.IDLE:
raise ValueError(f"Unknown LLM: {llm_name}") raise RuntimeError(f"Not ready for inference")
with self._llm_lock: self._update_state(AgentState.INFERENCE)
if self._llm_states[llm_name] != LlmState.IDLE: llm = self._llms[self.active_llm]
raise RuntimeError(f"LLM {llm_name} is not ready for inference") response_token_iter = llm.infer(
self._set_llm_state(llm_name, LlmState.INFERENCE) self.system_prompt,
self._stop_flags[llm_name] = False self._compiled_context,
llm = self._llms[llm_name] self._response_buffer.get_text()
def should_stop() -> bool: )
return self._stop_flags[llm_name]
response_token_iter = llm.infer(self.system_prompt, self.context, self._response_buffer.get_text(), should_stop)
for token in response_token_iter: for token in response_token_iter:
with self._output_lock: self._response_buffer.append_text(token)
self._response_buffer.append_text(token) self._update_state(AgentState.IDLE)
with self._llm_lock:
self._set_llm_state(llm_name, LlmState.IDLE)
def stop_inference(self, llm_name: str) -> None: def stop_inference(self) -> None:
"""Stop ongoing inference for specified LLM""" """Stop ongoing inference for specified LLM"""
if llm_name not in self._llms: self._llms[self.active_llm].restart()
raise ValueError(f"Unknown LLM: {llm_name}") self._update_state(AgentState.IDLE)
self._stop_flags[llm_name] = True
with self._llm_lock:
self._set_llm_state(llm_name, LlmState.IDLE)
def approve_response(self) -> None: def approve_response(self) -> None:
"""Process approved response from specified LLM""" """Process approved response from specified LLM"""
self._update_state(AgentState.PROCESSING_RESPONSE)
timestamp = datetime.now(timezone.utc) timestamp = datetime.now(timezone.utc)
self._iteration_logger.log_iteration(timestamp, self._context, self._response_buffer.get_text()) self._iteration_logger.log_iteration(
timestamp,
self._compiled_context,
self._response_buffer.get_text()
)
parse_result = self._parser.parse(timestamp, self._response_buffer.get_text()) parse_result = self._parser.parse(timestamp, self._response_buffer.get_text())
self._response_buffer.clear() self._response_buffer.clear()
if isinstance(parse_result, Command): if isinstance(parse_result, Command):
@@ -141,14 +123,13 @@ class WebAgent(BaseAgent):
parse_result.update() parse_result.update()
self._working_memory.update() self._working_memory.update()
self._working_memory.add_entry(parse_result) self._working_memory.add_entry(parse_result)
self._update_state(AgentState.IDLE)
def _set_llm_state(self, llm_name: str, state: LlmState) -> None: def _update_state(self, state: AgentState) -> None:
"""Update LLM state and notify handlers""" """Update LLM state and notify handlers"""
self._llm_states[llm_name] = state self._state = state
for handler in self._llm_change_handlers: for handler in self._state_change_handlers:
handler(llm_name, state) handler(state)
def _handle_memory_update(self) -> None: def _update_compiled_context(self):
"""Handle memory updates and update context""" self._compiled_context = self._compile_context(self._llms[self.active_llm])
context = self._compile_context(next(iter(self._llms.values())))
self.modify_context(context, True)

View File

@@ -85,8 +85,8 @@ class WorkingMemory:
def __del__(self): def __del__(self):
"""Clean up all entries when memory is deleted.""" """Clean up all entries when memory is deleted."""
if hasattr(self, '_entries'): self._change_handlers.clear()
self.clear() self.clear()
def get_entry(self, id: str) -> Optional[Entry]: def get_entry(self, id: str) -> Optional[Entry]:
""" """

View File

@@ -4,14 +4,14 @@ from threading import Event
import time import time
from sia.auto_approver import AutoApprover from sia.auto_approver import AutoApprover
from sia.web_agent import WebAgent, LlmState from sia.web_agent import WebAgent, AgentState
class AutoApproverTest(unittest.TestCase): class AutoApproverTest(unittest.TestCase):
def setUp(self): def setUp(self):
"""Create mock agent and approver for each test""" """Create mock agent and approver for each test"""
self.mock_agent = Mock(spec=WebAgent) self.mock_agent = Mock(spec=WebAgent)
# Set up llms attribute as a dictionary # Set up llms attribute as a dictionary
self.mock_agent.llms = {"default": LlmState.IDLE} self.mock_agent.llms = {"default": AgentState.IDLE}
self.approver = AutoApprover(self.mock_agent) self.approver = AutoApprover(self.mock_agent)
def test_timeout_setters(self): def test_timeout_setters(self):
@@ -30,7 +30,7 @@ class AutoApproverTest(unittest.TestCase):
def test_enable_starts_thread_in_correct_state(self): def test_enable_starts_thread_in_correct_state(self):
"""Test enabling only starts thread in matching state""" """Test enabling only starts thread in matching state"""
# Mock the agent's llm state # Mock the agent's llm state
self.mock_agent.llms["default"] = LlmState.IDLE self.mock_agent.llms["default"] = AgentState.IDLE
self.approver.context_enabled = True self.approver.context_enabled = True
time.sleep(0.1) # Allow thread to start time.sleep(0.1) # Allow thread to start
self.assertIsNotNone(self.approver._context_thread) self.assertIsNotNone(self.approver._context_thread)
@@ -40,7 +40,7 @@ class AutoApproverTest(unittest.TestCase):
def test_state_change_stops_threads(self): def test_state_change_stops_threads(self):
"""Test state changes stop irrelevant threads""" """Test state changes stop irrelevant threads"""
# Start in idle state with context thread # Start in idle state with context thread
self.mock_agent.llms["default"] = LlmState.IDLE self.mock_agent.llms["default"] = AgentState.IDLE
# Mock _stop_context_thread to verify it's called # Mock _stop_context_thread to verify it's called
with patch.object(self.approver, '_stop_context_thread') as mock_stop: with patch.object(self.approver, '_stop_context_thread') as mock_stop:
@@ -48,31 +48,31 @@ class AutoApproverTest(unittest.TestCase):
time.sleep(0.1) time.sleep(0.1)
# Change state and verify stop method was called # Change state and verify stop method was called
self.mock_agent.llms["default"] = LlmState.INFERENCE self.mock_agent.llms["default"] = AgentState.INFERENCE
self.approver._handle_llm_state_change("default", LlmState.INFERENCE) self.approver._handle_state_change(AgentState.INFERENCE)
mock_stop.assert_called_once() mock_stop.assert_called_once()
def test_quick_state_cycle(self): def test_quick_state_cycle(self):
"""Test thread stops when state changes before timeout""" """Test thread stops when state changes before timeout"""
self.approver.context_timeout = 5.0 self.approver.context_timeout = 5.0
self.mock_agent.llms["default"] = LlmState.IDLE self.mock_agent.llms["default"] = AgentState.IDLE
self.approver.context_enabled = True self.approver.context_enabled = True
# Change state quickly # Change state quickly
time.sleep(0.1) time.sleep(0.1)
self.mock_agent.llms["default"] = LlmState.INFERENCE self.mock_agent.llms["default"] = AgentState.INFERENCE
self.approver._handle_llm_state_change("default", LlmState.INFERENCE) self.approver._handle_state_change(AgentState.INFERENCE)
# Verify no approval happened # Verify no approval happened
self.mock_agent.run_inference.assert_not_called() self.mock_agent.run_inference.assert_not_called()
def test_disable_stops_thread(self): def test_disable_stops_thread(self):
"""Test disabling stops active thread""" """Test disabling stops active thread"""
self.mock_agent.llms["default"] = LlmState.IDLE self.mock_agent.llms["default"] = AgentState.IDLE
# Create a new instance with a fresh mock for this test # Create a new instance with a fresh mock for this test
mock_agent = Mock(spec=WebAgent) mock_agent = Mock(spec=WebAgent)
mock_agent.llms = {"default": LlmState.IDLE} mock_agent.llms = {"default": AgentState.IDLE}
test_approver = AutoApprover(mock_agent) test_approver = AutoApprover(mock_agent)
# Start with a mocked stop method # Start with a mocked stop method
@@ -91,7 +91,7 @@ class AutoApproverTest(unittest.TestCase):
def test_successful_auto_approval(self): def test_successful_auto_approval(self):
"""Test thread approves after timeout when conditions met""" """Test thread approves after timeout when conditions met"""
self.approver.context_timeout = 0.1 self.approver.context_timeout = 0.1
self.mock_agent.llms["default"] = LlmState.IDLE self.mock_agent.llms["default"] = AgentState.IDLE
self.approver.llm_name = "default" self.approver.llm_name = "default"
# Use threading Event to properly synchronize the test # Use threading Event to properly synchronize the test

View File

@@ -8,7 +8,6 @@ from sia.web_io_buffer import WebIOBuffer
from sia.working_memory import WorkingMemory from sia.working_memory import WorkingMemory
from sia.system_metrics import SystemMetrics from sia.system_metrics import SystemMetrics
from sia.llm_engine import LlmEngine from sia.llm_engine import LlmEngine
from sia.xml_validator import XMLValidator
from sia.response_parser import ResponseParser from sia.response_parser import ResponseParser
from sia.base_agent import BaseAgent from sia.base_agent import BaseAgent
from sia.command import Command from sia.command import Command
@@ -57,7 +56,6 @@ class BaseAgentTest(unittest.TestCase):
self.mock_llm = Mock(spec=LlmEngine) self.mock_llm = Mock(spec=LlmEngine)
self.mock_llm.token_count.return_value = 100 self.mock_llm.token_count.return_value = 100
self.mock_llm.token_limit.return_value = 1000 self.mock_llm.token_limit.return_value = 1000
self.mock_validator = Mock(spec=XMLValidator)
self.io_buffer = WebIOBuffer() self.io_buffer = WebIOBuffer()
self.working_memory = WorkingMemory() self.working_memory = WorkingMemory()
self.work_dir = Path("/tmp") # Use a temp directory for tests self.work_dir = Path("/tmp") # Use a temp directory for tests
@@ -79,7 +77,6 @@ class BaseAgentTest(unittest.TestCase):
action_schema="test schema", action_schema="test schema",
working_memory=self.working_memory, working_memory=self.working_memory,
metrics=self.mock_metrics, metrics=self.mock_metrics,
validator=self.mock_validator,
parser=self.parser parser=self.parser
) )
@@ -100,7 +97,6 @@ class BaseAgentTest(unittest.TestCase):
"""Test agent initialization and component setup.""" """Test agent initialization and component setup."""
self.assertEqual(self.agent._working_memory, self.working_memory) self.assertEqual(self.agent._working_memory, self.working_memory)
self.assertEqual(self.agent._metrics, self.mock_metrics) self.assertEqual(self.agent._metrics, self.mock_metrics)
self.assertEqual(self.agent._validator, self.mock_validator)
self.assertEqual(self.agent._parser, self.parser) self.assertEqual(self.agent._parser, self.parser)
self.assertEqual(self.agent._action_schema, "test schema") self.assertEqual(self.agent._action_schema, "test schema")
@@ -114,20 +110,19 @@ class BaseAgentTest(unittest.TestCase):
context = self.agent._compile_context(self.mock_llm) context = self.agent._compile_context(self.mock_llm)
# Parse context and verify structure # Parse context and verify structure
root = ET.fromstring(context) self.assertEqual(context.tag, "context")
self.assertEqual(root.tag, "context")
# Check metrics # Check metrics
self.assertEqual(root.get("memory_used"), "1000") self.assertEqual(context.get("memory_used"), "1000")
self.assertEqual(root.get("memory_total"), "2000") self.assertEqual(context.get("memory_total"), "2000")
self.assertEqual(root.get("disk_used"), "5000") self.assertEqual(context.get("disk_used"), "5000")
self.assertEqual(root.get("disk_total"), "10000") self.assertEqual(context.get("disk_total"), "10000")
# Check context size # Check context size
self.assertEqual(root.get("context"), "10.0%") self.assertEqual(context.get("context"), "10.0%")
# Check no memory entries # Check no memory entries
self.assertEqual(len(list(root)), 0) self.assertEqual(len(list(context)), 0)
def test_compile_context_with_entries(self): def test_compile_context_with_entries(self):
"""Test context compilation with a single entry.""" """Test context compilation with a single entry."""
@@ -135,13 +130,12 @@ class BaseAgentTest(unittest.TestCase):
self.agent._working_memory.add_entry(entry) self.agent._working_memory.add_entry(entry)
context = self.agent._compile_context(self.mock_llm) context = self.agent._compile_context(self.mock_llm)
root = ET.fromstring(context)
# Check context size reflects one entry # Check context size reflects one entry
self.assertEqual(root.get("context"), "10.0%") self.assertEqual(context.get("context"), "10.0%")
# Verify entry content # Verify entry content
reasoning_elem = root.find("reasoning") reasoning_elem = context.find("reasoning")
self.assertIsNotNone(reasoning_elem) self.assertIsNotNone(reasoning_elem)
self.assertEqual(reasoning_elem.get("id"), "test-id") self.assertEqual(reasoning_elem.get("id"), "test-id")
self.assertEqual(reasoning_elem.text.strip(), "test reasoning") self.assertEqual(reasoning_elem.text.strip(), "test reasoning")
@@ -157,13 +151,12 @@ class BaseAgentTest(unittest.TestCase):
self.agent._working_memory.add_entry(entry) self.agent._working_memory.add_entry(entry)
context = self.agent._compile_context(self.mock_llm) context = self.agent._compile_context(self.mock_llm)
root = ET.fromstring(context)
# Check context size reflects three entries # Check context size reflects three entries
self.assertEqual(root.get("context"), "10.0%") self.assertEqual(context.get("context"), "10.0%")
# Verify entry order maintained # Verify entry order maintained
reasoning_elems = root.findall("reasoning") reasoning_elems = context.findall("reasoning")
self.assertEqual(len(reasoning_elems), 3) self.assertEqual(len(reasoning_elems), 3)
for i, elem in enumerate(reasoning_elems): for i, elem in enumerate(reasoning_elems):
self.assertEqual(elem.get("id"), f"id-{i}") self.assertEqual(elem.get("id"), f"id-{i}")

237
test/llm_engine_test.py Normal file
View File

@@ -0,0 +1,237 @@
import unittest
import os
import tempfile
import time
from xml.etree import ElementTree as ET
from sia.llm_engine import LlmEngine
class TestLlmEngine(unittest.TestCase):
def setUp(self):
"""Set up test environment before each test."""
# Create a temporary bash script to simulate the LLM engine
self.temp_script = self._create_mock_llm_script()
os.chmod(self.temp_script, 0o755) # Make it executable
# Initialize the LlmEngine with our mock script
self.engine = LlmEngine(self.temp_script, "/root/sia/action_schema.xsd")
def tearDown(self):
"""Clean up after the test."""
if hasattr(self, 'engine') and self.engine:
self.engine._terminate_process()
def _create_mock_llm_script(self):
"""Create a bash script that simulates an LLM engine."""
fd, path = tempfile.mkstemp(suffix='.sh')
with os.fdopen(fd, 'w') as f:
# Paste the content from the mock_llm_script_debug artifact
# This is a placeholder - you'll replace this with the actual script content
f.write('''#!/bin/bash
# Mock LLM engine for testing
# This script simulates an LLM engine subprocess that responds to the three commands:
# <token_limit/>, <token_count>...</token_count>, and <infer_xml>...</infer_xml>
# Function to read XML input until a complete closing tag is found
read_xml_input() {
local input=""
local line
local char_count=0
# Read until we get a complete XML command
while IFS= read -r line; do
input="${input}${line}"
char_count=$((char_count + ${#line}))
# Debug the actual content (first 30 chars)
if [[ "$input" == *"<token_limit/>"* ]]; then
printf "1024"
printf "\004" # EOT character (hex 04)
return
fi
if [[ "$input" == *"<token_count>"*"</token_count>"* ]]; then
printf "405"
printf "\004" # EOT character (hex 04)
return
fi
if [[ "$input" == *"<infer_xml>"*"</infer_xml>"* ]]; then
generate_response
return
fi
done
}
# Function to generate a response token by token
generate_response() {
printf "<"
sleep 0.01
printf "reason"
sleep 0.01
printf "ing"
sleep 0.01
printf ">"
sleep 0.01
printf "This"
sleep 0.01
printf " is"
sleep 0.01
printf " a"
sleep 0.01
printf " test"
sleep 0.01
printf " response."
sleep 0.01
printf "</"
sleep 0.01
printf "reason"
sleep 0.01
printf "ing"
sleep 0.01
printf ">"
sleep 0.01
printf "\004"
}
# Main loop - keep reading input and responding
iteration=0
while true; do
iteration=$((iteration + 1))
read_xml_input
done''')
return path
def test_token_limit(self):
"""Test retrieving the token limit from the LLM engine."""
limit = self.engine.token_limit()
self.assertEqual(limit, 1024, "Token limit should be 1024")
def test_token_count(self):
"""Test counting tokens in a prompt."""
system_prompt = "You are a helpful AI assistant."
# Create a simple context ElementTree
context_et = ET.Element("context")
context_et.set("time", "2024-10-18T12:00:00Z")
context_et.text = "Test context"
prefix = ""
count = self.engine.token_count(system_prompt, context_et, prefix)
self.assertEqual(count, 405, "Token count should be 405")
def test_inference_token_by_token(self):
"""Test inference with the LLM engine, ensuring tokens come one by one."""
system_prompt = "You are a helpful AI assistant."
# Create a simple context ElementTree
context_et = ET.Element("context")
context_et.set("time", "2024-10-18T12:00:00Z")
context_et.text = "Test context"
prefix = ""
# Get the generator for token-by-token output
token_generator = self.engine.infer(system_prompt, context_et, prefix)
# Collect tokens
response = ""
tokens_received = 0
for token in token_generator:
response += token
tokens_received += 1
# Each "token" should be small (in the mock script, it's up to 10 characters)
self.assertLessEqual(len(token), 10, "Each token should be small")
# Verify we received multiple tokens
self.assertGreater(tokens_received, 10, "Should receive multiple tokens")
expected = "<reasoning>This is a test response.</reasoning>"
self.assertEqual(response, expected, "Inference response should match expected output")
def test_multiple_inferences(self):
"""Test running multiple inference cycles one after another."""
system_prompt = "You are a helpful AI assistant."
# Create a simple context ElementTree
context_et = ET.Element("context")
context_et.set("time", "2024-10-18T12:00:00Z")
context_et.text = "Test context"
prefix = ""
# Run first inference
token_generator = self.engine.infer(system_prompt, context_et, prefix)
response = ""
for token in token_generator:
response += token
expected = "<reasoning>This is a test response.</reasoning>"
self.assertEqual(response, expected, "First inference response should match expected output")
# Add a small delay between inferences to see if it helps
time.sleep(0.1)
# Run second inference
token_generator = self.engine.infer(system_prompt, context_et, prefix)
response = ""
for token in token_generator:
response += token
self.assertEqual(response, expected, "Second inference response should match expected output")
# We should be able to get the token limit again after inference
limit = self.engine.token_limit()
self.assertEqual(limit, 1024, "Token limit should still be 1024 after multiple inferences")
def test_restart(self):
"""Test restarting the LLM engine."""
# First get a token limit
limit = self.engine.token_limit()
self.assertEqual(limit, 1024, "Token limit should be 1024")
# Restart the engine
self.engine.restart()
# Get token limit again
limit = self.engine.token_limit()
self.assertEqual(limit, 1024, "Token limit should still be 1024 after restart")
# Engine should still work for inference after restart
system_prompt = "You are a helpful AI assistant."
# Create a simple context ElementTree
context_et = ET.Element("context")
context_et.set("time", "2024-10-18T12:00:00Z")
context_et.text = "Test context"
prefix = ""
token_generator = self.engine.infer(system_prompt, context_et, prefix)
response = ""
for token in token_generator:
response += token
expected = "<reasoning>This is a test response.</reasoning>"
self.assertEqual(response, expected, "Inference should work after restart")
if __name__ == '__main__':
unittest.main()

View File

@@ -1,53 +0,0 @@
import unittest
from itertools import tee
from . import test_data
from sia.llm_engine import LlmEngine
from sia.llm_engine.local_llm_engine import LocalLlmEngine
class LocalLlmEngineTest(unittest.TestCase):
def setUp(self):
self.model_path = "/root/models/current"
@unittest.skip("Takes too long")
def test_initialization(self):
llm_engine = LocalLlmEngine(self.model_path, 0.2, 1024)
self.assertIsInstance(llm_engine, LlmEngine)
@unittest.skip("Takes too long")
def test_infer(self):
main_context = "This is a test"
llm_engine = LocalLlmEngine(self.model_path, 0.2, 1024)
tokens = llm_engine.infer(test_data.echo_system_prompt, main_context, "")
print_tokens, result_tokens = tee(tokens)
for token in print_tokens:
print(token, end="", flush=True)
result = ''.join(result_tokens)
self.assertEqual(result, f"{main_context}<test_tag>{main_context}</test_tag>")
@unittest.skip("Takes too long")
def test_token_count(self):
"""Test token counting returns reasonable numbers"""
llm_engine = LocalLlmEngine(self.model_path, 0.2, 1024)
# Test with short inputs
count = llm_engine.token_count("Short prompt", "Brief context")
self.assertGreater(count, 5)
self.assertLess(count, 50)
# Test with longer inputs
long_prompt = "A detailed system prompt with multiple sentences. " * 5
long_context = "An extensive context containing various details. " * 10
count = llm_engine.token_count(long_prompt, long_context)
self.assertGreater(count, 100)
self.assertLess(count, 1000)
@unittest.skip("Takes too long")
def test_token_limit(self):
"""Test token limit is within expected range for modern LLMs"""
llm_engine = LocalLlmEngine(self.model_path, 0.2, 1024)
limit = llm_engine.token_limit()
self.assertGreaterEqual(limit, 2048)
self.assertLessEqual(limit, 1048576)

View File

@@ -5,37 +5,6 @@ import xml.etree.ElementTree as ET
from sia import util from sia import util
class UtilTest(unittest.TestCase): class UtilTest(unittest.TestCase):
def test_stop_before_value(self):
# Helper function to create iterator from list
def create_iterator(items: list) -> Iterator[str]:
for item in items:
yield item
# Test case 1: Stop value in middle of sequence
input_sequence = ['hello', 'world', 'STOP', 'ignored']
result = list(util.stop_before_value(create_iterator(input_sequence), 'STOP'))
self.assertEqual(result, ['hello', 'world'])
# Test case 2: Stop value not in sequence
input_sequence = ['hello', 'world']
result = list(util.stop_before_value(create_iterator(input_sequence), 'STOP'))
self.assertEqual(result, ['hello', 'world'])
# Test case 3: Stop value at start of sequence
input_sequence = ['STOP', 'ignored']
result = list(util.stop_before_value(create_iterator(input_sequence), 'STOP'))
self.assertEqual(result, [])
# Test case 4: Stop value as part of an item
input_sequence = ['hello', 'woSTOPrld', 'ignored']
result = list(util.stop_before_value(create_iterator(input_sequence), 'STOP'))
self.assertEqual(result, ['hello', 'wo'])
# Test case 5: Empty sequence
input_sequence = []
result = list(util.stop_before_value(create_iterator(input_sequence), 'STOP'))
self.assertEqual(result, [])
def test_pretty_print_element_basic(self): def test_pretty_print_element_basic(self):
"""Test basic element printing""" """Test basic element printing"""
elem = ET.Element('root') elem = ET.Element('root')

View File

@@ -10,10 +10,9 @@ from sia.web_io_buffer import WebIOBuffer
from sia.working_memory import WorkingMemory from sia.working_memory import WorkingMemory
from sia.system_metrics import SystemMetrics from sia.system_metrics import SystemMetrics
from sia.llm_engine import LlmEngine from sia.llm_engine import LlmEngine
from sia.xml_validator import XMLValidator
from sia.response_parser import ResponseParser from sia.response_parser import ResponseParser
from sia.iteration_logger import IterationLogger from sia.iteration_logger import IterationLogger
from sia.web_agent import WebAgent, LlmState from sia.web_agent import WebAgent, AgentState
from sia.command import Command from sia.command import Command
from sia.command_result import CommandResult from sia.command_result import CommandResult
from sia.entry.reasoning_entry import ReasoningEntry from sia.entry.reasoning_entry import ReasoningEntry
@@ -22,7 +21,7 @@ from sia.entry import Entry
from sia.response_buffer import ResponseBuffer from sia.response_buffer import ResponseBuffer
from pathlib import Path from pathlib import Path
class MockLlmEngine(LlmEngine): class MockLlmEngine:
"""Mock LLM engine for testing.""" """Mock LLM engine for testing."""
def __init__(self, output: str = "<reasoning>test reasoning</reasoning>"): def __init__(self, output: str = "<reasoning>test reasoning</reasoning>"):
@@ -67,14 +66,10 @@ class WebAgentTest(unittest.TestCase):
'default': MockLlmEngine(), 'default': MockLlmEngine(),
'alternative': MockLlmEngine("<reasoning>alternative reasoning</reasoning>") 'alternative': MockLlmEngine("<reasoning>alternative reasoning</reasoning>")
} }
self.mock_validator = Mock(spec=XMLValidator)
self.io_buffer = WebIOBuffer() self.io_buffer = WebIOBuffer()
self.working_memory = WorkingMemory() self.working_memory = WorkingMemory()
self.work_dir = Path("/tmp") # Use temp directory for tests self.work_dir = Path("/tmp") # Use temp directory for tests
# Mock validator to pass by default
self.mock_validator.validate.return_value = None
# Create parser with IO buffer # Create parser with IO buffer
self.parser = ResponseParser(self.work_dir, self.io_buffer) self.parser = ResponseParser(self.work_dir, self.io_buffer)
@@ -98,7 +93,6 @@ class WebAgentTest(unittest.TestCase):
working_memory=self.working_memory, working_memory=self.working_memory,
metrics=self.mock_metrics, metrics=self.mock_metrics,
llms=self.mock_llms, llms=self.mock_llms,
validator=self.mock_validator,
parser=self.parser, parser=self.parser,
iteration_logger=self.mock_iteration_logger iteration_logger=self.mock_iteration_logger
) )
@@ -107,7 +101,7 @@ class WebAgentTest(unittest.TestCase):
self.llm_state_changes = [] self.llm_state_changes = []
self.context_changes = [] self.context_changes = []
def llm_change_handler(self, llm_name: str, state: LlmState): def llm_change_handler(self, llm_name: str, state: AgentState):
"""Track LLM state changes for verification.""" """Track LLM state changes for verification."""
self.llm_state_changes.append((llm_name, state)) self.llm_state_changes.append((llm_name, state))
@@ -124,23 +118,11 @@ class WebAgentTest(unittest.TestCase):
# Check LLM states # Check LLM states
llm_states = self.agent.llms llm_states = self.agent.llms
self.assertEqual(len(llm_states), 2) self.assertEqual(len(llm_states), 2)
self.assertEqual(llm_states['default'], LlmState.IDLE) self.assertEqual(llm_states['default'], AgentState.IDLE)
self.assertEqual(llm_states['alternative'], LlmState.IDLE) self.assertEqual(llm_states['alternative'], AgentState.IDLE)
# Check response buffer # Check response buffer
self.assertIsInstance(self.agent.response_buffer, ResponseBuffer) self.assertIsInstance(self.agent.response_buffer, ResponseBuffer)
def test_handler_registration(self):
"""Test adding state and context change handlers."""
self.agent.add_llm_change_handler(self.llm_change_handler)
self.agent.add_context_change_handler(self.context_change_handler)
# Adding same handler twice should not duplicate
self.agent.add_llm_change_handler(self.llm_change_handler)
self.agent.add_context_change_handler(self.context_change_handler)
self.assertEqual(len(self.agent._llm_change_handlers), 1)
self.assertEqual(len(self.agent._context_change_handlers), 1)
def test_run_inference(self): def test_run_inference(self):
"""Test running inference.""" """Test running inference."""
@@ -153,8 +135,8 @@ class WebAgentTest(unittest.TestCase):
# Check state changes # Check state changes
self.assertEqual(len(self.llm_state_changes), 2) # IDLE -> INFERENCE -> IDLE self.assertEqual(len(self.llm_state_changes), 2) # IDLE -> INFERENCE -> IDLE
self.assertEqual(self.llm_state_changes[0], ('default', LlmState.INFERENCE)) self.assertEqual(self.llm_state_changes[0], ('default', AgentState.INFERENCE))
self.assertEqual(self.llm_state_changes[1], ('default', LlmState.IDLE)) self.assertEqual(self.llm_state_changes[1], ('default', AgentState.IDLE))
# Verify LLM was called # Verify LLM was called
self.assertTrue(self.mock_llms['default'].infer_called) self.assertTrue(self.mock_llms['default'].infer_called)
@@ -171,7 +153,7 @@ class WebAgentTest(unittest.TestCase):
def test_run_inference_busy_llm(self): def test_run_inference_busy_llm(self):
"""Test running inference on a busy LLM.""" """Test running inference on a busy LLM."""
# Set LLM state to INFERENCE # Set LLM state to INFERENCE
with patch.object(self.agent, '_llm_states', {'default': LlmState.INFERENCE}): with patch.object(self.agent, '_llm_states', {'default': AgentState.INFERENCE}):
with self.assertRaises(RuntimeError): with self.assertRaises(RuntimeError):
self.agent.run_inference('default') self.agent.run_inference('default')
@@ -187,7 +169,6 @@ class WebAgentTest(unittest.TestCase):
working_memory=WorkingMemory(), working_memory=WorkingMemory(),
metrics=self.mock_metrics, metrics=self.mock_metrics,
llms={'test': mock_engine}, llms={'test': mock_engine},
validator=self.mock_validator,
parser=self.parser, parser=self.parser,
iteration_logger=self.mock_iteration_logger iteration_logger=self.mock_iteration_logger
) )
@@ -224,7 +205,6 @@ class WebAgentTest(unittest.TestCase):
working_memory=WorkingMemory(), working_memory=WorkingMemory(),
metrics=self.mock_metrics, metrics=self.mock_metrics,
llms=self.mock_llms, llms=self.mock_llms,
validator=self.mock_validator,
parser=self.parser, parser=self.parser,
iteration_logger=self.mock_iteration_logger iteration_logger=self.mock_iteration_logger
) )
@@ -254,7 +234,6 @@ class WebAgentTest(unittest.TestCase):
working_memory=WorkingMemory(), working_memory=WorkingMemory(),
metrics=self.mock_metrics, metrics=self.mock_metrics,
llms=self.mock_llms, llms=self.mock_llms,
validator=self.mock_validator,
parser=self.parser, parser=self.parser,
iteration_logger=self.mock_iteration_logger iteration_logger=self.mock_iteration_logger
) )
@@ -290,7 +269,7 @@ class WebAgentTest(unittest.TestCase):
self.assertTrue(mock_command.executed) self.assertTrue(mock_command.executed)
# Check command result was stored # Check command result was stored
self.assertEqual(self.agent.command_result, command_result) self.assertEqual(self.agent._command_result, command_result)
# Verify iteration was logged # Verify iteration was logged
self.mock_iteration_logger.log_iteration.assert_called_once() self.mock_iteration_logger.log_iteration.assert_called_once()
@@ -329,7 +308,6 @@ class WebAgentTest(unittest.TestCase):
working_memory=test_memory, working_memory=test_memory,
metrics=self.mock_metrics, metrics=self.mock_metrics,
llms=self.mock_llms, llms=self.mock_llms,
validator=self.mock_validator,
parser=self.parser, parser=self.parser,
iteration_logger=self.mock_iteration_logger iteration_logger=self.mock_iteration_logger
) )

View File

@@ -1,116 +0,0 @@
import unittest
from sia.xml_validator import XMLValidator
class XMLValidatorTest(unittest.TestCase):
def setUp(self):
"""Set up test schema"""
self.test_schema = """<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="test_element">
<xs:complexType>
<xs:simpleContent>
<xs:extension base="xs:string">
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="count" type="xs:integer"/>
</xs:extension>
</xs:simpleContent>
</xs:complexType>
</xs:element>
<xs:element name="simple_element">
<xs:complexType>
<xs:simpleContent>
<xs:extension base="xs:string"/>
</xs:simpleContent>
</xs:complexType>
</xs:element>
</xs:schema>"""
self.validator = XMLValidator(self.test_schema)
def test_initialization(self):
"""Test validator initialization and root element extraction"""
valid_elements = self.validator.get_valid_root_elements()
self.assertEqual(valid_elements, {'test_element', 'simple_element'})
def test_invalid_schema(self):
"""Test handling invalid schema"""
with self.assertRaises(ValueError):
XMLValidator("invalid schema")
def test_valid_xml(self):
"""Test validation of valid XML"""
xml = '<test_element id="123" count="42">test content</test_element>'
result = self.validator.validate(xml)
self.assertIsNone(result)
# Test without optional attribute
xml = '<test_element id="123">test content</test_element>'
result = self.validator.validate(xml)
self.assertIsNone(result)
# Test simple element
xml = '<simple_element>test content</simple_element>'
result = self.validator.validate(xml)
self.assertIsNone(result)
def test_invalid_root_element(self):
"""Test validation with invalid root element"""
xml = '<invalid_element>test content</invalid_element>'
result = self.validator.validate(xml)
self.assertIsNotNone(result)
self.assertIn("Invalid root element", result)
def test_missing_required_attribute(self):
"""Test validation with missing required attribute"""
xml = '<test_element>test content</test_element>'
result = self.validator.validate(xml)
self.assertIsNotNone(result)
self.assertIn("Missing required attribute", result)
def test_invalid_integer_attribute(self):
"""Test validation with invalid integer attribute"""
xml = '<test_element id="123" count="not_a_number">test content</test_element>'
result = self.validator.validate(xml)
self.assertIsNotNone(result)
self.assertIn("Invalid integer value", result)
def test_unexpected_attribute(self):
"""Test validation with unexpected attribute"""
xml = '<test_element id="123" invalid_attr="value">test content</test_element>'
result = self.validator.validate(xml)
self.assertIsNotNone(result)
self.assertIn("Unexpected attribute", result)
def test_invalid_xml_syntax(self):
"""Test validation with invalid XML syntax"""
xml = '<test_element>unclosed tag'
result = self.validator.validate(xml)
self.assertIsNotNone(result)
self.assertIn("Invalid XML", result)
def test_whitespace_handling(self):
"""Test validation with whitespace in XML"""
xml = """
<test_element id="123">
test content
</test_element>
"""
result = self.validator.validate(xml)
self.assertIsNone(result)
def test_empty_content(self):
"""Test validation with empty element content"""
xml = '<test_element id="123"></test_element>'
result = self.validator.validate(xml)
self.assertIsNone(result)
xml = '<test_element id="123"/>'
result = self.validator.validate(xml)
self.assertIsNone(result)
def test_special_characters(self):
"""Test validation with special characters"""
xml = '<test_element id="123"><![CDATA[Special & < > " \' chars]]></test_element>'
result = self.validator.validate(xml)
self.assertIsNone(result)

View File

@@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=42", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "gemma_infer"
version = "0.1.0"
requires-python = ">=3.8"
dependencies = [
"llama-cpp-python @ git+https://github.com/abetlen/llama-cpp-python.git#egg=llama-cpp-python&env=CMAKE_ARGS=-DLLAMA_BUILD=OFF",
"llm_engine_utils @ file:///root/sia/lib/llm_engine_utils",
"python-dotenv>=1.0.0",
"transformers>=4.0.0",
"xml_schema_validator @ file:///root/sia/lib/xml_schema_validator",
]
[project.scripts]
gemma_infer = "gemma_infer.__main__:main"

View File

@@ -0,0 +1,42 @@
from .gemma_llm_engine import GemmaLlmEngine
from dotenv import load_dotenv
from llm_engine_utils.protocol import process
import argparse
import os
def main():
load_dotenv()
parser = argparse.ArgumentParser(description='Gemma Inference')
parser.add_argument(
'--model',
type=str,
default=os.getenv('SIA_GEMMA_MODEL', '/root/models/current/model.gguf'),
help='Model name (default: /root/models/current/model.gguf, env: SIA_GEMMA_MODEL)'
)
parser.add_argument(
'--tokenizer',
type=str,
default=os.getenv('SIA_GEMMA_TOKENIZER', '/root/models/current/tokenizer'),
help='Model name (default: /root/models/current/tokenizer, env: SIA_GEMMA_TOKENIZER)'
)
parser.add_argument(
'--token-limit',
type=int,
default=os.getenv('SIA_GEMMA_TOKEN_LIMIT', 4096),
help='Token limit (default: 4096, env: SIA_GEMMA_TOKEN_LIMIT)'
)
args = parser.parse_args()
gemma_llm_engine = GemmaLlmEngine(
model=args.model,
tokenizer=args.tokenizer,
token_limit=args.token_limit,
)
process(gemma_llm_engine)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,76 @@
import os
os.environ["LLAMA_CPP_LIB_PATH"] = "/usr/local/lib"
os.environ["LD_LIBRARY_PATH"] += ":/usr/local/lib"
os.chdir("/usr/local/lib")
from llama_cpp import Llama, LogitsProcessorList
from llm_engine_utils import LlmEngine
from pathlib import Path
from transformers import AutoTokenizer
from typing import Iterator
from xml_schema_validator import LlamaCppLogitsProcessor
class GemmaLlmEngine(LlmEngine):
def __init__(
self,
model: str,
tokenizer: str,
token_limit: int,
):
self._model = model
self._tokenizer = AutoTokenizer.from_pretrained(tokenizer)
self._token_limit = token_limit
self._llm = Llama(
model_path=model,
n_gpu_layers=100,
n_ctx=token_limit,
#verbose=False, # Disable most logging
)
def infer_xml(self, schema: Path, system: str, context: str, prefix: str) -> Iterator[str]:
xml_schema_text = Path(schema).read_text()
logits_processor = LlamaCppLogitsProcessor(self._tokenizer, xml_schema_text).get_processor()
logits_processor_list = LogitsProcessorList([logits_processor])
prompt = self._format_messages(system, context, prefix)
stream = self._llm.create_completion(
prompt=prompt,
max_tokens=self._token_limit,
stream=True,
logits_processor=logits_processor_list
)
for output in stream:
if 'text' in output["choices"][0]:
yield output["choices"][0]['text']
def token_count(self, system: str, context: str) -> int:
return len(self._format_messages(system, context, None))
def token_limit(self) -> int:
return self._token_limit
def _format_messages(self, system: str, context: str, prefix: str) -> str:
messages = [
{
"role": "user",
"content": f"{system}\n\n--- Context ---\n{context}",
},
{
"role": "assistant",
"content": prefix,
"prefix": True,
},
] if prefix else [
{
"role": "system",
"content": system,
},
{
"role": "user",
"content": context,
},
]
return self._tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True
).removeprefix("<bos>")

View File

@@ -0,0 +1,18 @@
[build-system]
requires = ["setuptools>=42", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "mistral_infer"
version = "0.1.0"
requires-python = ">=3.8"
dependencies = [
"llm_engine_utils @ file:///root/sia/lib/llm_engine_utils",
"mistral-common>=1.0.0",
"mistralai>=0.0.7",
"python-dotenv>=1.0.0",
]
[project.scripts]
mistral_infer = "mistral_infer.__main__:main"

View File

@@ -0,0 +1,42 @@
from .mistral_llm_engine import MistralLlmEngine
from dotenv import load_dotenv
from llm_engine_utils.protocol import process
import argparse
import os
def main():
load_dotenv()
parser = argparse.ArgumentParser(description='Mistral API Inference')
parser.add_argument(
'--model',
type=str,
default=os.getenv('SIA_MISTRAL_MODEL', 'mistral-large-latest'),
help='Model name (default: mistral-large-latest, env: SIA_MISTRAL_MODEL)'
)
parser.add_argument(
'--token-limit',
type=int,
default=int(os.getenv('SIA_MISTRAL_TOKEN_LIMIT', 128000)),
help='Token limit for the model (default: 128000, env: SIA_MISTRAL_TOKEN_LIMIT)'
)
parser.add_argument(
'--api-key',
type=str,
default=os.getenv('SIA_MISTRAL_API_KEY'),
help='API key for the model (required, env: SIA_MISTRAL_API_KEY)'
)
args = parser.parse_args()
mistral_llm_engine = MistralLlmEngine(
model=args.model,
token_limit=args.token_limit,
api_key=args.api_key,
)
process(mistral_llm_engine)
if __name__ == "__main__":
main()

View File

@@ -1,74 +1,69 @@
from typing import Iterator, Optional, Callable from llm_engine_utils import LlmEngine
from mistralai import Mistral from llm_engine_utils.iterators import skip_prefix
from mistral_common.protocol.instruct.messages import SystemMessage, UserMessage from mistral_common.protocol.instruct.messages import SystemMessage, UserMessage
from mistral_common.protocol.instruct.request import ChatCompletionRequest from mistral_common.protocol.instruct.request import ChatCompletionRequest
from mistral_common.tokens.tokenizers.mistral import MistralTokenizer from mistral_common.tokens.tokenizers.mistral import MistralTokenizer
from mistralai import Mistral
from . import LlmEngine from pathlib import Path
from ..util import skip_prefix from typing import Iterator
class MistralLlmEngine(LlmEngine): class MistralLlmEngine(LlmEngine):
def __init__( def __init__(
self, self,
model: str, model: str,
temperature: float,
token_limit: int,
api_key: str, api_key: str,
token_limit: int,
): ):
self._model = model self._model = model
self._temperature = temperature
self._token_limit = token_limit
self._api_key = api_key self._api_key = api_key
self._token_limit = token_limit
self._client = Mistral(api_key=api_key) self._client = Mistral(api_key=api_key)
self._tokenizer = MistralTokenizer.v3() self._tokenizer = MistralTokenizer.v3()
def infer(self, system_prompt: str, main_context: str, continuation_text: str, should_stop: Callable[[], bool] = lambda: False) -> Iterator[str]: def infer_xml(self, schema: Path, system: str, context: str, prefix: str) -> Iterator[str]:
messages = [ messages = [
{ {
"role": "system", "role": "system",
"content": system_prompt, "content": system,
}, },
{ {
"role": "user", "role": "user",
"content": main_context, "content": context,
}, },
{ {
"role": "assistant", "role": "assistant",
"content": continuation_text, "content": prefix,
"prefix": True, "prefix": True,
}, },
] if continuation_text else [ ] if prefix else [
{ {
"role": "system", "role": "system",
"content": system_prompt, "content": system,
}, },
{ {
"role": "user", "role": "user",
"content": main_context, "content": context,
}, },
] ]
stream_response = self._client.chat.stream( stream_response = self._client.chat.stream(
model=self._model, model=self._model,
messages=messages, messages=messages,
temperature=self._temperature, #temperature=self._temperature,
) )
try: try:
def content_generator(): def content_generator():
for chunk in stream_response: for chunk in stream_response:
if should_stop():
stream_response.response.close()
break
if content := chunk.data.choices[0].delta.content: if content := chunk.data.choices[0].delta.content:
yield content yield content
yield from skip_prefix(content_generator(), continuation_text) yield from skip_prefix(content_generator(), prefix)
finally: finally:
stream_response.response.close() stream_response.response.close()
def token_count(self, system_prompt: str, main_context: str) -> int: def token_count(self, system: str, context: str) -> int:
messages = [ messages = [
SystemMessage(content=system_prompt), SystemMessage(content=system),
UserMessage(content=main_context), UserMessage(content=context),
] ]
tokenized = self._tokenizer.encode_chat_completion( tokenized = self._tokenizer.encode_chat_completion(
@@ -80,4 +75,4 @@ class MistralLlmEngine(LlmEngine):
return len(tokenized.tokens) return len(tokenized.tokens)
def token_limit(self) -> int: def token_limit(self) -> int:
return self._token_limit return self._token_limit

View File

@@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=42", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "mistral_train"
version = "0.1.0"
requires-python = ">=3.8"
dependencies = [
"llm_engine_utils[dataset] @ file:///root/sia/lib/llm_engine_utils",
"mistral-common>=1.0.0",
"mistralai>=0.0.7",
"python-dotenv>=1.0.0",
"requests>=2.28.0",
]
[project.scripts]
mistral_train = "mistral_train.__main__:main"

View File

@@ -1,54 +1,11 @@
#!/root/venvs/train/bin/python
"""
Script for fine-tuning Mistral models for SIA using the Mistral API.
"""
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
import argparse
import json import json
import os import os
import sys import sys
import tempfile import tempfile
import requests import requests
# Import from our shared library from .config import Config
from .util import TrainingParams, DatasetCreator
@dataclass
class Config:
def __init__(self):
parser = argparse.ArgumentParser(description='Train SIA model using Mistral API')
parser.add_argument(
'--config',
type=Path,
default=Path('/root/sia/training/config.yaml'),
help='Path to config file'
)
parser.add_argument(
'--model',
type=str,
default='mistral-large-latest',
help='Base model for fine-tuning'
)
parser.add_argument(
'--api-key',
type=str,
default=os.environ.get('SIA_MISTRAL_API_KEY'),
help='Mistral API key'
)
self.args = parser.parse_args()
@property
def config_path(self) -> Path:
return self.args.config
@property
def model(self) -> str:
return self.args.model
@property
def api_key(self) -> str:
return self.args.api_key
def upload_file(api_key: str, file_path: Path) -> str: def upload_file(api_key: str, file_path: Path) -> str:
"""Upload a file to the Mistral API and return the file ID""" """Upload a file to the Mistral API and return the file ID"""
@@ -97,9 +54,6 @@ def start_finetune_job(api_key: str, model: str, file_id: str, params: sia_train
def main(): def main():
config = Config() config = Config()
if not config.api_key:
print("Error: Mistral API key not found. Set SIA_MISTRAL_API_KEY environment variable.")
return 1
training_data, train_params, commit_hash = sia_train_lib.prepare_training_data(config.config_path) training_data, train_params, commit_hash = sia_train_lib.prepare_training_data(config.config_path)

View File

@@ -0,0 +1,38 @@
from pathlib import Path
import argparse
import os
class Config:
def __init__(self):
parser = argparse.ArgumentParser(description='Train SIA model using Mistral API')
parser.add_argument(
'--config',
type=Path,
default=Path('/root/sia/training/config.yaml'),
help='Path to config file'
)
parser.add_argument(
'--model',
type=str,
default='mistral-large-latest',
help='Base model for fine-tuning'
)
parser.add_argument(
'--api-key',
type=str,
default=os.environ.get('SIA_MISTRAL_API_KEY'),
help='Mistral API key'
)
self.args = parser.parse_args()
@property
def config_path(self) -> Path:
return self.args.config
@property
def model(self) -> str:
return self.args.model
@property
def api_key(self) -> str:
return self.args.api_key

View File

@@ -1,15 +0,0 @@
#!/bin/bash
set -e
SIA_DIR="/root/sia"
OUTPUT_DIR="${1:-/root/models/$(cd "$SIA_DIR" && git rev-parse HEAD)}"
if [ -n "$(cd "$SIA_DIR" && git status --porcelain)" ]; then
echo "Uncommitted changes in SIA directory"
#exit 1
fi
mkdir -p "$OUTPUT_DIR"
/root/venvs/train/bin/python -m train.qwq --output-dir "$OUTPUT_DIR"

View File

@@ -1,68 +0,0 @@
# SIA Training Tool
This tool provides command-line utilities for fine-tuning SIA's language models.
## Supported Models
- DeepSeek R1 models (including distilled versions)
- Mistral models
## Commands
### train_deepseek
Fine-tune DeepSeek models using Unsloth optimization.
```bash
train_deepseek --base-model deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B --output-dir /root/models/DeepSeek-R1-Distill-Qwen-1.5B
```
Options:
- `--config`: Path to training configuration file (default: /root/sia/training/config.yaml)
- `--base-model`: HuggingFace model ID for the base model (required)
- `--output-dir`: Directory to save model (required)
- `--api-key`: HuggingFace API key (optional, will use SIA_HF_API_KEY)
### train_mistral
Fine-tune Mistral models using Mistral's API.
```bash
train_mistral --model mistral-large-latest
```
Options:
- `--config`: Path to training configuration file (default: /root/sia/training/config.yaml)
- `--model`: Base model name (default: mistral-large-latest)
- `--api-key`: Mistral API key (optional, will use SIA_MISTRAL_API_KEY)
## Configuration Format
The training configuration file (YAML) should include:
```yaml
model:
system_prompt_path: "/root/sia/system_prompt.md"
action_schema: "/root/sia/action_schema.xsd"
params:
learning_rate: 1e-5
epochs: 3
data:
- "/root/sia/training/data_dir1/"
- "/root/sia/training/data_dir2/"
```
## Data Format
Training data should be XML files in the following format:
```xml
<iteration system_prompt_hash="..." action_schema_hash="...">
<context>
<!-- XML context -->
</context>
<response>
<!-- Model response -->
</response>
</iteration>
```

View File

@@ -1,4 +0,0 @@
node_modules
dist
.git
*.log

5
web/.gitignore vendored
View File

@@ -1,5 +0,0 @@
.DS_Store
coverage
dist
node_modules
package-lock.json

View File

@@ -1,12 +1,13 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SIA Web Interface</title> <title>SIA Web Interface</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/index.jsx"></script> <script type="module" src="/src/index.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -1,43 +1,27 @@
{ {
"name": "sia-web", "name": "sia-web-interface",
"private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview"
"test": "jest",
"test:watch": "jest --watch"
}, },
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.6.0", "@monaco-editor/react": "^4.6.0",
"@radix-ui/react-alert-dialog": "^1.0.5", "monaco-editor": "^0.52.0",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-tabs": "^1.0.4",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"lucide-react": "^0.263.1", "lucide-react": "^0.263.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0"
"tailwind-merge": "^2.2.0",
"tailwindcss-animate": "^1.0.7"
}, },
"devDependencies": { "devDependencies": {
"@babel/preset-env": "^7.23.9", "@types/react": "^18.2.0",
"@babel/preset-react": "^7.23.9", "@types/react-dom": "^18.2.0",
"@testing-library/jest-dom": "^6.4.2", "@vitejs/plugin-react": "^4.2.0",
"@testing-library/react": "^14.2.1", "autoprefixer": "^10.4.14",
"@types/react": "^18.2.55", "postcss": "^8.4.31",
"@types/react-dom": "^18.2.19", "tailwindcss": "^3.3.5",
"@vitejs/plugin-react": "^4.2.1", "typescript": "^5.2.0",
"autoprefixer": "^10.4.17", "vite": "^6.3.5"
"babel-jest": "^29.7.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"vite": "^5.1.0"
} }
} }

77
web/src/App.tsx Normal file
View File

@@ -0,0 +1,77 @@
import React, { useState } from 'react';
import useScreenSize from './hooks/useScreenSize';
import { MobileControls, Ribbon, MobileTaskbar } from './components/Layout';
import { ResponseEditor } from './components/Response';
import { MemoryEditor } from './components/Memory';
import { ChatWindow } from './components/Chat';
import { WebSocketProvider } from './contexts/WebSocketContext';
import { MonacoProvider } from './contexts/MonacoContext';
const App: React.FC = () => {
const [mobileView, setMobileView] = useState<'controls' | 'memory' | 'response' | 'chat'>('controls');
const screenSize = useScreenSize();
return (
<WebSocketProvider>
<MonacoProvider>
<div className="h-screen bg-gray-100 flex flex-col">
<Ribbon />
<div className="flex-1 flex flex-col md:flex-row overflow-hidden">
<div className={`
${screenSize === 'mobile' ? 'flex-1' : 'hidden'}
${mobileView !== 'controls' && screenSize === 'mobile' ? 'hidden' : ''}
overflow-auto pb-16
`}>
<MobileControls />
</div>
<div className={`
${screenSize === 'mobile' ? 'flex-1' : 'hidden'}
${mobileView !== 'memory' && screenSize === 'mobile' ? 'hidden' : ''}
overflow-auto pb-16
`}>
<MemoryEditor className="h-full" />
</div>
<div className={`
${screenSize === 'mobile' ? 'flex-1' : 'hidden'}
${mobileView !== 'response' && screenSize === 'mobile' ? 'hidden' : ''}
overflow-auto pb-16
`}>
<ResponseEditor className="h-full" />
</div>
<div className={`
${screenSize === 'mobile' ? 'flex-1' : 'hidden'}
${mobileView !== 'chat' && screenSize === 'mobile' ? 'hidden' : ''}
overflow-auto pb-16
`}>
<ChatWindow className="h-full" />
</div>
<div className={`${screenSize === 'tablet' ? 'flex-1' : 'hidden'} flex flex-col overflow-hidden`}>
<div className="h-1/2 border-b overflow-auto">
<MemoryEditor className="h-full" />
</div>
<div className="h-1/2 overflow-hidden">
<ResponseEditor className="h-full" />
</div>
</div>
<div className={`${screenSize === 'desktop' ? 'w-1/2' : 'hidden'} border-r overflow-auto`}>
<MemoryEditor className="h-full" />
</div>
<div className={`${screenSize === 'desktop' ? 'w-1/2' : 'hidden'} overflow-hidden`}>
<ResponseEditor className="h-full" />
</div>
</div>
{screenSize === 'mobile' && <MobileTaskbar mobileView={mobileView} setMobileView={setMobileView} />}
{screenSize !== 'mobile' && <ChatWindow />}
</div>
</MonacoProvider>
</WebSocketProvider>
);
};
export default App;

View File

@@ -1,336 +0,0 @@
import React, { useState, useEffect } from 'react';
import { ContentEditor } from '@/components/editors/ContentEditor';
import { FloatingButton } from '@/components/FloatingButton';
import { Header, Tabs } from '@/components/Header';
import { MemoryEditor } from '@/components/editors/MemoryEditor';
import { Sidebar } from '@/components/Sidebar';
import { StandardEditor } from '@/components/editors/StandardEditor';
import { useWebSocket, WebSocketState } from '@/hooks/useWebSocket';
export const LlmState = {
IDLE: 'IDLE',
INFERENCE: 'INFERENCE'
};
const App = () => {
// Editor content state
const [generatedContext, setGeneratedContext] = useState('');
const [modifiedContext, setModifiedContext] = useState('');
const [generatedResponse, setGeneratedResponse] = useState('');
const [modifiedResponse, setModifiedResponse] = useState('');
const [input, setInput] = useState('');
const [output, setOutput] = useState('');
// LLM state
const [llms, setLlms] = useState({});
const [activeLlm, setActiveLlm] = useState(null);
// Auto approver state
const [autoApproverConfig, setAutoApproverConfig] = useState({
context_enabled: false,
response_enabled: false,
context_timeout: 5.0,
response_timeout: 10.0,
llm_name: null
});
// UI state
const [activeTab, setActiveTab] = useState(Tabs.CONTEXT);
const [showDiff, setShowDiff] = useState(false);
const [showSidebar, setShowSidebar] = useState(false);
const [autoScroll, setAutoScroll] = useState(false);
// WebSocket connections
const wsRoot = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`
const llmWs = useWebSocket(`${wsRoot}/llms`);
const contextWs = useWebSocket(`${wsRoot}/context`);
const responseWs = useWebSocket(`${wsRoot}/response`);
const stdoutWs = useWebSocket(`${wsRoot}/stdout`);
const autoApproverWs = useWebSocket(`${wsRoot}/auto_approver`);
const memoryWs = useWebSocket(`${wsRoot}/memory`);
const [entries, setEntries] = useState([]);
const [wsState, setWsState] = useState(WebSocketState.DISCONNECTED);
// Handle llm state changes
useEffect(() => {
llmWs.addMessageHandler((data) => {
setLlms(prevLlms => ({
...prevLlms,
[data.llm]: data.state
}));
});
}, []);
// Handle context changes
useEffect(() => {
contextWs.addMessageHandler((data) => {
setModifiedContext(data.context);
if (data.generated) {
setGeneratedContext(data.context);
}
});
}, []);
// Handle response changes
useEffect(() => {
responseWs.addMessageHandler((data) => {
setModifiedResponse(data.response);
setGeneratedResponse(data.response);
});
}, []);
// Handle stdout changes
useEffect(() => {
stdoutWs.addMessageHandler((data) => {
setOutput(data.output);
});
}, []);
// Handle auto approver config updates
useEffect(() => {
autoApproverWs.addMessageHandler((data) => {
setAutoApproverConfig(data.config);
});
}, []);
// Handle memory state changes
useEffect(() => {
memoryWs.addMessageHandler((data) => {
if (data.type === 'memory_state') {
setEntries(data.entries);
}
});
}, []);
// Initialize active llm
useEffect(() => {
if (activeLlm === null && Object.keys(llms).length > 0) {
setActiveLlm(Object.keys(llms)[0]);
}
}, [llms, activeLlm]);
const handleNextLlm = () => {
const llmNames = Object.keys(llms);
const currentIndex = llmNames.indexOf(activeLlm);
const nextIndex = (currentIndex + 1) % llmNames.length;
setActiveLlm(llmNames[nextIndex]);
};
const handleApprove = () => {
fetch('/api/response/approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ response: modifiedResponse })
});
};
const handleInference = () => {
fetch(`/api/inference/${activeLlm}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
response: modifiedResponse,
context: modifiedContext,
})
});
};
const handleStop = () => {
// We still specify which LLM to stop inference for
fetch(`/api/inference/${activeLlm}/stop`, {
method: 'POST'
});
};
const handleSendInput = () => {
if (input.trim()) {
fetch('/api/input', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: input })
});
setInput('');
}
};
const handleClearOutput = () => {
fetch('/api/clear', { method: 'POST' });
};
const handleContextEdit = (context) => {
setContextDirty(true);
// When context is edited, we need to reset all LLM states to IDLE
// since responses are no longer valid
const resetLlms = {};
for (const llm of Object.keys(llms)) {
resetLlms[llm] = LlmState.IDLE;
}
setLlms(resetLlms);
setModifiedContext(context);
};
const handleResponseEdit = (response) => {
setModifiedResponse(response);
};
const handleAutoApproverConfigChange = async (config) => {
try {
const response = await fetch('/api/auto_approver/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (!response.ok) {
throw new Error('Failed to update auto approver config');
}
} catch (error) {
console.error('Error updating auto approver config:', error);
}
};
const handleCreateEntry = async (entry) => {
const response = await fetch('/api/memory/entry', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entry)
});
if (!response.ok) throw new Error('Failed to create entry');
};
const handleSaveEntry = async (id, entry) => {
const response = await fetch(`/api/memory/entry/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entry)
});
if (!response.ok) throw new Error('Failed to update entry');
};
const handleDeleteEntry = async (id) => {
const response = await fetch(`/api/memory/entry/${id}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete entry');
};
const handleResetEntry = async (id) => {
const response = await fetch(`/api/memory/entry/${id}/reset`, {
method: 'POST'
});
if (!response.ok) throw new Error('Failed to reset entry');
};
const handleUpdateEntry = async (id) => {
const response = await fetch(`/api/memory/entry/${id}/update`, {
method: 'POST'
});
if (!response.ok) throw new Error('Failed to update entry');
};
// Determine active LLM's current state for UI feedback
const activeLlmState = activeLlm ? llms[activeLlm] : null;
// Track overall WebSocket connection state
useEffect(() => {
if (llmWs.wsState === WebSocketState.CONNECTED &&
contextWs.wsState === WebSocketState.CONNECTED &&
responseWs.wsState === WebSocketState.CONNECTED &&
stdoutWs.wsState === WebSocketState.CONNECTED &&
autoApproverWs.wsState === WebSocketState.CONNECTED &&
memoryWs.wsState === WebSocketState.CONNECTED) {
setWsState(WebSocketState.CONNECTED);
} else if (llmWs.wsState === WebSocketState.CONNECTING ||
contextWs.wsState === WebSocketState.CONNECTING ||
responseWs.wsState === WebSocketState.CONNECTING ||
stdoutWs.wsState === WebSocketState.CONNECTING ||
autoApproverWs.wsState === WebSocketState.CONNECTING ||
memoryWs.wsState === WebSocketState.CONNECTING) {
setWsState(WebSocketState.CONNECTING);
} else {
setWsState(WebSocketState.DISCONNECTED);
}
}, [llmWs.wsState, contextWs.wsState, responseWs.wsState, stdoutWs.wsState, autoApproverWs.wsState, memoryWs.wsState]);
return (
<div className="h-screen bg-gray-100 flex flex-col overflow-hidden">
<Header
wsState={wsState}
agentState={activeLlmState}
activeTab={activeTab}
setActiveTab={setActiveTab}
/>
<div className="flex flex-1 overflow-hidden">
<main className="flex-1 p-4 overflow-auto">
{activeTab === Tabs.CONTEXT && (
<ContentEditor
showDiff={showDiff}
originalContent={generatedContext}
modifiedContent={modifiedContext}
onChange={handleContextEdit}
autoScroll={autoScroll}
/>
)}
{activeTab === Tabs.RESPONSE && (
<ContentEditor
showDiff={showDiff}
originalContent={generatedResponse}
modifiedContent={modifiedResponse}
onChange={handleResponseEdit}
/>
)}
{activeTab === Tabs.INPUT && (
<StandardEditor
content={input}
onChange={value => setInput(value)}
language="plaintext"
/>
)}
{activeTab === Tabs.OUTPUT && (
<StandardEditor
content={output}
readOnly={true}
language="plaintext"
/>
)}
{activeTab === Tabs.MEMORY && (
<MemoryEditor
entries={entries}
onCreateEntry={handleCreateEntry}
onSaveEntry={handleSaveEntry}
onDeleteEntry={handleDeleteEntry}
onResetEntry={handleResetEntry}
onUpdateEntry={handleUpdateEntry}
/>
)}
</main>
<Sidebar
showSidebar={showSidebar}
llms={llms}
activeLlm={activeLlm}
llmState={activeLlmState}
showDiff={showDiff}
autoScroll={autoScroll}
onAutoScrollChange={setAutoScroll}
input={input}
output={output}
autoApproverConfig={autoApproverConfig}
onApprove={handleApprove}
onInference={handleInference}
onStop={handleStop}
onToggleDiff={() => setShowDiff(!showDiff)}
onSendInput={handleSendInput}
onClearOutput={handleClearOutput}
onLlmChange={setActiveLlm}
onNextLlm={handleNextLlm}
onAutoApproverConfigChange={handleAutoApproverConfigChange}
/>
<FloatingButton onClick={() => setShowSidebar(!showSidebar)} />
</div>
</div>
);
};
export default App;

View File

@@ -0,0 +1,62 @@
import React, { useState, useRef, KeyboardEvent } from 'react';
import { Send } from 'lucide-react';
interface ChatInputProps {
onSendMessage: (message: string) => void;
disabled?: boolean;
}
const ChatInput: React.FC<ChatInputProps> = ({ onSendMessage, disabled = false }) => {
const [message, setMessage] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const handleSend = () => {
if (message.trim() && !disabled) {
onSendMessage(message.trim());
setMessage('');
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, 0);
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSend();
}
};
return (
<div className="border-t p-3 bg-white">
<div className="flex gap-2">
<input
ref={inputRef}
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
className="flex-1 px-3 py-2 border rounded-lg text-sm"
disabled={disabled}
autoFocus
/>
<button
onClick={handleSend}
className={`px-3 py-2 rounded-lg ${
disabled || !message.trim()
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-blue-500 hover:bg-blue-600 text-white'
}`}
disabled={disabled || !message.trim()}
>
<Send className="w-4 h-4" />
</button>
</div>
</div>
);
};
export default ChatInput;

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { Trash, CheckCheck } from 'lucide-react';
import { ChatMessage as ChatMessageType } from '../../types';
import { useWebSocketContext } from '../../contexts/WebSocketContext';
type ChatMessageProps = ChatMessageType & {
onDelete: (id: string) => void;
};
const ChatMessage: React.FC<ChatMessageProps> = ({
id,
content,
message_type,
onDelete
}) => {
const { lastReadTimestamp } = useWebSocketContext();
const isUser = message_type === 'user';
const isRead = lastReadTimestamp !== null && id <= lastReadTimestamp;
return (
<div className={`flex mb-3 ${isUser ? 'justify-end' : 'justify-start'}`}>
<div className={`min-w-[200px] max-w-xs sm:max-w-sm px-3 py-2 rounded-lg ${
isUser ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-900'
}`}>
<div className={`flex items-center justify-between text-xs mb-1 ${
isUser ? 'text-blue-100' : 'text-gray-500'
}`}>
<span>{id}</span>
<div className="flex items-center gap-1">
{isUser && (
<span className="flex items-center" title={isRead ? "Read" : "Unread"}>
<CheckCheck className={`w-3 h-3 ${isRead ? 'opacity-100' : 'opacity-30'}`} />
</span>
)}
<button
onClick={() => onDelete(id)}
className={`hover:opacity-70 ${isUser ? 'text-blue-100' : 'text-gray-500'}`}
>
<Trash className="w-3 h-3" />
</button>
</div>
</div>
<p className="text-sm whitespace-pre-wrap break-words">{content}</p>
</div>
</div>
);
};
export default ChatMessage;

View File

@@ -0,0 +1,172 @@
import React, { useState, useRef, useEffect } from 'react';
import { MessageCircle, X, GripHorizontal } from 'lucide-react';
import ChatMessage from './ChatMessage';
import ChatInput from './ChatInput';
import useScreenSize from '../../hooks/useScreenSize';
import { useWebSocketContext } from '../../contexts/WebSocketContext';
import { api } from '../../services/api';
interface ChatWindowProps {
className?: string;
}
const ChatWindow: React.FC<ChatWindowProps> = ({ className = '' }) => {
const screenSize = useScreenSize();
const [chatOpen, setChatOpen] = useState(false);
const [chatHeight, setChatHeight] = useState(400);
const [chatWidth, setChatWidth] = useState(360);
const [isDragging, setIsDragging] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const startHeightRef = useRef<number>(0);
const startWidthRef = useRef<number>(0);
const startPosRef = useRef<{ x: number, y: number }>({ x: 0, y: 0 });
const { messages = [], isConnected } = useWebSocketContext();
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleResizeStart = (e: React.MouseEvent) => {
e.preventDefault();
setIsDragging(true);
startHeightRef.current = chatHeight;
startWidthRef.current = chatWidth;
startPosRef.current = { x: e.clientX, y: e.clientY };
};
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging) return;
const deltaY = startPosRef.current.y - e.clientY;
const deltaX = startPosRef.current.x - e.clientX;
const newHeight = Math.max(300, startHeightRef.current + deltaY);
const newWidth = Math.max(300, startWidthRef.current + deltaX);
setChatHeight(newHeight);
setChatWidth(newWidth);
};
const handleMouseUp = () => {
setIsDragging(false);
};
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging]);
const handleSendMessage = async (content: string) => {
try {
setIsLoading(true);
await api.addChatMessage({ message: content });
} catch (error) {
console.error('Failed to send message:', error);
} finally {
setIsLoading(false);
}
};
const handleDeleteMessage = async (id: string) => {
try {
await api.deleteChatMessage(id);
} catch (error) {
console.error('Failed to delete message:', error);
}
};
if (screenSize === 'mobile') {
return (
<div className={`h-full flex flex-col bg-white ${className}`}>
<div className="px-4 py-2 border-b bg-gray-50 flex justify-between items-center">
<h3 className="font-semibold text-sm">Chat</h3>
</div>
<div className="flex-1 overflow-y-auto p-4">
{messages.length === 0 ? (
<div className="flex items-center justify-center h-full text-gray-400">
<p>No messages yet</p>
</div>
) : (
messages.map((message) => (
<ChatMessage
key={message.id}
{...message}
onDelete={handleDeleteMessage}
/>
))
)}
<div ref={messagesEndRef} />
</div>
<ChatInput onSendMessage={handleSendMessage} disabled={isLoading || !isConnected} />
</div>
);
}
return (
<>
{!chatOpen && (
<button
onClick={() => setChatOpen(true)}
className="fixed bottom-4 right-4 w-14 h-14 bg-blue-500 text-white rounded-full shadow-lg hover:bg-blue-600 flex items-center justify-center z-40"
aria-label="Open chat"
>
<MessageCircle className="w-6 h-6" />
</button>
)}
{chatOpen && (
<div
className="fixed bottom-0 right-4 bg-white shadow-2xl rounded-t-lg flex flex-col z-40"
style={{ height: `${chatHeight}px`, width: `${chatWidth}px` }}
>
<div
className="flex items-center justify-between p-3 border-b bg-gray-50 rounded-t-lg cursor-move"
onMouseDown={handleResizeStart}
>
<div className="flex items-center gap-2">
<GripHorizontal className="w-4 h-4 text-gray-400" />
<h3 className="font-semibold text-sm">Chat</h3>
</div>
<button
onClick={() => setChatOpen(false)}
className="text-gray-500 hover:text-gray-700 p-1"
aria-label="Close chat"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{messages.length === 0 ? (
<div className="flex items-center justify-center h-full text-gray-400">
<p>No messages yet</p>
</div>
) : (
messages.map((message) => (
<ChatMessage
key={message.id}
{...message}
onDelete={handleDeleteMessage}
/>
))
)}
<div ref={messagesEndRef} />
</div>
<ChatInput onSendMessage={handleSendMessage} disabled={isLoading || !isConnected} />
</div>
)}
</>
);
};
export default ChatWindow;

View File

@@ -0,0 +1,4 @@
// src/components/Chat/index.ts
export { default as ChatWindow } from './ChatWindow';
export { default as ChatMessage } from './ChatMessage';
export { default as ChatInput } from './ChatInput';

View File

@@ -0,0 +1,67 @@
import React, { useState } from 'react';
import { useWebSocketContext } from '../../contexts/WebSocketContext';
interface AgentSectionProps {
className?: string;
}
const AgentSection: React.FC<AgentSectionProps> = ({ className = '' }) => {
const { state, isConnected, llms, setActiveLLM } = useWebSocketContext();
const [isLoading, setIsLoading] = useState(false);
const handleLLMChange = async (event: React.ChangeEvent<HTMLSelectElement>) => {
const newLLM = event.target.value;
try {
setIsLoading(true);
await setActiveLLM(newLLM);
} catch (error) {
console.error('Failed to set active LLM:', error);
} finally {
setIsLoading(false);
}
};
const stateColors = {
'IDLE': 'bg-green-100 text-green-800',
'INFERENCE': 'bg-yellow-100 text-yellow-800',
'PROCESSING_RESPONSE': 'bg-blue-100 text-blue-800'
};
return (
<div className={className}>
<h4 className="text-xs font-semibold text-gray-700 mb-1">Agent</h4>
<div className="grid grid-cols-2 gap-x-2 gap-y-1 items-center">
<span className="text-xs text-gray-600">Connection:</span>
<span className={`text-xs px-2 py-0.5 rounded-full text-center inline-block min-w-[85px] ${
isConnected ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{isConnected ? 'Connected' : 'Disconnected'}
</span>
<span className="text-xs text-gray-600">State:</span>
<span className={`text-xs px-2 py-0.5 rounded-full text-center inline-block min-w-[85px] ${stateColors[state.agentState]}`}>
{state.agentState}
</span>
<span className="text-xs text-gray-600">Engine:</span>
<select
value={state.activeLLM}
onChange={handleLLMChange}
disabled={isLoading || !isConnected || llms.length === 0}
className="w-full min-w-[85px] px-1 py-0.5 border rounded text-xs disabled:opacity-50 disabled:cursor-not-allowed"
>
{llms.length === 0 && (
<option value="">Loading...</option>
)}
{llms.map(llm => (
<option key={llm.name} value={llm.name}>
{llm.name}
</option>
))}
</select>
</div>
</div>
);
};
export default AgentSection;

View File

@@ -0,0 +1,102 @@
import React, { useState } from 'react';
import { useWebSocketContext } from '../../contexts/WebSocketContext';
interface AutoApproverSectionProps {
className?: string;
}
const AutoApproverSection: React.FC<AutoApproverSectionProps> = ({ className = '' }) => {
const { autoApprover, setAutoApproverConfig } = useWebSocketContext();
const [isLoading, setIsLoading] = useState(false);
const handleCheckboxChange = async (field: 'context_enabled' | 'response_enabled') => {
try {
setIsLoading(true);
await setAutoApproverConfig({ [field]: !autoApprover[field] });
} catch (error) {
console.error(`Failed to update ${field}:`, error);
} finally {
setIsLoading(false);
}
};
const handleTimeoutChange = async (field: 'context_timeout' | 'response_timeout', value: string) => {
const numValue = parseFloat(value);
if (!isNaN(numValue) && numValue >= 0) {
try {
setIsLoading(true);
await setAutoApproverConfig({ [field]: numValue });
} catch (error) {
console.error(`Failed to update ${field}:`, error);
} finally {
setIsLoading(false);
}
}
};
return (
<div className={className}>
<h4 className="text-xs font-semibold text-gray-700 mb-1">Auto Approver</h4>
<div className="space-y-1">
<div className="flex gap-2">
<label className="flex items-center gap-1 text-xs cursor-pointer">
<input
type="checkbox"
className="w-3 h-3"
checked={autoApprover.context_enabled}
onChange={() => handleCheckboxChange('context_enabled')}
disabled={isLoading}
/>
Context
</label>
<label className="flex items-center gap-1 text-xs cursor-pointer">
<input
type="checkbox"
className="w-3 h-3"
checked={autoApprover.response_enabled}
onChange={() => handleCheckboxChange('response_enabled')}
disabled={isLoading}
/>
Response
</label>
</div>
<div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-600">Context:</span>
<div className="flex items-center">
<input
type="number"
value={autoApprover.context_timeout}
onChange={(e) => handleTimeoutChange('context_timeout', e.target.value)}
step="0.1"
min="0"
className="w-28 sm:w-16 px-1 py-0.5 border rounded text-xs text-right"
disabled={isLoading || autoApprover.context_enabled}
/>
<span className="text-xs text-gray-500 ml-1">s</span>
</div>
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-xs text-gray-600">Response:</span>
<div className="flex items-center">
<input
type="number"
value={autoApprover.response_timeout}
onChange={(e) => handleTimeoutChange('response_timeout', e.target.value)}
step="0.1"
min="0"
className="w-28 sm:w-16 px-1 py-0.5 border rounded text-xs text-right"
disabled={isLoading || autoApprover.response_enabled}
/>
<span className="text-xs text-gray-500 ml-1">s</span>
</div>
</div>
</div>
</div>
</div>
);
};
export default AutoApproverSection;

View File

@@ -0,0 +1,91 @@
import React, { useState } from 'react';
import { Play, StopCircle, Check } from 'lucide-react';
import { Button } from '../shared';
import { api } from '../../services/api';
import { useWebSocketContext } from '../../contexts/WebSocketContext';
interface LLMSectionProps {
className?: string;
}
const LLMSection: React.FC<LLMSectionProps> = ({ className = '' }) => {
const { state } = useWebSocketContext();
const [isInferring, setIsInferring] = useState(false);
const [isStopping, setIsStopping] = useState(false);
const [isApproving, setIsApproving] = useState(false);
const isInferenceBlocked = state.agentState === 'INFERENCE' || state.agentState === 'PROCESSING_RESPONSE';
const handleInfer = async () => {
try {
setIsInferring(true);
await api.startInference();
} catch (error) {
console.error('Failed to start inference:', error);
} finally {
setIsInferring(false);
}
};
const handleStop = async () => {
try {
setIsStopping(true);
await api.stopInference();
} catch (error) {
console.error('Failed to stop inference:', error);
} finally {
setIsStopping(false);
}
};
const handleApprove = async () => {
try {
setIsApproving(true);
await api.approveResponse();
} catch (error) {
console.error('Failed to approve response:', error);
} finally {
setIsApproving(false);
}
};
return (
<div className={className}>
<h4 className="text-xs font-semibold text-gray-700 mb-1">LLM</h4>
<div className="space-y-1">
<Button
icon={Play}
variant="primary"
size="sm"
className="w-full"
onClick={handleInfer}
disabled={isInferring || isInferenceBlocked}
>
Infer
</Button>
<Button
icon={StopCircle}
variant="danger"
size="sm"
className="w-full"
onClick={handleStop}
disabled={isStopping || state.agentState !== 'INFERENCE'}
>
Stop
</Button>
<Button
icon={Check}
variant="success"
size="sm"
className="w-full"
onClick={handleApprove}
disabled={isApproving || state.agentState === 'PROCESSING_RESPONSE'}
>
Approve
</Button>
</div>
</div>
);
};
export default LLMSection;

View File

@@ -0,0 +1,126 @@
import React, { useState, useRef } from 'react';
import { FileText, Trash, Save } from 'lucide-react';
import { Button } from '../shared';
import { api } from '../../services/api';
interface MemorySectionProps {
className?: string;
}
const MemorySection: React.FC<MemorySectionProps> = ({ className = '' }) => {
const [isLoading, setIsLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleLoad = async () => {
fileInputRef.current?.click();
};
const handleFileLoad = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
setIsLoading(true);
const content = await file.text();
await api.loadIteration(content);
console.log('Iteration loaded successfully');
} catch (error) {
console.error('Failed to load iteration:', error);
alert('Failed to load iteration file');
} finally {
setIsLoading(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const handleClear = async () => {
if (!window.confirm('Are you sure you want to clear all memory entries?')) {
return;
}
try {
setIsLoading(true);
const memoryState = await api.getMemory();
const entries = memoryState.entries || [];
for (const entry of entries) {
await api.deleteMemoryEntry(entry.id);
}
console.log('Memory cleared successfully');
} catch (error) {
console.error('Failed to clear memory:', error);
alert('Failed to clear memory');
} finally {
setIsLoading(false);
}
};
const handleUpdate = async () => {
try {
setIsLoading(true);
const memoryState = await api.getMemory();
const entries = memoryState.entries || [];
for (const entry of entries) {
await api.updateMemoryEntry(entry.id);
}
console.log('Memory updated successfully');
} catch (error) {
console.error('Failed to update memory:', error);
alert('Failed to update memory');
} finally {
setIsLoading(false);
}
};
return (
<div className={className}>
<h4 className="text-xs font-semibold text-gray-700 mb-1">Memory</h4>
<div className="space-y-1">
<input
ref={fileInputRef}
type="file"
accept=".xml"
onChange={handleFileLoad}
style={{ display: 'none' }}
/>
<Button
icon={FileText}
variant="secondary"
size="sm"
className="w-full"
onClick={handleLoad}
disabled={isLoading}
>
Load
</Button>
<Button
icon={Trash}
variant="secondary"
size="sm"
className="w-full"
onClick={handleClear}
disabled={isLoading}
>
Clear
</Button>
<Button
icon={Save}
variant="secondary"
size="sm"
className="w-full"
onClick={handleUpdate}
disabled={isLoading}
>
Update
</Button>
</div>
</div>
);
};
export default MemorySection;

View File

@@ -0,0 +1,4 @@
export { default as AgentSection } from './AgentSection';
export { default as AutoApproverSection } from './AutoApproverSection';
export { default as LLMSection } from './LLMSection';
export { default as MemorySection } from './MemorySection';

View File

@@ -1,14 +0,0 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Menu } from 'lucide-react';
export const FloatingButton = ({ onClick }) => (
<Button
variant="secondary"
size="lg"
className="fixed bottom-4 right-4 rounded-full shadow-lg md:hidden z-[60]"
onClick={onClick}
>
<Menu className="h-6 w-6" />
</Button>
);

View File

@@ -1,58 +0,0 @@
import React from 'react';
import { WebSocketState } from '../hooks/useWebSocket';
export const Tabs = {
CONTEXT: 'CONTEXT',
RESPONSE: 'RESPONSE',
INPUT: 'INPUT',
OUTPUT: 'OUTPUT',
MEMORY: 'MEMORY'
};
export const Header = ({
wsState,
agentState,
activeTab,
setActiveTab,
}) => (
<header className="bg-white shadow-sm">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center space-x-4">
<h1 className="text-xl font-semibold">SIA Control Interface</h1>
</div>
<div className="flex items-center space-x-2">
<div className={`px-3 py-1 rounded-full text-sm ${
wsState === WebSocketState.CONNECTED
? 'bg-green-100 text-green-800'
: wsState === WebSocketState.CONNECTING
? 'bg-yellow-100 text-yellow-800'
: 'bg-red-100 text-red-800'
}`}>
{wsState}
</div>
{agentState && (
<div className="px-3 py-1 rounded-full bg-blue-100 text-blue-800 text-sm">
{agentState}
</div>
)}
</div>
</div>
<div className="border-t">
<div className="flex px-4">
{Object.values(Tabs).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 font-medium text-sm border-b-2 ${
activeTab === tab
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{tab.charAt(0).toUpperCase() + tab.slice(1).toLowerCase()}
</button>
))}
</div>
</div>
</header>
);

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { AgentSection, LLMSection, AutoApproverSection, MemorySection } from '../Controls';
const MobileControls: React.FC = () => {
return (
<div className="p-4 space-y-4 h-full overflow-y-auto bg-white">
<h3 className="text-lg font-semibold mb-4">Agent Controls</h3>
<AgentSection className="pb-4 border-b" />
<div className="pt-4">
<LLMSection className="pb-4 border-b" />
</div>
<div className="pt-4">
<AutoApproverSection className="pb-4 border-b" />
</div>
<div className="pt-4">
<MemorySection />
</div>
</div>
);
};
export default MobileControls;

View File

@@ -0,0 +1,53 @@
import React from 'react';
interface MobileTaskbarProps {
mobileView: 'controls' | 'memory' | 'response' | 'chat';
setMobileView: (view: 'controls' | 'memory' | 'response' | 'chat') => void;
}
const MobileTaskbar: React.FC<MobileTaskbarProps> = ({ mobileView, setMobileView }) => {
return (
<div className="fixed bottom-0 left-0 right-0 bg-white border-t z-10">
<div className="flex">
<button
onClick={() => setMobileView('controls')}
className={`flex-1 flex flex-col items-center justify-center py-2 ${
mobileView === 'controls' ? 'text-blue-600' : 'text-gray-600'
}`}
>
<span className="text-lg"></span>
<span className="text-xs mt-1">Controls</span>
</button>
<button
onClick={() => setMobileView('memory')}
className={`flex-1 flex flex-col items-center justify-center py-2 ${
mobileView === 'memory' ? 'text-blue-600' : 'text-gray-600'
}`}
>
<span className="text-lg">🧠</span>
<span className="text-xs mt-1">Memory</span>
</button>
<button
onClick={() => setMobileView('response')}
className={`flex-1 flex flex-col items-center justify-center py-2 ${
mobileView === 'response' ? 'text-blue-600' : 'text-gray-600'
}`}
>
<span className="text-lg">📄</span>
<span className="text-xs mt-1">Response</span>
</button>
<button
onClick={() => setMobileView('chat')}
className={`flex-1 flex flex-col items-center justify-center py-2 ${
mobileView === 'chat' ? 'text-blue-600' : 'text-gray-600'
}`}
>
<span className="text-lg">💬</span>
<span className="text-xs mt-1">Chat</span>
</button>
</div>
</div>
);
};
export default MobileTaskbar;

View File

@@ -0,0 +1,30 @@
import React from 'react';
import useScreenSize from '../../hooks/useScreenSize';
import { AgentSection, LLMSection, AutoApproverSection, MemorySection } from '../Controls';
const Ribbon: React.FC = () => {
const screenSize = useScreenSize();
if (screenSize === 'mobile') return null;
return (
<div className="bg-white border-b">
<div className="flex items-stretch">
<div className="px-3 py-2 border-r">
<AgentSection />
</div>
<div className="px-3 py-2 border-r">
<LLMSection />
</div>
<div className="px-3 py-2 border-r">
<AutoApproverSection />
</div>
<div className="px-3 py-2">
<MemorySection />
</div>
</div>
</div>
);
};
export default Ribbon;

View File

@@ -0,0 +1,3 @@
export { default as MobileControls } from './MobileControls';
export { default as Ribbon } from './Ribbon';
export { default as MobileTaskbar } from './MobileTaskbar';

View File

@@ -0,0 +1,269 @@
import React, { useState } from 'react';
import { useWebSocketContext } from '../../contexts/WebSocketContext';
import { api, EntryTypes, getInitialEntryData } from '../../services/api';
import {
ReasoningEntry,
SingleEntry,
RepeatEntry,
ReadEntry,
WriteEntry,
ParseErrorEntry,
BackgroundEntry
} from './entries';
import {
EntryData,
ReasoningEntryData,
ScriptEntryData,
IOEntryData,
ParseErrorEntryData,
BackgroundEntryData
} from '../../types/entries';
import { Plus } from 'lucide-react';
interface MemoryEditorProps {
className?: string;
}
const MemoryEditor: React.FC<MemoryEditorProps> = ({ className = '' }) => {
const { memory } = useWebSocketContext();
const [isLoading, setIsLoading] = useState(false);
const [showNewEntryMenu, setShowNewEntryMenu] = useState(false);
const handleDelete = async (id: string) => {
if (!window.confirm('Are you sure you want to delete this entry?')) {
return;
}
try {
setIsLoading(true);
await api.deleteMemoryEntry(id);
} catch (error) {
console.error('Failed to delete entry:', error);
alert('Failed to delete entry');
} finally {
setIsLoading(false);
}
};
const handleUpdate = async (id: string) => {
try {
setIsLoading(true);
await api.updateMemoryEntry(id);
} catch (error) {
console.error('Failed to update entry:', error);
alert('Failed to update entry');
} finally {
setIsLoading(false);
}
};
const handleReset = async (id: string) => {
try {
setIsLoading(true);
await api.resetMemoryEntry(id);
} catch (error) {
console.error('Failed to reset entry:', error);
alert('Failed to reset entry');
} finally {
setIsLoading(false);
}
};
const handleSaveEntry = async <T extends EntryData>(id: string, data: T) => {
try {
setIsLoading(true);
await api.saveMemoryEntry(id, data);
} catch (error) {
console.error('Failed to save entry:', error);
alert('Failed to save entry');
} finally {
setIsLoading(false);
}
};
const handleCreateEntry = async (type: string) => {
try {
setIsLoading(true);
setShowNewEntryMenu(false);
const entryType = type as typeof EntryTypes[keyof typeof EntryTypes];
const newEntryData = getInitialEntryData(entryType);
await api.createMemoryEntry(newEntryData);
} catch (error) {
console.error('Failed to create entry:', error);
alert('Failed to create entry');
} finally {
setIsLoading(false);
}
};
const renderEntry = (entry: EntryData) => {
switch (entry.type) {
case EntryTypes.REASONING:
return (
<ReasoningEntry
key={entry.id}
entry={entry as ReasoningEntryData}
onDelete={handleDelete}
onUpdate={handleUpdate}
onReset={handleReset}
onSave={handleSaveEntry}
/>
);
case EntryTypes.SINGLE:
return (
<SingleEntry
key={entry.id}
entry={entry as ScriptEntryData}
onDelete={handleDelete}
onUpdate={handleUpdate}
onReset={handleReset}
onSave={handleSaveEntry}
/>
);
case EntryTypes.REPEAT:
return (
<RepeatEntry
key={entry.id}
entry={entry as ScriptEntryData}
onDelete={handleDelete}
onUpdate={handleUpdate}
onReset={handleReset}
onSave={handleSaveEntry}
/>
);
case EntryTypes.READ_STDIN:
return (
<ReadEntry
key={entry.id}
entry={entry as IOEntryData}
onDelete={handleDelete}
onUpdate={handleUpdate}
onReset={handleReset}
onSave={handleSaveEntry}
/>
);
case EntryTypes.WRITE:
return (
<WriteEntry
key={entry.id}
entry={entry as IOEntryData}
onDelete={handleDelete}
onUpdate={handleUpdate}
onReset={handleReset}
onSave={handleSaveEntry}
/>
);
case EntryTypes.PARSE_ERROR:
return (
<ParseErrorEntry
key={entry.id}
entry={entry as ParseErrorEntryData}
onDelete={handleDelete}
onUpdate={handleUpdate}
onReset={handleReset}
onSave={handleSaveEntry}
/>
);
case EntryTypes.BACKGROUND:
return (
<BackgroundEntry
key={entry.id}
entry={entry as BackgroundEntryData}
onDelete={handleDelete}
onUpdate={handleUpdate}
onReset={handleReset}
onSave={handleSaveEntry}
/>
);
default:
return null;
}
};
const renderNewEntryMenu = () => (
<div className="absolute right-4 top-12 bg-white shadow-lg rounded-lg border p-2 z-10">
<div className="text-sm font-medium mb-2 pb-1 border-b">Create New Entry</div>
<div className="space-y-1">
<button
onClick={() => handleCreateEntry(EntryTypes.REASONING)}
className="w-full text-left px-3 py-1 text-sm hover:bg-blue-50 rounded"
>
Reasoning
</button>
<button
onClick={() => handleCreateEntry(EntryTypes.SINGLE)}
className="w-full text-left px-3 py-1 text-sm hover:bg-blue-50 rounded"
>
Single Script
</button>
<button
onClick={() => handleCreateEntry(EntryTypes.REPEAT)}
className="w-full text-left px-3 py-1 text-sm hover:bg-blue-50 rounded"
>
Repeat Script
</button>
<button
onClick={() => handleCreateEntry(EntryTypes.READ_STDIN)}
className="w-full text-left px-3 py-1 text-sm hover:bg-blue-50 rounded"
>
Read Input
</button>
<button
onClick={() => handleCreateEntry(EntryTypes.WRITE)}
className="w-full text-left px-3 py-1 text-sm hover:bg-blue-50 rounded"
>
Write Output
</button>
<button
onClick={() => handleCreateEntry(EntryTypes.BACKGROUND)}
className="w-full text-left px-3 py-1 text-sm hover:bg-blue-50 rounded"
>
Background Process
</button>
<button
onClick={() => handleCreateEntry(EntryTypes.PARSE_ERROR)}
className="w-full text-left px-3 py-1 text-sm hover:bg-blue-50 rounded"
>
Parse Error
</button>
</div>
</div>
);
return (
<div className={`h-full flex flex-col bg-white ${className}`}>
<div className="px-4 py-2 border-b bg-gray-50 flex justify-between items-center relative">
<h3 className="font-semibold text-sm">Memory / Context</h3>
<button
onClick={() => setShowNewEntryMenu(!showNewEntryMenu)}
className="p-0 text-blue-600 hover:bg-blue-50 rounded"
title="Create New Entry"
disabled={isLoading}
>
<Plus className="w-4 h-4" />
</button>
{showNewEntryMenu && renderNewEntryMenu()}
</div>
<div className="flex-1 overflow-y-auto p-4">
{isLoading && (
<div className="fixed top-0 left-0 right-0 bg-blue-500 h-1 z-50">
<div className="animate-pulse bg-blue-300 h-full w-24"></div>
</div>
)}
{memory.length === 0 ? (
<div className="flex items-center justify-center h-full">
<p className="text-gray-500">No memory entries</p>
</div>
) : (
memory.map((entry) => renderEntry(entry))
)}
</div>
</div>
);
};
export default MemoryEditor;

View File

@@ -0,0 +1,158 @@
import React from 'react';
import { BaseEntry } from './BaseEntry';
import { BackgroundEntryData } from '../../../types/entries';
interface BackgroundEntryProps {
entry: BackgroundEntryData;
onDelete: (id: string) => void;
onUpdate: (id: string) => void;
onReset: (id: string) => void;
onSave: (id: string, data: BackgroundEntryData) => Promise<void>;
}
const BackgroundEntry: React.FC<BackgroundEntryProps> = ({
entry,
onDelete,
onUpdate,
onReset,
onSave
}) => {
const renderContent = (data: BackgroundEntryData) => (
<div className="space-y-3">
<div>
<span className="text-sm font-medium text-gray-700">Script:</span>
<pre className="mt-1 text-sm text-gray-900 bg-gray-50 p-2 rounded whitespace-pre-wrap overflow-x-auto">{data.script || ''}</pre>
</div>
{data.pid !== null && data.pid !== undefined && (
<div>
<span className="text-sm font-medium text-gray-700">Process ID:</span>
<span className="ml-2 text-sm text-gray-900">{data.pid}</span>
</div>
)}
{data.stdout && (
<div>
<span className="text-sm font-medium text-gray-700">Standard Output:</span>
<pre className="mt-1 text-sm text-gray-900 bg-gray-50 p-2 rounded overflow-x-auto max-h-40">{data.stdout}</pre>
</div>
)}
{data.stderr && (
<div>
<span className="text-sm font-medium text-gray-700">Standard Error:</span>
<pre className="mt-1 text-sm text-gray-900 bg-red-50 p-2 rounded overflow-x-auto max-h-40">{data.stderr}</pre>
</div>
)}
{data.exit_code !== null && data.exit_code !== undefined && (
<div>
<span className="text-sm font-medium text-gray-700">Exit Code:</span>
<span className="ml-2 text-sm text-gray-900">{data.exit_code}</span>
</div>
)}
</div>
);
const renderEditForm = (
data: BackgroundEntryData,
onChange: (data: BackgroundEntryData) => void,
isLoading: boolean
) => (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Script</label>
<textarea
value={data.script || ''}
onChange={e => onChange({ ...data, script: e.target.value })}
className="w-full p-2 border rounded font-mono text-sm resize-vertical"
rows={4}
disabled={isLoading}
/>
</div>
<div>
<div className="flex items-center mb-2">
<input
type="checkbox"
checked={data.pid !== null && data.pid !== undefined}
onChange={e => onChange({
...data,
pid: e.target.checked ? 0 : null
})}
className="rounded border-gray-300"
disabled={isLoading}
/>
<label className="ml-2 text-sm font-medium text-gray-700">Process ID</label>
</div>
<input
type="number"
value={data.pid !== null && data.pid !== undefined ? data.pid : ''}
onChange={e => onChange({ ...data, pid: e.target.value ? parseInt(e.target.value) : null })}
className="w-full p-2 border rounded text-sm disabled:bg-gray-100"
min="0"
disabled={isLoading || data.pid === null || data.pid === undefined}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Standard Output</label>
<textarea
value={data.stdout || ''}
onChange={e => onChange({ ...data, stdout: e.target.value })}
className="w-full p-2 border rounded font-mono text-sm resize-vertical"
rows={3}
disabled={isLoading}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Standard Error</label>
<textarea
value={data.stderr || ''}
onChange={e => onChange({ ...data, stderr: e.target.value })}
className="w-full p-2 border rounded font-mono text-sm resize-vertical"
rows={3}
disabled={isLoading}
/>
</div>
<div>
<div className="flex items-center mb-2">
<input
type="checkbox"
checked={data.exit_code !== null && data.exit_code !== undefined}
onChange={e => onChange({
...data,
exit_code: e.target.checked ? 0 : null
})}
className="rounded border-gray-300"
disabled={isLoading}
/>
<label className="ml-2 text-sm font-medium text-gray-700">Exit Code</label>
</div>
<input
type="number"
value={data.exit_code !== null && data.exit_code !== undefined ? data.exit_code : ''}
onChange={e => onChange({ ...data, exit_code: e.target.value ? parseInt(e.target.value) : null })}
className="w-full p-2 border rounded text-sm disabled:bg-gray-100"
disabled={isLoading || data.exit_code === null || data.exit_code === undefined}
/>
</div>
</div>
);
return (
<BaseEntry
entry={entry}
onDelete={onDelete}
onUpdate={onUpdate}
onReset={onReset}
onSave={onSave}
renderContent={renderContent}
renderEditForm={renderEditForm}
/>
);
};
export default BackgroundEntry;

View File

@@ -0,0 +1,157 @@
import React, { useState } from 'react';
import { Edit2, Trash, RefreshCw, RotateCcw, X, Check } from 'lucide-react';
import { EntryData } from '../../../types/entries';
export interface BaseEntryProps<T extends EntryData> {
entry: T;
onDelete: (id: string) => void;
onUpdate: (id: string) => void;
onReset: (id: string) => void;
onSave: (id: string, data: T) => Promise<void>;
renderContent: (entry: T) => React.ReactNode;
renderEditForm: (
data: T,
onChange: (data: T) => void,
isLoading: boolean
) => React.ReactNode;
}
export function BaseEntry<T extends EntryData>({
entry,
onDelete,
onUpdate,
onReset,
onSave,
renderContent,
renderEditForm
}: BaseEntryProps<T>) {
const [isEditing, setIsEditing] = useState(false);
const [editData, setEditData] = useState<T>(entry);
const [isLoading, setIsLoading] = useState(false);
const [originalId, setOriginalId] = useState<string>(entry.id);
const getTypeColor = (type: string) => {
const colors: Record<string, string> = {
'reasoning': 'bg-blue-100 text-blue-800',
'repeat': 'bg-green-100 text-green-800',
'read_stdin': 'bg-purple-100 text-purple-800',
'single': 'bg-yellow-100 text-yellow-800',
'write_stdout': 'bg-pink-100 text-pink-800',
'parse_error': 'bg-red-100 text-red-800',
'background': 'bg-gray-100 text-gray-800'
};
return colors[type] || 'bg-gray-100 text-gray-800';
};
const handleEditClick = () => {
setEditData(entry);
setOriginalId(entry.id);
setIsEditing(true);
};
const handleCancel = () => {
setIsEditing(false);
};
const handleSave = async () => {
try {
setIsLoading(true);
await onSave(originalId, editData);
setIsEditing(false);
} catch (error) {
console.error('Failed to save entry:', error);
} finally {
setIsLoading(false);
}
};
return (
<div className="border rounded-lg p-3 mb-3 bg-white hover:shadow-md transition-shadow">
<div className="flex justify-between items-start mb-2">
<div>
<span className={`inline-block px-2 py-0.5 text-xs rounded ${getTypeColor(entry.type)}`}>
{entry.type}
</span>
<span className="ml-2 text-xs text-gray-500">{entry.id}</span>
</div>
{isEditing ? (
<div className="flex gap-1">
<button
onClick={handleCancel}
className="p-1 text-gray-600 hover:bg-gray-50 rounded"
title="Cancel"
disabled={isLoading}
>
<X className="w-3 h-3" />
</button>
<button
onClick={handleSave}
className="p-1 text-green-600 hover:bg-green-50 rounded"
title="Save"
disabled={isLoading}
>
<Check className="w-3 h-3" />
</button>
</div>
) : (
<div className="flex gap-1">
<button
onClick={() => onReset(entry.id)}
className="p-1 text-orange-600 hover:bg-orange-50 rounded"
title="Reset"
>
<RotateCcw className="w-3 h-3" />
</button>
<button
onClick={() => onUpdate(entry.id)}
className="p-1 text-gray-600 hover:bg-gray-50 rounded"
title="Update"
>
<RefreshCw className="w-3 h-3" />
</button>
<button
onClick={handleEditClick}
className="p-1 text-blue-600 hover:bg-blue-50 rounded"
title="Edit"
>
<Edit2 className="w-3 h-3" />
</button>
<button
onClick={() => onDelete(entry.id)}
className="p-1 text-red-600 hover:bg-red-50 rounded"
title="Delete"
>
<Trash className="w-3 h-3" />
</button>
</div>
)}
</div>
{isEditing ? (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">ID (Timestamp)</label>
<input
type="text"
value={editData.id}
onChange={(e) => setEditData({...editData, id: e.target.value})}
className="w-full p-2 border rounded text-sm"
disabled={isLoading}
/>
</div>
{renderEditForm(
editData,
(newData) => setEditData(newData),
isLoading
)}
</div>
) : (
renderContent(entry)
)}
</div>
);
}
export default BaseEntry;

View File

@@ -0,0 +1,87 @@
import React from 'react';
import { BaseEntry } from './BaseEntry';
import { IOEntryData } from '../../../types/entries';
interface IOEntryProps {
entry: IOEntryData;
onDelete: (id: string) => void;
onUpdate: (id: string) => void;
onReset: (id: string) => void;
onSave: (id: string, data: IOEntryData) => Promise<void>;
}
const IOEntry: React.FC<IOEntryProps> = ({
entry,
onDelete,
onUpdate,
onReset,
onSave
}) => {
const renderContent = (data: IOEntryData) => (
<div className="space-y-3">
<div>
<span className="text-sm font-medium text-gray-700">Content:</span>
<pre className="mt-1 text-sm text-gray-900 bg-gray-50 p-2 rounded whitespace-pre-wrap">{data.content || ''}</pre>
</div>
<div>
<span className="text-sm text-gray-700">
{data.type === 'read_stdin'
? (data.read ? '✓ Read' : '✗ Not Read')
: (data.written ? '✓ Written' : '✗ Not Written')}
</span>
</div>
</div>
);
const renderEditForm = (
data: IOEntryData,
onChange: (data: IOEntryData) => void,
isLoading: boolean
) => (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Content</label>
<textarea
value={data.content || ''}
onChange={e => onChange({ ...data, content: e.target.value })}
className="w-full p-2 border rounded font-mono text-sm resize-vertical"
rows={4}
disabled={isLoading}
/>
</div>
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={data.type === 'read_stdin' ? (data.read || false) : (data.written || false)}
onChange={e => onChange({
...data,
...(data.type === 'read_stdin'
? { read: e.target.checked }
: { written: e.target.checked })
})}
className="rounded border-gray-300"
disabled={isLoading}
/>
<span className="ml-2 text-sm text-gray-700">
{data.type === 'read_stdin' ? 'Read' : 'Written'}
</span>
</label>
</div>
</div>
);
return (
<BaseEntry
entry={entry}
onDelete={onDelete}
onUpdate={onUpdate}
onReset={onReset}
onSave={onSave}
renderContent={renderContent}
renderEditForm={renderEditForm}
/>
);
};
export default IOEntry;

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { BaseEntry } from './BaseEntry';
import { ParseErrorEntryData } from '../../../types/entries';
interface ParseErrorEntryProps {
entry: ParseErrorEntryData;
onDelete: (id: string) => void;
onUpdate: (id: string) => void;
onReset: (id: string) => void;
onSave: (id: string, data: ParseErrorEntryData) => Promise<void>;
}
const ParseErrorEntry: React.FC<ParseErrorEntryProps> = ({
entry,
onDelete,
onUpdate,
onReset,
onSave
}) => {
const renderContent = (data: ParseErrorEntryData) => (
<div className="space-y-3">
<div>
<span className="text-sm font-medium text-gray-700">Content:</span>
<pre className="mt-1 text-sm text-gray-900 bg-gray-50 p-2 rounded whitespace-pre-wrap">{data.content || ''}</pre>
</div>
<div>
<span className="text-sm font-medium text-red-700">Error:</span>
<pre className="mt-1 text-sm text-red-900 bg-red-50 p-2 rounded whitespace-pre-wrap">{data.error || ''}</pre>
</div>
</div>
);
const renderEditForm = (
data: ParseErrorEntryData,
onChange: (data: ParseErrorEntryData) => void,
isLoading: boolean
) => (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Content</label>
<textarea
value={data.content || ''}
onChange={e => onChange({ ...data, content: e.target.value })}
className="w-full p-2 border rounded font-mono text-sm resize-vertical"
rows={4}
disabled={isLoading}
/>
</div>
<div>
<label className="block text-sm font-medium text-red-700 mb-1">Error</label>
<textarea
value={data.error || ''}
onChange={e => onChange({ ...data, error: e.target.value })}
className="w-full p-2 border rounded font-mono text-sm resize-vertical"
rows={4}
disabled={isLoading}
/>
</div>
</div>
);
return (
<BaseEntry
entry={entry}
onDelete={onDelete}
onUpdate={onUpdate}
onReset={onReset}
onSave={onSave}
renderContent={renderContent}
renderEditForm={renderEditForm}
/>
);
};
export default ParseErrorEntry;

View File

@@ -0,0 +1,78 @@
import React from 'react';
import { BaseEntry } from './BaseEntry';
import { IOEntryData } from '../../../types/entries';
interface ReadEntryProps {
entry: IOEntryData;
onDelete: (id: string) => void;
onUpdate: (id: string) => void;
onReset: (id: string) => void;
onSave: (id: string, data: IOEntryData) => Promise<void>;
}
const ReadEntry: React.FC<ReadEntryProps> = ({
entry,
onDelete,
onUpdate,
onReset,
onSave
}) => {
const renderContent = (data: IOEntryData) => (
<div className="space-y-3">
<div>
<span className="text-sm font-medium text-gray-700">Content:</span>
<pre className="mt-1 text-sm text-gray-900 bg-gray-50 p-2 rounded whitespace-pre-wrap">{data.content || ''}</pre>
</div>
<div>
<span className="text-sm text-gray-700">
{data.read ? '✓ Read' : '✗ Not Read'}
</span>
</div>
</div>
);
const renderEditForm = (
data: IOEntryData,
onChange: (data: IOEntryData) => void,
isLoading: boolean
) => (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Content</label>
<textarea
value={data.content || ''}
onChange={e => onChange({ ...data, content: e.target.value })}
className="w-full p-2 border rounded font-mono text-sm resize-vertical"
rows={4}
disabled={isLoading}
/>
</div>
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={data.read || false}
onChange={e => onChange({ ...data, read: e.target.checked })}
className="rounded border-gray-300"
disabled={isLoading}
/>
<span className="ml-2 text-sm text-gray-700">Read</span>
</label>
</div>
</div>
);
return (
<BaseEntry
entry={entry}
onDelete={onDelete}
onUpdate={onUpdate}
onReset={onReset}
onSave={onSave}
renderContent={renderContent}
renderEditForm={renderEditForm}
/>
);
};
export default ReadEntry;

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { BaseEntry } from './BaseEntry';
import { ReasoningEntryData } from '../../../types/entries';
interface ReasoningEntryProps {
entry: ReasoningEntryData;
onDelete: (id: string) => void;
onUpdate: (id: string) => void;
onReset: (id: string) => void;
onSave: (id: string, data: ReasoningEntryData) => Promise<void>;
}
const ReasoningEntry: React.FC<ReasoningEntryProps> = ({
entry,
onDelete,
onUpdate,
onReset,
onSave
}) => {
const renderContent = (data: ReasoningEntryData) => (
<p className="text-sm text-gray-700 whitespace-pre-wrap">{data.content}</p>
);
const renderEditForm = (
data: ReasoningEntryData,
onChange: (data: ReasoningEntryData) => void,
isLoading: boolean
) => (
<div className="mt-2">
<textarea
value={data.content}
onChange={(e) => onChange({ ...data, content: e.target.value })}
className="w-full p-2 border rounded font-mono text-sm resize-vertical"
rows={6}
disabled={isLoading}
/>
</div>
);
return (
<BaseEntry
entry={entry}
onDelete={onDelete}
onUpdate={onUpdate}
onReset={onReset}
onSave={onSave}
renderContent={renderContent}
renderEditForm={renderEditForm}
/>
);
};
export default ReasoningEntry;

View File

@@ -0,0 +1,215 @@
import React from 'react';
import { BaseEntry } from './BaseEntry';
import { ScriptEntryData } from '../../../types/entries';
interface RepeatEntryProps {
entry: ScriptEntryData;
onDelete: (id: string) => void;
onUpdate: (id: string) => void;
onReset: (id: string) => void;
onSave: (id: string, data: ScriptEntryData) => Promise<void>;
}
const RepeatEntry: React.FC<RepeatEntryProps> = ({
entry,
onDelete,
onUpdate,
onReset,
onSave
}) => {
const renderContent = (data: ScriptEntryData) => (
<div className="space-y-3">
<div>
<span className="text-sm font-medium text-gray-700">Script:</span>
<pre className="mt-1 text-sm text-gray-900 bg-gray-50 p-2 rounded whitespace-pre-wrap overflow-x-auto">{data.script || ''}</pre>
</div>
{data.timeout !== undefined && data.timeout !== null && (
<div>
<span className="text-sm font-medium text-gray-700">Timeout:</span>
<span className="ml-2 text-sm text-gray-900">{data.timeout} seconds</span>
</div>
)}
{data.limit !== undefined && data.limit !== null && (
<div>
<span className="text-sm font-medium text-gray-700">Memory Limit:</span>
<span className="ml-2 text-sm text-gray-900">{data.limit} bytes</span>
</div>
)}
{data.stdout && (
<div>
<span className="text-sm font-medium text-gray-700">Standard Output:</span>
<pre className="mt-1 text-sm text-gray-900 bg-gray-50 p-2 rounded overflow-x-auto max-h-40">{data.stdout}</pre>
</div>
)}
{data.stderr && (
<div>
<span className="text-sm font-medium text-gray-700">Standard Error:</span>
<pre className="mt-1 text-sm text-gray-900 bg-red-50 p-2 rounded overflow-x-auto max-h-40">{data.stderr}</pre>
</div>
)}
{data.exit_code !== null && data.exit_code !== undefined && (
<div>
<span className="text-sm font-medium text-gray-700">Exit Code:</span>
<span className="ml-2 text-sm text-gray-900">{data.exit_code}</span>
</div>
)}
<div>
<span className="text-sm text-gray-700">
{data.timed_out ? '⚠ Timed Out' : '✓ Within Timeout'}
</span>
</div>
</div>
);
const renderEditForm = (
data: ScriptEntryData,
onChange: (data: ScriptEntryData) => void,
isLoading: boolean
) => (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Script</label>
<textarea
value={data.script || ''}
onChange={e => onChange({ ...data, script: e.target.value })}
className="w-full p-2 border rounded font-mono text-sm resize-vertical"
rows={4}
disabled={isLoading}
/>
</div>
<div>
<div className="flex items-center mb-2">
<input
type="checkbox"
checked={data.timeout !== null && data.timeout !== undefined}
onChange={e => onChange({
...data,
timeout: e.target.checked ? 1.0 : null
})}
className="rounded border-gray-300"
disabled={isLoading}
/>
<label className="ml-2 text-sm font-medium text-gray-700">Custom Timeout (seconds)</label>
</div>
<input
type="number"
value={data.timeout !== null && data.timeout !== undefined ? data.timeout : ''}
onChange={e => onChange({
...data,
timeout: e.target.value ? parseFloat(e.target.value) : null
})}
className="w-full p-2 border rounded text-sm disabled:bg-gray-100"
step="0.1"
min="0"
disabled={isLoading || data.timeout === null || data.timeout === undefined}
/>
</div>
<div>
<div className="flex items-center mb-2">
<input
type="checkbox"
checked={data.limit !== null && data.limit !== undefined}
onChange={e => onChange({
...data,
limit: e.target.checked ? 1024 : null
})}
className="rounded border-gray-300"
disabled={isLoading}
/>
<label className="ml-2 text-sm font-medium text-gray-700">Custom Memory Limit (bytes)</label>
</div>
<input
type="number"
value={data.limit !== null && data.limit !== undefined ? data.limit : ''}
onChange={e => onChange({
...data,
limit: e.target.value ? parseInt(e.target.value) : null
})}
className="w-full p-2 border rounded text-sm disabled:bg-gray-100"
min="0"
disabled={isLoading || data.limit === null || data.limit === undefined}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Standard Output</label>
<textarea
value={data.stdout || ''}
onChange={e => onChange({ ...data, stdout: e.target.value })}
className="w-full p-2 border rounded font-mono text-sm resize-vertical"
rows={3}
disabled={isLoading}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Standard Error</label>
<textarea
value={data.stderr || ''}
onChange={e => onChange({ ...data, stderr: e.target.value })}
className="w-full p-2 border rounded font-mono text-sm resize-vertical"
rows={3}
disabled={isLoading}
/>
</div>
<div>
<div className="flex items-center mb-2">
<input
type="checkbox"
checked={data.exit_code !== null && data.exit_code !== undefined}
onChange={e => onChange({
...data,
exit_code: e.target.checked ? 0 : null
})}
className="rounded border-gray-300"
disabled={isLoading}
/>
<label className="ml-2 text-sm font-medium text-gray-700">Exit Code</label>
</div>
<input
type="number"
value={data.exit_code !== null && data.exit_code !== undefined ? data.exit_code : ''}
onChange={e => onChange({ ...data, exit_code: e.target.value ? parseInt(e.target.value) : null })}
className="w-full p-2 border rounded text-sm disabled:bg-gray-100"
disabled={isLoading || data.exit_code === null || data.exit_code === undefined}
/>
</div>
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={data.timed_out || false}
onChange={e => onChange({ ...data, timed_out: e.target.checked })}
className="rounded border-gray-300"
disabled={isLoading}
/>
<span className="ml-2 text-sm text-gray-700">Timed Out</span>
</label>
</div>
</div>
);
return (
<BaseEntry
entry={entry}
onDelete={onDelete}
onUpdate={onUpdate}
onReset={onReset}
onSave={onSave}
renderContent={renderContent}
renderEditForm={renderEditForm}
/>
);
};
export default RepeatEntry;

View File

@@ -0,0 +1,127 @@
import React from 'react';
import { BaseEntry } from './BaseEntry';
import { ScriptEntryData } from '../../../types/entries';
interface ScriptEntryProps {
entry: ScriptEntryData;
onDelete: (id: string) => void;
onUpdate: (id: string) => void;
onReset: (id: string) => void;
onSave: (id: string, data: ScriptEntryData) => Promise<void>;
}
const ScriptEntry: React.FC<ScriptEntryProps> = ({
entry,
onDelete,
onUpdate,
onReset,
onSave
}) => {
const renderContent = (data: ScriptEntryData) => (
<div className="space-y-3">
<div>
<span className="text-sm font-medium text-gray-700">Script:</span>
<pre className="mt-1 text-sm text-gray-900 bg-gray-50 p-2 rounded whitespace-pre-wrap overflow-x-auto">{data.script || ''}</pre>
</div>
{data.stdout && (
<div>
<span className="text-sm font-medium text-gray-700">Standard Output:</span>
<pre className="mt-1 text-sm text-gray-900 bg-gray-50 p-2 rounded overflow-x-auto max-h-40">{data.stdout}</pre>
</div>
)}
{data.stderr && (
<div>
<span className="text-sm font-medium text-gray-700">Standard Error:</span>
<pre className="mt-1 text-sm text-gray-900 bg-red-50 p-2 rounded overflow-x-auto max-h-40">{data.stderr}</pre>
</div>
)}
{data.exit_code !== null && data.exit_code !== undefined && (
<div>
<span className="text-sm font-medium text-gray-700">Exit Code:</span>
<span className="ml-2 text-sm text-gray-900">{data.exit_code}</span>
</div>
)}
</div>
);
const renderEditForm = (
data: ScriptEntryData,
onChange: (data: ScriptEntryData) => void,
isLoading: boolean
) => (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Script</label>
<textarea
value={data.script || ''}
onChange={e => onChange({ ...data, script: e.target.value })}
className="w-full p-2 border rounded font-mono text-sm resize-vertical"
rows={4}
disabled={isLoading}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Standard Output</label>
<textarea
value={data.stdout || ''}
onChange={e => onChange({ ...data, stdout: e.target.value })}
className="w-full p-2 border rounded font-mono text-sm resize-vertical"
rows={3}
disabled={isLoading}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Standard Error</label>
<textarea
value={data.stderr || ''}
onChange={e => onChange({ ...data, stderr: e.target.value })}
className="w-full p-2 border rounded font-mono text-sm resize-vertical"
rows={3}
disabled={isLoading}
/>
</div>
<div>
<div className="flex items-center mb-2">
<input
type="checkbox"
checked={data.exit_code !== null && data.exit_code !== undefined}
onChange={e => onChange({
...data,
exit_code: e.target.checked ? 0 : null
})}
className="rounded border-gray-300"
disabled={isLoading}
/>
<label className="ml-2 text-sm font-medium text-gray-700">Exit Code</label>
</div>
<input
type="number"
value={data.exit_code !== null && data.exit_code !== undefined ? data.exit_code : ''}
onChange={e => onChange({ ...data, exit_code: e.target.value ? parseInt(e.target.value) : null })}
className="w-full p-2 border rounded text-sm disabled:bg-gray-100"
disabled={isLoading || data.exit_code === null || data.exit_code === undefined}
/>
</div>
</div>
);
return (
<BaseEntry
entry={entry}
onDelete={onDelete}
onUpdate={onUpdate}
onReset={onReset}
onSave={onSave}
renderContent={renderContent}
renderEditForm={renderEditForm}
/>
);
};
export default ScriptEntry;

View File

@@ -0,0 +1,228 @@
import React from 'react';
import { BaseEntry } from './BaseEntry';
import { ScriptEntryData } from '../../../types/entries';
interface SingleEntryProps {
entry: ScriptEntryData;
onDelete: (id: string) => void;
onUpdate: (id: string) => void;
onReset: (id: string) => void;
onSave: (id: string, data: ScriptEntryData) => Promise<void>;
}
const SingleEntry: React.FC<SingleEntryProps> = ({
entry,
onDelete,
onUpdate,
onReset,
onSave
}) => {
const renderContent = (data: ScriptEntryData) => (
<div className="space-y-3">
<div>
<span className="text-sm font-medium text-gray-700">Script:</span>
<pre className="mt-1 text-sm text-gray-900 bg-gray-50 p-2 rounded whitespace-pre-wrap overflow-x-auto">{data.script || ''}</pre>
</div>
{data.timeout !== undefined && data.timeout !== null && (
<div>
<span className="text-sm font-medium text-gray-700">Timeout:</span>
<span className="ml-2 text-sm text-gray-900">{data.timeout} seconds</span>
</div>
)}
{data.limit !== undefined && data.limit !== null && (
<div>
<span className="text-sm font-medium text-gray-700">Memory Limit:</span>
<span className="ml-2 text-sm text-gray-900">{data.limit} bytes</span>
</div>
)}
{data.stdout && (
<div>
<span className="text-sm font-medium text-gray-700">Standard Output:</span>
<pre className="mt-1 text-sm text-gray-900 bg-gray-50 p-2 rounded overflow-x-auto max-h-40">{data.stdout}</pre>
</div>
)}
{data.stderr && (
<div>
<span className="text-sm font-medium text-gray-700">Standard Error:</span>
<pre className="mt-1 text-sm text-gray-900 bg-red-50 p-2 rounded overflow-x-auto max-h-40">{data.stderr}</pre>
</div>
)}
{data.exit_code !== null && data.exit_code !== undefined && (
<div>
<span className="text-sm font-medium text-gray-700">Exit Code:</span>
<span className="ml-2 text-sm text-gray-900">{data.exit_code}</span>
</div>
)}
<div className="flex space-x-4">
<span className="text-sm text-gray-700">
{data.executed ? '✓ Executed' : '✗ Not Executed'}
</span>
<span className="text-sm text-gray-700">
{data.timed_out ? '⚠ Timed Out' : '✓ Within Timeout'}
</span>
</div>
</div>
);
const renderEditForm = (
data: ScriptEntryData,
onChange: (data: ScriptEntryData) => void,
isLoading: boolean
) => (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Script</label>
<textarea
value={data.script || ''}
onChange={e => onChange({ ...data, script: e.target.value })}
className="w-full p-2 border rounded font-mono text-sm resize-vertical"
rows={4}
disabled={isLoading}
/>
</div>
<div>
<div className="flex items-center mb-2">
<input
type="checkbox"
checked={data.timeout !== null && data.timeout !== undefined}
onChange={e => onChange({
...data,
timeout: e.target.checked ? 1.0 : null
})}
className="rounded border-gray-300"
disabled={isLoading}
/>
<label className="ml-2 text-sm font-medium text-gray-700">Custom Timeout (seconds)</label>
</div>
<input
type="number"
value={data.timeout !== null && data.timeout !== undefined ? data.timeout : ''}
onChange={e => onChange({
...data,
timeout: e.target.value ? parseFloat(e.target.value) : null
})}
className="w-full p-2 border rounded text-sm disabled:bg-gray-100"
step="0.1"
min="0"
disabled={isLoading || data.timeout === null || data.timeout === undefined}
/>
</div>
<div>
<div className="flex items-center mb-2">
<input
type="checkbox"
checked={data.limit !== null && data.limit !== undefined}
onChange={e => onChange({
...data,
limit: e.target.checked ? 1024 : null
})}
className="rounded border-gray-300"
disabled={isLoading}
/>
<label className="ml-2 text-sm font-medium text-gray-700">Custom Memory Limit (bytes)</label>
</div>
<input
type="number"
value={data.limit !== null && data.limit !== undefined ? data.limit : ''}
onChange={e => onChange({
...data,
limit: e.target.value ? parseInt(e.target.value) : null
})}
className="w-full p-2 border rounded text-sm disabled:bg-gray-100"
min="0"
disabled={isLoading || data.limit === null || data.limit === undefined}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Standard Output</label>
<textarea
value={data.stdout || ''}
onChange={e => onChange({ ...data, stdout: e.target.value })}
className="w-full p-2 border rounded font-mono text-sm resize-vertical"
rows={3}
disabled={isLoading}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Standard Error</label>
<textarea
value={data.stderr || ''}
onChange={e => onChange({ ...data, stderr: e.target.value })}
className="w-full p-2 border rounded font-mono text-sm resize-vertical"
rows={3}
disabled={isLoading}
/>
</div>
<div>
<div className="flex items-center mb-2">
<input
type="checkbox"
checked={data.exit_code !== null && data.exit_code !== undefined}
onChange={e => onChange({
...data,
exit_code: e.target.checked ? 0 : null
})}
className="rounded border-gray-300"
disabled={isLoading}
/>
<label className="ml-2 text-sm font-medium text-gray-700">Exit Code</label>
</div>
<input
type="number"
value={data.exit_code !== null && data.exit_code !== undefined ? data.exit_code : ''}
onChange={e => onChange({ ...data, exit_code: e.target.value ? parseInt(e.target.value) : null })}
className="w-full p-2 border rounded text-sm disabled:bg-gray-100"
disabled={isLoading || data.exit_code === null || data.exit_code === undefined}
/>
</div>
<div className="flex space-x-4">
<label className="flex items-center">
<input
type="checkbox"
checked={data.executed || false}
onChange={e => onChange({ ...data, executed: e.target.checked })}
className="rounded border-gray-300"
disabled={isLoading}
/>
<span className="ml-2 text-sm text-gray-700">Executed</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={data.timed_out || false}
onChange={e => onChange({ ...data, timed_out: e.target.checked })}
className="rounded border-gray-300"
disabled={isLoading}
/>
<span className="ml-2 text-sm text-gray-700">Timed Out</span>
</label>
</div>
</div>
);
return (
<BaseEntry
entry={entry}
onDelete={onDelete}
onUpdate={onUpdate}
onReset={onReset}
onSave={onSave}
renderContent={renderContent}
renderEditForm={renderEditForm}
/>
);
};
export default SingleEntry;

View File

@@ -0,0 +1,78 @@
import React from 'react';
import { BaseEntry } from './BaseEntry';
import { IOEntryData } from '../../../types/entries';
interface WriteEntryProps {
entry: IOEntryData;
onDelete: (id: string) => void;
onUpdate: (id: string) => void;
onReset: (id: string) => void;
onSave: (id: string, data: IOEntryData) => Promise<void>;
}
const WriteEntry: React.FC<WriteEntryProps> = ({
entry,
onDelete,
onUpdate,
onReset,
onSave
}) => {
const renderContent = (data: IOEntryData) => (
<div className="space-y-3">
<div>
<span className="text-sm font-medium text-gray-700">Content:</span>
<pre className="mt-1 text-sm text-gray-900 bg-gray-50 p-2 rounded whitespace-pre-wrap">{data.content || ''}</pre>
</div>
<div>
<span className="text-sm text-gray-700">
{data.written ? '✓ Written' : '✗ Not Written'}
</span>
</div>
</div>
);
const renderEditForm = (
data: IOEntryData,
onChange: (data: IOEntryData) => void,
isLoading: boolean
) => (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Content</label>
<textarea
value={data.content || ''}
onChange={e => onChange({ ...data, content: e.target.value })}
className="w-full p-2 border rounded font-mono text-sm resize-vertical"
rows={6}
disabled={isLoading}
/>
</div>
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={data.written || false}
onChange={e => onChange({ ...data, written: e.target.checked })}
className="rounded border-gray-300"
disabled={isLoading}
/>
<span className="ml-2 text-sm text-gray-700">Written</span>
</label>
</div>
</div>
);
return (
<BaseEntry
entry={entry}
onDelete={onDelete}
onUpdate={onUpdate}
onReset={onReset}
onSave={onSave}
renderContent={renderContent}
renderEditForm={renderEditForm}
/>
);
};
export default WriteEntry;

View File

@@ -0,0 +1,8 @@
export { default as BaseEntry } from './BaseEntry';
export { default as BackgroundEntry } from './BackgroundEntry';
export { default as ParseErrorEntry } from './ParseErrorEntry';
export { default as ReadEntry } from './ReadEntry';
export { default as ReasoningEntry } from './ReasoningEntry';
export { default as RepeatEntry } from './RepeatEntry';
export { default as SingleEntry } from './SingleEntry';
export { default as WriteEntry } from './WriteEntry';

View File

@@ -0,0 +1 @@
export { default as MemoryEditor } from './MemoryEditor';

View File

@@ -0,0 +1,77 @@
import React, { useState, useRef } from 'react';
import Editor from '@monaco-editor/react';
import { useWebSocketContext } from '../../contexts/WebSocketContext';
import { useMonaco } from '../../contexts/MonacoContext';
interface ResponseEditorProps {
className?: string;
}
const ResponseEditor: React.FC<ResponseEditorProps> = ({ className = '' }) => {
const { response, setResponse } = useWebSocketContext();
const { isMonacoLoading } = useMonaco();
const editorRef = useRef<any>(null);
const lastUserEditTimeRef = useRef<number>(0);
const inactivityTimerRef = useRef<number | null>(null);
const [localResponse, setLocalResponse] = useState<string | null>(null);
const displayValue = localResponse !== null ? localResponse : response;
const handleEditorDidMount = (editor: any) => {
editorRef.current = editor;
};
const handleEditorChange = (value: string | undefined) => {
if (value !== undefined) {
setLocalResponse(value);
lastUserEditTimeRef.current = Date.now();
if (inactivityTimerRef.current !== null) {
clearTimeout(inactivityTimerRef.current);
}
inactivityTimerRef.current = window.setTimeout(() => {
setResponse(value);
setLocalResponse(null);
inactivityTimerRef.current = null;
}, 1000);
}
};
if (isMonacoLoading) {
return (
<div className={`h-full flex items-center justify-center bg-white ${className}`}>
<div className="text-gray-500">Loading editor...</div>
</div>
);
}
return (
<div className={`h-full flex flex-col bg-white ${className}`}>
<div className="px-4 py-2 border-b bg-gray-50">
<h3 className="font-semibold text-sm">Response</h3>
</div>
<div className="flex-1 relative">
<Editor
height="100%"
defaultLanguage="xml"
value={displayValue}
onChange={handleEditorChange}
onMount={handleEditorDidMount}
options={{
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontFamily: 'monospace',
fontSize: 14,
lineNumbers: 'on',
folding: true,
wordWrap: 'on'
}}
/>
</div>
</div>
);
};
export default ResponseEditor;

View File

@@ -0,0 +1 @@
export { default as ResponseEditor } from './ResponseEditor';

View File

@@ -1,177 +0,0 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { LlmState } from '@/components/App';
export const Sidebar = ({
showSidebar,
llms,
activeLlm,
llmState,
showDiff,
autoScroll,
input,
output,
autoApproverConfig,
onApprove,
onInference,
onStop,
onToggleDiff,
onSendInput,
onClearOutput,
onLlmChange,
onNextLlm,
onAutoScrollChange,
onAutoApproverConfigChange,
}) => (
<aside className={`
fixed top-0 right-0 h-screen w-64 bg-white shadow-lg
transition-transform duration-200 ease-in-out z-50
md:sticky md:h-[calc(100vh-4rem)]
${showSidebar ? 'translate-x-0' : 'translate-x-full md:translate-x-0'}
`}>
<div className="p-4 space-y-4 overflow-y-auto h-full">
<Select value={activeLlm} onValueChange={onLlmChange}>
<SelectTrigger>
<SelectValue placeholder="Select LLM" />
</SelectTrigger>
<SelectContent>
{Object.keys(llms).map(llm => (
<SelectItem key={llm} value={llm}>
{llm} ({llms[llm]})
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" onClick={onNextLlm} className="w-full">
Next LLM
</Button>
<Button variant="outline" onClick={onToggleDiff} className="w-full">
{showDiff ? 'Hide Diff' : 'Show Diff'}
</Button>
<div className="flex items-center justify-between">
<label>Auto-Scroll</label>
<Switch
checked={autoScroll}
onCheckedChange={onAutoScrollChange}
/>
</div>
{llmState === LlmState.INFERENCE ? (
<Button
onClick={onStop}
className="w-full"
>
Stop
</Button>
) : (
<Button
onClick={onInference}
className="w-full"
>
Start Inference
</Button>
)}
<Button
onClick={onApprove}
className="w-full"
>
Approve Response
</Button>
<Button onClick={onSendInput} disabled={!input.trim()} className="w-full">
Send Input
</Button>
<Button onClick={onClearOutput} disabled={!output.trim()} className="w-full">
Clear Output
</Button>
{/* Auto Approver Config */}
<div className="border-t pt-4">
<h3 className="font-medium mb-2">Auto Approver</h3>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label>Context Auto-Approve</label>
<Switch
checked={autoApproverConfig.context_enabled}
onCheckedChange={(enabled) => onAutoApproverConfigChange({
...autoApproverConfig,
context_enabled: enabled
})}
/>
</div>
<div className="flex items-center justify-between">
<label>Response Auto-Approve</label>
<Switch
checked={autoApproverConfig.response_enabled}
onCheckedChange={(enabled) => onAutoApproverConfigChange({
...autoApproverConfig,
response_enabled: enabled
})}
/>
</div>
<div className="space-y-1">
<label className="text-sm">Context Timeout (s)</label>
<Input
type="number"
min="0"
step="0.1"
value={autoApproverConfig.context_timeout}
onChange={(e) => onAutoApproverConfigChange({
...autoApproverConfig,
context_timeout: parseFloat(e.target.value)
})}
/>
</div>
<div className="space-y-1">
<label className="text-sm">Response Timeout (s)</label>
<Input
type="number"
min="0"
step="0.1"
value={autoApproverConfig.response_timeout}
onChange={(e) => onAutoApproverConfigChange({
...autoApproverConfig,
response_timeout: parseFloat(e.target.value)
})}
/>
</div>
<div className="space-y-1">
<label className="text-sm">LLM for Inference</label>
<Select
value={autoApproverConfig.llm_name}
onValueChange={(llm) => onAutoApproverConfigChange({
...autoApproverConfig,
llm_name: llm
})}
>
<SelectTrigger>
<SelectValue placeholder="Select LLM" />
</SelectTrigger>
<SelectContent>
{Object.keys(llms).map(llm => (
<SelectItem key={llm} value={llm}>{llm}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-gray-500 mt-1">
This selects which model will generate content during auto-approval.
</p>
</div>
</div>
</div>
</div>
</aside>
);

Some files were not shown because too many files have changed in this diff Show More