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:
24
services/toolcore/Dockerfile
Normal file
24
services/toolcore/Dockerfile
Normal 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
304
services/toolcore/README.md
Normal 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
|
||||
|
||||
|
||||
|
||||
|
||||
80
services/toolcore/config.yaml
Normal file
80
services/toolcore/config.yaml
Normal 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
|
||||
|
||||
|
||||
|
||||
|
||||
8
services/toolcore/executors/__init__.py
Normal file
8
services/toolcore/executors/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from .http_executor import HTTPExecutor
|
||||
from .python_executor import PythonExecutor
|
||||
|
||||
__all__ = ['HTTPExecutor', 'PythonExecutor']
|
||||
|
||||
|
||||
|
||||
|
||||
104
services/toolcore/executors/http_executor.py
Normal file
104
services/toolcore/executors/http_executor.py
Normal 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()
|
||||
|
||||
|
||||
|
||||
|
||||
68
services/toolcore/executors/python_executor.py
Normal file
68
services/toolcore/executors/python_executor.py
Normal 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
197
services/toolcore/main.py
Normal 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)
|
||||
|
||||
|
||||
|
||||
|
||||
32
services/toolcore/models.py
Normal file
32
services/toolcore/models.py
Normal 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
|
||||
|
||||
|
||||
|
||||
|
||||
79
services/toolcore/registry.py
Normal file
79
services/toolcore/registry.py
Normal 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
|
||||
|
||||
|
||||
|
||||
|
||||
10
services/toolcore/requirements.txt
Normal file
10
services/toolcore/requirements.txt
Normal 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
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user