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
19 changes: 19 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
repos:
- repo: local
hooks:
- id: uv-format
name: Format with uv format
entry: uv
args: [format]
language: system
types: [python]
pass_filenames: false
always_run: true
- id: uv-check
name: Lint with ruff
entry: uv
args: [run, ruff, check, --fix]
language: system
types: [python]
pass_filenames: false
always_run: true
70 changes: 70 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# OpenHands V1 Makefile
# Minimal Makefile for OpenHands V1 using uv

# Colors for output
GREEN := \033[32m
YELLOW := \033[33m
RED := \033[31m
CYAN := \033[36m
RESET := \033[0m

# Required uv version
REQUIRED_UV_VERSION := 0.8.13

.PHONY: build format lint clean help check-uv-version

# Default target
.DEFAULT_GOAL := help

# Check uv version
check-uv-version:
@echo "$(YELLOW)Checking uv version...$(RESET)"
@UV_VERSION=$$(uv --version | cut -d' ' -f2); \
REQUIRED_VERSION=$(REQUIRED_UV_VERSION); \
if [ "$$(printf '%s\n' "$$REQUIRED_VERSION" "$$UV_VERSION" | sort -V | head -n1)" != "$$REQUIRED_VERSION" ]; then \
echo "$(RED)Error: uv version $$UV_VERSION is less than required $$REQUIRED_VERSION$(RESET)"; \
echo "$(YELLOW)Please update uv with: uv self update$(RESET)"; \
exit 1; \
fi; \
echo "$(GREEN)uv version $$UV_VERSION meets requirements$(RESET)"

# Main build target - setup everything
build: check-uv-version
@echo "$(CYAN)Setting up OpenHands V1 development environment...$(RESET)"
@echo "$(YELLOW)Installing dependencies with uv sync --dev...$(RESET)"
@uv sync --dev
@echo "$(GREEN)Dependencies installed successfully.$(RESET)"
@echo "$(YELLOW)Setting up pre-commit hooks...$(RESET)"
@uv run pre-commit install
@echo "$(GREEN)Pre-commit hooks installed successfully.$(RESET)"
@echo "$(GREEN)Build complete! Development environment is ready.$(RESET)"

# Format code using uv format
format:
@echo "$(YELLOW)Formatting code with uv format...$(RESET)"
@uv format
@echo "$(GREEN)Code formatted successfully.$(RESET)"

# Lint code
lint:
@echo "$(YELLOW)Linting code with ruff...$(RESET)"
@uv run ruff check --fix
@echo "$(GREEN)Linting completed.$(RESET)"

# Clean up cache files
clean:
@echo "$(YELLOW)Cleaning up cache files...$(RESET)"
@find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
@find . -type f -name "*.pyc" -delete 2>/dev/null || true
@rm -rf .pytest_cache .ruff_cache .mypy_cache 2>/dev/null || true
@echo "$(GREEN)Cache files cleaned.$(RESET)"

# Show help
help:
@echo "$(CYAN)OpenHands V1 Makefile$(RESET)"
@echo "Available targets:"
@echo " $(GREEN)build$(RESET) - Setup development environment (install deps + hooks)"
@echo " $(GREEN)format$(RESET) - Format code with uv format"
@echo " $(GREEN)lint$(RESET) - Lint code with ruff"
@echo " $(GREEN)clean$(RESET) - Clean up cache files"
@echo " $(GREEN)help$(RESET) - Show this help message"
79 changes: 79 additions & 0 deletions openhands/runtime/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
from typing import Any, TypeVar
from pydantic import BaseModel, Field, ConfigDict, create_model

S = TypeVar("S", bound="Schema")


def py_type(spec: dict[str, Any]) -> Any:
"""Map JSON schema types to Python types."""
t = spec.get("type")
if t == "array":
items = spec.get("items", {})
inner = py_type(items) if isinstance(items, dict) else Any
return list[inner] # type: ignore[index]
if t == "object":
return dict[str, Any]
_map = {
"string": str,
"integer": int,
"number": float,
"boolean": bool,
}
if t in _map:
return _map[t]
return Any


class Schema(BaseModel):
"""Base schema for input action / output observation."""

model_config = ConfigDict(extra="forbid")

@classmethod
def to_mcp_schema(cls) -> dict[str, Any]:
"""Convert to JSON schema format compatible with MCP."""
js = cls.model_json_schema()
req = [n for n, f in cls.model_fields.items() if f.is_required()]
return {
"type": "object",
"properties": js.get("properties", {}) or {},
"required": req or [],
}

@classmethod
def from_mcp_schema(
cls: type[S], model_name: str, schema: dict[str, Any]
) -> type["S"]:
"""Create a Schema subclass from an MCP/JSON Schema object."""
assert isinstance(schema, dict), "Schema must be a dict"
assert schema.get("type") == "object", "Only object schemas are supported"

props: dict[str, Any] = schema.get("properties", {}) or {}
required = set(schema.get("required", []) or [])

fields: dict[str, tuple] = {}
for fname, spec in props.items():
tp = py_type(spec if isinstance(spec, dict) else {})
default = ... if fname in required else None
desc: str | None = (
spec.get("description") if isinstance(spec, dict) else None
)
fields[fname] = (
tp,
Field(default=default, description=desc)
if desc
else Field(default=default),
)
return create_model(model_name, __base__=cls, **fields) # type: ignore[return-value]


class ActionBase(Schema):
"""Base schema for input action."""

pass


class ObservationBase(Schema):
"""Base schema for output observation."""

model_config = ConfigDict(extra="allow")
120 changes: 120 additions & 0 deletions openhands/runtime/tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
from typing import Any, Callable
from pydantic import BaseModel
from .schema import ActionBase, ObservationBase, Schema


class ToolAnnotations(BaseModel):
title: str | None = None
readOnlyHint: bool | None = None
destructiveHint: bool | None = None
idempotentHint: bool | None = None
openWorldHint: bool | None = None


class Tool:
"""Tool that wraps an executor function with input/output validation and schema.

- Normalize input/output schemas (class or dict) into both model+schema.
- Validate inputs before execute.
- Coerce outputs only if an output model is defined; else return vanilla JSON.
- Export MCP tool description.
"""

def __init__(
self,
*,
name: str,
input_schema: type[ActionBase] | dict[str, Any],
output_schema: type[ObservationBase] | dict[str, Any] | None = None,
description: str | None = None,
annotations: ToolAnnotations | None = None,
_meta: dict[str, Any] | None = None,
execute_fn: Callable[[ActionBase], ObservationBase] | None = None,
):
self.name = name
self.description = description
self.annotations = annotations
self._meta = _meta
self._set_input_schema(input_schema)
self._set_output_schema(output_schema)

self.execute_fn = execute_fn

def _set_input_schema(
self, input_schema: dict[str, Any] | type[ActionBase]
) -> None:
# ---- INPUT: class or dict -> model + schema
self.action_type: type[ActionBase]
self.input_schema: dict[str, Any]
if isinstance(input_schema, type) and issubclass(input_schema, Schema):
self.action_type = input_schema
self.input_schema = input_schema.to_mcp_schema()
elif isinstance(input_schema, dict):
self.input_schema = input_schema
self.action_type = ActionBase.from_mcp_schema(
f"{self.name}Action", input_schema
)
else:
raise TypeError(
"input_schema must be ActionBase subclass or dict JSON schema"
)

def _set_output_schema(
self, output_schema: dict[str, Any] | type[ObservationBase] | None
) -> None:
# ---- OUTPUT: optional class or dict -> model + schema
self.observation_type: type[ObservationBase] | None
self.output_schema: dict[str, Any] | None
if output_schema is None:
self.observation_type = None
self.output_schema = None
elif isinstance(output_schema, type) and issubclass(output_schema, Schema):
self.observation_type = output_schema
self.output_schema = output_schema.to_mcp_schema()
elif isinstance(output_schema, dict):
self.output_schema = output_schema
self.observation_type = ObservationBase.from_mcp_schema(
f"{self.name}Observation", output_schema
)
else:
raise TypeError(
"output_schema must be ObservationBase subclass, dict, or None"
)

def call(self, action: ActionBase) -> ObservationBase:
if self.execute_fn is None:
raise NotImplementedError(f"Tool '{self.name}' has no executor")

# Execute
result = self.execute_fn(action)

# Coerce output only if we declared a model; else wrap in base ObservationBase
if self.observation_type:
if isinstance(result, self.observation_type):
return result
return self.observation_type.model_validate(result)
else:
# When no output schema is defined, wrap the result in ObservationBase
if isinstance(result, ObservationBase):
return result
elif isinstance(result, BaseModel):
return ObservationBase.model_validate(result.model_dump())
elif isinstance(result, dict):
return ObservationBase.model_validate(result)
raise TypeError(
"Output must be dict or BaseModel when no output schema is defined"
)

def to_mcp_tool(self) -> dict[str, Any]:
out = {
"name": self.name,
"description": self.description,
"inputSchema": self.input_schema,
}
if self.annotations:
out["annotations"] = self.annotations
if self._meta is not None:
out["_meta"] = self._meta
if self.output_schema:
out["outputSchema"] = self.output_schema
return out
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,9 @@ dependencies = [
"litellm>=1.75.9",
"pydantic>=2.11.7",
]

[dependency-groups]
dev = [
"pre-commit>=4.3.0",
"ruff>=0.12.10",
]
Loading