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:
Jon Saad-Falcon
2026-02-23 18:32:32 +00:00
co-authored by Claude Opus 4.6
parent a0961633a3
commit 8d538cd1b0
105 changed files with 16486 additions and 1858 deletions
+5 -5
View File
@@ -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 -1
View File
@@ -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
-181
View File
@@ -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"]
-86
View File
@@ -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"]
-143
View File
@@ -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"]
+193 -2
View File
@@ -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,
+352
View File
@@ -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"]
+183
View File
@@ -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"]
+72 -2
View File
@@ -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
+133
View File
@@ -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"]
+197
View File
@@ -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"]
+240
View File
@@ -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"]
+162
View File
@@ -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"]
+123
View File
@@ -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"]
+145
View File
@@ -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"]
+137
View File
@@ -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"]
+134
View File
@@ -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"]
-257
View File
@@ -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"]
+131
View File
@@ -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"]
+221
View File
@@ -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"]
+142
View File
@@ -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"]
+202
View File
@@ -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"]
+114
View File
@@ -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"]
+139
View File
@@ -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"]
+141
View File
@@ -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"]
+177 -32
View File
@@ -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
View File
@@ -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
+225 -5
View File
@@ -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",
+6
View File
@@ -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",
+1
View File
@@ -15,6 +15,7 @@ _HOST_MAP: Dict[str, str | None] = {
"llamacpp": "llamacpp_host",
"sglang": "sglang_host",
"cloud": None,
"litellm": None,
}
+144
View File
@@ -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"]
+6
View File
@@ -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",
+1 -8
View File
@@ -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",
]
+4 -3
View File
@@ -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",
]
+16 -6
View File
@@ -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
+22
View File
@@ -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
-1
View File
@@ -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
View File
@@ -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
+10
View File
@@ -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"]
+188
View File
@@ -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",
]
+303
View File
@@ -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"]