Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,55 @@ on:
branches: [main]

jobs:
lint-backend:
name: Backend Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v6

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install dependencies
run: uv sync
working-directory: backend

- name: Run ruff
run: uv run ruff check openmlr/ tests/
working-directory: backend

lint-frontend:
name: Frontend Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 20

- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10

- name: Install dependencies
run: pnpm install
working-directory: frontend

- name: Run ESLint
run: pnpm lint
working-directory: frontend

test-backend:
name: Backend Tests
runs-on: ubuntu-latest
needs: lint-backend
steps:
- uses: actions/checkout@v4

Expand All @@ -32,6 +78,7 @@ jobs:
test-frontend:
name: Frontend Tests
runs-on: ubuntu-latest
needs: lint-frontend
steps:
- uses: actions/checkout@v4

Expand Down
24 changes: 24 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,30 @@ check-backend: ## Verify backend loads without errors
check-frontend: ## Type-check the frontend (tsc --noEmit)
cd $(FRONTEND) && npx tsc --noEmit

# ─── Linting ─────────────────────────────────────────────

.PHONY: lint
lint: lint-backend lint-frontend ## Run all linters

.PHONY: lint-backend
lint-backend: ## Lint backend with ruff
cd $(BACKEND) && uv run ruff check openmlr/ tests/

.PHONY: lint-frontend
lint-frontend: ## Lint frontend with ESLint
cd $(FRONTEND) && pnpm lint

.PHONY: lint-fix
lint-fix: lint-fix-backend lint-fix-frontend ## Auto-fix linting issues

.PHONY: lint-fix-backend
lint-fix-backend: ## Auto-fix backend linting issues
cd $(BACKEND) && uv run ruff check openmlr/ tests/ --fix

.PHONY: lint-fix-frontend
lint-fix-frontend: ## Auto-fix frontend linting issues
cd $(FRONTEND) && pnpm lint:fix

# ─── Testing ─────────────────────────────────────────────

