feat: openclaw-style secrets (env.vars + \) and per-task model routing
- Replace python-dotenv with config.json env.vars block + \ substitution - Add models section for per-task model routing (heartbeat, subagent, default) - Heartbeat/subagent tasks can use different models/providers than main chat - Remove python-dotenv from dependencies - Update all docs to reflect new config approach - Reorganize docs into project/ and research/ subdirectories
This commit is contained in:
238
main.py
238
main.py
@@ -24,10 +24,8 @@ import sys
|
||||
import threading
|
||||
from datetime import datetime
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env file (secrets only — config comes from ~/.aetheel/config.json)
|
||||
load_dotenv()
|
||||
# Config handles secrets via config.json env.vars + ${VAR} substitution.
|
||||
# No .env file needed.
|
||||
|
||||
from adapters.base import BaseAdapter, IncomingMessage
|
||||
from adapters.slack_adapter import SlackAdapter
|
||||
@@ -40,7 +38,7 @@ from agent.opencode_runtime import (
|
||||
build_aetheel_system_prompt,
|
||||
)
|
||||
from agent.subagent import SubagentManager
|
||||
from config import AetheelConfig, load_config, save_default_config, write_mcp_config, CONFIG_PATH
|
||||
from config import AetheelConfig, ModelRouteConfig, load_config, save_default_config, write_mcp_config, CONFIG_PATH
|
||||
from heartbeat import HeartbeatRunner
|
||||
from hooks import HookManager, HookEvent
|
||||
from memory import MemoryManager
|
||||
@@ -172,6 +170,13 @@ def _build_context(msg: IncomingMessage) -> str:
|
||||
if skills_summary:
|
||||
sections.append(skills_summary)
|
||||
|
||||
# ── Discord channel history context ──
|
||||
history_context = msg.raw_event.get("history_context", "")
|
||||
if history_context:
|
||||
sections.append(
|
||||
f"# Recent Channel History\n\n{history_context}"
|
||||
)
|
||||
|
||||
return "\n\n---\n\n".join(sections)
|
||||
|
||||
|
||||
@@ -242,6 +247,15 @@ def ai_handler(msg: IncomingMessage) -> str:
|
||||
if cmd.startswith("usage"):
|
||||
return _handle_usage_command()
|
||||
|
||||
if cmd.startswith("models"):
|
||||
return _handle_models_command(msg.text.strip().lstrip("/"))
|
||||
|
||||
if cmd.startswith("stats"):
|
||||
return _handle_stats_command(msg.text.strip().lstrip("/"))
|
||||
|
||||
if cmd.startswith("agents") or cmd == "agent list":
|
||||
return _handle_agents_command()
|
||||
|
||||
if cmd.startswith("mcp"):
|
||||
return _handle_mcp_command(msg.text.strip().lstrip("/"))
|
||||
|
||||
@@ -537,6 +551,8 @@ def _handle_engine_command(text: str) -> str:
|
||||
provider=cfg.runtime.provider,
|
||||
workspace_dir=cfg.runtime.workspace,
|
||||
format=cfg.runtime.format,
|
||||
agent=cfg.runtime.agent,
|
||||
attach_url=cfg.runtime.attach,
|
||||
)
|
||||
_runtime = OpenCodeRuntime(new_config)
|
||||
|
||||
@@ -608,6 +624,8 @@ def _handle_model_command(text: str) -> str:
|
||||
provider=cfg.runtime.provider,
|
||||
workspace_dir=cfg.runtime.workspace,
|
||||
format=cfg.runtime.format,
|
||||
agent=cfg.runtime.agent,
|
||||
attach_url=cfg.runtime.attach,
|
||||
)
|
||||
_runtime = OpenCodeRuntime(new_config)
|
||||
_update_config_file({"runtime": {"model": new_model}})
|
||||
@@ -655,6 +673,8 @@ def _handle_provider_command(text: str) -> str:
|
||||
provider=new_provider if new_provider != "auto" else None,
|
||||
workspace_dir=cfg.runtime.workspace,
|
||||
format=cfg.runtime.format,
|
||||
agent=cfg.runtime.agent,
|
||||
attach_url=cfg.runtime.attach,
|
||||
)
|
||||
_runtime = OpenCodeRuntime(new_config)
|
||||
_update_config_file({"runtime": {"provider": new_provider}})
|
||||
@@ -844,6 +864,8 @@ def _handle_rate_limit(
|
||||
provider=cfg.runtime.provider,
|
||||
workspace_dir=cfg.runtime.workspace,
|
||||
format=cfg.runtime.format,
|
||||
agent=cfg.runtime.agent,
|
||||
attach_url=cfg.runtime.attach,
|
||||
))
|
||||
else:
|
||||
# Failover: OpenCode → Claude
|
||||
@@ -942,6 +964,90 @@ def _handle_usage_command() -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Models, Stats, Agents Commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _handle_models_command(text: str) -> str:
|
||||
"""
|
||||
Handle the `models` command — list available models from OpenCode.
|
||||
|
||||
models List all models
|
||||
models <provider> List models for a specific provider
|
||||
models --verbose Include metadata (costs, etc.)
|
||||
"""
|
||||
global _runtime, _use_claude
|
||||
|
||||
if _use_claude:
|
||||
return "Model listing is only available with the OpenCode engine."
|
||||
|
||||
if not isinstance(_runtime, OpenCodeRuntime):
|
||||
return "⚠️ OpenCode runtime not initialized."
|
||||
|
||||
parts = text.strip().split()
|
||||
provider = None
|
||||
verbose = False
|
||||
|
||||
for part in parts[1:]: # skip "models"
|
||||
if part == "--verbose" or part == "-v":
|
||||
verbose = True
|
||||
elif not part.startswith("-"):
|
||||
provider = part
|
||||
|
||||
output = _runtime.list_models(provider=provider, verbose=verbose)
|
||||
# Wrap in code block for readability
|
||||
if len(output) > 100:
|
||||
return f"```\n{output[:3500]}\n```"
|
||||
return output
|
||||
|
||||
|
||||
def _handle_stats_command(text: str) -> str:
|
||||
"""
|
||||
Handle the `stats` command — show OpenCode token usage and cost stats.
|
||||
|
||||
stats All-time stats
|
||||
stats 7 Stats for last 7 days
|
||||
stats 30 Stats for last 30 days
|
||||
"""
|
||||
global _runtime, _use_claude
|
||||
|
||||
if _use_claude:
|
||||
return "OpenCode stats are only available with the OpenCode engine. Use `usage` for Aetheel stats."
|
||||
|
||||
if not isinstance(_runtime, OpenCodeRuntime):
|
||||
return "⚠️ OpenCode runtime not initialized."
|
||||
|
||||
parts = text.strip().split()
|
||||
days = None
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
days = int(parts[1])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
output = _runtime.get_stats(days=days)
|
||||
if len(output) > 100:
|
||||
return f"```\n{output[:3500]}\n```"
|
||||
return output
|
||||
|
||||
|
||||
def _handle_agents_command() -> str:
|
||||
"""Handle the `agents` command — list available OpenCode agents."""
|
||||
global _runtime, _use_claude
|
||||
|
||||
if _use_claude:
|
||||
return "Agent listing is only available with the OpenCode engine."
|
||||
|
||||
if not isinstance(_runtime, OpenCodeRuntime):
|
||||
return "⚠️ OpenCode runtime not initialized."
|
||||
|
||||
output = _runtime.list_agents()
|
||||
if len(output) > 100:
|
||||
return f"```\n{output[:3500]}\n```"
|
||||
return output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP Server Management Commands
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1254,9 +1360,14 @@ def _on_scheduled_job(job: ScheduledJob) -> None:
|
||||
|
||||
Creates a synthetic IncomingMessage and routes it through ai_handler,
|
||||
then sends the response to the right channel.
|
||||
|
||||
Heartbeat jobs use a dedicated runtime when ``models.heartbeat`` is
|
||||
configured, so cheap/local models can handle periodic tasks.
|
||||
"""
|
||||
logger.info(f"🔔 Scheduled job firing: {job.id} — '{job.prompt[:50]}'")
|
||||
|
||||
is_heartbeat = job.channel_type == "heartbeat"
|
||||
|
||||
# Build a synthetic message
|
||||
msg = IncomingMessage(
|
||||
text=job.prompt,
|
||||
@@ -1270,9 +1381,13 @@ def _on_scheduled_job(job: ScheduledJob) -> None:
|
||||
raw_event={"thread_id": job.thread_id},
|
||||
)
|
||||
|
||||
# Route through the AI handler
|
||||
# Route through the AI handler — use heartbeat runtime if configured
|
||||
try:
|
||||
response = ai_handler(msg)
|
||||
if is_heartbeat and _has_model_route("heartbeat"):
|
||||
response = _run_with_task_runtime("heartbeat", msg)
|
||||
else:
|
||||
response = ai_handler(msg)
|
||||
|
||||
if response:
|
||||
_send_to_channel(
|
||||
channel_id=job.channel_id,
|
||||
@@ -1284,6 +1399,39 @@ def _on_scheduled_job(job: ScheduledJob) -> None:
|
||||
logger.error(f"Scheduled job {job.id} handler failed: {e}", exc_info=True)
|
||||
|
||||
|
||||
def _has_model_route(task_type: str) -> bool:
|
||||
"""Check if a model route is configured for the given task type."""
|
||||
cfg = load_config()
|
||||
route = getattr(cfg.models, task_type, None)
|
||||
return route is not None and route.model is not None
|
||||
|
||||
|
||||
def _run_with_task_runtime(task_type: str, msg: IncomingMessage) -> str:
|
||||
"""Run a message through a task-specific runtime instance."""
|
||||
runtime = _make_runtime(task_type)
|
||||
|
||||
system_prompt = build_aetheel_system_prompt(
|
||||
user_name=msg.user_name,
|
||||
channel_name=msg.channel_name,
|
||||
is_dm=msg.is_dm,
|
||||
extra_context=_build_context(msg),
|
||||
)
|
||||
|
||||
response = runtime.chat(
|
||||
message=msg.text,
|
||||
conversation_id=msg.conversation_id,
|
||||
system_prompt=system_prompt,
|
||||
)
|
||||
|
||||
_track_usage(response)
|
||||
|
||||
if not response.ok:
|
||||
logger.warning(f"Task runtime [{task_type}] error: {response.error}")
|
||||
return f"⚠️ {task_type} task error: {response.error or 'Unknown error'}"
|
||||
|
||||
return _process_action_tags(response.text, msg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-Channel Send
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1324,8 +1472,19 @@ def _send_to_channel(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_runtime() -> AnyRuntime:
|
||||
"""Create a fresh runtime instance (used by subagent manager)."""
|
||||
def _make_runtime(task_type: str | None = None) -> AnyRuntime:
|
||||
"""Create a runtime instance, optionally routed by task type.
|
||||
|
||||
*task_type* can be ``"heartbeat"``, ``"subagent"``, or ``None``
|
||||
(which falls back to the ``"default"`` route, then the global config).
|
||||
|
||||
Model routing is configured in ``config.json`` → ``models``::
|
||||
|
||||
"models": {
|
||||
"heartbeat": { "model": "ollama/llama3.2", "provider": "ollama" },
|
||||
"subagent": { "model": "minimax/minimax-m1", "provider": "minimax" }
|
||||
}
|
||||
"""
|
||||
global _use_claude, _cli_args
|
||||
|
||||
cfg = load_config()
|
||||
@@ -1333,25 +1492,41 @@ def _make_runtime() -> AnyRuntime:
|
||||
cfg.runtime.model = _cli_args.model
|
||||
cfg.claude.model = _cli_args.model
|
||||
|
||||
if _use_claude:
|
||||
# Resolve model route for this task type
|
||||
route: ModelRouteConfig | None = None
|
||||
if task_type:
|
||||
route = getattr(cfg.models, task_type, None)
|
||||
if route is None:
|
||||
route = cfg.models.default # may also be None → use global
|
||||
|
||||
# Determine engine: route overrides global
|
||||
use_claude = _use_claude
|
||||
if route and route.engine:
|
||||
use_claude = route.engine == "claude"
|
||||
|
||||
if use_claude:
|
||||
config = ClaudeCodeConfig(
|
||||
model=cfg.claude.model,
|
||||
timeout_seconds=cfg.claude.timeout_seconds,
|
||||
model=(route.model if route and route.model else cfg.claude.model),
|
||||
timeout_seconds=(route.timeout_seconds if route and route.timeout_seconds else cfg.claude.timeout_seconds),
|
||||
max_turns=cfg.claude.max_turns,
|
||||
no_tools=cfg.claude.no_tools,
|
||||
allowed_tools=cfg.claude.allowed_tools,
|
||||
)
|
||||
logger.info(f"Runtime [{task_type or 'default'}]: claude, model={config.model or 'default'}")
|
||||
return ClaudeCodeRuntime(config)
|
||||
else:
|
||||
config = OpenCodeConfig(
|
||||
mode=RuntimeMode.SDK if cfg.runtime.mode == "sdk" else RuntimeMode.CLI,
|
||||
server_url=cfg.runtime.server_url,
|
||||
timeout_seconds=cfg.runtime.timeout_seconds,
|
||||
model=cfg.runtime.model,
|
||||
provider=cfg.runtime.provider,
|
||||
timeout_seconds=(route.timeout_seconds if route and route.timeout_seconds else cfg.runtime.timeout_seconds),
|
||||
model=(route.model if route and route.model else cfg.runtime.model),
|
||||
provider=(route.provider if route and route.provider else cfg.runtime.provider),
|
||||
workspace_dir=cfg.runtime.workspace,
|
||||
format=cfg.runtime.format,
|
||||
agent=cfg.runtime.agent,
|
||||
attach_url=cfg.runtime.attach,
|
||||
)
|
||||
logger.info(f"Runtime [{task_type or 'default'}]: opencode, model={config.model or 'default'}, provider={config.provider or 'default'}")
|
||||
return OpenCodeRuntime(config)
|
||||
|
||||
|
||||
@@ -1401,6 +1576,16 @@ def _format_status() -> str:
|
||||
active = _subagent_mgr.list_active()
|
||||
lines.append(f"• *Active Subagents:* {len(active)}")
|
||||
|
||||
# Model routing
|
||||
cfg = load_config()
|
||||
routes = []
|
||||
for task_name in ("heartbeat", "subagent", "default"):
|
||||
route = getattr(cfg.models, task_name, None)
|
||||
if route and route.model:
|
||||
routes.append(f"{task_name}→{route.model}")
|
||||
if routes:
|
||||
lines.append(f"• *Model Routes:* {', '.join(routes)}")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
f"• *Time:* {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
@@ -1428,6 +1613,11 @@ def _format_help() -> str:
|
||||
"• `model` — Show/switch AI model\n"
|
||||
"• `provider` — Show/switch provider (opencode only)\n"
|
||||
"• `usage` — Show LLM usage stats and costs\n"
|
||||
"• `models` — List available models (opencode only)\n"
|
||||
"• `models <provider>` — List models for a provider\n"
|
||||
"• `stats` — OpenCode token usage and cost stats\n"
|
||||
"• `stats <days>` — Stats for last N days\n"
|
||||
"• `agents` — List available OpenCode agents\n"
|
||||
"\n"
|
||||
"*Config:*\n"
|
||||
"• `config` — View config summary\n"
|
||||
@@ -1638,6 +1828,8 @@ CLI flags are optional overrides.
|
||||
provider=cfg.runtime.provider,
|
||||
workspace_dir=cfg.runtime.workspace,
|
||||
format=cfg.runtime.format,
|
||||
agent=cfg.runtime.agent,
|
||||
attach_url=cfg.runtime.attach,
|
||||
)
|
||||
_runtime = OpenCodeRuntime(oc_config)
|
||||
runtime_label = (
|
||||
@@ -1684,7 +1876,7 @@ CLI flags are optional overrides.
|
||||
if _runtime:
|
||||
try:
|
||||
_subagent_mgr = SubagentManager(
|
||||
runtime_factory=_make_runtime,
|
||||
runtime_factory=lambda: _make_runtime("subagent"),
|
||||
send_fn=_send_to_channel,
|
||||
)
|
||||
logger.info("Subagent manager initialized")
|
||||
@@ -1785,6 +1977,20 @@ CLI flags are optional overrides.
|
||||
discord_adapter = DiscordAdapter(
|
||||
bot_token=cfg.discord.bot_token,
|
||||
listen_channels=cfg.discord.listen_channels or None,
|
||||
reply_to_mode=cfg.discord.reply_to_mode,
|
||||
history_enabled=cfg.discord.history_enabled,
|
||||
history_limit=cfg.discord.history_limit,
|
||||
channel_overrides={
|
||||
k: {"history_limit": v.history_limit, "history_enabled": v.history_enabled}
|
||||
for k, v in cfg.discord.channel_overrides.items()
|
||||
},
|
||||
ack_reaction=cfg.discord.ack_reaction,
|
||||
typing_indicator=cfg.discord.typing_indicator,
|
||||
reaction_mode=cfg.discord.reaction_mode,
|
||||
exec_approvals=cfg.discord.exec_approvals,
|
||||
exec_approval_tools=cfg.discord.exec_approval_tools,
|
||||
slash_commands=cfg.discord.slash_commands,
|
||||
components_enabled=cfg.discord.components_enabled,
|
||||
)
|
||||
discord_adapter.on_message(handler)
|
||||
_adapters["discord"] = discord_adapter
|
||||
|
||||
Reference in New Issue
Block a user