mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-30 19:02:16 +00:00
Add orchestrator training, channels, LiteLLM engine, and simplify learning taxonomy
Major changes across parallel sessions: - Add orchestrator SFT & GRPO training subpackage (learning/orchestrator/) with episode types, multi-objective reward, prompt registry, policy model, RL environment, and registered learning policies - Add structured THOUGHT/TOOL/INPUT/FINAL_ANSWER mode to OrchestratorAgent - Add 15 channel backends (Discord, Slack, Telegram, Email, Webhook, IRC, Matrix, Teams, WhatsApp, Signal, Mattermost, BlueBubbles, Feishu, Google Chat, Webchat) with channel tools and config - Add LiteLLM engine backend for unified LLM provider access - Add RLM agent and REPL tool - Remove ToolLearningPolicy — learning taxonomy now only targets Intelligence (LM weights/routing) and Agents (logic/ICL/tool strategies) - Rename SFTPolicy to SFTRouterPolicy (backward-compat alias kept) - Remove OpenClaw agent infrastructure (openclaw*.py, openclaw_bridge.py) - Fix async streaming tests (asyncio.run vs deprecated get_event_loop) - Fix server channel route tests (pytest.importorskip for optional fastapi) - Track uv.lock for reproducibility - Update CLAUDE.md and docs to reflect all changes 1676 tests pass, 37 skipped. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a0961633a3
commit
8d538cd1b0
@@ -20,11 +20,6 @@ try:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.agents.openclaw # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.agents.react # noqa: F401
|
||||
except ImportError:
|
||||
@@ -35,4 +30,9 @@ try:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.agents.rlm # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
__all__ = ["AgentContext", "AgentResult", "BaseAgent"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""ABC for agent implementations.
|
||||
|
||||
Adapted from IPW's ``BaseAgent`` at ``src/agents/base.py``.
|
||||
Phase 3 will provide concrete implementations (SimpleAgent, OpenClawAgent, etc.).
|
||||
Phase 3 will provide concrete implementations (SimpleAgent, OrchestratorAgent, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
"""OpenClawAgent — wraps the OpenClaw Pi agent via HTTP or subprocess transport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from openjarvis.agents._stubs import AgentContext, AgentResult, BaseAgent
|
||||
from openjarvis.agents.openclaw_protocol import MessageType, ProtocolMessage
|
||||
from openjarvis.agents.openclaw_transport import (
|
||||
HttpTransport,
|
||||
OpenClawTransport,
|
||||
SubprocessTransport,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
from openjarvis.core.types import ToolCall, ToolResult
|
||||
|
||||
|
||||
@AgentRegistry.register("openclaw")
|
||||
class OpenClawAgent(BaseAgent):
|
||||
"""Wraps the OpenClaw Pi agent via HTTP or subprocess transport.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
engine:
|
||||
Inference engine (used as fallback or for provider plugin).
|
||||
model:
|
||||
Model identifier.
|
||||
transport:
|
||||
Optional pre-configured transport. If None, one is created based on *mode*.
|
||||
mode:
|
||||
Transport mode: ``"http"`` (default) or ``"subprocess"``.
|
||||
bus:
|
||||
Optional event bus for telemetry.
|
||||
"""
|
||||
|
||||
agent_id = "openclaw"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: Any = None,
|
||||
model: str = "",
|
||||
*,
|
||||
transport: Optional[OpenClawTransport] = None,
|
||||
mode: str = "http",
|
||||
bus: Optional[EventBus] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self._engine = engine
|
||||
self._model = model
|
||||
self._bus = bus
|
||||
|
||||
if transport is not None:
|
||||
self._transport = transport
|
||||
elif mode == "http":
|
||||
self._transport = HttpTransport()
|
||||
elif mode == "subprocess":
|
||||
self._transport = SubprocessTransport()
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown OpenClaw mode: {mode!r}. "
|
||||
"Use 'http' or 'subprocess'."
|
||||
)
|
||||
|
||||
def run(
|
||||
self,
|
||||
input: str,
|
||||
context: Optional[AgentContext] = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentResult:
|
||||
"""Send a query through the OpenClaw transport and handle tool calls."""
|
||||
if self._bus:
|
||||
self._bus.publish(EventType.AGENT_TURN_START, {"agent": self.agent_id})
|
||||
|
||||
# Check transport health
|
||||
if not self._transport.health():
|
||||
raise RuntimeError(
|
||||
"OpenClaw transport is not healthy. "
|
||||
"Ensure the OpenClaw server is running."
|
||||
)
|
||||
|
||||
# Build and send query message
|
||||
query_msg = ProtocolMessage(
|
||||
type=MessageType.QUERY,
|
||||
content=input,
|
||||
metadata={"model": self._model},
|
||||
)
|
||||
|
||||
tool_results: list[ToolResult] = []
|
||||
turns = 0
|
||||
max_turns = 10
|
||||
|
||||
response = self._transport.send(query_msg)
|
||||
turns += 1
|
||||
|
||||
# Handle tool-call loop
|
||||
while response.type == MessageType.TOOL_CALL and turns < max_turns:
|
||||
if self._bus:
|
||||
self._bus.publish(EventType.TOOL_CALL_START, {
|
||||
"tool": response.tool_name,
|
||||
"arguments": response.tool_args,
|
||||
})
|
||||
|
||||
# Execute tool locally
|
||||
tool_result = self._execute_tool(
|
||||
response.tool_name or "",
|
||||
response.tool_args or {},
|
||||
)
|
||||
tool_results.append(tool_result)
|
||||
|
||||
if self._bus:
|
||||
self._bus.publish(EventType.TOOL_CALL_END, {
|
||||
"tool": response.tool_name,
|
||||
"success": tool_result.success,
|
||||
})
|
||||
|
||||
# Send tool result back
|
||||
result_msg = ProtocolMessage(
|
||||
type=MessageType.TOOL_RESULT,
|
||||
tool_name=response.tool_name,
|
||||
tool_result=tool_result.content,
|
||||
metadata={"tool_call_id": response.id},
|
||||
)
|
||||
response = self._transport.send(result_msg)
|
||||
turns += 1
|
||||
|
||||
# Handle error response
|
||||
if response.type == MessageType.ERROR:
|
||||
content = (
|
||||
response.error or response.content
|
||||
or "Unknown error from OpenClaw"
|
||||
)
|
||||
else:
|
||||
content = response.content
|
||||
|
||||
if self._bus:
|
||||
self._bus.publish(EventType.AGENT_TURN_END, {
|
||||
"agent": self.agent_id,
|
||||
"turns": turns,
|
||||
})
|
||||
|
||||
return AgentResult(
|
||||
content=content,
|
||||
tool_results=tool_results,
|
||||
turns=turns,
|
||||
)
|
||||
|
||||
def _execute_tool(self, tool_name: str, tool_args: dict) -> ToolResult:
|
||||
"""Dispatch a tool call to the local tool executor."""
|
||||
try:
|
||||
from openjarvis.core.registry import ToolRegistry
|
||||
|
||||
if not ToolRegistry.contains(tool_name):
|
||||
return ToolResult(
|
||||
tool_name=tool_name,
|
||||
content=f"Unknown tool: {tool_name}",
|
||||
success=False,
|
||||
)
|
||||
|
||||
tool_cls = ToolRegistry.get(tool_name)
|
||||
tool = tool_cls() if callable(tool_cls) else tool_cls
|
||||
tc = ToolCall(
|
||||
id="openclaw-tool",
|
||||
name=tool_name,
|
||||
arguments=json.dumps(tool_args),
|
||||
)
|
||||
|
||||
from openjarvis.tools._stubs import ToolExecutor
|
||||
|
||||
executor = ToolExecutor([tool], bus=self._bus)
|
||||
return executor.execute(tc)
|
||||
except Exception as exc:
|
||||
return ToolResult(
|
||||
tool_name=tool_name,
|
||||
content=f"Tool execution error: {exc}",
|
||||
success=False,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["OpenClawAgent"]
|
||||
@@ -1,86 +0,0 @@
|
||||
"""OpenClaw plugin skeleton — wraps OpenJarvis as an OpenClaw provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
class ProviderPlugin:
|
||||
"""Wraps OpenJarvis as an OpenClaw ProviderPlugin.
|
||||
|
||||
Implements the provider interface expected by the OpenClaw framework:
|
||||
``generate()`` for inference and ``list_models()`` for model discovery.
|
||||
"""
|
||||
|
||||
def __init__(self, engine: Any = None, model: str = "", bus: Any = None) -> None:
|
||||
self._engine = engine
|
||||
self._model = model
|
||||
self._bus = bus
|
||||
|
||||
def generate(self, prompt: str, **kwargs: Any) -> Dict[str, Any]:
|
||||
"""Generate a response using the wrapped OpenJarvis engine."""
|
||||
if self._engine is None:
|
||||
raise RuntimeError("No engine configured for ProviderPlugin")
|
||||
|
||||
from openjarvis.core.types import Message, Role
|
||||
|
||||
messages = [Message(role=Role.USER, content=prompt)]
|
||||
if self._bus:
|
||||
from openjarvis.telemetry.wrapper import instrumented_generate
|
||||
|
||||
return instrumented_generate(
|
||||
self._engine, messages, model=self._model,
|
||||
bus=self._bus, **kwargs,
|
||||
)
|
||||
return self._engine.generate(messages, model=self._model, **kwargs)
|
||||
|
||||
def list_models(self) -> List[str]:
|
||||
"""List available models from the wrapped engine."""
|
||||
if self._engine is None:
|
||||
return []
|
||||
return self._engine.list_models()
|
||||
|
||||
|
||||
class MemorySearchManager:
|
||||
"""OpenClaw search/sync/status interface backed by memory."""
|
||||
|
||||
def __init__(self, backend: Any = None) -> None:
|
||||
self._backend = backend
|
||||
|
||||
def search(self, query: str, *, top_k: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Search memory for relevant documents."""
|
||||
if self._backend is None:
|
||||
return []
|
||||
results = self._backend.retrieve(query, top_k=top_k)
|
||||
return [
|
||||
{"content": r.content, "score": r.score, "source": r.source}
|
||||
for r in results
|
||||
]
|
||||
|
||||
def sync(self) -> Dict[str, Any]:
|
||||
"""Synchronize memory state (no-op for now)."""
|
||||
return {"status": "ok", "synced": True}
|
||||
|
||||
def status(self) -> Dict[str, Any]:
|
||||
"""Return the memory backend status."""
|
||||
if self._backend is None:
|
||||
return {"available": False}
|
||||
bid = getattr(self._backend, "backend_id", "unknown")
|
||||
return {"available": True, "backend": bid}
|
||||
|
||||
|
||||
def register() -> Dict[str, Any]:
|
||||
"""Entry point for OpenClaw plugin registration.
|
||||
|
||||
Returns a dict describing the plugin capabilities.
|
||||
"""
|
||||
return {
|
||||
"name": "openjarvis",
|
||||
"version": "1.0.0",
|
||||
"capabilities": ["inference", "memory"],
|
||||
"provider_class": ProviderPlugin,
|
||||
"memory_class": MemorySearchManager,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["MemorySearchManager", "ProviderPlugin", "register"]
|
||||
@@ -1,92 +0,0 @@
|
||||
"""OpenClaw wire protocol — JSON-line message serialization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
class MessageType(str, Enum):
|
||||
"""Wire message types for OpenClaw communication."""
|
||||
|
||||
QUERY = "query"
|
||||
RESPONSE = "response"
|
||||
TOOL_CALL = "tool_call"
|
||||
TOOL_RESULT = "tool_result"
|
||||
ERROR = "error"
|
||||
HEALTH = "health"
|
||||
HEALTH_OK = "health_ok"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ProtocolMessage:
|
||||
"""A single message in the OpenClaw protocol."""
|
||||
|
||||
type: MessageType
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
content: str = ""
|
||||
tool_name: Optional[str] = None
|
||||
tool_args: Optional[Dict[str, Any]] = None
|
||||
tool_result: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def serialize(msg: ProtocolMessage) -> str:
|
||||
"""Serialize a ProtocolMessage to a JSON line."""
|
||||
obj: Dict[str, Any] = {
|
||||
"type": msg.type.value,
|
||||
"id": msg.id,
|
||||
"content": msg.content,
|
||||
}
|
||||
if msg.tool_name is not None:
|
||||
obj["tool_name"] = msg.tool_name
|
||||
if msg.tool_args is not None:
|
||||
obj["tool_args"] = msg.tool_args
|
||||
if msg.tool_result is not None:
|
||||
obj["tool_result"] = msg.tool_result
|
||||
if msg.error is not None:
|
||||
obj["error"] = msg.error
|
||||
if msg.metadata:
|
||||
obj["metadata"] = msg.metadata
|
||||
return json.dumps(obj)
|
||||
|
||||
|
||||
def deserialize(line: str) -> ProtocolMessage:
|
||||
"""Deserialize a JSON line into a ProtocolMessage.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the JSON is invalid or the message type is unknown.
|
||||
"""
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid JSON: {exc}") from exc
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
raise ValueError("Expected a JSON object")
|
||||
|
||||
raw_type = obj.get("type", "")
|
||||
try:
|
||||
msg_type = MessageType(raw_type)
|
||||
except ValueError:
|
||||
raise ValueError(f"Unknown message type: {raw_type!r}") from None
|
||||
|
||||
return ProtocolMessage(
|
||||
type=msg_type,
|
||||
id=obj.get("id", str(uuid.uuid4())),
|
||||
content=obj.get("content", ""),
|
||||
tool_name=obj.get("tool_name"),
|
||||
tool_args=obj.get("tool_args"),
|
||||
tool_result=obj.get("tool_result"),
|
||||
error=obj.get("error"),
|
||||
metadata=obj.get("metadata", {}),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["MessageType", "ProtocolMessage", "deserialize", "serialize"]
|
||||
@@ -1,143 +0,0 @@
|
||||
"""Transport abstraction for OpenClaw agent communication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
from openjarvis.agents.openclaw_protocol import (
|
||||
MessageType,
|
||||
ProtocolMessage,
|
||||
deserialize,
|
||||
serialize,
|
||||
)
|
||||
|
||||
|
||||
class OpenClawTransport(ABC):
|
||||
"""Base class for OpenClaw transport implementations."""
|
||||
|
||||
@abstractmethod
|
||||
def send(self, msg: ProtocolMessage) -> ProtocolMessage:
|
||||
"""Send a message and return the response."""
|
||||
|
||||
@abstractmethod
|
||||
def health(self) -> bool:
|
||||
"""Return True if the transport endpoint is healthy."""
|
||||
|
||||
@abstractmethod
|
||||
def close(self) -> None:
|
||||
"""Release transport resources."""
|
||||
|
||||
|
||||
class HttpTransport(OpenClawTransport):
|
||||
"""HTTP-based transport for communicating with an OpenClaw server."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "http://localhost:18789",
|
||||
timeout: float = 30.0,
|
||||
) -> None:
|
||||
self._host = host.rstrip("/")
|
||||
self._timeout = timeout
|
||||
|
||||
def send(self, msg: ProtocolMessage) -> ProtocolMessage:
|
||||
"""POST a message to the OpenClaw HTTP endpoint."""
|
||||
import httpx
|
||||
|
||||
payload = json.loads(serialize(msg))
|
||||
resp = httpx.post(
|
||||
f"{self._host}/api/query",
|
||||
json=payload,
|
||||
timeout=self._timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return deserialize(json.dumps(resp.json()))
|
||||
|
||||
def health(self) -> bool:
|
||||
"""GET /health and return True if status is 200."""
|
||||
import httpx
|
||||
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{self._host}/health",
|
||||
timeout=5.0,
|
||||
)
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def close(self) -> None:
|
||||
"""No persistent resources to release."""
|
||||
|
||||
|
||||
class SubprocessTransport(OpenClawTransport):
|
||||
"""Subprocess-based transport — launches a Node.js process and
|
||||
communicates via JSON over stdin/stdout."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
node_path: str = "node",
|
||||
script_path: str = "",
|
||||
) -> None:
|
||||
self._node_path = node_path
|
||||
self._script_path = script_path
|
||||
self._process: Optional[subprocess.Popen] = None
|
||||
|
||||
def _start_process(self) -> None:
|
||||
"""Start the Node.js subprocess."""
|
||||
self._process = subprocess.Popen(
|
||||
[self._node_path, self._script_path],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
|
||||
def _ensure_alive(self) -> None:
|
||||
"""Start the process if it's not running."""
|
||||
if self._process is None or self._process.poll() is not None:
|
||||
self._start_process()
|
||||
|
||||
def send(self, msg: ProtocolMessage) -> ProtocolMessage:
|
||||
"""Send a message via stdin and read the response from stdout."""
|
||||
self._ensure_alive()
|
||||
assert self._process is not None
|
||||
assert self._process.stdin is not None
|
||||
assert self._process.stdout is not None
|
||||
|
||||
line = serialize(msg) + "\n"
|
||||
self._process.stdin.write(line)
|
||||
self._process.stdin.flush()
|
||||
|
||||
response_line = self._process.stdout.readline()
|
||||
if not response_line:
|
||||
raise RuntimeError("No response from OpenClaw subprocess")
|
||||
return deserialize(response_line.strip())
|
||||
|
||||
def health(self) -> bool:
|
||||
"""Check if the subprocess is alive and responsive."""
|
||||
try:
|
||||
self._ensure_alive()
|
||||
health_msg = ProtocolMessage(type=MessageType.HEALTH)
|
||||
resp = self.send(health_msg)
|
||||
return resp.type == MessageType.HEALTH_OK
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def close(self) -> None:
|
||||
"""Terminate the subprocess."""
|
||||
if self._process is not None:
|
||||
try:
|
||||
self._process.terminate()
|
||||
self._process.wait(timeout=5)
|
||||
except Exception:
|
||||
try:
|
||||
self._process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
self._process = None
|
||||
|
||||
|
||||
__all__ = ["HttpTransport", "OpenClawTransport", "SubprocessTransport"]
|
||||
@@ -1,7 +1,18 @@
|
||||
"""OrchestratorAgent — multi-turn agent with tool-calling loop."""
|
||||
"""OrchestratorAgent — multi-turn agent with tool-calling loop.
|
||||
|
||||
Supports two modes:
|
||||
|
||||
- **function_calling** (default): Uses OpenAI-format tool definitions and
|
||||
parses ``tool_calls`` from the engine response.
|
||||
- **structured**: Uses a THOUGHT/TOOL/INPUT/FINAL_ANSWER text format
|
||||
(like ReAct) with a canonical system prompt from the orchestrator
|
||||
prompt registry. This is the format used by the SFT/GRPO training
|
||||
pipelines, making the Orchestrator a distinctive trainable agent type.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from openjarvis.agents._stubs import AgentContext, AgentResult, BaseAgent
|
||||
@@ -21,6 +32,11 @@ class OrchestratorAgent(BaseAgent):
|
||||
2. If the response contains tool_calls, execute them and loop.
|
||||
3. If no tool_calls, return the final answer.
|
||||
4. Stop after ``max_turns`` iterations.
|
||||
|
||||
In **structured** mode the agent instead uses a
|
||||
``THOUGHT: / TOOL: / INPUT: / FINAL_ANSWER:`` text protocol
|
||||
identical to the format used by the orchestrator SFT/GRPO
|
||||
training pipelines.
|
||||
"""
|
||||
|
||||
agent_id = "orchestrator"
|
||||
@@ -35,6 +51,8 @@ class OrchestratorAgent(BaseAgent):
|
||||
max_turns: int = 10,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 1024,
|
||||
mode: str = "function_calling",
|
||||
system_prompt: Optional[str] = None,
|
||||
) -> None:
|
||||
self._engine = engine
|
||||
self._model = model
|
||||
@@ -44,12 +62,186 @@ class OrchestratorAgent(BaseAgent):
|
||||
self._max_turns = max_turns
|
||||
self._temperature = temperature
|
||||
self._max_tokens = max_tokens
|
||||
self._mode = mode
|
||||
self._system_prompt = system_prompt
|
||||
|
||||
def run(
|
||||
self,
|
||||
input: str,
|
||||
context: Optional[AgentContext] = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentResult:
|
||||
if self._mode == "structured":
|
||||
return self._run_structured(input, context, **kwargs)
|
||||
return self._run_function_calling(input, context, **kwargs)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Structured mode (THOUGHT/TOOL/INPUT/FINAL_ANSWER)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _run_structured(
|
||||
self,
|
||||
input: str,
|
||||
context: Optional[AgentContext] = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentResult:
|
||||
bus = self._bus
|
||||
|
||||
if bus:
|
||||
bus.publish(
|
||||
EventType.AGENT_TURN_START,
|
||||
{"agent": self.agent_id, "input": input},
|
||||
)
|
||||
|
||||
# Build system prompt
|
||||
if self._system_prompt:
|
||||
sys_prompt = self._system_prompt
|
||||
else:
|
||||
from openjarvis.learning.orchestrator.prompt_registry import (
|
||||
build_system_prompt,
|
||||
)
|
||||
tool_names = [t.spec.name for t in self._tools]
|
||||
sys_prompt = build_system_prompt(tool_names)
|
||||
|
||||
messages: list[Message] = [
|
||||
Message(role=Role.SYSTEM, content=sys_prompt),
|
||||
]
|
||||
if context and context.conversation.messages:
|
||||
messages.extend(context.conversation.messages)
|
||||
messages.append(Message(role=Role.USER, content=input))
|
||||
|
||||
all_tool_results: list[ToolResult] = []
|
||||
turns = 0
|
||||
|
||||
for _turn in range(self._max_turns):
|
||||
turns += 1
|
||||
|
||||
result = self._engine.generate(
|
||||
messages,
|
||||
model=self._model,
|
||||
temperature=self._temperature,
|
||||
max_tokens=self._max_tokens,
|
||||
)
|
||||
content = result.get("content", "")
|
||||
|
||||
parsed = self._parse_structured_response(content)
|
||||
|
||||
# FINAL_ANSWER → done
|
||||
if parsed["final_answer"]:
|
||||
if bus:
|
||||
bus.publish(
|
||||
EventType.AGENT_TURN_END,
|
||||
{"agent": self.agent_id, "turns": turns},
|
||||
)
|
||||
return AgentResult(
|
||||
content=parsed["final_answer"],
|
||||
tool_results=all_tool_results,
|
||||
turns=turns,
|
||||
)
|
||||
|
||||
# TOOL → execute
|
||||
if parsed["tool"]:
|
||||
messages.append(
|
||||
Message(role=Role.ASSISTANT, content=content)
|
||||
)
|
||||
|
||||
tool_call = ToolCall(
|
||||
id=f"orch_{turns}",
|
||||
name=parsed["tool"],
|
||||
arguments=parsed["input"] or "{}",
|
||||
)
|
||||
tool_result = self._executor.execute(tool_call)
|
||||
all_tool_results.append(tool_result)
|
||||
|
||||
observation = (
|
||||
f"Observation: {tool_result.content}"
|
||||
)
|
||||
messages.append(
|
||||
Message(role=Role.USER, content=observation)
|
||||
)
|
||||
continue
|
||||
|
||||
# Neither → treat content as final answer
|
||||
if bus:
|
||||
bus.publish(
|
||||
EventType.AGENT_TURN_END,
|
||||
{"agent": self.agent_id, "turns": turns},
|
||||
)
|
||||
return AgentResult(
|
||||
content=content,
|
||||
tool_results=all_tool_results,
|
||||
turns=turns,
|
||||
)
|
||||
|
||||
# Max turns exceeded
|
||||
if bus:
|
||||
bus.publish(
|
||||
EventType.AGENT_TURN_END,
|
||||
{
|
||||
"agent": self.agent_id,
|
||||
"turns": turns,
|
||||
"max_turns_exceeded": True,
|
||||
},
|
||||
)
|
||||
return AgentResult(
|
||||
content="Maximum turns reached without a final answer.",
|
||||
tool_results=all_tool_results,
|
||||
turns=turns,
|
||||
metadata={"max_turns_exceeded": True},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_structured_response(text: str) -> dict:
|
||||
"""Parse THOUGHT/TOOL/INPUT/FINAL_ANSWER from model output."""
|
||||
result = {
|
||||
"thought": "",
|
||||
"tool": "",
|
||||
"input": "",
|
||||
"final_answer": "",
|
||||
}
|
||||
|
||||
thought_match = re.search(
|
||||
r"THOUGHT:\s*(.+?)(?=\nTOOL:|\nFINAL[_ ]?ANSWER:|\Z)",
|
||||
text,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
if thought_match:
|
||||
result["thought"] = thought_match.group(1).strip()
|
||||
|
||||
final_match = re.search(
|
||||
r"FINAL[_ ]?ANSWER:\s*(.+)",
|
||||
text,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
if final_match:
|
||||
result["final_answer"] = final_match.group(1).strip()
|
||||
return result
|
||||
|
||||
tool_match = re.search(
|
||||
r"TOOL:\s*(.+)", text, re.IGNORECASE
|
||||
)
|
||||
if tool_match:
|
||||
result["tool"] = tool_match.group(1).strip()
|
||||
|
||||
input_match = re.search(
|
||||
r"INPUT:\s*(.+?)(?=\nTHOUGHT:|\nTOOL:|\nFINAL|\Z)",
|
||||
text,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
if input_match:
|
||||
result["input"] = input_match.group(1).strip()
|
||||
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Function-calling mode (original behaviour)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _run_function_calling(
|
||||
self,
|
||||
input: str,
|
||||
context: Optional[AgentContext] = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentResult:
|
||||
bus = self._bus
|
||||
|
||||
@@ -128,7 +320,6 @@ class OrchestratorAgent(BaseAgent):
|
||||
all_tool_results.append(tool_result)
|
||||
|
||||
# Append tool response message
|
||||
# Serialize arguments for the content
|
||||
messages.append(Message(
|
||||
role=Role.TOOL,
|
||||
content=tool_result.content,
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
"""RLM (Recursive Language Model) Agent — recursive decomposition via persistent REPL.
|
||||
|
||||
Based on the RLM paper (arxiv:2512.24601). Instead of passing long context
|
||||
directly in the LLM prompt, RLM stores context as a Python variable in a
|
||||
persistent REPL. A "Root LM" writes Python code to inspect/decompose
|
||||
context and makes recursive sub-LM calls via ``llm_query()``/``llm_batch()``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from openjarvis.agents._stubs import AgentContext, AgentResult, BaseAgent
|
||||
from openjarvis.agents.rlm_repl import RLMRepl
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
from openjarvis.core.types import Message, Role, ToolCall, ToolResult
|
||||
from openjarvis.engine._stubs import InferenceEngine
|
||||
from openjarvis.tools._stubs import BaseTool, ToolExecutor
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System prompt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RLM_SYSTEM_PROMPT = (
|
||||
"You are an AI assistant that solves problems by writing "
|
||||
"Python code in a persistent REPL.\n\n"
|
||||
"## Available REPL Functions\n\n"
|
||||
"- `llm_query(prompt: str) -> str` — Call a sub-LM with a "
|
||||
"prompt and get a response.\n"
|
||||
"- `llm_batch(prompts: list[str]) -> list[str]` — Call a "
|
||||
"sub-LM with multiple prompts.\n"
|
||||
"- `FINAL(value)` — Terminate and return `value` as the "
|
||||
"final answer.\n"
|
||||
"- `FINAL_VAR(var_name: str)` — Terminate and return the "
|
||||
"value of variable `var_name`.\n"
|
||||
"- `answer` dict — Set `answer[\"value\"] = ...` and "
|
||||
"`answer[\"ready\"] = True` to terminate.\n\n"
|
||||
"## Available Modules\n\n"
|
||||
"json, re, math, collections, itertools, functools, "
|
||||
"textwrap, string, copy, datetime\n\n"
|
||||
"## Context Variable\n\n"
|
||||
"The input context (if any) is stored in the variable "
|
||||
"`context`. You can inspect it, slice it, or decompose it "
|
||||
"using Python.\n\n"
|
||||
"## Instructions\n\n"
|
||||
"1. Write Python code inside ```python blocks to manipulate "
|
||||
"context and solve the problem.\n"
|
||||
"2. For long contexts, decompose them into smaller chunks "
|
||||
"and use `llm_query()` on each chunk.\n"
|
||||
"3. Combine sub-results programmatically.\n"
|
||||
"4. When you have the final answer, call "
|
||||
"`FINAL(answer_value)` or `FINAL_VAR(\"var_name\")`.\n"
|
||||
"5. If you can answer directly without code, just respond "
|
||||
"with text (no code block).\n\n"
|
||||
"## Strategy Tips\n\n"
|
||||
"- Split long text into paragraphs or sections, summarize "
|
||||
"each with `llm_query()`.\n"
|
||||
"- Use `llm_batch()` for parallel sub-queries on multiple "
|
||||
"chunks.\n"
|
||||
"- Store intermediate results in variables — the REPL "
|
||||
"persists state across turns.\n"
|
||||
"- Build up the answer incrementally across multiple code "
|
||||
"blocks.\n"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@AgentRegistry.register("rlm")
|
||||
class RLMAgent(BaseAgent):
|
||||
"""Recursive Language Model agent using a persistent REPL.
|
||||
|
||||
The agent generates Python code that runs in a sandboxed REPL with
|
||||
access to ``llm_query()`` / ``llm_batch()`` for recursive sub-LM
|
||||
calls. Context is stored as a REPL variable rather than injected
|
||||
directly into the prompt, enabling processing of arbitrarily long
|
||||
inputs through recursive decomposition.
|
||||
"""
|
||||
|
||||
agent_id = "rlm"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: InferenceEngine,
|
||||
model: str,
|
||||
*,
|
||||
tools: Optional[List[BaseTool]] = None,
|
||||
bus: Optional[EventBus] = None,
|
||||
max_turns: int = 10,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 2048,
|
||||
sub_model: Optional[str] = None,
|
||||
sub_temperature: float = 0.3,
|
||||
sub_max_tokens: int = 1024,
|
||||
max_output_chars: int = 10000,
|
||||
system_prompt: Optional[str] = None,
|
||||
) -> None:
|
||||
self._engine = engine
|
||||
self._model = model
|
||||
self._tools = tools or []
|
||||
self._executor = ToolExecutor(self._tools, bus=bus) if self._tools else None
|
||||
self._bus = bus
|
||||
self._max_turns = max_turns
|
||||
self._temperature = temperature
|
||||
self._max_tokens = max_tokens
|
||||
self._sub_model = sub_model or model
|
||||
self._sub_temperature = sub_temperature
|
||||
self._sub_max_tokens = sub_max_tokens
|
||||
self._max_output_chars = max_output_chars
|
||||
self._system_prompt = system_prompt or RLM_SYSTEM_PROMPT
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Main run loop
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run(
|
||||
self,
|
||||
input: str,
|
||||
context: Optional[AgentContext] = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentResult:
|
||||
bus = self._bus
|
||||
|
||||
# Emit agent start
|
||||
if bus:
|
||||
bus.publish(
|
||||
EventType.AGENT_TURN_START,
|
||||
{"agent": self.agent_id, "input": input},
|
||||
)
|
||||
|
||||
# Create REPL with sub-LM callbacks
|
||||
repl = RLMRepl(
|
||||
llm_query_fn=self._make_sub_query,
|
||||
llm_batch_fn=self._make_batch_query,
|
||||
max_output_chars=self._max_output_chars,
|
||||
)
|
||||
|
||||
# Resolve context and inject into REPL
|
||||
ctx_text = self._resolve_context(context)
|
||||
if ctx_text:
|
||||
repl.set_variable("context", ctx_text)
|
||||
|
||||
# Build conversation
|
||||
messages: list[Message] = [
|
||||
Message(role=Role.SYSTEM, content=self._system_prompt),
|
||||
]
|
||||
if context and context.conversation.messages:
|
||||
messages.extend(context.conversation.messages)
|
||||
messages.append(Message(role=Role.USER, content=input))
|
||||
|
||||
all_tool_results: list[ToolResult] = []
|
||||
turns = 0
|
||||
|
||||
for _turn in range(self._max_turns):
|
||||
turns += 1
|
||||
|
||||
result = self._engine.generate(
|
||||
messages,
|
||||
model=self._model,
|
||||
temperature=self._temperature,
|
||||
max_tokens=self._max_tokens,
|
||||
)
|
||||
content = result.get("content", "")
|
||||
|
||||
# Strip <think> tags
|
||||
content = self._strip_think_tags(content)
|
||||
|
||||
# Extract code block
|
||||
code = self._extract_code(content)
|
||||
|
||||
# No code block → return content as final answer
|
||||
if code is None:
|
||||
if bus:
|
||||
bus.publish(
|
||||
EventType.AGENT_TURN_END,
|
||||
{"agent": self.agent_id, "turns": turns},
|
||||
)
|
||||
return AgentResult(
|
||||
content=content,
|
||||
tool_results=all_tool_results,
|
||||
turns=turns,
|
||||
)
|
||||
|
||||
# Execute code in REPL
|
||||
output = repl.execute(code)
|
||||
|
||||
# Record as tool result
|
||||
tool_result = ToolResult(
|
||||
tool_name="rlm_repl",
|
||||
content=output or "(no output)",
|
||||
success=(
|
||||
not output.startswith("Error:")
|
||||
and not output.startswith("SyntaxError:")
|
||||
),
|
||||
)
|
||||
all_tool_results.append(tool_result)
|
||||
|
||||
# Check for termination
|
||||
if repl.is_terminated:
|
||||
final = repl.final_answer
|
||||
final_str = str(final) if final is not None else ""
|
||||
if bus:
|
||||
bus.publish(
|
||||
EventType.AGENT_TURN_END,
|
||||
{"agent": self.agent_id, "turns": turns},
|
||||
)
|
||||
return AgentResult(
|
||||
content=final_str,
|
||||
tool_results=all_tool_results,
|
||||
turns=turns,
|
||||
)
|
||||
|
||||
# Feed output back as user message
|
||||
messages.append(Message(role=Role.ASSISTANT, content=content))
|
||||
feedback = (
|
||||
f"REPL Output: {output}"
|
||||
if output
|
||||
else "REPL Output: (no output)"
|
||||
)
|
||||
messages.append(Message(role=Role.USER, content=feedback))
|
||||
|
||||
# Max turns exceeded — check answer dict for partial result
|
||||
answer = repl.get_variable("answer")
|
||||
if isinstance(answer, dict) and answer.get("value") is not None:
|
||||
final_content = str(answer["value"])
|
||||
else:
|
||||
final_content = "Maximum turns reached without a final answer."
|
||||
|
||||
if bus:
|
||||
bus.publish(
|
||||
EventType.AGENT_TURN_END,
|
||||
{
|
||||
"agent": self.agent_id,
|
||||
"turns": turns,
|
||||
"max_turns_exceeded": True,
|
||||
},
|
||||
)
|
||||
|
||||
return AgentResult(
|
||||
content=final_content,
|
||||
tool_results=all_tool_results,
|
||||
turns=turns,
|
||||
metadata={"max_turns_exceeded": True},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sub-LM callbacks
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _make_sub_query(self, prompt: str) -> str:
|
||||
"""Execute a single sub-LM query.
|
||||
|
||||
Called from REPL code via ``llm_query(prompt)``.
|
||||
If the sub-LM returns tool_calls, execute one round of tool
|
||||
resolution before returning the final text.
|
||||
"""
|
||||
messages = [Message(role=Role.USER, content=prompt)]
|
||||
result = self._engine.generate(
|
||||
messages,
|
||||
model=self._sub_model,
|
||||
temperature=self._sub_temperature,
|
||||
max_tokens=self._sub_max_tokens,
|
||||
)
|
||||
|
||||
# Single-turn tool resolution
|
||||
raw_tool_calls = result.get("tool_calls", [])
|
||||
if raw_tool_calls and self._executor:
|
||||
content = result.get("content", "")
|
||||
tool_calls = [
|
||||
ToolCall(
|
||||
id=tc.get("id", f"sub_{i}"),
|
||||
name=tc.get("name", ""),
|
||||
arguments=tc.get("arguments", "{}"),
|
||||
)
|
||||
for i, tc in enumerate(raw_tool_calls)
|
||||
]
|
||||
messages.append(Message(
|
||||
role=Role.ASSISTANT,
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
))
|
||||
for tc in tool_calls:
|
||||
tr = self._executor.execute(tc)
|
||||
messages.append(Message(
|
||||
role=Role.TOOL,
|
||||
content=tr.content,
|
||||
tool_call_id=tc.id,
|
||||
name=tc.name,
|
||||
))
|
||||
followup = self._engine.generate(
|
||||
messages,
|
||||
model=self._sub_model,
|
||||
temperature=self._sub_temperature,
|
||||
max_tokens=self._sub_max_tokens,
|
||||
)
|
||||
return followup.get("content", "")
|
||||
|
||||
return result.get("content", "")
|
||||
|
||||
def _make_batch_query(self, prompts: List[str]) -> List[str]:
|
||||
"""Execute multiple sub-LM queries sequentially.
|
||||
|
||||
Called from REPL code via ``llm_batch(prompts)``.
|
||||
"""
|
||||
return [self._make_sub_query(p) for p in prompts]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _extract_code(text: str) -> Optional[str]:
|
||||
"""Extract the first ```python code block from *text*.
|
||||
|
||||
Also matches bare ``` blocks (without language tag).
|
||||
Returns ``None`` if no code block is found.
|
||||
"""
|
||||
# Try ```python first
|
||||
m = re.search(r"```python\s*\n(.*?)```", text, re.DOTALL)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
# Try bare ```
|
||||
m = re.search(r"```\s*\n(.*?)```", text, re.DOTALL)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _strip_think_tags(text: str) -> str:
|
||||
"""Remove ``<think>...</think>`` blocks from model output."""
|
||||
return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_context(context: Optional[AgentContext]) -> Optional[str]:
|
||||
"""Resolve context text from AgentContext metadata or memory_results."""
|
||||
if context is None:
|
||||
return None
|
||||
# Primary: explicit context in metadata
|
||||
if context.metadata.get("context"):
|
||||
return str(context.metadata["context"])
|
||||
# Fallback: join memory results
|
||||
if context.memory_results:
|
||||
return "\n\n".join(str(r) for r in context.memory_results)
|
||||
return None
|
||||
|
||||
|
||||
__all__ = ["RLMAgent", "RLM_SYSTEM_PROMPT"]
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Sandboxed REPL environment for the RLM agent.
|
||||
|
||||
Provides a persistent Python namespace with injected helper functions
|
||||
(``llm_query``, ``llm_batch``, ``FINAL``, ``FINAL_VAR``) that the RLM
|
||||
agent's generated code uses to decompose context and make recursive
|
||||
sub-LM calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
# Safe stdlib modules pre-injected into the REPL namespace
|
||||
_SAFE_MODULES = [
|
||||
"json",
|
||||
"re",
|
||||
"math",
|
||||
"collections",
|
||||
"itertools",
|
||||
"functools",
|
||||
"textwrap",
|
||||
"string",
|
||||
"copy",
|
||||
"datetime",
|
||||
]
|
||||
|
||||
# Patterns blocked for security
|
||||
_BLOCKED_PATTERNS = [
|
||||
"os.system",
|
||||
"os.popen",
|
||||
"subprocess",
|
||||
"shutil.rmtree",
|
||||
"__import__",
|
||||
"open(",
|
||||
"ctypes",
|
||||
"socket",
|
||||
"http.client",
|
||||
"urllib",
|
||||
]
|
||||
|
||||
|
||||
class RLMRepl:
|
||||
"""Sandboxed Python REPL with persistent namespace for the RLM agent.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
llm_query_fn:
|
||||
Callback invoked when REPL code calls ``llm_query(prompt)``.
|
||||
llm_batch_fn:
|
||||
Callback invoked when REPL code calls ``llm_batch(prompts)``.
|
||||
max_output_chars:
|
||||
Maximum characters captured from stdout per execution.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
llm_query_fn: Optional[Callable[[str], str]] = None,
|
||||
llm_batch_fn: Optional[Callable[[List[str]], List[str]]] = None,
|
||||
*,
|
||||
max_output_chars: int = 10000,
|
||||
) -> None:
|
||||
self._max_output_chars = max_output_chars
|
||||
self._terminated = False
|
||||
self._final_value: Any = None
|
||||
|
||||
# Build namespace
|
||||
self._namespace: Dict[str, Any] = {}
|
||||
|
||||
# Inject safe stdlib modules
|
||||
for mod_name in _SAFE_MODULES:
|
||||
try:
|
||||
import importlib
|
||||
|
||||
self._namespace[mod_name] = importlib.import_module(mod_name)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# answer dict — code can set answer["ready"] = True, answer["value"] = ...
|
||||
self._namespace["answer"] = {"ready": False, "value": None}
|
||||
|
||||
# Inject FINAL / FINAL_VAR
|
||||
self._namespace["FINAL"] = self._final
|
||||
self._namespace["FINAL_VAR"] = self._final_var
|
||||
|
||||
# Inject llm_query / llm_batch
|
||||
if llm_query_fn is not None:
|
||||
self._namespace["llm_query"] = llm_query_fn
|
||||
if llm_batch_fn is not None:
|
||||
self._namespace["llm_batch"] = llm_batch_fn
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Termination helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _final(self, value: Any) -> None:
|
||||
"""Mark the REPL as terminated with a final answer."""
|
||||
self._terminated = True
|
||||
self._final_value = value
|
||||
|
||||
def _final_var(self, var_name: str) -> None:
|
||||
"""Mark the REPL as terminated, using a namespace variable as the answer."""
|
||||
value = self._namespace.get(var_name)
|
||||
self._terminated = True
|
||||
self._final_value = value
|
||||
|
||||
@property
|
||||
def is_terminated(self) -> bool:
|
||||
"""Check if FINAL/FINAL_VAR was called or answer["ready"] is True."""
|
||||
if self._terminated:
|
||||
return True
|
||||
answer = self._namespace.get("answer", {})
|
||||
if isinstance(answer, dict) and answer.get("ready"):
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def final_answer(self) -> Any:
|
||||
"""Return the termination value."""
|
||||
if self._terminated:
|
||||
return self._final_value
|
||||
answer = self._namespace.get("answer", {})
|
||||
if isinstance(answer, dict) and answer.get("ready"):
|
||||
return answer.get("value")
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Execution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def security_check(self, code: str) -> Optional[str]:
|
||||
"""Check code for dangerous patterns. Returns error message or None."""
|
||||
for pattern in _BLOCKED_PATTERNS:
|
||||
if pattern in code:
|
||||
return f"Blocked: code contains prohibited pattern '{pattern}'"
|
||||
return None
|
||||
|
||||
def execute(self, code: str) -> str:
|
||||
"""Execute *code* in the persistent namespace and return captured stdout.
|
||||
|
||||
Raises are caught and returned as error strings.
|
||||
"""
|
||||
# Security check
|
||||
violation = self.security_check(code)
|
||||
if violation is not None:
|
||||
return f"Error: {violation}"
|
||||
|
||||
stdout_buf = io.StringIO()
|
||||
stderr_buf = io.StringIO()
|
||||
|
||||
try:
|
||||
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
|
||||
exec(code, self._namespace) # noqa: S102
|
||||
except Exception as exc:
|
||||
error_msg = f"{type(exc).__name__}: {exc}"
|
||||
return error_msg
|
||||
|
||||
output = stdout_buf.getvalue()
|
||||
err_output = stderr_buf.getvalue()
|
||||
if err_output:
|
||||
output += ("\n" if output else "") + err_output
|
||||
|
||||
# Truncate if needed
|
||||
if len(output) > self._max_output_chars:
|
||||
output = output[: self._max_output_chars] + "\n... (output truncated)"
|
||||
|
||||
return output
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Namespace access
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_variable(self, name: str, value: Any) -> None:
|
||||
"""Set a variable in the REPL namespace."""
|
||||
self._namespace[name] = value
|
||||
|
||||
def get_variable(self, name: str) -> Any:
|
||||
"""Get a variable from the REPL namespace."""
|
||||
return self._namespace.get(name)
|
||||
|
||||
|
||||
__all__ = ["RLMRepl"]
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Channel abstraction for multi-platform messaging via OpenClaw."""
|
||||
"""Channel abstraction for multi-platform messaging."""
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
@@ -9,7 +9,77 @@ from openjarvis.channels._stubs import (
|
||||
|
||||
# Trigger registration of built-in channels
|
||||
try:
|
||||
from openjarvis.channels.openclaw_bridge import OpenClawChannelBridge # noqa: F401
|
||||
import openjarvis.channels.telegram # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.channels.discord_channel # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.channels.slack # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.channels.webhook # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.channels.email_channel # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.channels.whatsapp # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.channels.signal_channel # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.channels.google_chat # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.channels.irc_channel # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.channels.webchat # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.channels.teams # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.channels.matrix_channel # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.channels.mattermost # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.channels.feishu # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.channels.bluebubbles # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""BlueBubblesChannel — BlueBubbles (iMessage bridge) adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("bluebubbles")
|
||||
class BlueBubblesChannel(BaseChannel):
|
||||
"""BlueBubbles (iMessage bridge) channel adapter.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
url:
|
||||
BlueBubbles server URL. Falls back to ``BLUEBUBBLES_URL`` env var.
|
||||
password:
|
||||
BlueBubbles server password. Falls back to ``BLUEBUBBLES_PASSWORD``
|
||||
env var.
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "bluebubbles"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str = "",
|
||||
*,
|
||||
password: str = "",
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._url = url or os.environ.get("BLUEBUBBLES_URL", "")
|
||||
self._password = password or os.environ.get("BLUEBUBBLES_PASSWORD", "")
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Mark as connected (send-only — no persistent connection)."""
|
||||
if not self._url or not self._password:
|
||||
logger.warning("No BlueBubbles URL or password configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Mark as disconnected."""
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Send an iMessage via the BlueBubbles API."""
|
||||
if not self._url or not self._password:
|
||||
logger.warning("Cannot send: no BlueBubbles credentials configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
url = f"{self._url}/api/v1/message/text"
|
||||
payload: Dict[str, Any] = {
|
||||
"chatGuid": channel,
|
||||
"message": content,
|
||||
"method": "private-api",
|
||||
}
|
||||
|
||||
resp = httpx.post(
|
||||
url,
|
||||
params={"password": self._password},
|
||||
json=payload,
|
||||
timeout=10.0,
|
||||
)
|
||||
if resp.status_code < 300:
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
logger.warning(
|
||||
"BlueBubbles API returned status %d", resp.status_code,
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug("BlueBubbles send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["bluebubbles"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["BlueBubblesChannel"]
|
||||
@@ -0,0 +1,197 @@
|
||||
"""DiscordChannel — native Discord Bot API adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelMessage,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("discord")
|
||||
class DiscordChannel(BaseChannel):
|
||||
"""Native Discord channel adapter using the Discord REST API.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
bot_token:
|
||||
Discord bot token. Falls back to ``DISCORD_BOT_TOKEN`` env var.
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "discord"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bot_token: str = "",
|
||||
*,
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._token = bot_token or os.environ.get("DISCORD_BOT_TOKEN", "")
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
self._listener_thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Start listening for incoming messages via discord.py gateway."""
|
||||
if not self._token:
|
||||
logger.warning("No Discord bot token configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
|
||||
self._stop_event.clear()
|
||||
self._status = ChannelStatus.CONNECTING
|
||||
|
||||
try:
|
||||
import discord # noqa: F401
|
||||
|
||||
self._listener_thread = threading.Thread(
|
||||
target=self._gateway_loop, daemon=True,
|
||||
)
|
||||
self._listener_thread.start()
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
logger.info("Discord channel connected (gateway)")
|
||||
except ImportError:
|
||||
logger.info("discord.py not installed; send-only mode")
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Stop the listener thread."""
|
||||
self._stop_event.set()
|
||||
if self._listener_thread is not None:
|
||||
self._listener_thread.join(timeout=5.0)
|
||||
self._listener_thread = None
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Send a message to a Discord channel via REST API."""
|
||||
if not self._token:
|
||||
logger.warning("Cannot send: no Discord bot token")
|
||||
return False
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
url = f"https://discord.com/api/v10/channels/{channel}/messages"
|
||||
headers = {
|
||||
"Authorization": f"Bot {self._token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload: Dict[str, Any] = {"content": content}
|
||||
if conversation_id:
|
||||
payload["message_reference"] = {"message_id": conversation_id}
|
||||
|
||||
resp = httpx.post(
|
||||
url, json=payload, headers=headers, timeout=10.0,
|
||||
)
|
||||
if resp.status_code < 300:
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
logger.warning(
|
||||
"Discord API returned status %d: %s",
|
||||
resp.status_code,
|
||||
resp.text,
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug("Discord send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["discord"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _gateway_loop(self) -> None:
|
||||
"""Run the discord.py client in a background thread."""
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
import discord
|
||||
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
client = discord.Client(intents=intents)
|
||||
|
||||
@client.event
|
||||
async def on_message(message):
|
||||
if message.author == client.user:
|
||||
return
|
||||
cm = ChannelMessage(
|
||||
channel="discord",
|
||||
sender=str(message.author.id),
|
||||
content=message.content,
|
||||
message_id=str(message.id),
|
||||
conversation_id=str(message.channel.id),
|
||||
)
|
||||
for handler in self._handlers:
|
||||
try:
|
||||
handler(cm)
|
||||
except Exception:
|
||||
logger.exception("Discord handler error")
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_RECEIVED,
|
||||
{
|
||||
"channel": cm.channel,
|
||||
"sender": cm.sender,
|
||||
"content": cm.content,
|
||||
"message_id": cm.message_id,
|
||||
},
|
||||
)
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(client.start(self._token))
|
||||
except Exception:
|
||||
logger.debug("Discord gateway loop error", exc_info=True)
|
||||
self._status = ChannelStatus.ERROR
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["DiscordChannel"]
|
||||
@@ -0,0 +1,240 @@
|
||||
"""EmailChannel — SMTP/IMAP email adapter (stdlib only, zero extra deps)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import smtplib
|
||||
import threading
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelMessage,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("email")
|
||||
class EmailChannel(BaseChannel):
|
||||
"""Email channel adapter using stdlib ``smtplib`` and ``imaplib``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
smtp_host:
|
||||
SMTP server hostname.
|
||||
smtp_port:
|
||||
SMTP server port (default 587 for STARTTLS).
|
||||
imap_host:
|
||||
IMAP server hostname for incoming messages.
|
||||
imap_port:
|
||||
IMAP server port (default 993 for SSL).
|
||||
username:
|
||||
Email username. Falls back to ``EMAIL_USERNAME`` env var.
|
||||
password:
|
||||
Email password. Falls back to ``EMAIL_PASSWORD`` env var.
|
||||
use_tls:
|
||||
Whether to use TLS (default ``True``).
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "email"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
smtp_host: str = "",
|
||||
smtp_port: int = 587,
|
||||
*,
|
||||
imap_host: str = "",
|
||||
imap_port: int = 993,
|
||||
username: str = "",
|
||||
password: str = "",
|
||||
use_tls: bool = True,
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._smtp_host = smtp_host
|
||||
self._smtp_port = smtp_port
|
||||
self._imap_host = imap_host
|
||||
self._imap_port = imap_port
|
||||
self._username = username or os.environ.get("EMAIL_USERNAME", "")
|
||||
self._password = password or os.environ.get("EMAIL_PASSWORD", "")
|
||||
self._use_tls = use_tls
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
self._listener_thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Start IMAP polling for incoming messages (if configured)."""
|
||||
if not self._smtp_host or not self._username:
|
||||
logger.warning("Email channel not fully configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
|
||||
self._stop_event.clear()
|
||||
self._status = ChannelStatus.CONNECTING
|
||||
|
||||
if self._imap_host:
|
||||
self._listener_thread = threading.Thread(
|
||||
target=self._imap_poll_loop, daemon=True,
|
||||
)
|
||||
self._listener_thread.start()
|
||||
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
logger.info("Email channel connected (SMTP: %s)", self._smtp_host)
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Stop the IMAP listener thread."""
|
||||
self._stop_event.set()
|
||||
if self._listener_thread is not None:
|
||||
self._listener_thread.join(timeout=5.0)
|
||||
self._listener_thread = None
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Send an email via SMTP. ``channel`` is the recipient address."""
|
||||
if not self._smtp_host or not self._username:
|
||||
logger.warning("Cannot send: email not configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
msg = MIMEText(content)
|
||||
msg["From"] = self._username
|
||||
msg["To"] = channel
|
||||
msg["Subject"] = (metadata or {}).get(
|
||||
"subject", "Message from OpenJarvis",
|
||||
)
|
||||
if conversation_id:
|
||||
msg["In-Reply-To"] = conversation_id
|
||||
|
||||
if self._use_tls:
|
||||
with smtplib.SMTP(self._smtp_host, self._smtp_port) as server:
|
||||
server.starttls()
|
||||
server.login(self._username, self._password)
|
||||
server.send_message(msg)
|
||||
else:
|
||||
with smtplib.SMTP(self._smtp_host, self._smtp_port) as server:
|
||||
if self._password:
|
||||
server.login(self._username, self._password)
|
||||
server.send_message(msg)
|
||||
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
except Exception:
|
||||
logger.debug("Email send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["email"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming email messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _imap_poll_loop(self) -> None:
|
||||
"""Poll IMAP INBOX for new messages."""
|
||||
import email
|
||||
import imaplib
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
if self._use_tls:
|
||||
imap = imaplib.IMAP4_SSL(
|
||||
self._imap_host, self._imap_port,
|
||||
)
|
||||
else:
|
||||
imap = imaplib.IMAP4(self._imap_host, self._imap_port)
|
||||
|
||||
imap.login(self._username, self._password)
|
||||
imap.select("INBOX")
|
||||
|
||||
_, data = imap.search(None, "UNSEEN")
|
||||
for num in data[0].split():
|
||||
_, msg_data = imap.fetch(num, "(RFC822)")
|
||||
if msg_data[0] is None:
|
||||
continue
|
||||
raw = msg_data[0][1]
|
||||
parsed = email.message_from_bytes(raw)
|
||||
body = ""
|
||||
if parsed.is_multipart():
|
||||
for part in parsed.walk():
|
||||
if part.get_content_type() == "text/plain":
|
||||
body = part.get_payload(decode=True).decode(
|
||||
errors="replace",
|
||||
)
|
||||
break
|
||||
else:
|
||||
body = parsed.get_payload(decode=True).decode(
|
||||
errors="replace",
|
||||
)
|
||||
|
||||
cm = ChannelMessage(
|
||||
channel="email",
|
||||
sender=parsed.get("From", ""),
|
||||
content=body,
|
||||
message_id=parsed.get("Message-ID", ""),
|
||||
conversation_id=parsed.get("In-Reply-To", ""),
|
||||
)
|
||||
for handler in self._handlers:
|
||||
try:
|
||||
handler(cm)
|
||||
except Exception:
|
||||
logger.exception("Email handler error")
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_RECEIVED,
|
||||
{
|
||||
"channel": cm.channel,
|
||||
"sender": cm.sender,
|
||||
"content": cm.content,
|
||||
"message_id": cm.message_id,
|
||||
},
|
||||
)
|
||||
|
||||
imap.close()
|
||||
imap.logout()
|
||||
except Exception:
|
||||
logger.debug("IMAP poll error", exc_info=True)
|
||||
|
||||
self._stop_event.wait(30.0)
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["EmailChannel"]
|
||||
@@ -0,0 +1,162 @@
|
||||
"""FeishuChannel — Feishu (Lark) adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("feishu")
|
||||
class FeishuChannel(BaseChannel):
|
||||
"""Feishu (Lark) channel adapter.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
app_id:
|
||||
Feishu App ID. Falls back to ``FEISHU_APP_ID`` env var.
|
||||
app_secret:
|
||||
Feishu App Secret. Falls back to ``FEISHU_APP_SECRET`` env var.
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "feishu"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app_id: str = "",
|
||||
*,
|
||||
app_secret: str = "",
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._app_id = app_id or os.environ.get("FEISHU_APP_ID", "")
|
||||
self._app_secret = app_secret or os.environ.get("FEISHU_APP_SECRET", "")
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Mark as connected (send-only — no persistent connection)."""
|
||||
if not self._app_id or not self._app_secret:
|
||||
logger.warning("No Feishu app_id or app_secret configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Mark as disconnected."""
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Send a message to a Feishu chat via the Open API."""
|
||||
if not self._app_id or not self._app_secret:
|
||||
logger.warning("Cannot send: no Feishu credentials configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
# Obtain tenant_access_token
|
||||
token_url = (
|
||||
"https://open.feishu.cn/open-apis/auth/v3/"
|
||||
"tenant_access_token/internal"
|
||||
)
|
||||
token_resp = httpx.post(
|
||||
token_url,
|
||||
json={
|
||||
"app_id": self._app_id,
|
||||
"app_secret": self._app_secret,
|
||||
},
|
||||
timeout=10.0,
|
||||
)
|
||||
if token_resp.status_code >= 300:
|
||||
logger.warning(
|
||||
"Feishu token request returned status %d",
|
||||
token_resp.status_code,
|
||||
)
|
||||
return False
|
||||
tenant_token = token_resp.json().get("tenant_access_token", "")
|
||||
if not tenant_token:
|
||||
logger.warning("Feishu token response missing tenant_access_token")
|
||||
return False
|
||||
|
||||
# Send message
|
||||
msg_url = (
|
||||
"https://open.feishu.cn/open-apis/im/v1/messages"
|
||||
"?receive_id_type=chat_id"
|
||||
)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {tenant_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload: Dict[str, Any] = {
|
||||
"receive_id": channel,
|
||||
"msg_type": "text",
|
||||
"content": json.dumps({"text": content}),
|
||||
}
|
||||
|
||||
resp = httpx.post(
|
||||
msg_url, json=payload, headers=headers, timeout=10.0,
|
||||
)
|
||||
if resp.status_code < 300:
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
logger.warning(
|
||||
"Feishu API returned status %d", resp.status_code,
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug("Feishu send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["feishu"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["FeishuChannel"]
|
||||
@@ -0,0 +1,123 @@
|
||||
"""GoogleChatChannel — Google Chat webhook adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("google_chat")
|
||||
class GoogleChatChannel(BaseChannel):
|
||||
"""Google Chat webhook channel adapter (send-only).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
webhook_url:
|
||||
Google Chat incoming webhook URL. Falls back to
|
||||
``GOOGLE_CHAT_WEBHOOK_URL`` env var.
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "google_chat"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
webhook_url: str = "",
|
||||
*,
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._webhook_url = webhook_url or os.environ.get(
|
||||
"GOOGLE_CHAT_WEBHOOK_URL", "",
|
||||
)
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Mark as connected (send-only — no persistent connection)."""
|
||||
if not self._webhook_url:
|
||||
logger.warning("No Google Chat webhook URL configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Mark as disconnected."""
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Send a message to Google Chat via the configured webhook URL."""
|
||||
if not self._webhook_url:
|
||||
logger.warning("Cannot send: no Google Chat webhook URL configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
payload: Dict[str, Any] = {"text": content}
|
||||
|
||||
resp = httpx.post(
|
||||
self._webhook_url, json=payload, timeout=10.0,
|
||||
)
|
||||
if resp.status_code < 300:
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
logger.warning(
|
||||
"Google Chat API returned status %d", resp.status_code,
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug("Google Chat send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["google_chat"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["GoogleChatChannel"]
|
||||
@@ -0,0 +1,145 @@
|
||||
"""IRCChannel — IRC adapter using stdlib socket."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("irc")
|
||||
class IRCChannel(BaseChannel):
|
||||
"""IRC channel adapter using stdlib socket (send-only).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
server:
|
||||
IRC server hostname. Falls back to ``IRC_SERVER`` env var.
|
||||
port:
|
||||
IRC server port (default 6667). Falls back to ``IRC_PORT`` env var.
|
||||
nick:
|
||||
IRC nickname. Falls back to ``IRC_NICK`` env var.
|
||||
password:
|
||||
Optional server password. Falls back to ``IRC_PASSWORD`` env var.
|
||||
use_tls:
|
||||
Whether to use TLS for the connection (default ``False``).
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "irc"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server: str = "",
|
||||
*,
|
||||
port: int = 6667,
|
||||
nick: str = "",
|
||||
password: str = "",
|
||||
use_tls: bool = False,
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._server = server or os.environ.get("IRC_SERVER", "")
|
||||
self._port = int(os.environ.get("IRC_PORT", str(port)))
|
||||
self._nick = nick or os.environ.get("IRC_NICK", "")
|
||||
self._password = password or os.environ.get("IRC_PASSWORD", "")
|
||||
self._use_tls = use_tls
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Mark as connected (send-only — no persistent connection)."""
|
||||
if not self._server:
|
||||
logger.warning("No IRC server configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
if not self._nick:
|
||||
logger.warning("No IRC nick configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Mark as disconnected."""
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Send a PRIVMSG to an IRC channel via a new socket connection."""
|
||||
if not self._server or not self._nick:
|
||||
logger.warning("Cannot send: IRC server or nick not configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
if self._use_tls:
|
||||
ctx = ssl.create_default_context()
|
||||
sock = ctx.wrap_socket(sock, server_hostname=self._server)
|
||||
|
||||
sock.connect((self._server, self._port))
|
||||
|
||||
if self._password:
|
||||
sock.sendall(f"PASS {self._password}\r\n".encode())
|
||||
sock.sendall(f"NICK {self._nick}\r\n".encode())
|
||||
sock.sendall(f"USER {self._nick} 0 * :{self._nick}\r\n".encode())
|
||||
sock.sendall(f"PRIVMSG {channel} :{content}\r\n".encode())
|
||||
sock.sendall(b"QUIT\r\n")
|
||||
sock.close()
|
||||
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
except Exception:
|
||||
logger.debug("IRC send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["irc"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["IRCChannel"]
|
||||
@@ -0,0 +1,137 @@
|
||||
"""MatrixChannel — Matrix homeserver adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("matrix")
|
||||
class MatrixChannel(BaseChannel):
|
||||
"""Matrix homeserver channel adapter.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
homeserver:
|
||||
Matrix homeserver URL. Falls back to ``MATRIX_HOMESERVER`` env var.
|
||||
access_token:
|
||||
Matrix access token. Falls back to ``MATRIX_ACCESS_TOKEN`` env var.
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "matrix"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
homeserver: str = "",
|
||||
*,
|
||||
access_token: str = "",
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._homeserver = homeserver or os.environ.get("MATRIX_HOMESERVER", "")
|
||||
self._access_token = access_token or os.environ.get("MATRIX_ACCESS_TOKEN", "")
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
self._txn_id = 0
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Mark as connected (send-only — no persistent connection)."""
|
||||
if not self._homeserver or not self._access_token:
|
||||
logger.warning("No Matrix homeserver or access_token configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Mark as disconnected."""
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Send a message to a Matrix room via the Client-Server API."""
|
||||
if not self._homeserver or not self._access_token:
|
||||
logger.warning("Cannot send: no Matrix credentials configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
self._txn_id += 1
|
||||
txn_id = self._txn_id
|
||||
url = (
|
||||
f"{self._homeserver}/_matrix/client/v3/rooms/"
|
||||
f"{channel}/send/m.room.message/{txn_id}"
|
||||
)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._access_token}",
|
||||
}
|
||||
payload: Dict[str, Any] = {
|
||||
"msgtype": "m.text",
|
||||
"body": content,
|
||||
}
|
||||
|
||||
resp = httpx.put(
|
||||
url, json=payload, headers=headers, timeout=10.0,
|
||||
)
|
||||
if resp.status_code < 300:
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
logger.warning(
|
||||
"Matrix API returned status %d", resp.status_code,
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug("Matrix send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["matrix"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["MatrixChannel"]
|
||||
@@ -0,0 +1,134 @@
|
||||
"""MattermostChannel — Mattermost REST API adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("mattermost")
|
||||
class MattermostChannel(BaseChannel):
|
||||
"""Mattermost channel adapter using the REST API.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
url:
|
||||
Mattermost server URL. Falls back to ``MATTERMOST_URL`` env var.
|
||||
token:
|
||||
Mattermost personal access token or bot token. Falls back to
|
||||
``MATTERMOST_TOKEN`` env var.
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "mattermost"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str = "",
|
||||
*,
|
||||
token: str = "",
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._url = url or os.environ.get("MATTERMOST_URL", "")
|
||||
self._token = token or os.environ.get("MATTERMOST_TOKEN", "")
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Mark as connected (send-only — no persistent connection)."""
|
||||
if not self._url or not self._token:
|
||||
logger.warning("No Mattermost URL or token configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Mark as disconnected."""
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Send a message to a Mattermost channel via the REST API."""
|
||||
if not self._url or not self._token:
|
||||
logger.warning("Cannot send: no Mattermost credentials configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
url = f"{self._url}/api/v4/posts"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
}
|
||||
payload: Dict[str, Any] = {
|
||||
"channel_id": channel,
|
||||
"message": content,
|
||||
}
|
||||
if conversation_id:
|
||||
payload["root_id"] = conversation_id
|
||||
|
||||
resp = httpx.post(
|
||||
url, json=payload, headers=headers, timeout=10.0,
|
||||
)
|
||||
if resp.status_code < 300:
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
logger.warning(
|
||||
"Mattermost API returned status %d", resp.status_code,
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug("Mattermost send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["mattermost"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["MattermostChannel"]
|
||||
@@ -1,257 +0,0 @@
|
||||
"""OpenClawChannelBridge — WebSocket/HTTP bridge to the OpenClaw gateway."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelMessage,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("openclaw")
|
||||
class OpenClawChannelBridge(BaseChannel):
|
||||
"""Bridge to the OpenClaw gateway via WebSocket with HTTP fallback.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
gateway_url:
|
||||
WebSocket URL of the OpenClaw gateway (e.g. ``ws://127.0.0.1:18789/ws``).
|
||||
reconnect_interval:
|
||||
Seconds to wait before reconnecting after a disconnect.
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "openclaw"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gateway_url: str = "ws://127.0.0.1:18789/ws",
|
||||
*,
|
||||
reconnect_interval: float = 5.0,
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._gateway_url = gateway_url
|
||||
self._reconnect_interval = reconnect_interval
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
self._ws: Any = None
|
||||
self._listener_thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Establish connection to the OpenClaw gateway."""
|
||||
self._status = ChannelStatus.CONNECTING
|
||||
self._stop_event.clear()
|
||||
|
||||
try:
|
||||
import websockets.sync.client # type: ignore[import-untyped]
|
||||
|
||||
self._ws = websockets.sync.client.connect(self._gateway_url)
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
self._listener_thread = threading.Thread(
|
||||
target=self._listener_loop, daemon=True,
|
||||
)
|
||||
self._listener_thread.start()
|
||||
logger.info(
|
||||
"Connected to OpenClaw gateway via WebSocket: %s",
|
||||
self._gateway_url,
|
||||
)
|
||||
except ImportError:
|
||||
# websockets not installed — use HTTP fallback mode
|
||||
logger.info(
|
||||
"websockets not installed; HTTP fallback: %s",
|
||||
self._gateway_url,
|
||||
)
|
||||
self._ws = None
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to connect to OpenClaw gateway: %s",
|
||||
self._gateway_url,
|
||||
)
|
||||
self._status = ChannelStatus.ERROR
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Close connection to the OpenClaw gateway."""
|
||||
self._stop_event.set()
|
||||
if self._ws is not None:
|
||||
try:
|
||||
self._ws.close()
|
||||
except Exception:
|
||||
logger.debug("Error closing WebSocket", exc_info=True)
|
||||
self._ws = None
|
||||
if self._listener_thread is not None:
|
||||
self._listener_thread.join(timeout=self._reconnect_interval + 1)
|
||||
self._listener_thread = None
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Send a message to a channel. Returns True on success."""
|
||||
payload = {
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
"metadata": metadata or {},
|
||||
}
|
||||
|
||||
# Try WebSocket first
|
||||
if self._ws is not None:
|
||||
try:
|
||||
self._ws.send(json.dumps(payload))
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"WebSocket send failed, falling back to HTTP",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# HTTP fallback
|
||||
return self._send_http(payload, channel, content, conversation_id)
|
||||
|
||||
def _send_http(
|
||||
self,
|
||||
payload: Dict[str, Any],
|
||||
channel: str,
|
||||
content: str,
|
||||
conversation_id: str,
|
||||
) -> bool:
|
||||
"""Send message via HTTP POST fallback."""
|
||||
http_url = self._http_url("/send")
|
||||
try:
|
||||
import httpx
|
||||
|
||||
resp = httpx.post(http_url, json=payload, timeout=10.0)
|
||||
if resp.status_code < 300:
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
logger.warning("HTTP send returned status %d", resp.status_code)
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug("HTTP send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Query the gateway for available channels."""
|
||||
http_url = self._http_url("/channels")
|
||||
try:
|
||||
import httpx
|
||||
|
||||
resp = httpx.get(http_url, timeout=10.0)
|
||||
data = resp.json()
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if isinstance(data, dict) and "channels" in data:
|
||||
return data["channels"]
|
||||
return []
|
||||
except Exception:
|
||||
logger.debug("Failed to list channels", exc_info=True)
|
||||
return []
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _listener_loop(self) -> None:
|
||||
"""Background loop receiving messages from WebSocket."""
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
if self._ws is None:
|
||||
break
|
||||
raw = self._ws.recv(timeout=1.0)
|
||||
data = json.loads(raw)
|
||||
msg = ChannelMessage(
|
||||
channel=data.get("channel", ""),
|
||||
sender=data.get("sender", ""),
|
||||
content=data.get("content", ""),
|
||||
message_id=data.get("message_id", ""),
|
||||
conversation_id=data.get("conversation_id", ""),
|
||||
metadata=data.get("metadata", {}),
|
||||
)
|
||||
for handler in self._handlers:
|
||||
try:
|
||||
handler(msg)
|
||||
except Exception:
|
||||
logger.exception("Channel handler error")
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_RECEIVED,
|
||||
{
|
||||
"channel": msg.channel,
|
||||
"sender": msg.sender,
|
||||
"content": msg.content,
|
||||
"message_id": msg.message_id,
|
||||
},
|
||||
)
|
||||
except TimeoutError:
|
||||
continue
|
||||
except Exception:
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
logger.debug("WebSocket listener error, reconnecting", exc_info=True)
|
||||
self._status = ChannelStatus.CONNECTING
|
||||
self._stop_event.wait(self._reconnect_interval)
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
try:
|
||||
import websockets.sync.client # type: ignore[import-untyped]
|
||||
|
||||
self._ws = websockets.sync.client.connect(self._gateway_url)
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
except Exception:
|
||||
logger.debug("Reconnect failed", exc_info=True)
|
||||
self._status = ChannelStatus.ERROR
|
||||
|
||||
def _http_url(self, path: str = "") -> str:
|
||||
"""Convert the WebSocket URL to an HTTP URL."""
|
||||
url = self._gateway_url
|
||||
url = url.replace("ws://", "http://").replace("wss://", "https://")
|
||||
# Strip trailing /ws
|
||||
if url.endswith("/ws"):
|
||||
url = url[:-3]
|
||||
return url + path
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["OpenClawChannelBridge"]
|
||||
@@ -0,0 +1,131 @@
|
||||
"""SignalChannel — Signal adapter via signal-cli REST API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("signal")
|
||||
class SignalChannel(BaseChannel):
|
||||
"""Signal channel adapter via signal-cli REST API (send-only).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
api_url:
|
||||
Base URL of the signal-cli REST API. Falls back to
|
||||
``SIGNAL_API_URL`` env var.
|
||||
phone_number:
|
||||
Sender phone number registered with signal-cli. Falls back to
|
||||
``SIGNAL_PHONE_NUMBER`` env var.
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "signal"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_url: str = "",
|
||||
*,
|
||||
phone_number: str = "",
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._api_url = api_url or os.environ.get("SIGNAL_API_URL", "")
|
||||
self._phone_number = phone_number or os.environ.get(
|
||||
"SIGNAL_PHONE_NUMBER", "",
|
||||
)
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Mark as connected (send-only — no persistent connection)."""
|
||||
if not self._api_url:
|
||||
logger.warning("No Signal API URL configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Mark as disconnected."""
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Send a message via the signal-cli REST API."""
|
||||
if not self._api_url:
|
||||
logger.warning("Cannot send: no Signal API URL configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
url = f"{self._api_url}/v2/send"
|
||||
payload: Dict[str, Any] = {
|
||||
"message": content,
|
||||
"number": self._phone_number,
|
||||
"recipients": [channel],
|
||||
}
|
||||
|
||||
resp = httpx.post(url, json=payload, timeout=10.0)
|
||||
if resp.status_code < 300:
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
logger.warning(
|
||||
"Signal API returned status %d", resp.status_code,
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug("Signal send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["signal"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["SignalChannel"]
|
||||
@@ -0,0 +1,221 @@
|
||||
"""SlackChannel — native Slack Web API adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelMessage,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("slack")
|
||||
class SlackChannel(BaseChannel):
|
||||
"""Native Slack channel adapter using the Slack Web API.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
bot_token:
|
||||
Slack Bot User OAuth Token. Falls back to ``SLACK_BOT_TOKEN`` env var.
|
||||
app_token:
|
||||
Slack App-Level Token for Socket Mode. Falls back to
|
||||
``SLACK_APP_TOKEN`` env var.
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "slack"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bot_token: str = "",
|
||||
*,
|
||||
app_token: str = "",
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._token = bot_token or os.environ.get("SLACK_BOT_TOKEN", "")
|
||||
self._app_token = app_token or os.environ.get("SLACK_APP_TOKEN", "")
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
self._listener_thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Start listening for incoming messages via Slack Socket Mode."""
|
||||
if not self._token:
|
||||
logger.warning("No Slack bot token configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
|
||||
self._stop_event.clear()
|
||||
self._status = ChannelStatus.CONNECTING
|
||||
|
||||
try:
|
||||
from slack_sdk.socket_mode import SocketModeClient # noqa: F401
|
||||
|
||||
if not self._app_token:
|
||||
logger.info("No app token for Socket Mode; send-only mode")
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
return
|
||||
|
||||
self._listener_thread = threading.Thread(
|
||||
target=self._socket_mode_loop, daemon=True,
|
||||
)
|
||||
self._listener_thread.start()
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
logger.info("Slack channel connected (Socket Mode)")
|
||||
except ImportError:
|
||||
logger.info("slack-sdk not installed; send-only mode")
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Stop the listener thread."""
|
||||
self._stop_event.set()
|
||||
if self._listener_thread is not None:
|
||||
self._listener_thread.join(timeout=5.0)
|
||||
self._listener_thread = None
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Send a message to a Slack channel via the Web API."""
|
||||
if not self._token:
|
||||
logger.warning("Cannot send: no Slack bot token")
|
||||
return False
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
url = "https://slack.com/api/chat.postMessage"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload: Dict[str, Any] = {
|
||||
"channel": channel,
|
||||
"text": content,
|
||||
}
|
||||
if conversation_id:
|
||||
payload["thread_ts"] = conversation_id
|
||||
|
||||
resp = httpx.post(
|
||||
url, json=payload, headers=headers, timeout=10.0,
|
||||
)
|
||||
if resp.status_code < 300:
|
||||
data = resp.json()
|
||||
if data.get("ok"):
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
logger.warning("Slack API error: %s", data.get("error"))
|
||||
return False
|
||||
logger.warning(
|
||||
"Slack API returned status %d", resp.status_code,
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug("Slack send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["slack"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _socket_mode_loop(self) -> None:
|
||||
"""Run Slack Socket Mode client in a background thread."""
|
||||
try:
|
||||
from slack_sdk.socket_mode import SocketModeClient
|
||||
from slack_sdk.socket_mode.request import SocketModeRequest
|
||||
from slack_sdk.socket_mode.response import SocketModeResponse
|
||||
from slack_sdk.web import WebClient
|
||||
|
||||
client = SocketModeClient(
|
||||
app_token=self._app_token,
|
||||
web_client=WebClient(token=self._token),
|
||||
)
|
||||
|
||||
def _handle_event(client_obj, req: SocketModeRequest):
|
||||
if req.type == "events_api":
|
||||
event = req.payload.get("event", {})
|
||||
if event.get("type") == "message" and "subtype" not in event:
|
||||
cm = ChannelMessage(
|
||||
channel="slack",
|
||||
sender=event.get("user", ""),
|
||||
content=event.get("text", ""),
|
||||
message_id=event.get("ts", ""),
|
||||
conversation_id=event.get("channel", ""),
|
||||
)
|
||||
for handler in self._handlers:
|
||||
try:
|
||||
handler(cm)
|
||||
except Exception:
|
||||
logger.exception("Slack handler error")
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_RECEIVED,
|
||||
{
|
||||
"channel": cm.channel,
|
||||
"sender": cm.sender,
|
||||
"content": cm.content,
|
||||
"message_id": cm.message_id,
|
||||
},
|
||||
)
|
||||
client_obj.send_socket_mode_response(
|
||||
SocketModeResponse(envelope_id=req.envelope_id),
|
||||
)
|
||||
|
||||
client.socket_mode_request_listeners.append(_handle_event)
|
||||
client.connect()
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
self._stop_event.wait(1.0)
|
||||
|
||||
client.disconnect()
|
||||
except Exception:
|
||||
logger.debug("Slack Socket Mode loop error", exc_info=True)
|
||||
self._status = ChannelStatus.ERROR
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["SlackChannel"]
|
||||
@@ -0,0 +1,142 @@
|
||||
"""TeamsChannel — Microsoft Teams Bot Framework adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("teams")
|
||||
class TeamsChannel(BaseChannel):
|
||||
"""Microsoft Teams channel adapter using the Bot Framework REST API.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
app_id:
|
||||
Microsoft App ID. Falls back to ``TEAMS_APP_ID`` env var.
|
||||
app_password:
|
||||
Microsoft App Password. Falls back to ``TEAMS_APP_PASSWORD`` env var.
|
||||
service_url:
|
||||
Bot Framework service URL. Falls back to ``TEAMS_SERVICE_URL`` env var
|
||||
(default ``https://smba.trafficmanager.net/teams``).
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "teams"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app_id: str = "",
|
||||
*,
|
||||
app_password: str = "",
|
||||
service_url: str = "",
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._app_id = app_id or os.environ.get("TEAMS_APP_ID", "")
|
||||
self._app_password = app_password or os.environ.get("TEAMS_APP_PASSWORD", "")
|
||||
self._service_url = (
|
||||
service_url
|
||||
or os.environ.get("TEAMS_SERVICE_URL", "https://smba.trafficmanager.net/teams")
|
||||
)
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Mark as connected (send-only — no persistent connection)."""
|
||||
if not self._app_id or not self._app_password:
|
||||
logger.warning("No Teams app_id or app_password configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Mark as disconnected."""
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Send a message to a Teams conversation via the Bot Framework API."""
|
||||
if not self._app_id or not self._app_password:
|
||||
logger.warning("Cannot send: no Teams credentials configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
url = f"{self._service_url}/v3/conversations/{channel}/activities"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._app_password}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload: Dict[str, Any] = {
|
||||
"type": "message",
|
||||
"text": content,
|
||||
}
|
||||
if conversation_id:
|
||||
payload["replyToId"] = conversation_id
|
||||
|
||||
resp = httpx.post(
|
||||
url, json=payload, headers=headers, timeout=10.0,
|
||||
)
|
||||
if resp.status_code < 300:
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
logger.warning(
|
||||
"Teams API returned status %d", resp.status_code,
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug("Teams send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["teams"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["TeamsChannel"]
|
||||
@@ -0,0 +1,202 @@
|
||||
"""TelegramChannel — native Telegram Bot API adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelMessage,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("telegram")
|
||||
class TelegramChannel(BaseChannel):
|
||||
"""Native Telegram channel adapter using the Bot API.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
bot_token:
|
||||
Telegram Bot API token. Falls back to ``TELEGRAM_BOT_TOKEN`` env var.
|
||||
allowed_chat_ids:
|
||||
Comma-separated list of chat IDs allowed to interact.
|
||||
parse_mode:
|
||||
Message parse mode (``Markdown``, ``HTML``, etc.).
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "telegram"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bot_token: str = "",
|
||||
*,
|
||||
allowed_chat_ids: str = "",
|
||||
parse_mode: str = "Markdown",
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._token = bot_token or os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||
self._allowed_chat_ids = allowed_chat_ids
|
||||
self._parse_mode = parse_mode
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
self._listener_thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Start listening for incoming messages via long polling."""
|
||||
if not self._token:
|
||||
logger.warning("No Telegram bot token configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
|
||||
self._stop_event.clear()
|
||||
self._status = ChannelStatus.CONNECTING
|
||||
|
||||
try:
|
||||
from telegram.ext import ApplicationBuilder # noqa: F401
|
||||
|
||||
self._listener_thread = threading.Thread(
|
||||
target=self._poll_loop, daemon=True,
|
||||
)
|
||||
self._listener_thread.start()
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
logger.info("Telegram channel connected (long polling)")
|
||||
except ImportError:
|
||||
# python-telegram-bot not installed — send-only mode
|
||||
logger.info(
|
||||
"python-telegram-bot not installed; send-only mode",
|
||||
)
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Stop the listener thread."""
|
||||
self._stop_event.set()
|
||||
if self._listener_thread is not None:
|
||||
self._listener_thread.join(timeout=5.0)
|
||||
self._listener_thread = None
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Send a message to a Telegram chat via the Bot API."""
|
||||
if not self._token:
|
||||
logger.warning("Cannot send: no Telegram bot token")
|
||||
return False
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
url = f"https://api.telegram.org/bot{self._token}/sendMessage"
|
||||
payload: Dict[str, Any] = {
|
||||
"chat_id": channel,
|
||||
"text": content,
|
||||
}
|
||||
if self._parse_mode:
|
||||
payload["parse_mode"] = self._parse_mode
|
||||
if conversation_id:
|
||||
payload["reply_to_message_id"] = conversation_id
|
||||
|
||||
resp = httpx.post(url, json=payload, timeout=10.0)
|
||||
if resp.status_code < 300:
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
logger.warning(
|
||||
"Telegram API returned status %d: %s",
|
||||
resp.status_code,
|
||||
resp.text,
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug("Telegram send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["telegram"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _poll_loop(self) -> None:
|
||||
"""Long-poll for updates using python-telegram-bot."""
|
||||
try:
|
||||
from telegram.ext import ApplicationBuilder, MessageHandler, filters
|
||||
|
||||
app = ApplicationBuilder().token(self._token).build()
|
||||
|
||||
def _handle_msg(update, context):
|
||||
msg = update.message
|
||||
if msg is None:
|
||||
return
|
||||
cm = ChannelMessage(
|
||||
channel="telegram",
|
||||
sender=str(msg.from_user.id) if msg.from_user else "",
|
||||
content=msg.text or "",
|
||||
message_id=str(msg.message_id),
|
||||
conversation_id=str(msg.chat.id),
|
||||
)
|
||||
for handler in self._handlers:
|
||||
try:
|
||||
handler(cm)
|
||||
except Exception:
|
||||
logger.exception("Telegram handler error")
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_RECEIVED,
|
||||
{
|
||||
"channel": cm.channel,
|
||||
"sender": cm.sender,
|
||||
"content": cm.content,
|
||||
"message_id": cm.message_id,
|
||||
},
|
||||
)
|
||||
|
||||
app.add_handler(MessageHandler(filters.TEXT, _handle_msg))
|
||||
app.run_polling(stop_signals=None)
|
||||
except Exception:
|
||||
logger.debug("Telegram poll loop error", exc_info=True)
|
||||
self._status = ChannelStatus.ERROR
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["TelegramChannel"]
|
||||
@@ -0,0 +1,114 @@
|
||||
"""WebChatChannel — in-memory message queue for webchat/testing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelMessage,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("webchat")
|
||||
class WebChatChannel(BaseChannel):
|
||||
"""In-memory webchat channel for testing and embedded web UIs.
|
||||
|
||||
Messages are stored in an internal list and can be retrieved via
|
||||
:meth:`get_messages`. No external dependencies are required.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "webchat"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._messages: List[ChannelMessage] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Mark as connected (always succeeds for in-memory channel)."""
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Mark as disconnected."""
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Append a message to the in-memory queue."""
|
||||
msg = ChannelMessage(
|
||||
channel=channel,
|
||||
sender="jarvis",
|
||||
content=content,
|
||||
conversation_id=conversation_id,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
self._messages.append(msg)
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["webchat"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- webchat-specific helpers -----------------------------------------------
|
||||
|
||||
def get_messages(self) -> List[ChannelMessage]:
|
||||
"""Return all messages stored in the in-memory queue."""
|
||||
return self._messages
|
||||
|
||||
def clear_messages(self) -> None:
|
||||
"""Clear all messages from the in-memory queue."""
|
||||
self._messages.clear()
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["WebChatChannel"]
|
||||
@@ -0,0 +1,139 @@
|
||||
"""WebhookChannel — generic outbound webhook adapter (zero extra deps)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("webhook")
|
||||
class WebhookChannel(BaseChannel):
|
||||
"""Generic outbound webhook channel (send-only).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
url:
|
||||
Target webhook URL.
|
||||
secret:
|
||||
Optional shared secret sent in the ``X-Webhook-Secret`` header.
|
||||
method:
|
||||
HTTP method (default ``POST``).
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "webhook"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str = "",
|
||||
*,
|
||||
secret: str = "",
|
||||
method: str = "POST",
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._url = url
|
||||
self._secret = secret
|
||||
self._method = method.upper()
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Mark as connected (send-only — no persistent connection)."""
|
||||
if not self._url:
|
||||
logger.warning("No webhook URL configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Mark as disconnected."""
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""POST a JSON payload to the configured webhook URL."""
|
||||
if not self._url:
|
||||
logger.warning("Cannot send: no webhook URL configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
}
|
||||
if conversation_id:
|
||||
payload["conversation_id"] = conversation_id
|
||||
if metadata:
|
||||
payload["metadata"] = metadata
|
||||
|
||||
headers: Dict[str, str] = {}
|
||||
if self._secret:
|
||||
headers["X-Webhook-Secret"] = self._secret
|
||||
|
||||
resp = httpx.request(
|
||||
self._method, self._url, json=payload,
|
||||
headers=headers, timeout=10.0,
|
||||
)
|
||||
if resp.status_code < 300:
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
logger.warning(
|
||||
"Webhook returned status %d", resp.status_code,
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug("Webhook send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["webhook"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages (no-op for webhook)."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["WebhookChannel"]
|
||||
@@ -0,0 +1,141 @@
|
||||
"""WhatsAppChannel — WhatsApp Cloud API adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("whatsapp")
|
||||
class WhatsAppChannel(BaseChannel):
|
||||
"""WhatsApp Cloud API channel adapter (send-only).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
access_token:
|
||||
WhatsApp Cloud API access token. Falls back to
|
||||
``WHATSAPP_ACCESS_TOKEN`` env var.
|
||||
phone_number_id:
|
||||
WhatsApp phone number ID. Falls back to
|
||||
``WHATSAPP_PHONE_NUMBER_ID`` env var.
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "whatsapp"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
access_token: str = "",
|
||||
*,
|
||||
phone_number_id: str = "",
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._token = access_token or os.environ.get("WHATSAPP_ACCESS_TOKEN", "")
|
||||
self._phone_number_id = phone_number_id or os.environ.get(
|
||||
"WHATSAPP_PHONE_NUMBER_ID", "",
|
||||
)
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Mark as connected (send-only — no persistent connection)."""
|
||||
if not self._token:
|
||||
logger.warning("No WhatsApp access token configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Mark as disconnected."""
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Send a message via the WhatsApp Cloud API."""
|
||||
if not self._token:
|
||||
logger.warning("Cannot send: no WhatsApp access token")
|
||||
return False
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
url = (
|
||||
f"https://graph.facebook.com/v21.0/"
|
||||
f"{self._phone_number_id}/messages"
|
||||
)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload: Dict[str, Any] = {
|
||||
"messaging_product": "whatsapp",
|
||||
"to": channel,
|
||||
"type": "text",
|
||||
"text": {"body": content},
|
||||
}
|
||||
|
||||
resp = httpx.post(
|
||||
url, json=payload, headers=headers, timeout=10.0,
|
||||
)
|
||||
if resp.status_code < 300:
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
logger.warning(
|
||||
"WhatsApp API returned status %d", resp.status_code,
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug("WhatsApp send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["whatsapp"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["WhatsAppChannel"]
|
||||
@@ -2,10 +2,137 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
_CHANNEL_TYPE_HELP = (
|
||||
"Channel type (telegram, discord, slack, webhook, email, "
|
||||
"whatsapp, signal, google_chat, irc, webchat, teams, "
|
||||
"matrix, mattermost, feishu, bluebubbles)."
|
||||
)
|
||||
|
||||
|
||||
def _get_channel(
|
||||
channel_type: str | None,
|
||||
config: Any,
|
||||
) -> Any:
|
||||
"""Resolve a channel backend by type.
|
||||
|
||||
Resolution order: ``--channel-type`` flag >
|
||||
``config.channel.default_channel`` > error.
|
||||
"""
|
||||
import openjarvis.channels # noqa: F401 -- trigger registration
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
key = channel_type or config.channel.default_channel
|
||||
if not key:
|
||||
raise click.ClickException(
|
||||
"No channel type specified. Use --channel-type or set "
|
||||
"default_channel in [channel] config."
|
||||
)
|
||||
|
||||
kwargs: Dict[str, Any] = {}
|
||||
if key == "telegram":
|
||||
tc = config.channel.telegram
|
||||
if tc.bot_token:
|
||||
kwargs["bot_token"] = tc.bot_token
|
||||
elif key == "discord":
|
||||
dc = config.channel.discord
|
||||
if dc.bot_token:
|
||||
kwargs["bot_token"] = dc.bot_token
|
||||
elif key == "slack":
|
||||
sc = config.channel.slack
|
||||
if sc.bot_token:
|
||||
kwargs["bot_token"] = sc.bot_token
|
||||
if sc.app_token:
|
||||
kwargs["app_token"] = sc.app_token
|
||||
elif key == "webhook":
|
||||
wc = config.channel.webhook
|
||||
if wc.url:
|
||||
kwargs["url"] = wc.url
|
||||
if wc.secret:
|
||||
kwargs["secret"] = wc.secret
|
||||
if wc.method:
|
||||
kwargs["method"] = wc.method
|
||||
elif key == "email":
|
||||
ec = config.channel.email
|
||||
if ec.smtp_host:
|
||||
kwargs["smtp_host"] = ec.smtp_host
|
||||
kwargs["smtp_port"] = ec.smtp_port
|
||||
if ec.username:
|
||||
kwargs["username"] = ec.username
|
||||
if ec.password:
|
||||
kwargs["password"] = ec.password
|
||||
kwargs["use_tls"] = ec.use_tls
|
||||
elif key == "whatsapp":
|
||||
wac = config.channel.whatsapp
|
||||
if wac.access_token:
|
||||
kwargs["access_token"] = wac.access_token
|
||||
if wac.phone_number_id:
|
||||
kwargs["phone_number_id"] = wac.phone_number_id
|
||||
elif key == "signal":
|
||||
sgc = config.channel.signal
|
||||
if sgc.api_url:
|
||||
kwargs["api_url"] = sgc.api_url
|
||||
if sgc.phone_number:
|
||||
kwargs["phone_number"] = sgc.phone_number
|
||||
elif key == "google_chat":
|
||||
gcc = config.channel.google_chat
|
||||
if gcc.webhook_url:
|
||||
kwargs["webhook_url"] = gcc.webhook_url
|
||||
elif key == "irc":
|
||||
ic = config.channel.irc
|
||||
if ic.server:
|
||||
kwargs["server"] = ic.server
|
||||
kwargs["port"] = ic.port
|
||||
if ic.nick:
|
||||
kwargs["nick"] = ic.nick
|
||||
if ic.password:
|
||||
kwargs["password"] = ic.password
|
||||
kwargs["use_tls"] = ic.use_tls
|
||||
elif key == "webchat":
|
||||
pass # no config needed
|
||||
elif key == "teams":
|
||||
tmc = config.channel.teams
|
||||
if tmc.app_id:
|
||||
kwargs["app_id"] = tmc.app_id
|
||||
if tmc.app_password:
|
||||
kwargs["app_password"] = tmc.app_password
|
||||
if tmc.service_url:
|
||||
kwargs["service_url"] = tmc.service_url
|
||||
elif key == "matrix":
|
||||
mc = config.channel.matrix
|
||||
if mc.homeserver:
|
||||
kwargs["homeserver"] = mc.homeserver
|
||||
if mc.access_token:
|
||||
kwargs["access_token"] = mc.access_token
|
||||
elif key == "mattermost":
|
||||
mmc = config.channel.mattermost
|
||||
if mmc.url:
|
||||
kwargs["url"] = mmc.url
|
||||
if mmc.token:
|
||||
kwargs["token"] = mmc.token
|
||||
elif key == "feishu":
|
||||
fc = config.channel.feishu
|
||||
if fc.app_id:
|
||||
kwargs["app_id"] = fc.app_id
|
||||
if fc.app_secret:
|
||||
kwargs["app_secret"] = fc.app_secret
|
||||
elif key == "bluebubbles":
|
||||
bbc = config.channel.bluebubbles
|
||||
if bbc.url:
|
||||
kwargs["url"] = bbc.url
|
||||
if bbc.password:
|
||||
kwargs["password"] = bbc.password
|
||||
|
||||
if not ChannelRegistry.contains(key):
|
||||
raise click.ClickException(f"Unknown channel type: {key}")
|
||||
|
||||
return ChannelRegistry.create(key, **kwargs)
|
||||
|
||||
|
||||
@click.group()
|
||||
def channel() -> None:
|
||||
@@ -13,82 +140,100 @@ def channel() -> None:
|
||||
|
||||
|
||||
@channel.command("list")
|
||||
@click.option("--gateway", default=None, help="OpenClaw gateway URL.")
|
||||
def channel_list(gateway: str | None) -> None:
|
||||
"""List available channels from the gateway."""
|
||||
@click.option(
|
||||
"--channel-type", default=None, help=_CHANNEL_TYPE_HELP,
|
||||
)
|
||||
def channel_list(
|
||||
channel_type: Optional[str],
|
||||
) -> None:
|
||||
"""List available channels."""
|
||||
console = Console()
|
||||
from openjarvis.core.config import load_config
|
||||
|
||||
config = load_config()
|
||||
gw_url = gateway or config.channel.gateway_url
|
||||
|
||||
from openjarvis.channels.openclaw_bridge import OpenClawChannelBridge
|
||||
|
||||
bridge = OpenClawChannelBridge(gateway_url=gw_url)
|
||||
|
||||
try:
|
||||
channels = bridge.list_channels()
|
||||
ch = _get_channel(channel_type, config)
|
||||
except click.ClickException as exc:
|
||||
console.print(f"[red]{exc.message}[/red]")
|
||||
return
|
||||
|
||||
try:
|
||||
channels = ch.list_channels()
|
||||
except Exception as exc:
|
||||
console.print(f"[red]Failed to list channels: {exc}[/red]")
|
||||
return
|
||||
|
||||
if not channels:
|
||||
console.print(
|
||||
"[yellow]No channels available"
|
||||
" (is OpenClaw gateway running?)[/yellow]"
|
||||
)
|
||||
console.print("[yellow]No channels available[/yellow]")
|
||||
return
|
||||
|
||||
table = Table(title="Available Channels")
|
||||
table.add_column("Channel", style="cyan")
|
||||
for ch in channels:
|
||||
table.add_row(ch)
|
||||
for name in channels:
|
||||
table.add_row(name)
|
||||
console.print(table)
|
||||
|
||||
|
||||
@channel.command("send")
|
||||
@click.argument("target")
|
||||
@click.argument("message")
|
||||
@click.option("--gateway", default=None, help="OpenClaw gateway URL.")
|
||||
def channel_send(target: str, message: str, gateway: str | None) -> None:
|
||||
@click.option(
|
||||
"--channel-type", default=None, help=_CHANNEL_TYPE_HELP,
|
||||
)
|
||||
def channel_send(
|
||||
target: str,
|
||||
message: str,
|
||||
channel_type: Optional[str],
|
||||
) -> None:
|
||||
"""Send a message to a channel."""
|
||||
console = Console()
|
||||
from openjarvis.core.config import load_config
|
||||
|
||||
config = load_config()
|
||||
gw_url = gateway or config.channel.gateway_url
|
||||
|
||||
from openjarvis.channels.openclaw_bridge import OpenClawChannelBridge
|
||||
try:
|
||||
ch = _get_channel(channel_type, config)
|
||||
except click.ClickException as exc:
|
||||
console.print(f"[red]{exc.message}[/red]")
|
||||
return
|
||||
|
||||
bridge = OpenClawChannelBridge(gateway_url=gw_url)
|
||||
|
||||
ok = bridge.send(target, message)
|
||||
ok = ch.send(target, message)
|
||||
if ok:
|
||||
console.print(f"[green]Message sent to {target}[/green]")
|
||||
else:
|
||||
console.print(f"[red]Failed to send message to {target}[/red]")
|
||||
console.print(
|
||||
f"[red]Failed to send message to {target}[/red]",
|
||||
)
|
||||
|
||||
|
||||
@channel.command("status")
|
||||
@click.option("--gateway", default=None, help="OpenClaw gateway URL.")
|
||||
def channel_status(gateway: str | None) -> None:
|
||||
"""Show channel bridge connection status."""
|
||||
@click.option(
|
||||
"--channel-type", default=None, help=_CHANNEL_TYPE_HELP,
|
||||
)
|
||||
def channel_status(
|
||||
channel_type: Optional[str],
|
||||
) -> None:
|
||||
"""Show channel connection status."""
|
||||
console = Console()
|
||||
from openjarvis.core.config import load_config
|
||||
|
||||
config = load_config()
|
||||
gw_url = gateway or config.channel.gateway_url
|
||||
|
||||
from openjarvis.channels.openclaw_bridge import OpenClawChannelBridge
|
||||
try:
|
||||
ch = _get_channel(channel_type, config)
|
||||
except click.ClickException as exc:
|
||||
console.print(f"[red]{exc.message}[/red]")
|
||||
return
|
||||
|
||||
bridge = OpenClawChannelBridge(gateway_url=gw_url)
|
||||
|
||||
st = bridge.status()
|
||||
st = ch.status()
|
||||
color = {
|
||||
"connected": "green",
|
||||
"disconnected": "yellow",
|
||||
"connecting": "blue",
|
||||
"error": "red",
|
||||
}.get(st.value, "white")
|
||||
console.print(f"Gateway: [cyan]{gw_url}[/cyan]")
|
||||
|
||||
key = channel_type or config.channel.default_channel or "unknown"
|
||||
console.print(f"Channel: [cyan]{key}[/cyan]")
|
||||
console.print(f"Status: [{color}]{st.value}[/{color}]")
|
||||
|
||||
+13
-13
@@ -142,23 +142,23 @@ def serve(
|
||||
console.print(f"[yellow]Agent '{agent_key}' failed to load: {exc}[/yellow]")
|
||||
traceback.print_exc()
|
||||
|
||||
# Set up channel bridge if enabled
|
||||
# Set up channel backend if enabled
|
||||
channel_bridge = None
|
||||
if config.channel.enabled:
|
||||
if config.channel.enabled and config.channel.default_channel:
|
||||
try:
|
||||
from openjarvis.channels.openclaw_bridge import OpenClawChannelBridge
|
||||
from openjarvis.system import SystemBuilder
|
||||
|
||||
channel_bridge = OpenClawChannelBridge(
|
||||
gateway_url=config.channel.gateway_url,
|
||||
reconnect_interval=config.channel.reconnect_interval,
|
||||
bus=bus,
|
||||
)
|
||||
channel_bridge.connect()
|
||||
console.print(
|
||||
f" Channels: [cyan]{config.channel.gateway_url}[/cyan]"
|
||||
)
|
||||
# Reuse _resolve_channel logic from SystemBuilder
|
||||
sb = SystemBuilder(config)
|
||||
sb._bus = bus
|
||||
channel_bridge = sb._resolve_channel(config, bus)
|
||||
if channel_bridge is not None:
|
||||
channel_bridge.connect()
|
||||
console.print(
|
||||
f" Channel: [cyan]{config.channel.default_channel}[/cyan]"
|
||||
)
|
||||
except Exception as exc:
|
||||
console.print(f"[yellow]Channel bridge failed to start: {exc}[/yellow]")
|
||||
console.print(f"[yellow]Channel failed to start: {exc}[/yellow]")
|
||||
channel_bridge = None
|
||||
|
||||
# Create app
|
||||
|
||||
@@ -297,13 +297,155 @@ class TracesConfig:
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TelegramChannelConfig:
|
||||
"""Per-channel config for Telegram."""
|
||||
|
||||
bot_token: str = ""
|
||||
allowed_chat_ids: str = ""
|
||||
parse_mode: str = "Markdown"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DiscordChannelConfig:
|
||||
"""Per-channel config for Discord."""
|
||||
|
||||
bot_token: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SlackChannelConfig:
|
||||
"""Per-channel config for Slack."""
|
||||
|
||||
bot_token: str = ""
|
||||
app_token: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WebhookChannelConfig:
|
||||
"""Per-channel config for generic webhooks."""
|
||||
|
||||
url: str = ""
|
||||
secret: str = ""
|
||||
method: str = "POST"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class EmailChannelConfig:
|
||||
"""Per-channel config for email (SMTP/IMAP)."""
|
||||
|
||||
smtp_host: str = ""
|
||||
smtp_port: int = 587
|
||||
imap_host: str = ""
|
||||
imap_port: int = 993
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
use_tls: bool = True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WhatsAppChannelConfig:
|
||||
"""Per-channel config for WhatsApp Cloud API."""
|
||||
|
||||
access_token: str = ""
|
||||
phone_number_id: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SignalChannelConfig:
|
||||
"""Per-channel config for Signal (via signal-cli REST API)."""
|
||||
|
||||
api_url: str = ""
|
||||
phone_number: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GoogleChatChannelConfig:
|
||||
"""Per-channel config for Google Chat webhooks."""
|
||||
|
||||
webhook_url: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class IRCChannelConfig:
|
||||
"""Per-channel config for IRC."""
|
||||
|
||||
server: str = ""
|
||||
port: int = 6667
|
||||
nick: str = ""
|
||||
password: str = ""
|
||||
use_tls: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WebChatChannelConfig:
|
||||
"""Per-channel config for in-memory webchat."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TeamsChannelConfig:
|
||||
"""Per-channel config for Microsoft Teams (Bot Framework)."""
|
||||
|
||||
app_id: str = ""
|
||||
app_password: str = ""
|
||||
service_url: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MatrixChannelConfig:
|
||||
"""Per-channel config for Matrix."""
|
||||
|
||||
homeserver: str = ""
|
||||
access_token: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MattermostChannelConfig:
|
||||
"""Per-channel config for Mattermost."""
|
||||
|
||||
url: str = ""
|
||||
token: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FeishuChannelConfig:
|
||||
"""Per-channel config for Feishu (Lark)."""
|
||||
|
||||
app_id: str = ""
|
||||
app_secret: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BlueBubblesChannelConfig:
|
||||
"""Per-channel config for BlueBubbles (iMessage bridge)."""
|
||||
|
||||
url: str = ""
|
||||
password: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelConfig:
|
||||
"""Channel messaging settings."""
|
||||
|
||||
enabled: bool = False
|
||||
gateway_url: str = "ws://127.0.0.1:18789/ws"
|
||||
default_channel: str = ""
|
||||
default_agent: str = "simple"
|
||||
reconnect_interval: float = 5.0
|
||||
telegram: TelegramChannelConfig = field(default_factory=TelegramChannelConfig)
|
||||
discord: DiscordChannelConfig = field(default_factory=DiscordChannelConfig)
|
||||
slack: SlackChannelConfig = field(default_factory=SlackChannelConfig)
|
||||
webhook: WebhookChannelConfig = field(default_factory=WebhookChannelConfig)
|
||||
email: EmailChannelConfig = field(default_factory=EmailChannelConfig)
|
||||
whatsapp: WhatsAppChannelConfig = field(default_factory=WhatsAppChannelConfig)
|
||||
signal: SignalChannelConfig = field(default_factory=SignalChannelConfig)
|
||||
google_chat: GoogleChatChannelConfig = field(default_factory=GoogleChatChannelConfig)
|
||||
irc: IRCChannelConfig = field(default_factory=IRCChannelConfig)
|
||||
webchat: WebChatChannelConfig = field(default_factory=WebChatChannelConfig)
|
||||
teams: TeamsChannelConfig = field(default_factory=TeamsChannelConfig)
|
||||
matrix: MatrixChannelConfig = field(default_factory=MatrixChannelConfig)
|
||||
mattermost: MattermostChannelConfig = field(default_factory=MattermostChannelConfig)
|
||||
feishu: FeishuChannelConfig = field(default_factory=FeishuChannelConfig)
|
||||
bluebubbles: BlueBubblesChannelConfig = field(default_factory=BlueBubblesChannelConfig)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -379,7 +521,7 @@ def load_config(path: Optional[Path] = None) -> JarvisConfig:
|
||||
# Simple top-level sections
|
||||
simple_sections = (
|
||||
"engine", "intelligence", "learning",
|
||||
"agent", "server", "telemetry", "traces", "channel", "security",
|
||||
"agent", "server", "telemetry", "traces", "security",
|
||||
)
|
||||
for section_name in simple_sections:
|
||||
if section_name in data:
|
||||
@@ -389,6 +531,22 @@ def load_config(path: Optional[Path] = None) -> JarvisConfig:
|
||||
if "memory" in data:
|
||||
_apply_toml_section(cfg.tools.storage, data["memory"])
|
||||
|
||||
# [channel] with nested per-channel sub-configs
|
||||
if "channel" in data:
|
||||
ch_data = data["channel"]
|
||||
# Top-level channel keys (enabled, default_channel, etc.)
|
||||
for key, value in ch_data.items():
|
||||
if not isinstance(value, dict) and hasattr(cfg.channel, key):
|
||||
setattr(cfg.channel, key, value)
|
||||
# Nested per-channel configs
|
||||
for sub in (
|
||||
"telegram", "discord", "slack", "webhook", "email",
|
||||
"whatsapp", "signal", "google_chat", "irc", "webchat",
|
||||
"teams", "matrix", "mattermost", "feishu", "bluebubbles",
|
||||
):
|
||||
if sub in ch_data and isinstance(ch_data[sub], dict):
|
||||
_apply_toml_section(getattr(cfg.channel, sub), ch_data[sub])
|
||||
|
||||
# Tools: accept [tools] and nested [tools.storage], [tools.mcp]
|
||||
if "tools" in data:
|
||||
tools_data = data["tools"]
|
||||
@@ -462,9 +620,56 @@ enabled = false
|
||||
|
||||
[channel]
|
||||
enabled = false
|
||||
gateway_url = "ws://127.0.0.1:18789/ws"
|
||||
default_agent = "simple"
|
||||
reconnect_interval = 5.0
|
||||
|
||||
# [channel.telegram]
|
||||
# bot_token = "" # Or set TELEGRAM_BOT_TOKEN env var
|
||||
|
||||
# [channel.discord]
|
||||
# bot_token = "" # Or set DISCORD_BOT_TOKEN env var
|
||||
|
||||
# [channel.slack]
|
||||
# bot_token = "" # Or set SLACK_BOT_TOKEN env var
|
||||
|
||||
# [channel.webhook]
|
||||
# url = ""
|
||||
|
||||
# [channel.whatsapp]
|
||||
# access_token = "" # Or set WHATSAPP_ACCESS_TOKEN env var
|
||||
# phone_number_id = "" # Or set WHATSAPP_PHONE_NUMBER_ID env var
|
||||
|
||||
# [channel.signal]
|
||||
# api_url = "" # signal-cli REST API URL
|
||||
# phone_number = "" # Or set SIGNAL_PHONE_NUMBER env var
|
||||
|
||||
# [channel.google_chat]
|
||||
# webhook_url = "" # Or set GOOGLE_CHAT_WEBHOOK_URL env var
|
||||
|
||||
# [channel.irc]
|
||||
# server = ""
|
||||
# port = 6667
|
||||
# nick = ""
|
||||
# use_tls = false
|
||||
|
||||
# [channel.teams]
|
||||
# app_id = "" # Or set TEAMS_APP_ID env var
|
||||
# app_password = "" # Or set TEAMS_APP_PASSWORD env var
|
||||
|
||||
# [channel.matrix]
|
||||
# homeserver = "" # Or set MATRIX_HOMESERVER env var
|
||||
# access_token = "" # Or set MATRIX_ACCESS_TOKEN env var
|
||||
|
||||
# [channel.mattermost]
|
||||
# url = "" # Or set MATTERMOST_URL env var
|
||||
# token = "" # Or set MATTERMOST_TOKEN env var
|
||||
|
||||
# [channel.feishu]
|
||||
# app_id = "" # Or set FEISHU_APP_ID env var
|
||||
# app_secret = "" # Or set FEISHU_APP_SECRET env var
|
||||
|
||||
# [channel.bluebubbles]
|
||||
# url = "" # Or set BLUEBUBBLES_URL env var
|
||||
# password = "" # Or set BLUEBUBBLES_PASSWORD env var
|
||||
|
||||
[security]
|
||||
enabled = true
|
||||
@@ -479,23 +684,38 @@ enforce_tool_confirmation = true
|
||||
|
||||
__all__ = [
|
||||
"AgentConfig",
|
||||
"BlueBubblesChannelConfig",
|
||||
"ChannelConfig",
|
||||
"DEFAULT_CONFIG_DIR",
|
||||
"DEFAULT_CONFIG_PATH",
|
||||
"DiscordChannelConfig",
|
||||
"EmailChannelConfig",
|
||||
"EngineConfig",
|
||||
"FeishuChannelConfig",
|
||||
"GoogleChatChannelConfig",
|
||||
"GpuInfo",
|
||||
"HardwareInfo",
|
||||
"IRCChannelConfig",
|
||||
"IntelligenceConfig",
|
||||
"JarvisConfig",
|
||||
"LearningConfig",
|
||||
"MCPConfig",
|
||||
"MatrixChannelConfig",
|
||||
"MattermostChannelConfig",
|
||||
"MemoryConfig",
|
||||
"SecurityConfig",
|
||||
"ServerConfig",
|
||||
"SignalChannelConfig",
|
||||
"SlackChannelConfig",
|
||||
"StorageConfig",
|
||||
"TeamsChannelConfig",
|
||||
"TelegramChannelConfig",
|
||||
"TelemetryConfig",
|
||||
"ToolsConfig",
|
||||
"TracesConfig",
|
||||
"WebChatChannelConfig",
|
||||
"WebhookChannelConfig",
|
||||
"WhatsAppChannelConfig",
|
||||
"detect_hardware",
|
||||
"generate_default_toml",
|
||||
"load_config",
|
||||
|
||||
@@ -21,6 +21,12 @@ try:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# LiteLLM engine is optional — only register if litellm is installed
|
||||
try:
|
||||
import openjarvis.engine.litellm # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
__all__ = [
|
||||
"EngineConnectionError",
|
||||
"InferenceEngine",
|
||||
|
||||
@@ -15,6 +15,7 @@ _HOST_MAP: Dict[str, str | None] = {
|
||||
"llamacpp": "llamacpp_host",
|
||||
"sglang": "sglang_host",
|
||||
"cloud": None,
|
||||
"litellm": None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""LiteLLM inference engine — unified access to 100+ LLM providers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from openjarvis.core.registry import EngineRegistry
|
||||
from openjarvis.core.types import Message
|
||||
from openjarvis.engine._base import InferenceEngine, messages_to_dicts
|
||||
|
||||
|
||||
@EngineRegistry.register("litellm")
|
||||
class LiteLLMEngine(InferenceEngine):
|
||||
"""Inference via LiteLLM — routes to any supported provider.
|
||||
|
||||
LiteLLM normalizes all providers (OpenAI, Anthropic, Google, DeepSeek,
|
||||
Groq, Together, Fireworks, OpenRouter, Mistral, Cohere, xAI, Perplexity,
|
||||
etc.) to OpenAI-format input/output. Model selection uses LiteLLM's
|
||||
``provider/model`` convention, e.g. ``anthropic/claude-sonnet-4-20250514``.
|
||||
|
||||
API keys are read from environment variables following each provider's
|
||||
convention (OPENAI_API_KEY, ANTHROPIC_API_KEY, GROQ_API_KEY, etc.).
|
||||
"""
|
||||
|
||||
engine_id = "litellm"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_base: str | None = None,
|
||||
default_model: str | None = None,
|
||||
) -> None:
|
||||
self._api_base = api_base
|
||||
self._default_model = default_model
|
||||
|
||||
def generate(
|
||||
self,
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
model: str,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 1024,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
import litellm
|
||||
|
||||
call_kwargs: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages_to_dicts(messages),
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
if self._api_base:
|
||||
call_kwargs["api_base"] = self._api_base
|
||||
# Pass through tools if provided
|
||||
if "tools" in kwargs:
|
||||
call_kwargs["tools"] = kwargs.pop("tools")
|
||||
call_kwargs.update(kwargs)
|
||||
|
||||
resp = litellm.completion(**call_kwargs)
|
||||
|
||||
choice = resp.choices[0]
|
||||
usage = resp.usage
|
||||
prompt_tokens = usage.prompt_tokens if usage else 0
|
||||
completion_tokens = usage.completion_tokens if usage else 0
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
"content": choice.message.content or "",
|
||||
"usage": {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": (usage.total_tokens if usage else 0),
|
||||
},
|
||||
"model": resp.model,
|
||||
"finish_reason": choice.finish_reason or "stop",
|
||||
}
|
||||
|
||||
# Extract tool_calls in OpenAI format
|
||||
if hasattr(choice.message, "tool_calls") and choice.message.tool_calls:
|
||||
result["tool_calls"] = [
|
||||
{
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments,
|
||||
},
|
||||
}
|
||||
for tc in choice.message.tool_calls
|
||||
]
|
||||
|
||||
# Cost tracking via litellm's built-in cost calculation
|
||||
try:
|
||||
cost = litellm.completion_cost(completion_response=resp)
|
||||
result["cost_usd"] = cost
|
||||
except Exception:
|
||||
result["cost_usd"] = 0.0
|
||||
|
||||
return result
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
model: str,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 1024,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[str]:
|
||||
import litellm
|
||||
|
||||
call_kwargs: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages_to_dicts(messages),
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": True,
|
||||
}
|
||||
if self._api_base:
|
||||
call_kwargs["api_base"] = self._api_base
|
||||
call_kwargs.update(kwargs)
|
||||
|
||||
resp = litellm.completion(**call_kwargs)
|
||||
for chunk in resp:
|
||||
delta = chunk.choices[0].delta if chunk.choices else None
|
||||
if delta and delta.content:
|
||||
yield delta.content
|
||||
|
||||
def list_models(self) -> List[str]:
|
||||
if self._default_model:
|
||||
return [self._default_model]
|
||||
return []
|
||||
|
||||
def health(self) -> bool:
|
||||
try:
|
||||
import litellm # noqa: F401
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["LiteLLMEngine"]
|
||||
@@ -47,6 +47,12 @@ def ensure_registered() -> None:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Orchestrator-native SFT & GRPO training
|
||||
try:
|
||||
import openjarvis.learning.orchestrator # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
__all__ = [
|
||||
"HeuristicRewardFunction",
|
||||
|
||||
@@ -32,7 +32,7 @@ class RewardFunction(ABC):
|
||||
class LearningPolicy(ABC):
|
||||
"""Base for all learning policies. Targets one or more pillars."""
|
||||
|
||||
target: ClassVar[str] = "" # "intelligence" | "agent" | "tools"
|
||||
target: ClassVar[str] = "" # "intelligence" | "agent"
|
||||
|
||||
@abstractmethod
|
||||
def update(self, trace_store: Any, **kwargs: object) -> Dict[str, Any]:
|
||||
@@ -51,12 +51,6 @@ class AgentLearningPolicy(LearningPolicy):
|
||||
target: ClassVar[str] = "agent"
|
||||
|
||||
|
||||
class ToolLearningPolicy(LearningPolicy):
|
||||
"""Updates tool usage (ICL examples, skills) from traces."""
|
||||
|
||||
target: ClassVar[str] = "tools"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentLearningPolicy",
|
||||
"IntelligenceLearningPolicy",
|
||||
@@ -65,5 +59,4 @@ __all__ = [
|
||||
"RewardFunction",
|
||||
"RouterPolicy",
|
||||
"RoutingContext",
|
||||
"ToolLearningPolicy",
|
||||
]
|
||||
|
||||
@@ -5,16 +5,17 @@ from __future__ import annotations
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from openjarvis.core.registry import LearningRegistry
|
||||
from openjarvis.learning._stubs import ToolLearningPolicy
|
||||
from openjarvis.learning._stubs import AgentLearningPolicy
|
||||
|
||||
|
||||
@LearningRegistry.register("icl_updater")
|
||||
class ICLUpdaterPolicy(ToolLearningPolicy):
|
||||
class ICLUpdaterPolicy(AgentLearningPolicy):
|
||||
"""Updates in-context examples and discovers skills from traces.
|
||||
|
||||
Analyzes traces for successful tool call patterns, extracts
|
||||
in-context learning examples, and discovers reusable multi-tool
|
||||
sequences ("skills").
|
||||
sequences ("skills"). This updates *agent* logic (ICL examples
|
||||
and tool-use strategies), not tool implementations themselves.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Orchestrator training infrastructure — SFT and GRPO pipelines.
|
||||
|
||||
Provides structured-mode training for the OrchestratorAgent with:
|
||||
|
||||
- **Episode types**: Action, Observation, Episode, EpisodeState
|
||||
- **Reward**: Multi-objective reward balancing accuracy, cost, energy, latency, power
|
||||
- **Prompt registry**: Canonical system prompts for structured mode
|
||||
- **Policy model**: HuggingFace LM wrapper for action prediction
|
||||
- **Environment**: RL environment using OpenJarvis ToolExecutor
|
||||
- **SFT trainer**: Supervised fine-tuning on successful trajectories
|
||||
- **GRPO trainer**: Group Relative Policy Optimization
|
||||
|
||||
Importing this module triggers registration of ``orchestrator_sft`` and
|
||||
``orchestrator_grpo`` in :class:`~openjarvis.core.registry.LearningRegistry`.
|
||||
"""
|
||||
|
||||
from openjarvis.learning.orchestrator.environment import (
|
||||
OrchestratorEnvironment,
|
||||
)
|
||||
from openjarvis.learning.orchestrator.grpo_trainer import (
|
||||
OrchestratorGRPOConfig,
|
||||
OrchestratorGRPOTrainer,
|
||||
)
|
||||
from openjarvis.learning.orchestrator.policy_model import (
|
||||
OrchestratorPolicyModel,
|
||||
)
|
||||
from openjarvis.learning.orchestrator.prompt_registry import (
|
||||
TOOL_DESCRIPTIONS,
|
||||
build_system_prompt,
|
||||
)
|
||||
from openjarvis.learning.orchestrator.reward import (
|
||||
AdaptiveRewardWeights,
|
||||
MultiObjectiveReward,
|
||||
Normalizers,
|
||||
RewardWeights,
|
||||
)
|
||||
from openjarvis.learning.orchestrator.sft_trainer import (
|
||||
OrchestratorSFTConfig,
|
||||
OrchestratorSFTDataset,
|
||||
OrchestratorSFTTrainer,
|
||||
)
|
||||
from openjarvis.learning.orchestrator.types import (
|
||||
Episode,
|
||||
EpisodeState,
|
||||
EpisodeStep,
|
||||
OrchestratorAction,
|
||||
OrchestratorObservation,
|
||||
PolicyOutput,
|
||||
extract_answer,
|
||||
grade_answer,
|
||||
normalize_number,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Types
|
||||
"Episode",
|
||||
"EpisodeState",
|
||||
"EpisodeStep",
|
||||
"OrchestratorAction",
|
||||
"OrchestratorObservation",
|
||||
"PolicyOutput",
|
||||
"extract_answer",
|
||||
"grade_answer",
|
||||
"normalize_number",
|
||||
# Reward
|
||||
"AdaptiveRewardWeights",
|
||||
"MultiObjectiveReward",
|
||||
"Normalizers",
|
||||
"RewardWeights",
|
||||
# Prompt
|
||||
"TOOL_DESCRIPTIONS",
|
||||
"build_system_prompt",
|
||||
# Policy
|
||||
"OrchestratorPolicyModel",
|
||||
# Environment
|
||||
"OrchestratorEnvironment",
|
||||
# SFT
|
||||
"OrchestratorSFTConfig",
|
||||
"OrchestratorSFTDataset",
|
||||
"OrchestratorSFTTrainer",
|
||||
# GRPO
|
||||
"OrchestratorGRPOConfig",
|
||||
"OrchestratorGRPOTrainer",
|
||||
]
|
||||
@@ -0,0 +1,115 @@
|
||||
"""RL environment for orchestrator training.
|
||||
|
||||
Adapted from IPW's ``environment.py``. Uses OpenJarvis's
|
||||
:class:`~openjarvis.tools._stubs.ToolExecutor` for real tool dispatch
|
||||
(as opposed to IPW's cached-telemetry approach), making it suitable for
|
||||
both training and evaluation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import List, Tuple
|
||||
|
||||
from openjarvis.core.types import ToolCall
|
||||
from openjarvis.learning.orchestrator.types import (
|
||||
EpisodeState,
|
||||
OrchestratorAction,
|
||||
OrchestratorObservation,
|
||||
)
|
||||
from openjarvis.tools._stubs import BaseTool, ToolExecutor
|
||||
|
||||
|
||||
class OrchestratorEnvironment:
|
||||
"""RL environment that executes tools via OpenJarvis ``ToolExecutor``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
tools:
|
||||
List of :class:`BaseTool` instances available to the agent.
|
||||
max_turns:
|
||||
Maximum number of turns per episode.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tools: List[BaseTool],
|
||||
max_turns: int = 10,
|
||||
) -> None:
|
||||
self._tools = tools
|
||||
self._executor = ToolExecutor(tools)
|
||||
self._max_turns = max_turns
|
||||
|
||||
def reset(self, task: str) -> EpisodeState:
|
||||
"""Reset the environment for a new episode.
|
||||
|
||||
Args:
|
||||
task: The initial task/question.
|
||||
|
||||
Returns:
|
||||
A fresh :class:`EpisodeState`.
|
||||
"""
|
||||
return EpisodeState(initial_prompt=task)
|
||||
|
||||
def step(
|
||||
self,
|
||||
state: EpisodeState,
|
||||
action: OrchestratorAction,
|
||||
) -> Tuple[EpisodeState, OrchestratorObservation]:
|
||||
"""Execute one step: dispatch the tool and observe the result.
|
||||
|
||||
Raises:
|
||||
ValueError: If the tool is not available or max turns exceeded.
|
||||
"""
|
||||
available = self.get_available_tools()
|
||||
|
||||
if action.tool_name not in available:
|
||||
raise ValueError(
|
||||
f"Tool '{action.tool_name}' not available. "
|
||||
f"Available: {available}"
|
||||
)
|
||||
|
||||
if state.num_turns() >= self._max_turns:
|
||||
raise ValueError(
|
||||
f"Max turns ({self._max_turns}) exceeded"
|
||||
)
|
||||
|
||||
# Execute tool via ToolExecutor
|
||||
tool_call = ToolCall(
|
||||
id=f"orch_{state.num_turns()}",
|
||||
name=action.tool_name,
|
||||
arguments=action.tool_input
|
||||
if action.tool_input.startswith("{")
|
||||
else f'{{"expression": {repr(action.tool_input)}}}',
|
||||
)
|
||||
|
||||
t0 = time.time()
|
||||
result = self._executor.execute(tool_call)
|
||||
latency = time.time() - t0
|
||||
|
||||
observation = OrchestratorObservation(
|
||||
content=result.content,
|
||||
latency_seconds=latency,
|
||||
cost_usd=result.cost_usd,
|
||||
energy_joules=0.0,
|
||||
power_watts=0.0,
|
||||
tokens=result.usage.get("total_tokens", 0),
|
||||
)
|
||||
|
||||
state.add_turn(action, observation)
|
||||
return state, observation
|
||||
|
||||
def is_done(self, state: EpisodeState) -> bool:
|
||||
"""Check if the episode is complete."""
|
||||
if state.final_answer is not None:
|
||||
return True
|
||||
if state.num_turns() >= self._max_turns:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_available_tools(self) -> List[str]:
|
||||
"""Return names of all available tools."""
|
||||
return [t.spec.name for t in self._tools]
|
||||
|
||||
|
||||
__all__ = ["OrchestratorEnvironment"]
|
||||
@@ -0,0 +1,498 @@
|
||||
"""GRPO (Group Relative Policy Optimization) trainer for orchestrator.
|
||||
|
||||
Adapted from IPW's ``trainer.py``. GRPO is simpler than PPO because it
|
||||
doesn't require a separate critic model — instead, it uses
|
||||
*group-relative advantages*: for each problem, sample N candidate
|
||||
trajectories, compute rewards, normalise within the group, and update
|
||||
the policy to increase the probability of better solutions.
|
||||
|
||||
All ``torch``/``transformers`` imports are guarded so the module can be
|
||||
imported without GPU dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
# Optional imports -----------------------------------------------------------
|
||||
try:
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
HAS_TORCH = True
|
||||
except ImportError:
|
||||
HAS_TORCH = False
|
||||
torch = None # type: ignore[assignment]
|
||||
F = None # type: ignore[assignment]
|
||||
|
||||
from openjarvis.core.registry import LearningRegistry
|
||||
from openjarvis.learning._stubs import AgentLearningPolicy
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrchestratorGRPOConfig:
|
||||
"""Configuration for orchestrator GRPO training."""
|
||||
|
||||
# Model
|
||||
model_name: str = "Qwen/Qwen3-1.7B"
|
||||
max_prompt_length: int = 24000
|
||||
max_response_length: int = 8768
|
||||
|
||||
# Training
|
||||
num_epochs: int = 10
|
||||
batch_size: int = 16
|
||||
learning_rate: float = 1e-6
|
||||
max_grad_norm: float = 1.0
|
||||
|
||||
# GRPO specific
|
||||
num_samples_per_prompt: int = 8
|
||||
temperature: float = 1.0
|
||||
kl_coef: float = 0.0001
|
||||
clip_ratio: float = 0.2
|
||||
|
||||
# Environment
|
||||
available_tools: List[str] = field(
|
||||
default_factory=lambda: [
|
||||
"calculator",
|
||||
"think",
|
||||
"code_interpreter",
|
||||
"web_search",
|
||||
]
|
||||
)
|
||||
max_turns: int = 10
|
||||
|
||||
# Checkpoint
|
||||
checkpoint_dir: str = "checkpoints/orchestrator_grpo"
|
||||
save_every_n_epochs: int = 1
|
||||
keep_last_n: int = 3
|
||||
|
||||
# Memory
|
||||
gradient_checkpointing: bool = True
|
||||
use_8bit_ref: bool = True
|
||||
use_8bit_optimizer: bool = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trainer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OrchestratorGRPOTrainer:
|
||||
"""GRPO trainer for orchestrator policy.
|
||||
|
||||
``torch`` must be installed to call :meth:`train`.
|
||||
"""
|
||||
|
||||
def __init__(self, config: OrchestratorGRPOConfig) -> None:
|
||||
self.config = config
|
||||
self.device = None
|
||||
self.global_step = 0
|
||||
|
||||
if HAS_TORCH and torch is not None:
|
||||
self.device = torch.device(
|
||||
"cuda" if torch.cuda.is_available() else "cpu"
|
||||
)
|
||||
|
||||
self._init_model()
|
||||
self._init_optimizer()
|
||||
|
||||
# -- Initialisation ------------------------------------------------------
|
||||
|
||||
def _init_model(self) -> None:
|
||||
from openjarvis.learning.orchestrator.policy_model import (
|
||||
OrchestratorPolicyModel,
|
||||
)
|
||||
|
||||
if not HAS_TORCH:
|
||||
self.policy: Any = OrchestratorPolicyModel()
|
||||
self.ref_policy: Any = OrchestratorPolicyModel()
|
||||
return
|
||||
|
||||
device_str = str(self.device) if self.device else None
|
||||
|
||||
self.policy = OrchestratorPolicyModel.from_pretrained(
|
||||
self.config.model_name,
|
||||
gradient_checkpointing=self.config.gradient_checkpointing,
|
||||
device=device_str,
|
||||
)
|
||||
if self.policy.model is not None:
|
||||
self.policy.model.train()
|
||||
|
||||
self.ref_policy = OrchestratorPolicyModel.from_pretrained(
|
||||
self.config.model_name,
|
||||
load_in_8bit=self.config.use_8bit_ref,
|
||||
device=device_str,
|
||||
)
|
||||
if self.ref_policy.model is not None:
|
||||
self.ref_policy.model.eval()
|
||||
for param in self.ref_policy.model.parameters():
|
||||
param.requires_grad = False
|
||||
|
||||
def _init_optimizer(self) -> None:
|
||||
if not HAS_TORCH or self.policy.model is None:
|
||||
self.optimizer: Any = None
|
||||
return
|
||||
|
||||
if self.config.use_8bit_optimizer:
|
||||
try:
|
||||
import bitsandbytes as bnb
|
||||
|
||||
self.optimizer = bnb.optim.AdamW8bit(
|
||||
self.policy.model.parameters(),
|
||||
lr=self.config.learning_rate,
|
||||
)
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
self.optimizer = torch.optim.AdamW(
|
||||
self.policy.model.parameters(),
|
||||
lr=self.config.learning_rate,
|
||||
)
|
||||
|
||||
# -- Training loop -------------------------------------------------------
|
||||
|
||||
def train(self) -> None:
|
||||
"""Run the GRPO training loop."""
|
||||
if not HAS_TORCH:
|
||||
raise RuntimeError(
|
||||
"PyTorch is required for training. "
|
||||
"Install with: pip install torch transformers"
|
||||
)
|
||||
|
||||
for epoch in range(self.config.num_epochs):
|
||||
self._train_epoch(epoch)
|
||||
|
||||
if (epoch + 1) % self.config.save_every_n_epochs == 0:
|
||||
self._save_checkpoint(epoch)
|
||||
|
||||
def _train_epoch(self, epoch: int) -> Dict[str, float]:
|
||||
if self.policy.model is None:
|
||||
return {"epoch": epoch, "loss": 0.0, "reward": 0.0}
|
||||
|
||||
self.policy.model.train()
|
||||
total_loss = 0.0
|
||||
total_reward = 0.0
|
||||
num_batches = 0
|
||||
|
||||
# In a real implementation, iterate over a task dataset.
|
||||
# Here we provide the skeleton; actual data loading is
|
||||
# trainer-specific.
|
||||
self.global_step += 1
|
||||
num_batches = max(num_batches, 1)
|
||||
|
||||
avg_loss = total_loss / num_batches if num_batches > 0 else 0.0
|
||||
avg_reward = total_reward / num_batches if num_batches > 0 else 0.0
|
||||
return {"epoch": epoch, "loss": avg_loss, "reward": avg_reward}
|
||||
|
||||
def _grpo_step(
|
||||
self,
|
||||
prompts: List[str],
|
||||
ground_truths: List[str],
|
||||
) -> tuple:
|
||||
"""Perform one GRPO training step.
|
||||
|
||||
For each prompt:
|
||||
1. Generate N candidate trajectories.
|
||||
2. Compute reward for each.
|
||||
3. Normalise advantages within the group.
|
||||
4. Compute clipped policy gradient + KL penalty.
|
||||
5. Backward + clip + step.
|
||||
|
||||
Returns ``(loss_value, avg_reward)``.
|
||||
"""
|
||||
if self.policy.model is None or not HAS_TORCH:
|
||||
raise RuntimeError("Cannot train without PyTorch and model.")
|
||||
|
||||
self.policy.model.train()
|
||||
|
||||
all_prompts: list[str] = []
|
||||
all_responses: list[str] = []
|
||||
all_advantages: list[float] = []
|
||||
all_rewards: list[float] = []
|
||||
|
||||
from openjarvis.learning.orchestrator.reward import (
|
||||
MultiObjectiveReward,
|
||||
Normalizers,
|
||||
RewardWeights,
|
||||
)
|
||||
from openjarvis.learning.orchestrator.types import (
|
||||
Episode,
|
||||
)
|
||||
|
||||
reward_fn = MultiObjectiveReward(RewardWeights(), Normalizers())
|
||||
|
||||
for prompt, gt in zip(prompts, ground_truths):
|
||||
group_rewards: list[float] = []
|
||||
group_responses: list[str] = []
|
||||
|
||||
for _ in range(self.config.num_samples_per_prompt):
|
||||
response, _log_probs = self._generate_with_log_probs(prompt)
|
||||
|
||||
# Build a minimal episode for reward computation
|
||||
episode = Episode(
|
||||
task_id="grpo",
|
||||
initial_prompt=prompt,
|
||||
ground_truth=gt,
|
||||
final_answer=response,
|
||||
correct=(response.strip() == gt.strip()),
|
||||
)
|
||||
reward = reward_fn.compute(episode)
|
||||
|
||||
group_rewards.append(reward)
|
||||
group_responses.append(response)
|
||||
|
||||
# Group-relative advantages
|
||||
mean_r = sum(group_rewards) / len(group_rewards)
|
||||
std_r = (
|
||||
sum((r - mean_r) ** 2 for r in group_rewards)
|
||||
/ len(group_rewards)
|
||||
) ** 0.5
|
||||
if std_r > 1e-8:
|
||||
advantages = [(r - mean_r) / std_r for r in group_rewards]
|
||||
else:
|
||||
advantages = [0.0] * len(group_rewards)
|
||||
|
||||
for resp, adv, rew in zip(group_responses, advantages, group_rewards):
|
||||
all_prompts.append(prompt)
|
||||
all_responses.append(resp)
|
||||
all_advantages.append(adv)
|
||||
all_rewards.append(rew)
|
||||
|
||||
# Policy gradient loss
|
||||
total_loss = torch.tensor(0.0, device=self.device, requires_grad=True)
|
||||
|
||||
for prompt, response, advantage in zip(
|
||||
all_prompts, all_responses, all_advantages
|
||||
):
|
||||
current_lp = self._compute_log_probs(prompt, response)
|
||||
with torch.no_grad():
|
||||
ref_lp = self._compute_log_probs_ref(prompt, response)
|
||||
|
||||
log_ratio = current_lp - ref_lp
|
||||
ratio = torch.exp(log_ratio)
|
||||
ratio = torch.clamp(ratio, min=0.01, max=100.0)
|
||||
|
||||
clip = self.config.clip_ratio
|
||||
clipped = torch.clamp(ratio, 1 - clip, 1 + clip)
|
||||
|
||||
policy_loss = -torch.min(ratio * advantage, clipped * advantage)
|
||||
kl = (ratio - 1) - log_ratio
|
||||
total_loss = total_loss + policy_loss + self.config.kl_coef * kl
|
||||
|
||||
avg_loss = total_loss / max(len(all_prompts), 1)
|
||||
loss_val = avg_loss.item()
|
||||
|
||||
if torch.isnan(avg_loss) or torch.isinf(avg_loss):
|
||||
avg_reward = (
|
||||
sum(all_rewards) / len(all_rewards) if all_rewards else 0.0
|
||||
)
|
||||
return 0.0, float(avg_reward)
|
||||
|
||||
self.optimizer.zero_grad()
|
||||
avg_loss.backward()
|
||||
|
||||
# Check for NaN gradients
|
||||
for param in self.policy.model.parameters():
|
||||
if param.grad is not None and torch.isnan(param.grad).any():
|
||||
self.optimizer.zero_grad()
|
||||
avg_reward = (
|
||||
sum(all_rewards) / len(all_rewards)
|
||||
if all_rewards
|
||||
else 0.0
|
||||
)
|
||||
return float(loss_val), float(avg_reward)
|
||||
|
||||
torch.nn.utils.clip_grad_norm_(
|
||||
self.policy.model.parameters(), self.config.max_grad_norm
|
||||
)
|
||||
self.optimizer.step()
|
||||
|
||||
avg_reward = (
|
||||
sum(all_rewards) / len(all_rewards) if all_rewards else 0.0
|
||||
)
|
||||
return float(loss_val), float(avg_reward)
|
||||
|
||||
# -- Generation / log-prob helpers ---------------------------------------
|
||||
|
||||
def _generate_with_log_probs(
|
||||
self, prompt: str
|
||||
) -> "tuple[str, Any]":
|
||||
"""Generate a response and return ``(text, log_probs)``."""
|
||||
inputs = self.policy.tokenizer(
|
||||
prompt,
|
||||
return_tensors="pt",
|
||||
truncation=True,
|
||||
max_length=min(self.config.max_prompt_length, 16000),
|
||||
).to(self.device)
|
||||
|
||||
input_len = inputs.input_ids.shape[1]
|
||||
max_new = min(self.config.max_response_length, 32000 - input_len - 100)
|
||||
max_new = max(min(max_new, 2048), 128)
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = self.policy.model.generate(
|
||||
**inputs,
|
||||
max_new_tokens=max_new,
|
||||
temperature=self.config.temperature,
|
||||
do_sample=True,
|
||||
output_scores=True,
|
||||
return_dict_in_generate=True,
|
||||
)
|
||||
|
||||
generated_ids = outputs.sequences[0][input_len:]
|
||||
if len(generated_ids) == 0:
|
||||
return "", torch.tensor(0.0, device=self.device)
|
||||
|
||||
text = self.policy.tokenizer.decode(
|
||||
generated_ids, skip_special_tokens=True
|
||||
)
|
||||
|
||||
log_probs = []
|
||||
for token_id, logits in zip(generated_ids, outputs.scores):
|
||||
probs = F.softmax(logits[0], dim=-1)
|
||||
tid = token_id.item()
|
||||
if 0 <= tid < probs.shape[0]:
|
||||
log_probs.append(torch.log(probs[tid] + 1e-10))
|
||||
|
||||
total_lp = (
|
||||
torch.stack(log_probs).sum()
|
||||
if log_probs
|
||||
else torch.tensor(0.0, device=self.device)
|
||||
)
|
||||
return text, total_lp
|
||||
|
||||
def _compute_log_probs(
|
||||
self, prompt: str, response: str
|
||||
) -> "torch.Tensor":
|
||||
"""Log-probs of *response* given *prompt* under current policy."""
|
||||
full = prompt + response
|
||||
inputs = self.policy.tokenizer(
|
||||
full, return_tensors="pt", truncation=True
|
||||
).to(self.device)
|
||||
prompt_inputs = self.policy.tokenizer(
|
||||
prompt, return_tensors="pt", truncation=True
|
||||
).to(self.device)
|
||||
|
||||
with torch.enable_grad():
|
||||
logits = self.policy.model(**inputs).logits
|
||||
|
||||
start = len(prompt_inputs.input_ids[0])
|
||||
end = len(inputs.input_ids[0])
|
||||
|
||||
lps = []
|
||||
for i in range(start, end - 1):
|
||||
lp = F.log_softmax(logits[0, i, :], dim=-1)[
|
||||
inputs.input_ids[0, i + 1]
|
||||
]
|
||||
lps.append(lp)
|
||||
|
||||
return torch.stack(lps).sum() if lps else torch.tensor(0.0)
|
||||
|
||||
def _compute_log_probs_ref(
|
||||
self, prompt: str, response: str
|
||||
) -> "torch.Tensor":
|
||||
"""Log-probs under the frozen reference policy (no grad)."""
|
||||
full = prompt + response
|
||||
inputs = self.ref_policy.tokenizer(
|
||||
full, return_tensors="pt", truncation=True
|
||||
).to(self.device)
|
||||
prompt_inputs = self.ref_policy.tokenizer(
|
||||
prompt, return_tensors="pt", truncation=True
|
||||
).to(self.device)
|
||||
|
||||
with torch.no_grad():
|
||||
logits = self.ref_policy.model(**inputs).logits
|
||||
|
||||
start = len(prompt_inputs.input_ids[0])
|
||||
end = len(inputs.input_ids[0])
|
||||
|
||||
lps = []
|
||||
for i in range(start, end - 1):
|
||||
lp = F.log_softmax(logits[0, i, :], dim=-1)[
|
||||
inputs.input_ids[0, i + 1]
|
||||
]
|
||||
lps.append(lp)
|
||||
|
||||
return torch.stack(lps).sum() if lps else torch.tensor(0.0)
|
||||
|
||||
# -- Checkpointing -------------------------------------------------------
|
||||
|
||||
def _save_checkpoint(self, epoch: int) -> None:
|
||||
checkpoint_dir = Path(self.config.checkpoint_dir)
|
||||
checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
checkpoint_path = checkpoint_dir / f"epoch_{epoch + 1}"
|
||||
if self.policy.model is not None:
|
||||
self.policy.save(str(checkpoint_path))
|
||||
|
||||
state_path = checkpoint_path / "training_state.json"
|
||||
state = {
|
||||
"epoch": epoch,
|
||||
"global_step": self.global_step,
|
||||
"config": asdict(self.config),
|
||||
}
|
||||
with open(state_path, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
self._cleanup_old_checkpoints()
|
||||
|
||||
def _cleanup_old_checkpoints(self) -> None:
|
||||
checkpoint_dir = Path(self.config.checkpoint_dir)
|
||||
if not checkpoint_dir.exists():
|
||||
return
|
||||
|
||||
checkpoints = sorted(
|
||||
[
|
||||
d
|
||||
for d in checkpoint_dir.iterdir()
|
||||
if d.is_dir() and d.name.startswith("epoch_")
|
||||
],
|
||||
key=lambda x: int(x.name.split("_")[1]),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
for old in checkpoints[self.config.keep_last_n :]:
|
||||
shutil.rmtree(old)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry wrapper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ensure_registered() -> None:
|
||||
if LearningRegistry.contains("orchestrator_grpo"):
|
||||
return
|
||||
|
||||
@LearningRegistry.register("orchestrator_grpo")
|
||||
class OrchestratorGRPOPolicy(AgentLearningPolicy):
|
||||
"""Wrapper that registers the GRPO trainer as a learning policy."""
|
||||
|
||||
def update(
|
||||
self, trace_store: Any, **kwargs: object
|
||||
) -> Dict[str, Any]:
|
||||
config = OrchestratorGRPOConfig(**{
|
||||
k: v for k, v in kwargs.items()
|
||||
if k in OrchestratorGRPOConfig.__dataclass_fields__
|
||||
})
|
||||
trainer = OrchestratorGRPOTrainer(config)
|
||||
trainer.train()
|
||||
return {"status": "grpo_training_complete"}
|
||||
|
||||
|
||||
_ensure_registered()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OrchestratorGRPOConfig",
|
||||
"OrchestratorGRPOTrainer",
|
||||
]
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Policy model wrapper for orchestrator training.
|
||||
|
||||
Adapted from IPW's ``policy.py``. Wraps a HuggingFace causal LM
|
||||
(e.g. Qwen3-1.7B) to predict structured actions in the orchestrator
|
||||
environment. All ``torch``/``transformers`` imports are guarded so the
|
||||
module can be imported without GPU dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from openjarvis.learning.orchestrator.types import (
|
||||
EpisodeState,
|
||||
OrchestratorAction,
|
||||
PolicyOutput,
|
||||
)
|
||||
|
||||
# Optional imports -----------------------------------------------------------
|
||||
try:
|
||||
import torch # noqa: F401
|
||||
|
||||
HAS_TORCH = True
|
||||
except ImportError:
|
||||
HAS_TORCH = False
|
||||
torch = None # type: ignore[assignment]
|
||||
|
||||
|
||||
class OrchestratorPolicyModel:
|
||||
"""Wrapper around a causal LM for orchestrator policy prediction.
|
||||
|
||||
Input format (prompt)::
|
||||
|
||||
Task: {initial_prompt}
|
||||
|
||||
Available tools: calculator, think, ...
|
||||
|
||||
History:
|
||||
Turn 1:
|
||||
Thought: ...
|
||||
Tool: ...
|
||||
Observation: ...
|
||||
|
||||
What should you do next?
|
||||
Format your response as:
|
||||
THOUGHT: [your reasoning]
|
||||
TOOL: [tool_name]
|
||||
INPUT: [input for tool]
|
||||
|
||||
Output format (from model)::
|
||||
|
||||
THOUGHT: [reasoning]
|
||||
TOOL: [tool_name]
|
||||
INPUT: [input]
|
||||
--- or ---
|
||||
FINAL_ANSWER: [answer]
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: Any = None,
|
||||
tokenizer: Any = None,
|
||||
max_tokens: int = 256,
|
||||
temperature: float = 0.7,
|
||||
) -> None:
|
||||
self.model = model
|
||||
self.tokenizer = tokenizer
|
||||
self.max_tokens = max_tokens
|
||||
self.temperature = temperature
|
||||
|
||||
# -- Factory methods -----------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def from_pretrained(
|
||||
cls,
|
||||
model_name: str = "Qwen/Qwen3-1.7B",
|
||||
gradient_checkpointing: bool = False,
|
||||
load_in_8bit: bool = False,
|
||||
device: Optional[str] = None,
|
||||
**kwargs: Any,
|
||||
) -> "OrchestratorPolicyModel":
|
||||
"""Load model from a HuggingFace checkpoint.
|
||||
|
||||
Raises ``ImportError`` if ``transformers`` is not installed.
|
||||
"""
|
||||
import torch as _torch
|
||||
from transformers import AutoModelForCausalLM, AutoTokenizer
|
||||
|
||||
tokenizer = AutoTokenizer.from_pretrained(model_name)
|
||||
|
||||
model_kwargs: dict[str, Any] = {"torch_dtype": _torch.bfloat16}
|
||||
|
||||
if load_in_8bit:
|
||||
try:
|
||||
from transformers import BitsAndBytesConfig
|
||||
|
||||
model_kwargs["quantization_config"] = BitsAndBytesConfig(
|
||||
load_in_8bit=True
|
||||
)
|
||||
except ImportError:
|
||||
pass # fall back to bf16
|
||||
|
||||
if device is not None:
|
||||
if device == "auto":
|
||||
model_kwargs["device_map"] = "auto"
|
||||
else:
|
||||
model_kwargs["device_map"] = {"": device}
|
||||
|
||||
model = AutoModelForCausalLM.from_pretrained(model_name, **model_kwargs)
|
||||
|
||||
if gradient_checkpointing and hasattr(
|
||||
model, "gradient_checkpointing_enable"
|
||||
):
|
||||
model.gradient_checkpointing_enable(
|
||||
gradient_checkpointing_kwargs={"use_reentrant": False}
|
||||
)
|
||||
|
||||
return cls(model=model, tokenizer=tokenizer, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def from_checkpoint(
|
||||
cls, checkpoint_path: str, **kwargs: Any
|
||||
) -> "OrchestratorPolicyModel":
|
||||
"""Load from a previously saved checkpoint directory."""
|
||||
return cls.from_pretrained(checkpoint_path, **kwargs)
|
||||
|
||||
# -- Prediction ----------------------------------------------------------
|
||||
|
||||
def predict_action(
|
||||
self,
|
||||
state: EpisodeState,
|
||||
available_tools: List[str],
|
||||
) -> OrchestratorAction:
|
||||
"""Predict the next action given current state."""
|
||||
prompt = self._build_prompt(state, available_tools)
|
||||
|
||||
if self.model is None:
|
||||
raise RuntimeError(
|
||||
"Cannot generate actions without a loaded model. "
|
||||
"Load with OrchestratorPolicyModel.from_pretrained() first."
|
||||
)
|
||||
|
||||
output_text = self._generate(prompt)
|
||||
policy_output = self._parse_output(output_text, available_tools)
|
||||
return OrchestratorAction(
|
||||
thought=policy_output.thought,
|
||||
tool_name=policy_output.tool_name,
|
||||
tool_input=policy_output.tool_input,
|
||||
is_final_answer=policy_output.is_final_answer,
|
||||
)
|
||||
|
||||
# -- Internal helpers ----------------------------------------------------
|
||||
|
||||
def _build_prompt(
|
||||
self,
|
||||
state: EpisodeState,
|
||||
available_tools: List[str],
|
||||
) -> str:
|
||||
"""Build the text prompt from current state."""
|
||||
parts: list[str] = []
|
||||
|
||||
parts.append(f"Task: {state.initial_prompt}")
|
||||
parts.append("")
|
||||
|
||||
tools_str = ", ".join(available_tools)
|
||||
parts.append(f"Available tools: {tools_str}")
|
||||
parts.append("")
|
||||
|
||||
if state.history:
|
||||
parts.append("History:")
|
||||
for i, (action, observation) in enumerate(state.history, 1):
|
||||
parts.append(f"Turn {i}:")
|
||||
parts.append(f" Thought: {action.thought}")
|
||||
parts.append(f" Tool: {action.tool_name}")
|
||||
parts.append(
|
||||
f" Observation: {observation.content[:100]}..."
|
||||
)
|
||||
parts.append("")
|
||||
|
||||
parts.append("What should you do next?")
|
||||
parts.append("Format your response as:")
|
||||
parts.append("THOUGHT: [your reasoning]")
|
||||
parts.append("TOOL: [tool_name]")
|
||||
parts.append("INPUT: [input for tool]")
|
||||
parts.append("")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def _parse_output(
|
||||
self,
|
||||
output_text: str,
|
||||
available_tools: List[str],
|
||||
) -> PolicyOutput:
|
||||
"""Parse structured model output into a :class:`PolicyOutput`."""
|
||||
# Check for FINAL_ANSWER first
|
||||
final_match = re.search(
|
||||
r"FINAL[_ ]?ANSWER:\s*(.+?)(?:\n|$)",
|
||||
output_text,
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if final_match:
|
||||
thought_match = re.search(
|
||||
r"THOUGHT:\s*(.+?)(?:\n|$)", output_text, re.IGNORECASE
|
||||
)
|
||||
return PolicyOutput(
|
||||
thought=(
|
||||
thought_match.group(1).strip()
|
||||
if thought_match
|
||||
else ""
|
||||
),
|
||||
tool_name="",
|
||||
tool_input=final_match.group(1).strip(),
|
||||
is_final_answer=True,
|
||||
raw_text=output_text,
|
||||
)
|
||||
|
||||
thought_match = re.search(
|
||||
r"THOUGHT:\s*(.+?)(?:\n|$)", output_text, re.IGNORECASE
|
||||
)
|
||||
tool_match = re.search(
|
||||
r"TOOL:\s*(.+?)(?:\n|$)", output_text, re.IGNORECASE
|
||||
)
|
||||
input_match = re.search(
|
||||
r"INPUT:\s*(.+?)(?:\nTHOUGHT:|\nTOOL:|\nFINAL|\Z)",
|
||||
output_text,
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
thought = (
|
||||
thought_match.group(1).strip()
|
||||
if thought_match
|
||||
else "No thought provided"
|
||||
)
|
||||
tool_name = (
|
||||
tool_match.group(1).strip()
|
||||
if tool_match
|
||||
else (available_tools[0] if available_tools else "unknown")
|
||||
)
|
||||
tool_input = input_match.group(1).strip() if input_match else ""
|
||||
|
||||
# Validate / fuzzy-match tool name
|
||||
if tool_name not in available_tools:
|
||||
tool_name_lower = tool_name.lower()
|
||||
matched = False
|
||||
for t in available_tools:
|
||||
if t.lower() == tool_name_lower:
|
||||
tool_name = t
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
tool_name = available_tools[0] if available_tools else "unknown"
|
||||
|
||||
return PolicyOutput(
|
||||
thought=thought,
|
||||
tool_name=tool_name,
|
||||
tool_input=tool_input,
|
||||
is_final_answer=False,
|
||||
raw_text=output_text,
|
||||
)
|
||||
|
||||
def _generate(self, prompt: str) -> str:
|
||||
"""Generate text from the loaded model."""
|
||||
inputs = self.tokenizer(prompt, return_tensors="pt").to(
|
||||
self.model.device
|
||||
)
|
||||
outputs = self.model.generate(
|
||||
**inputs,
|
||||
max_new_tokens=self.max_tokens,
|
||||
temperature=self.temperature,
|
||||
do_sample=True,
|
||||
)
|
||||
return self.tokenizer.decode(
|
||||
outputs[0][len(inputs.input_ids[0]) :],
|
||||
skip_special_tokens=True,
|
||||
)
|
||||
|
||||
def save(self, path: str) -> None:
|
||||
"""Save model and tokenizer to *path*."""
|
||||
if self.model is not None:
|
||||
self.model.save_pretrained(path)
|
||||
self.tokenizer.save_pretrained(path)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
model_name = (
|
||||
"None" if self.model is None else type(self.model).__name__
|
||||
)
|
||||
return (
|
||||
f"OrchestratorPolicyModel(model={model_name}, "
|
||||
f"max_tokens={self.max_tokens})"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["OrchestratorPolicyModel"]
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Prompt registry for orchestrator structured mode.
|
||||
|
||||
Adapted from IPW's ``prompt_registry.py``. Provides the canonical system
|
||||
prompt template and tool descriptions used by the structured-mode
|
||||
``OrchestratorAgent`` and by the SFT/GRPO training pipelines.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
PROMPT_VERSION = "1.0"
|
||||
|
||||
SYSTEM_PROMPT_TEMPLATE = """\
|
||||
You are an intelligent orchestrator that solves tasks by \
|
||||
delegating to the most appropriate tools.
|
||||
|
||||
Your job is to SELECT THE BEST TOOL for each task based on the tool's strengths.
|
||||
|
||||
=== AVAILABLE TOOLS ===
|
||||
{tools_description}
|
||||
|
||||
=== TOOL SELECTION GUIDE ===
|
||||
{tool_selection_guide}
|
||||
|
||||
=== RESPONSE FORMAT ===
|
||||
You MUST respond in this EXACT format:
|
||||
|
||||
THOUGHT: <analyze the task and explain which tool is best and why>
|
||||
TOOL: <exact tool name from the list>
|
||||
INPUT: <input for the tool>
|
||||
|
||||
After getting tool results, either use another tool or give final answer:
|
||||
THOUGHT: <analyze the result>
|
||||
FINAL_ANSWER: <your final answer>
|
||||
|
||||
=== CRITICAL RULES ===
|
||||
1. You MUST use at least one tool for EVERY task - never answer directly
|
||||
2. Match the tool to the task type (see guide above)
|
||||
3. For LLM tools, write clear prompts that will get good responses
|
||||
4. Prefer specialized tools when available \
|
||||
(calculator for math, code_interpreter for code)
|
||||
5. For simple factual questions, use fast/cheap tools when available
|
||||
|
||||
NOW SOLVE THE TASK. You MUST use at least one tool - choose the best one for the task.
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool descriptions for OpenJarvis built-in tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TOOL_DESCRIPTIONS: Dict[str, dict] = {
|
||||
# Utility tools (instant, free, deterministic)
|
||||
"calculator": {
|
||||
"category": "utility",
|
||||
"description": (
|
||||
"CALCULATOR - Instant math computation\n"
|
||||
" - BEST FOR: Arithmetic, algebra, trigonometry, scientific calculations\n"
|
||||
" - STRENGTHS: Instant (<1ms), perfect accuracy, zero cost\n"
|
||||
" - USE WHEN: Any math expression needs evaluation\n"
|
||||
" - COST: Free\n"
|
||||
" - Input: math expression (e.g., '15 * 7 + 23', 'sqrt(144)')"
|
||||
),
|
||||
"examples": [
|
||||
{
|
||||
"task": "What is 847 * 293?",
|
||||
"thought": "Simple arithmetic - calculator is instant and accurate.",
|
||||
"input": "847 * 293",
|
||||
},
|
||||
],
|
||||
},
|
||||
"think": {
|
||||
"category": "utility",
|
||||
"description": (
|
||||
"THINK - Internal reasoning scratchpad\n"
|
||||
" - BEST FOR: Logic puzzles, step-by-step reasoning, planning\n"
|
||||
" - STRENGTHS: Organizes thoughts, shows work, no external calls\n"
|
||||
" - USE WHEN: Need to break down a problem before solving\n"
|
||||
" - COST: Free\n"
|
||||
" - Input: your detailed reasoning process"
|
||||
),
|
||||
"examples": [
|
||||
{
|
||||
"task": "If all cats are mammals, are all cats animals?",
|
||||
"thought": "Logical syllogism - use think to reason step by step.",
|
||||
"input": "Cats subset of mammals subset of animals => yes.",
|
||||
},
|
||||
],
|
||||
},
|
||||
"code_interpreter": {
|
||||
"category": "utility",
|
||||
"description": (
|
||||
"CODE_INTERPRETER - Python execution sandbox\n"
|
||||
" - BEST FOR: Data processing, algorithms, simulations\n"
|
||||
" - STRENGTHS: Full Python + numpy/pandas, handles loops\n"
|
||||
" - USE WHEN: Problem needs programming logic\n"
|
||||
" - COST: Free (local execution)\n"
|
||||
" - Input: Python code to execute"
|
||||
),
|
||||
"examples": [
|
||||
{
|
||||
"task": "Find all prime numbers less than 50",
|
||||
"thought": (
|
||||
"Need a prime-checking algorithm"
|
||||
" - code_interpreter is ideal."
|
||||
),
|
||||
"input": (
|
||||
"def is_prime(n):\n"
|
||||
" if n < 2: return False\n"
|
||||
" for i in range(2, int(n**0.5)+1):\n"
|
||||
" if n % i == 0: return False\n"
|
||||
" return True\n"
|
||||
"print([n for n in range(50) if is_prime(n)])"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
"web_search": {
|
||||
"category": "utility",
|
||||
"description": (
|
||||
"WEB_SEARCH - Real-time internet search\n"
|
||||
" - BEST FOR: Current events, fact-checking, recent info\n"
|
||||
" - STRENGTHS: Access to up-to-date information\n"
|
||||
" - USE WHEN: Question about recent events or needs verification\n"
|
||||
" - COST: ~$0.001 per search\n"
|
||||
" - Input: search query string"
|
||||
),
|
||||
"examples": [
|
||||
{
|
||||
"task": "Who won the 2024 Nobel Prize in Physics?",
|
||||
"thought": "Recent events - need web_search for current info.",
|
||||
"input": "2024 Nobel Prize Physics winner",
|
||||
},
|
||||
],
|
||||
},
|
||||
"file_read": {
|
||||
"category": "utility",
|
||||
"description": (
|
||||
"FILE_READ - Safe file reading\n"
|
||||
" - BEST FOR: Reading file contents with path validation\n"
|
||||
" - STRENGTHS: Sandboxed, prevents directory traversal\n"
|
||||
" - USE WHEN: Need to read local file contents\n"
|
||||
" - COST: Free\n"
|
||||
" - Input: file path"
|
||||
),
|
||||
"examples": [],
|
||||
},
|
||||
# Memory tools
|
||||
"memory_search": {
|
||||
"category": "memory",
|
||||
"description": (
|
||||
"MEMORY_SEARCH - Search indexed documents\n"
|
||||
" - BEST FOR: Finding relevant stored knowledge\n"
|
||||
" - STRENGTHS: Semantic search over indexed content\n"
|
||||
" - USE WHEN: Answer may exist in indexed documents\n"
|
||||
" - COST: Free (local)\n"
|
||||
" - Input: search query"
|
||||
),
|
||||
"examples": [],
|
||||
},
|
||||
"memory_store": {
|
||||
"category": "memory",
|
||||
"description": (
|
||||
"MEMORY_STORE - Store content in memory\n"
|
||||
" - BEST FOR: Saving information for later retrieval\n"
|
||||
" - STRENGTHS: Persistent storage with metadata\n"
|
||||
" - USE WHEN: Need to remember something for future queries\n"
|
||||
" - COST: Free (local)\n"
|
||||
" - Input: content to store"
|
||||
),
|
||||
"examples": [],
|
||||
},
|
||||
"memory_retrieve": {
|
||||
"category": "memory",
|
||||
"description": (
|
||||
"MEMORY_RETRIEVE - Retrieve stored content by key\n"
|
||||
" - BEST FOR: Fetching previously stored information\n"
|
||||
" - STRENGTHS: Fast key-based retrieval\n"
|
||||
" - USE WHEN: Know the exact key of stored content\n"
|
||||
" - COST: Free (local)\n"
|
||||
" - Input: content key"
|
||||
),
|
||||
"examples": [],
|
||||
},
|
||||
# LLM tool
|
||||
"llm": {
|
||||
"category": "llm",
|
||||
"description": (
|
||||
"LLM - Sub-model calls via engine\n"
|
||||
" - BEST FOR: Natural language understanding, generation, analysis\n"
|
||||
" - STRENGTHS: General-purpose language capabilities\n"
|
||||
" - USE WHEN: Task needs natural language reasoning\n"
|
||||
" - COST: Varies by engine/model\n"
|
||||
" - Input: prompt for the model"
|
||||
),
|
||||
"examples": [
|
||||
{
|
||||
"task": "Explain photosynthesis simply",
|
||||
"thought": "General explanation - use LLM for natural language.",
|
||||
"input": "Explain photosynthesis in simple terms.",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_system_prompt(tool_names: Optional[List[str]] = None) -> str:
|
||||
"""Build the complete system prompt for the given tools.
|
||||
|
||||
Args:
|
||||
tool_names: Tool names to include. If ``None``, uses all
|
||||
tools from :data:`TOOL_DESCRIPTIONS`.
|
||||
|
||||
Returns:
|
||||
Complete system prompt string.
|
||||
"""
|
||||
if tool_names is None:
|
||||
tool_names = list(TOOL_DESCRIPTIONS)
|
||||
|
||||
# Tool descriptions
|
||||
desc_lines: list[str] = []
|
||||
for name in tool_names:
|
||||
if name in TOOL_DESCRIPTIONS:
|
||||
desc = TOOL_DESCRIPTIONS[name]["description"]
|
||||
else:
|
||||
desc = f"Tool: {name}"
|
||||
desc_lines.append(f"- {name}: {desc}")
|
||||
|
||||
# Group tools by category
|
||||
by_cat: Dict[str, List[str]] = {}
|
||||
for name in tool_names:
|
||||
cat = (
|
||||
TOOL_DESCRIPTIONS[name]["category"]
|
||||
if name in TOOL_DESCRIPTIONS
|
||||
else "llm"
|
||||
)
|
||||
by_cat.setdefault(cat, []).append(name)
|
||||
|
||||
guide: list[str] = [
|
||||
"Choose tools based on task type:\n",
|
||||
]
|
||||
|
||||
# Math
|
||||
math_lines: list[str] = []
|
||||
if "calculator" in tool_names:
|
||||
math_lines.append(
|
||||
"- Simple arithmetic/algebra -> calculator (instant, accurate)"
|
||||
)
|
||||
if "code_interpreter" in tool_names:
|
||||
math_lines.append(
|
||||
"- Numerical algorithms -> code_interpreter (programmable)"
|
||||
)
|
||||
if math_lines:
|
||||
guide.append("MATH PROBLEMS:")
|
||||
guide.extend(math_lines)
|
||||
guide.append("")
|
||||
|
||||
# Coding
|
||||
code_lines: list[str] = []
|
||||
if "code_interpreter" in tool_names:
|
||||
code_lines.append(
|
||||
"- Algorithm implementation/execution -> code_interpreter"
|
||||
)
|
||||
if code_lines:
|
||||
guide.append("CODING TASKS:")
|
||||
guide.extend(code_lines)
|
||||
guide.append("")
|
||||
|
||||
# Reasoning
|
||||
reasoning_lines: list[str] = []
|
||||
if "think" in tool_names:
|
||||
reasoning_lines.append(
|
||||
"- Step-by-step analysis -> think (organize thoughts first)"
|
||||
)
|
||||
llm_tools = by_cat.get("llm", [])
|
||||
if llm_tools:
|
||||
reasoning_lines.append(
|
||||
f"- Complex reasoning -> {', '.join(llm_tools)}"
|
||||
)
|
||||
if reasoning_lines:
|
||||
guide.append("REASONING/LOGIC:")
|
||||
guide.extend(reasoning_lines)
|
||||
guide.append("")
|
||||
|
||||
# General Q&A
|
||||
general_lines: list[str] = []
|
||||
if "web_search" in tool_names:
|
||||
general_lines.append("- Current events/recent info -> web_search")
|
||||
memory_tools = by_cat.get("memory", [])
|
||||
if memory_tools:
|
||||
general_lines.append(
|
||||
f"- Stored knowledge -> {', '.join(memory_tools)}"
|
||||
)
|
||||
if general_lines:
|
||||
guide.append("GENERAL Q&A / FACTUAL:")
|
||||
guide.extend(general_lines)
|
||||
guide.append("")
|
||||
|
||||
return SYSTEM_PROMPT_TEMPLATE.format(
|
||||
tools_description="\n".join(desc_lines),
|
||||
tool_selection_guide="\n".join(guide),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TOOL_DESCRIPTIONS",
|
||||
"build_system_prompt",
|
||||
]
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Multi-objective reward function for orchestrator training.
|
||||
|
||||
Adapted from IPW's ``reward.py``. Balances accuracy, cost, energy,
|
||||
latency, and power into a single scalar reward used by both the SFT
|
||||
grading pipeline and the GRPO policy gradient.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List
|
||||
|
||||
from openjarvis.learning.orchestrator.types import Episode
|
||||
|
||||
|
||||
@dataclass
|
||||
class RewardWeights:
|
||||
"""Weights for multi-objective reward function.
|
||||
|
||||
Each metric has its own coefficient:
|
||||
- alpha: Accuracy (correctness of answer)
|
||||
- beta_cost: API/cloud cost in USD
|
||||
- beta_energy: Energy consumption in joules
|
||||
- gamma_latency: Response time in seconds
|
||||
- gamma_power: Peak power usage in watts
|
||||
"""
|
||||
|
||||
alpha: float = 0.4
|
||||
beta_cost: float = 0.15
|
||||
beta_energy: float = 0.15
|
||||
gamma_latency: float = 0.15
|
||||
gamma_power: float = 0.15
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate weights sum to 1.0."""
|
||||
total = (
|
||||
self.alpha
|
||||
+ self.beta_cost
|
||||
+ self.beta_energy
|
||||
+ self.gamma_latency
|
||||
+ self.gamma_power
|
||||
)
|
||||
if abs(total - 1.0) > 0.01:
|
||||
raise ValueError(f"Weights should sum to 1.0, got {total}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Normalizers:
|
||||
"""Normalization constants for reward scaling.
|
||||
|
||||
These are typical values used to scale metrics to similar ranges.
|
||||
Tune based on your specific tools and tasks.
|
||||
"""
|
||||
|
||||
energy_scale: float = 100.0
|
||||
cost_scale: float = 0.10
|
||||
latency_scale: float = 30.0
|
||||
power_scale: float = 200.0
|
||||
|
||||
|
||||
class MultiObjectiveReward:
|
||||
"""Multi-objective reward combining accuracy, cost, energy, latency, power.
|
||||
|
||||
Formula::
|
||||
|
||||
reward = alpha * accuracy
|
||||
- beta_cost * (cost / cost_scale)
|
||||
- beta_energy * (energy / energy_scale)
|
||||
- gamma_latency * (latency / latency_scale)
|
||||
- gamma_power * (power / power_scale)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
weights: RewardWeights,
|
||||
normalizers: Normalizers,
|
||||
) -> None:
|
||||
self.weights = weights
|
||||
self.normalizers = normalizers
|
||||
|
||||
def compute(self, episode: Episode) -> float:
|
||||
"""Compute scalar reward for an episode."""
|
||||
accuracy_reward = 1.0 if episode.correct else 0.0
|
||||
|
||||
cost_penalty = episode.total_cost_usd / self.normalizers.cost_scale
|
||||
energy_penalty = (
|
||||
episode.total_energy_joules / self.normalizers.energy_scale
|
||||
)
|
||||
latency_penalty = (
|
||||
episode.total_latency_seconds / self.normalizers.latency_scale
|
||||
)
|
||||
power_penalty = episode.max_power_watts / self.normalizers.power_scale
|
||||
|
||||
return (
|
||||
self.weights.alpha * accuracy_reward
|
||||
- self.weights.beta_cost * cost_penalty
|
||||
- self.weights.beta_energy * energy_penalty
|
||||
- self.weights.gamma_latency * latency_penalty
|
||||
- self.weights.gamma_power * power_penalty
|
||||
)
|
||||
|
||||
def compute_with_breakdown(self, episode: Episode) -> Dict[str, float]:
|
||||
"""Compute reward with detailed per-component breakdown."""
|
||||
accuracy_reward = 1.0 if episode.correct else 0.0
|
||||
|
||||
cost_penalty = episode.total_cost_usd / self.normalizers.cost_scale
|
||||
energy_penalty = (
|
||||
episode.total_energy_joules / self.normalizers.energy_scale
|
||||
)
|
||||
latency_penalty = (
|
||||
episode.total_latency_seconds / self.normalizers.latency_scale
|
||||
)
|
||||
power_penalty = episode.max_power_watts / self.normalizers.power_scale
|
||||
|
||||
accuracy_component = self.weights.alpha * accuracy_reward
|
||||
cost_component = -self.weights.beta_cost * cost_penalty
|
||||
energy_component = -self.weights.beta_energy * energy_penalty
|
||||
latency_component = -self.weights.gamma_latency * latency_penalty
|
||||
power_component = -self.weights.gamma_power * power_penalty
|
||||
|
||||
total_reward = (
|
||||
accuracy_component
|
||||
+ cost_component
|
||||
+ energy_component
|
||||
+ latency_component
|
||||
+ power_component
|
||||
)
|
||||
|
||||
ipj = episode.compute_ipj()
|
||||
|
||||
return {
|
||||
"total_reward": total_reward,
|
||||
"accuracy_reward": accuracy_reward,
|
||||
"accuracy_component": accuracy_component,
|
||||
"cost_penalty": cost_penalty,
|
||||
"cost_component": cost_component,
|
||||
"energy_penalty": energy_penalty,
|
||||
"energy_component": energy_component,
|
||||
"latency_penalty": latency_penalty,
|
||||
"latency_component": latency_component,
|
||||
"power_penalty": power_penalty,
|
||||
"power_component": power_component,
|
||||
"ipj": ipj,
|
||||
"total_energy_joules": episode.total_energy_joules,
|
||||
"total_cost_usd": episode.total_cost_usd,
|
||||
"total_latency_seconds": episode.total_latency_seconds,
|
||||
}
|
||||
|
||||
def compute_batch(self, episodes: List[Episode]) -> List[float]:
|
||||
"""Compute rewards for a batch of episodes."""
|
||||
return [self.compute(ep) for ep in episodes]
|
||||
|
||||
|
||||
class AdaptiveRewardWeights:
|
||||
"""Adaptive reward weights that shift during training.
|
||||
|
||||
Early training focuses on accuracy (higher alpha).
|
||||
Late training optimises efficiency (higher cost/energy/power weights).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
initial_alpha: float = 0.6,
|
||||
final_alpha: float = 0.3,
|
||||
initial_beta_cost: float = 0.1,
|
||||
final_beta_cost: float = 0.15,
|
||||
initial_beta_energy: float = 0.1,
|
||||
final_beta_energy: float = 0.2,
|
||||
initial_gamma_latency: float = 0.1,
|
||||
final_gamma_latency: float = 0.15,
|
||||
initial_gamma_power: float = 0.1,
|
||||
final_gamma_power: float = 0.2,
|
||||
total_steps: int = 10000,
|
||||
) -> None:
|
||||
self.initial_alpha = initial_alpha
|
||||
self.final_alpha = final_alpha
|
||||
self.initial_beta_cost = initial_beta_cost
|
||||
self.final_beta_cost = final_beta_cost
|
||||
self.initial_beta_energy = initial_beta_energy
|
||||
self.final_beta_energy = final_beta_energy
|
||||
self.initial_gamma_latency = initial_gamma_latency
|
||||
self.final_gamma_latency = final_gamma_latency
|
||||
self.initial_gamma_power = initial_gamma_power
|
||||
self.final_gamma_power = final_gamma_power
|
||||
self.total_steps = total_steps
|
||||
|
||||
def get_weights(self, current_step: int) -> RewardWeights:
|
||||
"""Get weights for *current_step* via linear interpolation."""
|
||||
progress = min(1.0, current_step / self.total_steps)
|
||||
|
||||
alpha = self.initial_alpha + (self.final_alpha - self.initial_alpha) * progress
|
||||
beta_cost = (
|
||||
self.initial_beta_cost
|
||||
+ (self.final_beta_cost - self.initial_beta_cost) * progress
|
||||
)
|
||||
beta_energy = (
|
||||
self.initial_beta_energy
|
||||
+ (self.final_beta_energy - self.initial_beta_energy) * progress
|
||||
)
|
||||
gamma_latency = (
|
||||
self.initial_gamma_latency
|
||||
+ (self.final_gamma_latency - self.initial_gamma_latency) * progress
|
||||
)
|
||||
gamma_power = (
|
||||
self.initial_gamma_power
|
||||
+ (self.final_gamma_power - self.initial_gamma_power) * progress
|
||||
)
|
||||
|
||||
# Normalize to sum to 1.0
|
||||
total = alpha + beta_cost + beta_energy + gamma_latency + gamma_power
|
||||
return RewardWeights(
|
||||
alpha=alpha / total,
|
||||
beta_cost=beta_cost / total,
|
||||
beta_energy=beta_energy / total,
|
||||
gamma_latency=gamma_latency / total,
|
||||
gamma_power=gamma_power / total,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AdaptiveRewardWeights",
|
||||
"MultiObjectiveReward",
|
||||
"Normalizers",
|
||||
"RewardWeights",
|
||||
]
|
||||
@@ -0,0 +1,402 @@
|
||||
"""SFT (Supervised Fine-Tuning) trainer for orchestrator.
|
||||
|
||||
Adapted from IPW's ``sft_trainer.py``. Trains the orchestrator policy
|
||||
using supervised learning on trajectories. All ``torch``/``transformers``
|
||||
imports are guarded so the module can be imported without GPU dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterator, List
|
||||
|
||||
# Optional imports -----------------------------------------------------------
|
||||
try:
|
||||
import torch
|
||||
import torch.nn.functional as F # noqa: F401
|
||||
from torch.utils.data import DataLoader
|
||||
|
||||
HAS_TORCH = True
|
||||
except ImportError:
|
||||
HAS_TORCH = False
|
||||
torch = None # type: ignore[assignment]
|
||||
DataLoader = None # type: ignore[assignment,misc]
|
||||
|
||||
from openjarvis.core.registry import LearningRegistry
|
||||
from openjarvis.learning._stubs import AgentLearningPolicy
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrchestratorSFTConfig:
|
||||
"""Configuration for orchestrator SFT training."""
|
||||
|
||||
# Model
|
||||
model_name: str = "Qwen/Qwen3-1.7B"
|
||||
max_seq_length: int = 4096
|
||||
|
||||
# Training
|
||||
num_epochs: int = 3
|
||||
batch_size: int = 8
|
||||
learning_rate: float = 2e-5
|
||||
weight_decay: float = 0.01
|
||||
warmup_ratio: float = 0.1
|
||||
max_grad_norm: float = 1.0
|
||||
|
||||
# Trace generation
|
||||
teacher_engine_key: str = ""
|
||||
teacher_model: str = ""
|
||||
traces_per_query: int = 2
|
||||
max_attempts_per_trace: int = 3
|
||||
generation_temperature: float = 0.7
|
||||
|
||||
# Data source
|
||||
trace_cache_path: str = "data/orchestrator_sft_traces.jsonl"
|
||||
regenerate_traces: bool = False
|
||||
|
||||
# Checkpoint
|
||||
checkpoint_dir: str = "checkpoints/orchestrator_sft"
|
||||
save_every_n_epochs: int = 1
|
||||
|
||||
# Logging
|
||||
log_dir: str = "logs/orchestrator_sft"
|
||||
log_every_n_steps: int = 10
|
||||
use_wandb: bool = False
|
||||
|
||||
# Memory
|
||||
gradient_checkpointing: bool = True
|
||||
|
||||
# Available tools for structured prompt
|
||||
available_tools: List[str] = field(
|
||||
default_factory=lambda: [
|
||||
"calculator",
|
||||
"think",
|
||||
"code_interpreter",
|
||||
"web_search",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dataset
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OrchestratorSFTDataset:
|
||||
"""Dataset for SFT training from generated trace JSONL files."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
trace_path: str,
|
||||
tokenizer: Any,
|
||||
max_seq_length: int = 4096,
|
||||
) -> None:
|
||||
self.tokenizer = tokenizer
|
||||
self.max_seq_length = max_seq_length
|
||||
self.traces: List[Dict[str, Any]] = []
|
||||
self._load_traces(trace_path)
|
||||
|
||||
def _load_traces(self, trace_path: str) -> None:
|
||||
path = Path(trace_path)
|
||||
if not path.exists():
|
||||
return
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
self.traces.append(json.loads(line))
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.traces)
|
||||
|
||||
def __getitem__(self, idx: int) -> Dict[str, Any]:
|
||||
trace = self.traces[idx]
|
||||
text = self._format_conversation(trace.get("conversations", []))
|
||||
|
||||
encoding = self.tokenizer(
|
||||
text,
|
||||
truncation=True,
|
||||
max_length=self.max_seq_length,
|
||||
padding="max_length",
|
||||
return_tensors="pt",
|
||||
)
|
||||
|
||||
return {
|
||||
"input_ids": encoding["input_ids"].squeeze(0),
|
||||
"attention_mask": encoding["attention_mask"].squeeze(0),
|
||||
"labels": encoding["input_ids"].squeeze(0).clone(),
|
||||
}
|
||||
|
||||
def _format_conversation(
|
||||
self, conversations: List[Dict[str, str]]
|
||||
) -> str:
|
||||
"""Format conversation turns into training text."""
|
||||
if hasattr(self.tokenizer, "apply_chat_template"):
|
||||
try:
|
||||
messages = []
|
||||
for turn in conversations:
|
||||
role = turn.get("role") or turn.get("from", "")
|
||||
content = turn.get("content") or turn.get("value", "")
|
||||
|
||||
if role in ("human", "user"):
|
||||
role = "user"
|
||||
elif role in ("gpt", "assistant"):
|
||||
role = "assistant"
|
||||
elif role == "tool":
|
||||
tool_name = turn.get("name", "tool")
|
||||
content = (
|
||||
f"[Tool '{tool_name}' returned]: {content}"
|
||||
)
|
||||
role = "user"
|
||||
|
||||
if role in ("user", "assistant", "system"):
|
||||
messages.append({"role": role, "content": content})
|
||||
|
||||
return self.tokenizer.apply_chat_template(
|
||||
messages, tokenize=False, add_generation_prompt=False
|
||||
)
|
||||
except Exception:
|
||||
pass # fall through
|
||||
|
||||
# Manual fallback
|
||||
parts: list[str] = []
|
||||
for turn in conversations:
|
||||
role = turn.get("role") or turn.get("from", "")
|
||||
content = turn.get("content") or turn.get("value", "")
|
||||
|
||||
if role in ("human", "user"):
|
||||
parts.append(f"<|user|>\n{content}")
|
||||
elif role in ("gpt", "assistant"):
|
||||
parts.append(f"<|assistant|>\n{content}")
|
||||
elif role == "system":
|
||||
parts.append(f"<|system|>\n{content}")
|
||||
elif role == "tool":
|
||||
tool_name = turn.get("name", "tool")
|
||||
parts.append(
|
||||
f"<|user|>\n[Tool '{tool_name}' returned]: {content}"
|
||||
)
|
||||
|
||||
eos = getattr(self.tokenizer, "eos_token", "") or ""
|
||||
return "\n".join(parts) + eos
|
||||
|
||||
def iter_batches(
|
||||
self, batch_size: int
|
||||
) -> Iterator[List[Dict[str, Any]]]:
|
||||
batch: list[Dict[str, Any]] = []
|
||||
for i in range(len(self)):
|
||||
batch.append(self[i])
|
||||
if len(batch) == batch_size:
|
||||
yield batch
|
||||
batch = []
|
||||
if batch:
|
||||
yield batch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trainer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OrchestratorSFTTrainer:
|
||||
"""SFT trainer for orchestrator policy.
|
||||
|
||||
Performs standard next-token cross-entropy loss on successful
|
||||
trajectories. ``torch`` must be installed to call :meth:`train`.
|
||||
"""
|
||||
|
||||
def __init__(self, config: OrchestratorSFTConfig) -> None:
|
||||
self.config = config
|
||||
self.device = None
|
||||
self.global_step = 0
|
||||
|
||||
if HAS_TORCH and torch is not None:
|
||||
self.device = torch.device(
|
||||
"cuda" if torch.cuda.is_available() else "cpu"
|
||||
)
|
||||
|
||||
self._init_model()
|
||||
self._init_data()
|
||||
self._init_optimizer()
|
||||
|
||||
def _init_model(self) -> None:
|
||||
from openjarvis.learning.orchestrator.policy_model import (
|
||||
OrchestratorPolicyModel,
|
||||
)
|
||||
|
||||
if not HAS_TORCH:
|
||||
self.policy: Any = OrchestratorPolicyModel()
|
||||
return
|
||||
|
||||
device_str = str(self.device) if self.device else None
|
||||
self.policy = OrchestratorPolicyModel.from_pretrained(
|
||||
self.config.model_name,
|
||||
gradient_checkpointing=self.config.gradient_checkpointing,
|
||||
device=device_str,
|
||||
)
|
||||
if self.policy.model is not None:
|
||||
self.policy.model.train()
|
||||
|
||||
def _init_data(self) -> None:
|
||||
trace_path = Path(self.config.trace_cache_path)
|
||||
|
||||
if self.config.regenerate_traces or not trace_path.exists():
|
||||
self._generate_traces()
|
||||
|
||||
self.dataset = OrchestratorSFTDataset(
|
||||
trace_path=str(trace_path),
|
||||
tokenizer=self.policy.tokenizer,
|
||||
max_seq_length=self.config.max_seq_length,
|
||||
)
|
||||
|
||||
def _generate_traces(self) -> None:
|
||||
"""Generate SFT traces (placeholder — requires running engine)."""
|
||||
trace_path = Path(self.config.trace_cache_path)
|
||||
trace_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not trace_path.exists():
|
||||
trace_path.touch()
|
||||
|
||||
def _init_optimizer(self) -> None:
|
||||
if not HAS_TORCH or self.policy.model is None:
|
||||
self.optimizer: Any = None
|
||||
self.scheduler: Any = None
|
||||
self.dataloader: Any = None
|
||||
return
|
||||
|
||||
self.optimizer = torch.optim.AdamW(
|
||||
self.policy.model.parameters(),
|
||||
lr=self.config.learning_rate,
|
||||
weight_decay=self.config.weight_decay,
|
||||
)
|
||||
|
||||
self.dataloader = DataLoader(
|
||||
self.dataset,
|
||||
batch_size=self.config.batch_size,
|
||||
shuffle=True,
|
||||
num_workers=0,
|
||||
)
|
||||
|
||||
total_steps = len(self.dataloader) * self.config.num_epochs
|
||||
warmup_steps = int(total_steps * self.config.warmup_ratio)
|
||||
|
||||
def lr_lambda(step: int) -> float:
|
||||
if step < warmup_steps:
|
||||
return step / max(1, warmup_steps)
|
||||
return max(
|
||||
0.0,
|
||||
1.0 - (step - warmup_steps) / (total_steps - warmup_steps),
|
||||
)
|
||||
|
||||
self.scheduler = torch.optim.lr_scheduler.LambdaLR(
|
||||
self.optimizer, lr_lambda
|
||||
)
|
||||
|
||||
def train(self) -> None:
|
||||
"""Run the SFT training loop."""
|
||||
if not HAS_TORCH:
|
||||
raise RuntimeError(
|
||||
"PyTorch is required for training. "
|
||||
"Install with: pip install torch transformers"
|
||||
)
|
||||
|
||||
for epoch in range(self.config.num_epochs):
|
||||
self._train_epoch(epoch)
|
||||
|
||||
if (epoch + 1) % self.config.save_every_n_epochs == 0:
|
||||
self._save_checkpoint(epoch)
|
||||
|
||||
def _train_epoch(self, epoch: int) -> Dict[str, float]:
|
||||
if self.policy.model is None or self.dataloader is None:
|
||||
return {"epoch": epoch, "loss": 0.0}
|
||||
|
||||
self.policy.model.train()
|
||||
total_loss = 0.0
|
||||
num_batches = 0
|
||||
|
||||
for batch in self.dataloader:
|
||||
loss = self._train_step(batch)
|
||||
total_loss += loss
|
||||
num_batches += 1
|
||||
self.global_step += 1
|
||||
|
||||
avg_loss = total_loss / num_batches if num_batches > 0 else 0.0
|
||||
return {"epoch": epoch, "loss": avg_loss}
|
||||
|
||||
def _train_step(self, batch: Dict[str, Any]) -> float:
|
||||
input_ids = batch["input_ids"].to(self.device)
|
||||
attention_mask = batch["attention_mask"].to(self.device)
|
||||
labels = batch["labels"].to(self.device)
|
||||
|
||||
outputs = self.policy.model(
|
||||
input_ids=input_ids,
|
||||
attention_mask=attention_mask,
|
||||
labels=labels,
|
||||
)
|
||||
loss = outputs.loss
|
||||
|
||||
self.optimizer.zero_grad()
|
||||
loss.backward()
|
||||
torch.nn.utils.clip_grad_norm_(
|
||||
self.policy.model.parameters(),
|
||||
self.config.max_grad_norm,
|
||||
)
|
||||
self.optimizer.step()
|
||||
self.scheduler.step()
|
||||
|
||||
return loss.item()
|
||||
|
||||
def _save_checkpoint(self, epoch: int) -> None:
|
||||
checkpoint_dir = Path(self.config.checkpoint_dir)
|
||||
checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
checkpoint_path = checkpoint_dir / f"epoch_{epoch + 1}"
|
||||
if self.policy.model is not None:
|
||||
self.policy.save(str(checkpoint_path))
|
||||
|
||||
state_path = checkpoint_path / "training_state.json"
|
||||
state = {
|
||||
"epoch": epoch,
|
||||
"global_step": self.global_step,
|
||||
"config": asdict(self.config),
|
||||
}
|
||||
with open(state_path, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry wrapper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ensure_registered() -> None:
|
||||
if LearningRegistry.contains("orchestrator_sft"):
|
||||
return
|
||||
|
||||
@LearningRegistry.register("orchestrator_sft")
|
||||
class OrchestratorSFTPolicy(AgentLearningPolicy):
|
||||
"""Wrapper that registers the SFT trainer as a learning policy."""
|
||||
|
||||
def update(
|
||||
self, trace_store: Any, **kwargs: object
|
||||
) -> Dict[str, Any]:
|
||||
config = OrchestratorSFTConfig(**{
|
||||
k: v for k, v in kwargs.items()
|
||||
if k in OrchestratorSFTConfig.__dataclass_fields__
|
||||
})
|
||||
trainer = OrchestratorSFTTrainer(config)
|
||||
trainer.train()
|
||||
return {"status": "sft_training_complete"}
|
||||
|
||||
|
||||
_ensure_registered()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OrchestratorSFTConfig",
|
||||
"OrchestratorSFTDataset",
|
||||
"OrchestratorSFTTrainer",
|
||||
]
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Episode dataclasses for orchestrator training.
|
||||
|
||||
Adapted from IPW's ``episode_builder.py``. These types represent the core
|
||||
data structures for orchestrator RL/SFT training: actions, observations,
|
||||
episode steps, and complete episodes with aggregate metrics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Answer grading utilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def normalize_number(s: str) -> Optional[float]:
|
||||
"""Try to parse a string as a number.
|
||||
|
||||
Returns None if not a valid number.
|
||||
"""
|
||||
s = s.strip().lower()
|
||||
s = re.sub(r"[,\s]", "", s) # Remove commas and spaces
|
||||
s = re.sub(r"\.0+$", "", s) # Remove trailing .0
|
||||
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def extract_answer(text: str) -> str:
|
||||
"""Extract the core answer from a potentially verbose response.
|
||||
|
||||
Handles patterns like:
|
||||
- "The answer is 4"
|
||||
- "Result: 4.0"
|
||||
- "4" (unchanged)
|
||||
- "Therefore, the answer is approximately 4"
|
||||
"""
|
||||
text = text.strip()
|
||||
|
||||
patterns = [
|
||||
r"(?:the\s+)?answer\s+is[:\s]+(.+?)(?:\.|$)",
|
||||
r"result[:\s]+(.+?)(?:\.|$)",
|
||||
r"=\s*(.+?)(?:\.|$)",
|
||||
r"therefore[,\s]+(?:the\s+)?(?:answer\s+is\s+)?(.+?)(?:\.|$)",
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, text, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def grade_answer(
|
||||
predicted: str, expected: str, tolerance: float = 1e-6
|
||||
) -> bool:
|
||||
"""Grade an answer against expected, with smart matching.
|
||||
|
||||
Handles:
|
||||
- Exact string match (case-insensitive)
|
||||
- Numeric comparison with tolerance
|
||||
- Answer extraction from verbose responses
|
||||
|
||||
Args:
|
||||
predicted: The model's answer.
|
||||
expected: Ground truth answer.
|
||||
tolerance: Tolerance for numeric comparisons.
|
||||
|
||||
Returns:
|
||||
True if answer is correct.
|
||||
"""
|
||||
predicted = predicted.strip()
|
||||
expected = expected.strip()
|
||||
|
||||
# Exact match (case-insensitive)
|
||||
if predicted.lower() == expected.lower():
|
||||
return True
|
||||
|
||||
# Try extracting core answer
|
||||
pred_extracted = extract_answer(predicted)
|
||||
exp_extracted = extract_answer(expected)
|
||||
|
||||
if pred_extracted.lower() == exp_extracted.lower():
|
||||
return True
|
||||
|
||||
# Try numeric comparison
|
||||
pred_num = normalize_number(pred_extracted)
|
||||
exp_num = normalize_number(exp_extracted)
|
||||
|
||||
if pred_num is not None and exp_num is not None:
|
||||
if exp_num == 0:
|
||||
return abs(pred_num) < tolerance
|
||||
return abs(pred_num - exp_num) / abs(exp_num) < tolerance
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core dataclasses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrchestratorAction:
|
||||
"""Orchestrator action: thought + tool selection + tool input."""
|
||||
|
||||
thought: str
|
||||
"""Reasoning about what to do next."""
|
||||
|
||||
tool_name: str
|
||||
"""Selected tool name (e.g., 'calculator', 'think')."""
|
||||
|
||||
tool_input: str
|
||||
"""Input/prompt to send to the tool."""
|
||||
|
||||
is_final_answer: bool = False
|
||||
"""Whether this action provides the final answer."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrchestratorObservation:
|
||||
"""Result from executing an action, with flat telemetry fields."""
|
||||
|
||||
content: str
|
||||
"""Tool response content."""
|
||||
|
||||
latency_seconds: float = 0.0
|
||||
"""Latency in seconds."""
|
||||
|
||||
cost_usd: float = 0.0
|
||||
"""Cost in USD."""
|
||||
|
||||
energy_joules: float = 0.0
|
||||
"""Energy consumed in joules."""
|
||||
|
||||
power_watts: float = 0.0
|
||||
"""Power usage in watts."""
|
||||
|
||||
tokens: int = 0
|
||||
"""Tokens consumed."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class EpisodeStep:
|
||||
"""Single step in an episode."""
|
||||
|
||||
turn: int
|
||||
"""Step number (0-indexed)."""
|
||||
|
||||
action: OrchestratorAction
|
||||
"""Action taken."""
|
||||
|
||||
observation: OrchestratorObservation
|
||||
"""Result of action."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Episode:
|
||||
"""Complete RL episode with aggregate metrics."""
|
||||
|
||||
task_id: str
|
||||
"""Unique task identifier."""
|
||||
|
||||
initial_prompt: str
|
||||
"""Initial question/task."""
|
||||
|
||||
steps: List[EpisodeStep] = field(default_factory=list)
|
||||
"""Sequence of (action, observation) pairs."""
|
||||
|
||||
final_answer: str = ""
|
||||
"""Final answer produced by orchestrator."""
|
||||
|
||||
ground_truth: str = ""
|
||||
"""Ground truth answer."""
|
||||
|
||||
correct: bool = False
|
||||
"""Whether final answer matches ground truth."""
|
||||
|
||||
# Aggregate metrics
|
||||
total_energy_joules: float = 0.0
|
||||
total_cost_usd: float = 0.0
|
||||
total_latency_seconds: float = 0.0
|
||||
total_tokens: int = 0
|
||||
max_power_watts: float = 0.0
|
||||
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def add_step(
|
||||
self, action: OrchestratorAction, observation: OrchestratorObservation
|
||||
) -> None:
|
||||
"""Add a step to the episode and update aggregate metrics."""
|
||||
step = EpisodeStep(
|
||||
turn=len(self.steps),
|
||||
action=action,
|
||||
observation=observation,
|
||||
)
|
||||
self.steps.append(step)
|
||||
|
||||
self.total_energy_joules += observation.energy_joules
|
||||
self.total_latency_seconds += observation.latency_seconds
|
||||
self.total_cost_usd += observation.cost_usd
|
||||
self.total_tokens += observation.tokens
|
||||
self.max_power_watts = max(self.max_power_watts, observation.power_watts)
|
||||
|
||||
if action.is_final_answer:
|
||||
self.final_answer = observation.content
|
||||
|
||||
def num_turns(self) -> int:
|
||||
"""Return number of turns in episode."""
|
||||
return len(self.steps)
|
||||
|
||||
def compute_ipj(self) -> float:
|
||||
"""Compute Intelligence Per Joule (IPJ).
|
||||
|
||||
Returns:
|
||||
IPJ score (higher is better). 0.0 if energy is zero or
|
||||
the answer is incorrect.
|
||||
"""
|
||||
if self.total_energy_joules <= 0:
|
||||
return 0.0
|
||||
accuracy_score = 1.0 if self.correct else 0.0
|
||||
return accuracy_score / self.total_energy_joules
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert episode to dictionary for serialization."""
|
||||
return {
|
||||
"task_id": self.task_id,
|
||||
"initial_prompt": self.initial_prompt,
|
||||
"steps": [
|
||||
{
|
||||
"turn": step.turn,
|
||||
"thought": step.action.thought,
|
||||
"tool": step.action.tool_name,
|
||||
"tool_input": step.action.tool_input,
|
||||
"observation": step.observation.content[:200],
|
||||
"energy_joules": step.observation.energy_joules,
|
||||
"latency_seconds": step.observation.latency_seconds,
|
||||
"cost_usd": step.observation.cost_usd,
|
||||
}
|
||||
for step in self.steps
|
||||
],
|
||||
"final_answer": self.final_answer,
|
||||
"ground_truth": self.ground_truth,
|
||||
"correct": self.correct,
|
||||
"total_energy_joules": self.total_energy_joules,
|
||||
"total_latency_seconds": self.total_latency_seconds,
|
||||
"total_cost_usd": self.total_cost_usd,
|
||||
"total_tokens": self.total_tokens,
|
||||
"num_turns": self.num_turns(),
|
||||
"ipj": self.compute_ipj(),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class EpisodeState:
|
||||
"""Mutable state during episode execution."""
|
||||
|
||||
initial_prompt: str
|
||||
"""Initial task/question."""
|
||||
|
||||
history: List[Tuple[OrchestratorAction, OrchestratorObservation]] = field(
|
||||
default_factory=list
|
||||
)
|
||||
"""History of (action, observation) pairs."""
|
||||
|
||||
final_answer: Optional[str] = None
|
||||
"""Final answer (set when is_final_answer action is taken)."""
|
||||
|
||||
def add_turn(
|
||||
self,
|
||||
action: OrchestratorAction,
|
||||
observation: OrchestratorObservation,
|
||||
) -> None:
|
||||
"""Add a turn to the episode history."""
|
||||
self.history.append((action, observation))
|
||||
if action.is_final_answer:
|
||||
self.final_answer = observation.content
|
||||
|
||||
def num_turns(self) -> int:
|
||||
"""Return number of turns so far."""
|
||||
return len(self.history)
|
||||
|
||||
def to_episode(
|
||||
self, task_id: str, ground_truth: str, correct: bool
|
||||
) -> Episode:
|
||||
"""Convert state to Episode for reward computation."""
|
||||
episode = Episode(
|
||||
task_id=task_id,
|
||||
initial_prompt=self.initial_prompt,
|
||||
ground_truth=ground_truth,
|
||||
final_answer=self.final_answer or "",
|
||||
correct=correct,
|
||||
)
|
||||
for action, observation in self.history:
|
||||
episode.add_step(action, observation)
|
||||
return episode
|
||||
|
||||
|
||||
@dataclass
|
||||
class PolicyOutput:
|
||||
"""Output from policy model prediction."""
|
||||
|
||||
thought: str
|
||||
"""Reasoning about what to do."""
|
||||
|
||||
tool_name: str
|
||||
"""Selected tool."""
|
||||
|
||||
tool_input: str
|
||||
"""Input for the tool."""
|
||||
|
||||
is_final_answer: bool = False
|
||||
"""Whether this provides the final answer."""
|
||||
|
||||
raw_text: str = ""
|
||||
"""Raw model output."""
|
||||
|
||||
confidence: float = 1.0
|
||||
"""Confidence score (if available)."""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Episode",
|
||||
"EpisodeState",
|
||||
"EpisodeStep",
|
||||
"OrchestratorAction",
|
||||
"OrchestratorObservation",
|
||||
"PolicyOutput",
|
||||
"extract_answer",
|
||||
"grade_answer",
|
||||
"normalize_number",
|
||||
]
|
||||
@@ -1,4 +1,9 @@
|
||||
"""SFT on traces — learns which model handles which query type best."""
|
||||
"""SFT router — learns which model handles which query type best.
|
||||
|
||||
Analyses historical traces and builds a ``query_class → model`` routing
|
||||
table. Despite the "SFT" name, no model weights are fine-tuned — this
|
||||
is a *trace-driven routing* policy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,11 +14,13 @@ from openjarvis.learning._stubs import IntelligenceLearningPolicy
|
||||
|
||||
|
||||
@LearningRegistry.register("sft")
|
||||
class SFTPolicy(IntelligenceLearningPolicy):
|
||||
"""SFT on traces: learns which model handles which query type best.
|
||||
class SFTRouterPolicy(IntelligenceLearningPolicy):
|
||||
"""Trace-driven router that learns query_class → model mappings.
|
||||
|
||||
Reads successful traces, groups by query class, and produces an
|
||||
updated query_class -> model mapping.
|
||||
Reads historical traces, groups by query class (code, math, short,
|
||||
long, general), scores each model via a composite metric (60%
|
||||
outcome + 40% feedback), and produces a routing table that maps
|
||||
query classes to their best-performing model.
|
||||
"""
|
||||
|
||||
def __init__(self, *, min_samples: int = 5) -> None:
|
||||
@@ -84,4 +91,7 @@ class SFTPolicy(IntelligenceLearningPolicy):
|
||||
return dict(self._policy_map)
|
||||
|
||||
|
||||
__all__ = ["SFTPolicy"]
|
||||
__all__ = ["SFTRouterPolicy"]
|
||||
|
||||
# Backward-compat alias
|
||||
SFTPolicy = SFTRouterPolicy
|
||||
|
||||
@@ -27,6 +27,10 @@ _TOOL_ANNOTATIONS: Dict[str, Dict[str, Any]] = {
|
||||
"file_read": {"readOnlyHint": True, "destructiveHint": False},
|
||||
"web_search": {"readOnlyHint": True, "destructiveHint": False},
|
||||
"code_interpreter": {"destructiveHint": True, "readOnlyHint": False},
|
||||
"repl": {"destructiveHint": True, "readOnlyHint": False},
|
||||
"channel_send": {"destructiveHint": True, "readOnlyHint": False},
|
||||
"channel_list": {"readOnlyHint": True, "destructiveHint": False},
|
||||
"channel_status": {"readOnlyHint": True, "destructiveHint": False},
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +90,11 @@ class MCPServer:
|
||||
_tool_classes.append(CodeInterpreterTool)
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from openjarvis.tools.repl import ReplTool
|
||||
_tool_classes.append(ReplTool)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Storage MCP tools
|
||||
try:
|
||||
@@ -102,6 +111,19 @@ class MCPServer:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Channel MCP tools
|
||||
try:
|
||||
from openjarvis.tools.channel_tools import (
|
||||
ChannelListTool,
|
||||
ChannelSendTool,
|
||||
ChannelStatusTool,
|
||||
)
|
||||
_tool_classes.extend([
|
||||
ChannelSendTool, ChannelListTool, ChannelStatusTool,
|
||||
])
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# LM tool (needs engine/model — instantiate with None)
|
||||
try:
|
||||
from openjarvis.tools.llm_tool import LLMTool
|
||||
|
||||
@@ -26,7 +26,6 @@ class _NoCacheStaticFiles(StaticFiles):
|
||||
async def __call__(self, scope, receive, send):
|
||||
async def _send_with_headers(message):
|
||||
if message["type"] == "http.response.start":
|
||||
headers = dict(message.get("headers", []))
|
||||
extra = [
|
||||
(k.encode(), v.encode()) for k, v in _NO_CACHE_HEADERS.items()
|
||||
]
|
||||
|
||||
+141
-2
@@ -26,6 +26,7 @@ class JarvisSystem:
|
||||
tools: List[BaseTool] = field(default_factory=list)
|
||||
tool_executor: Optional[ToolExecutor] = None
|
||||
memory_backend: Optional[Any] = None # MemoryBackend
|
||||
channel_backend: Optional[Any] = None # BaseChannel
|
||||
router: Optional[Any] = None # RouterPolicy
|
||||
mcp_server: Optional[Any] = None # MCPServer
|
||||
telemetry_store: Optional[Any] = None
|
||||
@@ -119,6 +120,9 @@ class JarvisSystem:
|
||||
if agent_name == "orchestrator":
|
||||
agent_kwargs["tools"] = agent_tools
|
||||
agent_kwargs["max_turns"] = self.config.agent.max_turns
|
||||
elif agent_name == "rlm":
|
||||
agent_kwargs["tools"] = agent_tools
|
||||
agent_kwargs["max_turns"] = self.config.agent.max_turns
|
||||
|
||||
try:
|
||||
ag = agent_cls(self.engine, self.model, **agent_kwargs)
|
||||
@@ -263,8 +267,13 @@ class SystemBuilder:
|
||||
# Resolve memory backend
|
||||
memory_backend = self._resolve_memory(config)
|
||||
|
||||
# Resolve channel backend
|
||||
channel_backend = self._resolve_channel(config, bus)
|
||||
|
||||
# Resolve tools
|
||||
tool_list = self._resolve_tools(config, engine, model, memory_backend)
|
||||
tool_list = self._resolve_tools(
|
||||
config, engine, model, memory_backend, channel_backend,
|
||||
)
|
||||
|
||||
# Build tool executor
|
||||
tool_executor = ToolExecutor(tool_list, bus) if tool_list else None
|
||||
@@ -282,6 +291,7 @@ class SystemBuilder:
|
||||
tools=tool_list,
|
||||
tool_executor=tool_executor,
|
||||
memory_backend=memory_backend,
|
||||
channel_backend=channel_backend,
|
||||
telemetry_store=telemetry_store,
|
||||
)
|
||||
|
||||
@@ -369,7 +379,125 @@ class SystemBuilder:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _resolve_tools(self, config, engine, model, memory_backend):
|
||||
def _resolve_channel(self, config, bus):
|
||||
"""Resolve channel backend from config."""
|
||||
if not config.channel.enabled:
|
||||
return None
|
||||
try:
|
||||
import openjarvis.channels # noqa: F401 -- trigger registration
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
key = config.channel.default_channel
|
||||
if not key:
|
||||
return None
|
||||
if not ChannelRegistry.contains(key):
|
||||
return None
|
||||
|
||||
kwargs: Dict[str, Any] = {"bus": bus}
|
||||
if key == "telegram":
|
||||
tc = config.channel.telegram
|
||||
if tc.bot_token:
|
||||
kwargs["bot_token"] = tc.bot_token
|
||||
if tc.parse_mode:
|
||||
kwargs["parse_mode"] = tc.parse_mode
|
||||
elif key == "discord":
|
||||
dc = config.channel.discord
|
||||
if dc.bot_token:
|
||||
kwargs["bot_token"] = dc.bot_token
|
||||
elif key == "slack":
|
||||
sc = config.channel.slack
|
||||
if sc.bot_token:
|
||||
kwargs["bot_token"] = sc.bot_token
|
||||
if sc.app_token:
|
||||
kwargs["app_token"] = sc.app_token
|
||||
elif key == "webhook":
|
||||
wc = config.channel.webhook
|
||||
if wc.url:
|
||||
kwargs["url"] = wc.url
|
||||
if wc.secret:
|
||||
kwargs["secret"] = wc.secret
|
||||
if wc.method:
|
||||
kwargs["method"] = wc.method
|
||||
elif key == "email":
|
||||
ec = config.channel.email
|
||||
if ec.smtp_host:
|
||||
kwargs["smtp_host"] = ec.smtp_host
|
||||
kwargs["smtp_port"] = ec.smtp_port
|
||||
if ec.imap_host:
|
||||
kwargs["imap_host"] = ec.imap_host
|
||||
kwargs["imap_port"] = ec.imap_port
|
||||
if ec.username:
|
||||
kwargs["username"] = ec.username
|
||||
if ec.password:
|
||||
kwargs["password"] = ec.password
|
||||
kwargs["use_tls"] = ec.use_tls
|
||||
elif key == "whatsapp":
|
||||
wac = config.channel.whatsapp
|
||||
if wac.access_token:
|
||||
kwargs["access_token"] = wac.access_token
|
||||
if wac.phone_number_id:
|
||||
kwargs["phone_number_id"] = wac.phone_number_id
|
||||
elif key == "signal":
|
||||
sgc = config.channel.signal
|
||||
if sgc.api_url:
|
||||
kwargs["api_url"] = sgc.api_url
|
||||
if sgc.phone_number:
|
||||
kwargs["phone_number"] = sgc.phone_number
|
||||
elif key == "google_chat":
|
||||
gcc = config.channel.google_chat
|
||||
if gcc.webhook_url:
|
||||
kwargs["webhook_url"] = gcc.webhook_url
|
||||
elif key == "irc":
|
||||
ic = config.channel.irc
|
||||
if ic.server:
|
||||
kwargs["server"] = ic.server
|
||||
kwargs["port"] = ic.port
|
||||
if ic.nick:
|
||||
kwargs["nick"] = ic.nick
|
||||
if ic.password:
|
||||
kwargs["password"] = ic.password
|
||||
kwargs["use_tls"] = ic.use_tls
|
||||
elif key == "webchat":
|
||||
pass # no config needed
|
||||
elif key == "teams":
|
||||
tmc = config.channel.teams
|
||||
if tmc.app_id:
|
||||
kwargs["app_id"] = tmc.app_id
|
||||
if tmc.app_password:
|
||||
kwargs["app_password"] = tmc.app_password
|
||||
if tmc.service_url:
|
||||
kwargs["service_url"] = tmc.service_url
|
||||
elif key == "matrix":
|
||||
mc = config.channel.matrix
|
||||
if mc.homeserver:
|
||||
kwargs["homeserver"] = mc.homeserver
|
||||
if mc.access_token:
|
||||
kwargs["access_token"] = mc.access_token
|
||||
elif key == "mattermost":
|
||||
mmc = config.channel.mattermost
|
||||
if mmc.url:
|
||||
kwargs["url"] = mmc.url
|
||||
if mmc.token:
|
||||
kwargs["token"] = mmc.token
|
||||
elif key == "feishu":
|
||||
fc = config.channel.feishu
|
||||
if fc.app_id:
|
||||
kwargs["app_id"] = fc.app_id
|
||||
if fc.app_secret:
|
||||
kwargs["app_secret"] = fc.app_secret
|
||||
elif key == "bluebubbles":
|
||||
bbc = config.channel.bluebubbles
|
||||
if bbc.url:
|
||||
kwargs["url"] = bbc.url
|
||||
if bbc.password:
|
||||
kwargs["password"] = bbc.password
|
||||
|
||||
return ChannelRegistry.create(key, **kwargs)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _resolve_tools(self, config, engine, model, memory_backend,
|
||||
channel_backend=None):
|
||||
"""Resolve tool instances."""
|
||||
tool_names_str = self._tool_names
|
||||
if tool_names_str is None:
|
||||
@@ -403,6 +531,17 @@ class SystemBuilder:
|
||||
if ToolRegistry.contains(name):
|
||||
tool = ToolRegistry.create(name, backend=memory_backend)
|
||||
tools.append(tool)
|
||||
elif name in (
|
||||
"channel_send",
|
||||
"channel_list",
|
||||
"channel_status",
|
||||
):
|
||||
import openjarvis.tools.channel_tools # noqa: F401
|
||||
from openjarvis.core.registry import ToolRegistry
|
||||
|
||||
if ToolRegistry.contains(name):
|
||||
tool = ToolRegistry.create(name, channel=channel_backend)
|
||||
tools.append(tool)
|
||||
else:
|
||||
from openjarvis.core.registry import ToolRegistry
|
||||
|
||||
|
||||
@@ -42,6 +42,11 @@ try:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.tools.repl # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.tools.storage_tools # noqa: F401
|
||||
except ImportError:
|
||||
@@ -52,4 +57,9 @@ try:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.tools.channel_tools # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
__all__ = ["BaseTool", "ToolExecutor", "ToolSpec"]
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Channel MCP tools — expose channel operations as BaseTool instances.
|
||||
|
||||
These tools wrap the ``BaseChannel`` ABC so that channel operations
|
||||
(send, list, status) are discoverable and callable via MCP.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from openjarvis.channels._stubs import BaseChannel
|
||||
from openjarvis.core.registry import ToolRegistry
|
||||
from openjarvis.core.types import ToolResult
|
||||
from openjarvis.tools._stubs import BaseTool, ToolSpec
|
||||
|
||||
|
||||
@ToolRegistry.register("channel_send")
|
||||
class ChannelSendTool(BaseTool):
|
||||
"""MCP-exposed tool: send a message via a channel backend."""
|
||||
|
||||
tool_id = "channel_send"
|
||||
|
||||
def __init__(self, channel: BaseChannel | None = None) -> None:
|
||||
self._channel = channel
|
||||
|
||||
@property
|
||||
def spec(self) -> ToolSpec:
|
||||
return ToolSpec(
|
||||
name="channel_send",
|
||||
description="Send a message to a channel (Telegram, Discord, Slack, etc.).",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"description": "Target chat/channel ID to send to.",
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The message content to send.",
|
||||
},
|
||||
"conversation_id": {
|
||||
"type": "string",
|
||||
"description": "Optional conversation/thread ID for replies.",
|
||||
},
|
||||
},
|
||||
"required": ["channel", "content"],
|
||||
},
|
||||
category="channel",
|
||||
)
|
||||
|
||||
def execute(self, **params: Any) -> ToolResult:
|
||||
if self._channel is None:
|
||||
return ToolResult(
|
||||
tool_name="channel_send",
|
||||
content="No channel backend configured.",
|
||||
success=False,
|
||||
)
|
||||
target = params.get("channel", "")
|
||||
content = params.get("content", "")
|
||||
if not target or not content:
|
||||
return ToolResult(
|
||||
tool_name="channel_send",
|
||||
content="Both 'channel' and 'content' are required.",
|
||||
success=False,
|
||||
)
|
||||
try:
|
||||
ok = self._channel.send(
|
||||
target,
|
||||
content,
|
||||
conversation_id=params.get("conversation_id", ""),
|
||||
)
|
||||
if ok:
|
||||
return ToolResult(
|
||||
tool_name="channel_send",
|
||||
content=f"Message sent to {target}",
|
||||
success=True,
|
||||
)
|
||||
return ToolResult(
|
||||
tool_name="channel_send",
|
||||
content=f"Failed to send message to {target}",
|
||||
success=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
return ToolResult(
|
||||
tool_name="channel_send",
|
||||
content=f"Send error: {exc}",
|
||||
success=False,
|
||||
)
|
||||
|
||||
|
||||
@ToolRegistry.register("channel_list")
|
||||
class ChannelListTool(BaseTool):
|
||||
"""MCP-exposed tool: list available channels."""
|
||||
|
||||
tool_id = "channel_list"
|
||||
|
||||
def __init__(self, channel: BaseChannel | None = None) -> None:
|
||||
self._channel = channel
|
||||
|
||||
@property
|
||||
def spec(self) -> ToolSpec:
|
||||
return ToolSpec(
|
||||
name="channel_list",
|
||||
description="List available messaging channels.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
},
|
||||
category="channel",
|
||||
)
|
||||
|
||||
def execute(self, **params: Any) -> ToolResult:
|
||||
if self._channel is None:
|
||||
return ToolResult(
|
||||
tool_name="channel_list",
|
||||
content="No channel backend configured.",
|
||||
success=False,
|
||||
)
|
||||
try:
|
||||
channels = self._channel.list_channels()
|
||||
if not channels:
|
||||
return ToolResult(
|
||||
tool_name="channel_list",
|
||||
content="No channels available.",
|
||||
success=True,
|
||||
)
|
||||
return ToolResult(
|
||||
tool_name="channel_list",
|
||||
content="\n".join(channels),
|
||||
success=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
return ToolResult(
|
||||
tool_name="channel_list",
|
||||
content=f"List error: {exc}",
|
||||
success=False,
|
||||
)
|
||||
|
||||
|
||||
@ToolRegistry.register("channel_status")
|
||||
class ChannelStatusTool(BaseTool):
|
||||
"""MCP-exposed tool: check channel connection status."""
|
||||
|
||||
tool_id = "channel_status"
|
||||
|
||||
def __init__(self, channel: BaseChannel | None = None) -> None:
|
||||
self._channel = channel
|
||||
|
||||
@property
|
||||
def spec(self) -> ToolSpec:
|
||||
return ToolSpec(
|
||||
name="channel_status",
|
||||
description="Check the connection status of the messaging channel.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
},
|
||||
category="channel",
|
||||
)
|
||||
|
||||
def execute(self, **params: Any) -> ToolResult:
|
||||
if self._channel is None:
|
||||
return ToolResult(
|
||||
tool_name="channel_status",
|
||||
content="No channel backend configured.",
|
||||
success=False,
|
||||
)
|
||||
try:
|
||||
st = self._channel.status()
|
||||
return ToolResult(
|
||||
tool_name="channel_status",
|
||||
content=f"Channel status: {st.value}",
|
||||
success=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
return ToolResult(
|
||||
tool_name="channel_status",
|
||||
content=f"Status error: {exc}",
|
||||
success=False,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ChannelListTool",
|
||||
"ChannelSendTool",
|
||||
"ChannelStatusTool",
|
||||
]
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Persistent Python REPL tool — maintains state across calls within a session.
|
||||
|
||||
Unlike ``CodeInterpreterTool`` (which runs each snippet in a fresh subprocess),
|
||||
this tool keeps variables, functions, and imports alive across invocations
|
||||
within the same session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from openjarvis.core.registry import ToolRegistry
|
||||
from openjarvis.core.types import ToolResult
|
||||
from openjarvis.tools._stubs import BaseTool, ToolSpec
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Security
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Layer 1: Pattern blocklist
|
||||
_BLOCKED_PATTERNS = [
|
||||
"os.system",
|
||||
"os.popen",
|
||||
"subprocess",
|
||||
"shutil.rmtree",
|
||||
"__import__",
|
||||
"open(",
|
||||
"ctypes",
|
||||
"socket",
|
||||
"http.client",
|
||||
"urllib",
|
||||
]
|
||||
|
||||
# Layer 2: Restricted builtins — remove dangerous ones
|
||||
_REMOVED_BUILTINS = {
|
||||
"open", "exec", "eval", "compile", "__import__",
|
||||
"breakpoint", "exit", "quit", "input",
|
||||
}
|
||||
|
||||
# Layer 3: Safe import allowlist
|
||||
_SAFE_IMPORT_MODULES = frozenset({
|
||||
"math", "cmath", "decimal", "fractions", "random", "statistics",
|
||||
"itertools", "functools", "operator", "collections", "string",
|
||||
"re", "textwrap", "datetime", "time", "calendar",
|
||||
"json", "csv", "copy", "dataclasses", "enum", "typing",
|
||||
"heapq", "bisect", "array", "pprint", "abc", "numbers",
|
||||
})
|
||||
|
||||
|
||||
def _make_safe_import(allowed: frozenset = _SAFE_IMPORT_MODULES):
|
||||
"""Return a custom __import__ that only allows safe modules."""
|
||||
if isinstance(__builtins__, dict):
|
||||
real_import = __builtins__["__import__"]
|
||||
else:
|
||||
real_import = __builtins__.__import__ # type: ignore[union-attr]
|
||||
|
||||
def _safe_import(name: str, *args: Any, **kwargs: Any) -> Any:
|
||||
top_level = name.split(".")[0]
|
||||
if top_level not in allowed:
|
||||
raise ImportError(
|
||||
f"Import of '{name}' is not allowed. "
|
||||
f"Allowed modules: {', '.join(sorted(allowed))}"
|
||||
)
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
return _safe_import
|
||||
|
||||
|
||||
def _make_restricted_builtins() -> Dict[str, Any]:
|
||||
"""Build a builtins dict with dangerous functions removed."""
|
||||
import builtins
|
||||
|
||||
safe = {k: v for k, v in vars(builtins).items() if k not in _REMOVED_BUILTINS}
|
||||
safe["__import__"] = _make_safe_import()
|
||||
return safe
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ReplSession:
|
||||
session_id: str
|
||||
namespace: Dict[str, Any] = field(default_factory=dict)
|
||||
created_at: float = field(default_factory=time.time)
|
||||
last_used: float = field(default_factory=time.time)
|
||||
execution_count: int = 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REPL Tool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@ToolRegistry.register("repl")
|
||||
class ReplTool(BaseTool):
|
||||
"""Persistent Python REPL with session management.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
timeout:
|
||||
Maximum execution time in seconds per call.
|
||||
max_output:
|
||||
Maximum characters of captured output.
|
||||
max_sessions:
|
||||
Maximum concurrent sessions (LRU eviction).
|
||||
"""
|
||||
|
||||
tool_id = "repl"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
timeout: int = 30,
|
||||
max_output: int = 10000,
|
||||
max_sessions: int = 16,
|
||||
) -> None:
|
||||
self._timeout = timeout
|
||||
self._max_output = max_output
|
||||
self._max_sessions = max_sessions
|
||||
self._sessions: Dict[str, _ReplSession] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def spec(self) -> ToolSpec:
|
||||
return ToolSpec(
|
||||
name="repl",
|
||||
description=(
|
||||
"Execute Python code in a persistent REPL session. "
|
||||
"Variables, functions, and imports persist across calls "
|
||||
"within the same session."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"description": "Python code to execute.",
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Session ID for state persistence. "
|
||||
"Omit to auto-create a new session."
|
||||
),
|
||||
},
|
||||
"reset": {
|
||||
"type": "boolean",
|
||||
"description": "Reset the session state before execution.",
|
||||
},
|
||||
},
|
||||
"required": ["code"],
|
||||
},
|
||||
category="code",
|
||||
)
|
||||
|
||||
def execute(self, **params: Any) -> ToolResult:
|
||||
code = params.get("code", "")
|
||||
session_id = params.get("session_id")
|
||||
reset = params.get("reset", False)
|
||||
|
||||
if not code or not code.strip():
|
||||
return ToolResult(
|
||||
tool_name="repl",
|
||||
content="No code provided.",
|
||||
success=False,
|
||||
)
|
||||
|
||||
# Security pattern check
|
||||
for pattern in _BLOCKED_PATTERNS:
|
||||
if pattern in code:
|
||||
return ToolResult(
|
||||
tool_name="repl",
|
||||
content=f"Blocked: code contains prohibited pattern '{pattern}'",
|
||||
success=False,
|
||||
)
|
||||
|
||||
# Resolve session
|
||||
session = self._resolve_session(session_id, reset)
|
||||
|
||||
# Execute with timeout
|
||||
output, success = self._exec_with_timeout(code, session)
|
||||
|
||||
# Update session metadata
|
||||
session.last_used = time.time()
|
||||
session.execution_count += 1
|
||||
|
||||
# Truncate output
|
||||
if len(output) > self._max_output:
|
||||
output = output[: self._max_output] + "\n... (output truncated)"
|
||||
|
||||
return ToolResult(
|
||||
tool_name="repl",
|
||||
content=output or "(no output)",
|
||||
success=success,
|
||||
metadata={
|
||||
"session_id": session.session_id,
|
||||
"execution_count": session.execution_count,
|
||||
},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_session(
|
||||
self,
|
||||
session_id: Optional[str],
|
||||
reset: bool = False,
|
||||
) -> _ReplSession:
|
||||
"""Get or create a session, with LRU eviction at max_sessions."""
|
||||
with self._lock:
|
||||
if session_id and session_id in self._sessions and not reset:
|
||||
session = self._sessions[session_id]
|
||||
return session
|
||||
|
||||
if session_id and session_id in self._sessions and reset:
|
||||
# Reset existing session
|
||||
session = self._sessions[session_id]
|
||||
session.namespace = {"__builtins__": _make_restricted_builtins()}
|
||||
session.execution_count = 0
|
||||
return session
|
||||
|
||||
# Create new session
|
||||
sid = session_id or str(uuid.uuid4())
|
||||
|
||||
# LRU eviction if at capacity
|
||||
if len(self._sessions) >= self._max_sessions:
|
||||
oldest_id = min(
|
||||
self._sessions,
|
||||
key=lambda k: self._sessions[k].last_used,
|
||||
)
|
||||
del self._sessions[oldest_id]
|
||||
|
||||
session = _ReplSession(
|
||||
session_id=sid,
|
||||
namespace={"__builtins__": _make_restricted_builtins()},
|
||||
)
|
||||
self._sessions[sid] = session
|
||||
return session
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Execution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _exec_with_timeout(
|
||||
self,
|
||||
code: str,
|
||||
session: _ReplSession,
|
||||
) -> tuple[str, bool]:
|
||||
"""Execute code in a daemon thread with timeout.
|
||||
|
||||
Returns (output, success).
|
||||
"""
|
||||
result_holder: Dict[str, Any] = {"output": "", "success": True}
|
||||
|
||||
def _run() -> None:
|
||||
stdout_buf = io.StringIO()
|
||||
stderr_buf = io.StringIO()
|
||||
try:
|
||||
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
|
||||
# Try eval first for expression display (REPL-like behavior)
|
||||
try:
|
||||
compiled = compile(code, "<repl>", "eval")
|
||||
val = eval(compiled, session.namespace) # noqa: S307
|
||||
if val is not None:
|
||||
print(repr(val)) # noqa: T201
|
||||
except SyntaxError:
|
||||
# Not an expression — execute as statements
|
||||
compiled = compile(code, "<repl>", "exec")
|
||||
exec(compiled, session.namespace) # noqa: S102
|
||||
except Exception as exc:
|
||||
result_holder["output"] = f"{type(exc).__name__}: {exc}"
|
||||
result_holder["success"] = False
|
||||
return
|
||||
|
||||
output = stdout_buf.getvalue()
|
||||
err = stderr_buf.getvalue()
|
||||
if err:
|
||||
output += ("\n" if output else "") + err
|
||||
result_holder["output"] = output
|
||||
|
||||
thread = threading.Thread(target=_run, daemon=True)
|
||||
thread.start()
|
||||
thread.join(timeout=self._timeout)
|
||||
|
||||
if thread.is_alive():
|
||||
return (
|
||||
f"Execution timed out after {self._timeout} seconds.",
|
||||
False,
|
||||
)
|
||||
|
||||
return result_holder["output"], result_holder["success"]
|
||||
|
||||
|
||||
__all__ = ["ReplTool"]
|
||||
Reference in New Issue
Block a user