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
14 changes: 12 additions & 2 deletions openvibe/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,17 @@

__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__ = [
"OpenVibe",
Expand All @@ -14,4 +23,5 @@
"Option",
"ErrorInfo",
"InvalidStateError",
"tool",
]
51 changes: 41 additions & 10 deletions openvibe/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -584,11 +583,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
Expand All @@ -614,6 +615,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:
Expand Down Expand Up @@ -792,6 +795,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(
Expand Down Expand Up @@ -892,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 = ""

Expand Down Expand Up @@ -1113,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()
Expand Down
88 changes: 88 additions & 0 deletions openvibe/tool/decorator.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading