v2.14.0 (#743)
Co-authored-by: Alexis Tacnet <alexis@mistral.ai> Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com> Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com> Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Quentin <quentin.torroba@mistral.ai> Co-authored-by: Val <102326092+vdeva@users.noreply.github.com> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: p.vezia <166131032+le-codeur-rapide@users.noreply.github.com> Co-authored-by: Hiba Chaabnia <Hiba-Chaabnia@users.noreply.github.com> Co-authored-by: Nikhil Bhima <nikhilbhima@users.noreply.github.com> Co-authored-by: Nkipohcs <Nkipohcs@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
ad0d5c9520
commit
3f8487f761
197 changed files with 10819 additions and 2830 deletions
|
|
@ -3,4 +3,4 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
|
||||
VIBE_ROOT = Path(__file__).parent
|
||||
__version__ = "2.13.0"
|
||||
__version__ = "2.14.0"
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ from vibe.core.proxy_setup import (
|
|||
unset_proxy_var,
|
||||
)
|
||||
from vibe.core.session.saved_sessions import (
|
||||
delete_saved_session,
|
||||
update_saved_session_title,
|
||||
update_saved_session_title_at_path,
|
||||
)
|
||||
|
|
@ -187,6 +188,12 @@ logger = logging.getLogger("vibe")
|
|||
NON_INTERACTIVE_DISABLED_TOOLS = ["ask_user_question", "exit_plan_mode"]
|
||||
|
||||
|
||||
def _merge_non_interactive_disabled_tools(config: VibeConfig) -> None:
|
||||
for tool in NON_INTERACTIVE_DISABLED_TOOLS:
|
||||
if tool not in config.disabled_tools:
|
||||
config.disabled_tools.append(tool)
|
||||
|
||||
|
||||
class ForkSessionParams(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
|
|
@ -202,6 +209,14 @@ class SessionSetTitleRequest(BaseModel):
|
|||
title: str = Field(min_length=1)
|
||||
|
||||
|
||||
class SessionDeleteRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
||||
|
||||
session_id: str = Field(
|
||||
validation_alias=AliasChoices("session_id", "sessionId"), min_length=1
|
||||
)
|
||||
|
||||
|
||||
class TelemetrySendNotification(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
|
@ -496,7 +511,8 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
"browser-auth-delegated": {
|
||||
"attemptId": attempt.process_id,
|
||||
"expiresAt": (
|
||||
attempt.expires_at.astimezone(UTC)
|
||||
attempt.expires_at
|
||||
.astimezone(UTC)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
),
|
||||
|
|
@ -578,7 +594,8 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
|
||||
def _load_config(self) -> VibeConfig:
|
||||
try:
|
||||
config = VibeConfig.load(disabled_tools=NON_INTERACTIVE_DISABLED_TOOLS)
|
||||
config = VibeConfig.load()
|
||||
_merge_non_interactive_disabled_tools(config)
|
||||
config.tool_paths.extend(self._get_acp_tool_overrides())
|
||||
return config
|
||||
except MissingAPIKeyError as e:
|
||||
|
|
@ -648,6 +665,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
async def new_session(
|
||||
self,
|
||||
cwd: str,
|
||||
additional_directories: list[str] | None = None,
|
||||
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> NewSessionResponse:
|
||||
|
|
@ -689,9 +707,9 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
if self.client_capabilities.fs:
|
||||
fs = self.client_capabilities.fs
|
||||
if fs.read_text_file:
|
||||
overrides.append("read_file")
|
||||
overrides.append("read")
|
||||
if fs.write_text_file:
|
||||
overrides.extend(["write_file", "search_replace"])
|
||||
overrides.extend(["write_file", "edit"])
|
||||
|
||||
return [
|
||||
VIBE_ROOT / "acp" / "tools" / "builtins" / f"{override}.py"
|
||||
|
|
@ -782,6 +800,13 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
|
||||
return None
|
||||
|
||||
def _find_live_session_by_requested_session_id(
|
||||
self, session_id: str
|
||||
) -> AcpSessionLoop | None:
|
||||
return self.sessions.get(
|
||||
session_id
|
||||
) or self._find_acp_session_by_vibe_session_id(session_id)
|
||||
|
||||
def _load_session_logging_config(self) -> SessionLoggingConfig:
|
||||
try:
|
||||
return VibeConfig.load().session_logging
|
||||
|
|
@ -904,6 +929,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
self,
|
||||
cwd: str,
|
||||
session_id: str,
|
||||
additional_directories: list[str] | None = None,
|
||||
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> LoadSessionResponse | None:
|
||||
|
|
@ -969,10 +995,8 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
return True
|
||||
|
||||
async def _reload_config(self, session: AcpSessionLoop) -> None:
|
||||
new_config = VibeConfig.load(
|
||||
tool_paths=session.agent_loop.config.tool_paths,
|
||||
disabled_tools=NON_INTERACTIVE_DISABLED_TOOLS,
|
||||
)
|
||||
new_config = VibeConfig.load(tool_paths=session.agent_loop.config.tool_paths)
|
||||
_merge_non_interactive_disabled_tools(new_config)
|
||||
await session.agent_loop.reload_with_initial_messages(base_config=new_config)
|
||||
|
||||
async def _apply_model_change(self, session: AcpSessionLoop, model_id: str) -> bool:
|
||||
|
|
@ -1040,7 +1064,11 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
|
||||
@override
|
||||
async def list_sessions(
|
||||
self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any
|
||||
self,
|
||||
additional_directories: list[str] | None = None,
|
||||
cursor: str | None = None,
|
||||
cwd: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ListSessionsResponse:
|
||||
try:
|
||||
config = VibeConfig.load()
|
||||
|
|
@ -1190,7 +1218,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
text_prompt = f"{text_prompt}{separator}{block_prompt}"
|
||||
case "resource_link":
|
||||
# NOTE: we currently keep more information than just the URI
|
||||
# making it more detailed than the output of the read_file tool.
|
||||
# making it more detailed than the output of the read tool.
|
||||
# This is OK, but might be worth testing how it affect performance.
|
||||
fields = {
|
||||
"uri": block.uri,
|
||||
|
|
@ -1370,6 +1398,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
self,
|
||||
cwd: str,
|
||||
session_id: str,
|
||||
additional_directories: list[str] | None = None,
|
||||
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ForkSessionResponse:
|
||||
|
|
@ -1414,6 +1443,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
self,
|
||||
cwd: str,
|
||||
session_id: str,
|
||||
additional_directories: list[str] | None = None,
|
||||
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ResumeSessionResponse:
|
||||
|
|
@ -1433,6 +1463,20 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
session_id=session_id, update=SessionInfoUpdate(**update_kwargs)
|
||||
)
|
||||
|
||||
async def _delete_saved_session(self, session_id: str) -> None:
|
||||
try:
|
||||
await delete_saved_session(session_id, self._load_session_logging_config())
|
||||
except ValueError as exc:
|
||||
raise SessionNotFoundError(session_id) from exc
|
||||
|
||||
def _live_session_has_saved_history(self, session: AcpSessionLoop) -> bool:
|
||||
logger = session.agent_loop.session_logger
|
||||
return (
|
||||
logger.enabled
|
||||
and logger.session_dir is not None
|
||||
and logger.metadata_filepath.exists()
|
||||
)
|
||||
|
||||
async def _persist_live_session_title(
|
||||
self, session: AcpSessionLoop, title: str
|
||||
) -> dict[str, Any] | None:
|
||||
|
|
@ -1457,6 +1501,40 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
f"Invalid ACP session title request: {exc}"
|
||||
) from exc
|
||||
|
||||
async def _handle_session_delete(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
request = SessionDeleteRequest.model_validate(params)
|
||||
except ValidationError as exc:
|
||||
raise InvalidRequestError(
|
||||
f"Invalid ACP session delete request: {exc}"
|
||||
) from exc
|
||||
|
||||
live_session = self._find_live_session_by_requested_session_id(
|
||||
request.session_id
|
||||
)
|
||||
if live_session is None:
|
||||
await self._delete_saved_session(request.session_id)
|
||||
return {}
|
||||
|
||||
saved_session_id = live_session.agent_loop.session_id
|
||||
has_saved_history = self._live_session_has_saved_history(live_session)
|
||||
|
||||
await self.close_session(live_session.id)
|
||||
|
||||
if not has_saved_history:
|
||||
return {}
|
||||
|
||||
try:
|
||||
await delete_saved_session(
|
||||
saved_session_id, self._load_session_logging_config()
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise InternalError(
|
||||
f"Failed to delete saved session {saved_session_id}: {exc}"
|
||||
) from exc
|
||||
|
||||
return {}
|
||||
|
||||
async def _handle_session_set_title(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
request = SessionSetTitleRequest.model_validate(params)
|
||||
|
|
@ -1465,9 +1543,9 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
f"Invalid ACP session title request: {exc}"
|
||||
) from exc
|
||||
|
||||
live_session = self.sessions.get(
|
||||
live_session = self._find_live_session_by_requested_session_id(
|
||||
request.session_id
|
||||
) or self._find_acp_session_by_vibe_session_id(request.session_id)
|
||||
)
|
||||
if live_session is None:
|
||||
try:
|
||||
metadata = await update_saved_session_title(
|
||||
|
|
@ -1563,6 +1641,9 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
if method == "session/set_title":
|
||||
return await self._handle_session_set_title(params)
|
||||
|
||||
if method == "session/delete":
|
||||
return await self._handle_session_delete(params)
|
||||
|
||||
raise NotImplementedMethodError(method)
|
||||
|
||||
@override
|
||||
|
|
@ -1681,10 +1762,8 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
|
||||
async def _reload_session_config(self, session: AcpSessionLoop) -> None:
|
||||
"""Reload config from disk and reinitialize the agent loop."""
|
||||
new_config = VibeConfig.load(
|
||||
tool_paths=session.agent_loop.config.tool_paths,
|
||||
disabled_tools=NON_INTERACTIVE_DISABLED_TOOLS,
|
||||
)
|
||||
new_config = VibeConfig.load(tool_paths=session.agent_loop.config.tool_paths)
|
||||
_merge_non_interactive_disabled_tools(new_config)
|
||||
await session.agent_loop.reload_with_initial_messages(base_config=new_config)
|
||||
|
||||
async def _handle_reload(
|
||||
|
|
|
|||
|
|
@ -18,28 +18,22 @@ from vibe.acp.tools.session_update import (
|
|||
resolve_kind,
|
||||
)
|
||||
from vibe.core.tools.base import BaseToolState, ToolError
|
||||
from vibe.core.tools.builtins.search_replace import (
|
||||
SearchReplace as CoreSearchReplaceTool,
|
||||
SearchReplaceArgs,
|
||||
SearchReplaceResult,
|
||||
)
|
||||
from vibe.core.tools.builtins.edit import Edit as CoreEditTool, EditArgs, EditResult
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
from vibe.core.utils.io import ReadSafeResult, normalize_newlines
|
||||
|
||||
|
||||
class AcpSearchReplaceState(BaseToolState, AcpToolState):
|
||||
file_backup_content: str | None = None
|
||||
class AcpEditState(BaseToolState, AcpToolState):
|
||||
pass
|
||||
|
||||
|
||||
class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
|
||||
state: AcpSearchReplaceState
|
||||
prompt_path = (
|
||||
VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "search_replace.md"
|
||||
)
|
||||
class Edit(CoreEditTool, BaseAcpTool[AcpEditState]):
|
||||
state: AcpEditState
|
||||
prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "edit.md"
|
||||
|
||||
@classmethod
|
||||
def _get_tool_state_class(cls) -> type[AcpSearchReplaceState]:
|
||||
return AcpSearchReplaceState
|
||||
def _get_tool_state_class(cls) -> type[AcpEditState]:
|
||||
return AcpEditState
|
||||
|
||||
async def _read_file(self, file_path: Path) -> ReadSafeResult:
|
||||
client, session_id = self._load_state()
|
||||
|
|
@ -49,58 +43,44 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
|
|||
session_id=session_id, path=str(file_path)
|
||||
)
|
||||
except Exception as e:
|
||||
raise ToolError(f"Unexpected error reading {file_path}: {e}") from e
|
||||
raise ToolError(f"Error reading {file_path}: {e}") from e
|
||||
|
||||
self.state.file_backup_content = response.content
|
||||
text, newline = normalize_newlines(response.content)
|
||||
return ReadSafeResult(text, "utf-8", newline)
|
||||
|
||||
async def _backup_file(self, file_path: Path) -> None:
|
||||
if self.state.file_backup_content is None:
|
||||
return
|
||||
|
||||
await self._write_via_client(
|
||||
file_path.with_suffix(file_path.suffix + ".bak"),
|
||||
self.state.file_backup_content,
|
||||
)
|
||||
|
||||
async def _write_file(
|
||||
self, file_path: Path, content: str, encoding: str, newline: str
|
||||
) -> None:
|
||||
await self._write_via_client(file_path, content.replace("\n", newline))
|
||||
|
||||
async def _write_via_client(self, file_path: Path, content: str) -> None:
|
||||
client, session_id = self._load_state()
|
||||
|
||||
try:
|
||||
await client.write_text_file(
|
||||
session_id=session_id, path=str(file_path), content=content
|
||||
session_id=session_id,
|
||||
path=str(file_path),
|
||||
content=content.replace("\n", newline),
|
||||
)
|
||||
except Exception as e:
|
||||
raise ToolError(f"Error writing {file_path}: {e}") from e
|
||||
|
||||
@classmethod
|
||||
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
|
||||
if not isinstance(event.args, SearchReplaceArgs):
|
||||
return fallback_tool_call(event, "search_replace")
|
||||
if not isinstance(event.args, EditArgs):
|
||||
return fallback_tool_call(event, "edit")
|
||||
|
||||
args = event.args
|
||||
|
||||
blocks = cls._parse_search_replace_blocks(args.content)
|
||||
|
||||
return ToolCallStart(
|
||||
session_update="tool_call",
|
||||
title=cls.get_call_display(event).summary,
|
||||
title=cls.format_call_display(args).summary,
|
||||
tool_call_id=event.tool_call_id,
|
||||
kind=resolve_kind(event.tool_name),
|
||||
content=[
|
||||
FileEditToolCallContent(
|
||||
type="diff",
|
||||
path=args.file_path,
|
||||
old_text=block.search,
|
||||
new_text=block.replace,
|
||||
old_text=args.old_string,
|
||||
new_text=args.new_string,
|
||||
)
|
||||
for block in blocks
|
||||
],
|
||||
locations=[ToolCallLocation(path=str(Path(args.file_path).resolve()))],
|
||||
raw_input=args.model_dump_json(),
|
||||
|
|
@ -109,13 +89,11 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
|
|||
|
||||
@classmethod
|
||||
def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
|
||||
if failure := failed_tool_result(event, SearchReplaceResult):
|
||||
if failure := failed_tool_result(event, EditResult):
|
||||
return failure
|
||||
|
||||
result = event.result
|
||||
assert isinstance(result, SearchReplaceResult)
|
||||
|
||||
blocks = cls._parse_search_replace_blocks(result.content)
|
||||
assert isinstance(result, EditResult)
|
||||
|
||||
return ToolCallProgress(
|
||||
session_update="tool_call_update",
|
||||
|
|
@ -126,10 +104,9 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
|
|||
FileEditToolCallContent(
|
||||
type="diff",
|
||||
path=result.file,
|
||||
old_text=block.search,
|
||||
new_text=block.replace,
|
||||
old_text=result.old_string,
|
||||
new_text=result.new_string,
|
||||
)
|
||||
for block in blocks
|
||||
],
|
||||
locations=[ToolCallLocation(path=str(Path(result.file).resolve()))],
|
||||
raw_output=result.model_dump_json(),
|
||||
|
|
@ -21,41 +21,59 @@ from vibe.acp.tools.session_update import (
|
|||
resolve_kind,
|
||||
)
|
||||
from vibe.core.tools.base import ToolError
|
||||
from vibe.core.tools.builtins.read_file import (
|
||||
ReadFile as CoreReadFileTool,
|
||||
ReadFileArgs,
|
||||
ReadFileResult,
|
||||
ReadFileState,
|
||||
_ReadResult,
|
||||
from vibe.core.tools.builtins.read import (
|
||||
Read as CoreReadTool,
|
||||
ReadArgs,
|
||||
ReadResult,
|
||||
ReadState,
|
||||
)
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
|
||||
ReadFileResult = ReadFileResult
|
||||
|
||||
|
||||
class AcpReadFileState(ReadFileState, AcpToolState):
|
||||
class AcpReadState(ReadState, AcpToolState):
|
||||
pass
|
||||
|
||||
|
||||
class ReadFile(
|
||||
CoreReadFileTool,
|
||||
BaseAcpTool[AcpReadFileState],
|
||||
class Read(
|
||||
CoreReadTool,
|
||||
BaseAcpTool[AcpReadState],
|
||||
ToolCallSessionUpdateProtocol,
|
||||
ToolResultSessionUpdateProtocol,
|
||||
):
|
||||
state: AcpReadFileState
|
||||
prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "read_file.md"
|
||||
state: AcpReadState
|
||||
prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "read.md"
|
||||
|
||||
@classmethod
|
||||
def _get_tool_state_class(cls) -> type[AcpReadFileState]:
|
||||
return AcpReadFileState
|
||||
def _get_tool_state_class(cls) -> type[AcpReadState]:
|
||||
return AcpReadState
|
||||
|
||||
async def _read_file(
|
||||
self, args: ReadArgs, file_path: Path
|
||||
) -> tuple[list[str], int | None, bool]:
|
||||
client, session_id = self._load_state()
|
||||
|
||||
line = args.offset
|
||||
limit = args.limit
|
||||
|
||||
try:
|
||||
response = await client.read_text_file(
|
||||
session_id=session_id, path=str(file_path), line=line, limit=limit + 1
|
||||
)
|
||||
except Exception as e:
|
||||
raise ToolError(f"Error reading {file_path}: {e}") from e
|
||||
|
||||
lines = response.content.splitlines()
|
||||
total_lines = 0 if not response.content else None
|
||||
was_truncated = len(lines) > limit
|
||||
lines = lines[:limit]
|
||||
return lines, total_lines, was_truncated
|
||||
|
||||
@classmethod
|
||||
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
|
||||
if not isinstance(event.args, ReadFileArgs):
|
||||
return fallback_tool_call(event, "read_file")
|
||||
if not isinstance(event.args, ReadArgs):
|
||||
return fallback_tool_call(event, "read")
|
||||
|
||||
resolved = str(Path(event.args.path).resolve())
|
||||
resolved = str(Path(event.args.file_path).resolve())
|
||||
|
||||
return ToolCallStart(
|
||||
session_update="tool_call",
|
||||
|
|
@ -78,19 +96,19 @@ class ReadFile(
|
|||
|
||||
@classmethod
|
||||
def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
|
||||
if failure := failed_tool_result(event, ReadFileResult):
|
||||
if failure := failed_tool_result(event, ReadResult):
|
||||
return failure
|
||||
|
||||
result = event.result
|
||||
assert isinstance(result, ReadFileResult)
|
||||
resolved = str(Path(result.path).resolve())
|
||||
assert isinstance(result, ReadResult)
|
||||
resolved = str(Path(result.file_path).resolve())
|
||||
locations = [
|
||||
ToolCallLocation(
|
||||
path=resolved,
|
||||
field_meta={
|
||||
"type": "file_range",
|
||||
"offset": result.offset,
|
||||
"limit": result.lines_read,
|
||||
"offset": result.start_line,
|
||||
"limit": result.num_lines,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
|
@ -112,26 +130,3 @@ class ReadFile(
|
|||
locations=locations,
|
||||
field_meta={"tool_name": event.tool_name},
|
||||
)
|
||||
|
||||
async def _read_file(self, args: ReadFileArgs, file_path: Path) -> _ReadResult:
|
||||
client, session_id = self._load_state()
|
||||
|
||||
line = args.offset + 1 if args.offset > 0 else None
|
||||
limit = args.limit
|
||||
|
||||
try:
|
||||
response = await client.read_text_file(
|
||||
session_id=session_id, path=str(file_path), line=line, limit=limit
|
||||
)
|
||||
except Exception as e:
|
||||
raise ToolError(f"Error reading {file_path}: {e}") from e
|
||||
|
||||
content_lines = response.content.splitlines(keepends=True)
|
||||
lines_read = len(content_lines)
|
||||
bytes_read = sum(len(line.encode("utf-8")) for line in content_lines)
|
||||
|
||||
was_truncated = args.limit is not None and lines_read >= args.limit
|
||||
|
||||
return _ReadResult(
|
||||
lines=content_lines, bytes_read=bytes_read, was_truncated=was_truncated
|
||||
)
|
||||
|
|
@ -1,12 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
|
||||
from acp.helpers import SessionUpdate
|
||||
from acp.schema import AgentPlanUpdate, PlanEntry, PlanEntryPriority, PlanEntryStatus
|
||||
|
||||
from vibe import VIBE_ROOT
|
||||
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
|
||||
from vibe.acp.tools.session_update import failed_tool_result
|
||||
from vibe.core.tools.builtins.todo import (
|
||||
Todo as CoreTodoTool,
|
||||
TodoArgs,
|
||||
|
|
@ -38,7 +37,11 @@ class Todo(CoreTodoTool, BaseAcpTool[AcpTodoState]):
|
|||
|
||||
@classmethod
|
||||
def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
|
||||
result = cast(TodoResult, event.result)
|
||||
if failure := failed_tool_result(event, TodoResult):
|
||||
return failure
|
||||
|
||||
result = event.result
|
||||
assert isinstance(result, TodoResult)
|
||||
todos = [todo for todo in result.todos if todo.status != TodoStatus.CANCELLED]
|
||||
matched_status: dict[TodoStatus, PlanEntryStatus] = {
|
||||
TodoStatus.PENDING: "pending",
|
||||
|
|
|
|||
|
|
@ -40,12 +40,12 @@ def _cancellation_raw_output(event: ToolResultEvent) -> str | None:
|
|||
|
||||
|
||||
TOOL_KIND_MAP: dict[str, ToolKind] = {
|
||||
"read_file": "read",
|
||||
"read": "read",
|
||||
"grep": "search",
|
||||
"web_search": "search",
|
||||
"web_fetch": "fetch",
|
||||
"write_file": "edit",
|
||||
"search_replace": "edit",
|
||||
"edit": "edit",
|
||||
"bash": "execute",
|
||||
"skill": "read",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,16 +154,27 @@ def copy_text_to_clipboard(
|
|||
if not text:
|
||||
return None
|
||||
|
||||
try:
|
||||
_copy_to_clipboard(text)
|
||||
if try_copy_text_to_clipboard(text):
|
||||
if show_toast:
|
||||
app.notify(success_message, severity="information", timeout=2, markup=False)
|
||||
return text
|
||||
|
||||
app.notify(
|
||||
"Failed to copy - clipboard not available", severity="warning", timeout=3
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def try_copy_text_to_clipboard(text: str) -> bool:
|
||||
if not text:
|
||||
return False
|
||||
|
||||
try:
|
||||
_copy_to_clipboard(text)
|
||||
except Exception:
|
||||
app.notify(
|
||||
"Failed to copy - clipboard not available", severity="warning", timeout=3
|
||||
)
|
||||
return None
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> str | None:
|
||||
|
|
|
|||
|
|
@ -9,8 +9,14 @@ from rich import print as rprint
|
|||
|
||||
from vibe import __version__
|
||||
from vibe.core.config.harness_files import init_harness_files_manager
|
||||
from vibe.core.trusted_folders import find_trustable_files, trusted_folders_manager
|
||||
from vibe.core.trusted_folders import (
|
||||
find_git_repo_ancestor,
|
||||
find_repo_trustable_files_for_cwd,
|
||||
find_trustable_files,
|
||||
trusted_folders_manager,
|
||||
)
|
||||
from vibe.setup.trusted_folders.trust_folder_dialog import (
|
||||
TrustDecision,
|
||||
TrustDialogQuitException,
|
||||
ask_trust_folder,
|
||||
)
|
||||
|
|
@ -148,28 +154,49 @@ def check_and_resolve_trusted_folder(cwd: Path) -> None:
|
|||
if cwd.resolve() == Path.home().resolve():
|
||||
return
|
||||
|
||||
if trusted_folders_manager.is_trusted(cwd) is True:
|
||||
return
|
||||
if trusted_folders_manager.is_explicitly_untrusted(cwd):
|
||||
return
|
||||
|
||||
repo_root = find_git_repo_ancestor(cwd)
|
||||
detected_files = find_trustable_files(cwd)
|
||||
|
||||
if not detected_files:
|
||||
repo_detected_files = find_repo_trustable_files_for_cwd(cwd, repo_root)
|
||||
if not detected_files and not repo_detected_files:
|
||||
return
|
||||
|
||||
is_folder_trusted = trusted_folders_manager.is_trusted(cwd)
|
||||
|
||||
if is_folder_trusted is not None:
|
||||
return
|
||||
offer_repo_trust = (
|
||||
repo_root is not None
|
||||
and trusted_folders_manager.is_trusted(repo_root) is not True
|
||||
and not trusted_folders_manager.is_explicitly_untrusted(repo_root)
|
||||
)
|
||||
repo_explicitly_untrusted = (
|
||||
repo_root is not None
|
||||
and trusted_folders_manager.is_explicitly_untrusted(repo_root)
|
||||
)
|
||||
|
||||
try:
|
||||
is_folder_trusted = ask_trust_folder(cwd, detected_files)
|
||||
decision = ask_trust_folder(
|
||||
cwd,
|
||||
repo_root,
|
||||
detected_files,
|
||||
repo_detected_files=repo_detected_files,
|
||||
offer_repo_trust=offer_repo_trust,
|
||||
repo_explicitly_untrusted=repo_explicitly_untrusted,
|
||||
)
|
||||
except (KeyboardInterrupt, EOFError, TrustDialogQuitException):
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
rprint(f"[yellow]Error showing trust dialog: {e}[/]")
|
||||
return
|
||||
|
||||
if is_folder_trusted is True:
|
||||
trusted_folders_manager.add_trusted(cwd)
|
||||
elif is_folder_trusted is False:
|
||||
trusted_folders_manager.add_untrusted(cwd)
|
||||
match decision:
|
||||
case TrustDecision.TRUST_REPO if repo_root is not None:
|
||||
trusted_folders_manager.add_trusted(repo_root)
|
||||
case TrustDecision.TRUST_CWD:
|
||||
trusted_folders_manager.add_trusted(cwd)
|
||||
case TrustDecision.DECLINE:
|
||||
trusted_folders_manager.add_untrusted(cwd)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ def plan_title(payload: PlanInfo | None) -> str | None: # noqa: PLR0911
|
|||
if payload.is_chat_pro_plan():
|
||||
return "[Subscription] Pro"
|
||||
if payload.is_free_api_plan():
|
||||
return "[API] Experiment plan"
|
||||
return "Free"
|
||||
if payload.is_paid_api_plan():
|
||||
return "[API] Scale plan"
|
||||
if payload.is_free_mistral_code_plan():
|
||||
|
|
|
|||
|
|
@ -136,10 +136,15 @@ from vibe.core.agents import AgentProfile
|
|||
from vibe.core.audio_player.audio_player import AudioPlayer
|
||||
from vibe.core.audio_recorder import AudioRecorder
|
||||
from vibe.core.autocompletion.path_prompt import (
|
||||
PathPromptPayload,
|
||||
PathResource,
|
||||
build_path_prompt_payload,
|
||||
build_title_segments,
|
||||
)
|
||||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||
from vibe.core.autocompletion.path_prompt_adapter import (
|
||||
extract_image_resources,
|
||||
render_path_prompt_from_payload,
|
||||
)
|
||||
from vibe.core.config import DEFAULT_THEME, VibeConfig
|
||||
from vibe.core.data_retention import DATA_RETENTION_MESSAGE
|
||||
from vibe.core.hooks.models import HookStartEvent
|
||||
|
|
@ -147,6 +152,7 @@ from vibe.core.log_reader import LogReader
|
|||
from vibe.core.logger import logger
|
||||
from vibe.core.paths import HISTORY_FILE
|
||||
from vibe.core.rewind import RewindError
|
||||
from vibe.core.session.image_snapshot import ImageSnapshotError, snapshot_image
|
||||
from vibe.core.session.resume_sessions import (
|
||||
ResumeSessionInfo,
|
||||
list_local_resume_sessions,
|
||||
|
|
@ -172,15 +178,18 @@ from vibe.core.tools.builtins.ask_user_question import (
|
|||
Choice,
|
||||
Question,
|
||||
)
|
||||
from vibe.core.tools.connectors import ConnectorRegistry
|
||||
from vibe.core.tools.connectors import compute_connector_counts
|
||||
from vibe.core.tools.mcp_settings import persist_mcp_toggle
|
||||
from vibe.core.tools.permissions import RequiredPermission
|
||||
from vibe.core.transcribe import make_transcribe_client
|
||||
from vibe.core.types import (
|
||||
MAX_IMAGE_BYTES,
|
||||
MAX_IMAGES_PER_MESSAGE,
|
||||
AgentStats,
|
||||
ApprovalResponse,
|
||||
BaseEvent,
|
||||
ContextTooLongError,
|
||||
ImageAttachment,
|
||||
LLMMessage,
|
||||
RateLimitError,
|
||||
Role,
|
||||
|
|
@ -199,20 +208,6 @@ def _is_vscode_family_terminal() -> bool:
|
|||
return detect_terminal() in _VSCODE_FAMILY_TERMINALS
|
||||
|
||||
|
||||
def _compute_connector_counts(
|
||||
config: VibeConfig, connector_registry: ConnectorRegistry | None
|
||||
) -> tuple[int, int]:
|
||||
total = connector_registry.connector_count if connector_registry else 0
|
||||
if total == 0:
|
||||
return (0, 0)
|
||||
disabled_names = {c.name for c in config.connectors if c.disabled}
|
||||
known_names = set(
|
||||
connector_registry.get_connector_names() if connector_registry else []
|
||||
)
|
||||
enabled = total - len(disabled_names & known_names)
|
||||
return (enabled, total)
|
||||
|
||||
|
||||
class BottomApp(StrEnum):
|
||||
"""Bottom panel app types.
|
||||
|
||||
|
|
@ -335,6 +330,12 @@ class StartupOptions:
|
|||
is_resuming_session: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ImageAttachmentRejection:
|
||||
message: str
|
||||
no_vision: bool = False
|
||||
|
||||
|
||||
class VibeApp(App): # noqa: PLR0904
|
||||
ENABLE_COMMAND_PALETTE = False
|
||||
CSS_PATH = "app.tcss"
|
||||
|
|
@ -487,13 +488,13 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
def compose(self) -> ComposeResult:
|
||||
with ChatScroll(id="chat"):
|
||||
connectors_enabled, connectors_total = _compute_connector_counts(
|
||||
connectors_connected, connectors_total = compute_connector_counts(
|
||||
self.config, self.agent_loop.connector_registry
|
||||
)
|
||||
self._banner = Banner(
|
||||
config=self.config,
|
||||
skill_manager=self.agent_loop.skill_manager,
|
||||
connectors_enabled=connectors_enabled,
|
||||
connectors_connected=connectors_connected,
|
||||
connectors_total=connectors_total,
|
||||
)
|
||||
yield self._banner
|
||||
|
|
@ -569,7 +570,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.agent_loop.start_initialize_experiments()
|
||||
|
||||
self.call_after_refresh(self._refresh_banner)
|
||||
self._show_hook_config_issues_once()
|
||||
self._show_config_issues()
|
||||
|
||||
self.run_worker(self._watch_init_completion(), exclusive=False)
|
||||
|
||||
|
|
@ -581,8 +582,11 @@ class VibeApp(App): # noqa: PLR0904
|
|||
gc.collect()
|
||||
gc.freeze()
|
||||
|
||||
def _show_hook_config_issues_once(self) -> None:
|
||||
for issue in self.agent_loop.hook_config_issues:
|
||||
def _show_config_issues(self) -> None:
|
||||
for issue in (
|
||||
*self.agent_loop.hook_config_issues,
|
||||
*self.agent_loop.skill_manager.config_issues,
|
||||
):
|
||||
self.notify(
|
||||
f"{issue.file}\n{issue.message}",
|
||||
severity="warning",
|
||||
|
|
@ -759,6 +763,83 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await self._loading_widget.remove()
|
||||
self._loading_widget = None
|
||||
|
||||
async def _resolve_turn_images(
|
||||
self, payload: PathPromptPayload, prebuilt: list[ImageAttachment] | None
|
||||
) -> list[ImageAttachment] | None:
|
||||
if prebuilt is not None:
|
||||
return prebuilt
|
||||
return await self._prepare_images_or_abort(payload)
|
||||
|
||||
async def _prepare_images_or_abort(
|
||||
self, payload: PathPromptPayload
|
||||
) -> list[ImageAttachment] | None:
|
||||
result = await self._build_image_attachments(payload)
|
||||
if isinstance(result, _ImageAttachmentRejection):
|
||||
await self._remove_loading_widget()
|
||||
if result.no_vision:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(result.message, show_border=False)
|
||||
)
|
||||
else:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(result.message, collapsed=self._tools_collapsed)
|
||||
)
|
||||
return None
|
||||
return result
|
||||
|
||||
async def _build_image_attachments(
|
||||
self, payload: PathPromptPayload
|
||||
) -> list[ImageAttachment] | _ImageAttachmentRejection:
|
||||
image_resources = extract_image_resources(payload)
|
||||
if not image_resources:
|
||||
return []
|
||||
|
||||
if len(image_resources) > MAX_IMAGES_PER_MESSAGE:
|
||||
return _ImageAttachmentRejection(
|
||||
f"Too many image attachments (got {len(image_resources)}, "
|
||||
f"max {MAX_IMAGES_PER_MESSAGE})."
|
||||
)
|
||||
|
||||
try:
|
||||
active_model = self.agent_loop.config.get_active_model()
|
||||
except ValueError:
|
||||
active_model = None
|
||||
if active_model is not None and not active_model.supports_images:
|
||||
return _ImageAttachmentRejection(
|
||||
f"Model `{active_model.alias}` does not support images. "
|
||||
f"Switch with /model, remove the attachment, or ask me to enable the support for this model.",
|
||||
no_vision=True,
|
||||
)
|
||||
|
||||
attachments: list[ImageAttachment] = []
|
||||
session_dir = self.agent_loop.session_logger.session_dir
|
||||
for resource in image_resources:
|
||||
result = self._snapshot_single_image(resource, session_dir)
|
||||
if isinstance(result, str):
|
||||
return _ImageAttachmentRejection(result)
|
||||
attachments.append(result)
|
||||
return attachments
|
||||
|
||||
def _snapshot_single_image(
|
||||
self, resource: PathResource, session_dir: Path | None
|
||||
) -> ImageAttachment | str:
|
||||
try:
|
||||
size = resource.path.stat().st_size
|
||||
except OSError as e:
|
||||
return f"Cannot read image {resource.alias}: {e}"
|
||||
if size > MAX_IMAGE_BYTES:
|
||||
return (
|
||||
f"Image `{resource.alias}` is "
|
||||
f"{size / (1024 * 1024):.1f} MB; max is "
|
||||
f"{MAX_IMAGE_BYTES // (1024 * 1024)} MB."
|
||||
)
|
||||
try:
|
||||
return snapshot_image(
|
||||
resource.path, alias=resource.alias, session_dir=session_dir
|
||||
)
|
||||
except ImageSnapshotError as e:
|
||||
return f"Failed to attach image {resource.alias}: {e}"
|
||||
|
||||
async def on_config_app_open_model_picker(
|
||||
self, _message: ConfigApp.OpenModelPicker
|
||||
) -> None:
|
||||
|
|
@ -1203,10 +1284,20 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await self._handle_remote_user_message(message)
|
||||
return
|
||||
|
||||
prompt_payload = build_path_prompt_payload(message, base_dir=Path.cwd())
|
||||
images = await self._prepare_images_or_abort(prompt_payload)
|
||||
if images is None:
|
||||
input_widget = self.query_one(ChatInputContainer)
|
||||
if not input_widget.value:
|
||||
input_widget.value = message
|
||||
return
|
||||
|
||||
# message_index is where the user message will land in agent_loop.messages
|
||||
# (checkpoint is created in agent_loop.act())
|
||||
message_index = len(self.agent_loop.messages)
|
||||
user_message = UserMessage(message, message_index=message_index)
|
||||
user_message = UserMessage(
|
||||
message, message_index=message_index, images=images or None
|
||||
)
|
||||
|
||||
await self._mount_and_scroll(user_message)
|
||||
if self._feedback_bar_manager.should_show(self.agent_loop):
|
||||
|
|
@ -1217,7 +1308,12 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await self._remote_manager.stop_stream()
|
||||
await self._remove_loading_widget()
|
||||
self._agent_task = asyncio.create_task(
|
||||
self._handle_agent_loop_turn(message, title_source=title_source)
|
||||
self._handle_agent_loop_turn(
|
||||
message,
|
||||
title_source=title_source,
|
||||
prebuilt_images=images,
|
||||
prebuilt_payload=prompt_payload,
|
||||
)
|
||||
)
|
||||
|
||||
async def _handle_remote_user_message(self, message: str) -> None:
|
||||
|
|
@ -1419,7 +1515,12 @@ class VibeApp(App): # noqa: PLR0904
|
|||
)
|
||||
|
||||
async def _handle_agent_loop_turn(
|
||||
self, prompt: str, *, title_source: str | None = None
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
title_source: str | None = None,
|
||||
prebuilt_images: list[ImageAttachment] | None = None,
|
||||
prebuilt_payload: PathPromptPayload | None = None,
|
||||
) -> None:
|
||||
self._agent_running = True
|
||||
|
||||
|
|
@ -1429,7 +1530,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await self._handle_agent_loop_init()
|
||||
await self._ensure_loading_widget()
|
||||
message_id = str(uuid4())
|
||||
prompt_payload = build_path_prompt_payload(prompt, base_dir=Path.cwd())
|
||||
prompt_payload = prebuilt_payload or build_path_prompt_payload(
|
||||
prompt, base_dir=Path.cwd()
|
||||
)
|
||||
if prompt_payload.all_resources:
|
||||
context_types: dict[str, int] = {}
|
||||
for r in prompt_payload.all_resources:
|
||||
|
|
@ -1446,7 +1549,12 @@ class VibeApp(App): # noqa: PLR0904
|
|||
file_extensions=file_ext_counts or None,
|
||||
message_id=message_id,
|
||||
)
|
||||
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
|
||||
images = await self._resolve_turn_images(prompt_payload, prebuilt_images)
|
||||
if images is None:
|
||||
return
|
||||
rendered_prompt = render_path_prompt_from_payload(
|
||||
prompt_payload, skip_images=True
|
||||
)
|
||||
auto_title: str | None = None
|
||||
if self.agent_loop.session_logger.needs_initial_auto_title():
|
||||
auto_title = (
|
||||
|
|
@ -1461,7 +1569,10 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._narrator_manager.on_turn_start(rendered_prompt)
|
||||
async with aclosing(
|
||||
self.agent_loop.act(
|
||||
rendered_prompt, client_message_id=message_id, auto_title=auto_title
|
||||
rendered_prompt,
|
||||
client_message_id=message_id,
|
||||
auto_title=auto_title,
|
||||
images=images or None,
|
||||
)
|
||||
) as events:
|
||||
await self._handle_agent_loop_events(events)
|
||||
|
|
@ -1752,7 +1863,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
tool_manager=self.agent_loop.tool_manager,
|
||||
initial_server=name,
|
||||
connector_registry=connector_registry,
|
||||
get_connector_configs=lambda: self.agent_loop.config.connectors,
|
||||
get_vibe_config=lambda: self.agent_loop.config,
|
||||
refresh_callback=self._refresh_mcp_browser,
|
||||
)
|
||||
)
|
||||
|
|
@ -2059,21 +2170,37 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._narrator_manager.sync()
|
||||
|
||||
if self._banner:
|
||||
ce, ct = _compute_connector_counts(
|
||||
cc, ct = compute_connector_counts(
|
||||
base_config, self.agent_loop.connector_registry
|
||||
)
|
||||
self._banner.set_state(
|
||||
base_config,
|
||||
self.agent_loop.skill_manager,
|
||||
connectors_enabled=ce,
|
||||
connectors_connected=cc,
|
||||
connectors_total=ct,
|
||||
plan_description=plan_title(self._plan_info),
|
||||
)
|
||||
self._show_config_issues()
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage(
|
||||
"Configuration reloaded (includes agent instructions and skills)."
|
||||
)
|
||||
)
|
||||
stripped_count = (
|
||||
self.agent_loop.count_history_images_unsupported_by_active_model()
|
||||
)
|
||||
if stripped_count > 0:
|
||||
try:
|
||||
model_alias = self.agent_loop.config.get_active_model().alias
|
||||
except ValueError:
|
||||
model_alias = "the active model"
|
||||
noun = "image" if stripped_count == 1 else "images"
|
||||
await self._mount_and_scroll(
|
||||
WarningMessage(
|
||||
f"{stripped_count} {noun} from earlier turns will be omitted "
|
||||
f"when sending to {model_alias} (no vision support)."
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
|
|
@ -2849,13 +2976,13 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
def _refresh_banner(self) -> None:
|
||||
if self._banner:
|
||||
ce, ct = _compute_connector_counts(
|
||||
cc, ct = compute_connector_counts(
|
||||
self.config, self.agent_loop.connector_registry
|
||||
)
|
||||
self._banner.set_state(
|
||||
self.config,
|
||||
self.agent_loop.skill_manager,
|
||||
connectors_enabled=ce,
|
||||
connectors_connected=cc,
|
||||
connectors_total=ct,
|
||||
plan_description=plan_title(self._plan_info),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -292,6 +292,22 @@ Markdown {
|
|||
height: auto;
|
||||
}
|
||||
|
||||
.user-message-row {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.user-message-attachments {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding-left: 2;
|
||||
color: $text-muted;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
.user-message-prompt {
|
||||
width: auto;
|
||||
height: auto;
|
||||
|
|
@ -934,7 +950,7 @@ StatusMessage {
|
|||
|
||||
#approval-app {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
/* height set by ApprovalApp._recompute_height; max-height below caps it */
|
||||
max-height: 70vh;
|
||||
background: transparent;
|
||||
border: solid $foreground-muted;
|
||||
|
|
@ -945,18 +961,16 @@ StatusMessage {
|
|||
#approval-options {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
dock: bottom;
|
||||
}
|
||||
|
||||
#approval-content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
.approval-tool-info-scroll {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 50vh;
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
.approval-title {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from textual.app import ComposeResult
|
|||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Container, Vertical, VerticalScroll
|
||||
from textual.message import Message
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
|
|
@ -87,13 +88,24 @@ class ApprovalApp(Container):
|
|||
self.required_permissions = required_permissions or []
|
||||
self.selected_option = 0
|
||||
self.content_container: Vertical | None = None
|
||||
self.title_widget: Static | None = None
|
||||
self.title_widget = NoMarkupStatic(
|
||||
self._build_title(), classes="approval-title"
|
||||
)
|
||||
self.tool_info_container: Vertical | None = None
|
||||
self.option_widgets: list[Static] = []
|
||||
self.help_widget: Static | None = None
|
||||
self._mount_time: float = 0.0
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="approval-content"):
|
||||
yield self.title_widget
|
||||
|
||||
with VerticalScroll(classes="approval-tool-info-scroll"):
|
||||
self.tool_info_container = Vertical(
|
||||
classes="approval-tool-info-container"
|
||||
)
|
||||
yield self.tool_info_container
|
||||
|
||||
with Vertical(id="approval-options"):
|
||||
yield NoMarkupStatic("")
|
||||
for _ in range(self.NUM_OPTIONS):
|
||||
|
|
@ -105,17 +117,6 @@ class ApprovalApp(Container):
|
|||
)
|
||||
yield self.help_widget
|
||||
|
||||
with Vertical(id="approval-content"):
|
||||
title = self._build_title()
|
||||
self.title_widget = NoMarkupStatic(title, classes="approval-title")
|
||||
yield self.title_widget
|
||||
|
||||
with VerticalScroll(classes="approval-tool-info-scroll"):
|
||||
self.tool_info_container = Vertical(
|
||||
classes="approval-tool-info-container"
|
||||
)
|
||||
yield self.tool_info_container
|
||||
|
||||
def _build_title(self) -> str:
|
||||
if self.required_permissions:
|
||||
labels = ", ".join(rp.label for rp in self.required_permissions)
|
||||
|
|
@ -127,6 +128,33 @@ class ApprovalApp(Container):
|
|||
await self._update_tool_info()
|
||||
self._update_options()
|
||||
self.focus()
|
||||
self._recompute_height()
|
||||
self.screen.screen_layout_refresh_signal.subscribe(
|
||||
self, lambda _screen: self._recompute_height()
|
||||
)
|
||||
|
||||
def _recompute_height(self) -> None:
|
||||
"""Manual sizing: the scroll uses `1fr`, so `height: auto` cannot shrink to fit."""
|
||||
options = self.query_one("#approval-options", Vertical)
|
||||
scroll = self.query_one(".approval-tool-info-scroll", VerticalScroll)
|
||||
|
||||
natural_height = (
|
||||
options.outer_size.height
|
||||
+ self.title_widget.outer_size.height
|
||||
+ scroll.virtual_size.height
|
||||
+ self.gutter.height
|
||||
)
|
||||
|
||||
# Cap the natural height if greater than max_height
|
||||
if max_height := self.styles.max_height:
|
||||
viewport = self.app.size
|
||||
parent_size = (
|
||||
self.parent.size if isinstance(self.parent, Widget) else viewport
|
||||
)
|
||||
resolved_max_height = int(max_height.resolve(parent_size, viewport))
|
||||
natural_height = min(natural_height, resolved_max_height)
|
||||
|
||||
self.styles.height = natural_height
|
||||
|
||||
def is_within_grace_period(self) -> bool:
|
||||
return (time.monotonic() - self._mount_time) < _INPUT_GRACE_PERIOD_S
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ class BannerState:
|
|||
models_count: int = 0
|
||||
mcp_servers_enabled: int = 0
|
||||
mcp_servers_total: int = 0
|
||||
connectors_enabled: int = 0
|
||||
connectors_connected: int = 0
|
||||
connectors_total: int = 0
|
||||
skills_count: int = 0
|
||||
plan_description: str | None = None
|
||||
|
|
@ -38,7 +38,7 @@ class Banner(Static):
|
|||
self,
|
||||
config: VibeConfig,
|
||||
skill_manager: SkillManager,
|
||||
connectors_enabled: int = 0,
|
||||
connectors_connected: int = 0,
|
||||
connectors_total: int = 0,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
|
|
@ -47,7 +47,7 @@ class Banner(Static):
|
|||
self._initial_state = self._build_state(
|
||||
config=config,
|
||||
skill_manager=skill_manager,
|
||||
connectors_enabled=connectors_enabled,
|
||||
connectors_connected=connectors_connected,
|
||||
connectors_total=connectors_total,
|
||||
plan_description=None,
|
||||
)
|
||||
|
|
@ -91,14 +91,14 @@ class Banner(Static):
|
|||
self,
|
||||
config: VibeConfig,
|
||||
skill_manager: SkillManager,
|
||||
connectors_enabled: int = 0,
|
||||
connectors_connected: int = 0,
|
||||
connectors_total: int = 0,
|
||||
plan_description: str | None = None,
|
||||
) -> None:
|
||||
self.state = self._build_state(
|
||||
config,
|
||||
skill_manager,
|
||||
connectors_enabled,
|
||||
connectors_connected,
|
||||
connectors_total,
|
||||
plan_description,
|
||||
)
|
||||
|
|
@ -107,7 +107,7 @@ class Banner(Static):
|
|||
def _build_state(
|
||||
config: VibeConfig,
|
||||
skill_manager: SkillManager,
|
||||
connectors_enabled: int = 0,
|
||||
connectors_connected: int = 0,
|
||||
connectors_total: int = 0,
|
||||
plan_description: str | None = None,
|
||||
) -> BannerState:
|
||||
|
|
@ -120,7 +120,7 @@ class Banner(Static):
|
|||
models_count=len(config.models),
|
||||
mcp_servers_enabled=len(enabled_servers),
|
||||
mcp_servers_total=len(all_servers),
|
||||
connectors_enabled=connectors_enabled,
|
||||
connectors_connected=connectors_connected,
|
||||
connectors_total=connectors_total,
|
||||
skills_count=skill_manager.custom_skills_count,
|
||||
plan_description=plan_description,
|
||||
|
|
@ -130,13 +130,13 @@ class Banner(Static):
|
|||
parts = [_pluralize(self.state.models_count, "model")]
|
||||
# Format as x/y for MCP servers and connectors (only when enabled != total)
|
||||
if self.state.connectors_total > 0:
|
||||
if self.state.connectors_enabled != self.state.connectors_total:
|
||||
if self.state.connectors_connected != self.state.connectors_total:
|
||||
connector_str = (
|
||||
f"{self.state.connectors_enabled}/{self.state.connectors_total} connector"
|
||||
f"{self.state.connectors_connected}/{self.state.connectors_total} connector"
|
||||
+ ("s" if self.state.connectors_total != 1 else "")
|
||||
)
|
||||
else:
|
||||
connector_str = _pluralize(self.state.connectors_enabled, "connector")
|
||||
connector_str = _pluralize(self.state.connectors_connected, "connector")
|
||||
parts.append(connector_str)
|
||||
# Always show MCP servers count (even if 0/0)
|
||||
if self.state.mcp_servers_enabled != self.state.mcp_servers_total:
|
||||
|
|
|
|||
117
vibe/cli/textual_ui/widgets/chat_input/paste_path.py
Normal file
117
vibe/cli/textual_ui/widgets/chat_input/paste_path.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from vibe.core.types import IMAGE_EXTENSIONS
|
||||
|
||||
_QUOTES: frozenset[str] = frozenset({"'", '"'})
|
||||
_PATH_ROOTS: frozenset[str] = frozenset({"/", "~"})
|
||||
_TOKEN_BOUNDARY_CHARS: frozenset[str] = frozenset("(<[")
|
||||
_MIN_QUOTED_LEN = 2
|
||||
|
||||
|
||||
def maybe_prepend_at_for_image_path(pasted: str) -> str:
|
||||
text = pasted.strip()
|
||||
if not text or "\n" in text or "\r" in text:
|
||||
return pasted
|
||||
candidate = _unescape_spaces(_strip_matched_quotes(text))
|
||||
if not candidate or candidate[0] not in _PATH_ROOTS:
|
||||
return pasted
|
||||
if not _is_image_file(candidate):
|
||||
return pasted
|
||||
return f"@{_quote_if_needed(candidate)}"
|
||||
|
||||
|
||||
def rewrite_bare_image_paths_in_text(text: str) -> str:
|
||||
"""Scan `text` for bare absolute image paths (raw, backslash-escaped, or
|
||||
quoted) and prepend `@` to each. Idempotent: tokens already preceded by
|
||||
`@` are not touched. Used as a text-changed hook to recover the UX of
|
||||
drag-and-drop in terminals that do not emit bracketed-paste sequences.
|
||||
"""
|
||||
# Per-keystroke fast path: a bare path token must start with `/`, `~`, or
|
||||
# a quote, so skip the per-token stat() walk if none are present.
|
||||
if not any(ch in text for ch in "/~'\""):
|
||||
return text
|
||||
out: list[str] = []
|
||||
pos = 0
|
||||
while pos < len(text):
|
||||
if _at_token_boundary(text, pos):
|
||||
token, end = _extract_path_token(text, pos)
|
||||
if token is not None and _is_image_file(token):
|
||||
out.append(f"@{_quote_if_needed(token)}")
|
||||
pos = end
|
||||
continue
|
||||
out.append(text[pos])
|
||||
pos += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _is_image_file(candidate: str) -> bool:
|
||||
try:
|
||||
resolved = Path(candidate).expanduser()
|
||||
except RuntimeError:
|
||||
# `~unknownuser/...` raises when the user cannot be resolved.
|
||||
return False
|
||||
return (
|
||||
resolved.is_absolute()
|
||||
and resolved.suffix.lower() in IMAGE_EXTENSIONS
|
||||
and resolved.is_file()
|
||||
)
|
||||
|
||||
|
||||
def _quote_if_needed(path: str) -> str:
|
||||
return f"'{path}'" if " " in path else path
|
||||
|
||||
|
||||
def _unescape_spaces(text: str) -> str:
|
||||
return text.replace("\\ ", " ")
|
||||
|
||||
|
||||
def _strip_matched_quotes(text: str) -> str:
|
||||
if len(text) >= _MIN_QUOTED_LEN and text[0] in _QUOTES and text[-1] == text[0]:
|
||||
return text[1:-1]
|
||||
return text
|
||||
|
||||
|
||||
def _at_token_boundary(text: str, pos: int) -> bool:
|
||||
if pos == 0:
|
||||
return True
|
||||
prev = text[pos - 1]
|
||||
if prev == "@":
|
||||
return False
|
||||
return prev.isspace() or prev in _TOKEN_BOUNDARY_CHARS
|
||||
|
||||
|
||||
def _extract_path_token(text: str, pos: int) -> tuple[str | None, int]:
|
||||
head = text[pos]
|
||||
if head in _QUOTES:
|
||||
return _extract_quoted(text, pos, quote=head)
|
||||
if head in _PATH_ROOTS:
|
||||
return _extract_bare(text, pos)
|
||||
return None, pos
|
||||
|
||||
|
||||
def _extract_quoted(text: str, start: int, *, quote: str) -> tuple[str | None, int]:
|
||||
end = text.find(quote, start + 1)
|
||||
if end == -1:
|
||||
return None, start
|
||||
return text[start + 1 : end], end + 1
|
||||
|
||||
|
||||
def _extract_bare(text: str, start: int) -> tuple[str | None, int]:
|
||||
out: list[str] = []
|
||||
end = start
|
||||
n = len(text)
|
||||
while end < n:
|
||||
ch = text[end]
|
||||
if ch == "\\" and end + 1 < n and text[end + 1] == " ":
|
||||
out.append(" ")
|
||||
end += 2
|
||||
continue
|
||||
if ch.isspace():
|
||||
break
|
||||
out.append(ch)
|
||||
end += 1
|
||||
if end == start:
|
||||
return None, start
|
||||
return "".join(out), end
|
||||
|
|
@ -14,6 +14,10 @@ from vibe.cli.textual_ui.external_editor import ExternalEditor
|
|||
from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
|
||||
MultiCompletionManager,
|
||||
)
|
||||
from vibe.cli.textual_ui.widgets.chat_input.paste_path import (
|
||||
maybe_prepend_at_for_image_path,
|
||||
rewrite_bare_image_paths_in_text,
|
||||
)
|
||||
from vibe.cli.textual_ui.widgets.vscode_compat import patch_vscode_space
|
||||
from vibe.cli.voice_manager.voice_manager_port import (
|
||||
RecordingStartError,
|
||||
|
|
@ -33,6 +37,8 @@ class ChatTextArea(TextArea):
|
|||
show=False,
|
||||
priority=True,
|
||||
),
|
||||
Binding("shift+backspace", "delete_left", "Delete character left", show=False),
|
||||
Binding("shift+delete", "delete_right", "Delete character right", show=False),
|
||||
Binding("ctrl+g", "open_external_editor", "External Editor", show=False),
|
||||
]
|
||||
|
||||
|
|
@ -91,6 +97,17 @@ class ChatTextArea(TextArea):
|
|||
def on_click(self, event: events.Click) -> None:
|
||||
self._mark_cursor_moved_if_needed()
|
||||
|
||||
async def _on_paste(self, event: events.Paste) -> None:
|
||||
# Best-effort: terminals that emit bracketed paste sequences will
|
||||
# land here, and we can rewrite event.text directly. event.stop()
|
||||
# prevents App.on_event from auto-forwarding the Paste back to the
|
||||
# focused widget (which would otherwise dispatch this handler a
|
||||
# second time and double-insert). TextArea._on_paste in the same
|
||||
# MRO still runs inside this dispatch cycle and performs the
|
||||
# single insertion using the mutated text.
|
||||
event.text = maybe_prepend_at_for_image_path(event.text)
|
||||
event.stop()
|
||||
|
||||
def action_insert_newline(self) -> None:
|
||||
self.insert("\n")
|
||||
|
||||
|
|
@ -106,6 +123,19 @@ class ChatTextArea(TextArea):
|
|||
self.insert(result)
|
||||
|
||||
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
||||
# Fallback for terminals that deliver drag-n-drop as a bulk text
|
||||
# edit rather than a Paste event (so _on_paste never fires).
|
||||
# Scan the full text for any bare absolute image path token and
|
||||
# rewrite it to @<path>. Idempotent: tokens already preceded by
|
||||
# `@` are skipped, so this is safe to run on every change.
|
||||
current = self.text
|
||||
rewritten = rewrite_bare_image_paths_in_text(current)
|
||||
if rewritten != current:
|
||||
self.text = rewritten
|
||||
last_line = rewritten.rsplit("\n", 1)[-1]
|
||||
self.move_cursor((rewritten.count("\n"), len(last_line)))
|
||||
return
|
||||
|
||||
if not self._navigating_history and self.text != self._last_text:
|
||||
self._original_text = ""
|
||||
self._cursor_pos_after_load = None
|
||||
|
|
@ -268,7 +298,10 @@ class ChatTextArea(TextArea):
|
|||
event.stop()
|
||||
return
|
||||
|
||||
if event.key == "backspace" and self._should_reset_mode_on_backspace():
|
||||
if (
|
||||
event.key in {"backspace", "shift+backspace"}
|
||||
and self._should_reset_mode_on_backspace()
|
||||
):
|
||||
self._set_mode(self.DEFAULT_MODE)
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from textual.widgets.option_list import Option, OptionDoesNotExist
|
|||
from textual.worker import Worker
|
||||
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.core.config import ConnectorConfig
|
||||
from vibe.core.config import ConnectorConfig, VibeConfig
|
||||
from vibe.core.tools.connectors import ConnectorAuthAction, ConnectorRegistry
|
||||
from vibe.core.tools.mcp.tools import MCPTool
|
||||
from vibe.core.tools.mcp_settings import updated_tool_list
|
||||
|
|
@ -123,19 +123,19 @@ class MCPApp(Container):
|
|||
tool_manager: ToolManager,
|
||||
initial_server: str = "",
|
||||
connector_registry: ConnectorRegistry | None = None,
|
||||
get_connector_configs: Callable[[], list[ConnectorConfig]] | None = None,
|
||||
get_vibe_config: Callable[[], VibeConfig] | None = None,
|
||||
refresh_callback: Callable[[], Awaitable[str]] | None = None,
|
||||
) -> None:
|
||||
super().__init__(id="mcp-app")
|
||||
self._mcp_servers = mcp_servers
|
||||
self._connector_registry = connector_registry
|
||||
self._get_connector_configs = get_connector_configs or (lambda: [])
|
||||
self._get_vibe_config = get_vibe_config
|
||||
connector_names = (
|
||||
connector_registry.get_connector_names() if connector_registry else []
|
||||
)
|
||||
self._connector_names = connector_names
|
||||
self._sorted_connector_names = _sort_connector_names_for_menu(
|
||||
connector_names, connector_registry
|
||||
connector_names, connector_registry, self._current_disabled_names()
|
||||
)
|
||||
self._tool_manager = tool_manager
|
||||
self._index = collect_mcp_tool_index(mcp_servers, tool_manager, connector_names)
|
||||
|
|
@ -163,7 +163,9 @@ class MCPApp(Container):
|
|||
if self._connector_registry:
|
||||
self._connector_names = self._connector_registry.get_connector_names()
|
||||
self._sorted_connector_names = _sort_connector_names_for_menu(
|
||||
self._connector_names, self._connector_registry
|
||||
self._connector_names,
|
||||
self._connector_registry,
|
||||
self._current_disabled_names(),
|
||||
)
|
||||
self._rebuild_preserving_scroll()
|
||||
|
||||
|
|
@ -259,9 +261,22 @@ class MCPApp(Container):
|
|||
text = f"{self._status_message} {text}"
|
||||
self.query_one("#mcp-help", NoMarkupStatic).update(text)
|
||||
|
||||
def _connector_configs(self) -> list[ConnectorConfig]:
|
||||
return self._get_vibe_config().connectors if self._get_vibe_config else []
|
||||
|
||||
def _find_connector_config(self, name: str) -> ConnectorConfig | None:
|
||||
"""Look up a connector config by name from the live VibeConfig source."""
|
||||
return next((c for c in self._get_connector_configs() if c.name == name), None)
|
||||
return next((c for c in self._connector_configs() if c.name == name), None)
|
||||
|
||||
def _current_disabled_names(self) -> set[str]:
|
||||
config = self._get_vibe_config() if self._get_vibe_config else None
|
||||
if config is None:
|
||||
return set(self._connector_names)
|
||||
by_name = config.connectors_by_name()
|
||||
return {
|
||||
n
|
||||
for n in self._connector_names
|
||||
if (cfg := by_name.get(n)) is None or cfg.disabled
|
||||
}
|
||||
|
||||
def action_disable(self) -> None:
|
||||
self._set_highlighted_disabled(disabled=True)
|
||||
|
|
@ -290,7 +305,7 @@ class MCPApp(Container):
|
|||
cfg = self._find_connector_config(name)
|
||||
if cfg is None:
|
||||
cfg = ConnectorConfig(name=name, disabled=disabled)
|
||||
self._get_connector_configs().append(cfg)
|
||||
self._connector_configs().append(cfg)
|
||||
else:
|
||||
cfg.disabled = disabled
|
||||
|
||||
|
|
@ -342,7 +357,7 @@ class MCPApp(Container):
|
|||
cfg = ConnectorConfig(
|
||||
name=server_name, disabled_tools=[remote_name] if disabled else []
|
||||
)
|
||||
self._get_connector_configs().append(cfg)
|
||||
self._connector_configs().append(cfg)
|
||||
else:
|
||||
cfg.disabled_tools = updated_tool_list(
|
||||
cfg.disabled_tools, remote_name, disabled
|
||||
|
|
@ -366,6 +381,11 @@ class MCPApp(Container):
|
|||
self._index = collect_mcp_tool_index(
|
||||
self._mcp_servers, self._tool_manager, self._connector_names
|
||||
)
|
||||
self._sorted_connector_names = _sort_connector_names_for_menu(
|
||||
self._connector_names,
|
||||
self._connector_registry,
|
||||
self._current_disabled_names(),
|
||||
)
|
||||
self._refresh_view(self._viewing_server, kind=self._viewing_kind)
|
||||
|
||||
if saved_option_id is not None:
|
||||
|
|
@ -612,12 +632,15 @@ def _tool_count_text(enabled: int, total: int | None = None) -> str:
|
|||
|
||||
|
||||
def _sort_connector_names_for_menu(
|
||||
connector_names: Sequence[str], connector_registry: ConnectorRegistry | None
|
||||
connector_names: Sequence[str],
|
||||
connector_registry: ConnectorRegistry | None,
|
||||
disabled_names: set[str],
|
||||
) -> list[str]:
|
||||
return sorted(
|
||||
connector_names,
|
||||
key=lambda name: (
|
||||
not connector_registry.is_connected(name) if connector_registry else True,
|
||||
name.lower(),
|
||||
),
|
||||
)
|
||||
def key(name: str) -> tuple[bool, bool, str]:
|
||||
is_disabled = name in disabled_names
|
||||
is_connected = (
|
||||
connector_registry.is_connected(name) if connector_registry else False
|
||||
)
|
||||
return (is_disabled, not is_connected, name.lower())
|
||||
|
||||
return sorted(connector_names, key=key)
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@ import asyncio
|
|||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, cast
|
||||
|
||||
from rich.markup import escape
|
||||
|
||||
from vibe.core.hooks.models import HookMessageSeverity
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.types import ImageAttachment
|
||||
from vibe.core.utils.io import read_safe_async
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -62,12 +65,17 @@ class UserMessage(Static):
|
|||
SHOW_SEPARATOR: ClassVar[bool] = True
|
||||
|
||||
def __init__(
|
||||
self, content: str, pending: bool = False, message_index: int | None = None
|
||||
self,
|
||||
content: str,
|
||||
pending: bool = False,
|
||||
message_index: int | None = None,
|
||||
images: list[ImageAttachment] | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.add_class("user-message")
|
||||
self._content = content
|
||||
self._pending = pending
|
||||
self._images = images or []
|
||||
self.message_index: int | None = message_index
|
||||
|
||||
def get_content(self) -> str:
|
||||
|
|
@ -80,10 +88,28 @@ class UserMessage(Static):
|
|||
f"{self.PROMPT_CHAR} ", classes="user-message-prompt"
|
||||
)
|
||||
yield NoMarkupStatic(self._content, classes="user-message-content")
|
||||
if self._images:
|
||||
yield Static(
|
||||
self._format_attachments_footer(self._images),
|
||||
classes="user-message-attachments",
|
||||
markup=True,
|
||||
)
|
||||
if self.SHOW_SEPARATOR:
|
||||
yield ExpandingSeparator(classes="user-message-separator")
|
||||
if self._pending:
|
||||
self.add_class("pending")
|
||||
if self._pending:
|
||||
self.add_class("pending")
|
||||
|
||||
@staticmethod
|
||||
def _format_attachments_footer(images: list[ImageAttachment]) -> str:
|
||||
label = "attached image" if len(images) == 1 else "attached images"
|
||||
# Use Textual [link="..."] markup with the URL quoted: Textual's
|
||||
# markup parser stops at `:` inside an unquoted tag value, so a raw
|
||||
# `file://...` URL would raise MarkupError. Textual auto-wires the
|
||||
# click to webbrowser.open(url), opening the OS default viewer.
|
||||
links = ", ".join(
|
||||
f'[link="{att.path.as_uri()}"]{escape(att.alias)}[/link]' for att in images
|
||||
)
|
||||
return f"└ {label}: {links}"
|
||||
|
||||
async def set_pending(self, pending: bool) -> None:
|
||||
if pending == self._pending:
|
||||
|
|
@ -429,19 +455,22 @@ class BashOutputMessage(SpinnerMixin, Static):
|
|||
|
||||
|
||||
class ErrorMessage(Static):
|
||||
def __init__(self, error: str, collapsed: bool = False) -> None:
|
||||
def __init__(
|
||||
self, error: str, collapsed: bool = False, show_border: bool = True
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.add_class("error-message")
|
||||
self._error = error
|
||||
self.collapsed = collapsed
|
||||
self._show_border = show_border
|
||||
self._content_widget: Static | None = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(classes="error-container"):
|
||||
yield ExpandingBorder(classes="error-border")
|
||||
self._content_widget = NoMarkupStatic(
|
||||
f"Error: {self._error}", classes="error-content"
|
||||
)
|
||||
if self._show_border:
|
||||
yield ExpandingBorder(classes="error-border")
|
||||
text = f"Error: {self._error}" if self._show_border else self._error
|
||||
self._content_widget = NoMarkupStatic(text, classes="error-content")
|
||||
yield self._content_widget
|
||||
|
||||
def set_collapsed(self, collapsed: bool) -> None:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import difflib
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from pydantic import BaseModel
|
||||
from textual.app import ComposeResult
|
||||
|
|
@ -11,16 +12,19 @@ from textual.widgets import Markdown, Static
|
|||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.core.tools.builtins.ask_user_question import AskUserQuestionResult
|
||||
from vibe.core.tools.builtins.bash import BashArgs, BashResult
|
||||
from vibe.core.tools.builtins.edit import EditArgs, EditResult
|
||||
from vibe.core.tools.builtins.grep import GrepArgs, GrepResult
|
||||
from vibe.core.tools.builtins.read_file import ReadFileArgs, ReadFileResult
|
||||
from vibe.core.tools.builtins.search_replace import (
|
||||
SEARCH_REPLACE_BLOCK_RE,
|
||||
SearchReplaceArgs,
|
||||
SearchReplaceResult,
|
||||
)
|
||||
from vibe.core.tools.builtins.read import ReadArgs, ReadResult
|
||||
from vibe.core.tools.builtins.todo import TodoArgs, TodoResult
|
||||
from vibe.core.tools.builtins.write_file import WriteFileArgs, WriteFileResult
|
||||
|
||||
_LINE_NUMBER_PREFIX = re.compile(r"^ *\d+→")
|
||||
|
||||
|
||||
def _strip_line_numbers(content: str) -> str:
|
||||
"""Remove the model-facing `` 12→`` line-number prefixes for CLI display."""
|
||||
return "\n".join(_LINE_NUMBER_PREFIX.sub("", line) for line in content.split("\n"))
|
||||
|
||||
|
||||
def _truncate_lines(content: str, max_lines: int) -> tuple[str, str | None]:
|
||||
"""Truncate content to max_lines, returning (content, truncation_info)."""
|
||||
|
|
@ -31,24 +35,6 @@ def _truncate_lines(content: str, max_lines: int) -> tuple[str, str | None]:
|
|||
return "\n".join(lines[:max_lines]), f"… ({remaining} more lines)"
|
||||
|
||||
|
||||
def parse_search_replace_to_diff(content: str) -> list[str]:
|
||||
"""Parse SEARCH/REPLACE blocks and generate unified diff lines."""
|
||||
all_diff_lines: list[str] = []
|
||||
matches = SEARCH_REPLACE_BLOCK_RE.findall(content)
|
||||
if not matches:
|
||||
return [content[:500]] if content else []
|
||||
|
||||
for i, (search_text, replace_text) in enumerate(matches):
|
||||
if i > 0:
|
||||
all_diff_lines.append("") # Separator between blocks
|
||||
search_lines = search_text.strip("\n").split("\n")
|
||||
replace_lines = replace_text.strip("\n").split("\n")
|
||||
diff = difflib.unified_diff(search_lines, replace_lines, lineterm="", n=2)
|
||||
all_diff_lines.extend(list(diff)[2:]) # Skip file headers
|
||||
|
||||
return all_diff_lines
|
||||
|
||||
|
||||
def render_diff_line(line: str) -> Static:
|
||||
"""Render a single diff line with appropriate styling."""
|
||||
if line.startswith("---") or line.startswith("+++"):
|
||||
|
|
@ -193,28 +179,35 @@ class WriteFileResultWidget(ToolResultWidget[WriteFileResult]):
|
|||
yield from self._footer()
|
||||
|
||||
|
||||
class SearchReplaceApprovalWidget(ToolApprovalWidget[SearchReplaceArgs]):
|
||||
class EditApprovalWidget(ToolApprovalWidget[EditArgs]):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield NoMarkupStatic(
|
||||
f"File: {self.args.file_path}", classes="approval-description"
|
||||
)
|
||||
yield NoMarkupStatic("")
|
||||
|
||||
diff_lines = parse_search_replace_to_diff(self.args.content)
|
||||
for line in diff_lines:
|
||||
old_lines = self.args.old_string.split("\n")
|
||||
new_lines = self.args.new_string.split("\n")
|
||||
diff = list(difflib.unified_diff(old_lines, new_lines, lineterm="", n=2))[2:]
|
||||
for line in diff:
|
||||
yield render_diff_line(line)
|
||||
|
||||
if self.args.replace_all:
|
||||
yield NoMarkupStatic("(replace_all)", classes="approval-description")
|
||||
|
||||
class SearchReplaceResultWidget(ToolResultWidget[SearchReplaceResult]):
|
||||
|
||||
class EditResultWidget(ToolResultWidget[EditResult]):
|
||||
def compose(self) -> ComposeResult:
|
||||
if not self.result:
|
||||
yield from self._footer()
|
||||
return
|
||||
for warning in self.warnings:
|
||||
yield NoMarkupStatic(f"⚠ {warning}", classes="tool-result-warning")
|
||||
if self.result.content:
|
||||
for line in parse_search_replace_to_diff(self.result.content):
|
||||
yield render_diff_line(line)
|
||||
old_lines = self.result.old_string.split("\n")
|
||||
new_lines = self.result.new_string.split("\n")
|
||||
diff = list(difflib.unified_diff(old_lines, new_lines, lineterm="", n=2))[2:]
|
||||
for line in diff:
|
||||
yield render_diff_line(line)
|
||||
yield from self._footer()
|
||||
|
||||
|
||||
|
|
@ -260,10 +253,12 @@ class TodoResultWidget(ToolResultWidget[TodoResult]):
|
|||
return icons.get(status, "☐")
|
||||
|
||||
|
||||
class ReadFileApprovalWidget(ToolApprovalWidget[ReadFileArgs]):
|
||||
class ReadApprovalWidget(ToolApprovalWidget[ReadArgs]):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield NoMarkupStatic(f"path: {self.args.path}", classes="approval-description")
|
||||
if self.args.offset > 0:
|
||||
yield NoMarkupStatic(
|
||||
f"file_path: {self.args.file_path}", classes="approval-description"
|
||||
)
|
||||
if self.args.offset is not None:
|
||||
yield NoMarkupStatic(
|
||||
f"offset: {self.args.offset}", classes="approval-description"
|
||||
)
|
||||
|
|
@ -273,23 +268,24 @@ class ReadFileApprovalWidget(ToolApprovalWidget[ReadFileArgs]):
|
|||
)
|
||||
|
||||
|
||||
class ReadFileResultWidget(ToolResultWidget[ReadFileResult]):
|
||||
class ReadResultWidget(ToolResultWidget[ReadResult]):
|
||||
def compose(self) -> ComposeResult:
|
||||
if self.collapsed:
|
||||
yield from self._footer()
|
||||
return
|
||||
if self.result:
|
||||
yield NoMarkupStatic(
|
||||
f"Path: {self.result.path}", classes="tool-result-detail"
|
||||
f"Path: {self.result.file_path}", classes="tool-result-detail"
|
||||
)
|
||||
for warning in self.warnings:
|
||||
yield NoMarkupStatic(f"⚠ {warning}", classes="tool-result-warning")
|
||||
truncation_info = None
|
||||
if self.result and self.result.content:
|
||||
yield NoMarkupStatic("")
|
||||
ext = Path(self.result.path).suffix.lstrip(".") or "text"
|
||||
content, truncation_info = _truncate_lines(self.result.content, 10)
|
||||
yield Markdown(f"```{ext}\n{content}\n```")
|
||||
content, truncation_info = _truncate_lines(
|
||||
_strip_line_numbers(self.result.content), 10
|
||||
)
|
||||
yield NoMarkupStatic(content, classes="tool-result-detail")
|
||||
yield from self._footer(truncation_info)
|
||||
|
||||
|
||||
|
|
@ -337,18 +333,18 @@ class AskUserQuestionResultWidget(ToolResultWidget[AskUserQuestionResult]):
|
|||
|
||||
APPROVAL_WIDGETS: dict[str, type[ToolApprovalWidget]] = {
|
||||
"bash": BashApprovalWidget,
|
||||
"read_file": ReadFileApprovalWidget,
|
||||
"read": ReadApprovalWidget,
|
||||
"write_file": WriteFileApprovalWidget,
|
||||
"search_replace": SearchReplaceApprovalWidget,
|
||||
"edit": EditApprovalWidget,
|
||||
"grep": GrepApprovalWidget,
|
||||
"todo": TodoApprovalWidget,
|
||||
}
|
||||
|
||||
RESULT_WIDGETS: dict[str, type[ToolResultWidget]] = {
|
||||
"bash": BashResultWidget,
|
||||
"read_file": ReadFileResultWidget,
|
||||
"read": ReadResultWidget,
|
||||
"write_file": WriteFileResultWidget,
|
||||
"search_replace": SearchReplaceResultWidget,
|
||||
"edit": EditResultWidget,
|
||||
"grep": GrepResultWidget,
|
||||
"todo": TodoResultWidget,
|
||||
"ask_user_question": AskUserQuestionResultWidget,
|
||||
|
|
|
|||
|
|
@ -46,10 +46,14 @@ def build_history_widgets(
|
|||
continue
|
||||
match msg.role:
|
||||
case Role.user:
|
||||
if msg.content:
|
||||
if msg.content or msg.images:
|
||||
# history_index is 0-based in non-system messages;
|
||||
# agent_loop.messages index = history_index + 1 (system msg at 0)
|
||||
widget = UserMessage(msg.content, message_index=history_index + 1)
|
||||
widget = UserMessage(
|
||||
msg.content or "",
|
||||
message_index=history_index + 1,
|
||||
images=msg.images,
|
||||
)
|
||||
widgets.append(widget)
|
||||
history_widget_indices[widget] = history_index
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator, Callable, Generator
|
||||
from collections.abc import AsyncGenerator, Callable, Generator, Sequence
|
||||
import contextlib
|
||||
import copy
|
||||
from enum import StrEnum, auto
|
||||
|
|
@ -106,6 +106,7 @@ from vibe.core.types import (
|
|||
CompactEndEvent,
|
||||
CompactStartEvent,
|
||||
ContextTooLongError,
|
||||
ImageAttachment,
|
||||
LLMChunk,
|
||||
LLMMessage,
|
||||
LLMUsage,
|
||||
|
|
@ -171,6 +172,10 @@ class AgentLoopLLMResponseError(AgentLoopError):
|
|||
"""Raised when LLM response is malformed or missing expected data."""
|
||||
|
||||
|
||||
class ImagesNotSupportedError(AgentLoopError):
|
||||
"""Raised when the active model does not support image attachments."""
|
||||
|
||||
|
||||
class TeleportError(AgentLoopError):
|
||||
"""Raised when teleport to Vibe Code fails."""
|
||||
|
||||
|
|
@ -649,16 +654,24 @@ class AgentLoop: # noqa: PLR0904
|
|||
client_message_id: str | None = None,
|
||||
*,
|
||||
auto_title: str | None = None,
|
||||
images: list[ImageAttachment] | None = None,
|
||||
) -> AsyncGenerator[BaseEvent, None]:
|
||||
try:
|
||||
active_model = self.config.get_active_model()
|
||||
model_name = active_model.name
|
||||
except ValueError:
|
||||
active_model = None
|
||||
model_name = None
|
||||
if images and active_model is not None and not active_model.supports_images:
|
||||
raise ImagesNotSupportedError(active_model.alias)
|
||||
self._clean_message_history()
|
||||
self.rewind_manager.create_checkpoint()
|
||||
try:
|
||||
model_name = self.config.get_active_model().name
|
||||
except ValueError:
|
||||
model_name = None
|
||||
async with agent_span(model=model_name, session_id=self.session_id):
|
||||
async for event in self._conversation_loop(
|
||||
msg, client_message_id=client_message_id, auto_title=auto_title
|
||||
msg,
|
||||
client_message_id=client_message_id,
|
||||
auto_title=auto_title,
|
||||
images=images,
|
||||
):
|
||||
yield event
|
||||
|
||||
|
|
@ -872,9 +885,13 @@ class AgentLoop: # noqa: PLR0904
|
|||
client_message_id: str | None = None,
|
||||
*,
|
||||
auto_title: str | None = None,
|
||||
images: list[ImageAttachment] | None = None,
|
||||
) -> AsyncGenerator[BaseEvent]:
|
||||
user_message = LLMMessage(
|
||||
role=Role.user, content=user_msg, message_id=client_message_id
|
||||
role=Role.user,
|
||||
content=user_msg,
|
||||
message_id=client_message_id,
|
||||
images=images or None,
|
||||
)
|
||||
self.messages.append(user_message)
|
||||
self.stats.steps += 1
|
||||
|
|
@ -1317,6 +1334,25 @@ class AgentLoop: # noqa: PLR0904
|
|||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
|
||||
def _messages_for_backend(self, active_model: ModelConfig) -> Sequence[LLMMessage]:
|
||||
if active_model.supports_images:
|
||||
return self.messages
|
||||
if not any(m.images for m in self.messages):
|
||||
return self.messages
|
||||
return [
|
||||
m.model_copy(update={"images": None}) if m.images else m
|
||||
for m in self.messages
|
||||
]
|
||||
|
||||
def count_history_images_unsupported_by_active_model(self) -> int:
|
||||
try:
|
||||
active_model = self.config.get_active_model()
|
||||
except ValueError:
|
||||
return 0
|
||||
if active_model.supports_images:
|
||||
return 0
|
||||
return sum(1 for m in self.messages if m.images)
|
||||
|
||||
async def _chat(
|
||||
self, max_tokens: int | None = None, model_override: ModelConfig | None = None
|
||||
) -> LLMChunk:
|
||||
|
|
@ -1343,7 +1379,7 @@ class AgentLoop: # noqa: PLR0904
|
|||
start_time = time.perf_counter()
|
||||
result = await self.backend.complete(
|
||||
model=active_model,
|
||||
messages=self.messages,
|
||||
messages=self._messages_for_backend(active_model),
|
||||
temperature=active_model.temperature,
|
||||
tools=available_tools,
|
||||
tool_choice=tool_choice,
|
||||
|
|
@ -1408,7 +1444,7 @@ class AgentLoop: # noqa: PLR0904
|
|||
chunk_agg: LLMChunk | None = None
|
||||
async for chunk in self.backend.complete_streaming(
|
||||
model=active_model,
|
||||
messages=self.messages,
|
||||
messages=self._messages_for_backend(active_model),
|
||||
temperature=active_model.temperature,
|
||||
tools=available_tools,
|
||||
tool_choice=tool_choice,
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ class AgentProfile:
|
|||
)
|
||||
|
||||
|
||||
CHAT_AGENT_TOOLS = ["grep", "read_file", "ask_user_question", "task"]
|
||||
CHAT_AGENT_TOOLS = ["grep", "read", "ask_user_question", "task"]
|
||||
|
||||
|
||||
def _plan_overrides() -> dict[str, Any]:
|
||||
|
|
@ -102,7 +102,7 @@ def _plan_overrides() -> dict[str, Any]:
|
|||
return {
|
||||
"tools": {
|
||||
"write_file": {"permission": "never", "allowlist": [plans_pattern]},
|
||||
"search_replace": {"permission": "never", "allowlist": [plans_pattern]},
|
||||
"edit": {"permission": "never", "allowlist": [plans_pattern]},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -137,7 +137,7 @@ ACCEPT_EDITS = AgentProfile(
|
|||
"base_disabled": ["exit_plan_mode"],
|
||||
"tools": {
|
||||
"write_file": {"permission": "always"},
|
||||
"search_replace": {"permission": "always"},
|
||||
"edit": {"permission": "always"},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
|
@ -155,7 +155,7 @@ EXPLORE = AgentProfile(
|
|||
description="Read-only subagent for codebase exploration",
|
||||
safety=AgentSafety.SAFE,
|
||||
agent_type=AgentType.SUBAGENT,
|
||||
overrides={"enabled_tools": ["grep", "read_file"], "system_prompt_id": "explore"},
|
||||
overrides={"enabled_tools": ["grep", "read"], "system_prompt_id": "explore"},
|
||||
)
|
||||
|
||||
LEAN = AgentProfile(
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@ from pathlib import Path
|
|||
from typing import Literal
|
||||
|
||||
from vibe.core.session.title_format import MentionSegment, TextSegment, TitleSegment
|
||||
from vibe.core.types import IMAGE_EXTENSIONS
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PathResource:
|
||||
path: Path
|
||||
alias: str
|
||||
kind: Literal["file", "folder"]
|
||||
kind: Literal["file", "folder", "image"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -80,14 +81,17 @@ def _extract_candidate(message: str, start: int) -> tuple[str | None, int]:
|
|||
|
||||
|
||||
def _is_path_char(char: str) -> bool:
|
||||
return char.isalnum() or char in "._/\\-()[]{}"
|
||||
return char.isalnum() or char in "._/\\-()[]{}~"
|
||||
|
||||
|
||||
def _to_resource(candidate: str, base_dir: Path) -> PathResource | None:
|
||||
if not candidate:
|
||||
return None
|
||||
|
||||
candidate_path = Path(candidate)
|
||||
try:
|
||||
candidate_path = Path(candidate).expanduser()
|
||||
except RuntimeError:
|
||||
return None
|
||||
resolved = (
|
||||
candidate_path if candidate_path.is_absolute() else base_dir / candidate_path
|
||||
)
|
||||
|
|
@ -96,7 +100,13 @@ def _to_resource(candidate: str, base_dir: Path) -> PathResource | None:
|
|||
if not resolved.exists():
|
||||
return None
|
||||
|
||||
kind = "folder" if resolved.is_dir() else "file"
|
||||
kind: Literal["file", "folder", "image"]
|
||||
if resolved.is_dir():
|
||||
kind = "folder"
|
||||
elif resolved.suffix.lower() in IMAGE_EXTENSIONS:
|
||||
kind = "image"
|
||||
else:
|
||||
kind = "file"
|
||||
return PathResource(path=resolved, alias=candidate, kind=kind)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -16,19 +16,41 @@ DEFAULT_MAX_EMBED_BYTES = 256 * 1024
|
|||
ResourceBlock = dict[str, str | None]
|
||||
|
||||
|
||||
def extract_image_resources(payload: PathPromptPayload) -> list[PathResource]:
|
||||
return [r for r in payload.resources if r.kind == "image"]
|
||||
|
||||
|
||||
def render_path_prompt_from_payload(
|
||||
payload: PathPromptPayload,
|
||||
*,
|
||||
max_embed_bytes: int | None = DEFAULT_MAX_EMBED_BYTES,
|
||||
skip_images: bool = False,
|
||||
) -> str:
|
||||
blocks = _path_prompt_to_content_blocks(
|
||||
payload, max_embed_bytes=max_embed_bytes, skip_images=skip_images
|
||||
)
|
||||
return _content_blocks_to_prompt_text(blocks)
|
||||
|
||||
|
||||
def render_path_prompt(
|
||||
message: str,
|
||||
*,
|
||||
base_dir: Path,
|
||||
max_embed_bytes: int | None = DEFAULT_MAX_EMBED_BYTES,
|
||||
skip_images: bool = False,
|
||||
) -> str:
|
||||
payload = build_path_prompt_payload(message, base_dir=base_dir)
|
||||
blocks = _path_prompt_to_content_blocks(payload, max_embed_bytes=max_embed_bytes)
|
||||
return _content_blocks_to_prompt_text(blocks)
|
||||
return render_path_prompt_from_payload(
|
||||
build_path_prompt_payload(message, base_dir=base_dir),
|
||||
max_embed_bytes=max_embed_bytes,
|
||||
skip_images=skip_images,
|
||||
)
|
||||
|
||||
|
||||
def _path_prompt_to_content_blocks(
|
||||
payload: PathPromptPayload, *, max_embed_bytes: int | None = DEFAULT_MAX_EMBED_BYTES
|
||||
payload: PathPromptPayload,
|
||||
*,
|
||||
max_embed_bytes: int | None = DEFAULT_MAX_EMBED_BYTES,
|
||||
skip_images: bool = False,
|
||||
) -> list[ResourceBlock]:
|
||||
blocks: list[ResourceBlock] = [{"type": "text", "text": payload.prompt_text}]
|
||||
|
||||
|
|
@ -50,6 +72,19 @@ def _path_prompt_to_content_blocks(
|
|||
"uri": resource.path.as_uri(),
|
||||
"name": resource.alias,
|
||||
})
|
||||
case "image":
|
||||
# Callers that carry images as `LLMMessage.images` sidecar
|
||||
# attachments (e.g. the Textual TUI) pass skip_images=True to
|
||||
# avoid duplicating them as text. Other callers (e.g. ACP,
|
||||
# which has no sidecar plumbing yet) fall back to a
|
||||
# resource_link so the model still sees the file reference.
|
||||
if skip_images:
|
||||
continue
|
||||
blocks.append({
|
||||
"type": "resource_link",
|
||||
"uri": resource.path.as_uri(),
|
||||
"name": resource.alias,
|
||||
})
|
||||
|
||||
return blocks
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ from vibe.core.config.schema import (
|
|||
WithShallowMerge,
|
||||
WithUnionMerge,
|
||||
)
|
||||
from vibe.core.config.vibe_schema import VibeConfigSchema
|
||||
from vibe.core.prompts import MissingPromptFileError
|
||||
|
||||
__all__ = [
|
||||
|
|
@ -120,6 +121,7 @@ __all__ = [
|
|||
"TrustResolutionError",
|
||||
"UntrustedLayerError",
|
||||
"VibeConfig",
|
||||
"VibeConfigSchema",
|
||||
"WithConcatMerge",
|
||||
"WithConflictMerge",
|
||||
"WithReplaceMerge",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from pathlib import Path
|
|||
import re
|
||||
import shlex
|
||||
import tomllib
|
||||
from typing import Annotated, Any, Literal, get_args
|
||||
from typing import Annotated, Any, ClassVar, Literal, get_args
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from dotenv import dotenv_values
|
||||
|
|
@ -30,7 +30,12 @@ from vibe.core.agents.models import BuiltinAgentName
|
|||
from vibe.core.config.harness_files import get_harness_files_manager
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR
|
||||
from vibe.core.prompts import UtilityPrompt, load_prompt, load_system_prompt
|
||||
from vibe.core.prompts import (
|
||||
SystemPrompt,
|
||||
UtilityPrompt,
|
||||
load_prompt,
|
||||
load_system_prompt,
|
||||
)
|
||||
from vibe.core.types import Backend
|
||||
from vibe.core.utils import configure_ssl_context, get_server_url_from_api_base
|
||||
|
||||
|
|
@ -362,6 +367,9 @@ def _default_alias_to_name(data: Any) -> Any:
|
|||
ThinkingLevel = Literal["off", "low", "medium", "high", "max"]
|
||||
THINKING_LEVELS: list[str] = list(get_args(ThinkingLevel))
|
||||
|
||||
DEFAULT_AUTO_COMPACT_THRESHOLD = 200_000
|
||||
DEFAULT_API_TIMEOUT = 720.0
|
||||
|
||||
|
||||
class ModelConfig(BaseModel):
|
||||
name: str
|
||||
|
|
@ -371,8 +379,8 @@ class ModelConfig(BaseModel):
|
|||
input_price: float = 0.0 # Price per million input tokens
|
||||
output_price: float = 0.0 # Price per million output tokens
|
||||
thinking: ThinkingLevel = "off"
|
||||
auto_compact_threshold: int = 200_000
|
||||
|
||||
supports_images: bool = False
|
||||
auto_compact_threshold: int = DEFAULT_AUTO_COMPACT_THRESHOLD
|
||||
_default_alias_to_name = model_validator(mode="before")(_default_alias_to_name)
|
||||
|
||||
|
||||
|
|
@ -417,12 +425,15 @@ class OtelSpanExporterConfig(BaseModel):
|
|||
|
||||
|
||||
MISTRAL_OTEL_PATH = "/telemetry"
|
||||
_DEFAULT_MISTRAL_SERVER_URL = "https://api.mistral.ai"
|
||||
DEFAULT_MISTRAL_SERVER_URL = "https://api.mistral.ai"
|
||||
|
||||
DEFAULT_VIBE_CODE_WORKFLOW_ID = "__shared-nuage-workflow"
|
||||
DEFAULT_VIBE_CODE_TASK_QUEUE = "shared-vibe-nuage"
|
||||
|
||||
DEFAULT_PROVIDERS = [
|
||||
ProviderConfig(
|
||||
name="mistral",
|
||||
api_base=f"{_DEFAULT_MISTRAL_SERVER_URL}/v1",
|
||||
api_base=f"{DEFAULT_MISTRAL_SERVER_URL}/v1",
|
||||
api_key_env_var=DEFAULT_MISTRAL_API_ENV_KEY,
|
||||
browser_auth_base_url=DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL,
|
||||
browser_auth_api_base_url=DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL,
|
||||
|
|
@ -435,16 +446,19 @@ DEFAULT_PROVIDERS = [
|
|||
),
|
||||
]
|
||||
|
||||
DEFAULT_ACTIVE_MODEL_CONFIG = ModelConfig(
|
||||
name="mistral-vibe-cli-latest",
|
||||
provider="mistral",
|
||||
alias="mistral-medium-3.5",
|
||||
temperature=1.0,
|
||||
input_price=1.5,
|
||||
output_price=7.5,
|
||||
thinking="high",
|
||||
supports_images=True,
|
||||
)
|
||||
|
||||
DEFAULT_MODELS = [
|
||||
ModelConfig(
|
||||
name="mistral-vibe-cli-latest",
|
||||
provider="mistral",
|
||||
alias="mistral-medium-3.5",
|
||||
temperature=1.0,
|
||||
input_price=1.5,
|
||||
output_price=7.5,
|
||||
thinking="high",
|
||||
),
|
||||
DEFAULT_ACTIVE_MODEL_CONFIG,
|
||||
ModelConfig(
|
||||
name="devstral-small-latest",
|
||||
provider="mistral",
|
||||
|
|
@ -461,8 +475,6 @@ DEFAULT_MODELS = [
|
|||
),
|
||||
]
|
||||
|
||||
DEFAULT_ACTIVE_MODEL = DEFAULT_MODELS[0].alias
|
||||
|
||||
DEFAULT_TRANSCRIBE_PROVIDERS = [
|
||||
TranscribeProviderConfig(
|
||||
name="mistral",
|
||||
|
|
@ -471,13 +483,13 @@ DEFAULT_TRANSCRIBE_PROVIDERS = [
|
|||
)
|
||||
]
|
||||
|
||||
DEFAULT_TRANSCRIBE_MODELS = [
|
||||
TranscribeModelConfig(
|
||||
name="voxtral-mini-transcribe-realtime-2602",
|
||||
provider="mistral",
|
||||
alias="voxtral-realtime",
|
||||
)
|
||||
]
|
||||
DEFAULT_ACTIVE_TRANSCRIBE_MODEL_CONFIG = TranscribeModelConfig(
|
||||
name="voxtral-mini-transcribe-realtime-2602",
|
||||
provider="mistral",
|
||||
alias="voxtral-realtime",
|
||||
)
|
||||
|
||||
DEFAULT_TRANSCRIBE_MODELS = [DEFAULT_ACTIVE_TRANSCRIBE_MODEL_CONFIG]
|
||||
|
||||
DEFAULT_TTS_PROVIDERS = [
|
||||
TTSProviderConfig(
|
||||
|
|
@ -487,17 +499,17 @@ DEFAULT_TTS_PROVIDERS = [
|
|||
)
|
||||
]
|
||||
|
||||
DEFAULT_TTS_MODELS = [
|
||||
TTSModelConfig(
|
||||
name="voxtral-mini-tts-latest", provider="mistral", alias="voxtral-tts"
|
||||
)
|
||||
]
|
||||
DEFAULT_ACTIVE_TTS_MODEL_CONFIG = TTSModelConfig(
|
||||
name="voxtral-mini-tts-latest", provider="mistral", alias="voxtral-tts"
|
||||
)
|
||||
|
||||
DEFAULT_TTS_MODELS = [DEFAULT_ACTIVE_TTS_MODEL_CONFIG]
|
||||
|
||||
DEFAULT_THEME = "ansi-dark"
|
||||
|
||||
|
||||
class VibeConfig(BaseSettings):
|
||||
active_model: str = DEFAULT_ACTIVE_MODEL
|
||||
active_model: str = DEFAULT_ACTIVE_MODEL_CONFIG.alias
|
||||
vim_keybindings: bool = False
|
||||
theme: str = DEFAULT_THEME
|
||||
disable_welcome_banner_animation: bool = False
|
||||
|
|
@ -507,13 +519,13 @@ class VibeConfig(BaseSettings):
|
|||
context_warnings: bool = False
|
||||
voice_mode_enabled: bool = False
|
||||
narrator_enabled: bool = False
|
||||
active_transcribe_model: str = "voxtral-realtime"
|
||||
active_tts_model: str = "voxtral-tts"
|
||||
active_transcribe_model: str = DEFAULT_ACTIVE_TRANSCRIBE_MODEL_CONFIG.alias
|
||||
active_tts_model: str = DEFAULT_ACTIVE_TTS_MODEL_CONFIG.alias
|
||||
bypass_tool_permissions: bool = False
|
||||
enable_telemetry: bool = True
|
||||
experiment_overrides: dict[str, str] = Field(default_factory=dict)
|
||||
system_prompt_id: str = "cli"
|
||||
compaction_prompt_id: str = "compact"
|
||||
system_prompt_id: str = SystemPrompt.CLI
|
||||
compaction_prompt_id: str = UtilityPrompt.COMPACT
|
||||
include_commit_signature: bool = True
|
||||
include_model_info: bool = True
|
||||
include_project_context: bool = True
|
||||
|
|
@ -522,16 +534,20 @@ class VibeConfig(BaseSettings):
|
|||
enable_auto_update: bool = True
|
||||
enable_notifications: bool = True
|
||||
enable_system_trust_store: bool = False
|
||||
api_timeout: float = 720.0
|
||||
auto_compact_threshold: int = 200_000
|
||||
api_timeout: float = DEFAULT_API_TIMEOUT
|
||||
auto_compact_threshold: int = DEFAULT_AUTO_COMPACT_THRESHOLD
|
||||
|
||||
vibe_code_enabled: bool = Field(default=True, exclude=True)
|
||||
vibe_code_base_url: str = Field(default="https://api.mistral.ai", exclude=True)
|
||||
vibe_code_base_url: str = Field(default=DEFAULT_MISTRAL_SERVER_URL, exclude=True)
|
||||
vibe_code_sessions_base_url: str = Field(
|
||||
default="https://chat.mistral.ai", exclude=True
|
||||
)
|
||||
vibe_code_workflow_id: str = Field(default="__shared-nuage-workflow", exclude=True)
|
||||
vibe_code_api_key_env_var: str = Field(default="MISTRAL_API_KEY", exclude=True)
|
||||
vibe_code_workflow_id: str = Field(
|
||||
default=DEFAULT_VIBE_CODE_WORKFLOW_ID, exclude=True
|
||||
)
|
||||
vibe_code_api_key_env_var: str = Field(
|
||||
default=DEFAULT_MISTRAL_API_ENV_KEY, exclude=True
|
||||
)
|
||||
vibe_code_project_name: str | None = Field(default=None, exclude=True)
|
||||
|
||||
# TODO(otel): remove exclude=True once the feature is publicly available
|
||||
|
|
@ -702,7 +718,7 @@ class VibeConfig(BaseSettings):
|
|||
api_key_env = DEFAULT_MISTRAL_API_ENV_KEY
|
||||
|
||||
endpoint = urljoin(
|
||||
f"{urljoin(server_url or _DEFAULT_MISTRAL_SERVER_URL, MISTRAL_OTEL_PATH).rstrip('/')}/",
|
||||
f"{urljoin(server_url or DEFAULT_MISTRAL_SERVER_URL, MISTRAL_OTEL_PATH).rstrip('/')}/",
|
||||
traces_export_path,
|
||||
)
|
||||
|
||||
|
|
@ -741,6 +757,9 @@ class VibeConfig(BaseSettings):
|
|||
return self.compaction_model
|
||||
return self.get_active_model()
|
||||
|
||||
def connectors_by_name(self) -> dict[str, ConnectorConfig]:
|
||||
return {c.name: c for c in self.connectors}
|
||||
|
||||
def get_mistral_provider(self) -> ProviderConfig | None:
|
||||
try:
|
||||
active_provider = self.get_active_provider()
|
||||
|
|
@ -964,14 +983,15 @@ class VibeConfig(BaseSettings):
|
|||
entry["thinking"] = level
|
||||
break
|
||||
else:
|
||||
# Model comes from defaults; materialize the full list so we
|
||||
# Model comes from defaults; materialize the identities so we
|
||||
# don't lose the other models.
|
||||
models = [
|
||||
{
|
||||
"alias": m.alias,
|
||||
"name": m.name,
|
||||
"provider": m.provider,
|
||||
"alias": m.alias,
|
||||
"thinking": level if m.alias == model.alias else m.thinking,
|
||||
**({"supports_images": True} if m.supports_images else {}),
|
||||
}
|
||||
for m in self.models
|
||||
]
|
||||
|
|
@ -1057,13 +1077,64 @@ class VibeConfig(BaseSettings):
|
|||
model["thinking"] = "high"
|
||||
changed = True
|
||||
|
||||
if (
|
||||
model.get("name") == "mistral-vibe-cli-latest"
|
||||
and model.get("alias") == "mistral-medium-3.5"
|
||||
and "supports_images" not in model
|
||||
):
|
||||
model["supports_images"] = True
|
||||
changed = True
|
||||
|
||||
if data.get("active_model") == "devstral-2":
|
||||
data["active_model"] = "mistral-medium-3.5"
|
||||
changed = True
|
||||
|
||||
if cls._migrate_renamed_tools(data):
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
cls.dump_config(data)
|
||||
|
||||
# Old tool name -> new tool name. The new tools replaced these in-place, so
|
||||
# existing user configs keyed by the old names need their settings moved over.
|
||||
_RENAMED_TOOLS: ClassVar[dict[str, str]] = {
|
||||
"read_file": "read",
|
||||
"search_replace": "edit",
|
||||
}
|
||||
# Options on the old tool that have no equivalent on the new one; dropped on migrate.
|
||||
_DROPPED_TOOL_OPTIONS: ClassVar[dict[str, tuple[str, ...]]] = {
|
||||
"edit": ("max_content_size", "create_backup")
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _migrate_renamed_tools(cls, data: dict[str, Any]) -> bool:
|
||||
changed = False
|
||||
|
||||
tools = data.get("tools")
|
||||
if isinstance(tools, dict):
|
||||
for old, new in cls._RENAMED_TOOLS.items():
|
||||
if old not in tools:
|
||||
continue
|
||||
old_config = tools.pop(old)
|
||||
changed = True
|
||||
# Prefer an already-present new key; don't clobber it.
|
||||
if new not in tools:
|
||||
if isinstance(old_config, dict):
|
||||
for dropped in cls._DROPPED_TOOL_OPTIONS.get(new, ()):
|
||||
old_config.pop(dropped, None)
|
||||
tools[new] = old_config
|
||||
|
||||
for list_key in ("enabled_tools", "disabled_tools"):
|
||||
names = data.get(list_key)
|
||||
if not isinstance(names, list):
|
||||
continue
|
||||
renamed = [cls._RENAMED_TOOLS.get(name, name) for name in names]
|
||||
if renamed != names:
|
||||
data[list_key] = renamed
|
||||
changed = True
|
||||
|
||||
return changed
|
||||
|
||||
@classmethod
|
||||
def load(cls, **overrides: Any) -> VibeConfig:
|
||||
cls._migrate()
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ from vibe.core.config.harness_files._paths import (
|
|||
from vibe.core.paths import (
|
||||
AGENTS_MD_FILENAME,
|
||||
VIBE_HOME,
|
||||
ConfigWalkResult,
|
||||
LocalConfigDirs,
|
||||
dedup_paths,
|
||||
walk_local_config_dirs,
|
||||
find_local_config_dirs,
|
||||
)
|
||||
from vibe.core.trusted_folders import trusted_folders_manager
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
|
@ -60,19 +60,18 @@ class HarnessFilesManager:
|
|||
"""Open project directories: trusted cwd (if any) plus ``--add-dir``
|
||||
paths.
|
||||
|
||||
``--add-dir`` entries are coalesced (resolved + nested-collapse: adding
|
||||
``/x`` and ``/x/y`` is the same as just ``/x``). Add-dirs equal to or
|
||||
nested inside the cwd are dropped — cwd already covers them. The cwd
|
||||
itself, if trusted, is always kept as a starting point: when an add-dir
|
||||
contains it, both survive (the add-dir contributes its own root-level
|
||||
discovery; cwd preserves its walk-up semantics for AGENTS.md).
|
||||
``--add-dir`` entries are resolved and deduplicated; nested paths are
|
||||
preserved because project config discovery is root-level only.
|
||||
Add-dirs equal to the cwd are dropped (redundant). When an add-dir
|
||||
contains the cwd, both survive (the add-dir contributes its own
|
||||
root-level discovery; cwd preserves walk-up semantics for AGENTS.md).
|
||||
"""
|
||||
add_dirs = dedup_paths(self._additional_dirs, drop_nested=True)
|
||||
add_dirs = dedup_paths(self._additional_dirs)
|
||||
workdir = self._trusted_workdir
|
||||
if workdir is None:
|
||||
return add_dirs
|
||||
w = workdir.resolve()
|
||||
return [w, *(p for p in add_dirs if p != w and not p.is_relative_to(w))]
|
||||
return [w, *(p for p in add_dirs if p != w)]
|
||||
|
||||
@property
|
||||
def hook_files(self) -> list[Path]:
|
||||
|
|
@ -110,23 +109,23 @@ class HarnessFilesManager:
|
|||
d = GLOBAL_AGENTS_DIR.path
|
||||
return [d] if d.is_dir() else []
|
||||
|
||||
def _walk_project_roots(self) -> ConfigWalkResult:
|
||||
result = ConfigWalkResult()
|
||||
def _collect_project_roots(self) -> LocalConfigDirs:
|
||||
result = LocalConfigDirs()
|
||||
for root in self.project_roots:
|
||||
result |= walk_local_config_dirs(root)
|
||||
result |= find_local_config_dirs(root)
|
||||
return result
|
||||
|
||||
@property
|
||||
def project_tools_dirs(self) -> list[Path]:
|
||||
return list(self._walk_project_roots().tools)
|
||||
return list(self._collect_project_roots().tools)
|
||||
|
||||
@property
|
||||
def project_skills_dirs(self) -> list[Path]:
|
||||
return list(self._walk_project_roots().skills)
|
||||
return list(self._collect_project_roots().skills)
|
||||
|
||||
@property
|
||||
def project_agents_dirs(self) -> list[Path]:
|
||||
return list(self._walk_project_roots().agents)
|
||||
return list(self._collect_project_roots().agents)
|
||||
|
||||
@property
|
||||
def user_config_file(self) -> Path:
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ from typing import Any
|
|||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from vibe.core.config.patch import ConfigPatch
|
||||
from vibe.core.config.types import ConflictStrategy
|
||||
|
||||
|
||||
class RawConfig(BaseModel):
|
||||
"""Permissive default schema that preserves all fields as extras."""
|
||||
|
|
@ -287,7 +290,12 @@ class ConfigLayer[S: BaseModel](ABC):
|
|||
"""Return opaque token representing current backing store state."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def apply(self, patch: Any, *, on_conflict: str = "cancel") -> None:
|
||||
async def apply(
|
||||
self,
|
||||
patch: ConfigPatch,
|
||||
*,
|
||||
on_conflict: ConflictStrategy = ConflictStrategy.CANCEL,
|
||||
) -> None:
|
||||
"""Persist a patch to this layer's backing store."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.config.layers.environment import EnvironmentLayer
|
||||
from vibe.core.config.layers.overrides import OverridesLayer
|
||||
from vibe.core.config.layers.project import ProjectConfigLayer
|
||||
from vibe.core.config.layers.user import UserConfigLayer
|
||||
|
||||
__all__ = ["OverridesLayer", "ProjectConfigLayer", "UserConfigLayer"]
|
||||
__all__ = [
|
||||
"EnvironmentLayer",
|
||||
"OverridesLayer",
|
||||
"ProjectConfigLayer",
|
||||
"UserConfigLayer",
|
||||
]
|
||||
|
|
|
|||
44
vibe/core/config/layers/environment.py
Normal file
44
vibe/core/config/layers/environment.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, create_model
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from vibe.core.config.layer import ConfigLayer, RawConfig
|
||||
|
||||
|
||||
class _EnvBase(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="VIBE_",
|
||||
case_sensitive=False,
|
||||
env_nested_delimiter="__",
|
||||
env_ignore_empty=True,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
|
||||
class EnvironmentLayer(ConfigLayer[RawConfig]):
|
||||
"""Reads VIBE_* env vars via pydantic-settings, which handles type coercion
|
||||
and validation against the schema.
|
||||
"""
|
||||
|
||||
def __init__(self, *, name: str = "environment", schema: type[BaseModel]) -> None:
|
||||
super().__init__(name=name)
|
||||
|
||||
fields: dict[str, Any] = {
|
||||
field_name: (info.annotation, info)
|
||||
for field_name, info in schema.model_fields.items()
|
||||
}
|
||||
self._settings_class: type[BaseSettings] = create_model(
|
||||
"_EnvSchema", __base__=_EnvBase, **fields
|
||||
)
|
||||
|
||||
async def _check_trust(self) -> bool:
|
||||
return True
|
||||
|
||||
async def _read_config(self) -> dict[str, Any]:
|
||||
return self._settings_class().model_dump(exclude_unset=True)
|
||||
|
||||
async def apply(self, patch: Any, *, on_conflict: str = "cancel") -> None:
|
||||
raise NotImplementedError("EnvironmentLayer.apply() is not implemented (M2)")
|
||||
|
|
@ -4,6 +4,8 @@ import copy
|
|||
from typing import Any
|
||||
|
||||
from vibe.core.config.layer import ConfigLayer, RawConfig
|
||||
from vibe.core.config.patch import ConfigPatch
|
||||
from vibe.core.config.types import ConflictStrategy
|
||||
|
||||
|
||||
class OverridesLayer(ConfigLayer[RawConfig]):
|
||||
|
|
@ -23,5 +25,10 @@ class OverridesLayer(ConfigLayer[RawConfig]):
|
|||
async def _read_config(self) -> dict[str, Any]:
|
||||
return copy.deepcopy(self._data)
|
||||
|
||||
async def apply(self, patch: Any, *, on_conflict: str = "cancel") -> None:
|
||||
async def apply(
|
||||
self,
|
||||
patch: ConfigPatch,
|
||||
*,
|
||||
on_conflict: ConflictStrategy = ConflictStrategy.CANCEL,
|
||||
) -> None:
|
||||
raise NotImplementedError("OverridesLayer.apply() is not implemented (M2)")
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import tomllib
|
|||
from typing import Any
|
||||
|
||||
from vibe.core.config.layer import ConfigLayer, RawConfig
|
||||
from vibe.core.config.patch import ConfigPatch
|
||||
from vibe.core.config.types import ConflictStrategy
|
||||
from vibe.core.paths._vibe_home import VIBE_HOME
|
||||
from vibe.core.trusted_folders import trusted_folders_manager
|
||||
|
||||
|
|
@ -64,7 +66,12 @@ class ProjectConfigLayer(ConfigLayer[RawConfig]):
|
|||
|
||||
await super().revoke_trust()
|
||||
|
||||
async def apply(self, patch: Any, *, on_conflict: str = "cancel") -> None:
|
||||
async def apply(
|
||||
self,
|
||||
patch: ConfigPatch,
|
||||
*,
|
||||
on_conflict: ConflictStrategy = ConflictStrategy.CANCEL,
|
||||
) -> None:
|
||||
raise NotImplementedError("ProjectConfigLayer.apply() is not implemented (M2)")
|
||||
|
||||
async def _find_config_file(self) -> None:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import tomllib
|
|||
from typing import Any
|
||||
|
||||
from vibe.core.config.layer import ConfigLayer, RawConfig
|
||||
from vibe.core.config.patch import ConfigPatch
|
||||
from vibe.core.config.types import ConflictStrategy
|
||||
from vibe.core.paths._vibe_home import VIBE_HOME
|
||||
|
||||
|
||||
|
|
@ -28,5 +30,10 @@ class UserConfigLayer(ConfigLayer[RawConfig]):
|
|||
with self._path.open("rb") as f:
|
||||
return tomllib.load(f)
|
||||
|
||||
async def apply(self, patch: Any, *, on_conflict: str = "cancel") -> None:
|
||||
async def apply(
|
||||
self,
|
||||
patch: ConfigPatch,
|
||||
*,
|
||||
on_conflict: ConflictStrategy = ConflictStrategy.CANCEL,
|
||||
) -> None:
|
||||
raise NotImplementedError("UserConfigLayer.apply() is not implemented (M2)")
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ from typing import Any
|
|||
|
||||
from vibe.core.config.builder import ConfigBuilder
|
||||
from vibe.core.config.layer import ConfigLayer, RawConfig
|
||||
from vibe.core.config.patch import ConfigPatch
|
||||
from vibe.core.config.schema import ConfigSchema
|
||||
from vibe.core.config.types import ConflictStrategy
|
||||
|
||||
|
||||
class ConfigOrchestrator[S: ConfigSchema]:
|
||||
|
|
@ -39,7 +41,12 @@ class ConfigOrchestrator[S: ConfigSchema]:
|
|||
"""Force-reload all layers and atomically replace the config snapshot."""
|
||||
self._config = await self._builder.build(force_load=True)
|
||||
|
||||
async def apply_patch(self, patch: Any) -> None:
|
||||
async def apply_patch(
|
||||
self,
|
||||
patch: ConfigPatch,
|
||||
*,
|
||||
on_conflict: ConflictStrategy = ConflictStrategy.CANCEL,
|
||||
) -> None:
|
||||
raise NotImplementedError("apply_patch() is not implemented (M2)")
|
||||
|
||||
async def subscribe(self, callback: Any) -> None:
|
||||
|
|
|
|||
|
|
@ -1,8 +1,25 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum, auto
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigOrigin:
|
||||
layer_name: str
|
||||
|
||||
|
||||
class ConflictStrategy(StrEnum):
|
||||
CANCEL = auto() # abort on conflict (default)
|
||||
REPLACE = auto() # force-overwrite, discard external changes
|
||||
|
||||
|
||||
class ConcurrencyConflictError(Exception):
|
||||
"""Raised when backing store was modified externally between load and apply."""
|
||||
|
||||
def __init__(self, expected_fp: str, actual_fp: str) -> None:
|
||||
super().__init__(
|
||||
f"Backing store was modified externally (expected fingerprint '{expected_fp}', got '{actual_fp}')"
|
||||
)
|
||||
self.expected_fp = expected_fp
|
||||
self.actual_fp = actual_fp
|
||||
|
|
|
|||
242
vibe/core/config/vibe_schema.py
Normal file
242
vibe/core/config/vibe_schema.py
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.config._settings import (
|
||||
DEFAULT_ACTIVE_MODEL_CONFIG,
|
||||
DEFAULT_ACTIVE_TRANSCRIBE_MODEL_CONFIG,
|
||||
DEFAULT_ACTIVE_TTS_MODEL_CONFIG,
|
||||
DEFAULT_API_TIMEOUT,
|
||||
DEFAULT_AUTO_COMPACT_THRESHOLD,
|
||||
DEFAULT_CONSOLE_BASE_URL,
|
||||
DEFAULT_MISTRAL_API_ENV_KEY,
|
||||
DEFAULT_MISTRAL_SERVER_URL,
|
||||
DEFAULT_MODELS,
|
||||
DEFAULT_PROVIDERS,
|
||||
DEFAULT_THEME,
|
||||
DEFAULT_TRANSCRIBE_MODELS,
|
||||
DEFAULT_TRANSCRIBE_PROVIDERS,
|
||||
DEFAULT_TTS_MODELS,
|
||||
DEFAULT_TTS_PROVIDERS,
|
||||
DEFAULT_VIBE_BASE_URL,
|
||||
DEFAULT_VIBE_CODE_TASK_QUEUE,
|
||||
DEFAULT_VIBE_CODE_WORKFLOW_ID,
|
||||
ConnectorConfig,
|
||||
ExperimentsConfig,
|
||||
MCPServer,
|
||||
ModelConfig,
|
||||
ProjectContextConfig,
|
||||
ProviderConfig,
|
||||
SessionLoggingConfig,
|
||||
TranscribeModelConfig,
|
||||
TranscribeProviderConfig,
|
||||
TTSModelConfig,
|
||||
TTSProviderConfig,
|
||||
)
|
||||
from vibe.core.config.schema import (
|
||||
ConfigSchema,
|
||||
WithConcatMerge,
|
||||
WithReplaceMerge,
|
||||
WithShallowMerge,
|
||||
WithUnionMerge,
|
||||
)
|
||||
from vibe.core.prompts import SystemPrompt, UtilityPrompt
|
||||
|
||||
|
||||
class VibeConfigSchema(ConfigSchema):
|
||||
# Models
|
||||
active_model: Annotated[str, WithReplaceMerge()] = DEFAULT_ACTIVE_MODEL_CONFIG.alias
|
||||
providers: Annotated[list[ProviderConfig], WithUnionMerge(merge_key="name")] = (
|
||||
Field(default_factory=lambda: list(DEFAULT_PROVIDERS))
|
||||
)
|
||||
models: Annotated[list[ModelConfig], WithUnionMerge(merge_key="alias")] = Field(
|
||||
default_factory=lambda: list(DEFAULT_MODELS)
|
||||
)
|
||||
compaction_model: Annotated[ModelConfig | None, WithReplaceMerge()] = None
|
||||
auto_compact_threshold: Annotated[int, WithReplaceMerge()] = (
|
||||
DEFAULT_AUTO_COMPACT_THRESHOLD
|
||||
)
|
||||
active_transcribe_model: Annotated[str, WithReplaceMerge()] = (
|
||||
DEFAULT_ACTIVE_TRANSCRIBE_MODEL_CONFIG.alias
|
||||
)
|
||||
transcribe_providers: Annotated[
|
||||
list[TranscribeProviderConfig], WithUnionMerge(merge_key="name")
|
||||
] = Field(default_factory=lambda: list(DEFAULT_TRANSCRIBE_PROVIDERS))
|
||||
transcribe_models: Annotated[
|
||||
list[TranscribeModelConfig], WithUnionMerge(merge_key="alias")
|
||||
] = Field(default_factory=lambda: list(DEFAULT_TRANSCRIBE_MODELS))
|
||||
active_tts_model: Annotated[str, WithReplaceMerge()] = (
|
||||
DEFAULT_ACTIVE_TTS_MODEL_CONFIG.alias
|
||||
)
|
||||
tts_providers: Annotated[
|
||||
list[TTSProviderConfig], WithUnionMerge(merge_key="name")
|
||||
] = Field(default_factory=lambda: list(DEFAULT_TTS_PROVIDERS))
|
||||
tts_models: Annotated[list[TTSModelConfig], WithUnionMerge(merge_key="alias")] = (
|
||||
Field(default_factory=lambda: list(DEFAULT_TTS_MODELS))
|
||||
)
|
||||
|
||||
# Tools
|
||||
tools: Annotated[dict[str, dict[str, Any]], WithShallowMerge()] = Field(
|
||||
default_factory=dict
|
||||
)
|
||||
tool_paths: Annotated[list[Path], WithConcatMerge()] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"Additional directories or files to explore for custom tools. "
|
||||
"Paths may be absolute or relative to the current working directory. "
|
||||
"Directories are shallow-searched for tool definition files, "
|
||||
"while files are loaded directly if valid."
|
||||
),
|
||||
)
|
||||
enabled_tools: Annotated[list[str], WithConcatMerge()] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"An explicit list of tool names/patterns to enable. If set, only these"
|
||||
" tools will be active. Supports glob patterns (e.g., 'serena_*') and"
|
||||
" regex with 're:' prefix (e.g., 're:^serena_.*')."
|
||||
),
|
||||
)
|
||||
disabled_tools: Annotated[list[str], WithConcatMerge()] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"A list of tool names/patterns to disable. Ignored if 'enabled_tools'"
|
||||
" is set. Supports glob patterns and regex with 're:' prefix."
|
||||
),
|
||||
)
|
||||
mcp_servers: Annotated[list[MCPServer], WithUnionMerge(merge_key="name")] = Field(
|
||||
default_factory=list, description="Preferred MCP server configuration entries."
|
||||
)
|
||||
enable_connectors: Annotated[bool, WithReplaceMerge()] = True
|
||||
connectors: Annotated[list[ConnectorConfig], WithUnionMerge(merge_key="name")] = (
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="Per-connector settings (disable, disabled_tools).",
|
||||
)
|
||||
)
|
||||
|
||||
# Agents
|
||||
agent_paths: Annotated[list[Path], WithConcatMerge()] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"Additional directories to search for custom agent profiles. "
|
||||
"Each path may be absolute or relative to the current working directory."
|
||||
),
|
||||
)
|
||||
enabled_agents: Annotated[list[str], WithConcatMerge()] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"An explicit list of agent names/patterns to enable. If set, only these"
|
||||
" agents will be available. Supports glob patterns (e.g., 'custom-*')"
|
||||
" and regex with 're:' prefix."
|
||||
),
|
||||
)
|
||||
disabled_agents: Annotated[list[str], WithConcatMerge()] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"A list of agent names/patterns to disable. Ignored if 'enabled_agents'"
|
||||
" is set. Supports glob patterns and regex with 're:' prefix."
|
||||
),
|
||||
)
|
||||
installed_agents: Annotated[list[str], WithConcatMerge()] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"A list of opt-in builtin agent names that have been explicitly installed."
|
||||
),
|
||||
)
|
||||
default_agent: Annotated[str, WithReplaceMerge()] = Field(
|
||||
default=BuiltinAgentName.DEFAULT,
|
||||
description=(
|
||||
"Agent profile to use when no --agent flag is passed. "
|
||||
"Builtin: default, plan, accept-edits, auto-approve. "
|
||||
"Applies in both interactive and programmatic (-p/--prompt) mode."
|
||||
),
|
||||
)
|
||||
|
||||
# Skills
|
||||
skill_paths: Annotated[list[Path], WithConcatMerge()] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"Additional directories to search for skills. "
|
||||
"Each path may be absolute or relative to the current working directory."
|
||||
),
|
||||
)
|
||||
enabled_skills: Annotated[list[str], WithConcatMerge()] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"An explicit list of skill names/patterns to enable. If set, only these"
|
||||
" skills will be active. Supports glob patterns (e.g., 'search-*') and"
|
||||
" regex with 're:' prefix."
|
||||
),
|
||||
)
|
||||
disabled_skills: Annotated[list[str], WithConcatMerge()] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"A list of skill names/patterns to disable. Ignored if 'enabled_skills'"
|
||||
" is set. Supports glob patterns and regex with 're:' prefix."
|
||||
),
|
||||
)
|
||||
|
||||
# Internal
|
||||
vibe_code_enabled: Annotated[bool, WithReplaceMerge()] = True
|
||||
vibe_code_base_url: Annotated[str, WithReplaceMerge()] = DEFAULT_MISTRAL_SERVER_URL
|
||||
vibe_code_workflow_id: Annotated[str, WithReplaceMerge()] = (
|
||||
DEFAULT_VIBE_CODE_WORKFLOW_ID
|
||||
)
|
||||
vibe_code_task_queue: Annotated[str | None, WithReplaceMerge()] = (
|
||||
DEFAULT_VIBE_CODE_TASK_QUEUE
|
||||
)
|
||||
vibe_code_api_key_env_var: Annotated[str, WithReplaceMerge()] = (
|
||||
DEFAULT_MISTRAL_API_ENV_KEY
|
||||
)
|
||||
vibe_code_project_name: Annotated[str | None, WithReplaceMerge()] = None
|
||||
vibe_code_experimental_nuage_enabled: Annotated[bool, WithReplaceMerge()] = False
|
||||
enable_otel: Annotated[bool, WithReplaceMerge()] = False
|
||||
otel_endpoint: Annotated[str, WithReplaceMerge()] = ""
|
||||
console_base_url: Annotated[str, WithReplaceMerge()] = DEFAULT_CONSOLE_BASE_URL
|
||||
enable_experimental_hooks: Annotated[bool, WithReplaceMerge()] = False
|
||||
|
||||
# Top-level scalars
|
||||
theme: Annotated[str, WithReplaceMerge()] = DEFAULT_THEME
|
||||
experiment_overrides: Annotated[dict[str, str], WithReplaceMerge()] = Field(
|
||||
default_factory=dict
|
||||
)
|
||||
vim_keybindings: Annotated[bool, WithReplaceMerge()] = False
|
||||
disable_welcome_banner_animation: Annotated[bool, WithReplaceMerge()] = False
|
||||
autocopy_to_clipboard: Annotated[bool, WithReplaceMerge()] = True
|
||||
file_watcher_for_autocomplete: Annotated[bool, WithReplaceMerge()] = False
|
||||
displayed_workdir: Annotated[str, WithReplaceMerge()] = ""
|
||||
context_warnings: Annotated[bool, WithReplaceMerge()] = False
|
||||
voice_mode_enabled: Annotated[bool, WithReplaceMerge()] = False
|
||||
narrator_enabled: Annotated[bool, WithReplaceMerge()] = False
|
||||
bypass_tool_permissions: Annotated[bool, WithReplaceMerge()] = False
|
||||
enable_telemetry: Annotated[bool, WithReplaceMerge()] = True
|
||||
system_prompt_id: Annotated[str, WithReplaceMerge()] = SystemPrompt.CLI
|
||||
compaction_prompt_id: Annotated[str, WithReplaceMerge()] = UtilityPrompt.COMPACT
|
||||
include_commit_signature: Annotated[bool, WithReplaceMerge()] = True
|
||||
include_model_info: Annotated[bool, WithReplaceMerge()] = True
|
||||
include_project_context: Annotated[bool, WithReplaceMerge()] = True
|
||||
include_prompt_detail: Annotated[bool, WithReplaceMerge()] = True
|
||||
enable_update_checks: Annotated[bool, WithReplaceMerge()] = True
|
||||
enable_auto_update: Annotated[bool, WithReplaceMerge()] = True
|
||||
enable_notifications: Annotated[bool, WithReplaceMerge()] = True
|
||||
enable_system_trust_store: Annotated[bool, WithReplaceMerge()] = False
|
||||
api_timeout: Annotated[float, WithReplaceMerge()] = DEFAULT_API_TIMEOUT
|
||||
vibe_base_url: Annotated[str, WithReplaceMerge()] = DEFAULT_VIBE_BASE_URL
|
||||
vibe_code_sessions_base_url: Annotated[str, WithReplaceMerge()] = (
|
||||
"https://chat.mistral.ai"
|
||||
)
|
||||
|
||||
# Nested configs (REPLACE — simple nested models, no merge semantics)
|
||||
project_context: Annotated[ProjectContextConfig, WithReplaceMerge()] = Field(
|
||||
default_factory=ProjectContextConfig
|
||||
)
|
||||
session_logging: Annotated[SessionLoggingConfig, WithReplaceMerge()] = Field(
|
||||
default_factory=SessionLoggingConfig
|
||||
)
|
||||
experiments: Annotated[ExperimentsConfig, WithReplaceMerge()] = Field(
|
||||
default_factory=ExperimentsConfig
|
||||
)
|
||||
38
vibe/core/llm/backend/_image.py
Normal file
38
vibe/core/llm/backend/_image.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from vibe.core.types import ImageAttachment
|
||||
|
||||
|
||||
class ImageReadError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
_CACHE_MAX = 32
|
||||
|
||||
|
||||
@lru_cache(maxsize=_CACHE_MAX)
|
||||
def _encode_cached(path_str: str, mtime_ns: int, size: int) -> str:
|
||||
try:
|
||||
return base64.b64encode(Path(path_str).read_bytes()).decode("ascii")
|
||||
except OSError as e:
|
||||
raise ImageReadError(f"Failed to read image {path_str}: {e}") from e
|
||||
|
||||
|
||||
def _encode(att: ImageAttachment) -> str:
|
||||
try:
|
||||
stat = att.path.stat()
|
||||
except OSError as e:
|
||||
raise ImageReadError(f"Failed to stat image {att.path}: {e}") from e
|
||||
return _encode_cached(str(att.path), stat.st_mtime_ns, stat.st_size)
|
||||
|
||||
|
||||
def to_data_uri(att: ImageAttachment) -> str:
|
||||
return f"data:{att.mime_type};base64,{_encode(att)}"
|
||||
|
||||
|
||||
def to_base64(att: ImageAttachment) -> str:
|
||||
return _encode(att)
|
||||
|
|
@ -6,6 +6,7 @@ import re
|
|||
from typing import Any, ClassVar
|
||||
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.core.llm.backend._image import to_base64 as _to_base64
|
||||
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
|
||||
from vibe.core.types import (
|
||||
AvailableTool,
|
||||
|
|
@ -36,6 +37,18 @@ class AnthropicMapper:
|
|||
user_content: list[dict[str, Any]] = []
|
||||
if msg.content:
|
||||
user_content.append({"type": "text", "text": msg.content})
|
||||
if msg.images:
|
||||
user_content.extend(
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": att.mime_type,
|
||||
"data": _to_base64(att),
|
||||
},
|
||||
}
|
||||
for att in msg.images
|
||||
)
|
||||
converted.append({"role": "user", "content": user_content or ""})
|
||||
case Role.assistant:
|
||||
converted.append(self._convert_assistant_message(msg))
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple
|
|||
|
||||
import httpx
|
||||
|
||||
from vibe.core.llm.backend._image import to_data_uri as _to_data_uri
|
||||
from vibe.core.llm.backend.anthropic import AnthropicAdapter
|
||||
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
|
||||
from vibe.core.llm.backend.openai_responses import OpenAIResponsesAdapter
|
||||
|
|
@ -79,6 +80,22 @@ class OpenAIAdapter(APIAdapter):
|
|||
msg_dict["reasoning_content"] = msg_dict.pop(field_name)
|
||||
return msg_dict
|
||||
|
||||
def _user_with_images_to_parts(
|
||||
self, msg_dict: dict[str, Any], source: LLMMessage
|
||||
) -> dict[str, Any]:
|
||||
if source.role != Role.user or not source.images:
|
||||
return msg_dict
|
||||
parts: list[dict[str, Any]] = []
|
||||
text = msg_dict.get("content")
|
||||
if isinstance(text, str) and text:
|
||||
parts.append({"type": "text", "text": text})
|
||||
parts.extend(
|
||||
{"type": "image_url", "image_url": {"url": _to_data_uri(att)}}
|
||||
for att in source.images
|
||||
)
|
||||
msg_dict["content"] = parts
|
||||
return msg_dict
|
||||
|
||||
def prepare_request(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -95,17 +112,21 @@ class OpenAIAdapter(APIAdapter):
|
|||
) -> PreparedRequest:
|
||||
field_name = provider.reasoning_field_name
|
||||
converted_messages = [
|
||||
self._reasoning_to_api(
|
||||
msg.model_dump(
|
||||
exclude_none=True,
|
||||
exclude={
|
||||
"message_id",
|
||||
"reasoning_message_id",
|
||||
"reasoning_state",
|
||||
"injected",
|
||||
},
|
||||
self._user_with_images_to_parts(
|
||||
self._reasoning_to_api(
|
||||
msg.model_dump(
|
||||
exclude_none=True,
|
||||
exclude={
|
||||
"message_id",
|
||||
"reasoning_message_id",
|
||||
"reasoning_state",
|
||||
"injected",
|
||||
"images",
|
||||
},
|
||||
),
|
||||
field_name,
|
||||
),
|
||||
field_name,
|
||||
msg,
|
||||
)
|
||||
for msg in messages
|
||||
]
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ from mistralai.client.models import (
|
|||
Function,
|
||||
FunctionCall as MistralFunctionCall,
|
||||
FunctionName,
|
||||
ImageURL,
|
||||
ImageURLChunk,
|
||||
SystemMessage,
|
||||
TextChunk,
|
||||
ThinkChunk,
|
||||
|
|
@ -31,6 +33,7 @@ from mistralai.client.models import (
|
|||
)
|
||||
from mistralai.client.utils.retries import BackoffStrategy, RetryConfig
|
||||
|
||||
from vibe.core.llm.backend._image import to_data_uri as _to_data_uri
|
||||
from vibe.core.llm.exceptions import BackendErrorBuilder
|
||||
from vibe.core.types import (
|
||||
AvailableTool,
|
||||
|
|
@ -61,6 +64,17 @@ class MistralMapper:
|
|||
case Role.system:
|
||||
return SystemMessage(role="system", content=msg.content or "")
|
||||
case Role.user:
|
||||
if msg.images:
|
||||
user_parts: list[ContentChunk] = []
|
||||
if msg.content:
|
||||
user_parts.append(TextChunk(type="text", text=msg.content))
|
||||
user_parts.extend(
|
||||
ImageURLChunk(
|
||||
type="image_url", image_url=ImageURL(url=_to_data_uri(att))
|
||||
)
|
||||
for att in msg.images
|
||||
)
|
||||
return UserMessage(role="user", content=user_parts)
|
||||
return UserMessage(role="user", content=msg.content)
|
||||
case Role.assistant:
|
||||
content: AssistantMessageContent
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, TypedDict, cast
|
|||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from vibe.core.llm.backend._image import to_data_uri as _to_data_uri
|
||||
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
|
||||
from vibe.core.types import (
|
||||
AvailableTool,
|
||||
|
|
@ -434,7 +435,20 @@ class OpenAIResponsesAdapter(APIAdapter):
|
|||
input_items.append({"role": "system", "content": msg.content or ""})
|
||||
|
||||
case Role.user:
|
||||
input_items.append({"role": "user", "content": msg.content or ""})
|
||||
if msg.images:
|
||||
parts: list[dict[str, Any]] = []
|
||||
if msg.content:
|
||||
parts.append({"type": "input_text", "text": msg.content})
|
||||
parts.extend(
|
||||
{"type": "input_image", "image_url": _to_data_uri(att)}
|
||||
for att in msg.images
|
||||
)
|
||||
input_items.append({"role": "user", "content": parts})
|
||||
else:
|
||||
input_items.append({
|
||||
"role": "user",
|
||||
"content": msg.content or "",
|
||||
})
|
||||
|
||||
case Role.assistant:
|
||||
for encrypted_content in msg.reasoning_state or []:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import json
|
|||
from typing import Any, ClassVar
|
||||
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.core.llm.backend._image import to_data_uri as _to_data_uri
|
||||
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
|
||||
from vibe.core.types import (
|
||||
AvailableTool,
|
||||
|
|
@ -26,6 +27,15 @@ class ReasoningAdapter(APIAdapter):
|
|||
case Role.system:
|
||||
return {"role": "system", "content": msg.content or ""}
|
||||
case Role.user:
|
||||
if msg.images:
|
||||
parts: list[dict[str, Any]] = []
|
||||
if msg.content:
|
||||
parts.append({"type": "text", "text": msg.content})
|
||||
parts.extend(
|
||||
{"type": "image_url", "image_url": {"url": _to_data_uri(att)}}
|
||||
for att in msg.images
|
||||
)
|
||||
return {"role": "user", "content": parts}
|
||||
return {"role": "user", "content": msg.content or ""}
|
||||
case Role.assistant:
|
||||
return self._convert_assistant_message(msg)
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ def make_plan_agent_reminder(
|
|||
has_exit_plan_mode: bool = True,
|
||||
) -> str:
|
||||
instructions = [
|
||||
"Research the user's query using read-only tools (grep, read_file, etc.)"
|
||||
"Research the user's query using read-only tools (grep, read, etc.)"
|
||||
]
|
||||
if has_ask_user_question:
|
||||
instructions.append(
|
||||
|
|
@ -171,7 +171,7 @@ def make_plan_agent_reminder(
|
|||
return f"""<{VIBE_WARNING_TAG}>Plan mode is active. You MUST NOT make any edits (except to the plan file below, or in your scratchpad), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received.
|
||||
|
||||
## Plan File Info
|
||||
Create or edit your plan at {plan_file_path} using the write_file and search_replace tools.
|
||||
Create or edit your plan at {plan_file_path} using the write_file and edit tools.
|
||||
Build your plan incrementally by writing to or editing this file.
|
||||
This is the only file you are allowed to edit. Make sure to create it early and edit as soon as you internally update your plan.
|
||||
|
||||
|
|
@ -184,7 +184,7 @@ PLAN_AGENT_EXIT = f"""<{VIBE_WARNING_TAG}>Plan mode has ended. If you have a pla
|
|||
CHAT_AGENT_REMINDER = f"""<{VIBE_WARNING_TAG}>Chat mode is active. The user wants to have a conversation -- ask questions, get explanations, or discuss code and architecture. You MUST NOT make any edits, run any non-readonly tools, or otherwise make any changes to the system. This supersedes any other instructions you have received. Instead, you should:
|
||||
1. Answer the user's questions directly and comprehensively
|
||||
2. Explain code, concepts, or architecture as requested
|
||||
3. Use read-only tools (grep, read_file) to look up relevant code when needed
|
||||
3. Use read-only tools (grep, read) to look up relevant code when needed
|
||||
4. Focus on being informative and conversational -- your response IS the deliverable, not a precursor to action</{VIBE_WARNING_TAG}>"""
|
||||
|
||||
CHAT_AGENT_EXIT = f"""<{VIBE_WARNING_TAG}>Chat mode has ended. You can now use editing tools and make changes to the system.</{VIBE_WARNING_TAG}>"""
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class RemoteEventsSource:
|
|||
self._next_start_seq = 0
|
||||
self._client: WorkflowsClient | None = None
|
||||
self._translator = RemoteWorkflowEventTranslator(
|
||||
available_tools=self._tool_manager._available,
|
||||
available_tools=self._tool_manager._all_tools,
|
||||
stats=self.stats,
|
||||
merge_message=self._merge_message,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -822,11 +822,7 @@ class RemoteWorkflowEventTranslator:
|
|||
if not ui_state.operations:
|
||||
return {}
|
||||
op = ui_state.operations[0]
|
||||
return {
|
||||
"path": op.uri,
|
||||
"content": op.content,
|
||||
"overwrite": op.type == "replace",
|
||||
}
|
||||
return {"path": op.uri, "content": op.content}
|
||||
if isinstance(ui_state, CommandUIState):
|
||||
return {"command": ui_state.command}
|
||||
if isinstance(ui_state, GenericToolUIState):
|
||||
|
|
@ -853,7 +849,6 @@ class RemoteWorkflowEventTranslator:
|
|||
return {
|
||||
"path": op.uri,
|
||||
"bytes_written": len(op.content.encode()),
|
||||
"file_existed": op.type == "replace",
|
||||
"content": op.content,
|
||||
}, None
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.paths._agents_home import AGENTS_HOME
|
||||
from vibe.core.paths._local_config_walk import (
|
||||
WALK_MAX_DEPTH,
|
||||
ConfigWalkResult,
|
||||
from vibe.core.paths._local_config_files import (
|
||||
LocalConfigDirs,
|
||||
dedup_paths,
|
||||
walk_local_config_dirs,
|
||||
find_local_config_dirs,
|
||||
)
|
||||
from vibe.core.paths._vibe_home import (
|
||||
CACHE_FILE,
|
||||
|
|
@ -35,9 +34,8 @@ __all__ = [
|
|||
"SESSION_LOG_DIR",
|
||||
"TRUSTED_FOLDERS_FILE",
|
||||
"VIBE_HOME",
|
||||
"WALK_MAX_DEPTH",
|
||||
"ConfigWalkResult",
|
||||
"GlobalPath",
|
||||
"LocalConfigDirs",
|
||||
"dedup_paths",
|
||||
"walk_local_config_dirs",
|
||||
"find_local_config_dirs",
|
||||
]
|
||||
|
|
|
|||
80
vibe/core/paths/_local_config_files.py
Normal file
80
vibe/core/paths/_local_config_files.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def dedup_paths(paths: Iterable[Path]) -> list[Path]:
|
||||
"""Resolve and dedup paths, preserving first-occurrence order."""
|
||||
resolved = [p.resolve() for p in paths]
|
||||
return [p for i, p in enumerate(resolved) if p not in resolved[:i]]
|
||||
|
||||
|
||||
_VIBE_DIR = Path(".vibe")
|
||||
_TOOLS_SUBDIR = _VIBE_DIR / "tools"
|
||||
_VIBE_SKILLS_SUBDIR = _VIBE_DIR / "skills"
|
||||
_AGENTS_SUBDIR = _VIBE_DIR / "agents"
|
||||
_AGENTS_DIR = Path(".agents")
|
||||
_AGENTS_SKILLS_SUBDIR = _AGENTS_DIR / "skills"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocalConfigDirs:
|
||||
"""Local config directories discovered at a project root."""
|
||||
|
||||
config_dirs: tuple[Path, ...] = ()
|
||||
tools: tuple[Path, ...] = ()
|
||||
skills: tuple[Path, ...] = ()
|
||||
agents: tuple[Path, ...] = ()
|
||||
|
||||
def __or__(self, other: LocalConfigDirs) -> LocalConfigDirs:
|
||||
return LocalConfigDirs(
|
||||
config_dirs=tuple(dedup_paths([*self.config_dirs, *other.config_dirs])),
|
||||
tools=tuple(dedup_paths([*self.tools, *other.tools])),
|
||||
skills=tuple(dedup_paths([*self.skills, *other.skills])),
|
||||
agents=tuple(dedup_paths([*self.agents, *other.agents])),
|
||||
)
|
||||
|
||||
|
||||
def find_local_config_dirs(root: Path) -> LocalConfigDirs:
|
||||
"""Inspect *root* for ``.vibe/`` and ``.agents/`` config directories.
|
||||
|
||||
Only the root itself is examined — no recursion into subdirectories.
|
||||
"""
|
||||
resolved = root.resolve()
|
||||
config_dirs: list[Path] = []
|
||||
tools: list[Path] = []
|
||||
skills: list[Path] = []
|
||||
agents: list[Path] = []
|
||||
|
||||
vibe_dir = resolved / _VIBE_DIR
|
||||
if vibe_dir.is_dir():
|
||||
has_content = False
|
||||
if (candidate := resolved / _TOOLS_SUBDIR).is_dir():
|
||||
tools.append(candidate)
|
||||
has_content = True
|
||||
if (candidate := resolved / _VIBE_SKILLS_SUBDIR).is_dir():
|
||||
skills.append(candidate)
|
||||
has_content = True
|
||||
if (candidate := resolved / _AGENTS_SUBDIR).is_dir():
|
||||
agents.append(candidate)
|
||||
has_content = True
|
||||
if (
|
||||
has_content
|
||||
or (vibe_dir / "prompts").is_dir()
|
||||
or (vibe_dir / "config.toml").is_file()
|
||||
):
|
||||
config_dirs.append(vibe_dir)
|
||||
|
||||
agents_dir = resolved / _AGENTS_DIR
|
||||
if agents_dir.is_dir() and (candidate := resolved / _AGENTS_SKILLS_SUBDIR).is_dir():
|
||||
skills.append(candidate)
|
||||
config_dirs.append(agents_dir)
|
||||
|
||||
return LocalConfigDirs(
|
||||
config_dirs=tuple(config_dirs),
|
||||
tools=tuple(tools),
|
||||
skills=tuple(skills),
|
||||
agents=tuple(agents),
|
||||
)
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from functools import cache
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from vibe.core.autocompletion.file_indexer.ignore_rules import WALK_SKIP_DIR_NAMES
|
||||
|
||||
|
||||
def dedup_paths(paths: Iterable[Path], *, drop_nested: bool = False) -> list[Path]:
|
||||
"""Resolve and dedup paths, preserving first-occurrence order.
|
||||
|
||||
With ``drop_nested=True``, also drop paths contained inside another path
|
||||
(so adding ``/x`` and ``/x/y`` collapses to just ``/x``).
|
||||
"""
|
||||
resolved = [p.resolve() for p in paths]
|
||||
return [
|
||||
p
|
||||
for i, p in enumerate(resolved)
|
||||
if p not in resolved[:i]
|
||||
and not (drop_nested and any(p != q and p.is_relative_to(q) for q in resolved))
|
||||
]
|
||||
|
||||
|
||||
logger = logging.getLogger("vibe")
|
||||
|
||||
_VIBE_DIR = ".vibe"
|
||||
_TOOLS_SUBDIR = Path(_VIBE_DIR) / "tools"
|
||||
_VIBE_SKILLS_SUBDIR = Path(_VIBE_DIR) / "skills"
|
||||
_AGENTS_SUBDIR = Path(_VIBE_DIR) / "agents"
|
||||
_AGENTS_DIR = ".agents"
|
||||
_AGENTS_SKILLS_SUBDIR = Path(_AGENTS_DIR) / "skills"
|
||||
|
||||
WALK_MAX_DEPTH = 4
|
||||
_MAX_DIRS = 2000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConfigWalkResult:
|
||||
"""Aggregated results of a config directory walk."""
|
||||
|
||||
config_dirs: tuple[Path, ...] = ()
|
||||
tools: tuple[Path, ...] = ()
|
||||
skills: tuple[Path, ...] = ()
|
||||
agents: tuple[Path, ...] = ()
|
||||
|
||||
def __or__(self, other: ConfigWalkResult) -> ConfigWalkResult:
|
||||
return ConfigWalkResult(
|
||||
config_dirs=tuple(dedup_paths([*self.config_dirs, *other.config_dirs])),
|
||||
tools=tuple(dedup_paths([*self.tools, *other.tools])),
|
||||
skills=tuple(dedup_paths([*self.skills, *other.skills])),
|
||||
agents=tuple(dedup_paths([*self.agents, *other.agents])),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ConfigWalkCollector:
|
||||
"""Mutable accumulator used during BFS, frozen into ConfigWalkResult at the end."""
|
||||
|
||||
config_dirs: list[Path] = field(default_factory=list)
|
||||
tools: list[Path] = field(default_factory=list)
|
||||
skills: list[Path] = field(default_factory=list)
|
||||
agents: list[Path] = field(default_factory=list)
|
||||
|
||||
def freeze(self) -> ConfigWalkResult:
|
||||
return ConfigWalkResult(
|
||||
config_dirs=tuple(self.config_dirs),
|
||||
tools=tuple(self.tools),
|
||||
skills=tuple(self.skills),
|
||||
agents=tuple(self.agents),
|
||||
)
|
||||
|
||||
|
||||
def _collect_at(
|
||||
path: Path, entry_names: set[str], collector: _ConfigWalkCollector
|
||||
) -> None:
|
||||
"""Check a single directory for .vibe/ and .agents/ config subdirs."""
|
||||
if _VIBE_DIR in entry_names and (vibe_dir := path / _VIBE_DIR).is_dir():
|
||||
has_content = False
|
||||
if (candidate := path / _TOOLS_SUBDIR).is_dir():
|
||||
collector.tools.append(candidate)
|
||||
has_content = True
|
||||
if (candidate := path / _VIBE_SKILLS_SUBDIR).is_dir():
|
||||
collector.skills.append(candidate)
|
||||
has_content = True
|
||||
if (candidate := path / _AGENTS_SUBDIR).is_dir():
|
||||
collector.agents.append(candidate)
|
||||
has_content = True
|
||||
if (
|
||||
has_content
|
||||
or (vibe_dir / "prompts").is_dir()
|
||||
or (vibe_dir / "config.toml").is_file()
|
||||
):
|
||||
collector.config_dirs.append(vibe_dir)
|
||||
if _AGENTS_DIR in entry_names and (agents_dir := path / _AGENTS_DIR).is_dir():
|
||||
if (candidate := path / _AGENTS_SKILLS_SUBDIR).is_dir():
|
||||
collector.skills.append(candidate)
|
||||
collector.config_dirs.append(agents_dir)
|
||||
|
||||
|
||||
def _scandir_entries(path: Path) -> tuple[set[str], list[Path]]:
|
||||
"""Scan a directory, returning entry names and sorted child directories to descend into.
|
||||
|
||||
Uses ``os.scandir`` so that ``DirEntry.is_dir()`` leverages the dirent
|
||||
d_type field and avoids a separate ``stat`` syscall on most filesystems.
|
||||
"""
|
||||
try:
|
||||
entries = list(os.scandir(path))
|
||||
except OSError:
|
||||
return set(), []
|
||||
|
||||
entry_names = {e.name for e in entries}
|
||||
children: list[Path] = []
|
||||
for entry in entries:
|
||||
name = entry.name
|
||||
if name in WALK_SKIP_DIR_NAMES or name.startswith("."):
|
||||
continue
|
||||
try:
|
||||
if entry.is_dir():
|
||||
children.append(path / name)
|
||||
except OSError:
|
||||
continue
|
||||
children.sort()
|
||||
return entry_names, children
|
||||
|
||||
|
||||
@cache
|
||||
def walk_local_config_dirs(
|
||||
root: Path, *, max_depth: int = WALK_MAX_DEPTH, max_dirs: int = _MAX_DIRS
|
||||
) -> ConfigWalkResult:
|
||||
"""Discover .vibe/ and .agents/ config directories under *root*.
|
||||
|
||||
Uses breadth-first search bounded by *max_depth* and *max_dirs*
|
||||
to avoid unbounded traversal in large repositories.
|
||||
|
||||
Returns a ``ConfigWalkResult`` containing both the parent config dirs
|
||||
(for trust decisions) and the categorised subdirs (for loading).
|
||||
"""
|
||||
collector = _ConfigWalkCollector()
|
||||
resolved_root = root.resolve()
|
||||
queue: deque[tuple[Path, int]] = deque([(resolved_root, 0)])
|
||||
visited = 0
|
||||
|
||||
while queue and visited < max_dirs:
|
||||
current, depth = queue.popleft()
|
||||
visited += 1
|
||||
|
||||
entry_names, children = _scandir_entries(current)
|
||||
if not entry_names:
|
||||
continue
|
||||
|
||||
_collect_at(current, entry_names, collector)
|
||||
|
||||
if depth < max_depth:
|
||||
queue.extend((child, depth + 1) for child in children)
|
||||
|
||||
if visited >= max_dirs:
|
||||
logger.warning(
|
||||
"Config directory scan reached directory limit (%d dirs) at %s",
|
||||
max_dirs,
|
||||
resolved_root,
|
||||
)
|
||||
|
||||
return collector.freeze()
|
||||
|
|
@ -76,7 +76,7 @@ When editing:
|
|||
|
||||
- Match existing style (indentation, naming, error handling density).
|
||||
- Minimal diff. Remove completely when removing — no `_unused` renames, no `// removed` comments, no wrapper shims. Update all call sites.
|
||||
- Whitespace and line endings matter for search_replace. Copy exactly from the read.
|
||||
- Whitespace matters for `edit`. Copy `old_string` exactly from the read.
|
||||
|
||||
**Prove it worked**
|
||||
|
||||
|
|
@ -93,7 +93,7 @@ You are **not** done when the edit landed, when there are no syntax errors, or w
|
|||
If you see any of these, the current approach is not working:
|
||||
|
||||
- `lines_changed: 0` or a no-op result
|
||||
- `diff_error`, "string not found", repeated search_replace failures
|
||||
- `diff_error`, "string not found", repeated `edit` failures
|
||||
- The same error twice in a row
|
||||
- Three edits to the same file without the problem resolving
|
||||
- Whitespace/CRLF mismatch
|
||||
|
|
|
|||
56
vibe/core/session/image_snapshot.py
Normal file
56
vibe/core/session/image_snapshot.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
|
||||
from vibe.core.types import IMAGE_EXTENSIONS, ImageAttachment
|
||||
|
||||
_DEFAULT_MIME_BY_EXT = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
|
||||
|
||||
class ImageSnapshotError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def snapshot_image(
|
||||
source: Path, *, alias: str, session_dir: Path | None
|
||||
) -> ImageAttachment:
|
||||
source_abs = source.expanduser().resolve()
|
||||
if not source_abs.is_file():
|
||||
raise ImageSnapshotError(f"Not a file: {source_abs}")
|
||||
|
||||
ext = source_abs.suffix.lower()
|
||||
if ext not in IMAGE_EXTENSIONS:
|
||||
raise ImageSnapshotError(f"Unsupported image extension: {ext}")
|
||||
|
||||
mime_type = _DEFAULT_MIME_BY_EXT.get(ext) or (
|
||||
mimetypes.guess_type(source_abs.name)[0] or "application/octet-stream"
|
||||
)
|
||||
|
||||
try:
|
||||
data = source_abs.read_bytes()
|
||||
except OSError as e:
|
||||
raise ImageSnapshotError(f"Failed to read image {source_abs}: {e}") from e
|
||||
|
||||
# Session logging disabled: no snapshot copy is made; the attachment
|
||||
# points to the original source. Resume is not possible in this mode so
|
||||
# snapshot stability is not required.
|
||||
if session_dir is None:
|
||||
return ImageAttachment(path=source_abs, alias=alias, mime_type=mime_type)
|
||||
|
||||
attachments_dir = session_dir / "attachments"
|
||||
attachments_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
digest = hashlib.sha1(data, usedforsecurity=False).hexdigest()
|
||||
dest = attachments_dir / f"{digest}{ext}"
|
||||
if not dest.exists():
|
||||
dest.write_bytes(data)
|
||||
|
||||
return ImageAttachment(path=dest.resolve(), alias=alias, mime_type=mime_type)
|
||||
|
|
@ -96,3 +96,37 @@ def load(config: SessionLoggingConfig) -> str | None:
|
|||
logger.debug("Failed to read last session pointer path=%s err=%s", path, e)
|
||||
return None
|
||||
return content or None
|
||||
|
||||
|
||||
def clear_matching(config: SessionLoggingConfig, session_id: str) -> None:
|
||||
if not session_id or not config.enabled:
|
||||
return
|
||||
|
||||
pointer_dir = _pointer_dir(config)
|
||||
if not pointer_dir.is_dir():
|
||||
return
|
||||
|
||||
try:
|
||||
pointer_paths = list(pointer_dir.iterdir())
|
||||
except OSError as e:
|
||||
logger.debug(
|
||||
"Failed to list last session pointers path=%s err=%s", pointer_dir, e
|
||||
)
|
||||
return
|
||||
|
||||
for path in pointer_paths:
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
content = read_safe(path).text.strip()
|
||||
except OSError as e:
|
||||
logger.debug("Failed to read last session pointer path=%s err=%s", path, e)
|
||||
continue
|
||||
|
||||
if content != session_id:
|
||||
continue
|
||||
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError as e:
|
||||
logger.debug("Failed to clear last session pointer path=%s err=%s", path, e)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from typing import Any
|
||||
|
||||
from vibe.core.config import SessionLoggingConfig
|
||||
from vibe.core.session import last_session_pointer
|
||||
from vibe.core.session.session_loader import METADATA_FILENAME, SessionLoader
|
||||
from vibe.core.session.session_logger import SessionLogger
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
|
@ -21,23 +24,37 @@ def _normalize_session_title(title: str) -> str:
|
|||
def _resolve_saved_session_dir(
|
||||
session_id: str, session_config: SessionLoggingConfig
|
||||
) -> Path:
|
||||
session_dir = _find_saved_session_dir(session_id, session_config)
|
||||
if session_dir is None:
|
||||
raise ValueError(f"Session not found: {session_id}")
|
||||
|
||||
return session_dir
|
||||
|
||||
|
||||
def _find_saved_session_dir(
|
||||
session_id: str, session_config: SessionLoggingConfig
|
||||
) -> Path | None:
|
||||
for session_dir in SessionLoader._find_session_dirs_by_short_id(
|
||||
session_id, session_config
|
||||
):
|
||||
try:
|
||||
metadata = _load_raw_metadata(session_dir)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
continue
|
||||
|
||||
if metadata.get("session_id") == session_id:
|
||||
return session_dir
|
||||
|
||||
raise ValueError(f"Session not found: {session_id}")
|
||||
return None
|
||||
|
||||
|
||||
def _load_raw_metadata(session_dir: Path) -> dict[str, Any]:
|
||||
metadata_path = session_dir / METADATA_FILENAME
|
||||
return json.loads(read_safe(metadata_path).text)
|
||||
metadata = json.loads(read_safe(metadata_path).text)
|
||||
if not isinstance(metadata, dict):
|
||||
raise ValueError(f"Session metadata must be an object: {metadata_path}")
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
async def update_saved_session_title_at_path(
|
||||
|
|
@ -56,3 +73,15 @@ async def update_saved_session_title(
|
|||
) -> dict[str, Any]:
|
||||
session_dir = _resolve_saved_session_dir(session_id, session_config)
|
||||
return await update_saved_session_title_at_path(session_dir, title)
|
||||
|
||||
|
||||
async def delete_saved_session(
|
||||
session_id: str, session_config: SessionLoggingConfig
|
||||
) -> None:
|
||||
session_dir = _find_saved_session_dir(session_id, session_config)
|
||||
if session_dir is None:
|
||||
last_session_pointer.clear_matching(session_config, session_id)
|
||||
return
|
||||
|
||||
await asyncio.to_thread(shutil.rmtree, session_dir)
|
||||
last_session_pointer.clear_matching(session_config, session_id)
|
||||
|
|
|
|||
|
|
@ -302,7 +302,9 @@ class SessionLogger:
|
|||
if len(new_messages) == 0:
|
||||
return
|
||||
|
||||
messages_data = [m.model_dump(exclude_none=True) for m in new_messages]
|
||||
messages_data = [
|
||||
m.model_dump(exclude_none=True, mode="json") for m in new_messages
|
||||
]
|
||||
await SessionLogger.persist_messages(messages_data, session_dir)
|
||||
|
||||
# If message update succeeded, write metadata
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.skills.manager import SkillManager
|
||||
from vibe.core.skills.models import SkillInfo, SkillMetadata
|
||||
from vibe.core.skills.models import SkillConfigIssue, SkillInfo, SkillMetadata
|
||||
from vibe.core.skills.parser import SkillParseError
|
||||
|
||||
__all__ = ["SkillInfo", "SkillManager", "SkillMetadata", "SkillParseError"]
|
||||
__all__ = [
|
||||
"SkillConfigIssue",
|
||||
"SkillInfo",
|
||||
"SkillManager",
|
||||
"SkillMetadata",
|
||||
"SkillParseError",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ input_price = 1.5
|
|||
output_price = 7.5
|
||||
thinking = "high" # "off", "low", "medium", "high", "max"
|
||||
auto_compact_threshold = 200000
|
||||
supports_images = true # vision-capable; allows @-mentioned images
|
||||
|
||||
[[models]]
|
||||
name = "devstral-small-latest"
|
||||
|
|
@ -147,7 +148,7 @@ alias = "local"
|
|||
tool_paths = ["/path/to/custom/tools"]
|
||||
|
||||
# Enable only specific tools (glob and regex supported)
|
||||
enabled_tools = ["bash", "read_file", "grep"]
|
||||
enabled_tools = ["bash", "read", "grep"]
|
||||
|
||||
# Disable specific tools
|
||||
disabled_tools = ["webfetch"]
|
||||
|
|
@ -163,7 +164,7 @@ prompt for user permission before running the command.
|
|||
|
||||
#### File Tool Permission Resolution
|
||||
|
||||
File-based tools (`read_file`, `grep`, `write_file`, `search_replace`) resolve
|
||||
File-based tools (`read`, `grep`, `write_file`, `edit`) resolve
|
||||
permissions in this order (first match wins):
|
||||
|
||||
1. **Scratchpad** path → always allowed
|
||||
|
|
@ -366,7 +367,7 @@ fix the problems. After 3 failed retries the hook stops retrying.
|
|||
### Pattern Matching
|
||||
|
||||
Tool, skill, and agent names support three matching modes:
|
||||
- **Exact**: `"bash"`, `"read_file"`
|
||||
- **Exact**: `"bash"`, `"read"`
|
||||
- **Glob**: `"bash*"`, `"mcp_*"`
|
||||
- **Regex**: `"re:^serena_.*$"` (full match, case-insensitive)
|
||||
|
||||
|
|
@ -409,7 +410,7 @@ There are two kinds of agents:
|
|||
|
||||
### Subagents
|
||||
|
||||
- **explore**: Read-only codebase exploration subagent (grep + read_file only).
|
||||
- **explore**: Read-only codebase exploration subagent (grep + read only).
|
||||
Spawned by the model, not selectable by the user.
|
||||
|
||||
Custom agents are TOML files in `~/.vibe/agents/NAME.toml`.
|
||||
|
|
@ -448,6 +449,40 @@ Custom agents are TOML files in `~/.vibe/agents/NAME.toml`.
|
|||
- `/teleport` - Teleport session to Vibe Code Web (only available when Vibe Code is enabled)
|
||||
- `/exit` - Exit the application
|
||||
|
||||
## File Mentions (`@`)
|
||||
|
||||
Type `@` in the chat input to autocomplete files and folders from the
|
||||
project tree. Pressing Tab/Enter inserts the chosen path. Behavior
|
||||
depends on the file kind:
|
||||
|
||||
- **Text files** are read at submit and their contents are inlined into the
|
||||
prompt text (up to ~256KB).
|
||||
- **Folders** are inserted as a resource link header (name + uri).
|
||||
- **Image files** (`.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`) become image
|
||||
attachments — sent alongside the prompt as native multimodal content for
|
||||
vision-capable models.
|
||||
|
||||
Image attachments:
|
||||
|
||||
- Require `supports_images = true` on the active model in `config.toml`.
|
||||
By default this is enabled only on `mistral-vibe-cli-latest`. Sending
|
||||
images to a non-vision model raises a clear error and the message is
|
||||
not added to the conversation.
|
||||
- Snapshotted into `<session_dir>/attachments/<sha1>.<ext>` so that
|
||||
resumed sessions stay reproducible even if the source file is moved.
|
||||
- Capped at 10 MB per image and 8 images per message.
|
||||
- Out-of-project paths work via `@/abs/path/to.png` (the picker only
|
||||
suggests project files, but the `@`-parser accepts absolute paths).
|
||||
Drag-and-drop from Finder into Terminal, iTerm2, or Ghostty is
|
||||
intercepted at paste time: if the pasted content is a single bare
|
||||
path to an image file (raw, `\\ `-escaped, or quoted), the input
|
||||
automatically prepends `@` (and quotes paths containing spaces).
|
||||
Non-image paths are pasted verbatim so non-image use cases are not
|
||||
affected.
|
||||
- Rendered in the chat bubble as a dim footer line linking each
|
||||
attachment to its snapshot. Clicking opens the file with the OS
|
||||
default image viewer.
|
||||
|
||||
## Skills System
|
||||
|
||||
Skills are specialized instruction sets the model can load on demand.
|
||||
|
|
@ -460,7 +495,7 @@ Each skill is a directory containing a `SKILL.md` file with YAML frontmatter.
|
|||
name: my-skill
|
||||
description: What this skill does and when to use it.
|
||||
user-invocable: true
|
||||
allowed-tools: bash read_file
|
||||
allowed-tools: bash read
|
||||
---
|
||||
|
||||
# Skill Instructions
|
||||
|
|
@ -511,9 +546,12 @@ directories. The trust database is stored in `~/.vibe/trusted_folders.toml`.
|
|||
Project-local config (`.vibe/` directory) is only loaded when the current
|
||||
directory is explicitly trusted.
|
||||
|
||||
Interactive mode prompts to trust unknown folders. Programmatic mode
|
||||
(`-p`/`--prompt`) never prompts: the folder is untrusted. Use `--trust` to
|
||||
trust cwd for the current invocation only (not persisted).
|
||||
Interactive mode prompts to trust unknown folders. The prompt targets the
|
||||
closest ancestor of the cwd (the cwd itself included) containing a `.git`
|
||||
entry; the search excludes the user's home directory and the filesystem
|
||||
root, and falls back to the cwd if no qualifying ancestor is found.
|
||||
Programmatic mode (`-p`/`--prompt`) never prompts: the folder is untrusted.
|
||||
Use `--trust` to trust cwd for the current invocation only (not persisted).
|
||||
|
||||
## Sensitive Files — DO NOT READ OR EDIT
|
||||
|
||||
|
|
@ -523,7 +561,7 @@ NEVER read, display, or edit any of these files:
|
|||
|
||||
If the user asks to set or change an API key, instruct them to edit the `.env`
|
||||
file themselves. Do not offer to read it, write it, or display its contents.
|
||||
Do not use tools (read_file, write_file, bash cat/echo, etc.) to access these files.
|
||||
Do not use tools (read, write_file, bash cat/echo, etc.) to access these files.
|
||||
|
||||
## How to Modify Configuration
|
||||
|
||||
|
|
@ -535,7 +573,7 @@ To help the user modify their Vibe configuration:
|
|||
same directory (e.g. `cp ~/.vibe/config.toml ~/.vibe/config.toml.bak`). This
|
||||
applies to any config file you are about to modify (`config.toml`,
|
||||
`trusted_folders.toml`, agent TOML files, etc.)
|
||||
3. **Edit the TOML file**: Make changes using the search_replace or write_file tool
|
||||
3. **Edit the TOML file**: Make changes using the edit tool
|
||||
4. **Reload**: The user can run `/reload` to apply changes without restarting
|
||||
|
||||
For API keys, tell the user to edit `~/.vibe/.env` directly — never read or
|
||||
|
|
|
|||
|
|
@ -8,7 +8,12 @@ from typing import TYPE_CHECKING
|
|||
from vibe.core.config.harness_files import get_harness_files_manager
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.skills.builtins import BUILTIN_SKILLS
|
||||
from vibe.core.skills.models import ParsedSkillCommand, SkillInfo, SkillMetadata
|
||||
from vibe.core.skills.models import (
|
||||
ParsedSkillCommand,
|
||||
SkillConfigIssue,
|
||||
SkillInfo,
|
||||
SkillMetadata,
|
||||
)
|
||||
from vibe.core.skills.parser import SkillParseError, parse_skill_markdown
|
||||
from vibe.core.utils import name_matches
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
|
@ -21,6 +26,7 @@ class SkillManager:
|
|||
def __init__(self, config_getter: Callable[[], VibeConfig]) -> None:
|
||||
self._config_getter = config_getter
|
||||
self._search_paths = self._compute_search_paths(self._config)
|
||||
self._config_issues: list[SkillConfigIssue] = []
|
||||
self.available_skills: Mapping[str, SkillInfo] = MappingProxyType(
|
||||
self._apply_filters(self._discover_skills())
|
||||
)
|
||||
|
|
@ -36,6 +42,10 @@ class SkillManager:
|
|||
def _config(self) -> VibeConfig:
|
||||
return self._config_getter()
|
||||
|
||||
@property
|
||||
def config_issues(self) -> tuple[SkillConfigIssue, ...]:
|
||||
return tuple(self._config_issues)
|
||||
|
||||
def _apply_filters(self, skills: dict[str, SkillInfo]) -> dict[str, SkillInfo]:
|
||||
if self._config.enabled_skills:
|
||||
return {
|
||||
|
|
@ -121,6 +131,9 @@ class SkillManager:
|
|||
skill_info = self._parse_skill_file(skill_file)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to parse skill at %s: %s", skill_file, e)
|
||||
self._config_issues.append(
|
||||
SkillConfigIssue(file=skill_file, message=f"Failed to load: {e}")
|
||||
)
|
||||
return None
|
||||
return skill_info
|
||||
|
||||
|
|
|
|||
|
|
@ -98,6 +98,11 @@ class SkillInfo(BaseModel):
|
|||
)
|
||||
|
||||
|
||||
class SkillConfigIssue(BaseModel):
|
||||
file: Path
|
||||
message: str
|
||||
|
||||
|
||||
class ParsedSkillCommand(BaseModel):
|
||||
name: str
|
||||
content: str
|
||||
|
|
|
|||
|
|
@ -193,13 +193,9 @@ class TelemetryClient:
|
|||
nb_files_modified = 0
|
||||
if status == "success" and result is not None:
|
||||
if tool_call.tool_name == "write_file":
|
||||
file_existed = result.get("file_existed", False)
|
||||
if file_existed:
|
||||
nb_files_modified = 1
|
||||
else:
|
||||
nb_files_created = 1
|
||||
elif tool_call.tool_name == "search_replace":
|
||||
nb_files_modified = 1 if result.get("blocks_applied", 0) > 0 else 0
|
||||
nb_files_created = 1
|
||||
elif tool_call.tool_name == "edit":
|
||||
nb_files_modified = 1
|
||||
return nb_files_created, nb_files_modified
|
||||
|
||||
def send_tool_call_finished(
|
||||
|
|
|
|||
|
|
@ -58,6 +58,13 @@ class NuageRequest(BaseModel):
|
|||
context: NuageContext
|
||||
|
||||
|
||||
class TeleportSession(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
metadata: dict[str, object]
|
||||
messages: list[dict[str, object]]
|
||||
|
||||
|
||||
class NuageResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
|
|
|||
205
vibe/core/tools/builtins/edit.py
Normal file
205
vibe/core/tools/builtins/edit.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, final
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from vibe.core.rewind.manager import FileSnapshot
|
||||
from vibe.core.scratchpad import is_scratchpad_path
|
||||
from vibe.core.tools.base import (
|
||||
BaseTool,
|
||||
BaseToolConfig,
|
||||
BaseToolState,
|
||||
InvokeContext,
|
||||
ToolError,
|
||||
ToolPermission,
|
||||
)
|
||||
from vibe.core.tools.permissions import PermissionContext
|
||||
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
||||
from vibe.core.tools.utils import resolve_file_tool_permission
|
||||
from vibe.core.types import ToolResultEvent, ToolStreamEvent
|
||||
from vibe.core.utils.io import (
|
||||
ReadSafeResult,
|
||||
atomic_replace,
|
||||
file_write_lock,
|
||||
read_safe_async,
|
||||
)
|
||||
|
||||
|
||||
class EditArgs(BaseModel):
|
||||
file_path: str = Field(description="The absolute path to the file to modify")
|
||||
old_string: str = Field(description="The text to replace")
|
||||
new_string: str = Field(
|
||||
description="The text to replace it with (must be different from old_string)"
|
||||
)
|
||||
replace_all: bool = Field(
|
||||
default=False,
|
||||
description="Replace all occurrences of old_string (default false)",
|
||||
)
|
||||
|
||||
|
||||
class EditResult(BaseModel):
|
||||
file: str
|
||||
message: str
|
||||
old_string: str
|
||||
new_string: str
|
||||
|
||||
|
||||
class EditConfig(BaseToolConfig):
|
||||
permission: ToolPermission = ToolPermission.ASK
|
||||
sensitive_patterns: list[str] = Field(
|
||||
default=["**/.env", "**/.env.*"],
|
||||
description="File patterns that trigger ASK even when permission is ALWAYS.",
|
||||
)
|
||||
|
||||
|
||||
class Edit(
|
||||
BaseTool[EditArgs, EditResult, EditConfig, BaseToolState],
|
||||
ToolUIData[EditArgs, EditResult],
|
||||
):
|
||||
description: ClassVar[str] = (
|
||||
"Perform exact string replacements in files. "
|
||||
"Supports single or bulk (replace_all) substitutions "
|
||||
"with atomic, concurrent-safe writes."
|
||||
)
|
||||
|
||||
def resolve_permission(self, args: EditArgs) -> PermissionContext | None:
|
||||
return resolve_file_tool_permission(
|
||||
args.file_path,
|
||||
tool_name=self.get_name(),
|
||||
allowlist=self.config.allowlist,
|
||||
denylist=self.config.denylist,
|
||||
config_permission=self.config.permission,
|
||||
sensitive_patterns=self.config.sensitive_patterns,
|
||||
)
|
||||
|
||||
def get_file_snapshot(self, args: EditArgs) -> FileSnapshot | None:
|
||||
return self.get_file_snapshot_for_path(args.file_path)
|
||||
|
||||
@classmethod
|
||||
def format_call_display(cls, args: EditArgs) -> ToolCallDisplay:
|
||||
tag = " (scratchpad)" if is_scratchpad_path(args.file_path) else ""
|
||||
return ToolCallDisplay(
|
||||
summary=f"Editing {Path(args.file_path).name}{tag}",
|
||||
content=f"old_string: {args.old_string!r}\nnew_string: {args.new_string!r}",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
||||
if isinstance(event.result, EditResult):
|
||||
tag = " (scratchpad)" if is_scratchpad_path(event.result.file) else ""
|
||||
return ToolResultDisplay(
|
||||
success=True, message=f"Edited {Path(event.result.file).name}{tag}"
|
||||
)
|
||||
return ToolResultDisplay(
|
||||
success=False, message=event.error or event.skip_reason or "No result"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_status_text(cls) -> str:
|
||||
return "Editing files"
|
||||
|
||||
async def _read_file(self, file_path: Path) -> ReadSafeResult:
|
||||
return await read_safe_async(file_path, raise_on_error=True)
|
||||
|
||||
async def _write_file(
|
||||
self, file_path: Path, content: str, encoding: str, newline: str
|
||||
) -> None:
|
||||
await atomic_replace(file_path, content, encoding=encoding, newline=newline)
|
||||
|
||||
@final
|
||||
async def run(
|
||||
self, args: EditArgs, ctx: InvokeContext | None = None
|
||||
) -> AsyncGenerator[ToolStreamEvent | EditResult, None]:
|
||||
file_path = self._validate_args(args)
|
||||
|
||||
try:
|
||||
async with file_write_lock(file_path):
|
||||
result = await self._read_file(file_path)
|
||||
original = result.text
|
||||
|
||||
if args.old_string not in original:
|
||||
raise ToolError(
|
||||
f"String to replace not found in file.\n"
|
||||
f"String: {args.old_string}"
|
||||
)
|
||||
occurrences = original.count(args.old_string)
|
||||
if occurrences > 1 and not args.replace_all:
|
||||
raise ToolError(
|
||||
f"Found {occurrences} matches of the string to replace, "
|
||||
f"but replace_all is false. To replace all occurrences, "
|
||||
f"set replace_all to true. To replace only one occurrence, "
|
||||
f"please provide more context to uniquely identify the "
|
||||
f"instance.\nString: {args.old_string}"
|
||||
)
|
||||
|
||||
modified = self._apply_edit(
|
||||
original, args.old_string, args.new_string, args.replace_all
|
||||
)
|
||||
|
||||
if modified != original:
|
||||
await self._write_file(
|
||||
file_path, modified, result.encoding, result.newline
|
||||
)
|
||||
except UnicodeDecodeError as e:
|
||||
raise ToolError(
|
||||
f"Cannot edit {file_path}: file is not valid text "
|
||||
f"({e.encoding}, byte {e.start})"
|
||||
) from e
|
||||
except PermissionError as e:
|
||||
raise ToolError(f"Permission denied accessing file: {file_path}") from e
|
||||
except OSError as e:
|
||||
raise ToolError(f"OS error accessing {file_path}: {e}") from e
|
||||
|
||||
if args.replace_all:
|
||||
message = (
|
||||
"The file has been updated. All occurrences were successfully replaced"
|
||||
)
|
||||
else:
|
||||
message = "The file has been updated successfully."
|
||||
|
||||
yield EditResult(
|
||||
file=str(file_path),
|
||||
message=message,
|
||||
old_string=args.old_string,
|
||||
new_string=args.new_string,
|
||||
)
|
||||
|
||||
@final
|
||||
def _validate_args(self, args: EditArgs) -> Path:
|
||||
file_path_str = args.file_path.strip()
|
||||
if not file_path_str:
|
||||
raise ToolError("File path cannot be empty")
|
||||
|
||||
if not args.old_string:
|
||||
raise ToolError(
|
||||
"old_string cannot be empty. Use write_file to create new files."
|
||||
)
|
||||
|
||||
if args.old_string == args.new_string:
|
||||
raise ToolError(
|
||||
"No changes to make — old_string and new_string are identical"
|
||||
)
|
||||
|
||||
file_path = Path(file_path_str).expanduser()
|
||||
if not file_path.is_absolute():
|
||||
file_path = Path.cwd() / file_path
|
||||
file_path = file_path.resolve()
|
||||
|
||||
if not file_path.exists():
|
||||
raise ToolError(f"File does not exist: {file_path}")
|
||||
|
||||
if not file_path.is_file():
|
||||
raise ToolError(f"Path is not a file: {file_path}")
|
||||
|
||||
return file_path
|
||||
|
||||
@staticmethod
|
||||
def _apply_edit(
|
||||
content: str, old_string: str, new_string: str, replace_all: bool
|
||||
) -> str:
|
||||
if replace_all:
|
||||
return content.replace(old_string, new_string)
|
||||
return content.replace(old_string, new_string, 1)
|
||||
|
|
@ -11,13 +11,13 @@ Use the `bash` tool to run one-off shell commands.
|
|||
**IMPORTANT: Use dedicated tools if available instead of these bash commands:**
|
||||
|
||||
**File Operations - DO NOT USE:**
|
||||
- `cat filename` → Use `read_file(path="filename")`
|
||||
- `head -n 20 filename` → Use `read_file(path="filename", limit=20)`
|
||||
- `tail -n 20 filename` → Read with offset: `read_file(path="filename", offset=<line_number>, limit=20)`
|
||||
- `sed -n '100,200p' filename` → Use `read_file(path="filename", offset=99, limit=101)`
|
||||
- `less`, `more`, `vim`, `nano` → Use `read_file` with offset/limit for navigation
|
||||
- `cat filename` → Use `read(file_path="filename")`
|
||||
- `head -n 20 filename` → Use `read(file_path="filename", limit=20)`
|
||||
- `tail -n 20 filename` → Read with offset: `read(file_path="filename", offset=<line_number>, limit=20)`
|
||||
- `sed -n '100,200p' filename` → Use `read(file_path="filename", offset=100, limit=101)`
|
||||
- `less`, `more`, `vim`, `nano` → Use `read` with offset/limit for navigation
|
||||
- `echo "content" > file` → Use `write_file(path="file", content="content")`
|
||||
- `echo "content" >> file` → Read first, then `write_file` with overwrite=true
|
||||
- `echo "content" >> existing_file` → Read first, then use `search_replace` to append (write_file refuses to overwrite)
|
||||
|
||||
**Search Operations - DO NOT USE:**
|
||||
- `grep -r "pattern" .` → Use `grep(pattern="pattern", path=".")`
|
||||
|
|
@ -26,9 +26,9 @@ Use the `bash` tool to run one-off shell commands.
|
|||
- `locate` → Use `grep` tool
|
||||
|
||||
**File Modification - DO NOT USE:**
|
||||
- `sed -i 's/old/new/g' file` → Use `search_replace` tool
|
||||
- `awk` for file editing → Use `search_replace` tool
|
||||
- Any in-place file editing → Use `search_replace` tool
|
||||
- `sed -i 's/old/new/g' file` → Use `edit` tool
|
||||
- `awk` for file editing → Use `edit` tool
|
||||
- Any in-place file editing → Use `edit` tool
|
||||
|
||||
**APPROPRIATE bash uses:**
|
||||
- System information: `pwd`, `whoami`, `date`, `uname -a`
|
||||
|
|
@ -51,9 +51,9 @@ bash("head -1000 large_file.txt") # Inefficient
|
|||
RIGHT:
|
||||
```python
|
||||
# First chunk
|
||||
read_file(path="large_file.txt", limit=1000)
|
||||
# If was_truncated=true, read next chunk
|
||||
read_file(path="large_file.txt", offset=1000, limit=1000)
|
||||
read(file_path="large_file.txt", limit=1000)
|
||||
# If output is truncated, read next chunk
|
||||
read(file_path="large_file.txt", offset=1001, limit=1000)
|
||||
```
|
||||
|
||||
**Example: Searching for patterns**
|
||||
|
|
|
|||
19
vibe/core/tools/builtins/prompts/edit.md
Normal file
19
vibe/core/tools/builtins/prompts/edit.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
Use `edit` to make exact string replacements in files.
|
||||
|
||||
**Arguments:**
|
||||
- `file_path`: The path to the file to modify
|
||||
- `old_string`: The exact text to find and replace
|
||||
- `new_string`: The replacement text (must differ from old_string)
|
||||
- `replace_all`: Set to `true` to replace all occurrences (default: `false`)
|
||||
|
||||
**IMPORTANT:**
|
||||
|
||||
- **ALWAYS** call `read` on the target before `edit`. The on-disk content may have changed since you last saw it (user edits, prior tool calls, external processes). Operating on stale content will either fail the exact-match check or silently apply the edit to the wrong place.
|
||||
- The `old_string` must match the file content exactly, including whitespace and indentation
|
||||
- When editing text from `read` output, match only the content AFTER the line number prefix (the ` 1→` part is not in the file). Never include any part of the line number prefix in old_string or new_string.
|
||||
- If `old_string` appears multiple times, the edit will fail unless `replace_all` is `true`. Either provide more surrounding context to uniquely identify the target, or set `replace_all` to `true`.
|
||||
- Use `replace_all` for renaming variables or strings across the file
|
||||
- Prefer editing existing files over writing new ones
|
||||
- `old_string` cannot be empty; use `write_file` to create new files
|
||||
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
||||
- If an `edit` fails because the `old_string` was not found, re-read the file before retrying — do not guess at variations.
|
||||
19
vibe/core/tools/builtins/prompts/read.md
Normal file
19
vibe/core/tools/builtins/prompts/read.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
Use `read` to read the content of a file with line numbers. It handles encoding safely.
|
||||
|
||||
- By default, it reads up to 2000 lines from the beginning of the file
|
||||
- Output larger than 50KB returns an error; use `offset` and `limit` for larger files
|
||||
- Results include line numbers in ` 1→content` format (1-indexed)
|
||||
- Use `offset` (1-indexed line number) and `limit` to read specific portions
|
||||
- This is more efficient than using `bash` with `cat` or `wc`
|
||||
|
||||
**Strategy for large files:**
|
||||
|
||||
1. Call `read` without offset/limit to get the start of the file
|
||||
2. If the output is too large, use `offset` and `limit` to read targeted sections
|
||||
3. Prefer `grep` to find specific content rather than reading sequentially chunk by chunk
|
||||
4. Do not call `read` more than 3 times on the same file without responding to the user first
|
||||
|
||||
**Do not read:**
|
||||
- Model checkpoint directories or weight files (.bin, .safetensors, .pt, .gguf, optimizer states, etc.)
|
||||
- Binary files of any kind
|
||||
- Entire directory trees of training runs or large codebases. If the user provides paths to such files, treat them as references. Do not open them unless the user explicitly asks you to inspect a specific file.
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
Use `read_file` to read the content of a file. It's designed to handle large files safely.
|
||||
|
||||
- By default, it reads from the beginning of the file.
|
||||
- Use `offset` (line number) and `limit` (number of lines) to read specific parts or chunks of a file. This is efficient for exploring large files.
|
||||
- The result includes `was_truncated: true` if the file content was cut short due to size limits.
|
||||
- This is more efficient than using `bash` with `cat` or `wc`.
|
||||
|
||||
**Strategy for large files:**
|
||||
|
||||
1. Call `read_file` with a `limit` (e.g., 1000 lines) to get the start of the file.
|
||||
2. If `was_truncated` is true, the file is large. STOP and assess: do you already have enough information to answer the user's question? If yes, respond immediately — do not keep reading.
|
||||
3. If you need more, prefer targeted reads (e.g., jump to a specific offset, read the last 100 lines, search for a relevant section) over reading sequentially chunk by chunk.
|
||||
4. Do not call `read_file` more than 3 times on the same file without responding to the user first.
|
||||
|
||||
**Do not read or explore:**
|
||||
- Model checkpoint directories or weight files (.bin, .safetensors, .pt, .gguf, optimizer states, etc.)
|
||||
- Binary files of any kind
|
||||
- Entire directory trees of training runs or large codebases. If the user provides paths to such files, treat them as references. Do not open them unless the user explicitly asks you to inspect a specific file.
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
Use `search_replace` to make targeted changes to files using SEARCH/REPLACE blocks. This tool finds exact text matches and replaces them.
|
||||
|
||||
Arguments:
|
||||
- `file_path`: The path to the file to modify
|
||||
- `content`: The SEARCH/REPLACE blocks defining the changes
|
||||
|
||||
The content format is:
|
||||
|
||||
```
|
||||
<<<<<<< SEARCH
|
||||
[exact text to find in the file]
|
||||
=======
|
||||
[exact text to replace it with]
|
||||
>>>>>>> REPLACE
|
||||
```
|
||||
|
||||
You can include multiple SEARCH/REPLACE blocks to make multiple changes to the same file:
|
||||
|
||||
```
|
||||
<<<<<<< SEARCH
|
||||
def old_function():
|
||||
return "old value"
|
||||
=======
|
||||
def new_function():
|
||||
return "new value"
|
||||
>>>>>>> REPLACE
|
||||
|
||||
<<<<<<< SEARCH
|
||||
import os
|
||||
=======
|
||||
import os
|
||||
import sys
|
||||
>>>>>>> REPLACE
|
||||
```
|
||||
|
||||
IMPORTANT:
|
||||
|
||||
- The SEARCH text must match EXACTLY (including whitespace, indentation, and line endings)
|
||||
- The SEARCH text must appear exactly once in the file - if it appears multiple times, the tool will error
|
||||
- Use at least 5 equals signs (=====) between SEARCH and REPLACE sections
|
||||
- The tool will provide detailed error messages showing context if search text is not found
|
||||
- Each search/replace block is applied in order, so later blocks see the results of earlier ones
|
||||
- Be careful with escape sequences in string literals - use \n not \\n for newlines in code
|
||||
|
|
@ -1,42 +1,27 @@
|
|||
Use `write_file` to write content to a file.
|
||||
Use `write_file` to create a new file.
|
||||
|
||||
**Arguments:**
|
||||
- `path`: The file path (relative or absolute)
|
||||
- `content`: The content to write to the file
|
||||
- `overwrite`: Must be set to `true` to overwrite an existing file (default: `false`)
|
||||
|
||||
**IMPORTANT SAFETY RULES:**
|
||||
**BEHAVIOR:**
|
||||
|
||||
- By default, the tool will **fail if the file already exists** to prevent accidental data loss
|
||||
- To **overwrite** an existing file, you **MUST** set `overwrite: true`
|
||||
- To **create a new file**, just provide the `path` and `content` (overwrite defaults to false)
|
||||
- If parent directories don't exist, they will be created automatically
|
||||
- `write_file` can ONLY create new files.
|
||||
- If the file already exists, the tool returns an error. Use `search_replace` to edit existing files.
|
||||
- Parent directories are created automatically if they don't exist.
|
||||
|
||||
**BEST PRACTICES:**
|
||||
|
||||
- **ALWAYS** use the `read_file` tool first before overwriting an existing file to understand its current contents
|
||||
- **ALWAYS** prefer using `search_replace` to edit existing files rather than overwriting them completely
|
||||
- **NEVER** write new files unless explicitly required - prefer modifying existing files
|
||||
- **NEVER** proactively create documentation files (*.md) or README files unless explicitly requested
|
||||
- **AVOID** using emojis in file content unless the user explicitly requests them
|
||||
- **NEVER** use `write_file` to modify an existing file — it will fail. Use `edit` instead.
|
||||
- **NEVER** write new files unless explicitly required — prefer modifying existing files via `edit`.
|
||||
- **NEVER** proactively create documentation files (*.md) or README files unless explicitly requested.
|
||||
- **AVOID** using emojis in file content unless the user explicitly requests them.
|
||||
|
||||
**Usage Examples:**
|
||||
**Usage Example:**
|
||||
|
||||
```python
|
||||
# Create a new file (will error if file exists)
|
||||
write_file(
|
||||
path="src/new_module.py",
|
||||
content="def hello():\n return 'Hello World'"
|
||||
)
|
||||
|
||||
# Overwrite an existing file (must read it first!)
|
||||
# First: read_file(path="src/existing.py")
|
||||
# Then:
|
||||
write_file(
|
||||
path="src/existing.py",
|
||||
content="# Updated content\ndef new_function():\n pass",
|
||||
overwrite=True
|
||||
)
|
||||
```
|
||||
|
||||
**Remember:** For editing existing files, prefer `search_replace` over `write_file` to preserve unchanged portions and avoid accidental data loss.
|
||||
|
|
|
|||
235
vibe/core/tools/builtins/read.py
Normal file
235
vibe/core/tools/builtins/read.py
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, ClassVar, final
|
||||
|
||||
from humanize import naturalsize
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from vibe.core.config.harness_files import get_harness_files_manager
|
||||
from vibe.core.scratchpad import is_scratchpad_path
|
||||
from vibe.core.tools.base import (
|
||||
BaseTool,
|
||||
BaseToolConfig,
|
||||
BaseToolState,
|
||||
InvokeContext,
|
||||
ToolError,
|
||||
ToolPermission,
|
||||
)
|
||||
from vibe.core.tools.permissions import PermissionContext
|
||||
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
||||
from vibe.core.tools.utils import resolve_file_tool_permission
|
||||
from vibe.core.types import ToolStreamEvent
|
||||
from vibe.core.utils import VIBE_WARNING_TAG
|
||||
from vibe.core.utils.io import read_lines_safe_async
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.types import ToolResultEvent
|
||||
|
||||
_KB = 1024
|
||||
DEFAULT_LINE_LIMIT = 2000
|
||||
MAX_BYTES = 50 * _KB
|
||||
|
||||
|
||||
def _add_line_numbers(lines: list[str], *, start: int) -> str:
|
||||
return "\n".join(
|
||||
f"{str(n).rjust(9)}\u2192{line}" for n, line in enumerate(lines, start=start)
|
||||
)
|
||||
|
||||
|
||||
def _warning(message: str) -> str:
|
||||
return f"<{VIBE_WARNING_TAG}>{message}</{VIBE_WARNING_TAG}>"
|
||||
|
||||
|
||||
class ReadArgs(BaseModel):
|
||||
file_path: str = Field(description="The absolute path to the file to read.")
|
||||
offset: int | None = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
description=(
|
||||
"The line number to start reading from (1-indexed). "
|
||||
"Only provide if the file is too large to read at once."
|
||||
),
|
||||
)
|
||||
limit: int = Field(
|
||||
default=DEFAULT_LINE_LIMIT,
|
||||
gt=0,
|
||||
description=(
|
||||
"The number of lines to read. Lower it to read a smaller portion "
|
||||
"of a large file."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ReadResult(BaseModel):
|
||||
file_path: str
|
||||
content: str
|
||||
num_lines: int
|
||||
start_line: int
|
||||
total_lines: int | None = None
|
||||
was_truncated: bool = False
|
||||
|
||||
|
||||
class ReadConfig(BaseToolConfig):
|
||||
permission: ToolPermission = ToolPermission.ALWAYS
|
||||
sensitive_patterns: list[str] = Field(
|
||||
default=["**/.env", "**/.env.*"],
|
||||
description="File patterns that trigger ASK even when permission is ALWAYS.",
|
||||
)
|
||||
max_read_bytes: int = Field(
|
||||
default=MAX_BYTES,
|
||||
gt=0,
|
||||
description="Maximum selected/output bytes to return in one call.",
|
||||
)
|
||||
|
||||
|
||||
class ReadState(BaseToolState):
|
||||
injected_agents_md: set[str] = Field(default_factory=set)
|
||||
|
||||
|
||||
class Read(
|
||||
BaseTool[ReadArgs, ReadResult, ReadConfig, ReadState],
|
||||
ToolUIData[ReadArgs, ReadResult],
|
||||
):
|
||||
description: ClassVar[str] = (
|
||||
"Read a text file with line numbers. "
|
||||
"Results are formatted with line number prefixes for easy reference."
|
||||
)
|
||||
|
||||
def resolve_permission(self, args: ReadArgs) -> PermissionContext | None:
|
||||
return resolve_file_tool_permission(
|
||||
args.file_path,
|
||||
tool_name=self.get_name(),
|
||||
allowlist=self.config.allowlist,
|
||||
denylist=self.config.denylist,
|
||||
config_permission=self.config.permission,
|
||||
sensitive_patterns=self.config.sensitive_patterns,
|
||||
)
|
||||
|
||||
def get_result_extra(self, result: ReadResult) -> str | None:
|
||||
try:
|
||||
mgr = get_harness_files_manager()
|
||||
except RuntimeError:
|
||||
return None
|
||||
docs = mgr.find_subdirectory_agents_md(Path(result.file_path))
|
||||
new_docs = [
|
||||
(d, c)
|
||||
for d, c in docs
|
||||
if str(d.resolve()) not in self.state.injected_agents_md
|
||||
]
|
||||
if not new_docs:
|
||||
return None
|
||||
for d, _ in new_docs:
|
||||
self.state.injected_agents_md.add(str(d.resolve()))
|
||||
sections = [
|
||||
f"Contents of {d}/AGENTS.md (project instructions for this directory):\n\n{c.strip()}"
|
||||
for d, c in new_docs
|
||||
]
|
||||
return f"<{VIBE_WARNING_TAG}>\n{'\n\n'.join(sections)}\n</{VIBE_WARNING_TAG}>"
|
||||
|
||||
async def _read_file(
|
||||
self, args: ReadArgs, file_path: Path
|
||||
) -> tuple[list[str], int | None, bool]:
|
||||
start_line = args.offset or 1
|
||||
try:
|
||||
result = await read_lines_safe_async(
|
||||
file_path,
|
||||
start_line=start_line,
|
||||
limit=args.limit,
|
||||
max_bytes=self.config.max_read_bytes,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise ToolError(f"Error reading {file_path}: {exc}") from exc
|
||||
return result.lines, result.total_lines, result.was_truncated
|
||||
|
||||
@final
|
||||
async def run(
|
||||
self, args: ReadArgs, ctx: InvokeContext | None = None
|
||||
) -> AsyncGenerator[ToolStreamEvent | ReadResult, None]:
|
||||
file_path = self._resolve_path(args.file_path)
|
||||
|
||||
start_line = args.offset or 1
|
||||
|
||||
selected, total_lines, was_truncated = await self._read_file(args, file_path)
|
||||
|
||||
if selected:
|
||||
content = _add_line_numbers(selected, start=start_line)
|
||||
elif total_lines == 0:
|
||||
content = _warning("Warning: the file exists but the contents are empty.")
|
||||
elif total_lines is None:
|
||||
content = _warning(f"Warning: no content returned for offset {start_line}.")
|
||||
else:
|
||||
content = _warning(
|
||||
f"Warning: the file exists but is shorter than the provided "
|
||||
f"offset ({start_line}). The file has {total_lines} lines."
|
||||
)
|
||||
|
||||
size = len(content.encode("utf-8"))
|
||||
if size > self.config.max_read_bytes:
|
||||
raise ToolError(
|
||||
f"Output ({naturalsize(size, binary=True)}) exceeds maximum "
|
||||
f"allowed size ({naturalsize(self.config.max_read_bytes, binary=True)}). "
|
||||
f"Use offset and limit to read a smaller portion of the file."
|
||||
)
|
||||
|
||||
yield ReadResult(
|
||||
file_path=str(file_path),
|
||||
content=content,
|
||||
num_lines=len(selected),
|
||||
start_line=start_line,
|
||||
total_lines=total_lines,
|
||||
was_truncated=was_truncated,
|
||||
)
|
||||
|
||||
def _resolve_path(self, raw_path: str) -> Path:
|
||||
if not raw_path.strip():
|
||||
raise ToolError("file_path cannot be empty")
|
||||
|
||||
path = Path(raw_path).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = Path.cwd() / path
|
||||
path = path.resolve()
|
||||
|
||||
if not path.exists():
|
||||
raise ToolError(f"File not found at: {path}")
|
||||
if path.is_dir():
|
||||
raise ToolError(f"Path is a directory, not a file: {path}")
|
||||
return path
|
||||
|
||||
@classmethod
|
||||
def format_call_display(cls, args: ReadArgs) -> ToolCallDisplay:
|
||||
tag = " (scratchpad)" if is_scratchpad_path(args.file_path) else ""
|
||||
summary = f"Reading {args.file_path}"
|
||||
extras: list[str] = []
|
||||
if args.offset:
|
||||
extras.append(f"from line {args.offset}")
|
||||
if args.limit != DEFAULT_LINE_LIMIT:
|
||||
extras.append(f"limit {args.limit} lines")
|
||||
if extras:
|
||||
summary += f" ({', '.join(extras)})"
|
||||
return ToolCallDisplay(summary=f"{summary}{tag}")
|
||||
|
||||
@classmethod
|
||||
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
||||
if not isinstance(event.result, ReadResult):
|
||||
return ToolResultDisplay(
|
||||
success=False, message=event.error or event.skip_reason or "No result"
|
||||
)
|
||||
|
||||
path_obj = Path(event.result.file_path)
|
||||
tag = " (scratchpad)" if is_scratchpad_path(event.result.file_path) else ""
|
||||
word = "line" if event.result.num_lines == 1 else "lines"
|
||||
message = f"Read {event.result.num_lines} {word} from {path_obj.name}{tag}"
|
||||
if event.result.was_truncated or (
|
||||
event.result.total_lines is not None
|
||||
and event.result.start_line + event.result.num_lines - 1
|
||||
< event.result.total_lines
|
||||
):
|
||||
message += " (truncated)"
|
||||
|
||||
return ToolResultDisplay(success=True, message=message)
|
||||
|
||||
@classmethod
|
||||
def get_status_text(cls) -> str:
|
||||
return "Reading file"
|
||||
|
|
@ -1,234 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, ClassVar, NamedTuple, final
|
||||
|
||||
import anyio
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from vibe.core.config.harness_files import get_harness_files_manager
|
||||
from vibe.core.scratchpad import is_scratchpad_path
|
||||
from vibe.core.tools.base import (
|
||||
BaseTool,
|
||||
BaseToolConfig,
|
||||
BaseToolState,
|
||||
InvokeContext,
|
||||
ToolError,
|
||||
ToolPermission,
|
||||
)
|
||||
from vibe.core.tools.permissions import PermissionContext
|
||||
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
||||
from vibe.core.tools.utils import resolve_file_tool_permission
|
||||
from vibe.core.types import ToolStreamEvent
|
||||
from vibe.core.utils import VIBE_WARNING_TAG
|
||||
from vibe.core.utils.io import decode_safe
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.types import ToolResultEvent
|
||||
|
||||
|
||||
class _ReadResult(NamedTuple):
|
||||
lines: list[str]
|
||||
bytes_read: int
|
||||
was_truncated: bool
|
||||
|
||||
|
||||
class ReadFileArgs(BaseModel):
|
||||
path: str
|
||||
offset: int = Field(
|
||||
default=0,
|
||||
description="Line number to start reading from (0-indexed, inclusive).",
|
||||
)
|
||||
limit: int | None = Field(
|
||||
default=None, description="Maximum number of lines to read."
|
||||
)
|
||||
|
||||
|
||||
class ReadFileResult(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
offset: int = 0
|
||||
lines_read: int
|
||||
was_truncated: bool = Field(
|
||||
description="True if the reading was stopped due to the max_read_bytes limit."
|
||||
)
|
||||
|
||||
|
||||
class ReadFileToolConfig(BaseToolConfig):
|
||||
permission: ToolPermission = ToolPermission.ALWAYS
|
||||
sensitive_patterns: list[str] = Field(
|
||||
default=["**/.env", "**/.env.*"],
|
||||
description="File patterns that trigger ASK even when permission is ALWAYS.",
|
||||
)
|
||||
|
||||
max_read_bytes: int = Field(
|
||||
default=64_000, description="Maximum total bytes to read from a file in one go."
|
||||
)
|
||||
|
||||
|
||||
class ReadFileState(BaseToolState):
|
||||
injected_agents_md: set[str] = Field(default_factory=set)
|
||||
|
||||
|
||||
class ReadFile(
|
||||
BaseTool[ReadFileArgs, ReadFileResult, ReadFileToolConfig, ReadFileState],
|
||||
ToolUIData[ReadFileArgs, ReadFileResult],
|
||||
):
|
||||
description: ClassVar[str] = (
|
||||
"Read a text file (encoding detected safely), returning content from a "
|
||||
"specific line range. Reading is capped by a byte limit for safety."
|
||||
)
|
||||
|
||||
@final
|
||||
async def run(
|
||||
self, args: ReadFileArgs, ctx: InvokeContext | None = None
|
||||
) -> AsyncGenerator[ToolStreamEvent | ReadFileResult, None]:
|
||||
file_path = self._prepare_and_validate_path(args)
|
||||
|
||||
read_result = await self._read_file(args, file_path)
|
||||
|
||||
yield ReadFileResult(
|
||||
path=str(file_path),
|
||||
content="".join(read_result.lines),
|
||||
offset=args.offset,
|
||||
lines_read=len(read_result.lines),
|
||||
was_truncated=read_result.was_truncated,
|
||||
)
|
||||
|
||||
def resolve_permission(self, args: ReadFileArgs) -> PermissionContext | None:
|
||||
return resolve_file_tool_permission(
|
||||
args.path,
|
||||
tool_name=self.get_name(),
|
||||
allowlist=self.config.allowlist,
|
||||
denylist=self.config.denylist,
|
||||
config_permission=self.config.permission,
|
||||
sensitive_patterns=self.config.sensitive_patterns,
|
||||
)
|
||||
|
||||
def get_result_extra(self, result: ReadFileResult) -> str | None:
|
||||
try:
|
||||
mgr = get_harness_files_manager()
|
||||
except RuntimeError:
|
||||
return None
|
||||
docs = mgr.find_subdirectory_agents_md(Path(result.path))
|
||||
new_docs = [
|
||||
(d, c)
|
||||
for d, c in docs
|
||||
if str(d.resolve()) not in self.state.injected_agents_md
|
||||
]
|
||||
if not new_docs:
|
||||
return None
|
||||
for d, _ in new_docs:
|
||||
self.state.injected_agents_md.add(str(d.resolve()))
|
||||
sections = [
|
||||
f"Contents of {d}/AGENTS.md (project instructions for this directory):\n\n{c.strip()}"
|
||||
for d, c in new_docs
|
||||
]
|
||||
return f"<{VIBE_WARNING_TAG}>\n{'\n\n'.join(sections)}\n</{VIBE_WARNING_TAG}>"
|
||||
|
||||
def _prepare_and_validate_path(self, args: ReadFileArgs) -> Path:
|
||||
self._validate_inputs(args)
|
||||
|
||||
file_path = Path(args.path).expanduser()
|
||||
if not file_path.is_absolute():
|
||||
file_path = Path.cwd() / file_path
|
||||
|
||||
self._validate_path(file_path)
|
||||
return file_path
|
||||
|
||||
async def _read_file(self, args: ReadFileArgs, file_path: Path) -> _ReadResult:
|
||||
try:
|
||||
raw_lines: list[bytes] = []
|
||||
bytes_read = 0
|
||||
was_truncated = True
|
||||
|
||||
async with await anyio.Path(file_path).open("rb") as f:
|
||||
line_index = 0
|
||||
while raw_line := await f.readline():
|
||||
if line_index < args.offset:
|
||||
line_index += 1
|
||||
continue
|
||||
|
||||
if args.limit is not None and len(raw_lines) >= args.limit:
|
||||
break
|
||||
|
||||
line_bytes = len(raw_line)
|
||||
if bytes_read + line_bytes > self.config.max_read_bytes:
|
||||
break
|
||||
|
||||
raw_lines.append(raw_line)
|
||||
bytes_read += line_bytes
|
||||
line_index += 1
|
||||
else:
|
||||
was_truncated = False
|
||||
except OSError as exc:
|
||||
raise ToolError(f"Error reading {file_path}: {exc}") from exc
|
||||
|
||||
lines_to_return = decode_safe(b"".join(raw_lines)).text.splitlines(
|
||||
keepends=True
|
||||
)
|
||||
return _ReadResult(
|
||||
lines=lines_to_return, bytes_read=bytes_read, was_truncated=was_truncated
|
||||
)
|
||||
|
||||
def _validate_inputs(self, args: ReadFileArgs) -> None:
|
||||
if not args.path.strip():
|
||||
raise ToolError("Path cannot be empty")
|
||||
if args.offset < 0:
|
||||
raise ToolError("Offset cannot be negative")
|
||||
if args.limit is not None and args.limit <= 0:
|
||||
raise ToolError("Limit, if provided, must be a positive number")
|
||||
|
||||
def _validate_path(self, file_path: Path) -> None:
|
||||
try:
|
||||
resolved_path = file_path.resolve()
|
||||
except ValueError:
|
||||
raise ToolError(
|
||||
f"Security error: Cannot read path '{file_path}' outside of the project directory '{Path.cwd()}'."
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise ToolError(f"File not found at: {file_path}")
|
||||
|
||||
if not resolved_path.exists():
|
||||
raise ToolError(f"File not found at: {file_path}")
|
||||
if resolved_path.is_dir():
|
||||
raise ToolError(f"Path is a directory, not a file: {file_path}")
|
||||
|
||||
@classmethod
|
||||
def format_call_display(cls, args: ReadFileArgs) -> ToolCallDisplay:
|
||||
tag = " (scratchpad)" if is_scratchpad_path(args.path) else ""
|
||||
summary = f"Reading {args.path}"
|
||||
if args.offset > 0 or args.limit is not None:
|
||||
parts = []
|
||||
if args.offset > 0:
|
||||
parts.append(f"from line {args.offset}")
|
||||
if args.limit is not None:
|
||||
parts.append(f"limit {args.limit} lines")
|
||||
summary += f" ({', '.join(parts)})"
|
||||
return ToolCallDisplay(summary=f"{summary}{tag}")
|
||||
|
||||
@classmethod
|
||||
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
||||
if not isinstance(event.result, ReadFileResult):
|
||||
return ToolResultDisplay(
|
||||
success=False, message=event.error or event.skip_reason or "No result"
|
||||
)
|
||||
|
||||
path_obj = Path(event.result.path)
|
||||
tag = " (scratchpad)" if is_scratchpad_path(event.result.path) else ""
|
||||
message = f"Read {event.result.lines_read} line{'' if event.result.lines_read <= 1 else 's'} from {path_obj.name}{tag}"
|
||||
if event.result.was_truncated:
|
||||
message += " (truncated)"
|
||||
|
||||
return ToolResultDisplay(
|
||||
success=True,
|
||||
message=message,
|
||||
warnings=["File was truncated due to size limit"]
|
||||
if event.result.was_truncated
|
||||
else [],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_status_text(cls) -> str:
|
||||
return "Reading file"
|
||||
|
|
@ -1,480 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
import difflib
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
from typing import ClassVar, NamedTuple, final
|
||||
|
||||
import anyio
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from vibe.core.rewind.manager import FileSnapshot
|
||||
from vibe.core.scratchpad import is_scratchpad_path
|
||||
from vibe.core.tools.base import (
|
||||
BaseTool,
|
||||
BaseToolConfig,
|
||||
BaseToolState,
|
||||
InvokeContext,
|
||||
ToolError,
|
||||
)
|
||||
from vibe.core.tools.permissions import PermissionContext
|
||||
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
||||
from vibe.core.tools.utils import resolve_file_tool_permission
|
||||
from vibe.core.types import ToolResultEvent, ToolStreamEvent
|
||||
from vibe.core.utils.io import ReadSafeResult, read_safe_async
|
||||
|
||||
SEARCH_REPLACE_BLOCK_RE = re.compile(
|
||||
r"<{5,} SEARCH\r?\n(.*?)\r?\n?={5,}\r?\n(.*?)\r?\n?>{5,} REPLACE", flags=re.DOTALL
|
||||
)
|
||||
|
||||
SEARCH_REPLACE_BLOCK_WITH_FENCE_RE = re.compile(
|
||||
r"```[\s\S]*?\n<{5,} SEARCH\r?\n(.*?)\r?\n?={5,}\r?\n(.*?)\r?\n?>{5,} REPLACE\s*\n```",
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
class SearchReplaceBlock(NamedTuple):
|
||||
search: str
|
||||
replace: str
|
||||
|
||||
|
||||
class FuzzyMatch(NamedTuple):
|
||||
similarity: float
|
||||
start_line: int
|
||||
end_line: int
|
||||
text: str
|
||||
|
||||
|
||||
class BlockApplyResult(NamedTuple):
|
||||
content: str
|
||||
applied: int
|
||||
errors: list[str]
|
||||
warnings: list[str]
|
||||
|
||||
|
||||
class SearchReplaceArgs(BaseModel):
|
||||
file_path: str
|
||||
content: str
|
||||
|
||||
|
||||
class SearchReplaceResult(BaseModel):
|
||||
file: str
|
||||
blocks_applied: int
|
||||
lines_changed: int
|
||||
content: str
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SearchReplaceConfig(BaseToolConfig):
|
||||
sensitive_patterns: list[str] = Field(
|
||||
default=["**/.env", "**/.env.*"],
|
||||
description="File patterns that trigger ASK even when permission is ALWAYS.",
|
||||
)
|
||||
max_content_size: int = 100_000
|
||||
create_backup: bool = False
|
||||
fuzzy_threshold: float = 0.9
|
||||
|
||||
|
||||
class SearchReplace(
|
||||
BaseTool[
|
||||
SearchReplaceArgs, SearchReplaceResult, SearchReplaceConfig, BaseToolState
|
||||
],
|
||||
ToolUIData[SearchReplaceArgs, SearchReplaceResult],
|
||||
):
|
||||
description: ClassVar[str] = (
|
||||
"Replace sections of files using SEARCH/REPLACE blocks. "
|
||||
"Supports fuzzy matching and detailed error reporting. "
|
||||
"Format: <<<<<<< SEARCH\\n[text]\\n=======\\n[replacement]\\n>>>>>>> REPLACE"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def format_call_display(cls, args: SearchReplaceArgs) -> ToolCallDisplay:
|
||||
tag = " (scratchpad)" if is_scratchpad_path(args.file_path) else ""
|
||||
blocks = cls._parse_search_replace_blocks(args.content)
|
||||
return ToolCallDisplay(
|
||||
summary=f"Patching {args.file_path} ({len(blocks)} blocks){tag}",
|
||||
content=args.content,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
||||
if isinstance(event.result, SearchReplaceResult):
|
||||
path_name = Path(event.result.file).name
|
||||
tag = " (scratchpad)" if is_scratchpad_path(event.result.file) else ""
|
||||
return ToolResultDisplay(
|
||||
success=True,
|
||||
message=f"Applied {event.result.blocks_applied} block{'' if event.result.blocks_applied == 1 else 's'} to {path_name}{tag}",
|
||||
warnings=event.result.warnings,
|
||||
)
|
||||
|
||||
return ToolResultDisplay(success=True, message="Patch applied")
|
||||
|
||||
@classmethod
|
||||
def get_status_text(cls) -> str:
|
||||
return "Editing files"
|
||||
|
||||
def get_file_snapshot(self, args: SearchReplaceArgs) -> FileSnapshot | None:
|
||||
return self.get_file_snapshot_for_path(args.file_path)
|
||||
|
||||
def resolve_permission(self, args: SearchReplaceArgs) -> PermissionContext | None:
|
||||
return resolve_file_tool_permission(
|
||||
args.file_path,
|
||||
tool_name=self.get_name(),
|
||||
allowlist=self.config.allowlist,
|
||||
denylist=self.config.denylist,
|
||||
config_permission=self.config.permission,
|
||||
sensitive_patterns=self.config.sensitive_patterns,
|
||||
)
|
||||
|
||||
@final
|
||||
async def run(
|
||||
self, args: SearchReplaceArgs, ctx: InvokeContext | None = None
|
||||
) -> AsyncGenerator[ToolStreamEvent | SearchReplaceResult, None]:
|
||||
file_path, search_replace_blocks = self._prepare_and_validate_args(args)
|
||||
|
||||
decoded = await self._read_file(file_path)
|
||||
original_content = decoded.text
|
||||
|
||||
block_result = self._apply_blocks(
|
||||
original_content,
|
||||
search_replace_blocks,
|
||||
file_path,
|
||||
self.config.fuzzy_threshold,
|
||||
)
|
||||
|
||||
if block_result.errors:
|
||||
error_message = "SEARCH/REPLACE blocks failed:\n" + "\n\n".join(
|
||||
block_result.errors
|
||||
)
|
||||
if block_result.warnings:
|
||||
error_message += "\n\nWarnings encountered:\n" + "\n".join(
|
||||
block_result.warnings
|
||||
)
|
||||
raise ToolError(error_message)
|
||||
|
||||
modified_content = block_result.content
|
||||
|
||||
# Calculate line changes
|
||||
if modified_content == original_content:
|
||||
lines_changed = 0
|
||||
else:
|
||||
original_lines = len(original_content.splitlines())
|
||||
new_lines = len(modified_content.splitlines())
|
||||
lines_changed = new_lines - original_lines
|
||||
|
||||
try:
|
||||
if self.config.create_backup:
|
||||
await self._backup_file(file_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await self._write_file(
|
||||
file_path, modified_content, decoded.encoding, decoded.newline
|
||||
)
|
||||
|
||||
yield SearchReplaceResult(
|
||||
file=str(file_path),
|
||||
blocks_applied=block_result.applied,
|
||||
lines_changed=lines_changed,
|
||||
warnings=block_result.warnings,
|
||||
content=args.content,
|
||||
)
|
||||
|
||||
@final
|
||||
def _prepare_and_validate_args(
|
||||
self, args: SearchReplaceArgs
|
||||
) -> tuple[Path, list[SearchReplaceBlock]]:
|
||||
file_path_str = args.file_path.strip()
|
||||
content = args.content.strip()
|
||||
|
||||
if not file_path_str:
|
||||
raise ToolError("File path cannot be empty")
|
||||
|
||||
if len(content) > self.config.max_content_size:
|
||||
raise ToolError(
|
||||
f"Content size ({len(content)} bytes) exceeds max_content_size "
|
||||
f"({self.config.max_content_size} bytes)"
|
||||
)
|
||||
|
||||
if not content:
|
||||
raise ToolError("Empty content provided")
|
||||
|
||||
project_root = Path.cwd()
|
||||
file_path = Path(file_path_str).expanduser()
|
||||
if not file_path.is_absolute():
|
||||
file_path = project_root / file_path
|
||||
file_path = file_path.resolve()
|
||||
|
||||
if not file_path.exists():
|
||||
raise ToolError(f"File does not exist: {file_path}")
|
||||
|
||||
if not file_path.is_file():
|
||||
raise ToolError(f"Path is not a file: {file_path}")
|
||||
|
||||
search_replace_blocks = self._parse_search_replace_blocks(content)
|
||||
if not search_replace_blocks:
|
||||
raise ToolError(
|
||||
"No valid SEARCH/REPLACE blocks found in content.\n"
|
||||
"Expected format:\n"
|
||||
"<<<<<<< SEARCH\n"
|
||||
"[exact content to find]\n"
|
||||
"=======\n"
|
||||
"[new content to replace with]\n"
|
||||
">>>>>>> REPLACE"
|
||||
)
|
||||
|
||||
return file_path, search_replace_blocks
|
||||
|
||||
async def _read_file(self, file_path: Path) -> ReadSafeResult:
|
||||
try:
|
||||
return await read_safe_async(file_path, raise_on_error=True)
|
||||
except PermissionError:
|
||||
raise ToolError(f"Permission denied reading file: {file_path}")
|
||||
except OSError as e:
|
||||
raise ToolError(f"OS error reading {file_path}: {e}") from e
|
||||
except Exception as e:
|
||||
raise ToolError(f"Unexpected error reading {file_path}: {e}") from e
|
||||
|
||||
async def _backup_file(self, file_path: Path) -> None:
|
||||
shutil.copy2(file_path, file_path.with_suffix(file_path.suffix + ".bak"))
|
||||
|
||||
async def _write_file(
|
||||
self, file_path: Path, content: str, encoding: str, newline: str
|
||||
) -> None:
|
||||
try:
|
||||
async with await anyio.Path(file_path).open(
|
||||
mode="w", encoding=encoding, newline=newline
|
||||
) as f:
|
||||
await f.write(content)
|
||||
except UnicodeEncodeError as e:
|
||||
raise ToolError(
|
||||
f"Cannot encode patched content for {file_path} using {encoding!r}: {e}"
|
||||
) from e
|
||||
except PermissionError:
|
||||
raise ToolError(f"Permission denied writing to file: {file_path}")
|
||||
except OSError as e:
|
||||
raise ToolError(f"OS error writing to {file_path}: {e}") from e
|
||||
except Exception as e:
|
||||
raise ToolError(f"Unexpected error writing to {file_path}: {e}") from e
|
||||
|
||||
@final
|
||||
@staticmethod
|
||||
def _apply_blocks(
|
||||
content: str,
|
||||
blocks: list[SearchReplaceBlock],
|
||||
filepath: Path,
|
||||
fuzzy_threshold: float = 0.9,
|
||||
) -> BlockApplyResult:
|
||||
applied = 0
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
current_content = content
|
||||
|
||||
for i, (search, replace) in enumerate(blocks, 1):
|
||||
if search not in current_content:
|
||||
context = SearchReplace._find_search_context(current_content, search)
|
||||
fuzzy_context = SearchReplace._find_fuzzy_match_context(
|
||||
current_content, search, fuzzy_threshold
|
||||
)
|
||||
|
||||
error_msg = (
|
||||
f"SEARCH/REPLACE block {i} failed: Search text not found in {filepath}\n"
|
||||
f"Search text was:\n{search!r}\n"
|
||||
f"Context analysis:\n{context}"
|
||||
)
|
||||
|
||||
if fuzzy_context:
|
||||
error_msg += f"\n{fuzzy_context}"
|
||||
|
||||
error_msg += (
|
||||
"\nDebugging tips:\n"
|
||||
"1. Check for exact whitespace/indentation match\n"
|
||||
"2. Verify line endings match the file exactly (\\r\\n vs \\n)\n"
|
||||
"3. Ensure the search text hasn't been modified by previous blocks or user edits\n"
|
||||
"4. Check for typos or case sensitivity issues"
|
||||
)
|
||||
|
||||
errors.append(error_msg)
|
||||
continue
|
||||
|
||||
occurrences = current_content.count(search)
|
||||
if occurrences > 1:
|
||||
warning_msg = (
|
||||
f"Search text in block {i} appears {occurrences} times in the file. "
|
||||
f"Only the first occurrence will be replaced. Consider making your "
|
||||
f"search pattern more specific to avoid unintended changes."
|
||||
)
|
||||
warnings.append(warning_msg)
|
||||
|
||||
current_content = current_content.replace(search, replace, 1)
|
||||
applied += 1
|
||||
|
||||
return BlockApplyResult(
|
||||
content=current_content, applied=applied, errors=errors, warnings=warnings
|
||||
)
|
||||
|
||||
@final
|
||||
@staticmethod
|
||||
def _find_fuzzy_match_context(
|
||||
content: str, search_text: str, threshold: float = 0.9
|
||||
) -> str | None:
|
||||
best_match = SearchReplace._find_best_fuzzy_match(
|
||||
content, search_text, threshold
|
||||
)
|
||||
|
||||
if not best_match:
|
||||
return None
|
||||
|
||||
diff = SearchReplace._create_unified_diff(
|
||||
search_text, best_match.text, "SEARCH", "CLOSEST MATCH"
|
||||
)
|
||||
|
||||
similarity_pct = best_match.similarity * 100
|
||||
|
||||
return (
|
||||
f"Closest fuzzy match (similarity {similarity_pct:.1f}%) "
|
||||
f"at lines {best_match.start_line}–{best_match.end_line}:\n"
|
||||
f"```diff\n{diff}\n```"
|
||||
)
|
||||
|
||||
@final
|
||||
@staticmethod
|
||||
def _find_best_fuzzy_match( # noqa: PLR0914
|
||||
content: str, search_text: str, threshold: float = 0.9
|
||||
) -> FuzzyMatch | None:
|
||||
content_lines = content.split("\n")
|
||||
search_lines = search_text.split("\n")
|
||||
window_size = len(search_lines)
|
||||
|
||||
if window_size == 0:
|
||||
return None
|
||||
|
||||
non_empty_search = [line for line in search_lines if line.strip()]
|
||||
if not non_empty_search:
|
||||
return None
|
||||
|
||||
first_anchor = non_empty_search[0]
|
||||
last_anchor = (
|
||||
non_empty_search[-1] if len(non_empty_search) > 1 else first_anchor
|
||||
)
|
||||
|
||||
candidate_starts = set()
|
||||
spread = 5
|
||||
|
||||
for i, line in enumerate(content_lines):
|
||||
if first_anchor in line or last_anchor in line:
|
||||
start_min = max(0, i - spread)
|
||||
start_max = min(len(content_lines) - window_size + 1, i + spread + 1)
|
||||
for s in range(start_min, start_max):
|
||||
candidate_starts.add(s)
|
||||
|
||||
if not candidate_starts:
|
||||
max_positions = min(len(content_lines) - window_size + 1, 100)
|
||||
candidate_starts = set(range(0, max_positions))
|
||||
|
||||
best_match = None
|
||||
best_similarity = 0.0
|
||||
|
||||
for start in candidate_starts:
|
||||
end = start + window_size
|
||||
window_text = "\n".join(content_lines[start:end])
|
||||
|
||||
matcher = difflib.SequenceMatcher(None, search_text, window_text)
|
||||
similarity = matcher.ratio()
|
||||
|
||||
if similarity >= threshold and similarity > best_similarity:
|
||||
best_similarity = similarity
|
||||
best_match = FuzzyMatch(
|
||||
similarity=similarity,
|
||||
start_line=start + 1, # 1-based line numbers
|
||||
end_line=end,
|
||||
text=window_text,
|
||||
)
|
||||
|
||||
return best_match
|
||||
|
||||
@final
|
||||
@staticmethod
|
||||
def _create_unified_diff(
|
||||
text1: str, text2: str, label1: str = "SEARCH", label2: str = "CLOSEST MATCH"
|
||||
) -> str:
|
||||
lines1 = text1.splitlines(keepends=True)
|
||||
lines2 = text2.splitlines(keepends=True)
|
||||
|
||||
lines1 = [line if line.endswith("\n") else line + "\n" for line in lines1]
|
||||
lines2 = [line if line.endswith("\n") else line + "\n" for line in lines2]
|
||||
|
||||
diff = difflib.unified_diff(
|
||||
lines1, lines2, fromfile=label1, tofile=label2, lineterm="", n=3
|
||||
)
|
||||
|
||||
diff_lines = list(diff)
|
||||
|
||||
if diff_lines and not diff_lines[0].startswith("==="):
|
||||
diff_lines.insert(2, "=" * 67 + "\n")
|
||||
|
||||
result = "".join(diff_lines)
|
||||
|
||||
max_chars = 2000
|
||||
if len(result) > max_chars:
|
||||
result = result[:max_chars] + "\n...(diff truncated)"
|
||||
|
||||
return result.rstrip()
|
||||
|
||||
@final
|
||||
@staticmethod
|
||||
def _parse_search_replace_blocks(content: str) -> list[SearchReplaceBlock]:
|
||||
"""Parse SEARCH/REPLACE blocks from content.
|
||||
|
||||
Supports two formats:
|
||||
1. With code block fences (```...```)
|
||||
2. Without code block fences
|
||||
"""
|
||||
matches = SEARCH_REPLACE_BLOCK_WITH_FENCE_RE.findall(content)
|
||||
|
||||
if not matches:
|
||||
matches = SEARCH_REPLACE_BLOCK_RE.findall(content)
|
||||
|
||||
return [
|
||||
SearchReplaceBlock(
|
||||
search=search.rstrip("\r\n"), replace=replace.rstrip("\r\n")
|
||||
)
|
||||
for search, replace in matches
|
||||
]
|
||||
|
||||
@final
|
||||
@staticmethod
|
||||
def _find_search_context(
|
||||
content: str, search_text: str, max_context: int = 5
|
||||
) -> str:
|
||||
lines = content.split("\n")
|
||||
search_lines = search_text.split("\n")
|
||||
|
||||
if not search_lines:
|
||||
return "Search text is empty"
|
||||
|
||||
first_search_line = search_lines[0].strip()
|
||||
if not first_search_line:
|
||||
return "First line of search text is empty or whitespace only"
|
||||
|
||||
matches = []
|
||||
for i, line in enumerate(lines):
|
||||
if first_search_line in line:
|
||||
matches.append(i)
|
||||
|
||||
if not matches:
|
||||
return f"First search line '{first_search_line}' not found anywhere in file"
|
||||
|
||||
context_lines = []
|
||||
for match_idx in matches[:3]:
|
||||
start = max(0, match_idx - max_context)
|
||||
end = min(len(lines), match_idx + max_context + 1)
|
||||
|
||||
context_lines.append(f"\nPotential match area around line {match_idx + 1}:")
|
||||
for i in range(start, end):
|
||||
marker = ">>>" if i == match_idx else " "
|
||||
context_lines.append(f"{marker} {i + 1:3d}: {lines[i]}")
|
||||
|
||||
return "\n".join(context_lines)
|
||||
|
|
@ -26,15 +26,11 @@ from vibe.core.types import ToolResultEvent, ToolStreamEvent
|
|||
class WriteFileArgs(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
overwrite: bool = Field(
|
||||
default=False, description="Must be set to true to overwrite an existing file."
|
||||
)
|
||||
|
||||
|
||||
class WriteFileResult(BaseModel):
|
||||
path: str
|
||||
bytes_written: int
|
||||
file_existed: bool
|
||||
content: str
|
||||
|
||||
|
||||
|
|
@ -53,24 +49,22 @@ class WriteFile(
|
|||
ToolUIData[WriteFileArgs, WriteFileResult],
|
||||
):
|
||||
description: ClassVar[str] = (
|
||||
"Create or overwrite a UTF-8 file. Fails if file exists unless 'overwrite=True'."
|
||||
"Create a UTF-8 file. Fails if the file already exists; use search_replace to edit."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def format_call_display(cls, args: WriteFileArgs) -> ToolCallDisplay:
|
||||
tag = " (scratchpad)" if is_scratchpad_path(args.path) else ""
|
||||
overwrite = " (overwrite)" if args.overwrite else ""
|
||||
return ToolCallDisplay(
|
||||
summary=f"Writing {args.path}{overwrite}{tag}", content=args.content
|
||||
summary=f"Writing {args.path}{tag}", content=args.content
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
||||
if isinstance(event.result, WriteFileResult):
|
||||
action = "Overwritten" if event.result.file_existed else "Created"
|
||||
tag = " (scratchpad)" if is_scratchpad_path(event.result.path) else ""
|
||||
return ToolResultDisplay(
|
||||
success=True, message=f"{action} {Path(event.result.path).name}{tag}"
|
||||
success=True, message=f"Created {Path(event.result.path).name}{tag}"
|
||||
)
|
||||
|
||||
return ToolResultDisplay(success=True, message="File written")
|
||||
|
|
@ -96,18 +90,15 @@ class WriteFile(
|
|||
async def run(
|
||||
self, args: WriteFileArgs, ctx: InvokeContext | None = None
|
||||
) -> AsyncGenerator[ToolStreamEvent | WriteFileResult, None]:
|
||||
file_path, file_existed, content_bytes = self._prepare_and_validate_path(args)
|
||||
file_path, content_bytes = self._prepare_and_validate_path(args)
|
||||
|
||||
await self._write_file(args, file_path)
|
||||
|
||||
yield WriteFileResult(
|
||||
path=str(file_path),
|
||||
bytes_written=content_bytes,
|
||||
file_existed=file_existed,
|
||||
content=args.content,
|
||||
path=str(file_path), bytes_written=content_bytes, content=args.content
|
||||
)
|
||||
|
||||
def _prepare_and_validate_path(self, args: WriteFileArgs) -> tuple[Path, bool, int]:
|
||||
def _prepare_and_validate_path(self, args: WriteFileArgs) -> tuple[Path, int]:
|
||||
if not args.path.strip():
|
||||
raise ToolError("Path cannot be empty")
|
||||
|
||||
|
|
@ -122,11 +113,9 @@ class WriteFile(
|
|||
file_path = Path.cwd() / file_path
|
||||
file_path = file_path.resolve()
|
||||
|
||||
file_existed = file_path.exists()
|
||||
|
||||
if file_existed and not args.overwrite:
|
||||
if file_path.exists():
|
||||
raise ToolError(
|
||||
f"File '{file_path}' exists. Set overwrite=True to replace."
|
||||
f"File '{file_path}' already exists. Use search_replace to edit it."
|
||||
)
|
||||
|
||||
if self.config.create_parent_dirs:
|
||||
|
|
@ -134,13 +123,17 @@ class WriteFile(
|
|||
elif not file_path.parent.exists():
|
||||
raise ToolError(f"Parent directory does not exist: {file_path.parent}")
|
||||
|
||||
return file_path, file_existed, content_bytes
|
||||
return file_path, content_bytes
|
||||
|
||||
async def _write_file(self, args: WriteFileArgs, file_path: Path) -> None:
|
||||
try:
|
||||
async with await anyio.Path(file_path).open(
|
||||
mode="w", encoding="utf-8"
|
||||
mode="x", encoding="utf-8"
|
||||
) as f:
|
||||
await f.write(args.content)
|
||||
except FileExistsError as e:
|
||||
raise ToolError(
|
||||
f"File '{file_path}' already exists. Use search_replace to edit it."
|
||||
) from e
|
||||
except Exception as e:
|
||||
raise ToolError(f"Error writing {file_path}: {e}") from e
|
||||
|
|
|
|||
|
|
@ -4,5 +4,6 @@ from vibe.core.tools.connectors.connector_registry import (
|
|||
ConnectorAuthAction,
|
||||
ConnectorRegistry,
|
||||
)
|
||||
from vibe.core.tools.connectors.counts import compute_connector_counts
|
||||
|
||||
__all__ = ["ConnectorAuthAction", "ConnectorRegistry"]
|
||||
__all__ = ["ConnectorAuthAction", "ConnectorRegistry", "compute_connector_counts"]
|
||||
|
|
|
|||
26
vibe/core/tools/connectors/counts.py
Normal file
26
vibe/core/tools/connectors/counts.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vibe.core.tools.connectors.connector_registry import ConnectorRegistry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.config import VibeConfig
|
||||
|
||||
|
||||
def compute_connector_counts(
|
||||
config: VibeConfig, connector_registry: ConnectorRegistry | None
|
||||
) -> tuple[int, int]:
|
||||
if connector_registry is None:
|
||||
return (0, 0)
|
||||
aliases = connector_registry.get_connector_names()
|
||||
if not aliases:
|
||||
return (0, 0)
|
||||
by_name = config.connectors_by_name()
|
||||
connected = sum(
|
||||
(cfg := by_name.get(alias)) is not None
|
||||
and not cfg.disabled
|
||||
and connector_registry.is_connected(alias)
|
||||
for alias in aliases
|
||||
)
|
||||
return (connected, len(aliases))
|
||||
|
|
@ -87,7 +87,7 @@ class ToolManager:
|
|||
self._lock = threading.Lock()
|
||||
self._mcp_integrated = False
|
||||
|
||||
self._available: dict[str, type[BaseTool]] = {
|
||||
self._all_tools: dict[str, type[BaseTool]] = {
|
||||
cls.get_name(): cls for cls in self._iter_tool_classes(self._search_paths)
|
||||
}
|
||||
if not defer_mcp:
|
||||
|
|
@ -190,14 +190,14 @@ class ToolManager:
|
|||
@property
|
||||
def registered_tools(self) -> dict[str, type[BaseTool]]:
|
||||
with self._lock:
|
||||
return dict(self._available)
|
||||
return dict(self._all_tools)
|
||||
|
||||
@property
|
||||
def available_tools(self) -> dict[str, type[BaseTool]]:
|
||||
with self._lock:
|
||||
runtime_available = {
|
||||
name: cls
|
||||
for name, cls in self._available.items()
|
||||
for name, cls in self._all_tools.items()
|
||||
if self._is_tool_available(cls)
|
||||
}
|
||||
|
||||
|
|
@ -254,20 +254,15 @@ class ToolManager:
|
|||
elif srv.disabled_tools:
|
||||
per_source_disabled[key] = set(srv.disabled_tools)
|
||||
|
||||
# Track connector names that have explicit config entries
|
||||
configured_connectors = {cfg.name for cfg in self._config.connectors}
|
||||
|
||||
for cfg in self._config.connectors:
|
||||
key = (cfg.name, True)
|
||||
if cfg.disabled:
|
||||
disabled_sources.add(key)
|
||||
elif cfg.disabled_tools:
|
||||
per_source_disabled[key] = set(cfg.disabled_tools)
|
||||
if cfg.disabled_tools and not cfg.disabled:
|
||||
per_source_disabled[(cfg.name, True)] = set(cfg.disabled_tools)
|
||||
|
||||
# Disable connectors discovered but not in config (new connectors)
|
||||
if self._connector_registry is not None:
|
||||
by_name = self._config.connectors_by_name()
|
||||
for name in self._connector_registry.get_connector_names():
|
||||
if name not in configured_connectors:
|
||||
cfg = by_name.get(name)
|
||||
if cfg is None or cfg.disabled:
|
||||
disabled_sources.add((name, True))
|
||||
|
||||
return disabled_sources, per_source_disabled
|
||||
|
|
@ -314,7 +309,7 @@ class ToolManager:
|
|||
return
|
||||
|
||||
with self._lock:
|
||||
self._available = {**self._available, **mcp_tools}
|
||||
self._all_tools = {**self._all_tools, **mcp_tools}
|
||||
self._mcp_integrated = True
|
||||
logger.info(
|
||||
"MCP integration registered %d tools (via registry)", len(mcp_tools)
|
||||
|
|
@ -324,22 +319,22 @@ class ToolManager:
|
|||
"""Remove stale connector tool classes and cached instances."""
|
||||
stale_keys = [
|
||||
name
|
||||
for name, cls in self._available.items()
|
||||
for name, cls in self._all_tools.items()
|
||||
if issubclass(cls, MCPTool) and cls.is_connector()
|
||||
]
|
||||
for key in stale_keys:
|
||||
self._available.pop(key, None)
|
||||
self._all_tools.pop(key, None)
|
||||
self._instances.pop(key, None)
|
||||
|
||||
def _purge_mcp_state(self) -> None:
|
||||
"""Remove stale MCP tool classes and cached instances."""
|
||||
stale_keys = [
|
||||
name
|
||||
for name, cls in self._available.items()
|
||||
for name, cls in self._all_tools.items()
|
||||
if issubclass(cls, MCPTool) and not cls.is_connector()
|
||||
]
|
||||
for key in stale_keys:
|
||||
self._available.pop(key, None)
|
||||
self._all_tools.pop(key, None)
|
||||
self._instances.pop(key, None)
|
||||
|
||||
def integrate_connectors(self) -> None:
|
||||
|
|
@ -364,7 +359,7 @@ class ToolManager:
|
|||
|
||||
with self._lock:
|
||||
self._purge_connector_state()
|
||||
self._available.update(connector_tools)
|
||||
self._all_tools.update(connector_tools)
|
||||
logger.info(f"Connector integration registered {len(connector_tools)} tools")
|
||||
|
||||
async def refresh_remote_tools_async(self) -> None:
|
||||
|
|
@ -414,7 +409,7 @@ class ToolManager:
|
|||
|
||||
def get_tool_config(self, tool_name: str) -> BaseToolConfig:
|
||||
with self._lock:
|
||||
tool_class = self._available.get(tool_name)
|
||||
tool_class = self._all_tools.get(tool_name)
|
||||
|
||||
if tool_class:
|
||||
config_class = tool_class._get_tool_config_class()
|
||||
|
|
@ -444,12 +439,13 @@ class ToolManager:
|
|||
if tool_name in self._instances:
|
||||
return self._instances[tool_name]
|
||||
|
||||
with self._lock:
|
||||
if tool_name not in self._available:
|
||||
raise NoSuchToolError(
|
||||
f"Unknown tool: {tool_name}. Available: {list(self._available.keys())}"
|
||||
)
|
||||
tool_class = self._available[tool_name]
|
||||
available = self.available_tools
|
||||
if tool_name not in available:
|
||||
raise NoSuchToolError(
|
||||
f"Unknown or disabled tool: {tool_name}. "
|
||||
f"Available: {list(available.keys())}"
|
||||
)
|
||||
tool_class = available[tool_name]
|
||||
self._instances[tool_name] = tool_class.from_config(
|
||||
lambda: self.get_tool_config(tool_name)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,28 +8,77 @@ import tomli_w
|
|||
from vibe.core.paths import (
|
||||
AGENTS_MD_FILENAME,
|
||||
TRUSTED_FOLDERS_FILE,
|
||||
walk_local_config_dirs,
|
||||
find_local_config_dirs,
|
||||
)
|
||||
|
||||
|
||||
def has_agents_md_file(path: Path) -> bool:
|
||||
return (path / AGENTS_MD_FILENAME).exists()
|
||||
return (path / AGENTS_MD_FILENAME).is_file()
|
||||
|
||||
|
||||
def _is_git_repo_root(path: Path) -> bool:
|
||||
git_dir = path / ".git"
|
||||
return git_dir.is_dir() and (git_dir / "HEAD").is_file()
|
||||
|
||||
|
||||
def find_git_repo_ancestor(path: Path) -> Path | None:
|
||||
"""Closest ancestor (or *path*) with a real ``.git/HEAD``.
|
||||
|
||||
Excludes the home directory and the filesystem root.
|
||||
"""
|
||||
resolved = path.expanduser().resolve()
|
||||
home = Path.home().resolve()
|
||||
current = resolved
|
||||
while current not in {home, current.parent}:
|
||||
if _is_git_repo_root(current):
|
||||
return current
|
||||
current = current.parent
|
||||
return None
|
||||
|
||||
|
||||
def find_trustable_files(path: Path) -> list[str]:
|
||||
"""Return relative paths of files/dirs that would modify the agent's behavior."""
|
||||
"""Relative paths of files/dirs under *path* that would modify agent behavior."""
|
||||
resolved = path.resolve()
|
||||
found: list[str] = []
|
||||
|
||||
if has_agents_md_file(path):
|
||||
found.append(AGENTS_MD_FILENAME)
|
||||
|
||||
for config_dir in walk_local_config_dirs(path).config_dirs:
|
||||
for config_dir in find_local_config_dirs(path).config_dirs:
|
||||
label = f"{config_dir.relative_to(resolved)}/"
|
||||
if label not in found:
|
||||
found.append(label)
|
||||
|
||||
return found
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def find_repo_trustable_files_for_cwd(cwd: Path, repo_root: Path | None) -> list[str]:
|
||||
"""Repo-context files that influence *cwd* when inside a git repository.
|
||||
|
||||
Includes:
|
||||
- all trustable files at ``repo_root``
|
||||
- all ``AGENTS.md`` files on ancestors between ``cwd`` and ``repo_root``
|
||||
"""
|
||||
if repo_root is None:
|
||||
return []
|
||||
|
||||
resolved_cwd = cwd.resolve()
|
||||
resolved_repo_root = repo_root.resolve()
|
||||
if resolved_repo_root not in resolved_cwd.parents:
|
||||
return []
|
||||
|
||||
found = set(find_trustable_files(resolved_repo_root))
|
||||
|
||||
current = resolved_cwd.parent
|
||||
while current != resolved_repo_root:
|
||||
if has_agents_md_file(current):
|
||||
relative_path = (current / AGENTS_MD_FILENAME).relative_to(
|
||||
resolved_repo_root
|
||||
)
|
||||
found.add(relative_path.as_posix())
|
||||
current = current.parent
|
||||
|
||||
return sorted(found)
|
||||
|
||||
|
||||
class TrustedFoldersManager:
|
||||
|
|
@ -72,38 +121,38 @@ class TrustedFoldersManager:
|
|||
except OSError:
|
||||
pass
|
||||
|
||||
def is_trusted(self, path: Path) -> bool | None:
|
||||
"""Check trust walking up from *path* to filesystem root.
|
||||
|
||||
The first ancestor (or *path* itself) found in either the trusted,
|
||||
session-trusted, or untrusted list wins. Returns ``None`` when no
|
||||
decision exists.
|
||||
"""
|
||||
def _closest_decision(self, path: Path) -> tuple[bool, Path] | None:
|
||||
"""``(trusted, ancestor)`` for the closest decision, ``None`` if undecided."""
|
||||
current = Path(self._normalize_path(path))
|
||||
while True:
|
||||
s = str(current)
|
||||
if s in self._trusted or s in self._session_trusted:
|
||||
return True
|
||||
return True, current
|
||||
if s in self._untrusted:
|
||||
return False
|
||||
parent = current.parent
|
||||
if parent == current:
|
||||
break
|
||||
current = parent
|
||||
return None
|
||||
return False, current
|
||||
if current.parent == current:
|
||||
return None
|
||||
current = current.parent
|
||||
|
||||
def is_trusted(self, path: Path) -> bool | None:
|
||||
"""Tri-state closest decision; ``None`` when no ancestor has one."""
|
||||
match self._closest_decision(path):
|
||||
case (trusted, _):
|
||||
return trusted
|
||||
case None:
|
||||
return None
|
||||
|
||||
def is_explicitly_untrusted(self, path: Path) -> bool:
|
||||
"""*path* literally in the untrusted list (no ancestor walk)."""
|
||||
return self._normalize_path(path) in self._untrusted
|
||||
|
||||
def find_trust_root(self, path: Path) -> Path | None:
|
||||
"""Return the closest ancestor (or *path* itself) explicitly trusted."""
|
||||
current = Path(self._normalize_path(path))
|
||||
while True:
|
||||
s = str(current)
|
||||
if s in self._trusted or s in self._session_trusted:
|
||||
return current
|
||||
parent = current.parent
|
||||
if parent == current:
|
||||
break
|
||||
current = parent
|
||||
return None
|
||||
"""Closest explicitly trusted ancestor; ``None`` if a closer untrust blocks."""
|
||||
match self._closest_decision(path):
|
||||
case (True, root):
|
||||
return root
|
||||
case _:
|
||||
return None
|
||||
|
||||
def add_trusted(self, path: Path) -> None:
|
||||
normalized = self._normalize_path(path)
|
||||
|
|
|
|||
|
|
@ -216,11 +216,25 @@ class ApprovalResponse(StrEnum):
|
|||
NO = "n"
|
||||
|
||||
|
||||
IMAGE_EXTENSIONS: frozenset[str] = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"})
|
||||
MAX_IMAGE_BYTES: int = 10 * 1024 * 1024
|
||||
MAX_IMAGES_PER_MESSAGE: int = 8
|
||||
|
||||
|
||||
class ImageAttachment(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
path: Path
|
||||
alias: str
|
||||
mime_type: str
|
||||
|
||||
|
||||
class LLMMessage(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
role: Role
|
||||
content: Content | None = None
|
||||
images: list[ImageAttachment] | None = None
|
||||
injected: bool = False
|
||||
reasoning_content: Content | None = None
|
||||
reasoning_state: list[str] | None = None
|
||||
|
|
@ -255,6 +269,7 @@ class LLMMessage(BaseModel):
|
|||
"tool_calls": getattr(v, "tool_calls", None),
|
||||
"name": getattr(v, "name", None),
|
||||
"tool_call_id": getattr(v, "tool_call_id", None),
|
||||
"images": getattr(v, "images", None),
|
||||
"message_id": getattr(v, "message_id", None)
|
||||
or (str(uuid4()) if role != "tool" else None),
|
||||
}
|
||||
|
|
@ -317,6 +332,7 @@ class LLMMessage(BaseModel):
|
|||
return LLMMessage(
|
||||
role=self.role,
|
||||
content=content,
|
||||
images=self.images if self.images is not None else other.images,
|
||||
reasoning_content=reasoning_content,
|
||||
reasoning_state=reasoning_state,
|
||||
reasoning_signature=reasoning_signature,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
import contextlib
|
||||
from contextlib import asynccontextmanager
|
||||
import locale
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import time
|
||||
from typing import NamedTuple
|
||||
|
||||
import anyio
|
||||
|
|
@ -106,3 +111,106 @@ async def read_safe_async(
|
|||
"""Async :func:`read_safe` (``anyio``)."""
|
||||
raw = await anyio.Path(path).read_bytes()
|
||||
return decode_safe(raw, raise_on_error=raise_on_error)
|
||||
|
||||
|
||||
class BoundedReadResult(NamedTuple):
|
||||
r"""A bounded slice of a file's lines plus truncation metadata.
|
||||
|
||||
``lines`` are decoded and ``\n``-normalized, without trailing newlines.
|
||||
``total_lines`` is ``None`` when the read stopped early at the line or byte
|
||||
budget (the true total is unknown without scanning the rest of the file);
|
||||
otherwise it is the number of lines in the file. ``was_truncated`` is true
|
||||
when the read stopped before reaching end of file.
|
||||
"""
|
||||
|
||||
lines: list[str]
|
||||
total_lines: int | None
|
||||
was_truncated: bool
|
||||
|
||||
|
||||
def read_lines_safe(
|
||||
path: Path, *, start_line: int = 1, limit: int, max_bytes: int
|
||||
) -> BoundedReadResult:
|
||||
r"""Read up to ``limit`` lines from ``start_line`` (1-indexed) bounded by bytes.
|
||||
|
||||
Streams the file line-by-line in binary and stops once ``limit`` lines or
|
||||
``max_bytes`` of selected content have been collected, so large files are
|
||||
never loaded whole. The collected bytes are decoded once via
|
||||
:func:`decode_safe`, which also normalizes ``\r\n``/``\r`` to ``\n``.
|
||||
"""
|
||||
raw_lines: list[bytes] = []
|
||||
bytes_read = 0
|
||||
line_number = 0
|
||||
was_truncated = True
|
||||
|
||||
with path.open("rb") as f:
|
||||
while raw_line := f.readline():
|
||||
line_number += 1
|
||||
if line_number < start_line:
|
||||
continue
|
||||
if len(raw_lines) >= limit:
|
||||
break
|
||||
if bytes_read + len(raw_line) > max_bytes:
|
||||
remaining = max_bytes - bytes_read
|
||||
if remaining > 0:
|
||||
raw_lines.append(raw_line[:remaining])
|
||||
break
|
||||
raw_lines.append(raw_line)
|
||||
bytes_read += len(raw_line)
|
||||
else:
|
||||
was_truncated = False
|
||||
|
||||
total_lines = None if was_truncated else line_number
|
||||
lines = decode_safe(b"".join(raw_lines)).text.splitlines()
|
||||
return BoundedReadResult(lines, total_lines, was_truncated)
|
||||
|
||||
|
||||
async def read_lines_safe_async(
|
||||
path: Path, *, start_line: int = 1, limit: int, max_bytes: int
|
||||
) -> BoundedReadResult:
|
||||
"""Async :func:`read_lines_safe` (runs the blocking read in a thread)."""
|
||||
return await asyncio.to_thread(
|
||||
read_lines_safe, path, start_line=start_line, limit=limit, max_bytes=max_bytes
|
||||
)
|
||||
|
||||
|
||||
_FILE_WRITE_LOCKS: dict[str, asyncio.Lock] = {}
|
||||
_FILE_WRITE_LOCK_LOOP: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
|
||||
def _get_lock(path: Path) -> asyncio.Lock:
|
||||
global _FILE_WRITE_LOCK_LOOP
|
||||
loop = asyncio.get_running_loop()
|
||||
if _FILE_WRITE_LOCK_LOOP is not loop:
|
||||
_FILE_WRITE_LOCKS.clear()
|
||||
_FILE_WRITE_LOCK_LOOP = loop
|
||||
key = str(path.resolve())
|
||||
lock = _FILE_WRITE_LOCKS.get(key)
|
||||
if lock is None:
|
||||
lock = _FILE_WRITE_LOCKS[key] = asyncio.Lock()
|
||||
return lock
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def file_write_lock(path: Path) -> AsyncIterator[None]:
|
||||
async with _get_lock(path):
|
||||
yield
|
||||
|
||||
|
||||
async def atomic_replace(
|
||||
path: Path, content: str, *, encoding: str = "utf-8", newline: str | None = None
|
||||
) -> None:
|
||||
target = Path(path)
|
||||
tmp = target.parent / f".{target.name}.tmp.{os.getpid()}.{time.time_ns()}"
|
||||
try:
|
||||
async with await anyio.Path(tmp).open(
|
||||
mode="w", encoding=encoding, newline=newline
|
||||
) as f:
|
||||
await f.write(content)
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
shutil.copymode(target, tmp)
|
||||
os.replace(tmp, target)
|
||||
except BaseException:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
tmp.unlink()
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -3,13 +3,26 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||
import functools
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger("vibe")
|
||||
|
||||
_RETRYABLE_REQUEST_ERRORS: tuple[type[httpx.RequestError], ...] = (
|
||||
httpx.TimeoutException,
|
||||
httpx.ConnectError,
|
||||
httpx.ReadError,
|
||||
httpx.WriteError,
|
||||
httpx.RemoteProtocolError,
|
||||
)
|
||||
|
||||
|
||||
def _is_retryable_http_error(e: Exception) -> bool:
|
||||
if isinstance(e, httpx.HTTPStatusError):
|
||||
return e.response.status_code in {408, 409, 425, 429, 500, 502, 503, 504, 529}
|
||||
if isinstance(e, _RETRYABLE_REQUEST_ERRORS):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
|
@ -43,6 +56,14 @@ def async_retry[T, **P](
|
|||
current_delay = (delay_seconds * (backoff_factor**attempt)) + (
|
||||
0.05 * attempt
|
||||
)
|
||||
logger.warning(
|
||||
"Retrying %s after error attempt=%d/%d delay=%.2fs error=%r",
|
||||
func.__qualname__,
|
||||
attempt + 1,
|
||||
tries,
|
||||
current_delay,
|
||||
e,
|
||||
)
|
||||
await asyncio.sleep(current_delay)
|
||||
continue
|
||||
raise e
|
||||
|
|
@ -81,19 +102,33 @@ def async_generator_retry[T, **P](
|
|||
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> AsyncGenerator[T]:
|
||||
last_exc = None
|
||||
for attempt in range(tries):
|
||||
generator = func(*args, **kwargs)
|
||||
try:
|
||||
async for item in func(*args, **kwargs):
|
||||
yield item
|
||||
first_item = await anext(generator)
|
||||
except StopAsyncIteration:
|
||||
return
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
await generator.aclose()
|
||||
if attempt < tries - 1 and is_retryable(e):
|
||||
current_delay = (delay_seconds * (backoff_factor**attempt)) + (
|
||||
0.05 * attempt
|
||||
)
|
||||
logger.warning(
|
||||
"Retrying %s after error attempt=%d/%d delay=%.2fs error=%r",
|
||||
func.__qualname__,
|
||||
attempt + 1,
|
||||
tries,
|
||||
current_delay,
|
||||
e,
|
||||
)
|
||||
await asyncio.sleep(current_delay)
|
||||
continue
|
||||
raise e
|
||||
raise
|
||||
yield first_item
|
||||
async for item in generator:
|
||||
yield item
|
||||
return
|
||||
raise RuntimeError(
|
||||
f"Retries exhausted. Last error: {last_exc}"
|
||||
) from last_exc
|
||||
|
|
|
|||
|
|
@ -3,8 +3,12 @@ from __future__ import annotations
|
|||
from vibe.setup.auth.auth_state import AuthState, AuthStateKind, assess_auth_state
|
||||
from vibe.setup.auth.browser_sign_in import (
|
||||
BrowserSignInAttempt,
|
||||
BrowserSignInAttemptStarted,
|
||||
BrowserSignInEvent,
|
||||
BrowserSignInEventCallback,
|
||||
BrowserSignInService,
|
||||
BrowserSignInStatus,
|
||||
BrowserSignInStatusChanged,
|
||||
)
|
||||
from vibe.setup.auth.browser_sign_in_gateway import (
|
||||
BrowserSignInError,
|
||||
|
|
@ -19,13 +23,17 @@ __all__ = [
|
|||
"AuthState",
|
||||
"AuthStateKind",
|
||||
"BrowserSignInAttempt",
|
||||
"BrowserSignInAttemptStarted",
|
||||
"BrowserSignInError",
|
||||
"BrowserSignInErrorCode",
|
||||
"BrowserSignInEvent",
|
||||
"BrowserSignInEventCallback",
|
||||
"BrowserSignInGateway",
|
||||
"BrowserSignInPollResult",
|
||||
"BrowserSignInProcess",
|
||||
"BrowserSignInService",
|
||||
"BrowserSignInStatus",
|
||||
"BrowserSignInStatusChanged",
|
||||
"HttpBrowserSignInGateway",
|
||||
"assess_auth_state",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -24,12 +24,27 @@ class BrowserSignInStatus(StrEnum):
|
|||
COMPLETED = "completed"
|
||||
|
||||
|
||||
StatusCallback = Callable[[BrowserSignInStatus], None]
|
||||
BrowserOpener = Callable[[str], bool]
|
||||
SleepFn = Callable[[float], Awaitable[None]]
|
||||
NowFn = Callable[[], datetime]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowserSignInAttemptStarted:
|
||||
sign_in_url: str
|
||||
# Keeps attempt expiry available to future UIs without changing the event contract.
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowserSignInStatusChanged:
|
||||
status: BrowserSignInStatus
|
||||
|
||||
|
||||
BrowserSignInEvent = BrowserSignInAttemptStarted | BrowserSignInStatusChanged
|
||||
BrowserSignInEventCallback = Callable[[BrowserSignInEvent], None]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowserSignInAttempt:
|
||||
process_id: str
|
||||
|
|
@ -71,26 +86,39 @@ class BrowserSignInService:
|
|||
code_verifier=verifier,
|
||||
)
|
||||
|
||||
async def complete_attempt(
|
||||
async def complete_attempt(self, attempt: BrowserSignInAttempt) -> str:
|
||||
return await self._complete_attempt(attempt)
|
||||
|
||||
async def authenticate(
|
||||
self, event_callback: BrowserSignInEventCallback | None = None
|
||||
) -> str:
|
||||
attempt = await self.start_attempt()
|
||||
event = BrowserSignInAttemptStarted(
|
||||
sign_in_url=attempt.sign_in_url, expires_at=attempt.expires_at
|
||||
)
|
||||
if event_callback is not None:
|
||||
event_callback(event)
|
||||
self._emit_status(event_callback, BrowserSignInStatus.OPENING_BROWSER)
|
||||
self._open_browser_or_raise(attempt.sign_in_url)
|
||||
return await self._complete_attempt(attempt, event_callback=event_callback)
|
||||
|
||||
async def _complete_attempt(
|
||||
self,
|
||||
attempt: BrowserSignInAttempt,
|
||||
status_callback: StatusCallback | None = None,
|
||||
*,
|
||||
event_callback: BrowserSignInEventCallback | None = None,
|
||||
) -> str:
|
||||
self._emit(status_callback, BrowserSignInStatus.WAITING_FOR_BROWSER_SIGN_IN)
|
||||
self._emit_status(
|
||||
event_callback, BrowserSignInStatus.WAITING_FOR_BROWSER_SIGN_IN
|
||||
)
|
||||
exchange_token = await self._wait_for_completion(attempt)
|
||||
self._emit(status_callback, BrowserSignInStatus.EXCHANGING)
|
||||
self._emit_status(event_callback, BrowserSignInStatus.EXCHANGING)
|
||||
api_key = await self._gateway.exchange(
|
||||
attempt.process_id, exchange_token, attempt.code_verifier
|
||||
)
|
||||
self._emit(status_callback, BrowserSignInStatus.COMPLETED)
|
||||
self._emit_status(event_callback, BrowserSignInStatus.COMPLETED)
|
||||
return api_key
|
||||
|
||||
async def authenticate(self, status_callback: StatusCallback | None = None) -> str:
|
||||
attempt = await self.start_attempt()
|
||||
self._emit(status_callback, BrowserSignInStatus.OPENING_BROWSER)
|
||||
self._open_browser_or_raise(attempt.sign_in_url)
|
||||
return await self.complete_attempt(attempt, status_callback=status_callback)
|
||||
|
||||
async def _wait_for_completion(self, attempt: BrowserSignInAttempt) -> str:
|
||||
consecutive_poll_failures = 0
|
||||
while self._now() < attempt.expires_at:
|
||||
|
|
@ -148,11 +176,11 @@ class BrowserSignInService:
|
|||
)
|
||||
await self._sleep(min(self._poll_interval, remaining_seconds))
|
||||
|
||||
def _emit(
|
||||
self, callback: StatusCallback | None, status: BrowserSignInStatus
|
||||
def _emit_status(
|
||||
self, callback: BrowserSignInEventCallback | None, status: BrowserSignInStatus
|
||||
) -> None:
|
||||
if callback is not None:
|
||||
callback(status)
|
||||
callback(BrowserSignInStatusChanged(status=status))
|
||||
|
||||
def _open_browser_or_raise(self, sign_in_url: str) -> None:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from typing import Any
|
|||
from rich import print as rprint
|
||||
from textual.app import App
|
||||
|
||||
from vibe.cli.clipboard import try_copy_text_to_clipboard
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.paths import GLOBAL_ENV_FILE
|
||||
from vibe.core.telemetry.types import EntrypointMetadata
|
||||
|
|
@ -19,7 +20,11 @@ from vibe.setup.onboarding.screens import (
|
|||
ThemeSelectionScreen,
|
||||
WelcomeScreen,
|
||||
)
|
||||
from vibe.setup.onboarding.screens.browser_sign_in import SUCCESS_EXIT_DELAY_SECONDS
|
||||
from vibe.setup.onboarding.screens.browser_sign_in import (
|
||||
SIGN_IN_URL_HELP_DELAY_SECONDS,
|
||||
SUCCESS_EXIT_DELAY_SECONDS,
|
||||
CopySignInUrl,
|
||||
)
|
||||
|
||||
|
||||
class OnboardingApp(App[str | None]):
|
||||
|
|
@ -32,6 +37,8 @@ class OnboardingApp(App[str | None]):
|
|||
| None = None,
|
||||
entrypoint_metadata: EntrypointMetadata | None = None,
|
||||
browser_sign_in_success_delay: float = SUCCESS_EXIT_DELAY_SECONDS,
|
||||
browser_sign_in_url_help_delay: float = SIGN_IN_URL_HELP_DELAY_SECONDS,
|
||||
copy_sign_in_url: CopySignInUrl | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
|
|
@ -45,6 +52,8 @@ class OnboardingApp(App[str | None]):
|
|||
self._vibe_base_url = config.vibe_base_url
|
||||
self._entrypoint_metadata = entrypoint_metadata
|
||||
self._browser_sign_in_success_delay = browser_sign_in_success_delay
|
||||
self._browser_sign_in_url_help_delay = browser_sign_in_url_help_delay
|
||||
self._copy_sign_in_url = copy_sign_in_url or self._copy_sign_in_url_to_clipboard
|
||||
self._browser_sign_in_service_factory = self._resolve_browser_sign_in_factory(
|
||||
browser_sign_in_service_factory
|
||||
)
|
||||
|
|
@ -72,8 +81,10 @@ class OnboardingApp(App[str | None]):
|
|||
BrowserSignInScreen(
|
||||
self._provider,
|
||||
self._browser_sign_in_service_factory,
|
||||
copy_sign_in_url=self._copy_sign_in_url,
|
||||
entrypoint_metadata=self._entrypoint_metadata,
|
||||
success_exit_delay=self._browser_sign_in_success_delay,
|
||||
sign_in_url_help_delay=self._browser_sign_in_url_help_delay,
|
||||
),
|
||||
"browser_sign_in",
|
||||
)
|
||||
|
|
@ -109,6 +120,9 @@ class OnboardingApp(App[str | None]):
|
|||
or self._build_browser_sign_in_service_factory()
|
||||
)
|
||||
|
||||
def _copy_sign_in_url_to_clipboard(self, text: str) -> bool:
|
||||
return try_copy_text_to_clipboard(text)
|
||||
|
||||
|
||||
def run_onboarding(
|
||||
app: App | None = None, *, entrypoint_metadata: EntrypointMetadata | None = None
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from pydantic import BaseModel, Field, TypeAdapter, ValidationError
|
|||
|
||||
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
|
||||
from vibe.core.config._settings import (
|
||||
DEFAULT_ACTIVE_MODEL,
|
||||
DEFAULT_ACTIVE_MODEL_CONFIG,
|
||||
DEFAULT_MODELS,
|
||||
DEFAULT_PROVIDERS,
|
||||
DEFAULT_VIBE_BASE_URL,
|
||||
|
|
@ -29,7 +29,7 @@ def _default_model_payloads() -> list[dict[str, Any]]:
|
|||
|
||||
|
||||
class _OnboardingSnapshot(BaseModel):
|
||||
active_model: str = DEFAULT_ACTIVE_MODEL
|
||||
active_model: str = DEFAULT_ACTIVE_MODEL_CONFIG.alias
|
||||
vibe_base_url: str = DEFAULT_VIBE_BASE_URL
|
||||
providers: list[Any] = Field(default_factory=_default_provider_payloads)
|
||||
models: list[Any] = Field(default_factory=_default_model_payloads)
|
||||
|
|
@ -168,7 +168,7 @@ def _resolve_provider(
|
|||
|
||||
models = _validated_payloads(snapshot.models, ModelConfig)
|
||||
|
||||
for model_alias in (active_model, DEFAULT_ACTIVE_MODEL):
|
||||
for model_alias in (active_model, DEFAULT_ACTIVE_MODEL_CONFIG.alias):
|
||||
for model in models:
|
||||
if model.alias != model_alias:
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -326,8 +326,22 @@ ThemeSelectionScreen #enter-hint {
|
|||
width: 100%;
|
||||
color: $foreground-muted;
|
||||
text-align: center;
|
||||
margin-top: 2;
|
||||
padding-left: 2;
|
||||
}
|
||||
|
||||
#browser-sign-in-url {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: $foreground-muted;
|
||||
link-style: none;
|
||||
link-style-hover: none;
|
||||
margin-top: 1;
|
||||
padding-left: 2;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
|
|
|
|||
|
|
@ -2,16 +2,18 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from enum import IntEnum
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
from rich.markup import escape
|
||||
from rich.text import Text
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Center, Horizontal, Vertical
|
||||
from textual.reactive import reactive
|
||||
from textual.timer import Timer
|
||||
from textual.widgets import Static
|
||||
from textual.worker import Worker
|
||||
|
||||
from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat
|
||||
|
|
@ -20,10 +22,13 @@ from vibe.core.config import ProviderConfig
|
|||
from vibe.core.logger import logger
|
||||
from vibe.core.telemetry.types import EntrypointMetadata
|
||||
from vibe.setup.auth import (
|
||||
BrowserSignInAttemptStarted,
|
||||
BrowserSignInError,
|
||||
BrowserSignInErrorCode,
|
||||
BrowserSignInEvent,
|
||||
BrowserSignInService,
|
||||
BrowserSignInStatus,
|
||||
BrowserSignInStatusChanged,
|
||||
)
|
||||
from vibe.setup.auth.api_key_persistence import (
|
||||
persist_api_key,
|
||||
|
|
@ -35,7 +40,12 @@ from vibe.setup.onboarding.gradient_text import GRADIENT_COLORS, append_gradient
|
|||
PENDING_HINT = "Press M to enter API key manually - Esc to cancel"
|
||||
ERROR_HINT = "Press R to retry - Press M to enter API key manually - Esc to cancel"
|
||||
SUCCESS_HINT = "Finishing setup..."
|
||||
SIGN_IN_URL_HELP_PREFIX = "If your browser did not open, "
|
||||
SIGN_IN_URL_COPY_LABEL = "copy this URL"
|
||||
SIGN_IN_URL_HELP_SUFFIX = " (press C)."
|
||||
SIGN_IN_URL_REVEAL_PREFIX = "Copy failed. Open this URL manually:"
|
||||
SUCCESS_EXIT_DELAY_SECONDS: float = 2.0
|
||||
SIGN_IN_URL_HELP_DELAY_SECONDS: float = 4.0
|
||||
WAITING_FOR_AUTHENTICATION_MESSAGE = "Waiting for authentication..."
|
||||
STEP_DESCRIPTIONS = [
|
||||
("Open browser", "Your browser should open automatically", "Browser opened"),
|
||||
|
|
@ -49,6 +59,9 @@ UNEXPECTED_ERROR_MESSAGE = (
|
|||
ERROR_MESSAGES = {
|
||||
BrowserSignInErrorCode.POLL_FAILED: "We couldn't complete sign-in. Please try again."
|
||||
}
|
||||
COPY_URL_SUCCESS_MESSAGE = "Sign-in URL copied to clipboard"
|
||||
|
||||
CopySignInUrl = Callable[[str], bool]
|
||||
|
||||
|
||||
class BrowserSignInStep(IntEnum):
|
||||
|
|
@ -61,9 +74,21 @@ class BrowserSignInStep(IntEnum):
|
|||
class BrowserSignInViewState:
|
||||
step: BrowserSignInStep
|
||||
message: str
|
||||
hint: str
|
||||
variant: Literal["pending", "error", "success"]
|
||||
running: bool
|
||||
sign_in_url: str | None = None
|
||||
show_sign_in_url_help: bool = False
|
||||
reveal_sign_in_url: bool = False
|
||||
|
||||
@property
|
||||
def hint(self) -> str:
|
||||
if self.variant == "success":
|
||||
return SUCCESS_HINT
|
||||
|
||||
if self.variant == "error":
|
||||
return ERROR_HINT
|
||||
|
||||
return PENDING_HINT
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -79,7 +104,6 @@ class BrowserSignInScreen(OnboardingScreen):
|
|||
BrowserSignInViewState(
|
||||
step=BrowserSignInStep.OPEN,
|
||||
message="Getting things ready...",
|
||||
hint=PENDING_HINT,
|
||||
variant="pending",
|
||||
running=False,
|
||||
),
|
||||
|
|
@ -88,6 +112,7 @@ class BrowserSignInScreen(OnboardingScreen):
|
|||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("r", "retry", "Retry", show=False),
|
||||
Binding("c", "copy_url", "Copy URL", show=False),
|
||||
Binding("m", "manual", "Manual", show=False),
|
||||
Binding("ctrl+c", "cancel", "Cancel", show=False),
|
||||
Binding("escape", "cancel", "Cancel", show=False),
|
||||
|
|
@ -98,28 +123,33 @@ class BrowserSignInScreen(OnboardingScreen):
|
|||
provider: ProviderConfig,
|
||||
browser_sign_in_factory: Callable[[], BrowserSignInService],
|
||||
*,
|
||||
copy_sign_in_url: CopySignInUrl,
|
||||
entrypoint_metadata: EntrypointMetadata | None = None,
|
||||
success_exit_delay: float = SUCCESS_EXIT_DELAY_SECONDS,
|
||||
sign_in_url_help_delay: float = SIGN_IN_URL_HELP_DELAY_SECONDS,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.provider = provider
|
||||
self._browser_sign_in_factory = browser_sign_in_factory
|
||||
self._copy_sign_in_url = copy_sign_in_url
|
||||
self._entrypoint_metadata = entrypoint_metadata
|
||||
self._success_exit_delay = success_exit_delay
|
||||
self._sign_in_url_help_delay = sign_in_url_help_delay
|
||||
self._attempt_number = 0
|
||||
self._active_attempt_number: int | None = None
|
||||
self._worker: Worker[None] | None = None
|
||||
self._gradient_offset = 0
|
||||
self._gradient_timer: Timer | None = None
|
||||
self._sign_in_url_help_timer: Timer | None = None
|
||||
self._initial_state = BrowserSignInViewState(
|
||||
step=BrowserSignInStep.OPEN,
|
||||
message="Getting things ready...",
|
||||
hint=PENDING_HINT,
|
||||
variant="pending",
|
||||
running=False,
|
||||
)
|
||||
self._step_widgets: list[BrowserSignInStepWidgets] = []
|
||||
self._title_widget: NoMarkupStatic
|
||||
self._url_widget: Static
|
||||
self._hint_widget: NoMarkupStatic
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
|
|
@ -141,6 +171,7 @@ class BrowserSignInScreen(OnboardingScreen):
|
|||
)
|
||||
with Vertical(id="browser-sign-in-steps"):
|
||||
yield from self._compose_step_rows()
|
||||
yield Static("", id="browser-sign-in-url")
|
||||
yield NoMarkupStatic("", id="browser-sign-in-hint")
|
||||
|
||||
def _compose_step_rows(self) -> ComposeResult:
|
||||
|
|
@ -159,6 +190,7 @@ class BrowserSignInScreen(OnboardingScreen):
|
|||
yield detail
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._url_widget = self.query_one("#browser-sign-in-url", Static)
|
||||
self._hint_widget = self.query_one("#browser-sign-in-hint", NoMarkupStatic)
|
||||
self.state = self._initial_state
|
||||
self.watch_state(self.state)
|
||||
|
|
@ -187,6 +219,23 @@ class BrowserSignInScreen(OnboardingScreen):
|
|||
self._cancel_current_attempt()
|
||||
super().action_cancel()
|
||||
|
||||
def action_copy_url(self) -> None:
|
||||
if self.state.variant == "success" or self.state.sign_in_url is None:
|
||||
return
|
||||
|
||||
if self._copy_sign_in_url(self.state.sign_in_url):
|
||||
self.app.notify(
|
||||
COPY_URL_SUCCESS_MESSAGE,
|
||||
severity="information",
|
||||
timeout=2,
|
||||
markup=False,
|
||||
)
|
||||
return
|
||||
|
||||
self.state = replace(
|
||||
self.state, show_sign_in_url_help=True, reveal_sign_in_url=True
|
||||
)
|
||||
|
||||
def _start_browser_sign_in(self) -> None:
|
||||
self._attempt_number += 1
|
||||
attempt_number = self._attempt_number
|
||||
|
|
@ -194,9 +243,11 @@ class BrowserSignInScreen(OnboardingScreen):
|
|||
self.state = BrowserSignInViewState(
|
||||
step=BrowserSignInStep.OPEN,
|
||||
message="Getting things ready...",
|
||||
hint=PENDING_HINT,
|
||||
variant="pending",
|
||||
running=True,
|
||||
sign_in_url=None,
|
||||
show_sign_in_url_help=False,
|
||||
reveal_sign_in_url=False,
|
||||
)
|
||||
self._worker = self.run_worker(
|
||||
self._authenticate_in_browser(attempt_number),
|
||||
|
|
@ -211,7 +262,7 @@ class BrowserSignInScreen(OnboardingScreen):
|
|||
try:
|
||||
browser_sign_in = self._browser_sign_in_factory()
|
||||
api_key = await browser_sign_in.authenticate(
|
||||
lambda status: self._on_status(attempt_number, status)
|
||||
lambda event: self._on_event(attempt_number, event)
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
|
|
@ -255,18 +306,21 @@ class BrowserSignInScreen(OnboardingScreen):
|
|||
api_key,
|
||||
entrypoint_metadata=self._entrypoint_metadata,
|
||||
)
|
||||
self._cancel_sign_in_url_help_timer()
|
||||
if result != "completed":
|
||||
self._active_attempt_number = None
|
||||
self._worker = None
|
||||
self.app.exit(result)
|
||||
return
|
||||
|
||||
self.state = BrowserSignInViewState(
|
||||
self.state = replace(
|
||||
self.state,
|
||||
step=BrowserSignInStep.FINISH,
|
||||
message="Sign-in complete",
|
||||
hint=SUCCESS_HINT,
|
||||
variant="success",
|
||||
running=True,
|
||||
show_sign_in_url_help=False,
|
||||
reveal_sign_in_url=False,
|
||||
)
|
||||
if self._success_exit_delay > 0:
|
||||
await asyncio.sleep(self._success_exit_delay)
|
||||
|
|
@ -274,32 +328,55 @@ class BrowserSignInScreen(OnboardingScreen):
|
|||
self._worker = None
|
||||
self.app.exit(result)
|
||||
|
||||
def _on_event(self, attempt_number: int, event: BrowserSignInEvent) -> None:
|
||||
if isinstance(event, BrowserSignInAttemptStarted):
|
||||
self._on_attempt_started(attempt_number, event)
|
||||
return
|
||||
|
||||
if isinstance(event, BrowserSignInStatusChanged):
|
||||
self._on_status(attempt_number, event.status)
|
||||
|
||||
def _on_attempt_started(
|
||||
self, attempt_number: int, event: BrowserSignInAttemptStarted
|
||||
) -> None:
|
||||
if not self._is_attempt_active(attempt_number):
|
||||
return
|
||||
|
||||
self._cancel_sign_in_url_help_timer()
|
||||
self.state = replace(
|
||||
self.state,
|
||||
sign_in_url=event.sign_in_url,
|
||||
show_sign_in_url_help=False,
|
||||
reveal_sign_in_url=False,
|
||||
)
|
||||
self._schedule_sign_in_url_help(attempt_number, event.sign_in_url)
|
||||
|
||||
def _on_status(self, attempt_number: int, status: BrowserSignInStatus) -> None:
|
||||
if not self._is_attempt_active(attempt_number):
|
||||
return
|
||||
|
||||
match status:
|
||||
case BrowserSignInStatus.OPENING_BROWSER:
|
||||
state = BrowserSignInViewState(
|
||||
state = replace(
|
||||
self.state,
|
||||
step=BrowserSignInStep.OPEN,
|
||||
message="Opening your browser...",
|
||||
hint=PENDING_HINT,
|
||||
variant="pending",
|
||||
running=True,
|
||||
)
|
||||
case BrowserSignInStatus.WAITING_FOR_BROWSER_SIGN_IN:
|
||||
state = BrowserSignInViewState(
|
||||
state = replace(
|
||||
self.state,
|
||||
step=BrowserSignInStep.CONFIRM,
|
||||
message="Waiting for you to finish signing in...",
|
||||
hint=PENDING_HINT,
|
||||
variant="pending",
|
||||
running=True,
|
||||
)
|
||||
case BrowserSignInStatus.EXCHANGING | BrowserSignInStatus.COMPLETED:
|
||||
state = BrowserSignInViewState(
|
||||
state = replace(
|
||||
self.state,
|
||||
step=BrowserSignInStep.FINISH,
|
||||
message="Finishing setup...",
|
||||
hint=PENDING_HINT,
|
||||
variant="pending",
|
||||
running=True,
|
||||
)
|
||||
|
|
@ -313,6 +390,7 @@ class BrowserSignInScreen(OnboardingScreen):
|
|||
return
|
||||
|
||||
self._hint_widget.update(state.hint)
|
||||
self._url_widget.update(self._build_url_text(state))
|
||||
|
||||
for index, (widgets, (title, pending_detail, done_detail)) in enumerate(
|
||||
zip(self._step_widgets, STEP_DESCRIPTIONS, strict=True)
|
||||
|
|
@ -369,7 +447,8 @@ class BrowserSignInScreen(OnboardingScreen):
|
|||
self.state.variant == "pending"
|
||||
and self.state.step == BrowserSignInStep.CONFIRM
|
||||
):
|
||||
self.watch_state(self.state)
|
||||
widgets = self._step_widgets[self.state.step]
|
||||
self._update_active_step_detail(widgets.detail, self.state)
|
||||
|
||||
async def _close_browser_sign_in(
|
||||
self, browser_sign_in: BrowserSignInService | None
|
||||
|
|
@ -387,23 +466,66 @@ class BrowserSignInScreen(OnboardingScreen):
|
|||
def _show_error(self, message: str) -> None:
|
||||
self._active_attempt_number = None
|
||||
self._worker = None
|
||||
self.state = BrowserSignInViewState(
|
||||
step=self.state.step,
|
||||
self._cancel_sign_in_url_help_timer()
|
||||
self.state = replace(
|
||||
self.state,
|
||||
message=message,
|
||||
hint=ERROR_HINT,
|
||||
variant="error",
|
||||
running=False,
|
||||
show_sign_in_url_help=self.state.sign_in_url is not None,
|
||||
reveal_sign_in_url=False,
|
||||
)
|
||||
|
||||
def _build_url_text(self, state: BrowserSignInViewState) -> str:
|
||||
if (
|
||||
state.variant == "success"
|
||||
or state.sign_in_url is None
|
||||
or not state.show_sign_in_url_help
|
||||
):
|
||||
return ""
|
||||
|
||||
help_text = (
|
||||
f"{escape(SIGN_IN_URL_HELP_PREFIX)}"
|
||||
f"[@click='screen.copy_url']{escape(SIGN_IN_URL_COPY_LABEL)}[/]"
|
||||
f"{escape(SIGN_IN_URL_HELP_SUFFIX)}"
|
||||
)
|
||||
if not state.reveal_sign_in_url:
|
||||
return help_text
|
||||
|
||||
return (
|
||||
f"{help_text} {escape(SIGN_IN_URL_REVEAL_PREFIX)} "
|
||||
f"{escape(state.sign_in_url)}"
|
||||
)
|
||||
|
||||
def _schedule_sign_in_url_help(self, attempt_number: int, sign_in_url: str) -> None:
|
||||
if self._sign_in_url_help_delay <= 0:
|
||||
self._show_sign_in_url_help(attempt_number, sign_in_url)
|
||||
return
|
||||
|
||||
self._sign_in_url_help_timer = self.set_timer(
|
||||
self._sign_in_url_help_delay,
|
||||
lambda: self._show_sign_in_url_help(attempt_number, sign_in_url),
|
||||
)
|
||||
|
||||
def _show_sign_in_url_help(self, attempt_number: int, sign_in_url: str) -> None:
|
||||
self._sign_in_url_help_timer = None
|
||||
if not self._is_attempt_active(attempt_number):
|
||||
return
|
||||
|
||||
if self.state.sign_in_url != sign_in_url:
|
||||
return
|
||||
|
||||
self.state = replace(self.state, show_sign_in_url_help=True)
|
||||
|
||||
def _cancel_sign_in_url_help_timer(self) -> None:
|
||||
if self._sign_in_url_help_timer is not None:
|
||||
self._sign_in_url_help_timer.stop()
|
||||
self._sign_in_url_help_timer = None
|
||||
|
||||
def _cancel_current_attempt(self) -> None:
|
||||
self._active_attempt_number = None
|
||||
self.state = BrowserSignInViewState(
|
||||
step=self.state.step,
|
||||
message=self.state.message,
|
||||
hint=self.state.hint,
|
||||
variant=self.state.variant,
|
||||
running=False,
|
||||
)
|
||||
self._cancel_sign_in_url_help_timer()
|
||||
self.state = replace(self.state, running=False)
|
||||
if self._worker is not None:
|
||||
self._worker.cancel()
|
||||
self._worker = None
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from textual import events
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Center, CenterMiddle, Horizontal, VerticalScroll
|
||||
from textual.containers import (
|
||||
Center,
|
||||
CenterMiddle,
|
||||
Horizontal,
|
||||
Vertical,
|
||||
VerticalScroll,
|
||||
)
|
||||
from textual.message import Message
|
||||
from textual.widgets import Static
|
||||
|
||||
|
|
@ -18,35 +25,72 @@ class TrustDialogQuitException(Exception):
|
|||
pass
|
||||
|
||||
|
||||
class TrustDecision(Enum):
|
||||
"""User's choice from the trust dialog."""
|
||||
|
||||
TRUST_REPO = "trust_repo"
|
||||
TRUST_CWD = "trust_cwd"
|
||||
DECLINE = "decline"
|
||||
|
||||
|
||||
class TrustFolderDialog(CenterMiddle):
|
||||
can_focus = True
|
||||
can_focus_children = True
|
||||
|
||||
# Number keys 1-3 cover up to three options; extras no-op.
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("left", "move_left", "Left", show=False),
|
||||
Binding("right", "move_right", "Right", show=False),
|
||||
Binding("enter", "select", "Select", show=False),
|
||||
Binding("1", "select_1", "Yes", show=False),
|
||||
Binding("y", "select_1", "Yes", show=False),
|
||||
Binding("2", "select_2", "No", show=False),
|
||||
Binding("n", "select_2", "No", show=False),
|
||||
Binding("1", "select_index(0)", show=False),
|
||||
Binding("2", "select_index(1)", show=False),
|
||||
Binding("3", "select_index(2)", show=False),
|
||||
]
|
||||
|
||||
class Trusted(Message):
|
||||
pass
|
||||
|
||||
class Untrusted(Message):
|
||||
pass
|
||||
class Decided(Message):
|
||||
def __init__(self, decision: TrustDecision) -> None:
|
||||
super().__init__()
|
||||
self.decision = decision
|
||||
|
||||
def __init__(
|
||||
self, folder_path: Path, detected_files: list[str], **kwargs: Any
|
||||
self,
|
||||
cwd: Path,
|
||||
repo_root: Path | None,
|
||||
detected_files: list[str],
|
||||
repo_detected_files: list[str] | None = None,
|
||||
offer_repo_trust: bool = False,
|
||||
repo_explicitly_untrusted: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.folder_path = folder_path
|
||||
self.cwd = cwd
|
||||
# Hide the repo line when it would duplicate cwd.
|
||||
self.repo_root = repo_root if repo_root and repo_root != cwd else None
|
||||
self.offer_repo_trust = offer_repo_trust and self.repo_root is not None
|
||||
self.repo_explicitly_untrusted = (
|
||||
repo_explicitly_untrusted and self.repo_root is not None
|
||||
)
|
||||
self.detected_files = detected_files
|
||||
self.selected_option = 0
|
||||
self.repo_detected_files = repo_detected_files or []
|
||||
self._options: list[tuple[TrustDecision, str]] = self._build_options()
|
||||
# Default to the safest option (rightmost: Decline).
|
||||
self.selected_option = len(self._options) - 1
|
||||
self.option_widgets: list[Static] = []
|
||||
|
||||
@property
|
||||
def _title(self) -> str:
|
||||
if self.offer_repo_trust:
|
||||
return "Trust folder or repository?"
|
||||
return "Trust this folder?"
|
||||
|
||||
def _build_options(self) -> list[tuple[TrustDecision, str]]:
|
||||
options: list[tuple[TrustDecision, str]] = []
|
||||
if self.offer_repo_trust:
|
||||
options.append((TrustDecision.TRUST_REPO, "Trust full repo"))
|
||||
options.append((TrustDecision.TRUST_CWD, "Trust folder"))
|
||||
options.append((TrustDecision.DECLINE, "Don't trust"))
|
||||
return options
|
||||
|
||||
def _compose_scroll_content(self) -> ComposeResult:
|
||||
why_content = (
|
||||
"Files here can modify AI behavior. Malicious "
|
||||
|
|
@ -61,26 +105,53 @@ class TrustFolderDialog(CenterMiddle):
|
|||
)
|
||||
|
||||
if self.detected_files:
|
||||
yield NoMarkupStatic(
|
||||
"Detected configuration files\n", classes="trust-dialog-section-header"
|
||||
)
|
||||
file_list = "\n".join(f"\u2022 {f}" for f in self.detected_files)
|
||||
with Center(classes="trust-dialog-section-center"):
|
||||
yield NoMarkupStatic(
|
||||
file_list,
|
||||
id="trust-dialog-files",
|
||||
classes="trust-dialog-section-content",
|
||||
)
|
||||
with Vertical(classes="trust-dialog-section-stack"):
|
||||
yield NoMarkupStatic(
|
||||
"Detected in current folder:",
|
||||
classes="trust-dialog-section-title",
|
||||
)
|
||||
yield NoMarkupStatic(
|
||||
"\n".join(f"\u2022 {f}" for f in self.detected_files),
|
||||
id="trust-dialog-files",
|
||||
classes="trust-dialog-section-content trust-dialog-file-list",
|
||||
)
|
||||
|
||||
if self.repo_detected_files:
|
||||
with Center(classes="trust-dialog-section-center"):
|
||||
with Vertical(classes="trust-dialog-section-stack"):
|
||||
yield NoMarkupStatic(
|
||||
"Detected in repository context:",
|
||||
classes="trust-dialog-section-title",
|
||||
)
|
||||
yield NoMarkupStatic(
|
||||
"\n".join(f"\u2022 {f}" for f in self.repo_detected_files),
|
||||
id="trust-dialog-files-repo",
|
||||
classes="trust-dialog-section-content trust-dialog-file-list",
|
||||
)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with CenterMiddle(id="trust-dialog-container"):
|
||||
with CenterMiddle(id="trust-dialog"):
|
||||
yield NoMarkupStatic("Trust this folder?", id="trust-dialog-title")
|
||||
yield NoMarkupStatic(self._title, id="trust-dialog-title")
|
||||
path_classes = "trust-dialog-path"
|
||||
if self.repo_root is not None:
|
||||
path_classes += " has-repo-root"
|
||||
yield NoMarkupStatic(
|
||||
str(self.folder_path),
|
||||
id="trust-dialog-path",
|
||||
classes="trust-dialog-path",
|
||||
str(self.cwd), id="trust-dialog-path", classes=path_classes
|
||||
)
|
||||
if self.repo_explicitly_untrusted:
|
||||
yield NoMarkupStatic(
|
||||
f"\u26a0 git repository {self.repo_root} is marked untrusted",
|
||||
id="trust-dialog-repo-untrusted",
|
||||
classes="trust-dialog-repo-untrusted",
|
||||
)
|
||||
elif self.repo_root is not None:
|
||||
yield NoMarkupStatic(
|
||||
f"\u21b3 git repository: {self.repo_root}",
|
||||
id="trust-dialog-repo-root",
|
||||
classes="trust-dialog-repo-root",
|
||||
)
|
||||
|
||||
with VerticalScroll(id="trust-dialog-content"):
|
||||
yield from self._compose_scroll_content()
|
||||
|
|
@ -92,10 +163,9 @@ class TrustFolderDialog(CenterMiddle):
|
|||
)
|
||||
|
||||
with Horizontal(id="trust-options-container"):
|
||||
options = ["Yes, trust this folder", "No, ignore config files"]
|
||||
for idx, text in enumerate(options):
|
||||
for idx, (_decision, label) in enumerate(self._options):
|
||||
widget = NoMarkupStatic(
|
||||
f" {idx + 1}. {text}", classes="trust-option"
|
||||
f" {idx + 1}. {label}", classes="trust-option"
|
||||
)
|
||||
self.option_widgets.append(widget)
|
||||
yield widget
|
||||
|
|
@ -111,25 +181,20 @@ class TrustFolderDialog(CenterMiddle):
|
|||
)
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
self.selected_option = 1 # Default to "No"
|
||||
self._update_options()
|
||||
self.focus()
|
||||
|
||||
def _update_options(self) -> None:
|
||||
options = ["Yes, trust this folder", "No, ignore config files"]
|
||||
|
||||
if len(self.option_widgets) != len(options):
|
||||
if len(self.option_widgets) != len(self._options):
|
||||
return
|
||||
|
||||
for idx, (text, widget) in enumerate(
|
||||
zip(options, self.option_widgets, strict=True)
|
||||
for idx, ((_, label), widget) in enumerate(
|
||||
zip(self._options, self.option_widgets, strict=True)
|
||||
):
|
||||
is_selected = idx == self.selected_option
|
||||
|
||||
cursor = "› " if is_selected else " "
|
||||
option_text = f"{cursor}{text}"
|
||||
|
||||
widget.update(option_text)
|
||||
widget.update(f"{cursor}{label}")
|
||||
|
||||
widget.remove_class("trust-cursor-selected")
|
||||
widget.remove_class("trust-option-selected")
|
||||
|
|
@ -140,30 +205,25 @@ class TrustFolderDialog(CenterMiddle):
|
|||
widget.add_class("trust-option-selected")
|
||||
|
||||
def action_move_left(self) -> None:
|
||||
self.selected_option = (self.selected_option - 1) % 2
|
||||
self.selected_option = (self.selected_option - 1) % len(self._options)
|
||||
self._update_options()
|
||||
|
||||
def action_move_right(self) -> None:
|
||||
self.selected_option = (self.selected_option + 1) % 2
|
||||
self.selected_option = (self.selected_option + 1) % len(self._options)
|
||||
self._update_options()
|
||||
|
||||
def action_select(self) -> None:
|
||||
self._handle_selection(self.selected_option)
|
||||
|
||||
def action_select_1(self) -> None:
|
||||
self.selected_option = 0
|
||||
self._handle_selection(0)
|
||||
|
||||
def action_select_2(self) -> None:
|
||||
self.selected_option = 1
|
||||
self._handle_selection(1)
|
||||
def action_select_index(self, idx: int) -> None:
|
||||
if not 0 <= idx < len(self._options):
|
||||
return
|
||||
self.selected_option = idx
|
||||
self._handle_selection(idx)
|
||||
|
||||
def _handle_selection(self, option: int) -> None:
|
||||
match option:
|
||||
case 0:
|
||||
self.post_message(self.Trusted())
|
||||
case 1:
|
||||
self.post_message(self.Untrusted())
|
||||
decision, _ = self._options[option]
|
||||
self.post_message(self.Decided(decision))
|
||||
|
||||
def on_blur(self, event: events.Blur) -> None:
|
||||
self.call_after_refresh(self.focus)
|
||||
|
|
@ -178,39 +238,69 @@ class TrustFolderApp(App):
|
|||
]
|
||||
|
||||
def __init__(
|
||||
self, folder_path: Path, detected_files: list[str], **kwargs: Any
|
||||
self,
|
||||
cwd: Path,
|
||||
repo_root: Path | None,
|
||||
detected_files: list[str],
|
||||
repo_detected_files: list[str] | None = None,
|
||||
offer_repo_trust: bool = False,
|
||||
repo_explicitly_untrusted: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.folder_path = folder_path
|
||||
self.cwd = cwd
|
||||
self.repo_root = repo_root
|
||||
self.offer_repo_trust = offer_repo_trust
|
||||
self.repo_explicitly_untrusted = repo_explicitly_untrusted
|
||||
self.detected_files = detected_files
|
||||
self._result: bool | None = None
|
||||
self.repo_detected_files = repo_detected_files or []
|
||||
self._result: TrustDecision | None = None
|
||||
self._quit_without_saving = False
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.theme = "ansi-dark"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield TrustFolderDialog(self.folder_path, self.detected_files)
|
||||
yield TrustFolderDialog(
|
||||
self.cwd,
|
||||
self.repo_root,
|
||||
self.detected_files,
|
||||
repo_detected_files=self.repo_detected_files,
|
||||
offer_repo_trust=self.offer_repo_trust,
|
||||
repo_explicitly_untrusted=self.repo_explicitly_untrusted,
|
||||
)
|
||||
|
||||
def action_quit_without_saving(self) -> None:
|
||||
self._quit_without_saving = True
|
||||
self.exit()
|
||||
|
||||
def on_trust_folder_dialog_trusted(self, _: TrustFolderDialog.Trusted) -> None:
|
||||
self._result = True
|
||||
def on_trust_folder_dialog_decided(
|
||||
self, message: TrustFolderDialog.Decided
|
||||
) -> None:
|
||||
self._result = message.decision
|
||||
self.exit()
|
||||
|
||||
def on_trust_folder_dialog_untrusted(self, _: TrustFolderDialog.Untrusted) -> None:
|
||||
self._result = False
|
||||
self.exit()
|
||||
|
||||
def run_trust_dialog(self) -> bool | None:
|
||||
def run_trust_dialog(self) -> TrustDecision | None:
|
||||
self.run()
|
||||
if self._quit_without_saving:
|
||||
raise TrustDialogQuitException()
|
||||
return self._result
|
||||
|
||||
|
||||
def ask_trust_folder(folder_path: Path, detected_files: list[str]) -> bool | None:
|
||||
app = TrustFolderApp(folder_path, detected_files)
|
||||
def ask_trust_folder(
|
||||
cwd: Path,
|
||||
repo_root: Path | None,
|
||||
detected_files: list[str],
|
||||
repo_detected_files: list[str] | None = None,
|
||||
offer_repo_trust: bool = False,
|
||||
repo_explicitly_untrusted: bool = False,
|
||||
) -> TrustDecision | None:
|
||||
app = TrustFolderApp(
|
||||
cwd,
|
||||
repo_root,
|
||||
detected_files,
|
||||
repo_detected_files=repo_detected_files,
|
||||
offer_repo_trust=offer_repo_trust,
|
||||
repo_explicitly_untrusted=repo_explicitly_untrusted,
|
||||
)
|
||||
return app.run_trust_dialog()
|
||||
|
|
|
|||
|
|
@ -3,18 +3,18 @@ Screen {
|
|||
background: transparent 80%;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-size: 1 1;
|
||||
}
|
||||
|
||||
#trust-dialog {
|
||||
max-width: 70;
|
||||
overflow-y: scroll;
|
||||
overflow-y: auto;
|
||||
border: round $border-blurred;
|
||||
background: transparent;
|
||||
height:auto;
|
||||
height: auto;
|
||||
max-height: 1fr;
|
||||
padding: 1;
|
||||
padding-left:5;
|
||||
padding-right:5;
|
||||
scrollbar-size: 1 1;
|
||||
padding: 1 5;
|
||||
}
|
||||
|
||||
#trust-dialog-content {
|
||||
|
|
@ -42,13 +42,32 @@ Screen {
|
|||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.trust-dialog-section-header {
|
||||
#trust-dialog-path.has-repo-root {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.trust-dialog-repo-root {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: $foreground;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
text-wrap: wrap;
|
||||
padding: 0 2;
|
||||
text-style: italic;
|
||||
margin-bottom: 1;
|
||||
|
||||
&:ansi {
|
||||
text-style: italic dim;
|
||||
}
|
||||
}
|
||||
|
||||
.trust-dialog-repo-untrusted {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: $warning;
|
||||
text-align: center;
|
||||
text-wrap: wrap;
|
||||
text-style: italic;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.trust-dialog-section-center {
|
||||
|
|
@ -57,6 +76,11 @@ Screen {
|
|||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.trust-dialog-section-stack {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.trust-dialog-section-content {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
|
|
@ -66,6 +90,24 @@ Screen {
|
|||
text-overflow: fold;
|
||||
}
|
||||
|
||||
.trust-dialog-section-title {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: $text-muted;
|
||||
text-style: bold;
|
||||
text-align: center;
|
||||
margin-bottom: 0;
|
||||
|
||||
&:ansi {
|
||||
text-style: bold dim;
|
||||
}
|
||||
}
|
||||
|
||||
.trust-dialog-file-list {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.trust-dialog-footer-warning {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
# What's new in v2.13.0
|
||||
# What's new in v2.14.0
|
||||
|
||||
- **Custom compaction prompts**: Override the default `/compact` prompt by setting `compaction_prompt_id` and dropping a markdown file in `~/.vibe/prompts/` or `.vibe/prompts/`.
|
||||
- **Safer programmatic mode**: `-p` no longer auto-approves tool calls by default — pass `--auto-approve` to restore the previous behavior.
|
||||
- **Teleport Vibe Code Web**: `/teleport` now uses the new Vibe Code Web sessions.
|
||||
- **Image attachments**: Drop an image into the chat input — or `@`-mention it — and send it to vision-capable models.
|
||||
- **New read and edit tools**: `read` and `edit` replace `read_file` and `search_replace`. Your config has been migrated to these new tool names.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue