v2.9.1 (#644)
Co-authored-by: Brice Carpentier <brice.carpentier@mistral.ai> Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com> Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Quentin <quentin.torroba@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
632ea8c032
commit
1fd7eea289
63 changed files with 3482 additions and 301 deletions
|
|
@ -3,4 +3,4 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
|
||||
VIBE_ROOT = Path(__file__).parent
|
||||
__version__ = "2.9.0"
|
||||
__version__ = "2.9.1"
|
||||
|
|
|
|||
|
|
@ -367,7 +367,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
)
|
||||
|
||||
def _get_acp_tool_overrides(self) -> list[Path]:
|
||||
overrides = ["todo"]
|
||||
overrides = ["todo", "grep", "web_fetch", "web_search", "skill", "task"]
|
||||
|
||||
if self.client_capabilities:
|
||||
if self.client_capabilities.terminal:
|
||||
|
|
@ -399,6 +399,9 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
session.agent_loop.approve_always(tool_name, required_permissions)
|
||||
return (ApprovalResponse.YES, None)
|
||||
case ToolOption.REJECT_ONCE:
|
||||
session.agent_loop.telemetry_client.send_user_cancelled_action(
|
||||
"reject_approval"
|
||||
)
|
||||
return (
|
||||
ApprovalResponse.NO,
|
||||
"User rejected the tool call, provide an alternative plan",
|
||||
|
|
@ -946,6 +949,9 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
@override
|
||||
async def cancel(self, session_id: str, **kwargs: Any) -> None:
|
||||
session = self._get_session(session_id)
|
||||
session.agent_loop.telemetry_client.send_user_cancelled_action(
|
||||
"interrupt_agent"
|
||||
)
|
||||
await session.cancel_prompt()
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ from collections.abc import AsyncGenerator
|
|||
from pathlib import Path
|
||||
|
||||
from acp.schema import (
|
||||
ContentToolCallContent,
|
||||
TerminalToolCallContent,
|
||||
TextContentBlock,
|
||||
ToolCallProgress,
|
||||
ToolCallStart,
|
||||
WaitForTerminalExitResponse,
|
||||
|
|
@ -13,6 +15,7 @@ from acp.schema import (
|
|||
|
||||
from vibe import VIBE_ROOT
|
||||
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
|
||||
from vibe.acp.tools.session_update import resolve_kind
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError
|
||||
from vibe.core.tools.builtins.bash import Bash as CoreBashTool, BashArgs, BashResult
|
||||
|
|
@ -116,9 +119,10 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
|
|||
session_update="tool_call",
|
||||
title="bash",
|
||||
tool_call_id=event.tool_call_id,
|
||||
kind="execute",
|
||||
kind=resolve_kind(event.tool_name),
|
||||
content=None,
|
||||
raw_input=None,
|
||||
field_meta={"tool_name": event.tool_name},
|
||||
)
|
||||
if not isinstance(event.args, BashArgs):
|
||||
raise ValueError(f"Unexpected tool args: {event.args}")
|
||||
|
|
@ -128,8 +132,9 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
|
|||
title=Bash.get_summary(event.args),
|
||||
content=None,
|
||||
tool_call_id=event.tool_call_id,
|
||||
kind="execute",
|
||||
kind=resolve_kind(event.tool_name),
|
||||
raw_input=event.args.model_dump_json(),
|
||||
field_meta={"tool_name": event.tool_name},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
|
@ -140,4 +145,14 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
|
|||
session_update="tool_call_update",
|
||||
tool_call_id=event.tool_call_id,
|
||||
status="failed" if event.error else "completed",
|
||||
content=[
|
||||
ContentToolCallContent(
|
||||
type="content",
|
||||
content=TextContentBlock(
|
||||
type="text", text=cls.get_result_display(event).message
|
||||
),
|
||||
)
|
||||
],
|
||||
kind=resolve_kind(event.tool_name),
|
||||
field_meta={"tool_name": event.tool_name},
|
||||
)
|
||||
|
|
|
|||
78
vibe/acp/tools/builtins/grep.py
Normal file
78
vibe/acp/tools/builtins/grep.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from acp.helpers import SessionUpdate
|
||||
from acp.schema import (
|
||||
ContentToolCallContent,
|
||||
TextContentBlock,
|
||||
ToolCallLocation,
|
||||
ToolCallProgress,
|
||||
ToolCallStart,
|
||||
)
|
||||
|
||||
from vibe import VIBE_ROOT
|
||||
from vibe.acp.tools.base import (
|
||||
ToolCallSessionUpdateProtocol,
|
||||
ToolResultSessionUpdateProtocol,
|
||||
)
|
||||
from vibe.acp.tools.session_update import (
|
||||
failed_tool_result,
|
||||
fallback_tool_call,
|
||||
resolve_kind,
|
||||
)
|
||||
from vibe.core.tools.builtins.grep import Grep as CoreGrepTool, GrepArgs, GrepResult
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
|
||||
|
||||
class Grep(
|
||||
CoreGrepTool, ToolCallSessionUpdateProtocol, ToolResultSessionUpdateProtocol
|
||||
):
|
||||
prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "grep.md"
|
||||
|
||||
@classmethod
|
||||
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
|
||||
if not isinstance(event.args, GrepArgs):
|
||||
return fallback_tool_call(event, "grep")
|
||||
|
||||
search_path = str(Path(event.args.path).resolve())
|
||||
|
||||
return ToolCallStart(
|
||||
session_update="tool_call",
|
||||
title=cls.get_call_display(event).summary,
|
||||
tool_call_id=event.tool_call_id,
|
||||
kind=resolve_kind(event.tool_name),
|
||||
raw_input=event.args.model_dump_json(),
|
||||
locations=[ToolCallLocation(path=search_path)],
|
||||
field_meta={"tool_name": event.tool_name, "query": event.args.pattern},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
|
||||
if failure := failed_tool_result(event, GrepResult):
|
||||
return failure
|
||||
|
||||
result = event.result
|
||||
assert isinstance(result, GrepResult)
|
||||
|
||||
locations = [
|
||||
ToolCallLocation(path=m.path, line=m.line) for m in result.parsed_matches
|
||||
]
|
||||
|
||||
return ToolCallProgress(
|
||||
session_update="tool_call_update",
|
||||
tool_call_id=event.tool_call_id,
|
||||
status="completed",
|
||||
content=[
|
||||
ContentToolCallContent(
|
||||
type="content",
|
||||
content=TextContentBlock(
|
||||
type="text", text=cls.get_result_display(event).message
|
||||
),
|
||||
)
|
||||
],
|
||||
kind=resolve_kind(event.tool_name),
|
||||
raw_output=result.model_dump_json(),
|
||||
locations=locations if locations else None,
|
||||
field_meta={"tool_name": event.tool_name},
|
||||
)
|
||||
|
|
@ -2,8 +2,27 @@ from __future__ import annotations
|
|||
|
||||
from pathlib import Path
|
||||
|
||||
from acp.helpers import SessionUpdate
|
||||
from acp.schema import (
|
||||
ContentToolCallContent,
|
||||
TextContentBlock,
|
||||
ToolCallLocation,
|
||||
ToolCallProgress,
|
||||
ToolCallStart,
|
||||
)
|
||||
|
||||
from vibe import VIBE_ROOT
|
||||
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
|
||||
from vibe.acp.tools.base import (
|
||||
AcpToolState,
|
||||
BaseAcpTool,
|
||||
ToolCallSessionUpdateProtocol,
|
||||
ToolResultSessionUpdateProtocol,
|
||||
)
|
||||
from vibe.acp.tools.session_update import (
|
||||
failed_tool_result,
|
||||
fallback_tool_call,
|
||||
resolve_kind,
|
||||
)
|
||||
from vibe.core.tools.base import ToolError
|
||||
from vibe.core.tools.builtins.read_file import (
|
||||
ReadFile as CoreReadFileTool,
|
||||
|
|
@ -12,6 +31,7 @@ from vibe.core.tools.builtins.read_file import (
|
|||
ReadFileState,
|
||||
_ReadResult,
|
||||
)
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
|
||||
ReadFileResult = ReadFileResult
|
||||
|
||||
|
|
@ -20,7 +40,12 @@ class AcpReadFileState(ReadFileState, AcpToolState):
|
|||
pass
|
||||
|
||||
|
||||
class ReadFile(CoreReadFileTool, BaseAcpTool[AcpReadFileState]):
|
||||
class ReadFile(
|
||||
CoreReadFileTool,
|
||||
BaseAcpTool[AcpReadFileState],
|
||||
ToolCallSessionUpdateProtocol,
|
||||
ToolResultSessionUpdateProtocol,
|
||||
):
|
||||
state: AcpReadFileState
|
||||
prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "read_file.md"
|
||||
|
||||
|
|
@ -28,6 +53,69 @@ class ReadFile(CoreReadFileTool, BaseAcpTool[AcpReadFileState]):
|
|||
def _get_tool_state_class(cls) -> type[AcpReadFileState]:
|
||||
return AcpReadFileState
|
||||
|
||||
@classmethod
|
||||
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
|
||||
if not isinstance(event.args, ReadFileArgs):
|
||||
return fallback_tool_call(event, "read_file")
|
||||
|
||||
resolved = str(Path(event.args.path).resolve())
|
||||
|
||||
return ToolCallStart(
|
||||
session_update="tool_call",
|
||||
title=cls.format_call_display(event.args).summary,
|
||||
tool_call_id=event.tool_call_id,
|
||||
kind=resolve_kind(event.tool_name),
|
||||
raw_input=event.args.model_dump_json(),
|
||||
locations=[
|
||||
ToolCallLocation(
|
||||
path=resolved,
|
||||
field_meta={
|
||||
"type": "file_range",
|
||||
"offset": event.args.offset,
|
||||
"limit": event.args.limit,
|
||||
},
|
||||
)
|
||||
],
|
||||
field_meta={"tool_name": event.tool_name},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
|
||||
if failure := failed_tool_result(event, ReadFileResult):
|
||||
return failure
|
||||
|
||||
result = event.result
|
||||
assert isinstance(result, ReadFileResult)
|
||||
resolved = str(Path(result.path).resolve())
|
||||
locations = [
|
||||
ToolCallLocation(
|
||||
path=resolved,
|
||||
field_meta={
|
||||
"type": "file_range",
|
||||
"offset": result.offset,
|
||||
"limit": result.lines_read,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
return ToolCallProgress(
|
||||
session_update="tool_call_update",
|
||||
tool_call_id=event.tool_call_id,
|
||||
status="completed",
|
||||
content=[
|
||||
ContentToolCallContent(
|
||||
type="content",
|
||||
content=TextContentBlock(
|
||||
type="text", text=cls.get_result_display(event).message
|
||||
),
|
||||
)
|
||||
],
|
||||
kind=resolve_kind(event.tool_name),
|
||||
raw_output=result.model_dump_json(),
|
||||
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()
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ from acp.schema import (
|
|||
|
||||
from vibe import VIBE_ROOT
|
||||
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
|
||||
from vibe.acp.tools.session_update import (
|
||||
failed_tool_result,
|
||||
fallback_tool_call,
|
||||
resolve_kind,
|
||||
)
|
||||
from vibe.core.tools.base import BaseToolState, ToolError
|
||||
from vibe.core.tools.builtins.search_replace import (
|
||||
SearchReplace as CoreSearchReplaceTool,
|
||||
|
|
@ -75,18 +80,10 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
|
|||
|
||||
@classmethod
|
||||
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
|
||||
if not isinstance(event.args, SearchReplaceArgs):
|
||||
return fallback_tool_call(event, "search_replace")
|
||||
|
||||
args = event.args
|
||||
if args is None:
|
||||
return ToolCallStart(
|
||||
session_update="tool_call",
|
||||
title="search_replace",
|
||||
tool_call_id=event.tool_call_id,
|
||||
kind="edit",
|
||||
content=None,
|
||||
raw_input=None,
|
||||
)
|
||||
if not isinstance(args, SearchReplaceArgs):
|
||||
return None
|
||||
|
||||
blocks = cls._parse_search_replace_blocks(args.content)
|
||||
|
||||
|
|
@ -94,7 +91,7 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
|
|||
session_update="tool_call",
|
||||
title=cls.get_call_display(event).summary,
|
||||
tool_call_id=event.tool_call_id,
|
||||
kind="edit",
|
||||
kind=resolve_kind(event.tool_name),
|
||||
content=[
|
||||
FileEditToolCallContent(
|
||||
type="diff",
|
||||
|
|
@ -104,22 +101,18 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
|
|||
)
|
||||
for block in blocks
|
||||
],
|
||||
locations=[ToolCallLocation(path=args.file_path)],
|
||||
locations=[ToolCallLocation(path=str(Path(args.file_path).resolve()))],
|
||||
raw_input=args.model_dump_json(),
|
||||
field_meta={"tool_name": event.tool_name},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
|
||||
if event.error:
|
||||
return ToolCallProgress(
|
||||
session_update="tool_call_update",
|
||||
tool_call_id=event.tool_call_id,
|
||||
status="failed",
|
||||
)
|
||||
if failure := failed_tool_result(event, SearchReplaceResult):
|
||||
return failure
|
||||
|
||||
result = event.result
|
||||
if not isinstance(result, SearchReplaceResult):
|
||||
return None
|
||||
assert isinstance(result, SearchReplaceResult)
|
||||
|
||||
blocks = cls._parse_search_replace_blocks(result.content)
|
||||
|
||||
|
|
@ -127,6 +120,7 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
|
|||
session_update="tool_call_update",
|
||||
tool_call_id=event.tool_call_id,
|
||||
status="completed",
|
||||
kind=resolve_kind(event.tool_name),
|
||||
content=[
|
||||
FileEditToolCallContent(
|
||||
type="diff",
|
||||
|
|
@ -136,6 +130,7 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
|
|||
)
|
||||
for block in blocks
|
||||
],
|
||||
locations=[ToolCallLocation(path=result.file)],
|
||||
locations=[ToolCallLocation(path=str(Path(result.file).resolve()))],
|
||||
raw_output=result.model_dump_json(),
|
||||
field_meta={"tool_name": event.tool_name},
|
||||
)
|
||||
|
|
|
|||
79
vibe/acp/tools/builtins/skill.py
Normal file
79
vibe/acp/tools/builtins/skill.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from acp.helpers import SessionUpdate
|
||||
from acp.schema import (
|
||||
ContentToolCallContent,
|
||||
TextContentBlock,
|
||||
ToolCallLocation,
|
||||
ToolCallProgress,
|
||||
ToolCallStart,
|
||||
)
|
||||
|
||||
from vibe import VIBE_ROOT
|
||||
from vibe.acp.tools.base import (
|
||||
ToolCallSessionUpdateProtocol,
|
||||
ToolResultSessionUpdateProtocol,
|
||||
)
|
||||
from vibe.acp.tools.session_update import (
|
||||
failed_tool_result,
|
||||
fallback_tool_call,
|
||||
resolve_kind,
|
||||
)
|
||||
from vibe.core.tools.builtins.skill import (
|
||||
Skill as CoreSkillTool,
|
||||
SkillArgs,
|
||||
SkillResult,
|
||||
)
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
|
||||
|
||||
class Skill(
|
||||
CoreSkillTool, ToolCallSessionUpdateProtocol, ToolResultSessionUpdateProtocol
|
||||
):
|
||||
prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "skill.md"
|
||||
|
||||
@classmethod
|
||||
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
|
||||
if not isinstance(event.args, SkillArgs):
|
||||
return fallback_tool_call(event, "skill")
|
||||
|
||||
return ToolCallStart(
|
||||
session_update="tool_call",
|
||||
title=cls.get_call_display(event).summary,
|
||||
tool_call_id=event.tool_call_id,
|
||||
kind=resolve_kind(event.tool_name),
|
||||
raw_input=event.args.model_dump_json(),
|
||||
field_meta={"tool_name": event.tool_name, "skill_name": event.args.name},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
|
||||
if failure := failed_tool_result(event, SkillResult):
|
||||
return failure
|
||||
|
||||
result = event.result
|
||||
assert isinstance(result, SkillResult)
|
||||
|
||||
locations: list[ToolCallLocation] | None = None
|
||||
if result.skill_dir:
|
||||
locations = [ToolCallLocation(path=str(Path(result.skill_dir).resolve()))]
|
||||
|
||||
return ToolCallProgress(
|
||||
session_update="tool_call_update",
|
||||
tool_call_id=event.tool_call_id,
|
||||
status="completed",
|
||||
content=[
|
||||
ContentToolCallContent(
|
||||
type="content",
|
||||
content=TextContentBlock(
|
||||
type="text", text=cls.get_result_display(event).message
|
||||
),
|
||||
)
|
||||
],
|
||||
kind=resolve_kind(event.tool_name),
|
||||
raw_output=result.model_dump_json(),
|
||||
locations=locations,
|
||||
field_meta={"tool_name": event.tool_name, "skill_name": result.name},
|
||||
)
|
||||
75
vibe/acp/tools/builtins/task.py
Normal file
75
vibe/acp/tools/builtins/task.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from acp.helpers import SessionUpdate
|
||||
from acp.schema import (
|
||||
ContentToolCallContent,
|
||||
TextContentBlock,
|
||||
ToolCallProgress,
|
||||
ToolCallStart,
|
||||
)
|
||||
|
||||
from vibe import VIBE_ROOT
|
||||
from vibe.acp.tools.base import (
|
||||
ToolCallSessionUpdateProtocol,
|
||||
ToolResultSessionUpdateProtocol,
|
||||
)
|
||||
from vibe.acp.tools.session_update import (
|
||||
failed_tool_result,
|
||||
fallback_tool_call,
|
||||
resolve_kind,
|
||||
)
|
||||
from vibe.core.tools.builtins.task import Task as CoreTaskTool, TaskArgs, TaskResult
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
|
||||
|
||||
class Task(
|
||||
CoreTaskTool, ToolCallSessionUpdateProtocol, ToolResultSessionUpdateProtocol
|
||||
):
|
||||
prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "task.md"
|
||||
|
||||
@classmethod
|
||||
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
|
||||
if not isinstance(event.args, TaskArgs):
|
||||
return fallback_tool_call(event, "task")
|
||||
|
||||
return ToolCallStart(
|
||||
session_update="tool_call",
|
||||
title=cls.get_call_display(event).summary,
|
||||
tool_call_id=event.tool_call_id,
|
||||
kind=resolve_kind(event.tool_name),
|
||||
raw_input=event.args.model_dump_json(),
|
||||
field_meta={
|
||||
"tool_name": event.tool_name,
|
||||
"agent": event.args.agent,
|
||||
"task": event.args.task,
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
|
||||
if failure := failed_tool_result(event, TaskResult):
|
||||
return failure
|
||||
|
||||
result = event.result
|
||||
assert isinstance(result, TaskResult)
|
||||
|
||||
return ToolCallProgress(
|
||||
session_update="tool_call_update",
|
||||
tool_call_id=event.tool_call_id,
|
||||
status="completed" if result.completed else "failed",
|
||||
content=[
|
||||
ContentToolCallContent(
|
||||
type="content",
|
||||
content=TextContentBlock(
|
||||
type="text", text=cls.get_result_display(event).message
|
||||
),
|
||||
)
|
||||
],
|
||||
kind=resolve_kind(event.tool_name),
|
||||
raw_output=result.model_dump_json(),
|
||||
field_meta={
|
||||
"tool_name": event.tool_name,
|
||||
"turn_count": result.turns_used,
|
||||
"response": result.response,
|
||||
},
|
||||
)
|
||||
85
vibe/acp/tools/builtins/web_fetch.py
Normal file
85
vibe/acp/tools/builtins/web_fetch.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from acp.helpers import SessionUpdate
|
||||
from acp.schema import (
|
||||
ContentToolCallContent,
|
||||
TextContentBlock,
|
||||
ToolCallLocation,
|
||||
ToolCallProgress,
|
||||
ToolCallStart,
|
||||
)
|
||||
|
||||
from vibe import VIBE_ROOT
|
||||
from vibe.acp.tools.base import (
|
||||
ToolCallSessionUpdateProtocol,
|
||||
ToolResultSessionUpdateProtocol,
|
||||
)
|
||||
from vibe.acp.tools.session_update import (
|
||||
failed_tool_result,
|
||||
fallback_tool_call,
|
||||
resolve_kind,
|
||||
)
|
||||
from vibe.core.tools.builtins.webfetch import (
|
||||
WebFetch as CoreWebFetchTool,
|
||||
WebFetchArgs,
|
||||
WebFetchResult,
|
||||
)
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
|
||||
|
||||
class WebFetch(
|
||||
CoreWebFetchTool, ToolCallSessionUpdateProtocol, ToolResultSessionUpdateProtocol
|
||||
):
|
||||
prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "webfetch.md"
|
||||
|
||||
@classmethod
|
||||
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
|
||||
if not isinstance(event.args, WebFetchArgs):
|
||||
return fallback_tool_call(event, "web_fetch")
|
||||
|
||||
url = cls._normalize_url(event.args.url)
|
||||
|
||||
return ToolCallStart(
|
||||
session_update="tool_call",
|
||||
title=cls.get_call_display(event).summary,
|
||||
tool_call_id=event.tool_call_id,
|
||||
kind=resolve_kind(event.tool_name),
|
||||
raw_input=event.args.model_dump_json(),
|
||||
locations=[ToolCallLocation(path=url, field_meta={"type": "url"})],
|
||||
field_meta={"tool_name": event.tool_name},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
|
||||
if failure := failed_tool_result(event, WebFetchResult):
|
||||
return failure
|
||||
|
||||
result = event.result
|
||||
assert isinstance(result, WebFetchResult)
|
||||
|
||||
return ToolCallProgress(
|
||||
session_update="tool_call_update",
|
||||
tool_call_id=event.tool_call_id,
|
||||
status="completed",
|
||||
content=[
|
||||
ContentToolCallContent(
|
||||
type="content",
|
||||
content=TextContentBlock(
|
||||
type="text", text=cls.get_result_display(event).message
|
||||
),
|
||||
)
|
||||
],
|
||||
kind=resolve_kind(event.tool_name),
|
||||
raw_output=result.model_dump_json(),
|
||||
locations=[
|
||||
ToolCallLocation(
|
||||
path=result.url,
|
||||
field_meta={
|
||||
"type": "url",
|
||||
"char_count": len(result.content),
|
||||
"truncated": result.was_truncated,
|
||||
},
|
||||
)
|
||||
],
|
||||
field_meta={"tool_name": event.tool_name},
|
||||
)
|
||||
80
vibe/acp/tools/builtins/web_search.py
Normal file
80
vibe/acp/tools/builtins/web_search.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from acp.helpers import SessionUpdate
|
||||
from acp.schema import (
|
||||
ContentToolCallContent,
|
||||
TextContentBlock,
|
||||
ToolCallLocation,
|
||||
ToolCallProgress,
|
||||
ToolCallStart,
|
||||
)
|
||||
|
||||
from vibe import VIBE_ROOT
|
||||
from vibe.acp.tools.base import (
|
||||
ToolCallSessionUpdateProtocol,
|
||||
ToolResultSessionUpdateProtocol,
|
||||
)
|
||||
from vibe.acp.tools.session_update import (
|
||||
failed_tool_result,
|
||||
fallback_tool_call,
|
||||
resolve_kind,
|
||||
)
|
||||
from vibe.core.tools.builtins.websearch import (
|
||||
WebSearch as CoreWebSearchTool,
|
||||
WebSearchArgs,
|
||||
WebSearchResult,
|
||||
)
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
|
||||
|
||||
class WebSearch(
|
||||
CoreWebSearchTool, ToolCallSessionUpdateProtocol, ToolResultSessionUpdateProtocol
|
||||
):
|
||||
prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "websearch.md"
|
||||
|
||||
@classmethod
|
||||
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
|
||||
if not isinstance(event.args, WebSearchArgs):
|
||||
return fallback_tool_call(event, "web_search")
|
||||
|
||||
return ToolCallStart(
|
||||
session_update="tool_call",
|
||||
title=cls.get_call_display(event).summary,
|
||||
tool_call_id=event.tool_call_id,
|
||||
kind=resolve_kind(event.tool_name),
|
||||
raw_input=event.args.model_dump_json(),
|
||||
field_meta={"tool_name": event.tool_name, "query": event.args.query},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
|
||||
if failure := failed_tool_result(event, WebSearchResult):
|
||||
return failure
|
||||
|
||||
result = event.result
|
||||
assert isinstance(result, WebSearchResult)
|
||||
|
||||
locations = [
|
||||
ToolCallLocation(
|
||||
path=source.url, field_meta={"type": "url", "title": source.title}
|
||||
)
|
||||
for source in result.sources
|
||||
]
|
||||
|
||||
return ToolCallProgress(
|
||||
session_update="tool_call_update",
|
||||
tool_call_id=event.tool_call_id,
|
||||
status="completed",
|
||||
content=[
|
||||
ContentToolCallContent(
|
||||
type="content",
|
||||
content=TextContentBlock(
|
||||
type="text", text=cls.get_result_display(event).message
|
||||
),
|
||||
)
|
||||
],
|
||||
kind=resolve_kind(event.tool_name),
|
||||
raw_output=result.model_dump_json(),
|
||||
locations=locations if locations else None,
|
||||
field_meta={"tool_name": event.tool_name},
|
||||
)
|
||||
|
|
@ -12,6 +12,11 @@ from acp.schema import (
|
|||
|
||||
from vibe import VIBE_ROOT
|
||||
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
|
||||
from vibe.acp.tools.session_update import (
|
||||
failed_tool_result,
|
||||
fallback_tool_call,
|
||||
resolve_kind,
|
||||
)
|
||||
from vibe.core.tools.base import BaseToolState, ToolError
|
||||
from vibe.core.tools.builtins.write_file import (
|
||||
WriteFile as CoreWriteFileTool,
|
||||
|
|
@ -49,50 +54,40 @@ class WriteFile(CoreWriteFileTool, BaseAcpTool[AcpWriteFileState]):
|
|||
|
||||
@classmethod
|
||||
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
|
||||
args = event.args
|
||||
if args is None:
|
||||
return ToolCallStart(
|
||||
session_update="tool_call",
|
||||
title="write_file",
|
||||
tool_call_id=event.tool_call_id,
|
||||
kind="edit",
|
||||
content=None,
|
||||
raw_input=None,
|
||||
)
|
||||
if not isinstance(args, WriteFileArgs):
|
||||
return None
|
||||
if not isinstance(event.args, WriteFileArgs):
|
||||
return fallback_tool_call(event, "write_file")
|
||||
|
||||
return ToolCallStart(
|
||||
session_update="tool_call",
|
||||
title=cls.get_call_display(event).summary,
|
||||
title=cls.format_call_display(event.args).summary,
|
||||
tool_call_id=event.tool_call_id,
|
||||
kind="edit",
|
||||
kind=resolve_kind(event.tool_name),
|
||||
content=[
|
||||
FileEditToolCallContent(
|
||||
type="diff", path=args.path, old_text=None, new_text=args.content
|
||||
type="diff",
|
||||
path=event.args.path,
|
||||
old_text=None,
|
||||
new_text=event.args.content,
|
||||
)
|
||||
],
|
||||
locations=[ToolCallLocation(path=args.path)],
|
||||
raw_input=args.model_dump_json(),
|
||||
locations=[ToolCallLocation(path=str(Path(event.args.path).resolve()))],
|
||||
raw_input=event.args.model_dump_json(),
|
||||
field_meta={"tool_name": event.tool_name},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
|
||||
if event.error:
|
||||
return ToolCallProgress(
|
||||
session_update="tool_call_update",
|
||||
tool_call_id=event.tool_call_id,
|
||||
status="failed",
|
||||
)
|
||||
if failure := failed_tool_result(event, WriteFileResult):
|
||||
return failure
|
||||
|
||||
result = event.result
|
||||
if not isinstance(result, WriteFileResult):
|
||||
return None
|
||||
assert isinstance(result, WriteFileResult)
|
||||
|
||||
return ToolCallProgress(
|
||||
session_update="tool_call_update",
|
||||
tool_call_id=event.tool_call_id,
|
||||
status="completed",
|
||||
kind=resolve_kind(event.tool_name),
|
||||
content=[
|
||||
FileEditToolCallContent(
|
||||
type="diff",
|
||||
|
|
@ -101,6 +96,7 @@ class WriteFile(CoreWriteFileTool, BaseAcpTool[AcpWriteFileState]):
|
|||
new_text=result.content,
|
||||
)
|
||||
],
|
||||
locations=[ToolCallLocation(path=result.path)],
|
||||
locations=[ToolCallLocation(path=str(Path(result.path).resolve()))],
|
||||
raw_output=result.model_dump_json(),
|
||||
field_meta={"tool_name": event.tool_name},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from acp.schema import (
|
|||
ToolCallStart,
|
||||
ToolKind,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
from vibe.acp.tools.base import (
|
||||
ToolCallSessionUpdateProtocol,
|
||||
|
|
@ -17,16 +18,75 @@ from vibe.core.tools.ui import ToolUIDataAdapter
|
|||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
from vibe.core.utils import TaggedText, is_user_cancellation_event
|
||||
|
||||
TOOL_KIND: dict[str, ToolKind] = {
|
||||
"grep": "search",
|
||||
|
||||
def _cancellation_raw_output(event: ToolResultEvent) -> str | None:
|
||||
if event.skip_reason:
|
||||
return TaggedText.from_string(event.skip_reason).message
|
||||
if event.error:
|
||||
return TaggedText.from_string(event.error).message
|
||||
return None
|
||||
|
||||
|
||||
TOOL_KIND_MAP: dict[str, ToolKind] = {
|
||||
"read_file": "read",
|
||||
# Right now, jetbrains implementation of "edit" tool kind is broken
|
||||
# Leading to the tool not appearing in the chat
|
||||
# "write_file": "edit",
|
||||
# "search_replace": "edit",
|
||||
"grep": "search",
|
||||
"web_search": "search",
|
||||
"web_fetch": "fetch",
|
||||
"write_file": "edit",
|
||||
"search_replace": "edit",
|
||||
"bash": "execute",
|
||||
"skill": "read",
|
||||
}
|
||||
|
||||
|
||||
def resolve_kind(tool_name: str) -> ToolKind:
|
||||
return TOOL_KIND_MAP.get(tool_name, "other")
|
||||
|
||||
|
||||
def failed_tool_result(
|
||||
event: ToolResultEvent, expected_type: type[BaseModel]
|
||||
) -> ToolCallProgress | None:
|
||||
"""Return a failed ToolCallProgress if event is cancelled or has unexpected result type.
|
||||
|
||||
Returns None when the result is valid (caller handles the success path).
|
||||
"""
|
||||
kind = resolve_kind(event.tool_name)
|
||||
|
||||
if is_user_cancellation_event(event):
|
||||
return ToolCallProgress(
|
||||
session_update="tool_call_update",
|
||||
tool_call_id=event.tool_call_id,
|
||||
status="failed",
|
||||
kind=kind,
|
||||
raw_output=_cancellation_raw_output(event),
|
||||
field_meta={"tool_name": event.tool_name},
|
||||
)
|
||||
|
||||
if not isinstance(event.result, expected_type):
|
||||
return ToolCallProgress(
|
||||
session_update="tool_call_update",
|
||||
tool_call_id=event.tool_call_id,
|
||||
status="failed",
|
||||
kind=kind,
|
||||
raw_output=event.error or event.skip_reason,
|
||||
field_meta={"tool_name": event.tool_name},
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def fallback_tool_call(event: ToolCallEvent, title: str) -> ToolCallStart:
|
||||
"""Default ToolCallStart when args are None or an unexpected type."""
|
||||
return ToolCallStart(
|
||||
session_update="tool_call",
|
||||
title=title,
|
||||
tool_call_id=event.tool_call_id,
|
||||
kind=resolve_kind(event.tool_name),
|
||||
raw_input=None,
|
||||
field_meta={"tool_name": event.tool_name},
|
||||
)
|
||||
|
||||
|
||||
def tool_call_session_update(event: ToolCallEvent) -> SessionUpdate | None:
|
||||
if issubclass(event.tool_class, ToolCallSessionUpdateProtocol):
|
||||
return event.tool_class.tool_call_session_update(event)
|
||||
|
|
@ -49,8 +109,9 @@ def tool_call_session_update(event: ToolCallEvent) -> SessionUpdate | None:
|
|||
title=display.summary,
|
||||
content=content,
|
||||
tool_call_id=event.tool_call_id,
|
||||
kind=TOOL_KIND.get(event.tool_name, "other"),
|
||||
kind=resolve_kind(event.tool_name),
|
||||
raw_input=event.args.model_dump_json() if event.args else None,
|
||||
field_meta={"tool_name": event.tool_name},
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -116,6 +177,8 @@ def tool_result_session_update(event: ToolResultEvent) -> SessionUpdate | None:
|
|||
session_update="tool_call_update",
|
||||
tool_call_id=event.tool_call_id,
|
||||
status=tool_status,
|
||||
kind=resolve_kind(event.tool_name),
|
||||
raw_output=raw_output,
|
||||
content=content,
|
||||
field_meta={"tool_name": event.tool_name},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -305,6 +305,7 @@ def create_tool_call_replay(
|
|||
tool_call_id=tool_call_id,
|
||||
kind="other",
|
||||
raw_input=arguments,
|
||||
field_meta={"tool_name": tool_name},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
|
|||
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
|
||||
from vibe.cli.textual_ui.widgets.compact import CompactMessage
|
||||
from vibe.cli.textual_ui.widgets.config_app import ConfigApp
|
||||
from vibe.cli.textual_ui.widgets.connector_auth_app import ConnectorAuthApp
|
||||
from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenState
|
||||
from vibe.cli.textual_ui.widgets.debug_console import DebugConsole
|
||||
from vibe.cli.textual_ui.widgets.feedback_bar import FeedbackBar
|
||||
|
|
@ -198,6 +199,7 @@ class BottomApp(StrEnum):
|
|||
|
||||
Approval = auto()
|
||||
Config = auto()
|
||||
ConnectorAuth = auto()
|
||||
Input = auto()
|
||||
MCP = auto()
|
||||
ModelPicker = auto()
|
||||
|
|
@ -808,6 +810,27 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.query_one(MCPApp).refresh_index()
|
||||
self._refresh_banner()
|
||||
|
||||
async def on_mcpapp_connector_auth_requested(
|
||||
self, message: MCPApp.ConnectorAuthRequested
|
||||
) -> None:
|
||||
await self._switch_to_input_app()
|
||||
await self._switch_from_input(
|
||||
ConnectorAuthApp(
|
||||
connector_name=message.connector_name,
|
||||
connector_registry=message.connector_registry,
|
||||
tool_manager=message.tool_manager,
|
||||
)
|
||||
)
|
||||
|
||||
async def on_connector_auth_app_connector_auth_closed(
|
||||
self, message: ConnectorAuthApp.ConnectorAuthClosed
|
||||
) -> None:
|
||||
if message.refreshed:
|
||||
await self.agent_loop.refresh_system_prompt()
|
||||
self._refresh_banner()
|
||||
await self._switch_to_input_app()
|
||||
await self._show_mcp(cmd_args=message.connector_name)
|
||||
|
||||
async def on_proxy_setup_app_proxy_setup_closed(
|
||||
self, message: ProxySetupApp.ProxySetupClosed
|
||||
) -> None:
|
||||
|
|
@ -1336,9 +1359,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
teleport_msg.set_status("Teleporting...")
|
||||
case TeleportWaitingForGitHubEvent():
|
||||
teleport_msg.set_status("Connecting to GitHub...")
|
||||
case TeleportAuthRequiredEvent(oauth_url=url):
|
||||
case TeleportAuthRequiredEvent(oauth_url=url, message=msg):
|
||||
webbrowser.open(url)
|
||||
teleport_msg.set_status("Authorizing GitHub...")
|
||||
teleport_msg.set_status(msg or "Authorizing GitHub...")
|
||||
case TeleportAuthCompleteEvent():
|
||||
teleport_msg.set_status("GitHub authorized")
|
||||
case TeleportFetchingUrlEvent():
|
||||
|
|
@ -2059,6 +2082,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.query_one(SessionPickerApp).focus()
|
||||
case BottomApp.MCP:
|
||||
self.query_one(MCPApp).focus()
|
||||
case BottomApp.ConnectorAuth:
|
||||
self.query_one(ConnectorAuthApp).focus()
|
||||
case BottomApp.Rewind:
|
||||
self.query_one(RewindApp).focus()
|
||||
case BottomApp.Voice:
|
||||
|
|
@ -2335,7 +2360,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.run_worker(self._interrupt_agent_loop(), exclusive=False)
|
||||
|
||||
def _handle_bottom_app_close_escape(
|
||||
self, widget_type: type[MCPApp] | type[ProxySetupApp]
|
||||
self, widget_type: type[MCPApp] | type[ProxySetupApp] | type[ConnectorAuthApp]
|
||||
) -> None:
|
||||
try:
|
||||
self.query_one(widget_type).action_close()
|
||||
|
|
@ -2350,6 +2375,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._handle_voice_app_escape()
|
||||
elif self._current_bottom_app == BottomApp.MCP:
|
||||
self._handle_bottom_app_close_escape(MCPApp)
|
||||
elif self._current_bottom_app == BottomApp.ConnectorAuth:
|
||||
self._handle_bottom_app_close_escape(ConnectorAuthApp)
|
||||
elif self._current_bottom_app == BottomApp.ProxySetup:
|
||||
self._handle_bottom_app_close_escape(ProxySetupApp)
|
||||
elif self._current_bottom_app == BottomApp.Approval:
|
||||
|
|
|
|||
|
|
@ -691,7 +691,7 @@ StatusMessage {
|
|||
|
||||
}
|
||||
|
||||
#config-app, #voice-app, #mcp-app {
|
||||
#config-app, #voice-app, #mcp-app, #connectorauth-app {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
|
|
@ -700,7 +700,7 @@ StatusMessage {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
#config-content, #voice-content, #mcp-content {
|
||||
#config-content, #voice-content, #mcp-content, #connectorauth-content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
|
@ -711,16 +711,20 @@ StatusMessage {
|
|||
color: ansi_blue;
|
||||
}
|
||||
|
||||
#config-options, #mcp-options {
|
||||
#config-options, #mcp-options, #connectorauth-options {
|
||||
width: 100%;
|
||||
max-height: 50vh;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#config-options:focus, #mcp-options:focus {
|
||||
#config-options:focus, #mcp-options:focus, #connectorauth-options:focus {
|
||||
border: none;
|
||||
}
|
||||
|
||||
#connectorauth-detail {
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
.settings-help {
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
|
|
|
|||
227
vibe/cli/textual_ui/widgets/connector_auth_app.py
Normal file
227
vibe/cli/textual_ui/widgets/connector_auth_app.py
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum, auto
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
import webbrowser
|
||||
|
||||
from rich.text import Text
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Container, Vertical
|
||||
from textual.events import DescendantBlur
|
||||
from textual.message import Message
|
||||
from textual.widgets import OptionList
|
||||
from textual.widgets.option_list import Option
|
||||
from textual.worker import Worker
|
||||
|
||||
from vibe.cli.clipboard import copy_text_to_clipboard
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.core.tools.connectors import ConnectorRegistry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.tools.manager import ToolManager
|
||||
|
||||
_HELP = "Backspace Back"
|
||||
_OPTION_PADDING = " "
|
||||
|
||||
|
||||
class _AuthOptionId(StrEnum):
|
||||
OPEN = auto()
|
||||
COPY = auto()
|
||||
SHOW = auto()
|
||||
|
||||
|
||||
class ConnectorAuthApp(Container):
|
||||
"""Bottom-panel app for authenticating a workspace connector."""
|
||||
|
||||
can_focus_children = True
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("escape", "close", "Close", show=False),
|
||||
Binding("backspace", "close", "Back", show=False),
|
||||
Binding("r", "refresh", "Refresh", show=False),
|
||||
]
|
||||
|
||||
class ConnectorAuthClosed(Message):
|
||||
def __init__(
|
||||
self, *, refreshed: bool = False, connector_name: str = ""
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.refreshed = refreshed
|
||||
self.connector_name = connector_name
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
connector_name: str,
|
||||
connector_registry: ConnectorRegistry,
|
||||
tool_manager: ToolManager,
|
||||
) -> None:
|
||||
super().__init__(id="connectorauth-app")
|
||||
self._connector_name = connector_name
|
||||
self._connector_registry = connector_registry
|
||||
self._tool_manager = tool_manager
|
||||
self._auth_url: str | None = None
|
||||
self._auth_url_visible = False
|
||||
self._status_message: str | None = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="connectorauth-content"):
|
||||
yield NoMarkupStatic("", id="connectorauth-title", classes="settings-title")
|
||||
yield NoMarkupStatic("")
|
||||
yield OptionList(id="connectorauth-options")
|
||||
yield NoMarkupStatic("", id="connectorauth-detail")
|
||||
yield NoMarkupStatic("", id="connectorauth-help", classes="settings-help")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one("#connectorauth-title", NoMarkupStatic).update(
|
||||
f"Connector: {self._connector_name}"
|
||||
)
|
||||
option_list = self.query_one(OptionList)
|
||||
option_list.add_option(Option("Fetching authentication info...", disabled=True))
|
||||
self._set_help_text(_HELP)
|
||||
option_list.focus()
|
||||
self.run_worker(self._fetch_auth_url(), exclusive=True, group="auth_url")
|
||||
|
||||
def on_descendant_blur(self, _event: DescendantBlur) -> None:
|
||||
self.query_one(OptionList).focus()
|
||||
|
||||
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||||
option_id = event.option.id or ""
|
||||
if option_id == _AuthOptionId.OPEN:
|
||||
self._open_browser()
|
||||
elif option_id == _AuthOptionId.COPY:
|
||||
self._copy_url()
|
||||
elif option_id == _AuthOptionId.SHOW:
|
||||
self._toggle_url()
|
||||
|
||||
def action_close(self) -> None:
|
||||
self.post_message(self.ConnectorAuthClosed())
|
||||
|
||||
async def action_refresh(self) -> None:
|
||||
self._status_message = "Refreshing connector..."
|
||||
self._set_help_text(_HELP)
|
||||
self.run_worker(
|
||||
self._refresh_connector(), exclusive=True, group="connector_refresh"
|
||||
)
|
||||
|
||||
# ── workers ──────────────────────────────────────────────────────
|
||||
|
||||
async def _fetch_auth_url(self) -> str | None:
|
||||
return await self._connector_registry.get_auth_url(self._connector_name)
|
||||
|
||||
async def _refresh_connector(self) -> int:
|
||||
"""Refresh connector tools. Returns the number of tools discovered."""
|
||||
from vibe.core.tools.manager import ToolManager
|
||||
|
||||
new_tools = await self._connector_registry.refresh_connector_async(
|
||||
self._connector_name
|
||||
)
|
||||
if isinstance(self._tool_manager, ToolManager):
|
||||
await self._tool_manager.integrate_connectors_async()
|
||||
return len(new_tools)
|
||||
|
||||
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
||||
if event.worker.group == "auth_url" and event.worker.is_finished:
|
||||
self._on_auth_url_fetched(event.worker.result)
|
||||
elif event.worker.group == "connector_refresh" and event.worker.is_finished:
|
||||
tool_count = (
|
||||
event.worker.result if isinstance(event.worker.result, int) else 0
|
||||
)
|
||||
self._on_connector_refreshed(tool_count)
|
||||
|
||||
# ── auth UI ──────────────────────────────────────────────────────
|
||||
|
||||
def _on_auth_url_fetched(self, result: object) -> None:
|
||||
option_list = self.query_one(OptionList)
|
||||
option_list.clear_options()
|
||||
auth_url = result if isinstance(result, str) else None
|
||||
if auth_url:
|
||||
self._auth_url = auth_url
|
||||
option_list.add_option(
|
||||
Option(
|
||||
Text("This connector requires authentication", no_wrap=True),
|
||||
disabled=True,
|
||||
)
|
||||
)
|
||||
option_list.add_option(Option("", disabled=True))
|
||||
option_list.add_option(
|
||||
Option(
|
||||
Text(
|
||||
f"{_OPTION_PADDING}Press enter to open auth in your browser",
|
||||
no_wrap=True,
|
||||
),
|
||||
id=_AuthOptionId.OPEN,
|
||||
)
|
||||
)
|
||||
option_list.add_option(
|
||||
Option(
|
||||
Text(f"{_OPTION_PADDING}Copy URL to clipboard", no_wrap=True),
|
||||
id=_AuthOptionId.COPY,
|
||||
)
|
||||
)
|
||||
option_list.add_option(
|
||||
Option(
|
||||
Text(f"{_OPTION_PADDING}Manually show the URL", no_wrap=True),
|
||||
id=_AuthOptionId.SHOW,
|
||||
)
|
||||
)
|
||||
option_list.highlighted = option_list.get_option_index(_AuthOptionId.OPEN)
|
||||
self._update_detail_text()
|
||||
else:
|
||||
self.query_one("#connectorauth-detail", NoMarkupStatic).update("")
|
||||
option_list.add_option(
|
||||
Option("This connector does not provide authentication", disabled=True)
|
||||
)
|
||||
self._set_help_text(_HELP)
|
||||
|
||||
def _on_connector_refreshed(self, tool_count: int) -> None:
|
||||
if tool_count > 0:
|
||||
self._status_message = f"{tool_count} tools discovered."
|
||||
self.post_message(
|
||||
self.ConnectorAuthClosed(
|
||||
refreshed=True, connector_name=self._connector_name
|
||||
)
|
||||
)
|
||||
else:
|
||||
self._status_message = (
|
||||
"No tools discovered. Authentication may still be pending, "
|
||||
"try again in a moment."
|
||||
)
|
||||
self._set_help_text(_HELP)
|
||||
|
||||
# ── actions ──────────────────────────────────────────────────────
|
||||
|
||||
def _open_browser(self) -> None:
|
||||
if self._auth_url is None:
|
||||
return
|
||||
webbrowser.open(self._auth_url)
|
||||
self._status_message = "Opened in browser."
|
||||
self._set_help_text(_HELP)
|
||||
|
||||
def _copy_url(self) -> None:
|
||||
if self._auth_url is None:
|
||||
return
|
||||
copy_text_to_clipboard(
|
||||
self.app, self._auth_url, success_message="Auth URL copied to clipboard"
|
||||
)
|
||||
|
||||
def _toggle_url(self) -> None:
|
||||
if self._auth_url is None:
|
||||
return
|
||||
self._auth_url_visible = not self._auth_url_visible
|
||||
self._update_detail_text()
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────
|
||||
|
||||
def _update_detail_text(self) -> None:
|
||||
detail = self.query_one("#connectorauth-detail", NoMarkupStatic)
|
||||
parts: list[str] = []
|
||||
if self._auth_url_visible and self._auth_url:
|
||||
parts.append(self._auth_url)
|
||||
parts.append("")
|
||||
parts.append("Once authenticated, press R to refresh")
|
||||
detail.update("\n".join(parts))
|
||||
|
||||
def _set_help_text(self, text: str) -> None:
|
||||
if self._status_message:
|
||||
text = f"{self._status_message} {text}"
|
||||
self.query_one("#connectorauth-help", NoMarkupStatic).update(text)
|
||||
|
|
@ -62,9 +62,12 @@ def collect_mcp_tool_index(
|
|||
return MCPToolIndex(server_tools, connector_tools, enabled_tools=available)
|
||||
|
||||
|
||||
_LIST_VIEW_HELP = (
|
||||
_LIST_VIEW_HELP_TOOLS = (
|
||||
"↑↓ Navigate Enter Show tools D Disable E Enable R Refresh Esc Close"
|
||||
)
|
||||
_LIST_VIEW_HELP_AUTH = (
|
||||
"↑↓ Navigate Enter Authenticate D Disable E Enable R Refresh Esc Close"
|
||||
)
|
||||
_DETAIL_VIEW_HELP = (
|
||||
"↑↓ Navigate D Disable E Enable Backspace Back R Refresh Esc Close"
|
||||
)
|
||||
|
|
@ -99,6 +102,20 @@ class MCPApp(Container):
|
|||
self.disabled = disabled
|
||||
self.tool_name = tool_name
|
||||
|
||||
class ConnectorAuthRequested(Message):
|
||||
"""Posted when a disconnected connector needs authentication."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
connector_name: str,
|
||||
connector_registry: ConnectorRegistry,
|
||||
tool_manager: ToolManager,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.connector_name = connector_name
|
||||
self.connector_registry = connector_registry
|
||||
self.tool_manager = tool_manager
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mcp_servers: Sequence[MCPServer],
|
||||
|
|
@ -134,7 +151,6 @@ class MCPApp(Container):
|
|||
yield NoMarkupStatic("", id="mcp-title", classes="settings-title")
|
||||
yield NoMarkupStatic("")
|
||||
yield OptionList(id="mcp-options")
|
||||
yield NoMarkupStatic("")
|
||||
yield NoMarkupStatic("", id="mcp-help", classes="settings-help")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
|
|
@ -175,6 +191,9 @@ class MCPApp(Container):
|
|||
# it are disabled headers, scroll to top so the header stays visible.
|
||||
if all(option_list.get_option_at_index(i).disabled for i in range(highlighted)):
|
||||
option_list.scroll_to(y=0, animate=False, force=True, immediate=True)
|
||||
# Update help text based on whether highlighted connector needs auth.
|
||||
if self._viewing_server is None:
|
||||
self._set_help_text(self._list_help_for_option(event.option))
|
||||
|
||||
def action_back(self) -> None:
|
||||
if self._refreshing:
|
||||
|
|
@ -192,7 +211,7 @@ class MCPApp(Container):
|
|||
return
|
||||
|
||||
self._status_message = "Refreshing..."
|
||||
help = _DETAIL_VIEW_HELP if self._viewing_server else _LIST_VIEW_HELP
|
||||
help = _DETAIL_VIEW_HELP if self._viewing_server else _LIST_VIEW_HELP_TOOLS
|
||||
self._set_help_text(help)
|
||||
|
||||
self._refreshing = True
|
||||
|
|
@ -212,6 +231,15 @@ class MCPApp(Container):
|
|||
self._status_message = result if isinstance(result, str) else "Refreshed."
|
||||
self.refresh_index()
|
||||
|
||||
def _list_help_for_option(self, option: Option) -> str:
|
||||
"""Return the appropriate list-view help text for the given option."""
|
||||
option_id = option.id or ""
|
||||
if option_id.startswith("connector:") and self._connector_registry:
|
||||
name = option_id.removeprefix("connector:")
|
||||
if not self._connector_registry.is_connected(name):
|
||||
return _LIST_VIEW_HELP_AUTH
|
||||
return _LIST_VIEW_HELP_TOOLS
|
||||
|
||||
def _set_help_text(self, text: str) -> None:
|
||||
if self._status_message:
|
||||
text = f"{self._status_message} {text}"
|
||||
|
|
@ -385,7 +413,7 @@ class MCPApp(Container):
|
|||
has_connectors = connectors_enabled() and bool(self._connector_names)
|
||||
title = "MCP Servers & Connectors" if has_connectors else "MCP Servers"
|
||||
self.query_one("#mcp-title", NoMarkupStatic).update(title)
|
||||
self._set_help_text(_LIST_VIEW_HELP)
|
||||
self._set_help_text(_LIST_VIEW_HELP_TOOLS)
|
||||
|
||||
has_servers = bool(self._mcp_servers)
|
||||
|
||||
|
|
@ -495,9 +523,22 @@ class MCPApp(Container):
|
|||
tools_source = index.connector_tools if is_connector else index.server_tools
|
||||
all_tools = sorted(tools_source.get(server_name, []), key=lambda t: t[0])
|
||||
if not all_tools:
|
||||
option_list.add_option(
|
||||
Option("No tools discovered for this server", disabled=True)
|
||||
)
|
||||
if (
|
||||
is_connector
|
||||
and self._connector_registry
|
||||
and not self._connector_registry.is_connected(server_name)
|
||||
):
|
||||
self.post_message(
|
||||
self.ConnectorAuthRequested(
|
||||
connector_name=server_name,
|
||||
connector_registry=self._connector_registry,
|
||||
tool_manager=self._tool_manager,
|
||||
)
|
||||
)
|
||||
else:
|
||||
option_list.add_option(
|
||||
Option("No tools discovered for this server", disabled=True)
|
||||
)
|
||||
return
|
||||
for tool_name, cls in all_tools:
|
||||
is_tool_enabled = tool_name in index.enabled_tools
|
||||
|
|
|
|||
|
|
@ -727,10 +727,9 @@ class AgentLoop:
|
|||
self, provider: ProviderConfig | None = None
|
||||
) -> dict[str, str]:
|
||||
provider = self.config.get_active_provider() if provider is None else provider
|
||||
headers: dict[str, str] = {
|
||||
"user-agent": get_user_agent(provider.backend),
|
||||
"x-affinity": self.session_id,
|
||||
}
|
||||
headers: dict[str, str] = {**provider.extra_headers}
|
||||
headers["user-agent"] = get_user_agent(provider.backend)
|
||||
headers["x-affinity"] = self.session_id
|
||||
return headers
|
||||
|
||||
async def _conversation_loop(
|
||||
|
|
@ -1423,6 +1422,7 @@ class AgentLoop:
|
|||
self.session_logger.reset_session(
|
||||
self.session_id, parent_session_id=old_session_id
|
||||
)
|
||||
self.emit_new_session_telemetry()
|
||||
|
||||
async def fork(self, message_id: str | None = None) -> AgentLoop:
|
||||
messages = self._messages_for_fork(message_id)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,13 @@ from vibe.core.config.layer import (
|
|||
TrustResolutionError,
|
||||
UntrustedLayerError,
|
||||
)
|
||||
from vibe.core.config.patch import (
|
||||
AppendToList,
|
||||
DeleteField,
|
||||
PatchOp,
|
||||
RemoveFromList,
|
||||
SetField,
|
||||
)
|
||||
from vibe.core.config.schema import (
|
||||
DuplicateMergeMetadataError,
|
||||
MergeFieldMetadata,
|
||||
|
|
@ -60,9 +67,11 @@ __all__ = [
|
|||
"DEFAULT_TTS_MODELS",
|
||||
"DEFAULT_TTS_PROVIDERS",
|
||||
"THINKING_LEVELS",
|
||||
"AppendToList",
|
||||
"ConfigLayer",
|
||||
"ConfigLayerError",
|
||||
"ConnectorConfig",
|
||||
"DeleteField",
|
||||
"DuplicateMergeMetadataError",
|
||||
"EmptyLayerError",
|
||||
"LayerImplementationError",
|
||||
|
|
@ -75,10 +84,13 @@ __all__ = [
|
|||
"MissingPromptFileError",
|
||||
"ModelConfig",
|
||||
"OtelSpanExporterConfig",
|
||||
"PatchOp",
|
||||
"ProjectContextConfig",
|
||||
"ProviderConfig",
|
||||
"RawConfig",
|
||||
"RemoveFromList",
|
||||
"SessionLoggingConfig",
|
||||
"SetField",
|
||||
"TTSClient",
|
||||
"TTSModelConfig",
|
||||
"TTSProviderConfig",
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ class ProviderConfig(BaseModel):
|
|||
reasoning_field_name: str = "reasoning_content"
|
||||
project_id: str = ""
|
||||
region: str = ""
|
||||
extra_headers: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
def _is_legacy_mistral_provider_without_backend(self) -> bool:
|
||||
return (
|
||||
|
|
@ -434,9 +435,11 @@ DEFAULT_MODELS = [
|
|||
ModelConfig(
|
||||
name="mistral-vibe-cli-latest",
|
||||
provider="mistral",
|
||||
alias="devstral-2",
|
||||
input_price=0.4,
|
||||
output_price=2.0,
|
||||
alias="mistral-medium-3.5",
|
||||
temperature=1.0,
|
||||
input_price=1.5,
|
||||
output_price=7.5,
|
||||
thinking="high",
|
||||
),
|
||||
ModelConfig(
|
||||
name="devstral-small-latest",
|
||||
|
|
@ -973,14 +976,33 @@ class VibeConfig(BaseSettings):
|
|||
except (FileNotFoundError, tomllib.TOMLDecodeError, OSError):
|
||||
return
|
||||
|
||||
changed = False
|
||||
|
||||
bash_tools = data.get("tools", {}).get("bash", {})
|
||||
allowlist = bash_tools.get("allowlist")
|
||||
if allowlist is None or "find" in allowlist:
|
||||
return
|
||||
if allowlist is not None and "find" not in allowlist:
|
||||
allowlist.append("find")
|
||||
allowlist.sort()
|
||||
changed = True
|
||||
|
||||
allowlist.append("find")
|
||||
allowlist.sort()
|
||||
cls.dump_config(data)
|
||||
for model in data.get("models", []):
|
||||
if (
|
||||
model.get("name") == "mistral-vibe-cli-latest"
|
||||
and model.get("alias") == "devstral-2"
|
||||
):
|
||||
model["alias"] = "mistral-medium-3.5"
|
||||
model["temperature"] = 1.0
|
||||
model["input_price"] = 1.5
|
||||
model["output_price"] = 7.5
|
||||
model["thinking"] = "high"
|
||||
changed = True
|
||||
|
||||
if data.get("active_model") == "devstral-2":
|
||||
data["active_model"] = "mistral-medium-3.5"
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
cls.dump_config(data)
|
||||
|
||||
@classmethod
|
||||
def load(cls, **overrides: Any) -> VibeConfig:
|
||||
|
|
|
|||
70
vibe/core/config/patch.py
Normal file
70
vibe/core/config/patch.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SetField:
|
||||
"""Set a top-level or nested field to a value."""
|
||||
|
||||
key: str
|
||||
value: Any
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_key_path(self.key)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AppendToList:
|
||||
"""Append items to a list field."""
|
||||
|
||||
key: str
|
||||
items: tuple[Any, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_key_path(self.key)
|
||||
_validate_tuple_value("AppendToList.items", self.items)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RemoveFromList:
|
||||
"""Remove items from a list field by value."""
|
||||
|
||||
key: str
|
||||
values: tuple[Any, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_key_path(self.key)
|
||||
_validate_tuple_value("RemoveFromList.values", self.values)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeleteField:
|
||||
"""Remove a field entirely from the config."""
|
||||
|
||||
key: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_key_path(self.key)
|
||||
|
||||
|
||||
PatchOp = SetField | AppendToList | RemoveFromList | DeleteField
|
||||
|
||||
|
||||
def _validate_key_path(key: object) -> None:
|
||||
if not isinstance(key, str):
|
||||
raise TypeError(
|
||||
f"Patch operation key must be a string, got {type(key).__name__}"
|
||||
)
|
||||
if not key:
|
||||
raise ValueError("Patch operation key must not be empty")
|
||||
if any(not segment for segment in key.split(".")):
|
||||
raise ValueError(
|
||||
"Patch operation key must be a dot-separated path without empty segments"
|
||||
)
|
||||
|
||||
|
||||
def _validate_tuple_value(field_name: str, value: object) -> None:
|
||||
if not isinstance(value, tuple):
|
||||
raise TypeError(f"{field_name} must be a tuple, got {type(value).__name__}")
|
||||
|
|
@ -64,8 +64,8 @@ class TextOutputFormatter(OutputFormatter):
|
|||
self._print("Teleporting...")
|
||||
case TeleportWaitingForGitHubEvent():
|
||||
self._print("Connecting to GitHub...")
|
||||
case TeleportAuthRequiredEvent(oauth_url=url):
|
||||
self._print(f"Open to authorize GitHub: {url}")
|
||||
case TeleportAuthRequiredEvent(oauth_url=url, message=msg):
|
||||
self._print(msg or f"Open to authorize GitHub: {url}")
|
||||
case TeleportAuthCompleteEvent():
|
||||
self._print("GitHub authorized")
|
||||
case TeleportFetchingUrlEvent():
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ environment variables with the `VIBE_` prefix (e.g., `VIBE_ACTIVE_MODEL=local`).
|
|||
|
||||
```toml
|
||||
# Model selection
|
||||
active_model = "devstral-2" # Model alias to use (see [[models]])
|
||||
active_model = "mistral-medium-3.5" # Model alias to use (see [[models]])
|
||||
|
||||
# UI preferences
|
||||
vim_keybindings = false
|
||||
|
|
@ -103,6 +103,7 @@ backend = "mistral"
|
|||
name = "llamacpp"
|
||||
api_base = "http://127.0.0.1:8080/v1"
|
||||
api_key_env_var = ""
|
||||
extra_headers = { "X-Custom-Header" = "value" } # optional per-provider HTTP headers
|
||||
```
|
||||
|
||||
### Models
|
||||
|
|
@ -111,11 +112,11 @@ api_key_env_var = ""
|
|||
[[models]]
|
||||
name = "mistral-vibe-cli-latest"
|
||||
provider = "mistral"
|
||||
alias = "devstral-2"
|
||||
temperature = 0.2
|
||||
input_price = 0.4
|
||||
output_price = 2.0
|
||||
thinking = "off" # "off", "low", "medium", "high", "max"
|
||||
alias = "mistral-medium-3.5"
|
||||
temperature = 1.0
|
||||
input_price = 1.5
|
||||
output_price = 7.5
|
||||
thinking = "high" # "off", "low", "medium", "high", "max"
|
||||
auto_compact_threshold = 200000
|
||||
|
||||
[[models]]
|
||||
|
|
|
|||
|
|
@ -188,7 +188,9 @@ class TeleportService:
|
|||
|
||||
if not github_data.connected:
|
||||
if github_data.oauth_url:
|
||||
yield TeleportAuthRequiredEvent(oauth_url=github_data.oauth_url)
|
||||
yield TeleportAuthRequiredEvent(
|
||||
oauth_url=github_data.oauth_url, message=github_data.error
|
||||
)
|
||||
await self._nuage_client.wait_for_github_connection(execution_id)
|
||||
yield TeleportAuthCompleteEvent()
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from vibe.core.types import BaseEvent
|
|||
|
||||
class TeleportAuthRequiredEvent(BaseEvent):
|
||||
oauth_url: str
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class TeleportAuthCompleteEvent(BaseEvent):
|
||||
|
|
|
|||
|
|
@ -94,6 +94,43 @@ class GrepArgs(BaseModel):
|
|||
)
|
||||
|
||||
|
||||
class GrepMatch(BaseModel):
|
||||
path: str
|
||||
line: int | None = None
|
||||
|
||||
@classmethod
|
||||
def from_output_line(cls, raw: str) -> GrepMatch | None:
|
||||
"""Parse a single grep/rg output line in `file:line:content` format.
|
||||
|
||||
Handles Windows drive-letter paths like ``C:\\repo\\file.py:10:match``
|
||||
by skipping a single-letter first segment.
|
||||
"""
|
||||
parts = raw.split(":", 3)
|
||||
MIN_MATCH_PARTS = 2
|
||||
if len(parts) < MIN_MATCH_PARTS:
|
||||
return None
|
||||
|
||||
# Windows drive letter: first part is a single letter (e.g. "C")
|
||||
MIN_WINDOWS_PARTS = 3
|
||||
is_windows_path = (
|
||||
len(parts[0]) == 1
|
||||
and parts[0].isalpha()
|
||||
and len(parts) >= MIN_WINDOWS_PARTS
|
||||
)
|
||||
if is_windows_path:
|
||||
file_path = f"{parts[0]}:{parts[1]}"
|
||||
line_str = parts[2]
|
||||
else:
|
||||
file_path = parts[0]
|
||||
line_str = parts[1]
|
||||
|
||||
try:
|
||||
line_num = int(line_str) if line_str else None
|
||||
except (ValueError, TypeError):
|
||||
line_num = None
|
||||
return cls(path=str(Path(file_path).resolve()), line=line_num)
|
||||
|
||||
|
||||
class GrepResult(BaseModel):
|
||||
matches: str
|
||||
match_count: int
|
||||
|
|
@ -101,6 +138,14 @@ class GrepResult(BaseModel):
|
|||
description="True if output was cut short by max_matches or max_output_bytes."
|
||||
)
|
||||
|
||||
@property
|
||||
def parsed_matches(self) -> list[GrepMatch]:
|
||||
results: list[GrepMatch] = []
|
||||
for line in self.matches.splitlines():
|
||||
if match := GrepMatch.from_output_line(line):
|
||||
results.append(match)
|
||||
return results
|
||||
|
||||
|
||||
class Grep(
|
||||
BaseTool[GrepArgs, GrepResult, GrepToolConfig, BaseToolState],
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ class ReadFileArgs(BaseModel):
|
|||
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."
|
||||
|
|
@ -90,6 +91,7 @@ class ReadFile(
|
|||
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ class ConnectorRegistry:
|
|||
self._cache: dict[str, dict[str, type[BaseTool]]] | None = None
|
||||
self._connector_names: list[str] = []
|
||||
self._connector_connected: dict[str, bool] = {}
|
||||
self._alias_to_id: dict[str, str] = {}
|
||||
self._discover_lock = asyncio.Lock()
|
||||
|
||||
def _get_client(self) -> Mistral:
|
||||
|
|
@ -269,6 +270,7 @@ class ConnectorRegistry:
|
|||
self._cache = {}
|
||||
self._connector_names = []
|
||||
self._connector_connected = {}
|
||||
self._alias_to_id = {}
|
||||
return {}
|
||||
|
||||
# Build results locally to avoid publishing incomplete state.
|
||||
|
|
@ -324,6 +326,7 @@ class ConnectorRegistry:
|
|||
# lock will see the completed cache.
|
||||
self._connector_names = connector_names
|
||||
self._connector_connected = connector_connected
|
||||
self._alias_to_id = {alias: cid for cid, alias, _ in unique_connectors}
|
||||
self._cache = cache
|
||||
|
||||
return all_tools
|
||||
|
|
@ -411,7 +414,63 @@ class ConnectorRegistry:
|
|||
def is_connected(self, name: str) -> bool:
|
||||
return self._connector_connected.get(name, False)
|
||||
|
||||
def get_connector_id(self, alias: str) -> str | None:
|
||||
"""Return the API connector ID for a given alias, or None."""
|
||||
return self._alias_to_id.get(alias)
|
||||
|
||||
async def refresh_connector_async(self, alias: str) -> dict[str, type[BaseTool]]:
|
||||
"""Re-fetch tools for a single connector by alias.
|
||||
|
||||
Updates the internal cache for that connector only. Returns
|
||||
the new tool map (empty dict on failure).
|
||||
"""
|
||||
connector_id = self._alias_to_id.get(alias)
|
||||
if connector_id is None:
|
||||
return {}
|
||||
|
||||
try:
|
||||
async with self._get_client() as client:
|
||||
connector = await client.beta.connectors.get_async(
|
||||
connector_id_or_name=connector_id
|
||||
)
|
||||
tools_map = await self._fetch_connector_tools(client, connector, alias)
|
||||
except Exception:
|
||||
logger.debug("Failed to refresh connector %s", alias)
|
||||
tools_map = None
|
||||
|
||||
if self._cache is None:
|
||||
self._cache = {}
|
||||
|
||||
if tools_map is None:
|
||||
self._cache.pop(connector_id, None)
|
||||
self._connector_connected[alias] = False
|
||||
return {}
|
||||
|
||||
self._cache[connector_id] = tools_map
|
||||
self._connector_connected[alias] = bool(tools_map)
|
||||
return tools_map
|
||||
|
||||
async def get_auth_url(self, alias: str) -> str | None:
|
||||
"""Return the OAuth authorization URL for a connector, or None.
|
||||
|
||||
Returns None when the connector does not support OAuth or the
|
||||
alias is unknown.
|
||||
"""
|
||||
connector_id = self._alias_to_id.get(alias)
|
||||
if connector_id is None:
|
||||
return None
|
||||
try:
|
||||
async with self._get_client() as client:
|
||||
result = await client.beta.connectors.get_auth_url_async(
|
||||
connector_id_or_name=connector_id
|
||||
)
|
||||
return result.auth_url
|
||||
except Exception:
|
||||
logger.debug("Failed to get auth URL for connector %s", alias)
|
||||
return None
|
||||
|
||||
def clear(self) -> None:
|
||||
self._cache = None
|
||||
self._connector_names = []
|
||||
self._connector_connected = {}
|
||||
self._alias_to_id = {}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
# What's new in v2.9.1
|
||||
|
||||
- **Default model**: Migrated to `mistral-medium-3.5`.
|
||||
- **Connector OAuth**: Authenticate connectors via OAuth directly from the `/mcp` menu.
|
||||
|
||||
# What's new in v2.9.0
|
||||
- **Scratchpad directory**: Model's autonomous workspace for temporary files, intermediate results, and drafts shared with subagents
|
||||
- **`/copy` command**: New slash command to copy content directly from the conversation
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue