feat: Add presence heartbeat for Matrix online status

- matrix-gateway: POST /internal/matrix/presence/online endpoint
- usePresenceHeartbeat hook with activity tracking
- Auto away after 5 min inactivity
- Offline on page close/visibility change
- Integrated in MatrixChatRoom component
This commit is contained in:
Apple
2025-11-27 00:19:40 -08:00
parent 5bed515852
commit 3de3c8cb36
6371 changed files with 1317450 additions and 932 deletions

View File

@@ -0,0 +1,864 @@
# TASK: PHASE 2 — AGENT INTEGRATION (agent_filter + Router + agent-runtime)
**Goal:**
Активувати повний ланцюг агентних відповідей у Messenger:
User → messaging-service → matrix-gateway → Matrix → NATS → agent_filter → DAGI Router → agent-runtime → LLM → messaging-service → Matrix → Frontend.
**Deliverables:**
- `services/agent-filter/` (rules + NATS + internal API)
- router extension (messaging.inbound → router.invoke.agent)
- `services/agent-runtime/` (LLM + memory + posting to channel)
- docker-compose integration
- documentation updates
---
## 1) SERVICE: agent-filter
**Create:** `services/agent-filter/`
**Files:**
- `main.py`
- `models.py`
- `rules.py`
- `config.yaml`
- `Dockerfile`
- `requirements.txt`
- `README.md`
### Specs:
#### models.py:
```python
from pydantic import BaseModel
from typing import Optional, Literal
from datetime import datetime
class MessageCreatedEvent(BaseModel):
channel_id: str
message_id: Optional[str] = None
matrix_event_id: str
sender_id: str
sender_type: Literal["human", "agent"]
microdao_id: str
created_at: datetime
class FilterDecision(BaseModel):
channel_id: str
message_id: Optional[str] = None
matrix_event_id: str
microdao_id: str
decision: Literal["allow", "deny", "modify"]
target_agent_id: Optional[str] = None
rewrite_prompt: Optional[str] = None
class ChannelContext(BaseModel):
microdao_id: str
visibility: Literal["public", "private", "microdao"]
allowed_agents: list[str] = []
disabled_agents: list[str] = []
class FilterContext(BaseModel):
channel: ChannelContext
sender_is_owner: bool = False
sender_is_admin: bool = False
sender_is_member: bool = True
local_time: Optional[datetime] = None
```
#### rules.py:
```python
from models import MessageCreatedEvent, FilterContext, FilterDecision
from datetime import datetime, time
import yaml
class FilterRules:
def __init__(self, config_path: str = "config.yaml"):
with open(config_path, 'r') as f:
self.config = yaml.safe_load(f)
self.quiet_hours_start = datetime.strptime(
self.config['rules']['quiet_hours']['start'],
"%H:%M"
).time()
self.quiet_hours_end = datetime.strptime(
self.config['rules']['quiet_hours']['end'],
"%H:%M"
).time()
self.default_agents = self.config['rules'].get('default_agents', {})
def is_quiet_hours(self, dt: datetime) -> bool:
current_time = dt.time()
if self.quiet_hours_start > self.quiet_hours_end:
# Overnight range (e.g., 23:00 - 07:00)
return current_time >= self.quiet_hours_start or current_time <= self.quiet_hours_end
else:
return self.quiet_hours_start <= current_time <= self.quiet_hours_end
def decide(self, event: MessageCreatedEvent, ctx: FilterContext) -> FilterDecision:
"""
Baseline rules v1:
- Block agent→agent loops
- Map channel → allowed_agents
- Apply quiet_hours
- Return FilterDecision with decision + target_agent_id
"""
base_decision = FilterDecision(
channel_id=event.channel_id,
message_id=event.message_id,
matrix_event_id=event.matrix_event_id,
microdao_id=event.microdao_id,
decision="deny"
)
# Rule 1: Block agent→agent loops
if event.sender_type == "agent":
return base_decision
# Rule 2: Check if agent is disabled
if ctx.channel.disabled_agents:
# For now, deny if any disabled agents exist
return base_decision
# Rule 3: Find target agent
target_agent_id = None
if ctx.channel.allowed_agents:
target_agent_id = ctx.channel.allowed_agents[0]
elif event.microdao_id in self.default_agents:
target_agent_id = self.default_agents[event.microdao_id]
if not target_agent_id:
return base_decision
# Rule 4: Check quiet hours
if ctx.local_time and self.is_quiet_hours(ctx.local_time):
return FilterDecision(
channel_id=event.channel_id,
message_id=event.message_id,
matrix_event_id=event.matrix_event_id,
microdao_id=event.microdao_id,
decision="modify",
target_agent_id=target_agent_id,
rewrite_prompt="Відповідай стисло і тільки якщо запит важливий. Не ініціюй розмову сам."
)
# Rule 5: Allow
return FilterDecision(
channel_id=event.channel_id,
message_id=event.message_id,
matrix_event_id=event.matrix_event_id,
microdao_id=event.microdao_id,
decision="allow",
target_agent_id=target_agent_id
)
```
#### main.py:
```python
from fastapi import FastAPI, HTTPException
from models import MessageCreatedEvent, FilterDecision, ChannelContext, FilterContext
from rules import FilterRules
import httpx
import asyncio
import json
from datetime import datetime, timezone
import os
app = FastAPI(title="DAARION Agent Filter", version="1.0.0")
# Configuration
MESSAGING_SERVICE_URL = os.getenv("MESSAGING_SERVICE_URL", "http://messaging-service:7004")
NATS_URL = os.getenv("NATS_URL", "nats://nats:4222")
# Rules engine
rules_engine = FilterRules("config.yaml")
# NATS setup (mocked for now, replace with actual NATS client)
# import nats
# nc = None
@app.on_event("startup")
async def startup_event():
print("Agent Filter starting up...")
# nc = await nats.connect(NATS_URL)
# await subscribe_to_messaging_events()
asyncio.create_task(mock_nats_listener())
async def mock_nats_listener():
"""Mock NATS listener for testing"""
print("Mock NATS listener started (replace with actual NATS subscription)")
# In production:
# sub = await nc.subscribe("messaging.message.created")
# async for msg in sub.messages:
# await handle_message_created(json.loads(msg.data.decode()))
async def handle_message_created(event_data: dict):
"""Process incoming message.created events"""
try:
event = MessageCreatedEvent(**event_data)
# Fetch channel context
ctx = await fetch_channel_context(event.channel_id)
# Apply rules
decision = rules_engine.decide(event, ctx)
# Publish decision to NATS
await publish_decision(decision)
print(f"Decision: {decision.decision} for channel {event.channel_id}")
except Exception as e:
print(f"Error processing message: {e}")
async def fetch_channel_context(channel_id: str) -> FilterContext:
"""Fetch channel context from messaging-service"""
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{MESSAGING_SERVICE_URL}/internal/messaging/channels/{channel_id}/context"
)
response.raise_for_status()
data = response.json()
channel_ctx = ChannelContext(**data)
return FilterContext(
channel=channel_ctx,
local_time=datetime.now(timezone.utc)
)
except httpx.HTTPStatusError as e:
print(f"HTTP error fetching context: {e}")
# Return default context
return FilterContext(
channel=ChannelContext(
microdao_id="microdao:daarion",
visibility="microdao"
),
local_time=datetime.now(timezone.utc)
)
except Exception as e:
print(f"Error fetching context: {e}")
return FilterContext(
channel=ChannelContext(
microdao_id="microdao:daarion",
visibility="microdao"
),
local_time=datetime.now(timezone.utc)
)
async def publish_decision(decision: FilterDecision):
"""Publish decision to NATS"""
# In production:
# await nc.publish("agent.filter.decision", decision.json().encode())
print(f"Publishing decision: {decision.json()}")
@app.get("/health")
async def health():
return {"status": "ok", "service": "agent-filter"}
@app.post("/internal/agent-filter/test", response_model=FilterDecision)
async def test_filter(event: MessageCreatedEvent):
"""Test endpoint for manual filtering"""
ctx = await fetch_channel_context(event.channel_id)
decision = rules_engine.decide(event, ctx)
return decision
```
#### config.yaml:
```yaml
nats:
servers: ["nats://nats:4222"]
messaging_subject: "messaging.message.created"
decision_subject: "agent.filter.decision"
rules:
quiet_hours:
start: "23:00"
end: "07:00"
default_agents:
"microdao:daarion": "agent:sofia"
"microdao:7": "agent:sofia"
```
#### requirements.txt:
```txt
fastapi==0.104.1
uvicorn==0.24.0
pydantic==2.5.0
httpx==0.25.1
python-nats==2.6.0
PyYAML==6.0.1
```
#### Dockerfile:
```dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7005"]
```
#### README.md:
```markdown
# Agent Filter Service
Security & routing layer for DAARION agents in Messenger.
## Purpose
- Subscribe to NATS `messaging.message.created`
- Apply filtering rules (permissions, content, timing)
- Decide which agent should reply
- Publish to `agent.filter.decision`
## Rules
1. Block agent→agent loops
2. Map channels to allowed agents
3. Apply quiet hours (23:0007:00)
4. Check disabled agents
## Running Locally
```bash
pip install -r requirements.txt
uvicorn main:app --reload --port 7005
```
## Testing
```bash
curl -X POST http://localhost:7005/internal/agent-filter/test \
-H "Content-Type: application/json" \
-d '{
"channel_id": "test-channel",
"matrix_event_id": "$event123",
"sender_id": "user:1",
"sender_type": "human",
"microdao_id": "microdao:daarion",
"created_at": "2025-11-24T10:00:00Z"
}'
```
## NATS Events
- **Subscribes to:** `messaging.message.created`
- **Publishes to:** `agent.filter.decision`
```
---
## 2) DAGI ROUTER EXTENSION
**Extend existing router service:**
### Add subscription:
```python
# In router service (e.g., services/router/main.py)
from pydantic import BaseModel
from typing import Literal
import json
class FilterDecision(BaseModel):
channel_id: str
message_id: Optional[str] = None
matrix_event_id: str
microdao_id: str
decision: Literal["allow", "deny", "modify"]
target_agent_id: Optional[str] = None
rewrite_prompt: Optional[str] = None
class AgentInvocation(BaseModel):
agent_id: str
entrypoint: Literal["channel_message", "direct", "cron"] = "channel_message"
payload: dict
async def handle_filter_decision(decision_data: dict):
"""Process agent.filter.decision events"""
decision = FilterDecision(**decision_data)
# Only process 'allow' decisions
if decision.decision != "allow":
print(f"Ignoring non-allow decision: {decision.decision}")
return
if not decision.target_agent_id:
print(f"No target agent specified, ignoring")
return
# Create AgentInvocation
invocation = AgentInvocation(
agent_id=decision.target_agent_id,
entrypoint="channel_message",
payload={
"channel_id": decision.channel_id,
"message_id": decision.message_id,
"matrix_event_id": decision.matrix_event_id,
"microdao_id": decision.microdao_id,
"rewrite_prompt": decision.rewrite_prompt
}
)
# Publish to NATS
await publish_agent_invocation(invocation)
print(f"Routed to {invocation.agent_id}")
async def publish_agent_invocation(invocation: AgentInvocation):
"""Publish to router.invoke.agent"""
# await nc.publish("router.invoke.agent", invocation.json().encode())
print(f"Publishing invocation: {invocation.json()}")
# Add to router startup
async def subscribe_to_filter_decisions():
"""Subscribe to agent.filter.decision"""
# sub = await nc.subscribe("agent.filter.decision")
# async for msg in sub.messages:
# await handle_filter_decision(json.loads(msg.data.decode()))
pass
```
### Add test endpoint:
```python
@app.post("/internal/router/test-messaging", response_model=AgentInvocation)
async def test_messaging_route(decision: FilterDecision):
"""Test endpoint for routing logic"""
if decision.decision != "allow" or not decision.target_agent_id:
raise HTTPException(status_code=400, detail="Decision not routable")
invocation = AgentInvocation(
agent_id=decision.target_agent_id,
entrypoint="channel_message",
payload={
"channel_id": decision.channel_id,
"message_id": decision.message_id,
"matrix_event_id": decision.matrix_event_id,
"microdao_id": decision.microdao_id,
"rewrite_prompt": decision.rewrite_prompt
}
)
return invocation
```
### Update config:
Create/update `router_config.yaml`:
```yaml
messaging_inbound:
enabled: true
source_subject: "agent.filter.decision"
target_subject: "router.invoke.agent"
```
---
## 3) SERVICE: agent-runtime
**Create:** `services/agent-runtime/`
**Files:**
- `main.py`
- `models.py`
- `llm_client.py`
- `messaging_client.py`
- `memory_client.py`
- `config.yaml`
- `Dockerfile`
- `requirements.txt`
- `README.md`
### Specs:
#### models.py:
```python
from pydantic import BaseModel
from typing import Literal, Optional
from datetime import datetime
class AgentInvocation(BaseModel):
agent_id: str
entrypoint: Literal["channel_message", "direct", "cron"] = "channel_message"
payload: dict
class AgentBlueprint(BaseModel):
id: str
name: str
model: str
instructions: str
capabilities: dict = {}
class ChannelMessage(BaseModel):
sender_id: str
sender_type: Literal["human", "agent"]
content: str
created_at: datetime
```
#### llm_client.py:
```python
import httpx
import os
LLM_PROXY_URL = os.getenv("LLM_PROXY_URL", "http://llm-proxy:7007")
async def generate_response(model: str, messages: list[dict]) -> str:
"""Call LLM Proxy to generate response"""
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{LLM_PROXY_URL}/internal/llm/proxy",
json={
"model": model,
"messages": messages
}
)
response.raise_for_status()
data = response.json()
return data.get("content", "")
except httpx.HTTPStatusError as e:
print(f"LLM Proxy error: {e}")
return "Вибачте, не можу відповісти зараз. (LLM error)"
except Exception as e:
print(f"Error calling LLM: {e}")
return "Вибачте, сталася помилка. (Connection error)"
```
#### messaging_client.py:
```python
import httpx
import os
from models import ChannelMessage
MESSAGING_SERVICE_URL = os.getenv("MESSAGING_SERVICE_URL", "http://messaging-service:7004")
async def get_channel_messages(channel_id: str, limit: int = 50) -> list[ChannelMessage]:
"""Fetch recent messages from channel"""
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{MESSAGING_SERVICE_URL}/internal/messaging/channels/{channel_id}/messages",
params={"limit": limit}
)
response.raise_for_status()
data = response.json()
return [ChannelMessage(**msg) for msg in data]
except Exception as e:
print(f"Error fetching messages: {e}")
return []
async def post_message(agent_id: str, channel_id: str, text: str) -> bool:
"""Post agent reply to channel"""
try:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{MESSAGING_SERVICE_URL}/internal/agents/{agent_id}/post-to-channel",
json={
"channel_id": channel_id,
"text": text
}
)
response.raise_for_status()
return True
except Exception as e:
print(f"Error posting message: {e}")
return False
```
#### memory_client.py:
```python
import httpx
import os
AGENT_MEMORY_URL = os.getenv("AGENT_MEMORY_URL", "http://agent-memory:7008")
async def query_memory(agent_id: str, microdao_id: str, query: str) -> list[dict]:
"""Query agent memory for relevant context"""
try:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{AGENT_MEMORY_URL}/internal/agent-memory/query",
json={
"agent_id": agent_id,
"microdao_id": microdao_id,
"query": query
}
)
response.raise_for_status()
data = response.json()
return data.get("results", [])
except Exception as e:
print(f"Error querying memory: {e}")
return []
```
#### main.py:
```python
from fastapi import FastAPI
from models import AgentInvocation, AgentBlueprint, ChannelMessage
from llm_client import generate_response
from messaging_client import get_channel_messages, post_message
from memory_client import query_memory
import asyncio
import json
app = FastAPI(title="DAARION Agent Runtime", version="1.0.0")
@app.on_event("startup")
async def startup_event():
print("Agent Runtime starting up...")
asyncio.create_task(mock_nats_listener())
async def mock_nats_listener():
"""Mock NATS listener (replace with actual subscription)"""
print("Mock NATS listener started")
# In production:
# sub = await nc.subscribe("router.invoke.agent")
# async for msg in sub.messages:
# await handle_invocation(json.loads(msg.data.decode()))
async def handle_invocation(invocation_data: dict):
"""Process agent invocation"""
try:
invocation = AgentInvocation(**invocation_data)
if invocation.entrypoint != "channel_message":
print(f"Ignoring non-channel_message invocation: {invocation.entrypoint}")
return
# Extract payload
channel_id = invocation.payload.get("channel_id")
microdao_id = invocation.payload.get("microdao_id")
rewrite_prompt = invocation.payload.get("rewrite_prompt")
# 1. Load agent blueprint (mock for now)
blueprint = await load_agent_blueprint(invocation.agent_id)
# 2. Load channel history
messages = await get_channel_messages(channel_id, limit=50)
# 3. Get last human message
last_human_msg = next(
(msg for msg in reversed(messages) if msg.sender_type == "human"),
None
)
if not last_human_msg:
print("No human message found, skipping")
return
# 4. Query memory
memory_results = await query_memory(
invocation.agent_id,
microdao_id,
last_human_msg.content
)
# 5. Build prompt
system_prompt = blueprint.instructions
if rewrite_prompt:
system_prompt += f"\n\nAdditional instructions: {rewrite_prompt}"
llm_messages = [
{"role": "system", "content": system_prompt}
]
# Add recent context
for msg in messages[-10:]:
role = "assistant" if msg.sender_type == "agent" else "user"
llm_messages.append({
"role": role,
"content": msg.content
})
# Add memory context
if memory_results:
memory_context = "\n\n".join([r.get("text", "") for r in memory_results[:3]])
llm_messages.insert(1, {
"role": "system",
"content": f"Relevant knowledge:\n{memory_context}"
})
# 6. Generate response
response_text = await generate_response(blueprint.model, llm_messages)
# 7. Post to channel
success = await post_message(invocation.agent_id, channel_id, response_text)
if success:
print(f"Agent {invocation.agent_id} replied successfully")
else:
print(f"Failed to post agent reply")
except Exception as e:
print(f"Error handling invocation: {e}")
async def load_agent_blueprint(agent_id: str) -> AgentBlueprint:
"""Load agent blueprint (mock for now)"""
# In production: GET /internal/agents/{agent_id}/blueprint
return AgentBlueprint(
id=agent_id,
name="Sofia-Prime",
model="gpt-4",
instructions="Ти Sofia, помічниця команди DAARION. Допомагай планувати, організовувати та підсумовувати роботу."
)
@app.get("/health")
async def health():
return {"status": "ok", "service": "agent-runtime"}
@app.post("/internal/agent-runtime/test-channel")
async def test_channel(invocation: AgentInvocation):
"""Test endpoint for manual invocation"""
await handle_invocation(invocation.dict())
return {"status": "processed"}
```
#### config.yaml:
```yaml
nats:
servers: ["nats://nats:4222"]
invocation_subject: "router.invoke.agent"
services:
messaging: "http://messaging-service:7004"
agent_memory: "http://agent-memory:7008"
llm_proxy: "http://llm-proxy:7007"
```
#### requirements.txt:
```txt
fastapi==0.104.1
uvicorn==0.24.0
pydantic==2.5.0
httpx==0.25.1
python-nats==2.6.0
PyYAML==6.0.1
```
#### Dockerfile:
```dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7006"]
```
---
## 4) DOCKER + COMPOSE
**Create:** `docker-compose.agents.yml`
```yaml
version: '3.8'
services:
agent-filter:
build:
context: ./services/agent-filter
dockerfile: Dockerfile
restart: always
environment:
MESSAGING_SERVICE_URL: http://messaging-service:7004
NATS_URL: nats://nats:4222
ports:
- "7005:7005"
depends_on:
- nats
- messaging-service
networks:
- daarion
agent-runtime:
build:
context: ./services/agent-runtime
dockerfile: Dockerfile
restart: always
environment:
MESSAGING_SERVICE_URL: http://messaging-service:7004
AGENT_MEMORY_URL: http://agent-memory:7008
LLM_PROXY_URL: http://llm-proxy:7007
NATS_URL: nats://nats:4222
ports:
- "7006:7006"
depends_on:
- nats
- messaging-service
networks:
- daarion
networks:
daarion:
external: true
```
**Update existing `docker-compose.messenger.yml` to include agents:**
```yaml
# Add at the end
agent-filter:
extends:
file: docker-compose.agents.yml
service: agent-filter
agent-runtime:
extends:
file: docker-compose.agents.yml
service: agent-runtime
```
---
## 5) ACCEPTANCE CRITERIA
**Phase 2 Complete When:**
- ✅ Human writes message in Messenger
- ✅ messaging-service → matrix-gateway → Matrix works
- ✅ Matrix webhook triggers messaging.message.created
- ✅ agent_filter receives → outputs agent.filter.decision
- ✅ Router receives → emits router.invoke.agent
- ✅ agent-runtime receives → generates LLM answer
- ✅ agent-runtime posts reply → messaging-service → Matrix → Messenger UI
- ✅ Reply visible in Element + Messenger
- ✅ E2E latency < 5 seconds
---
## END OF TASK
**Implementation Order:**
1. agent-filter (1 week)
2. Router extension (3 days)
3. agent-runtime (2 weeks)
4. Docker integration (2 days)
5. Testing (3 days)
**Total Estimated Time:** 4 weeks