From 242205709ec6e9cfe215eb5ffecf94a41cb1680e Mon Sep 17 00:00:00 2001 From: abhijithneilabraham Date: Fri, 15 May 2026 11:21:31 +0530 Subject: [PATCH 1/3] feat: tool decorator --- openvibe/__init__.py | 2 + openvibe/api.py | 4 + openvibe/tool/decorator.py | 88 +++++++++++++++++ tests/test_tool_decorator.py | 179 +++++++++++++++++++++++++++++++++++ 4 files changed, 273 insertions(+) create mode 100644 openvibe/tool/decorator.py create mode 100644 tests/test_tool_decorator.py diff --git a/openvibe/__init__.py b/openvibe/__init__.py index d401f1b..4279306 100644 --- a/openvibe/__init__.py +++ b/openvibe/__init__.py @@ -4,6 +4,7 @@ from openvibe.api import (ErrorInfo, InputRequest, InvalidStateError, OpenVibe, Option, Response, Session, SessionState) +from openvibe.tool.decorator import tool __all__ = [ "OpenVibe", @@ -14,4 +15,5 @@ "Option", "ErrorInfo", "InvalidStateError", + "tool", ] diff --git a/openvibe/api.py b/openvibe/api.py index 5e6c543..0b1427a 100644 --- a/openvibe/api.py +++ b/openvibe/api.py @@ -584,11 +584,13 @@ def __init__( config: Any | None = None, # openvibe.config.Config db: Any | None = None, # Database — inject for testing llm: Any | None = None, # sync LLM callable — inject for testing + tools: list[Any] | None = None, # extra Tool instances to register ) -> None: self._project_dir = (project_dir or Path.cwd()).resolve() self._config = config self._db: Any = db # None means create on start() self._llm: Any = llm # None means use litellm + self._extra_tools: list[Any] = tools or [] self._registry: Any = None self._project: Any = None self._mcp: Any = None # McpClientManager — kept alive to prevent GC @@ -614,6 +616,8 @@ def start(self) -> "OpenVibe": if self._db is None: self._db = create_database() self._registry = create_default_registry() + for t in self._extra_tools: + self._registry.register(t) self._project = _project_module.get_or_create(self._db, self._project_dir) if self._config.mcp: diff --git a/openvibe/tool/decorator.py b/openvibe/tool/decorator.py new file mode 100644 index 0000000..1ebb97f --- /dev/null +++ b/openvibe/tool/decorator.py @@ -0,0 +1,88 @@ +"""@tool decorator — wrap a plain Python function as an openvibe Tool. + +Usage:: + + from openvibe import tool + + @tool + def search_jira(query: str, project: str = "ENG") -> str: + \"\"\"Search Jira tickets matching a query.\"\"\" + tickets = jira.search(f"project={project} AND text~'{query}'") + return "\\n".join(f"[{t.key}] {t.summary}" for t in tickets) + +Rules: +* Type hints on parameters become the JSON schema sent to the LLM. +* The docstring (first line) becomes the tool description. +* The function must return a plain ``str`` — that string is the tool output. +* Both sync and async functions are supported. +* The resulting object is a ready-to-use ``Tool`` instance. +""" + +from __future__ import annotations + +import asyncio +import inspect +from typing import Any, Callable, get_type_hints + +from pydantic import BaseModel, create_model + +from openvibe.tool.base import Tool, ToolContext, ToolResult + + +class _StrictBase(BaseModel): + model_config = {"extra": "forbid"} + + +def tool(fn: Callable) -> Tool: + """Decorate a plain Python function to create an openvibe ``Tool``. + + The decorated function is replaced by a ``Tool`` instance that can be + passed to ``OpenVibe(tools=[...])``. + + Args: + fn: A sync or async callable. All parameters must have type hints. + The return type should be ``str``. + + Returns: + A ``Tool`` instance whose name matches the function name. + """ + hints = get_type_hints(fn) + sig = inspect.signature(fn) + + # Build (annotation, default) pairs for pydantic.create_model. + # Required params use Ellipsis; params with defaults carry the default. + fields: dict[str, Any] = {} + for param_name, param in sig.parameters.items(): + annotation = hints.get(param_name, str) + if param.default is inspect.Parameter.empty: + fields[param_name] = (annotation, ...) + else: + fields[param_name] = (annotation, param.default) + + DynamicParams: type[BaseModel] = create_model( + "Params", + __base__=_StrictBase, + **fields, + ) + + is_async = asyncio.iscoroutinefunction(fn) + _name = fn.__name__ + _description = (inspect.getdoc(fn) or _name).split("\n")[0].strip() + + class _WrappedTool(Tool): + name = _name + description = _description + Params = DynamicParams + + async def execute(self, ctx: ToolContext, params: BaseModel) -> ToolResult: + kwargs = params.model_dump() + if is_async: + result = await fn(**kwargs) + else: + result = await asyncio.to_thread(fn, **kwargs) + return ToolResult(title=self.name, output=str(result)) + + _WrappedTool.__name__ = _name + _WrappedTool.__qualname__ = fn.__qualname__ + + return _WrappedTool() diff --git a/tests/test_tool_decorator.py b/tests/test_tool_decorator.py new file mode 100644 index 0000000..b639e4e --- /dev/null +++ b/tests/test_tool_decorator.py @@ -0,0 +1,179 @@ +"""Tests for the @tool decorator.""" + +from __future__ import annotations + +import asyncio +import pytest + +from openvibe import tool +from openvibe.tool.base import Tool, ToolContext, ToolResult + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _ctx() -> ToolContext: + return ToolContext( + session_id="s1", + message_id="m1", + agent_name="build", + project_id="p1", + working_dir="/tmp", + ) + + +def _run(coro): + return asyncio.get_event_loop().run_until_complete(coro) + + +# --------------------------------------------------------------------------- +# Basic decoration +# --------------------------------------------------------------------------- + + +def test_tool_decorator_returns_tool_instance(): + @tool + def greet(name: str) -> str: + """Say hello.""" + return f"Hello, {name}!" + + assert isinstance(greet, Tool) + + +def test_tool_name_matches_function(): + @tool + def my_custom_tool(x: str) -> str: + """Does something.""" + return x + + assert my_custom_tool.name == "my_custom_tool" + + +def test_tool_description_from_docstring(): + @tool + def search_jira(query: str, project: str = "ENG") -> str: + """Search Jira tickets matching a query.""" + return "" + + assert search_jira.description == "Search Jira tickets matching a query." + + +def test_tool_description_fallback_to_name(): + @tool + def no_doc(x: str) -> str: + return x + + assert no_doc.description == "no_doc" + + +# --------------------------------------------------------------------------- +# Schema generation +# --------------------------------------------------------------------------- + + +def test_tool_schema_has_required_params(): + @tool + def echo(message: str) -> str: + """Echo a message.""" + return message + + schema = echo.parameters_schema() + assert "message" in schema["properties"] + assert "message" in schema.get("required", []) + + +def test_tool_schema_has_optional_param_with_default(): + @tool + def search(query: str, limit: int = 10) -> str: + """Search something.""" + return query + + schema = search.parameters_schema() + assert "limit" in schema["properties"] + # limit has a default so it must NOT be in required + assert "limit" not in schema.get("required", []) + + +def test_tool_schema_type_hints(): + @tool + def add(a: int, b: int) -> str: + """Add two numbers.""" + return str(a + b) + + schema = add.parameters_schema() + assert schema["properties"]["a"]["type"] == "integer" + assert schema["properties"]["b"]["type"] == "integer" + + +# --------------------------------------------------------------------------- +# Execution — sync function +# --------------------------------------------------------------------------- + + +def test_tool_execute_sync(): + @tool + def double(value: str) -> str: + """Double a string.""" + return value * 2 + + result = _run(double(_ctx(), {"value": "ab"})) + assert isinstance(result, ToolResult) + assert result.output == "abab" + assert result.error is False + + +def test_tool_execute_sync_with_default(): + @tool + def greet(name: str, greeting: str = "Hello") -> str: + """Greet someone.""" + return f"{greeting}, {name}!" + + result = _run(greet(_ctx(), {"name": "world"})) + assert result.output == "Hello, world!" + + +def test_tool_execute_bad_params_returns_error(): + @tool + def typed(count: int) -> str: + """Needs an int.""" + return str(count) + + result = _run(typed(_ctx(), {"count": "not-an-int"})) + assert result.error is True + + +# --------------------------------------------------------------------------- +# Execution — async function +# --------------------------------------------------------------------------- + + +def test_tool_execute_async(): + @tool + async def async_echo(msg: str) -> str: + """Async echo.""" + await asyncio.sleep(0) + return msg + + result = _run(async_echo(_ctx(), {"msg": "hello"})) + assert result.output == "hello" + assert result.error is False + + +# --------------------------------------------------------------------------- +# Integration with OpenVibe tools list +# --------------------------------------------------------------------------- + + +def test_tool_registered_in_openvibe(tmp_path): + from openvibe import OpenVibe + from openvibe.config import Config + + @tool + def ping(message: str) -> str: + """Ping tool.""" + return f"pong: {message}" + + with OpenVibe(project_dir=tmp_path, config=Config(), tools=[ping]) as ov: + assert ov._registry.get("ping") is ping From d66fe4aa5a9a1a3e764be008c24ba94c467e01ee Mon Sep 17 00:00:00 2001 From: abhijithneilabraham Date: Fri, 15 May 2026 14:24:09 +0530 Subject: [PATCH 2/3] upd: add tool mid session --- openvibe/api.py | 21 +++++++++++++++++++++ tests/test_tool_decorator.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/openvibe/api.py b/openvibe/api.py index 0b1427a..feaa194 100644 --- a/openvibe/api.py +++ b/openvibe/api.py @@ -796,6 +796,27 @@ def run( # Internal # ------------------------------------------------------------------ + def register_tool(self, t: Any) -> None: + """Register a tool into the live registry. + + Works mid-session — all active and future sessions share the same + registry object, so the tool becomes available immediately. + + Example:: + + @tool + def ping(msg: str) -> str: + \"\"\"Ping.\"\"\" + return f"pong: {msg}" + + with OpenVibe() as ov: + session = ov.create_session() + ov.register_tool(ping) # available to session right away + session.send("use ping") + """ + self._require_started() + self._registry.register(t) + def _require_started(self) -> None: if self._db is None: raise RuntimeError( diff --git a/tests/test_tool_decorator.py b/tests/test_tool_decorator.py index b639e4e..87ed6fc 100644 --- a/tests/test_tool_decorator.py +++ b/tests/test_tool_decorator.py @@ -177,3 +177,32 @@ def ping(message: str) -> str: with OpenVibe(project_dir=tmp_path, config=Config(), tools=[ping]) as ov: assert ov._registry.get("ping") is ping + + +def test_register_tool_mid_session(tmp_path): + from openvibe import OpenVibe + from openvibe.config import Config + + @tool + def late_tool(x: str) -> str: + """Added after start.""" + return x + + with OpenVibe(project_dir=tmp_path, config=Config()) as ov: + assert ov._registry.get("late_tool") is None + ov.register_tool(late_tool) + assert ov._registry.get("late_tool") is late_tool + + +def test_register_tool_before_start_raises(tmp_path): + from openvibe import OpenVibe + from openvibe.config import Config + + @tool + def early(x: str) -> str: + """Too early.""" + return x + + ov = OpenVibe(project_dir=tmp_path, config=Config()) + with pytest.raises(RuntimeError): + ov.register_tool(early) From 05911442ce4f5b4cbb962899c8526f81860adc13 Mon Sep 17 00:00:00 2001 From: abhijithneilabraham Date: Fri, 15 May 2026 14:31:45 +0530 Subject: [PATCH 3/3] upd: lint --- openvibe/__init__.py | 12 ++++++++++-- openvibe/api.py | 26 ++++++++++++++++---------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/openvibe/__init__.py b/openvibe/__init__.py index 4279306..4fe7ec2 100644 --- a/openvibe/__init__.py +++ b/openvibe/__init__.py @@ -2,8 +2,16 @@ __version__ = "0.1.0" -from openvibe.api import (ErrorInfo, InputRequest, InvalidStateError, OpenVibe, - Option, Response, Session, SessionState) +from openvibe.api import ( + ErrorInfo, + InputRequest, + InvalidStateError, + OpenVibe, + Option, + Response, + Session, + SessionState, +) from openvibe.tool.decorator import tool __all__ = [ diff --git a/openvibe/api.py b/openvibe/api.py index feaa194..18f765d 100644 --- a/openvibe/api.py +++ b/openvibe/api.py @@ -267,8 +267,7 @@ def update_session_config(self, overrides: dict[str, Any]) -> None: def _try_command(self, text: str) -> Response | None: """If *text* is a slash command, execute it and return a Response.""" - from openvibe.commands import (CommandContext, execute, get_command, - is_command) + from openvibe.commands import CommandContext, execute, get_command, is_command if not is_command(text): return None @@ -917,10 +916,14 @@ async def _run_turn_async( from openvibe.config import MessageRole, PermissionAction from openvibe.permission.permission import PermissionRequestedEvent from openvibe.session import session as _store - from openvibe.session.models import (MessageCreatedEvent, - ReasoningDeltaEvent, TextDeltaEvent, - TextPart, ToolStateChangedEvent, - TurnCompletedEvent) + from openvibe.session.models import ( + MessageCreatedEvent, + ReasoningDeltaEvent, + TextDeltaEvent, + TextPart, + ToolStateChangedEvent, + TurnCompletedEvent, + ) accumulated_text = "" @@ -1138,10 +1141,13 @@ async def _run_interrupted_async( from openvibe.config import MessageRole, PermissionAction from openvibe.permission.permission import PermissionRequestedEvent from openvibe.session import session as _store - from openvibe.session.models import (MessageCreatedEvent, - ReasoningDeltaEvent, TextDeltaEvent, - ToolStateChangedEvent, - TurnCompletedEvent) + from openvibe.session.models import ( + MessageCreatedEvent, + ReasoningDeltaEvent, + TextDeltaEvent, + ToolStateChangedEvent, + TurnCompletedEvent, + ) accumulated_text = "" abort_async = _asyncio.Event()