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,24 @@
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
# Expose port
EXPOSE 7009
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import httpx; httpx.get('http://localhost:7009/health').raise_for_status()"
# Run
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7009"]

304
services/toolcore/README.md Normal file
View File

@@ -0,0 +1,304 @@
# Toolcore Service
**Port:** 7009
**Purpose:** Tool registry and execution engine for DAARION agents
## Features
**Tool registry:**
- Config-driven (Phase 3)
- DB-backed (Phase 4)
- Permission model (agent allowlists)
**Executors:**
- HTTP executor (call external services)
- Python executor (stub for Phase 3)
**Security:**
- Permission checks (agent → tool)
- Timeouts per tool
- Error handling
## API
### GET /internal/tools
List available tools:
**Request:**
```bash
curl http://localhost:7009/internal/tools?agent_id=agent:sofia \
-H "X-Internal-Secret: dev-secret-token"
```
**Response:**
```json
{
"tools": [
{
"id": "projects.list",
"name": "List Projects",
"description": "Returns a list of projects for a microDAO",
"input_schema": { ... },
"executor": "http"
}
],
"total": 3
}
```
### POST /internal/tools/call
Execute a tool:
**Request:**
```json
{
"tool_id": "projects.list",
"agent_id": "agent:sofia",
"microdao_id": "microdao:7",
"args": {
"microdao_id": "microdao:7"
},
"context": {
"channel_id": "channel-uuid",
"user_id": "user:123"
}
}
```
**Response:**
```json
{
"ok": true,
"result": {
"projects": [
{ "id": "proj-1", "name": "Phase 3", "status": "active" }
]
},
"tool_id": "projects.list",
"latency_ms": 123.4
}
```
**Error Response:**
```json
{
"ok": false,
"error": "Connection failed: http://projects-service:8000/...",
"tool_id": "projects.list",
"latency_ms": 5000.0
}
```
### GET /internal/tools/{tool_id}
Get tool definition:
```bash
curl http://localhost:7009/internal/tools/projects.list \
-H "X-Internal-Secret: dev-secret-token"
```
## Configuration
Edit `config.yaml`:
```yaml
tools:
- id: "projects.list"
name: "List Projects"
description: "Returns a list of projects for a microDAO"
input_schema:
type: "object"
properties:
microdao_id: { type: "string" }
required: ["microdao_id"]
output_schema:
type: "array"
items: { type: "object" }
executor: "http"
target: "http://projects-service:8000/internal/tools/projects.list"
allowed_agents: ["agent:sofia", "agent:pm"]
timeout: 10
enabled: true
```
## Adding New Tools
### HTTP Tool
```yaml
- id: "my.tool"
name: "My Tool"
description: "Does something useful"
input_schema: { ... }
output_schema: { ... }
executor: "http"
target: "http://my-service:8000/tool/endpoint"
allowed_agents: ["agent:sofia"] # or null for all
timeout: 30
enabled: true
```
### Python Tool (Phase 4)
```yaml
- id: "my.python.tool"
name: "My Python Tool"
executor: "python"
target: "tools.my_module:my_function"
...
```
## Integration with agent-runtime
```python
import httpx
async def call_tool(tool_id, agent_id, args):
async with httpx.AsyncClient() as client:
response = await client.post(
"http://toolcore:7009/internal/tools/call",
headers={"X-Internal-Secret": "dev-secret-token"},
json={
"tool_id": tool_id,
"agent_id": agent_id,
"microdao_id": "microdao:7",
"args": args
}
)
result = response.json()
if not result["ok"]:
print(f"Tool failed: {result['error']}")
return None
return result["result"]
```
## Example: projects-service Tool Endpoint
```python
# projects-service/main.py
@app.post("/internal/tools/projects.list")
async def projects_list_tool(request: dict):
"""
Tool endpoint for projects.list
Expected payload:
{
"args": { "microdao_id": "..." },
"context": { ... }
}
"""
microdao_id = request["args"]["microdao_id"]
# Fetch projects from DB
projects = await db.fetch_projects(microdao_id)
return {
"projects": [
{"id": p.id, "name": p.name, "status": p.status}
for p in projects
]
}
```
## Setup
### Environment Variables
```bash
TOOLCORE_SECRET=dev-secret-token
```
### Local Development
```bash
cd services/toolcore
pip install -r requirements.txt
python main.py
```
### Docker
```bash
docker build -t toolcore .
docker run -p 7009:7009 toolcore
```
## Security Model
### Permission Checks
```python
allowed_agents: ["agent:sofia", "agent:pm"]
# Only these agents can call this tool
allowed_agents: null
# All agents can call this tool
```
### Timeouts
Each tool has a `timeout` (seconds). If execution exceeds timeout, it fails gracefully.
### Error Handling
- Connection errors → `ok: false`
- HTTP errors → `ok: false` with status code
- Timeouts → `ok: false` with timeout message
## Roadmap
### Phase 3 (Current):
- ✅ Config-based registry
- ✅ HTTP executor
- ✅ Python executor stub
- ✅ Permission checks
- ✅ 3 example tools
### Phase 3.5:
- 🔜 Tool usage tracking
- 🔜 Tool rate limiting
- 🔜 Advanced error handling
- 🔜 Tool chaining
### Phase 4:
- 🔜 DB-backed registry
- 🔜 Python executor with sandboxing
- 🔜 Tool marketplace
- 🔜 Agent-created tools
- 🔜 Tool versioning
## Troubleshooting
**Tool not found?**
- Check `config.yaml` for tool definition
- Restart service after config changes
**Permission denied?**
- Check `allowed_agents` in tool definition
- Ensure agent_id matches exactly
**Tool timeout?**
- Check if target service is running
- Increase `timeout` in config
**HTTP executor failing?**
- Test target URL directly: `curl http://...`
- Check service logs
---
**Status:** ✅ Phase 3 Ready
**Version:** 1.0.0
**Last Updated:** 2025-11-24

