Add Lua listeners proposal and message log stream
Lua proposal covers: - Sandboxed Lua handlers for SaaS custom logic - XML <-> Lua table bridging with XSD validation - Security sandbox (restricted stdlib, resource limits) - Schema DSL alternative to raw XSD - Hot reload mechanism API spec updated with: - Dedicated /ws/messages endpoint for message log streaming - Filter by agent/thread/payload type - Example for reconstructing agent buffers client-side Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a29c6f8321
commit
398fd5ac23
2 changed files with 420 additions and 1 deletions
|
|
@ -146,14 +146,78 @@ Response:
|
||||||
|
|
||||||
## WebSocket API
|
## WebSocket API
|
||||||
|
|
||||||
|
### Main Control Channel
|
||||||
|
|
||||||
Endpoint: `wss://host:443/ws`
|
Endpoint: `wss://host:443/ws`
|
||||||
|
|
||||||
### Connection
|
#### Connection
|
||||||
1. Client connects to `/ws`
|
1. Client connects to `/ws`
|
||||||
2. Server sends `connected` event with current state snapshot
|
2. Server sends `connected` event with current state snapshot
|
||||||
3. Server pushes events as they occur
|
3. Server pushes events as they occur
|
||||||
4. Client can send control commands
|
4. Client can send control commands
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Message Log Stream
|
||||||
|
|
||||||
|
Endpoint: `wss://host:443/ws/messages`
|
||||||
|
|
||||||
|
Dedicated stream of all messages flowing through the organism. Clients can filter by agent to reconstruct individual message buffers in real-time.
|
||||||
|
|
||||||
|
#### Connection
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cmd": "subscribe",
|
||||||
|
"filter": {
|
||||||
|
"agents": ["greeter", "shouter"], // optional, empty = all
|
||||||
|
"threads": ["thread-uuid"], // optional, empty = all
|
||||||
|
"payload_types": ["GreetingResponse"] // optional
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Message Event
|
||||||
|
Every message that flows through the pump:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "msg-uuid",
|
||||||
|
"thread_id": "thread-uuid",
|
||||||
|
"from": "greeter",
|
||||||
|
"to": "shouter",
|
||||||
|
"payload_type": "GreetingResponse",
|
||||||
|
"payload": {
|
||||||
|
"message": "Hello Dan!",
|
||||||
|
"original_sender": "console"
|
||||||
|
},
|
||||||
|
"timestamp": "2024-01-15T10:30:01.123Z",
|
||||||
|
"slot_index": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Use Case: Reconstructing Agent Buffers
|
||||||
|
|
||||||
|
Client-side, filter messages by agent to build each agent's context buffer:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const buffers = {}; // agent_name -> messages[]
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
|
||||||
|
// Add to sender's outbox
|
||||||
|
buffers[msg.from] = buffers[msg.from] || [];
|
||||||
|
buffers[msg.from].push({ direction: 'out', ...msg });
|
||||||
|
|
||||||
|
// Add to receiver's inbox
|
||||||
|
buffers[msg.to] = buffers[msg.to] || [];
|
||||||
|
buffers[msg.to].push({ direction: 'in', ...msg });
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows the GUI to show each agent's "conversation view" in real-time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Event Types
|
### Event Types
|
||||||
|
|
||||||
#### connected
|
#### connected
|
||||||
|
|
|
||||||
355
docs/lua_listeners_proposal.md
Normal file
355
docs/lua_listeners_proposal.md
Normal file
|
|
@ -0,0 +1,355 @@
|
||||||
|
# Lua Listeners Proposal
|
||||||
|
|
||||||
|
**Status:** Draft / Discussion
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
For a SaaS offering, users want custom agent logic without:
|
||||||
|
- Uploading Python code (security nightmare)
|
||||||
|
- Forking the codebase
|
||||||
|
- Complex deployment pipelines
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Allow users to write handler logic in Lua, which can be sandboxed effectively.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ LuaListener │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ XML Input │───▶│ Lua Sandbox │───▶│ XML Output │ │
|
||||||
|
│ │ (validated) │ │ (user code) │ │ │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||||
|
│ ▲ │ │
|
||||||
|
│ │ ▼ │
|
||||||
|
│ XSD validates XSD validates │
|
||||||
|
│ on input on output │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Insight
|
||||||
|
|
||||||
|
XSD is the source of truth. If validation passes, data is correct. The Python handler can be generic - it just bridges XML ↔ Lua tables. No typed Python dataclasses needed in the handler.
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
1. Message arrives, pump validates against input XSD
|
||||||
|
2. Generic `LuaListener` converts XML → Lua table
|
||||||
|
3. User's Lua `handle()` function is called
|
||||||
|
4. Lua returns `{to = "target", payload = {...}}`
|
||||||
|
5. Handler converts Lua table → XML
|
||||||
|
6. Pump validates output against target's XSD
|
||||||
|
7. Message delivered
|
||||||
|
|
||||||
|
## User Experience
|
||||||
|
|
||||||
|
### What user provides:
|
||||||
|
|
||||||
|
1. **XSD schema** for their payload (or simpler DSL that generates XSD)
|
||||||
|
2. **Lua script** with handler function
|
||||||
|
|
||||||
|
### Example Lua script:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- my_handler.lua
|
||||||
|
|
||||||
|
-- Called for each incoming message
|
||||||
|
function handle(payload, meta)
|
||||||
|
-- payload is a Lua table matching the XSD structure
|
||||||
|
local name = payload.name
|
||||||
|
local greeting = "Hello, " .. name .. "!"
|
||||||
|
|
||||||
|
-- Return response
|
||||||
|
return {
|
||||||
|
to = "next-agent",
|
||||||
|
payload = {
|
||||||
|
message = greeting,
|
||||||
|
original_sender = meta.from_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Optional: called once on load
|
||||||
|
function init(config)
|
||||||
|
print("Handler initialized")
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example XSD:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||||
|
<xs:element name="MyPayload">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="name" type="xs:string"/>
|
||||||
|
<xs:element name="count" type="xs:integer" minOccurs="0"/>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
</xs:schema>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registration in organism.yaml:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
listeners:
|
||||||
|
- name: my-custom-agent
|
||||||
|
type: lua
|
||||||
|
xsd: schemas/my_payload.xsd
|
||||||
|
script: scripts/my_handler.lua # or UUID for SaaS
|
||||||
|
description: My custom processing agent
|
||||||
|
peers: [next-agent]
|
||||||
|
|
||||||
|
# Optional Lua config passed to init()
|
||||||
|
config:
|
||||||
|
greeting_style: enthusiastic
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Python side:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class LuaListener:
|
||||||
|
"""Generic listener that delegates processing to Lua."""
|
||||||
|
|
||||||
|
def __init__(self, name: str, xsd_path: str, script_path: str, config: dict = None):
|
||||||
|
self.name = name
|
||||||
|
self.xsd = load_xsd(xsd_path)
|
||||||
|
self.sandbox = LuaSandbox()
|
||||||
|
self.sandbox.load_script(script_path)
|
||||||
|
|
||||||
|
# Call init if defined
|
||||||
|
if self.sandbox.has_function("init"):
|
||||||
|
self.sandbox.call("init", config or {})
|
||||||
|
|
||||||
|
async def handle(self, xml_payload: str, metadata: HandlerMetadata) -> HandlerResponse:
|
||||||
|
# Convert XML to Lua table
|
||||||
|
lua_table = xml_to_lua_table(xml_payload)
|
||||||
|
|
||||||
|
# Convert metadata
|
||||||
|
lua_meta = {
|
||||||
|
"thread_id": metadata.thread_id,
|
||||||
|
"from_id": metadata.from_id,
|
||||||
|
"own_name": metadata.own_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Call user's Lua handler
|
||||||
|
result = self.sandbox.call("handle", lua_table, lua_meta)
|
||||||
|
|
||||||
|
# Convert response back to XML
|
||||||
|
response_xml = lua_table_to_xml(result["payload"])
|
||||||
|
|
||||||
|
return HandlerResponse(
|
||||||
|
payload=response_xml,
|
||||||
|
to=result["to"]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lua Sandbox:
|
||||||
|
|
||||||
|
Using `lupa` (LuaJIT bindings for Python):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from lupa import LuaRuntime
|
||||||
|
|
||||||
|
class LuaSandbox:
|
||||||
|
def __init__(self, memory_limit_mb: int = 50, time_limit_sec: float = 5.0):
|
||||||
|
# Create restricted Lua runtime
|
||||||
|
self.lua = LuaRuntime(unpack_returned_tuples=True)
|
||||||
|
self.memory_limit = memory_limit_mb * 1024 * 1024
|
||||||
|
self.time_limit = time_limit_sec
|
||||||
|
|
||||||
|
# Remove dangerous functions
|
||||||
|
self._restrict_globals()
|
||||||
|
|
||||||
|
def _restrict_globals(self):
|
||||||
|
"""Remove dangerous Lua functions."""
|
||||||
|
dangerous = [
|
||||||
|
'os', 'io', 'loadfile', 'dofile',
|
||||||
|
'load', 'loadstring', 'require', 'package',
|
||||||
|
'debug', 'collectgarbage'
|
||||||
|
]
|
||||||
|
for name in dangerous:
|
||||||
|
self.lua.globals()[name] = None
|
||||||
|
|
||||||
|
# Provide safe subset
|
||||||
|
self.lua.globals()['print'] = self._safe_print
|
||||||
|
self.lua.globals()['json'] = self._json_module()
|
||||||
|
|
||||||
|
def _safe_print(self, *args):
|
||||||
|
"""Print that goes to log, not stdout."""
|
||||||
|
# Log instead of print
|
||||||
|
pass
|
||||||
|
|
||||||
|
def call(self, func_name: str, *args):
|
||||||
|
"""Call Lua function with timeout."""
|
||||||
|
func = self.lua.globals()[func_name]
|
||||||
|
# TODO: implement timeout
|
||||||
|
return func(*args)
|
||||||
|
```
|
||||||
|
|
||||||
|
### XML ↔ Lua conversion:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
def xml_to_lua_table(xml_str: str) -> dict:
|
||||||
|
"""Convert XML to nested dict (becomes Lua table)."""
|
||||||
|
root = ET.fromstring(xml_str)
|
||||||
|
return element_to_dict(root)
|
||||||
|
|
||||||
|
def element_to_dict(elem) -> dict:
|
||||||
|
result = {}
|
||||||
|
for child in elem:
|
||||||
|
if len(child) > 0:
|
||||||
|
result[child.tag] = element_to_dict(child)
|
||||||
|
else:
|
||||||
|
result[child.tag] = child.text
|
||||||
|
return result
|
||||||
|
|
||||||
|
def lua_table_to_xml(table: dict, root_name: str = "Payload") -> str:
|
||||||
|
"""Convert Lua table (dict) back to XML."""
|
||||||
|
root = ET.Element(root_name)
|
||||||
|
dict_to_element(table, root)
|
||||||
|
return ET.tostring(root, encoding='unicode')
|
||||||
|
|
||||||
|
def dict_to_element(d: dict, parent):
|
||||||
|
for key, value in d.items():
|
||||||
|
child = ET.SubElement(parent, key)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
dict_to_element(value, child)
|
||||||
|
else:
|
||||||
|
child.text = str(value)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sandbox Security
|
||||||
|
|
||||||
|
### Restricted Lua environment:
|
||||||
|
|
||||||
|
| Removed | Why |
|
||||||
|
|---------|-----|
|
||||||
|
| `os` | File system, env vars, process control |
|
||||||
|
| `io` | File I/O |
|
||||||
|
| `loadfile`, `dofile` | Load external code |
|
||||||
|
| `require`, `package` | Module system |
|
||||||
|
| `debug` | Introspection, can escape sandbox |
|
||||||
|
|
||||||
|
### Provided safe functions:
|
||||||
|
|
||||||
|
| Function | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `print()` | Logs to agent log (not stdout) |
|
||||||
|
| `json.encode()` | Table to JSON string |
|
||||||
|
| `json.decode()` | JSON string to table |
|
||||||
|
| `string.*` | String manipulation |
|
||||||
|
| `table.*` | Table manipulation |
|
||||||
|
| `math.*` | Math functions |
|
||||||
|
|
||||||
|
### Resource limits:
|
||||||
|
|
||||||
|
- **Memory:** 50MB per script (configurable)
|
||||||
|
- **CPU time:** 5 seconds per invocation (configurable)
|
||||||
|
- **Stack depth:** Limited to prevent recursion attacks
|
||||||
|
|
||||||
|
## Alternative: Schema DSL
|
||||||
|
|
||||||
|
Instead of requiring XSD, provide a simpler schema language:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- In the Lua script itself
|
||||||
|
schema = {
|
||||||
|
name = "string",
|
||||||
|
count = "number?", -- optional
|
||||||
|
tags = {"string"}, -- list of strings
|
||||||
|
address = { -- nested object
|
||||||
|
street = "string",
|
||||||
|
city = "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Python generates XSD from this declaration at registration time.
|
||||||
|
|
||||||
|
### Type mappings:
|
||||||
|
|
||||||
|
| Lua DSL | XSD Type |
|
||||||
|
|---------|----------|
|
||||||
|
| `"string"` | `xs:string` |
|
||||||
|
| `"number"` | `xs:decimal` |
|
||||||
|
| `"integer"` | `xs:integer` |
|
||||||
|
| `"boolean"` | `xs:boolean` |
|
||||||
|
| `"string?"` | `xs:string` with `minOccurs="0"` |
|
||||||
|
| `{"string"}` | `xs:string` with `maxOccurs="unbounded"` |
|
||||||
|
| `{...}` | Nested `xs:complexType` |
|
||||||
|
|
||||||
|
## Hot Reload
|
||||||
|
|
||||||
|
For SaaS, scripts should be hot-reloadable:
|
||||||
|
|
||||||
|
1. User updates Lua script via GUI/API
|
||||||
|
2. System detects change (or explicit reload trigger)
|
||||||
|
3. New sandbox created with new script
|
||||||
|
4. Old sandbox drained (finish in-flight requests)
|
||||||
|
5. New requests go to new sandbox
|
||||||
|
6. Old sandbox destroyed
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# API endpoint
|
||||||
|
POST /agents/{name}/reload
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
Lua errors should:
|
||||||
|
1. Not crash the organism
|
||||||
|
2. Be logged with context
|
||||||
|
3. Return error response to caller
|
||||||
|
4. Optionally trigger alerting
|
||||||
|
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
result = self.sandbox.call("handle", payload, meta)
|
||||||
|
except LuaError as e:
|
||||||
|
log.error(f"Lua error in {self.name}: {e}")
|
||||||
|
return HandlerResponse(
|
||||||
|
error=str(e),
|
||||||
|
to="error-handler"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- [ ] How to handle async operations in Lua? (LLM calls, HTTP requests)
|
||||||
|
- [ ] Should Lua scripts be able to call other agents directly?
|
||||||
|
- [ ] Version control for scripts? (rollback support)
|
||||||
|
- [ ] Testing framework for Lua handlers?
|
||||||
|
- [ ] How to expose `platform.complete()` to Lua safely?
|
||||||
|
- [ ] Metrics/observability for Lua execution?
|
||||||
|
|
||||||
|
## Pros/Cons
|
||||||
|
|
||||||
|
### Pros
|
||||||
|
- Users can customize without Python knowledge
|
||||||
|
- Strong sandboxing possible
|
||||||
|
- Hot reload without restart
|
||||||
|
- Portable scripts (no Python dependencies)
|
||||||
|
|
||||||
|
### Cons
|
||||||
|
- Another language to support
|
||||||
|
- XML ↔ Lua conversion overhead
|
||||||
|
- Limited async support in Lua
|
||||||
|
- Users still need to understand XSD (or we need DSL)
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Prototype `lupa` integration
|
||||||
|
2. Benchmark XML ↔ Lua conversion
|
||||||
|
3. Design schema DSL
|
||||||
|
4. Security audit of sandbox
|
||||||
|
5. Design hot-reload mechanism
|
||||||
Loading…
Reference in a new issue