.PHONY: test
Expand Down
3 changes: 2 additions & 1 deletion backend/openmlr/agent/context.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""ContextManager — message history, compaction, undo, token tracking."""

from dataclasses import dataclass, field
from .types import Message, ToolCall

from ..config import AgentConfig, get_model_max_tokens
from .types import Message, ToolCall


def estimate_tokens(text: str) -> int:
Expand Down
1 change: 1 addition & 0 deletions backend/openmlr/agent/doom_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import hashlib
import json

from .types import Message


Expand Down
52 changes: 27 additions & 25 deletions backend/openmlr/agent/llm.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
"""LLM Abstraction — multi-provider support (OpenAI, Anthropic, OpenRouter, litellm)."""

import os
import json
import asyncio
from typing import AsyncGenerator, Optional
from .types import LLMResult, ToolCall, ToolSpec
import json
import os
from collections.abc import AsyncGenerator

from ..config import AgentConfig
from .types import LLMResult, ToolCall


class LLMProvider:
"""Handles LLM calls across multiple providers with streaming and retry."""

@staticmethod
def _get_api_key(model_name: str) -> Optional[str]:
def _get_api_key(model_name: str) -> str | None:
mn = model_name.lower()
if mn.startswith("openai/"):
return os.environ.get("OPENAI_API_KEY")
Expand All @@ -37,9 +38,9 @@
if model_name.startswith(prefix):
return model_name[len(prefix):]
return model_name

@staticmethod
def _get_base_url(model_name: str) -> Optional[str]:
def _get_base_url(model_name: str) -> str | None:
"""Get the base URL for local/custom OpenAI-compatible APIs."""
mn = model_name.lower()
if mn.startswith("local/"):
Expand All @@ -59,7 +60,7 @@
return "https://opencode.ai/zen/go/v1" # Anthropic format
return "https://opencode.ai/zen/go/v1" # OpenAI-compatible format
return None

@staticmethod
def _is_opencode_go_anthropic_format(model_name: str) -> bool:
"""Check if OpenCode Go model uses Anthropic API format."""
Expand All @@ -75,7 +76,7 @@
"""True only for direct Anthropic API calls (anthropic/ prefix).
OpenRouter-routed Claude models use the OpenAI-compatible path."""
return model_name.lower().startswith("anthropic/")

@staticmethod
def _uses_anthropic_format(model_name: str) -> bool:
"""Check if model uses Anthropic message format (native Anthropic or OpenCode Go Anthropic models)."""
Expand All @@ -89,15 +90,15 @@
async def generate(
messages: list[dict],
config: AgentConfig,
tools: Optional[list[dict]] = None,
tools: list[dict] | None = None,
) -> LLMResult:
return await LLMProvider._call_with_retry(messages, config, tools)

@staticmethod
async def generate_stream(
messages: list[dict],
config: AgentConfig,
tools: Optional[list[dict]] = None,
tools: list[dict] | None = None,
) -> AsyncGenerator[str | ToolCall | dict, None]:
async for chunk in LLMProvider._stream_with_retry(messages, config, tools):
yield chunk
Expand All @@ -106,7 +107,7 @@
async def generate_title(
messages: list[dict],
config: AgentConfig,
) -> Optional[str]:
) -> str | None:
title_prompt = (
"Based on the conversation, generate a short title "
"(max 6 words). Return ONLY the title, nothing else."
Expand All @@ -126,7 +127,7 @@
result = await LLMProvider.generate(title_messages, title_config)
content = result.content.strip().strip('"').strip("'")
return content[:100] if content else None
except Exception:

Check notice on line 130 in backend/openmlr/agent/llm.py

View workflow job for this annotation

GitHub Actions / Qodana for Python

Unclear exception clauses

Too broad exception clause
return None

# ── Retry wrappers ────────────────────────────────────
Expand All @@ -143,7 +144,7 @@
async def _call_with_retry(
messages: list[dict],
config: AgentConfig,
tools: Optional[list[dict]] = None,
tools: list[dict] | None = None,
max_retries: int = 3,
) -> LLMResult:
last_error = None
Expand All @@ -165,7 +166,7 @@
async def _stream_with_retry(
messages: list[dict],
config: AgentConfig,
tools: Optional[list[dict]] = None,
tools: list[dict] | None = None,
) -> AsyncGenerator[str | ToolCall | dict, None]:
last_error = None
for attempt in range(3):
Expand All @@ -189,22 +190,23 @@

@staticmethod
def _openai_client(config: AgentConfig):
from openai import AsyncOpenAI
import logging

from openai import AsyncOpenAI
logger = logging.getLogger(__name__)

api_key = LLMProvider._get_api_key(config.model_name)
base_url = LLMProvider._get_base_url(config.model_name)

logger.debug(f"[LLM] Model: {config.model_name}, Base URL: {base_url}, API key set: {bool(api_key)}")

kwargs = {"api_key": api_key}
if base_url:
kwargs["base_url"] = base_url
return AsyncOpenAI(**kwargs)

@staticmethod
def _openai_tool_param(tools: Optional[list[dict]]) -> Optional[list[dict]]:
def _openai_tool_param(tools: list[dict] | None) -> list[dict] | None:
"""Convert tool specs to OpenAI tools param. Handles both raw and pre-wrapped."""
if not tools:
return None
Expand All @@ -222,7 +224,7 @@
async def _call_openai(
messages: list[dict],
config: AgentConfig,
tools: Optional[list[dict]],
tools: list[dict] | None,
) -> LLMResult:
client = LLMProvider._openai_client(config)
model = LLMProvider._normalize_model(config.model_name)
Expand Down Expand Up @@ -263,7 +265,7 @@
async def _stream_openai(
messages: list[dict],
config: AgentConfig,
tools: Optional[list[dict]],
tools: list[dict] | None,
) -> AsyncGenerator[str | ToolCall | dict, None]:
client = LLMProvider._openai_client(config)
model = LLMProvider._normalize_model(config.model_name)
Expand Down Expand Up @@ -335,7 +337,7 @@
# ── Anthropic ─────────────────────────────────────────

@staticmethod
def _anthropic_tool_param(tools: Optional[list[dict]]) -> Optional[list[dict]]:
def _anthropic_tool_param(tools: list[dict] | None) -> list[dict] | None:
"""Convert tool specs to Anthropic format."""
if not tools:
return None
Expand Down Expand Up @@ -392,7 +394,7 @@
def _anthropic_client(config: AgentConfig):
"""Create Anthropic client with appropriate settings for native or OpenCode Go."""
from anthropic import AsyncAnthropic

mn = config.model_name.lower()
if mn.startswith("opencode-go/"):
# OpenCode Go uses Anthropic format but different endpoint/key
Expand All @@ -407,7 +409,7 @@
async def _call_anthropic(
messages: list[dict],
config: AgentConfig,
tools: Optional[list[dict]],
tools: list[dict] | None,
) -> LLMResult:
model = LLMProvider._normalize_model(config.model_name)
client = LLMProvider._anthropic_client(config)
Expand Down Expand Up @@ -449,7 +451,7 @@
async def _stream_anthropic(
messages: list[dict],
config: AgentConfig,
tools: Optional[list[dict]],
tools: list[dict] | None,
) -> AsyncGenerator[str | ToolCall | dict, None]:
model = LLMProvider._normalize_model(config.model_name)
client = LLMProvider._anthropic_client(config)
Expand Down
18 changes: 8 additions & 10 deletions backend/openmlr/agent/loop.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
"""Agentic loop — the core turn-processing engine with tool execution."""

import json
import asyncio
import json
import traceback
from typing import Optional

from .types import AgentEvent, Message, ToolCall, ToolSpec, Submission, OpType, LLMResult
from .session import Session
from .context import ContextManager
from .llm import LLMProvider
from .doom_loop import detect_doom_loop
from ..config import AgentConfig
from .doom_loop import detect_doom_loop
from .llm import LLMProvider
from .session import Session
from .types import AgentEvent, LLMResult, Message, OpType, Submission, ToolCall


async def submission_loop(session: Session, tool_router) -> None:
Expand Down Expand Up @@ -167,7 +165,7 @@ async def _run_agent(session: Session, tool_router, user_message: str, mode: str
return_exceptions=True,
)

for tc, res in zip(auto_approve, results):
for tc, res in zip(auto_approve, results, strict=False):
if isinstance(res, Exception):
output = f"Error: {str(res)}"
success = False
Expand Down Expand Up @@ -233,7 +231,7 @@ async def _stream_llm_call(
session: Session,
messages: list[dict],
tools: list[dict],
) -> Optional[LLMResult]:
) -> LLMResult | None:
"""Execute a streaming LLM call, emitting chunks to SSE."""
content_buffer = ""
tool_calls: list[ToolCall] = []
Expand Down Expand Up @@ -278,7 +276,7 @@ async def _non_stream_llm_call(
session: Session,
messages: list[dict],
tools: list[dict],
) -> Optional[LLMResult]:
) -> LLMResult | None:
"""Execute a non-streaming LLM call."""
result = await LLMProvider.generate(messages, session.config, tools)

Expand Down
10 changes: 4 additions & 6 deletions backend/openmlr/agent/prompts.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
"""System prompt builder — loads Jinja2 YAML template and renders."""

import os
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
from typing import Optional

import yaml
from jinja2 import Template

from .types import ToolSpec
from ..config import AgentConfig

from .types import ToolSpec

PROMPT_DIR = Path(__file__).parent.parent.parent / "configs" / "prompts"
COMPACT_PROMPT = (
Expand All @@ -27,7 +25,7 @@
mode: str = "general",
username: str = "user",
sandbox_info: str = "none",
config: Optional[AgentConfig] = None,
config: AgentConfig | None = None,

Check notice on line 28 in backend/openmlr/agent/prompts.py

View workflow job for this annotation

GitHub Actions / Qodana for Python

Unused local symbols

Parameter 'config' value is not used
) -> str:
"""Build the full system prompt from YAML template."""
template_path = PROMPT_DIR / "system_prompt.yaml"
Expand All @@ -41,7 +39,7 @@
template_str = _fallback_prompt()

cwd = os.getcwd()
now = datetime.now(timezone.utc)
now = datetime.now(UTC)

template = Template(template_str)
prompt = template.render(
Expand Down
Loading
Loading