View File

@@ -0,0 +1,80 @@
tools:
- id: "projects.list"
name: "List Projects"
description: "Returns a list of projects for a microDAO"
input_schema:
type: "object"
properties:
microdao_id:
type: "string"
description: "MicroDAO ID"
required: ["microdao_id"]
output_schema:
type: "array"
items:
type: "object"
properties:
id: { type: "string" }
name: { type: "string" }
status: { type: "string" }
executor: "http"
target: "http://projects-service:8000/internal/tools/projects.list"
allowed_agents: ["agent:sofia", "agent:pm", "agent:cto"]
timeout: 10
enabled: true
- id: "task.create"
name: "Create Task"
description: "Creates a new task in a project"
input_schema:
type: "object"
properties:
project_id:
type: "string"
title:
type: "string"
description:
type: "string"
required: ["project_id", "title"]
output_schema:
type: "object"
properties:
id: { type: "string" }
status: { type: "string" }
executor: "http"
target: "http://projects-service:8000/internal/tools/task.create"
allowed_agents: ["agent:sofia", "agent:pm"]
timeout: 15
enabled: true
- id: "followup.create"
name: "Create Follow-up"
description: "Schedules a follow-up reminder for the agent"
input_schema:
type: "object"
properties:
channel_id:
type: "string"
message:
type: "string"
delay_hours:
type: "integer"
required: ["channel_id", "message", "delay_hours"]
output_schema:
type: "object"
properties:
id: { type: "string" }
scheduled_at: { type: "string" }
executor: "http"
target: "http://agent-runtime:7006/internal/tools/followup.create"
allowed_agents: null # All agents
timeout: 5
enabled: true
logging:
log_calls: true
log_results: false # Don't log full results for privacy

View File

@@ -0,0 +1,8 @@
from .http_executor import HTTPExecutor
from .python_executor import PythonExecutor
__all__ = ['HTTPExecutor', 'PythonExecutor']

