diff --git a/.gitignore b/.gitignore index ab1f8da..76cdc90 100644 --- a/.gitignore +++ b/.gitignore @@ -199,3 +199,5 @@ result-* # Tests run the agent in the playground, we don't need to keep the session files tests/playground/* .qodo +.zencoder +.zenflow diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a86c84..a339126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,69 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.4.0] - 2025-12-25 +## [0.2.0] - 2025-12-30 + +### Added + +- Support for XML-based tool calling via `--tool-format xml` flag. +- XML-specific prompts for all built-in tools (`bash`, `grep`, `read_file`, `write_file`, `search_replace`, `todo`). +- `XMLToolFormatHandler` for robust parsing of XML tool calls and generation of XML tool results. +- `supported_formats` field in `ModelConfig` and backend implementations to manage compatibility. +- Dynamic tool prompt resolution in `BaseTool` allowing automatic fallback to standard prompts if XML version is missing. +- First public release of ReVibe with all core functionality + +### Changed + +- TUI Visual & Functional Enhancements: + - Added `redact_xml_tool_calls(text)` utility in `revibe/core/utils.py` to remove raw `...` blocks from assistant output stream + - Refactored `StreamingMessageBase` in `revibe/cli/textual_ui/widgets/messages.py` to track `_displayed_content` for smart UI updates + - Enhanced premium tool summaries in chat history: + * Grep now shows as `Grep (pattern)` instead of `grep: 'pattern'` + * Bash now shows as `Bash (command)` instead of raw command string + * Read File now shows as `Read (filename)` with cleaner summary + * Write File now shows as `Write (filename)` + * Search & Replace now shows as `Patch (filename)` + - Applied redaction logic to `ReasoningMessage` in `revibe/cli/textual_ui/widgets/messages.py` to hide raw XML in reasoning blocks + +### Fixed + +- Case-sensitivity issue when specifying tool format via CLI. +- Type errors in backends when implementing `BackendLike` protocol (added missing `supported_formats`). +- Typo in `XMLToolFormatHandler` name property. + +## [0.1.5.1] - 2025-12-30 + +### Added + +- Support for XML-based tool calling via `--tool-format xml` flag. +- XML-specific prompts for all built-in tools (`bash`, `grep`, `read_file`, `write_file`, `search_replace`, `todo`). +- `XMLToolFormatHandler` for robust parsing of XML tool calls and generation of XML tool results. +- `supported_formats` field in `ModelConfig` and backend implementations to manage compatibility. +- Dynamic tool prompt resolution in `BaseTool` allowing automatic fallback to standard prompts if XML version is missing. + +### Fixed + +- Case-sensitivity issue when specifying tool format via CLI. +- Type errors in backends when implementing `BackendLike` protocol (added missing `supported_formats`). +- Typo in `XMLToolFormatHandler` name property. + +## [0.1.5.0] - 2025-12-30 + +### Added + +- Support for XML-based tool calling via `--tool-format xml` flag. +- XML-specific prompts for all built-in tools (`bash`, `grep`, `read_file`, `write_file`, `search_replace`, `todo`). +- `XMLToolFormatHandler` for robust parsing of XML tool calls and generation of XML tool results. +- `supported_formats` field in `ModelConfig` and backend implementations to manage compatibility. +- Dynamic tool prompt resolution in `BaseTool` allowing automatic fallback to standard prompts if XML version is missing. + +### Fixed + +- Case-sensitivity issue when specifying tool format via CLI. +- Type errors in backends when implementing `BackendLike` protocol (added missing `supported_formats`). +- Typo in `XMLToolFormatHandler` name property. + +## [0.1.4.0] - 2025-12-25 ### Added @@ -35,7 +97,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Remove conflicting default `api_base` for Qwen provider to allow proper endpoint auto-detection - Enhance Qwen backend robustness with improved SSE parsing and graceful JSON error handling -## [1.3.0] - 2025-12-23 +## [0.1.3.0] - 2025-12-23 ### Added @@ -57,7 +119,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix crash when switching mode - Fix some cases where clipboard copy didn't work -## [1.2.2] - 2025-12-22 +## [0.1.2.2] - 2025-12-22 ### Fixed @@ -65,14 +127,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix artefacts automatically attached to the release - Refactor agent post streaming -## [1.2.1] - 2025-12-18 +## [0.1.2.1] - 2025-12-18 ### Fixed - Improve error message when running in home dir - Do not show trusted folder workflow in home dir -## [1.2.0] - 2025-12-18 +## [0.1.2.0] - 2025-12-18 ### Added @@ -94,7 +156,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Prevent segmentation fault on exit by shutting down thread pools - Fix extra spacing with assistant message -## [1.1.3] - 2025-12-12 +## [0.1.1.3] - 2025-12-12 ### Added @@ -115,20 +177,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix security issue: prevent command injection in GitHub Action prompt handling - Fix issues with vLLM -## [1.1.2] - 2025-12-11 +## [0.1.1.2] - 2025-12-11 ### Changed - add `terminal-auth` auth method to ACP agent only if the client supports it - fix `user-agent` header when using Mistral backend, using SDK hook -## [1.1.1] - 2025-12-10 +## [0.1.1.1] - 2025-12-10 ### Changed - added `include_commit_signature` in `config.toml` to disable signing commits -## [1.1.0] - 2025-12-10 +## [0.1.1.0] - 2025-12-10 ### Fixed @@ -138,7 +200,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - improved context length from 100k to 200k -## [1.0.6] - 2025-12-10 +## [0.1.0.6] - 2025-12-10 ### Fixed @@ -154,13 +216,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - update default system prompt reference - document MCP tool permission configuration -## [1.0.5] - 2025-12-10 +## [0.1.0.5] - 2025-12-10 ### Fixed - Fix streaming with OpenAI adapter -## [1.0.4] - 2025-12-09 +## [0.1.0.4] - 2025-12-09 ### Changed @@ -174,25 +236,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Remove .envrc file -## [1.0.3] - 2025-12-09 +## [0.1.0.3] - 2025-12-09 ### Added - Add LICENCE symlink in distribution/zed for compatibility with zed extension release process -## [1.0.2] - 2025-12-09 +## [0.1.0.2] - 2025-12-09 ### Fixed - Fix setup flow for vibe-acp builds -## [1.0.1] - 2025-12-09 +## [0.1.0.1] - 2025-12-09 ### Fixed - Fix update notification -## [1.0.0] - 2025-12-09 +## [0.1.0.0] - 2025-12-09 ### Added diff --git a/chnageimade.md b/chnageimade.md new file mode 100644 index 0000000..108a889 --- /dev/null +++ b/chnageimade.md @@ -0,0 +1,30 @@ +# TUI Visual & Functional Enhancements + +I have implemented several key changes to the TUI to improve the visual experience and support the new XML-based tool calling mode. + +## 1. XML Tool Call Redaction +- **File**: `revibe/core/utils.py` +- **Change**: Added `redact_xml_tool_calls(text)` utility. +- **Purpose**: This function detects and removes raw `...` blocks from the assistant's output stream. It supports partially written tags, ensuring that raw XML never "flickers" on screen during streaming. + +## 2. Streaming UI Refresh +- **File**: `revibe/cli/textual_ui/widgets/messages.py` +- **Change**: Refactored `StreamingMessageBase` to track `_displayed_content`. +- **Purpose**: Allows the UI to smart-update only when visible content changes. If a tool call block starts in the stream, the UI detects the decrease in "visible" characters (due to redaction) and resets the stream to prevent showing fragments of XML. + +## 3. Premium Tool Summaries +I updated the display logic for all built-in tools to provide a cleaner, more premium aesthetic in the chat history: + +- **Grep**: Now shows as `Grep (pattern)` instead of `grep: 'pattern'`. +- **Bash**: Now shows as `Bash (command)` instead of a raw command string. +- **Read File**: Now shows as `Read (filename)` with a cleaner summary. +- **Write File**: Now shows as `Write (filename)`. +- **Search & Replace**: Now shows as `Patch (filename)`. + +## 4. Reasoning Integration +- **File**: `revibe/cli/textual_ui/widgets/messages.py` +- **Change**: Applied the same redaction logic to `ReasoningMessage`. +- **Purpose**: Ensures that even if the model starts thinking about tool calls in its reasoning block, the raw tags remain hidden from the user. + +--- +*Created on 2025-12-30 following TUI Aesthetic overhaul.* diff --git a/image.png b/image.png new file mode 100644 index 0000000..8881781 Binary files /dev/null and b/image.png differ diff --git a/pyproject.toml b/pyproject.toml index ea3ce71..22906b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "revibe" -version = "1.4.0" +version = "0.2.0" description = "ReVibe - Multi-provider CLI coding agent" readme = "README.md" requires-python = ">=3.12" diff --git a/revibe/cli/cli.py b/revibe/cli/cli.py index 7492e41..fd42c99 100644 --- a/revibe/cli/cli.py +++ b/revibe/cli/cli.py @@ -142,6 +142,10 @@ def run_cli(args: argparse.Namespace) -> None: if args.enabled_tools: config.enabled_tools = args.enabled_tools + if args.tool_format: + from revibe.core.config import ToolFormat + config.tool_format = ToolFormat(args.tool_format.lower()) + loaded_messages = load_session(args, config) stdin_prompt = get_prompt_from_stdin() diff --git a/revibe/cli/entrypoint.py b/revibe/cli/entrypoint.py index 32477de..a4c66f3 100644 --- a/revibe/cli/entrypoint.py +++ b/revibe/cli/entrypoint.py @@ -91,6 +91,16 @@ def parse_arguments() -> argparse.Namespace: help="Run interactive setup: choose provider, theme, and configure API key", ) + parser.add_argument( + "--tool-format", + type=str, + choices=["native", "xml"], + default=None, + metavar="FORMAT", + help="Tool calling format: 'native' for API function calling (default), " + "'xml' for XML-based tool calling in prompts.", + ) + continuation_group = parser.add_mutually_exclusive_group() continuation_group.add_argument( "-c", diff --git a/revibe/cli/textual_ui/app.py b/revibe/cli/textual_ui/app.py index c05ed03..d4d1c2a 100644 --- a/revibe/cli/textual_ui/app.py +++ b/revibe/cli/textual_ui/app.py @@ -80,6 +80,7 @@ class BottomApp(StrEnum): Model = auto() +# ruff: noqa: PLR0904 class VibeApp(App): ENABLE_COMMAND_PALETTE = False CSS_PATH = "app.tcss" diff --git a/revibe/cli/textual_ui/terminal_theme.py b/revibe/cli/textual_ui/terminal_theme.py index 6e8827c..fd8b24b 100644 --- a/revibe/cli/textual_ui/terminal_theme.py +++ b/revibe/cli/textual_ui/terminal_theme.py @@ -12,7 +12,6 @@ try: import select - import termios _UNIX_AVAILABLE = True except ImportError: diff --git a/revibe/cli/textual_ui/widgets/messages.py b/revibe/cli/textual_ui/widgets/messages.py index 39f71af..b94ade2 100644 --- a/revibe/cli/textual_ui/widgets/messages.py +++ b/revibe/cli/textual_ui/widgets/messages.py @@ -8,6 +8,7 @@ from textual.widgets._markdown import MarkdownStream from revibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType +from revibe.core.utils import redact_xml_tool_calls class NonSelectableStatic(Static): @@ -63,6 +64,7 @@ class StreamingMessageBase(Static): def __init__(self, content: str) -> None: super().__init__() self._content = content + self._displayed_content = "" self._markdown: Markdown | None = None self._stream: MarkdownStream | None = None @@ -84,13 +86,35 @@ async def append_content(self, content: str) -> None: self._content += content if self._should_write_content(): + await self._update_display() + + async def _update_display(self) -> None: + new_displayed = self._process_content_for_display(self._content) + + if len(new_displayed) > len(self._displayed_content): + # Append new content to stream + diff = new_displayed[len(self._displayed_content) :] stream = self._ensure_stream() - await stream.write(content) + await stream.write(diff) + self._displayed_content = new_displayed + elif len(new_displayed) < len(self._displayed_content): + # Content shrunk (e.g. tag started), reset and re-render + if self._stream: + await self._stream.stop() + self._stream = None + if self._markdown: + await self._markdown.update("") + self._displayed_content = "" + # Recursively update with the now empty displayed content + await self._update_display() + + def _process_content_for_display(self, content: str) -> str: + """Process content before it is shown in the UI. Overridden by subclasses.""" + return content async def write_initial_content(self) -> None: if self._content and self._should_write_content(): - stream = self._ensure_stream() - await stream.write(self._content) + await self._update_display() async def stop_stream(self) -> None: if self._stream is None: @@ -116,6 +140,9 @@ def compose(self) -> ComposeResult: self._markdown = markdown yield markdown + def _process_content_for_display(self, content: str) -> str: + return redact_xml_tool_calls(content) + class ReasoningMessage(SpinnerMixin, StreamingMessageBase): SPINNER_TYPE = SpinnerType.LINE @@ -176,8 +203,11 @@ async def set_collapsed(self, collapsed: bool) -> None: await self._stream.stop() self._stream = None await self._markdown.update("") - stream = self._ensure_stream() - await stream.write(self._content) + self._displayed_content = "" + await self._update_display() + + def _process_content_for_display(self, content: str) -> str: + return redact_xml_tool_calls(content) class UserCommandMessage(Static): diff --git a/revibe/cli/textual_ui/widgets/welcome.py b/revibe/cli/textual_ui/widgets/welcome.py index 0eb623c..c338ece 100644 --- a/revibe/cli/textual_ui/widgets/welcome.py +++ b/revibe/cli/textual_ui/widgets/welcome.py @@ -56,7 +56,6 @@ class WelcomeBanner(Static): COLOR_CACHE_THRESHOLD = 0.001 BORDER_PROGRESS_THRESHOLD = 0.01 - def __init__(self, config: VibeConfig) -> None: super().__init__(" ") self.config = config @@ -94,7 +93,6 @@ def _initialize_static_line_suffixes(self) -> None: MODEL_COLOR = "#00D1FF" STATS_COLOR = "#00FF94" PATH_COLOR = "#B388FF" - DIM = "#6272A4" self._static_line1_suffix = f"[{ACCENT}]✦[/] [b]ReVibe[/] [dim]v{__version__}[/]" self._static_line2_suffix = ( diff --git a/revibe/core/agent.py b/revibe/core/agent.py index 8017ba0..d414ae1 100644 --- a/revibe/core/agent.py +++ b/revibe/core/agent.py @@ -9,10 +9,14 @@ from pydantic import BaseModel -from revibe.core.config import VibeConfig +from revibe.core.config import ToolFormat, VibeConfig from revibe.core.interaction_logger import InteractionLogger from revibe.core.llm.backend.factory import BACKEND_FACTORY -from revibe.core.llm.format import APIToolFormatHandler, ResolvedMessage +from revibe.core.llm.format import ( + APIToolFormatHandler, + ResolvedMessage, + XMLToolFormatHandler, +) from revibe.core.llm.types import BackendLike from revibe.core.middleware import ( AutoCompactMiddleware, @@ -106,7 +110,12 @@ def __init__( self.tool_manager = ToolManager(config) self.skill_manager = SkillManager(config) - self.format_handler = APIToolFormatHandler() + + # Select format handler based on config + if config.tool_format == ToolFormat.XML: + self.format_handler: APIToolFormatHandler | XMLToolFormatHandler = XMLToolFormatHandler() + else: + self.format_handler = APIToolFormatHandler() self.backend_factory = lambda: backend or self._select_backend() self.backend = self.backend_factory() @@ -269,7 +278,7 @@ async def _conversation_loop(self, user_msg: str) -> AsyncGenerator[BaseEvent]: yield event last_message = self.messages[-1] - should_break_loop = last_message.role != Role.tool + should_break_loop = not self.format_handler.is_tool_response(last_message) self._flush_new_messages() diff --git a/revibe/core/config.py b/revibe/core/config.py index 7667c23..c15a96a 100644 --- a/revibe/core/config.py +++ b/revibe/core/config.py @@ -6,7 +6,7 @@ import re import shlex import tomllib -from typing import Annotated, Any, Literal, TypeAlias +from typing import Annotated, Any, Literal from dotenv import dotenv_values from pydantic import BaseModel, Field, field_validator, model_validator @@ -137,6 +137,17 @@ class Backend(StrEnum): QWEN = auto() +class ToolFormat(StrEnum): + """Tool calling format for LLM interactions. + + NATIVE: Use the API's native function/tool calling mechanism + XML: Use XML-based tool calling embedded in prompts (for models without native support) + """ + + NATIVE = auto() + XML = auto() + + class _ProviderBase(BaseModel): name: str api_base: str @@ -383,6 +394,14 @@ class VibeConfig(BaseSettings): ), ) + tool_format: ToolFormat = Field( + default=ToolFormat.NATIVE, + description=( + "Tool calling format: 'native' uses the API's function calling mechanism, " + "'xml' embeds tool definitions in the system prompt for models without native support." + ), + ) + model_config = SettingsConfigDict( env_prefix="VIBE_", case_sensitive=False, extra="ignore" ) diff --git a/revibe/core/llm/backend/factory.py b/revibe/core/llm/backend/factory.py index 3e40f54..1fab563 100644 --- a/revibe/core/llm/backend/factory.py +++ b/revibe/core/llm/backend/factory.py @@ -21,4 +21,3 @@ Backend.CEREBRAS: CerebrasBackend, Backend.QWEN: QwenBackend, } - diff --git a/revibe/core/llm/backend/mistral.py b/revibe/core/llm/backend/mistral.py index 00c5193..3899839 100644 --- a/revibe/core/llm/backend/mistral.py +++ b/revibe/core/llm/backend/mistral.py @@ -5,7 +5,7 @@ import os import re import types -from typing import TYPE_CHECKING, NamedTuple +from typing import TYPE_CHECKING, ClassVar, NamedTuple import httpx import mistralai @@ -133,7 +133,9 @@ def parse_content( reasoning_content=concat_reasoning if concat_reasoning else None, ) - def parse_tool_calls(self, tool_calls: list[mistralai.ToolCall] | None) -> list[ToolCall]: + def parse_tool_calls( + self, tool_calls: list[mistralai.ToolCall] | None + ) -> list[ToolCall]: if not tool_calls: return [] return [ @@ -152,6 +154,8 @@ def parse_tool_calls(self, tool_calls: list[mistralai.ToolCall] | None) -> list[ class MistralBackend: + supported_formats: ClassVar[list[str]] = ["native", "xml"] + def __init__(self, provider: ProviderConfigUnion, timeout: float = 720.0) -> None: self._client: mistralai.Mistral | None = None self._provider = provider diff --git a/revibe/core/llm/backend/openai.py b/revibe/core/llm/backend/openai.py index ba62259..7dd16dd 100644 --- a/revibe/core/llm/backend/openai.py +++ b/revibe/core/llm/backend/openai.py @@ -164,6 +164,8 @@ def parse_response(self, data: dict[str, Any]) -> LLMChunk: class OpenAIBackend: + supported_formats: ClassVar[list[str]] = ["native", "xml"] + def __init__( self, provider: ProviderConfigUnion, diff --git a/revibe/core/llm/backend/qwen/handler.py b/revibe/core/llm/backend/qwen/handler.py index fdcdd67..d8ced61 100644 --- a/revibe/core/llm/backend/qwen/handler.py +++ b/revibe/core/llm/backend/qwen/handler.py @@ -17,7 +17,7 @@ import json import os import types -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, ClassVar import httpx @@ -74,7 +74,7 @@ def parse(self, text: str) -> tuple[str, str]: end_idx = self._buffer.find("") if end_idx != -1: reasoning_content += self._buffer[:end_idx] - self._buffer = self._buffer[end_idx + 8:] # len("") = 8 + self._buffer = self._buffer[end_idx + 8 :] # len("") = 8 self._in_thinking_block = False else: # Still in thinking block, emit all as reasoning @@ -86,7 +86,7 @@ def parse(self, text: str) -> tuple[str, str]: start_idx = self._buffer.find("") if start_idx != -1: regular_content += self._buffer[:start_idx] - self._buffer = self._buffer[start_idx + 7:] # len("") = 7 + self._buffer = self._buffer[start_idx + 7 :] # len("") = 7 self._in_thinking_block = True else: # No thinking block, emit all as regular content @@ -98,6 +98,8 @@ def parse(self, text: str) -> tuple[str, str]: class QwenBackend: + supported_formats: ClassVar[list[str]] = ["native", "xml"] + """Backend for Qwen Code API (Alibaba Cloud DashScope). Supports both OAuth authentication (for Qwen CLI users) and @@ -199,9 +201,7 @@ async def _get_base_url(self) -> str: return QWEN_DEFAULT_BASE_URL.rstrip("/") - def _prepare_messages( - self, messages: list[LLMMessage] - ) -> list[dict[str, Any]]: + def _prepare_messages(self, messages: list[LLMMessage]) -> list[dict[str, Any]]: """Convert LLMMessages to OpenAI-compatible format.""" return [msg.model_dump(exclude_none=True) for msg in messages] @@ -313,9 +313,7 @@ async def _complete_with_retry( try: client = self._get_client() response = await client.post( - url, - headers=headers, - content=json.dumps(payload).encode("utf-8"), + url, headers=headers, content=json.dumps(payload).encode("utf-8") ) response.raise_for_status() @@ -323,9 +321,7 @@ async def _complete_with_retry( data = response.json() except json.JSONDecodeError as e: body_text = response.text[:200] if response.text else "(empty response)" - raise ValueError( - f"Invalid JSON response from API: {body_text}" - ) from e + raise ValueError(f"Invalid JSON response from API: {body_text}") from e # Parse response choices = data.get("choices", []) @@ -504,7 +500,9 @@ async def _complete_streaming_with_retry( ) raise ValueError(f"API returned error: {error_msg}") except json.JSONDecodeError: - raise ValueError(f"Unexpected API response: {body_text[:200]}") + raise ValueError( + f"Unexpected API response: {body_text[:200]}" + ) return async for line in response.aiter_lines(): @@ -532,7 +530,7 @@ async def _complete_streaming_with_retry( delim_index = line.find(":") key = line[:delim_index].strip() # Value starts after colon, with optional leading space - value = line[delim_index + 1:].lstrip() + value = line[delim_index + 1 :].lstrip() if key != "data": continue @@ -568,7 +566,7 @@ async def _complete_streaming_with_retry( # Handle cumulative content (some providers send full content) if new_text.startswith(full_content): - new_text = new_text[len(full_content):] + new_text = new_text[len(full_content) :] full_content = delta["content"] if new_text: @@ -600,12 +598,16 @@ async def _complete_streaming_with_retry( message=LLMMessage( role=Role.assistant, content=content if content else None, - reasoning_content=reasoning_content if reasoning_content else None, + reasoning_content=reasoning_content + if reasoning_content + else None, tool_calls=tool_calls, ), usage=LLMUsage( prompt_tokens=usage.get("prompt_tokens", 0) if usage else 0, - completion_tokens=usage.get("completion_tokens", 0) if usage else 0, + completion_tokens=usage.get("completion_tokens", 0) + if usage + else 0, ), ) diff --git a/revibe/core/llm/backend/qwen/oauth.py b/revibe/core/llm/backend/qwen/oauth.py index 3055ef4..1078cfd 100644 --- a/revibe/core/llm/backend/qwen/oauth.py +++ b/revibe/core/llm/backend/qwen/oauth.py @@ -278,4 +278,3 @@ async def get_credentials(self) -> QwenOAuthCredentials: await self.ensure_authenticated() assert self._credentials is not None return self._credentials - diff --git a/revibe/core/llm/format.py b/revibe/core/llm/format.py index 96c2290..1508326 100644 --- a/revibe/core/llm/format.py +++ b/revibe/core/llm/format.py @@ -2,9 +2,11 @@ from fnmatch import fnmatch from functools import lru_cache +import html import json import re from typing import TYPE_CHECKING, Any +from uuid import uuid4 from pydantic import BaseModel, ConfigDict, Field, ValidationError @@ -268,7 +270,256 @@ def create_failed_tool_response_message( ) -> LLMMessage: return LLMMessage( role=Role.tool, - tool_call_id=failed.call_id, - name=failed.tool_name, content=error_content, + tool_call_id=failed.call_id, + ) + + def is_tool_response(self, message: LLMMessage) -> bool: + """Check if message is a tool result.""" + return message.role == Role.tool + + +class XMLToolFormatHandler: + """Handles XML-based tool calling format. + + Tool definitions are embedded in the system prompt using XML tags. + The model responds with XML tool calls like: + + + bash + + ls -la + + + + This format is compatible with models that don't support native function calling. + """ + + # Regex patterns for parsing XML tool calls + TOOL_CALL_PATTERN = re.compile( + r'(.*?)', + re.DOTALL | re.IGNORECASE + ) + TOOL_NAME_PATTERN = re.compile( + r'(.*?)', + re.DOTALL | re.IGNORECASE + ) + PARAMETERS_PATTERN = re.compile( + r'(.*?)', + re.DOTALL | re.IGNORECASE + ) + PARAM_PATTERN = re.compile( + r'<(\w+)>(.*?)', + re.DOTALL + ) + + @property + def name(self) -> str: + return "xml" + + def get_available_tools( + self, tool_manager: ToolManager, config: VibeConfig + ) -> list[AvailableTool] | None: + """Return None - tools are embedded in system prompt for XML mode.""" + return None + + def get_tool_choice(self) -> StrToolChoice | AvailableTool | None: + """Return None - no tool choice in XML mode.""" + return None + + def get_tool_definitions_xml( + self, tool_manager: ToolManager, config: VibeConfig + ) -> str: + """Generate XML tool definitions for embedding in system prompt.""" + active_tools = get_active_tool_classes(tool_manager, config) + + if not active_tools: + return "" + + lines = [""] + + for tool_class in active_tools: + tool_name = tool_class.get_name() + description = tool_class.description + parameters = tool_class.get_parameters() + + lines.append(f' ') + lines.append(f' {html.escape(description)}') + + # Add parameters section + props = parameters.get("properties", {}) + required = parameters.get("required", []) + + if props: + lines.append(" ") + for param_name, param_info in props.items(): + param_type = param_info.get("type", "string") + param_desc = param_info.get("description", "") + is_required = param_name in required + req_str = "true" if is_required else "false" + + lines.append( + f' ' + ) + if param_desc: + lines.append(f' {html.escape(param_desc)}') + + # Add default value if present + if "default" in param_info: + default_val = param_info["default"] + lines.append(f' {html.escape(str(default_val))}') + + # Add enum values if present + if "enum" in param_info: + enum_vals = ", ".join(str(v) for v in param_info["enum"]) + lines.append(f' {html.escape(enum_vals)}') + + lines.append(" ") + lines.append(" ") + + lines.append(" ") + + lines.append("") + return "\n".join(lines) + + def process_api_response_message(self, message: Any) -> LLMMessage: + """Process API response - in XML mode, tool calls are in content.""" + return LLMMessage( + role=getattr(message, "role", Role.assistant), + content=getattr(message, "content", "") or "", + reasoning_content=getattr(message, "reasoning_content", None), + tool_calls=None, # XML mode doesn't use native tool_calls + ) + + def parse_message(self, message: LLMMessage) -> ParsedMessage: + """Parse XML tool calls from message content.""" + tool_calls = [] + content = message.content or "" + + # Find all blocks in content + for match in self.TOOL_CALL_PATTERN.finditer(content): + block = match.group(1) + + # Extract tool name + name_match = self.TOOL_NAME_PATTERN.search(block) + if not name_match: + continue + tool_name = name_match.group(1).strip() + + # Extract parameters + raw_args: dict[str, Any] = {} + params_match = self.PARAMETERS_PATTERN.search(block) + if params_match: + params_block = params_match.group(1) + # Parse individual parameters + for param_match in self.PARAM_PATTERN.finditer(params_block): + param_name = param_match.group(1) + param_value = param_match.group(2).strip() + # Try to parse as JSON for complex types + try: + raw_args[param_name] = json.loads(param_value) + except (json.JSONDecodeError, ValueError): + raw_args[param_name] = param_value + + # Generate a unique call ID + call_id = f"xml_{uuid4().hex[:12]}" + + tool_calls.append( + ParsedToolCall( + tool_name=tool_name, + raw_args=raw_args, + call_id=call_id, + ) + ) + + return ParsedMessage(tool_calls=tool_calls) + + def resolve_tool_calls( + self, parsed: ParsedMessage, tool_manager: ToolManager, config: VibeConfig + ) -> ResolvedMessage: + """Resolve parsed tool calls to actual tool instances.""" + resolved_calls = [] + failed_calls = [] + + active_tools = { + tool_class.get_name(): tool_class + for tool_class in get_active_tool_classes(tool_manager, config) + } + + for parsed_call in parsed.tool_calls: + tool_class = active_tools.get(parsed_call.tool_name) + if not tool_class: + failed_calls.append( + FailedToolCall( + tool_name=parsed_call.tool_name, + call_id=parsed_call.call_id, + error=f"Unknown tool '{parsed_call.tool_name}'", + ) + ) + continue + + args_model, _ = tool_class._get_tool_args_results() + try: + validated_args = args_model.model_validate(parsed_call.raw_args) + resolved_calls.append( + ResolvedToolCall( + tool_name=parsed_call.tool_name, + tool_class=tool_class, + validated_args=validated_args, + call_id=parsed_call.call_id, + ) + ) + except ValidationError as e: + failed_calls.append( + FailedToolCall( + tool_name=parsed_call.tool_name, + call_id=parsed_call.call_id, + error=f"Invalid arguments: {e}", + ) + ) + + return ResolvedMessage(tool_calls=resolved_calls, failed_calls=failed_calls) + + def create_tool_response_message( + self, tool_call: ResolvedToolCall, result_text: str + ) -> LLMMessage: + """Create a tool response message in XML format. + + Returns as a user message since XML mode doesn't use the tool role. + """ + xml_result = ( + f'\n' + f'success\n' + f'\n{result_text}\n\n' + f'' + ) + return LLMMessage( + role=Role.user, + content=xml_result, + ) + + def create_failed_tool_response_message( + self, failed: FailedToolCall, error_content: str + ) -> LLMMessage: + """Create a failed tool response message in XML format.""" + xml_result = ( + f'\n' + f'error\n' + f'\n{error_content}\n\n' + f'' + ) + return LLMMessage( + role=Role.user, + content=xml_result, + ) + + def is_tool_response(self, message: LLMMessage) -> bool: + """Check if message is an XML tool result.""" + return ( + message.role == Role.user + and message.content is not None + and message.content.strip().startswith(" BackendLike: ... async def __aexit__( self, diff --git a/revibe/core/model_config.py b/revibe/core/model_config.py index 71bab2b..0829432 100644 --- a/revibe/core/model_config.py +++ b/revibe/core/model_config.py @@ -2,10 +2,18 @@ from typing import Any -from pydantic import BaseModel, model_validator +from pydantic import BaseModel, Field, model_validator class ModelConfig(BaseModel): + """Configuration for an LLM model. + + Attributes: + supported_formats: List of supported tool calling formats. + - "native": Uses API's native function/tool calling + - "xml": Uses XML-based tool calling in prompts + Models default to supporting both formats. + """ name: str provider: str alias: str @@ -14,6 +22,7 @@ class ModelConfig(BaseModel): output_price: float = 0.0 context: int = 128000 max_output: int = 32000 + supported_formats: list[str] = Field(default_factory=lambda: ["native", "xml"]) @model_validator(mode="before") @classmethod @@ -135,7 +144,7 @@ def _default_alias_to_name(cls, data: Any) -> Any: context=1000000, max_output=32768, ), - ### Groq models + # Groq models ModelConfig( name="moonshotai/kimi-k2-instruct-0905", provider="groq", @@ -177,7 +186,7 @@ def _default_alias_to_name(cls, data: Any) -> Any: provider="huggingface", alias="glm-4.7", ), - ### Cerebras models + # Cerebras models ModelConfig( name="zai-glm-4.6", provider="cerebras", @@ -223,7 +232,7 @@ def _default_alias_to_name(cls, data: Any) -> Any: context=131072, max_output=40960, ), - ### Qwen Code models + # Qwen Code models ModelConfig( name="qwen3-coder-plus", provider="qwencode", @@ -243,4 +252,3 @@ def _default_alias_to_name(cls, data: Any) -> Any: max_output=65536, ) ] - diff --git a/revibe/core/system_prompt.py b/revibe/core/system_prompt.py index fce4d3d..5561159 100644 --- a/revibe/core/system_prompt.py +++ b/revibe/core/system_prompt.py @@ -425,8 +425,18 @@ def get_universal_system_prompt( sections.append(_get_os_system_prompt()) tool_prompts = [] active_tools = get_active_tool_classes(tool_manager, config) + + # Import ToolFormat here to check format mode + from revibe.core.config import ToolFormat + use_xml_prompts = config.tool_format == ToolFormat.XML + for tool_class in active_tools: - if prompt := tool_class.get_tool_prompt(): + # Use XML prompts when in XML mode, otherwise use standard prompts + if use_xml_prompts: + prompt = tool_class.get_xml_tool_prompt() + else: + prompt = tool_class.get_tool_prompt() + if prompt: tool_prompts.append(prompt) if tool_prompts: sections.append("\n---\n".join(tool_prompts)) @@ -439,6 +449,19 @@ def get_universal_system_prompt( if skills_section: sections.append(skills_section) + # Add XML tool definitions if using XML format + from revibe.core.config import ToolFormat + if config.tool_format == ToolFormat.XML: + from revibe import VIBE_ROOT + from revibe.core.llm.format import XMLToolFormatHandler + xml_handler = XMLToolFormatHandler() + tool_defs = xml_handler.get_tool_definitions_xml(tool_manager, config) + if tool_defs: + xml_prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "xml_tools.md" + xml_prompt_template = xml_prompt_path.read_text(encoding="utf-8") + xml_prompt = xml_prompt_template.format(tool_definitions=tool_defs) + sections.append(xml_prompt) + if config.include_project_context: is_dangerous, reason = is_dangerous_directory() if is_dangerous: diff --git a/revibe/core/tools/base.py b/revibe/core/tools/base.py index 757c6b3..debc2af 100644 --- a/revibe/core/tools/base.py +++ b/revibe/core/tools/base.py @@ -152,6 +152,31 @@ def get_tool_prompt(cls) -> str | None: return None + @classmethod + @functools.cache + def get_xml_tool_prompt(cls) -> str | None: + """Loads and returns the content of the tool's XML prompt file, if it exists. + + The XML prompt file is expected to be in a 'prompts' subdirectory relative to + the tool's source file, with the same name but a _xml.md extension + (e.g., bash.py -> prompts/bash_xml.md). + + Falls back to the standard prompt if no XML version exists. + """ + try: + class_file = inspect.getfile(cls) + class_path = Path(class_file) + prompt_dir = class_path.parent / "prompts" + xml_prompt_path = prompt_dir / f"{class_path.stem}_xml.md" + + if xml_prompt_path.exists(): + return xml_prompt_path.read_text("utf-8") + except (FileNotFoundError, TypeError, OSError): + pass + + # Fall back to standard prompt + return cls.get_tool_prompt() + async def invoke(self, **raw: Any) -> ToolResult: """Validate arguments and run the tool. Pattern checking is now handled by Agent._should_execute_tool. diff --git a/revibe/core/tools/builtins/bash.py b/revibe/core/tools/builtins/bash.py index 6b7ed36..1a114b3 100644 --- a/revibe/core/tools/builtins/bash.py +++ b/revibe/core/tools/builtins/bash.py @@ -5,7 +5,10 @@ import re import signal import sys -from typing import ClassVar, final +from typing import TYPE_CHECKING, ClassVar, final + +if TYPE_CHECKING: + from revibe.core.types import ToolCallEvent, ToolResultEvent from pydantic import BaseModel, Field @@ -16,6 +19,7 @@ ToolError, ToolPermission, ) +from revibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData from revibe.core.utils import is_windows @@ -163,9 +167,34 @@ class BashResult(BaseModel): returncode: int -class Bash(BaseTool[BashArgs, BashResult, BashToolConfig, BaseToolState]): +class Bash( + BaseTool[BashArgs, BashResult, BashToolConfig, BaseToolState], + ToolUIData[BashArgs, BashResult], +): description: ClassVar[str] = "Run a one-off bash command and capture its output." + @classmethod + def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay: + if not isinstance(event.args, BashArgs): + return ToolCallDisplay(summary="Bash") + + command = event.args.command.strip() + if len(command) > 30: + command = command[:27] + "..." + + summary = f"Bash ({command})" + return ToolCallDisplay(summary=summary) + + @classmethod + def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay: + if event.error: + return ToolResultDisplay(success=False, message=event.error) + return ToolResultDisplay(success=True, message="Completed") + + @classmethod + def get_status_text(cls) -> str: + return "Running command" + def check_allowlist_denylist(self, args: BashArgs) -> ToolPermission | None: command_parts = re.split(r"(?:&&|\|\||;|\|)", args.command) command_parts = [part.strip() for part in command_parts if part.strip()] diff --git a/revibe/core/tools/builtins/grep.py b/revibe/core/tools/builtins/grep.py index 70291e7..2ef7a0f 100644 --- a/revibe/core/tools/builtins/grep.py +++ b/revibe/core/tools/builtins/grep.py @@ -274,16 +274,13 @@ def _parse_output(self, stdout: str, max_matches: int) -> GrepResult: @classmethod def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay: if not isinstance(event.args, GrepArgs): - return ToolCallDisplay(summary="grep") + return ToolCallDisplay(summary="Grep") - summary = f"grep: '{event.args.pattern}'" - if event.args.path != ".": - summary += f" in {event.args.path}" - if event.args.max_matches: - summary += f" (max {event.args.max_matches} matches)" - if not event.args.use_default_ignore: - summary += " [no-ignore]" + pattern = event.args.pattern + if len(pattern) > 20: + pattern = pattern[:17] + "..." + summary = f"Grep ({pattern})" return ToolCallDisplay(summary=summary) @classmethod diff --git a/revibe/core/tools/builtins/prompts/bash_xml.md b/revibe/core/tools/builtins/prompts/bash_xml.md new file mode 100644 index 0000000..84192e4 --- /dev/null +++ b/revibe/core/tools/builtins/prompts/bash_xml.md @@ -0,0 +1,83 @@ +# Bash Tool – XML Format Guide + +## ⚠️ BASH IS THE TOOL OF LAST RESORT + +**STOP! Before using bash, check if a dedicated tool exists:** + +| Task | DO NOT USE BASH | USE THIS INSTEAD | +|------|-----------------|------------------| +| Searching files | ❌ `find`, `grep`, `rg`, `Select-String` | ✅ `grep` tool | +| Reading files | ❌ `cat`, `type`, `Get-Content` | ✅ `read_file` tool | +| Editing files | ❌ `sed`, `awk`, shell redirects | ✅ `search_replace` tool | +| Creating files | ❌ `echo >`, `touch` | ✅ `write_file` tool | + +## When to ACTUALLY Use Bash + +**ONLY** use bash for: +- Git commands: `git status`, `git log`, `git diff`, `git commit` +- Directory listing: `dir` (Windows) or `ls` (Unix) +- System info: `pwd`, `whoami` +- Network probes: `curl -I ` + +## XML Tool Call Format + +```xml + +bash + +your shell command here +30 + + +``` + +## Parameters +- `command` *(required)* – The shell command to execute +- `timeout` *(optional)* – Override default timeout in seconds + +## Platform Compatibility + +Check the OS in the system prompt and use appropriate commands: +- **Windows**: `dir`, `type`, `where` +- **Unix/Mac**: `ls`, `cat`, `which` + +## Example XML Calls (VALID uses of bash) + +```xml + + +bash + +git status -sb + + + + + +bash + +git log --oneline -5 + + +``` + +## ❌ INVALID Uses (Use dedicated tools instead) + +```xml + + + + + +grep + +pattern +. + + +``` diff --git a/revibe/core/tools/builtins/prompts/grep_xml.md b/revibe/core/tools/builtins/prompts/grep_xml.md new file mode 100644 index 0000000..1784e15 --- /dev/null +++ b/revibe/core/tools/builtins/prompts/grep_xml.md @@ -0,0 +1,77 @@ +# Grep Tool – XML Format Guide + +## ⚠️ ALWAYS USE THIS TOOL FOR SEARCHING + +**NEVER use `bash` with `grep`, `find`, `rg`, `Select-String`, or any shell search commands.** +This `grep` tool is cross-platform, faster, and automatically handles: +- `.gitignore` rules +- Binary file exclusions +- Common junk directories (node_modules, .git, __pycache__, etc.) + +Use `grep` for fast, recursive regex searches across the project. + +## XML Tool Call Format + +```xml + +grep + +your regex pattern +directory or file to search +100 +true + + +``` + +## Parameters +- `pattern` *(required)* – Regex pattern (smart-case) +- `path` *(optional, default ".")* – File or directory to search +- `max_matches` *(optional, default 100)* – Cap number of matches +- `use_default_ignore` *(optional, default true)* – Use .gitignore rules + +## When to Use (PREFER THIS OVER BASH) +- **Finding files containing text** → Use `grep`, NOT `bash` with `find | xargs grep` +- **Searching for patterns** → Use `grep`, NOT `bash` with shell grep commands +- **Locating function/class definitions** → Use `grep` +- **Finding symbol usage across codebase** → Use `grep` +- **Discovering TODOs, feature flags, config references** → Use `grep` +- **Investigating errors in logs** → Use `grep` +- **Finding files by name patterns** → Use `grep` with filename patterns + +## Example XML Calls + +```xml + + +grep + +def build_payload +revibe/core + + + + + +grep + +TODO +. +50 + + + + + +grep + +\bToolManager\b +revibe + + +``` + +## Tips +- Narrow `path` for faster, focused results +- Use word boundaries `\b` for precision +- If `was_truncated=True`, increase `max_matches` or narrow scope diff --git a/revibe/core/tools/builtins/prompts/read_file_xml.md b/revibe/core/tools/builtins/prompts/read_file_xml.md new file mode 100644 index 0000000..caf3859 --- /dev/null +++ b/revibe/core/tools/builtins/prompts/read_file_xml.md @@ -0,0 +1,63 @@ +# Read File Tool – XML Format Guide + +`read_file` is the safest way to inspect file contents. It streams UTF-8 text with size guards. + +## XML Tool Call Format + +```xml + +read_file + +path/to/file +0 +100 + + +``` + +## Parameters +- `path` *(required)* – Relative or absolute file path +- `offset` *(optional, default 0)* – 0-based line to start reading from +- `limit` *(optional)* – Maximum lines to return + +## Output +- `content` – Raw text chunk +- `lines_read` – Number of lines returned +- `was_truncated` – `True` if more content remains + +## Example XML Calls + +```xml + + +read_file + +revibe/core/agent.py + + + + + +read_file + +src/service.py +120 +80 + + + + + +read_file + +logs/run.log +0 +500 + + +``` + +## Best Practices +- Always inspect a file with `read_file` before modifying it +- Set `offset`/`limit` to keep responses concise +- If `was_truncated` is true, continue reading with updated offset diff --git a/revibe/core/tools/builtins/prompts/search_replace_xml.md b/revibe/core/tools/builtins/prompts/search_replace_xml.md new file mode 100644 index 0000000..8d74b73 --- /dev/null +++ b/revibe/core/tools/builtins/prompts/search_replace_xml.md @@ -0,0 +1,87 @@ +# Search & Replace Tool – XML Format Guide + +Use `search_replace` for deterministic file edits with SEARCH/REPLACE blocks. + +## XML Tool Call Format + +```xml + +search_replace + +path/to/file + +<<<<<<< SEARCH +exact text to replace +======= +new text +>>>>>>> REPLACE + + + +``` + +## Parameters +- `file_path` *(required)* – Target file path +- `content` *(required)* – SEARCH/REPLACE blocks + +## Block Format +``` +<<<<<<< SEARCH + +======= + +>>>>>>> REPLACE +``` + +## Key Rules +- **Exact match required:** Whitespace, indentation, and newlines must match +- **Multiple blocks:** Stack blocks in single `content` payload +- **First occurrence:** If text appears multiple times, only first is replaced + +## Example XML Calls + +```xml + + +search_replace + +revibe/core/config.py + +<<<<<<< SEARCH +DEFAULT_TIMEOUT = 30 +======= +DEFAULT_TIMEOUT = 60 +>>>>>>> REPLACE + + + + + + +search_replace + +src/utils.py + +<<<<<<< SEARCH +def old_function(): + pass +======= +def new_function(): + return True +>>>>>>> REPLACE + +<<<<<<< SEARCH +CONSTANT = "old" +======= +CONSTANT = "new" +>>>>>>> REPLACE + + + +``` + +## Best Practices +1. **Inspect first** – Always use `read_file` before editing +2. **Keep blocks tight** – Include only necessary context for uniqueness +3. **One concern per block** – Separate unrelated edits +4. **Order matters** – Later blocks see earlier modifications diff --git a/revibe/core/tools/builtins/prompts/todo_xml.md b/revibe/core/tools/builtins/prompts/todo_xml.md new file mode 100644 index 0000000..b96dcdb --- /dev/null +++ b/revibe/core/tools/builtins/prompts/todo_xml.md @@ -0,0 +1,84 @@ +# Todo Tool – XML Format Guide + +Use the `todo` tool to track multi-step assignments with a live checklist. + +## XML Tool Call Format + +### Read todos +```xml + +todo + +read + + +``` + +### Write todos +```xml + +todo + +write +[ + {"id": "1", "content": "Task description", "status": "pending", "priority": "high"} +] + + +``` + +## Parameters +- `action` *(required)* – `"read"` or `"write"` +- `todos` *(required for write)* – JSON array of todo items + +## Todo Item Fields +| Field | Type | Notes | +| ----- | ---- | ----- | +| `id` | str | Unique identifier | +| `content` | str | Clear, actionable description | +| `status` | `pending` \| `in_progress` \| `completed` \| `cancelled` | Default `pending` | +| `priority` | `low` \| `medium` \| `high` | Default `medium` | + +## Example XML Calls + +```xml + + +todo + +read + + + + + +todo + +write +[ + {"id": "1", "content": "Review existing code", "status": "pending", "priority": "high"}, + {"id": "2", "content": "Implement feature X", "status": "pending", "priority": "high"}, + {"id": "3", "content": "Write tests", "status": "pending", "priority": "medium"} +] + + + + + +todo + +write +[ + {"id": "1", "content": "Review existing code", "status": "completed", "priority": "high"}, + {"id": "2", "content": "Implement feature X", "status": "in_progress", "priority": "high"}, + {"id": "3", "content": "Write tests", "status": "pending", "priority": "medium"} +] + + +``` + +## Best Practices +- Initialize todos early after understanding requirements +- Keep only one task `in_progress` at a time +- Update todos as you discover new subtasks +- Mark tasks `completed` only when fully done diff --git a/revibe/core/tools/builtins/prompts/write_file_xml.md b/revibe/core/tools/builtins/prompts/write_file_xml.md new file mode 100644 index 0000000..de8b390 --- /dev/null +++ b/revibe/core/tools/builtins/prompts/write_file_xml.md @@ -0,0 +1,77 @@ +# Write File Tool – XML Format Guide + +Use `write_file` to create new files or fully overwrite existing ones. + +## XML Tool Call Format + +```xml + +write_file + +path/to/file +file content here +false + + +``` + +## Parameters +- `path` *(required)* – Target file path +- `content` *(required)* – UTF-8 text to write +- `overwrite` *(optional, default false)* – Must be `true` to replace existing file + +## Safety Guarantees +- Size limit enforced (~64 KB) +- Paths outside project root are blocked +- Overwrite guard prevents accidental data loss +- Parent directories created automatically + +## Example XML Calls + +```xml + + +write_file + +src/new_module.py + +"""New module for feature X.""" + +def helper(): + return True + + + + + + +write_file + +config/settings.json + +{ + "debug": true, + "version": "2.0" +} + +true + + + + + +write_file + +docs/api/README.md +# API Documentation + +This folder contains API docs. + + + +``` + +## Best Practices +- Always `read_file` before overwriting +- Prefer `search_replace` for partial edits +- `write_file` replaces entire file content diff --git a/revibe/core/tools/builtins/prompts/xml_tools.md b/revibe/core/tools/builtins/prompts/xml_tools.md new file mode 100644 index 0000000..d01940d --- /dev/null +++ b/revibe/core/tools/builtins/prompts/xml_tools.md @@ -0,0 +1,67 @@ +# Tool Usage Instructions + +You have access to tools that can help you complete tasks. To use a tool, respond with an XML tool call block. + +## Tool Call Format + +Use the following XML format to call a tool: + +```xml + +tool_name_here + +parameter_value + + +``` + +You can make multiple tool calls in a single response by including multiple `` blocks. + +## ⚠️ Tool Priority (ALWAYS follow this order) + +**NEVER use `bash` for tasks that have dedicated tools:** + +| Task | Use This Tool | NOT bash with... | +|------|---------------|------------------| +| Search/find text | `grep` | find, grep, rg, Select-String | +| Read files | `read_file` | cat, type, Get-Content | +| Edit files | `search_replace` | sed, awk, redirects | +| Create files | `write_file` | echo >, touch | + +**Only use `bash` for:** git commands, directory listing, system info, network probes + +## Important Rules + +1. **Use exact names**: Always use the exact tool and parameter names as specified in the tool definitions below +2. **Include required parameters**: All parameters marked as `required="true"` must be included +3. **Wait for results**: Tool results will be provided in `` blocks before you can use the output +4. **One thought at a time**: Explain your reasoning briefly, then make the tool call +5. **Prefer dedicated tools**: Use `grep`, `read_file`, `write_file`, `search_replace` instead of bash commands + +## Tool Result Format + +After a tool is executed, you will receive results in this format: + +```xml + +success + +... tool output here ... + + +``` + +Or in case of an error: + +```xml + +error + +... error message ... + + +``` + +## Available Tools + +{tool_definitions} diff --git a/revibe/core/tools/builtins/read_file.py b/revibe/core/tools/builtins/read_file.py index 36864e7..75d9cd4 100644 --- a/revibe/core/tools/builtins/read_file.py +++ b/revibe/core/tools/builtins/read_file.py @@ -177,17 +177,10 @@ def _update_state_history(self, file_path: Path) -> None: @classmethod def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay: if not isinstance(event.args, ReadFileArgs): - return ToolCallDisplay(summary="read_file") - - summary = f"read_file: {event.args.path}" - if event.args.offset > 0 or event.args.limit is not None: - parts = [] - if event.args.offset > 0: - parts.append(f"from line {event.args.offset}") - if event.args.limit is not None: - parts.append(f"limit {event.args.limit} lines") - summary += f" ({', '.join(parts)})" + return ToolCallDisplay(summary="Read") + path = Path(event.args.path).name + summary = f"Read ({path})" return ToolCallDisplay(summary=summary) @classmethod diff --git a/revibe/core/tools/builtins/search_replace.py b/revibe/core/tools/builtins/search_replace.py index e47eaea..03dd516 100644 --- a/revibe/core/tools/builtins/search_replace.py +++ b/revibe/core/tools/builtins/search_replace.py @@ -80,15 +80,11 @@ class SearchReplace( @classmethod def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay: if not isinstance(event.args, SearchReplaceArgs): - return ToolCallDisplay(summary="Invalid arguments") + return ToolCallDisplay(summary="Patch") - args = event.args - blocks = cls._parse_search_replace_blocks(args.content) - - return ToolCallDisplay( - summary=f"Patching {args.file_path} ({len(blocks)} blocks)", - content=args.content, - ) + path = Path(event.args.file_path).name + summary = f"Patch ({path})" + return ToolCallDisplay(summary=summary, content=event.args.content) @classmethod def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay: diff --git a/revibe/core/tools/builtins/write_file.py b/revibe/core/tools/builtins/write_file.py index f17d055..559b3a4 100644 --- a/revibe/core/tools/builtins/write_file.py +++ b/revibe/core/tools/builtins/write_file.py @@ -53,14 +53,11 @@ class WriteFile( @classmethod def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay: if not isinstance(event.args, WriteFileArgs): - return ToolCallDisplay(summary="Invalid arguments") + return ToolCallDisplay(summary="Write") - args = event.args - - return ToolCallDisplay( - summary=f"Writing {args.path}{' (overwrite)' if args.overwrite else ''}", - content=args.content, - ) + path = Path(event.args.path).name + summary = f"Write ({path})" + return ToolCallDisplay(summary=summary, content=event.args.content) @classmethod def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay: diff --git a/revibe/core/utils.py b/revibe/core/utils.py index cc9afb3..9574dad 100644 --- a/revibe/core/utils.py +++ b/revibe/core/utils.py @@ -264,6 +264,7 @@ def run_sync[T](coro: Coroutine[Any, Any, T]) -> T: """ try: asyncio.get_running_loop() + def _run() -> T: return asyncio.run(coro) @@ -276,3 +277,26 @@ def _run() -> T: def is_windows() -> bool: return sys.platform == "win32" + + +def redact_xml_tool_calls(text: str) -> str: + """Remove ... blocks from text. + + Supports partially written tags by hiding everything from the start of an open + blocks + processed = re.sub(r"]*>.*?", "", text, flags=re.DOTALL) + + # Hide any trailing open " not in processed[last_open:]: + processed = processed[:last_open] + + return processed diff --git a/revibe/setup/onboarding/screens/provider_selection.py b/revibe/setup/onboarding/screens/provider_selection.py index 13c1dce..786ab8e 100644 --- a/revibe/setup/onboarding/screens/provider_selection.py +++ b/revibe/setup/onboarding/screens/provider_selection.py @@ -130,4 +130,3 @@ def action_select(self) -> None: rprint(f"[yellow]Warning: Could not save provider selection: {e}[/]") # Continue anyway - the API key screen will handle the mismatch self.action_next() - diff --git a/tests/acp/test_acp.py b/tests/acp/test_acp.py index b0fd3ff..5dd3723 100644 --- a/tests/acp/test_acp.py +++ b/tests/acp/test_acp.py @@ -5,7 +5,7 @@ import json import os from pathlib import Path -from typing import Any +from typing import Any, cast from acp import ( InitializeRequest, @@ -457,10 +457,10 @@ async def test_agent_message_chunk_structure(self, vibe_home_dir: Path) -> None: assert response.params is not None assert response.params.update.sessionUpdate == "agent_message_chunk" - assert response.params.update.content is not None - assert response.params.update.content.type == "text" - assert response.params.update.content.text is not None - assert response.params.update.content.text == "Hi" + assert response.params.update.content is not None # type: ignore[union-attr] + assert response.params.update.content.type == "text" # type: ignore[union-attr] + assert response.params.update.content.text is not None # type: ignore[union-attr] + assert response.params.update.content.text == "Hi" # type: ignore[union-attr] @pytest.mark.asyncio async def test_tool_call_update_structure(self, vibe_home_dir: Path) -> None: @@ -666,9 +666,9 @@ async def test_tool_call_update_approved_structure( and r.params is not None and r.params.update is not None and r.params.update.sessionUpdate == "tool_call_update" - and r.params.update.toolCallId + and r.params.update.toolCallId # type: ignore[union-attr] == (permission_request.params.toolCall.toolCallId) - and r.params.update.status == "completed" + and r.params.update.status == "completed" # type: ignore[union-attr] ), None, ) @@ -728,9 +728,9 @@ async def test_tool_call_update_rejected_structure( and r.method == "session/update" and r.params is not None and r.params.update.sessionUpdate == "tool_call_update" - and r.params.update.toolCallId + and r.params.update.toolCallId # type: ignore[union-attr] == (permission_request.params.toolCall.toolCallId) - and r.params.update.status == "failed" + and r.params.update.status == "failed" # type: ignore[union-attr] ), None, ) @@ -787,7 +787,7 @@ async def test_tool_call_in_progress_update_structure( if isinstance(r, UpdateJsonRpcNotification) and r.params is not None and r.params.update.sessionUpdate == "tool_call_update" - and r.params.update.status == "in_progress" + and r.params.update.status == "in_progress" # type: ignore[union-attr] ] assert len(in_progress_calls) > 0, ( @@ -848,9 +848,9 @@ async def test_tool_call_result_update_failure_structure( if isinstance(r, UpdateJsonRpcNotification) and r.params is not None and r.params.update.sessionUpdate == "tool_call_update" - and r.params.update.status == "failed" - and r.params.update.rawOutput is not None - and r.params.update.toolCallId is not None + and r.params.update.status == "failed" # type: ignore[union-attr] + and r.params.update.rawOutput is not None # type: ignore[union-attr] + and r.params.update.toolCallId is not None # type: ignore[union-attr] ), None, ) @@ -921,9 +921,9 @@ async def test_tool_call_update_cancelled_structure( and r.method == "session/update" and r.params is not None and r.params.update.sessionUpdate == "tool_call_update" - and r.params.update.toolCallId + and r.params.update.toolCallId # type: ignore[union-attr] == (permission_request.params.toolCall.toolCallId) - and r.params.update.status == "failed" + and r.params.update.status == "failed" # type: ignore[union-attr] ), None, ) diff --git a/tests/backend/data/groq.py b/tests/backend/data/groq.py index 37c14aa..80cf54b 100644 --- a/tests/backend/data/groq.py +++ b/tests/backend/data/groq.py @@ -151,4 +151,4 @@ {"message": "", "usage": {"prompt_tokens": 15, "completion_tokens": 18}}, ], ) -] \ No newline at end of file +] diff --git a/tests/backend/data/openai.py b/tests/backend/data/openai.py index 7fedaf9..3b64b5d 100644 --- a/tests/backend/data/openai.py +++ b/tests/backend/data/openai.py @@ -147,4 +147,4 @@ {"message": "", "usage": {"prompt_tokens": 20, "completion_tokens": 25}}, ], ) -] \ No newline at end of file +] diff --git a/tests/core/test_config_migration.py b/tests/core/test_config_migration.py index 1b15d6a..4e95f20 100644 --- a/tests/core/test_config_migration.py +++ b/tests/core/test_config_migration.py @@ -8,7 +8,7 @@ from revibe.core import config from revibe.core.config import VibeConfig -from revibe.core.paths.config_paths import unlock_config_paths, ConfigPath +from revibe.core.paths.config_paths import ConfigPath, unlock_config_paths def _restore_dump_config(config_file: Path): diff --git a/tests/stubs/fake_backend.py b/tests/stubs/fake_backend.py index dacb0a8..794a6e7 100644 --- a/tests/stubs/fake_backend.py +++ b/tests/stubs/fake_backend.py @@ -14,6 +14,8 @@ class FakeBackend: + supported_formats: list[str] = ["native", "xml"] + """Minimal async backend stub to drive Agent.act without network. Provide a finite sequence of LLMResult objects to be returned by diff --git a/tests/test_agent_observer_streaming.py b/tests/test_agent_observer_streaming.py index 6f18604..2c91011 100644 --- a/tests/test_agent_observer_streaming.py +++ b/tests/test_agent_observer_streaming.py @@ -358,11 +358,11 @@ def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None: assert middleware.after_calls == 0 assert isinstance(events[-1], ToolResultEvent) # Add type check for skipped and skip_reason attributes - if hasattr(events[-1], 'skipped'): - assert events[-1].skipped is True - if hasattr(events[-1], 'skip_reason'): - assert events[-1].skip_reason is not None - assert "" in events[-1].skip_reason + if hasattr(events[-1], "skipped"): + assert events[-1].skipped is True # type: ignore[union-attr] + if hasattr(events[-1], "skip_reason"): + assert events[-1].skip_reason is not None # type: ignore[union-attr] + assert "" in events[-1].skip_reason # type: ignore[union-attr] assert agent.interaction_logger.save_interaction.await_count == 1