View File

@@ -0,0 +1,104 @@
import httpx
import time
from typing import Dict, Any
from models import ToolCallResult
class HTTPExecutor:
"""Execute tools via HTTP calls"""
def __init__(self):
self.client = httpx.AsyncClient()
async def execute(
self,
tool_id: str,
target: str,
args: Dict[str, Any],
context: Dict[str, Any],
timeout: int = 30
) -> ToolCallResult:
"""
Execute tool via HTTP POST
Sends args + context to target URL
"""
start_time = time.time()
payload = {
"args": args,
"context": context
}
try:
response = await self.client.post(
target,
json=payload,
timeout=timeout
)
response.raise_for_status()
result = response.json()
latency_ms = (time.time() - start_time) * 1000
return ToolCallResult(
ok=True,
result=result,
tool_id=tool_id,
latency_ms=latency_ms
)
except httpx.ConnectError as e:
latency_ms = (time.time() - start_time) * 1000
error_msg = f"Connection failed: {target} ({str(e)})"
print(f"{error_msg}")
return ToolCallResult(
ok=False,
error=error_msg,
tool_id=tool_id,
latency_ms=latency_ms
)
except httpx.HTTPStatusError as e:
latency_ms = (time.time() - start_time) * 1000
error_msg = f"HTTP {e.response.status_code}: {e.response.text}"
print(f"❌ Tool {tool_id} failed: {error_msg}")
return ToolCallResult(
ok=False,
error=error_msg,
tool_id=tool_id,
latency_ms=latency_ms
)
except httpx.TimeoutException:
latency_ms = (time.time() - start_time) * 1000
error_msg = f"Timeout after {timeout}s"
print(f"⏱️ Tool {tool_id} timeout")
return ToolCallResult(
ok=False,
error=error_msg,
tool_id=tool_id,
latency_ms=latency_ms
)
except Exception as e:
latency_ms = (time.time() - start_time) * 1000
error_msg = f"Unexpected error: {str(e)}"
print(f"❌ Tool {tool_id} error: {error_msg}")
return ToolCallResult(
ok=False,
error=error_msg,
tool_id=tool_id,
latency_ms=latency_ms
)
async def close(self):
"""Close HTTP client"""
await self.client.aclose()

View File

@@ -0,0 +1,68 @@
import time
import importlib
from typing import Dict, Any
from models import ToolCallResult
class PythonExecutor:
"""
Execute tools via Python function calls
Phase 3: Stub implementation
Phase 4: Full implementation with sandboxing
"""
async def execute(
self,
tool_id: str,
target: str,
args: Dict[str, Any],
context: Dict[str, Any],
timeout: int = 30
) -> ToolCallResult:
"""
Execute Python function
target format: "module.path:function_name"
Example: "tools.projects:list_projects"
Phase 3: Stub - returns placeholder
Phase 4: Actually import and call function with proper sandboxing
"""
start_time = time.time()
print(f" Python executor (stub): {tool_id}{target}")
# TODO Phase 4: Implement proper Python execution
# 1. Parse target (module:function)
# 2. Import module dynamically
# 3. Call function with args
# 4. Add sandboxing/security
latency_ms = (time.time() - start_time) * 1000
return ToolCallResult(
ok=False,
error="Python executor not implemented in Phase 3",
tool_id=tool_id,
latency_ms=latency_ms
)
# Stub for Phase 4 implementation:
"""
async def execute_real(self, tool_id, target, args, context, timeout):
module_path, func_name = target.split(':')
module = importlib.import_module(module_path)
func = getattr(module, func_name)
# Call function with timeout
result = await asyncio.wait_for(
func(**args, context=context),
timeout=timeout
)
return ToolCallResult(ok=True, result=result, tool_id=tool_id)
"""

197
services/toolcore/main.py Normal file
View File

@@ -0,0 +1,197 @@
"""
DAARION Toolcore Service
Port: 7009
Tool registry and execution engine for agents
"""
import os
from fastapi import FastAPI, HTTPException, Header
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from models import ToolDefinition, ToolCallRequest, ToolCallResult
from registry import ToolRegistry
from executors import HTTPExecutor, PythonExecutor
# ============================================================================
# Global State
# ============================================================================
registry: ToolRegistry | None = None
http_executor: HTTPExecutor | None = None
python_executor: PythonExecutor | None = None
# ============================================================================
# App Setup
# ============================================================================
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup and shutdown"""
global registry, http_executor, python_executor
# Startup
print("🚀 Starting Toolcore service...")
registry = ToolRegistry()
http_executor = HTTPExecutor()
python_executor = PythonExecutor()
print(f"✅ Toolcore ready with {len(registry.tools)} tools")
yield
# Shutdown
print("🛑 Shutting down Toolcore...")
if http_executor:
await http_executor.close()
app = FastAPI(
title="DAARION Toolcore",
version="1.0.0",
description="Tool registry and execution engine",
lifespan=lifespan
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ============================================================================
# API Endpoints
# ============================================================================
@app.get("/internal/tools")
async def list_tools(
agent_id: str | None = None,
x_internal_secret: str = Header(None, alias="X-Internal-Secret")
):
"""
List available tools
If agent_id provided, filters by allowed_agents
"""
expected_secret = os.getenv("TOOLCORE_SECRET", "dev-secret-token")
if x_internal_secret != expected_secret:
raise HTTPException(401, "Invalid or missing X-Internal-Secret header")
tools = registry.list_tools(agent_id=agent_id)
return {
"tools": [
{
"id": tool.id,
"name": tool.name,
"description": tool.description,
"input_schema": tool.input_schema,
"executor": tool.executor
}
for tool in tools
],
"total": len(tools)
}
@app.post("/internal/tools/call", response_model=ToolCallResult)
async def call_tool(
request: ToolCallRequest,
x_internal_secret: str = Header(None, alias="X-Internal-Secret")
):
"""
Execute a tool
Steps:
1. Check permission (agent → tool)
2. Get tool definition
3. Call appropriate executor
4. Return result
"""
expected_secret = os.getenv("TOOLCORE_SECRET", "dev-secret-token")
if x_internal_secret != expected_secret:
raise HTTPException(401, "Invalid or missing X-Internal-Secret header")
# Check permission
allowed, reason = registry.check_permission(request.tool_id, request.agent_id)
if not allowed:
raise HTTPException(403, reason)
# Get tool definition
tool = registry.get_tool(request.tool_id)
if not tool:
raise HTTPException(404, f"Tool not found: {request.tool_id}")
# Log call
print(f"🔧 Tool call: {request.tool_id} by {request.agent_id}")
# Execute
try:
if tool.executor == "http":
result = await http_executor.execute(
tool_id=tool.id,
target=tool.target,
args=request.args,
context=request.context or {},
timeout=tool.timeout
)
elif tool.executor == "python":
result = await python_executor.execute(
tool_id=tool.id,
target=tool.target,
args=request.args,
context=request.context or {},
timeout=tool.timeout
)
else:
raise HTTPException(500, f"Unknown executor: {tool.executor}")
# Log result
if result.ok:
print(f"✅ Tool {request.tool_id} succeeded in {result.latency_ms:.0f}ms")
else:
print(f"❌ Tool {request.tool_id} failed: {result.error}")
return result
except Exception as e:
raise HTTPException(500, f"Tool execution failed: {str(e)}")
@app.get("/internal/tools/{tool_id}")
async def get_tool(
tool_id: str,
x_internal_secret: str = Header(None, alias="X-Internal-Secret")
):
"""Get tool definition"""
expected_secret = os.getenv("TOOLCORE_SECRET", "dev-secret-token")
if x_internal_secret != expected_secret:
raise HTTPException(401, "Invalid or missing X-Internal-Secret header")
tool = registry.get_tool(tool_id)
if not tool:
raise HTTPException(404, f"Tool not found: {tool_id}")
return tool
@app.get("/health")
async def health():
"""Health check"""
return {
"status": "ok",
"service": "toolcore",
"tools": len(registry.tools) if registry else 0
}
# ============================================================================
# Run
# ============================================================================
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=7009)

View File

@@ -0,0 +1,32 @@
from pydantic import BaseModel, Field
from typing import Literal, Optional, Dict, Any
class ToolDefinition(BaseModel):
id: str = Field(..., description="Unique tool ID, e.g., 'projects.list'")
name: str = Field(..., description="Human-readable tool name")
description: str = Field(..., description="What this tool does")
input_schema: Dict[str, Any] = Field(..., description="JSON Schema for input")
output_schema: Dict[str, Any] = Field(..., description="JSON Schema for output")
executor: Literal["http", "python"] = Field("http", description="Execution method")
target: str = Field(..., description="HTTP URL or Python path")
allowed_agents: Optional[list[str]] = Field(None, description="Allowlist of agent IDs. None = all agents")
timeout: int = Field(30, ge=1, le=300, description="Timeout in seconds")
enabled: bool = Field(True, description="Whether tool is enabled")
class ToolCallRequest(BaseModel):
tool_id: str = Field(..., description="Tool ID to call")
agent_id: str = Field(..., description="Agent making the call")
microdao_id: str = Field(..., description="MicroDAO context")
args: Dict[str, Any] = Field(default_factory=dict, description="Tool arguments")
context: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Additional context")
class ToolCallResult(BaseModel):
ok: bool
result: Optional[Dict[str, Any]] = None
error: Optional[str] = None
tool_id: str
latency_ms: Optional[float] = None

View File

@@ -0,0 +1,79 @@
import yaml
import os
from models import ToolDefinition
from typing import Dict, Optional
class ToolRegistry:
"""Tool registry for Phase 3 (config-based)"""
def __init__(self, config_path: str = "config.yaml"):
self.tools: Dict[str, ToolDefinition] = {}
self._load_config(config_path)
def _load_config(self, config_path: str):
"""Load tool definitions from YAML"""
if not os.path.exists(config_path):
print(f"⚠️ Config file not found: {config_path}")
return
with open(config_path, 'r') as f:
config = yaml.safe_load(f)
for tool_config in config.get('tools', []):
tool_def = ToolDefinition(**tool_config)
self.tools[tool_def.id] = tool_def
print(f"✅ Loaded tool: {tool_def.id} ({tool_def.executor})")
print(f"📋 Total tools loaded: {len(self.tools)}")
def get_tool(self, tool_id: str) -> Optional[ToolDefinition]:
"""Get tool definition by ID"""
return self.tools.get(tool_id)
def list_tools(self, agent_id: Optional[str] = None) -> list[ToolDefinition]:
"""
List available tools
If agent_id provided, filter by allowed_agents
"""
tools = list(self.tools.values())
if agent_id:
tools = [
tool for tool in tools
if tool.enabled and (
tool.allowed_agents is None or
agent_id in tool.allowed_agents
)
]
else:
tools = [tool for tool in tools if tool.enabled]
return tools
def check_permission(self, tool_id: str, agent_id: str) -> tuple[bool, Optional[str]]:
"""
Check if agent has permission to use tool
Returns: (allowed: bool, reason: str | None)
"""
tool = self.get_tool(tool_id)
if not tool:
return False, f"Tool not found: {tool_id}"
if not tool.enabled:
return False, f"Tool is disabled: {tool_id}"
if tool.allowed_agents is None:
# No restrictions
return True, None
if agent_id not in tool.allowed_agents:
return False, f"Agent {agent_id} not in allowlist for {tool_id}"
return True, None

View File

@@ -0,0 +1,10 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
pydantic==2.5.3
httpx==0.26.0
pyyaml==6.0.1
python-multipart==0.0.6