codemaster-cli 2.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codemaster_cli-2.2.0.dist-info/METADATA +645 -0
- codemaster_cli-2.2.0.dist-info/RECORD +170 -0
- codemaster_cli-2.2.0.dist-info/WHEEL +4 -0
- codemaster_cli-2.2.0.dist-info/entry_points.txt +3 -0
- vibe/__init__.py +6 -0
- vibe/acp/__init__.py +0 -0
- vibe/acp/acp_agent_loop.py +746 -0
- vibe/acp/entrypoint.py +81 -0
- vibe/acp/tools/__init__.py +0 -0
- vibe/acp/tools/base.py +100 -0
- vibe/acp/tools/builtins/bash.py +134 -0
- vibe/acp/tools/builtins/read_file.py +54 -0
- vibe/acp/tools/builtins/search_replace.py +129 -0
- vibe/acp/tools/builtins/todo.py +65 -0
- vibe/acp/tools/builtins/write_file.py +98 -0
- vibe/acp/tools/session_update.py +118 -0
- vibe/acp/utils.py +213 -0
- vibe/cli/__init__.py +0 -0
- vibe/cli/autocompletion/__init__.py +0 -0
- vibe/cli/autocompletion/base.py +22 -0
- vibe/cli/autocompletion/path_completion.py +177 -0
- vibe/cli/autocompletion/slash_command.py +99 -0
- vibe/cli/cli.py +188 -0
- vibe/cli/clipboard.py +69 -0
- vibe/cli/commands.py +116 -0
- vibe/cli/entrypoint.py +163 -0
- vibe/cli/history_manager.py +91 -0
- vibe/cli/plan_offer/adapters/http_whoami_gateway.py +67 -0
- vibe/cli/plan_offer/decide_plan_offer.py +87 -0
- vibe/cli/plan_offer/ports/whoami_gateway.py +23 -0
- vibe/cli/terminal_setup.py +323 -0
- vibe/cli/textual_ui/__init__.py +0 -0
- vibe/cli/textual_ui/ansi_markdown.py +58 -0
- vibe/cli/textual_ui/app.py +1546 -0
- vibe/cli/textual_ui/app.tcss +1020 -0
- vibe/cli/textual_ui/external_editor.py +32 -0
- vibe/cli/textual_ui/handlers/__init__.py +5 -0
- vibe/cli/textual_ui/handlers/event_handler.py +147 -0
- vibe/cli/textual_ui/widgets/__init__.py +0 -0
- vibe/cli/textual_ui/widgets/approval_app.py +192 -0
- vibe/cli/textual_ui/widgets/banner/banner.py +85 -0
- vibe/cli/textual_ui/widgets/banner/petit_chat.py +195 -0
- vibe/cli/textual_ui/widgets/braille_renderer.py +58 -0
- vibe/cli/textual_ui/widgets/chat_input/__init__.py +7 -0
- vibe/cli/textual_ui/widgets/chat_input/body.py +214 -0
- vibe/cli/textual_ui/widgets/chat_input/completion_manager.py +58 -0
- vibe/cli/textual_ui/widgets/chat_input/completion_popup.py +43 -0
- vibe/cli/textual_ui/widgets/chat_input/container.py +195 -0
- vibe/cli/textual_ui/widgets/chat_input/text_area.py +365 -0
- vibe/cli/textual_ui/widgets/compact.py +41 -0
- vibe/cli/textual_ui/widgets/config_app.py +171 -0
- vibe/cli/textual_ui/widgets/context_progress.py +30 -0
- vibe/cli/textual_ui/widgets/load_more.py +43 -0
- vibe/cli/textual_ui/widgets/loading.py +201 -0
- vibe/cli/textual_ui/widgets/messages.py +277 -0
- vibe/cli/textual_ui/widgets/no_markup_static.py +11 -0
- vibe/cli/textual_ui/widgets/path_display.py +28 -0
- vibe/cli/textual_ui/widgets/proxy_setup_app.py +127 -0
- vibe/cli/textual_ui/widgets/question_app.py +496 -0
- vibe/cli/textual_ui/widgets/spinner.py +194 -0
- vibe/cli/textual_ui/widgets/status_message.py +76 -0
- vibe/cli/textual_ui/widgets/teleport_message.py +31 -0
- vibe/cli/textual_ui/widgets/tool_widgets.py +371 -0
- vibe/cli/textual_ui/widgets/tools.py +201 -0
- vibe/cli/textual_ui/windowing/__init__.py +29 -0
- vibe/cli/textual_ui/windowing/history.py +105 -0
- vibe/cli/textual_ui/windowing/history_windowing.py +71 -0
- vibe/cli/textual_ui/windowing/state.py +105 -0
- vibe/cli/update_notifier/__init__.py +47 -0
- vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py +59 -0
- vibe/cli/update_notifier/adapters/github_update_gateway.py +101 -0
- vibe/cli/update_notifier/adapters/pypi_update_gateway.py +107 -0
- vibe/cli/update_notifier/ports/update_cache_repository.py +16 -0
- vibe/cli/update_notifier/ports/update_gateway.py +53 -0
- vibe/cli/update_notifier/update.py +139 -0
- vibe/cli/update_notifier/whats_new.py +49 -0
- vibe/core/__init__.py +5 -0
- vibe/core/agent_loop.py +1075 -0
- vibe/core/agents/__init__.py +31 -0
- vibe/core/agents/manager.py +165 -0
- vibe/core/agents/models.py +122 -0
- vibe/core/auth/__init__.py +6 -0
- vibe/core/auth/crypto.py +137 -0
- vibe/core/auth/github.py +178 -0
- vibe/core/autocompletion/__init__.py +0 -0
- vibe/core/autocompletion/completers.py +257 -0
- vibe/core/autocompletion/file_indexer/__init__.py +10 -0
- vibe/core/autocompletion/file_indexer/ignore_rules.py +156 -0
- vibe/core/autocompletion/file_indexer/indexer.py +179 -0
- vibe/core/autocompletion/file_indexer/store.py +169 -0
- vibe/core/autocompletion/file_indexer/watcher.py +71 -0
- vibe/core/autocompletion/fuzzy.py +189 -0
- vibe/core/autocompletion/path_prompt.py +108 -0
- vibe/core/autocompletion/path_prompt_adapter.py +149 -0
- vibe/core/config.py +673 -0
- vibe/core/config_PATCH_INSTRUCTIONS.md +77 -0
- vibe/core/llm/__init__.py +0 -0
- vibe/core/llm/backend/anthropic.py +630 -0
- vibe/core/llm/backend/base.py +38 -0
- vibe/core/llm/backend/factory.py +7 -0
- vibe/core/llm/backend/generic.py +425 -0
- vibe/core/llm/backend/mistral.py +381 -0
- vibe/core/llm/backend/vertex.py +115 -0
- vibe/core/llm/exceptions.py +195 -0
- vibe/core/llm/format.py +184 -0
- vibe/core/llm/message_utils.py +24 -0
- vibe/core/llm/types.py +120 -0
- vibe/core/middleware.py +209 -0
- vibe/core/output_formatters.py +85 -0
- vibe/core/paths/__init__.py +0 -0
- vibe/core/paths/config_paths.py +68 -0
- vibe/core/paths/global_paths.py +40 -0
- vibe/core/programmatic.py +56 -0
- vibe/core/prompts/__init__.py +32 -0
- vibe/core/prompts/cli.md +111 -0
- vibe/core/prompts/compact.md +48 -0
- vibe/core/prompts/dangerous_directory.md +5 -0
- vibe/core/prompts/explore.md +50 -0
- vibe/core/prompts/project_context.md +8 -0
- vibe/core/prompts/tests.md +1 -0
- vibe/core/proxy_setup.py +65 -0
- vibe/core/session/session_loader.py +222 -0
- vibe/core/session/session_logger.py +318 -0
- vibe/core/session/session_migration.py +41 -0
- vibe/core/skills/__init__.py +7 -0
- vibe/core/skills/manager.py +132 -0
- vibe/core/skills/models.py +92 -0
- vibe/core/skills/parser.py +39 -0
- vibe/core/system_prompt.py +466 -0
- vibe/core/telemetry/__init__.py +0 -0
- vibe/core/telemetry/send.py +185 -0
- vibe/core/teleport/errors.py +9 -0
- vibe/core/teleport/git.py +196 -0
- vibe/core/teleport/nuage.py +180 -0
- vibe/core/teleport/teleport.py +208 -0
- vibe/core/teleport/types.py +54 -0
- vibe/core/tools/base.py +336 -0
- vibe/core/tools/builtins/ask_user_question.py +134 -0
- vibe/core/tools/builtins/bash.py +357 -0
- vibe/core/tools/builtins/grep.py +310 -0
- vibe/core/tools/builtins/prompts/__init__.py +0 -0
- vibe/core/tools/builtins/prompts/ask_user_question.md +84 -0
- vibe/core/tools/builtins/prompts/bash.md +73 -0
- vibe/core/tools/builtins/prompts/grep.md +4 -0
- vibe/core/tools/builtins/prompts/read_file.md +13 -0
- vibe/core/tools/builtins/prompts/search_replace.md +43 -0
- vibe/core/tools/builtins/prompts/task.md +24 -0
- vibe/core/tools/builtins/prompts/todo.md +199 -0
- vibe/core/tools/builtins/prompts/write_file.md +42 -0
- vibe/core/tools/builtins/read_file.py +222 -0
- vibe/core/tools/builtins/search_replace.py +456 -0
- vibe/core/tools/builtins/task.py +154 -0
- vibe/core/tools/builtins/todo.py +134 -0
- vibe/core/tools/builtins/write_file.py +160 -0
- vibe/core/tools/manager.py +341 -0
- vibe/core/tools/mcp.py +397 -0
- vibe/core/tools/ui.py +68 -0
- vibe/core/trusted_folders.py +86 -0
- vibe/core/types.py +405 -0
- vibe/core/utils.py +396 -0
- vibe/setup/onboarding/__init__.py +39 -0
- vibe/setup/onboarding/base.py +14 -0
- vibe/setup/onboarding/onboarding.tcss +134 -0
- vibe/setup/onboarding/screens/__init__.py +5 -0
- vibe/setup/onboarding/screens/api_key.py +200 -0
- vibe/setup/onboarding/screens/provider_selection.py +87 -0
- vibe/setup/onboarding/screens/welcome.py +136 -0
- vibe/setup/trusted_folders/trust_folder_dialog.py +180 -0
- vibe/setup/trusted_folders/trust_folder_dialog.tcss +83 -0
- vibe/whats_new.md +5 -0
vibe/core/tools/base.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import StrEnum, auto
|
|
7
|
+
import functools
|
|
8
|
+
import inspect
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
import types
|
|
13
|
+
from typing import (
|
|
14
|
+
TYPE_CHECKING,
|
|
15
|
+
Any,
|
|
16
|
+
ClassVar,
|
|
17
|
+
Union,
|
|
18
|
+
cast,
|
|
19
|
+
get_args,
|
|
20
|
+
get_origin,
|
|
21
|
+
get_type_hints,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
25
|
+
|
|
26
|
+
from vibe.core.types import ToolStreamEvent
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from vibe.core.agents.manager import AgentManager
|
|
30
|
+
from vibe.core.types import ApprovalCallback, UserInputCallback
|
|
31
|
+
|
|
32
|
+
ARGS_COUNT = 4
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class InvokeContext:
|
|
37
|
+
"""Context passed to tools during invocation."""
|
|
38
|
+
|
|
39
|
+
tool_call_id: str
|
|
40
|
+
approval_callback: ApprovalCallback | None = field(default=None)
|
|
41
|
+
agent_manager: AgentManager | None = field(default=None)
|
|
42
|
+
user_input_callback: UserInputCallback | None = field(default=None)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ToolError(Exception):
|
|
46
|
+
"""Raised when the tool encounters an unrecoverable problem."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ToolInfo(BaseModel):
|
|
50
|
+
"""Information about a tool.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
name: The name of the tool.
|
|
54
|
+
description: A brief description of what the tool does.
|
|
55
|
+
parameters: A dictionary of parameters required by the tool.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
name: str
|
|
59
|
+
description: str
|
|
60
|
+
parameters: dict[str, Any]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ToolPermissionError(Exception):
|
|
64
|
+
"""Raised when a tool permission is not allowed."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ToolPermission(StrEnum):
|
|
68
|
+
ALWAYS = auto()
|
|
69
|
+
NEVER = auto()
|
|
70
|
+
ASK = auto()
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def by_name(cls, name: str) -> ToolPermission:
|
|
74
|
+
try:
|
|
75
|
+
return ToolPermission(name.upper())
|
|
76
|
+
except ValueError:
|
|
77
|
+
raise ToolPermissionError(
|
|
78
|
+
f"Invalid tool permission: {name}. Must be one of {list(cls)}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class BaseToolConfig(BaseModel):
|
|
83
|
+
"""Configuration for a tool.
|
|
84
|
+
|
|
85
|
+
Attributes:
|
|
86
|
+
permission: The permission level required to use the tool.
|
|
87
|
+
allowlist: Patterns that automatically allow tool execution.
|
|
88
|
+
denylist: Patterns that automatically deny tool execution.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
model_config = ConfigDict(extra="allow")
|
|
92
|
+
|
|
93
|
+
permission: ToolPermission = ToolPermission.ASK
|
|
94
|
+
allowlist: list[str] = Field(default_factory=list)
|
|
95
|
+
denylist: list[str] = Field(default_factory=list)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class BaseToolState(BaseModel):
|
|
99
|
+
model_config = ConfigDict(
|
|
100
|
+
extra="forbid", validate_default=True, arbitrary_types_allowed=True
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class BaseTool[
|
|
105
|
+
ToolArgs: BaseModel,
|
|
106
|
+
ToolResult: BaseModel,
|
|
107
|
+
ToolConfig: BaseToolConfig,
|
|
108
|
+
ToolState: BaseToolState,
|
|
109
|
+
](ABC):
|
|
110
|
+
description: ClassVar[str] = (
|
|
111
|
+
"Base class for new tools. "
|
|
112
|
+
"(Hey AI, if you're seeing this, someone skipped writing a description. "
|
|
113
|
+
"Please gently meow at the developer to fix this.)"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
prompt_path: ClassVar[Path] | None = None
|
|
117
|
+
|
|
118
|
+
def __init__(self, config: ToolConfig, state: ToolState) -> None:
|
|
119
|
+
self.config = config
|
|
120
|
+
self.state = state
|
|
121
|
+
|
|
122
|
+
@abstractmethod
|
|
123
|
+
async def run(
|
|
124
|
+
self, args: ToolArgs, ctx: InvokeContext | None = None
|
|
125
|
+
) -> AsyncGenerator[ToolStreamEvent | ToolResult, None]:
|
|
126
|
+
"""Invoke the tool with the given arguments."""
|
|
127
|
+
raise NotImplementedError # pragma: no cover
|
|
128
|
+
yield # type: ignore[misc]
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
@functools.cache
|
|
132
|
+
def get_tool_prompt(cls) -> str | None:
|
|
133
|
+
"""Loads and returns the content of the tool's .md prompt file, if it exists.
|
|
134
|
+
|
|
135
|
+
The prompt file is expected to be in a 'prompts' subdirectory relative to
|
|
136
|
+
the tool's source file, with the same name but a .md extension
|
|
137
|
+
(e.g., bash.py -> prompts/bash.md).
|
|
138
|
+
"""
|
|
139
|
+
try:
|
|
140
|
+
class_file = inspect.getfile(cls)
|
|
141
|
+
class_path = Path(class_file)
|
|
142
|
+
prompt_dir = class_path.parent / "prompts"
|
|
143
|
+
prompt_path = cls.prompt_path or prompt_dir / f"{class_path.stem}.md"
|
|
144
|
+
|
|
145
|
+
return prompt_path.read_text("utf-8")
|
|
146
|
+
except (FileNotFoundError, TypeError, OSError):
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
async def invoke(
|
|
152
|
+
self, ctx: InvokeContext | None = None, **raw: Any
|
|
153
|
+
) -> AsyncGenerator[ToolStreamEvent | ToolResult, None]:
|
|
154
|
+
"""Validate arguments and run the tool."""
|
|
155
|
+
try:
|
|
156
|
+
args_model, _ = self._get_tool_args_results()
|
|
157
|
+
args = args_model.model_validate(raw)
|
|
158
|
+
except ValidationError as err:
|
|
159
|
+
raise ToolError(
|
|
160
|
+
f"Validation error in tool {self.get_name()}: {err}"
|
|
161
|
+
) from err
|
|
162
|
+
|
|
163
|
+
async for item in self.run(args, ctx):
|
|
164
|
+
yield item
|
|
165
|
+
|
|
166
|
+
@classmethod
|
|
167
|
+
def from_config(
|
|
168
|
+
cls, config: ToolConfig
|
|
169
|
+
) -> BaseTool[ToolArgs, ToolResult, ToolConfig, ToolState]:
|
|
170
|
+
state_class = cls._get_tool_state_class()
|
|
171
|
+
initial_state = state_class()
|
|
172
|
+
return cls(config=config, state=initial_state)
|
|
173
|
+
|
|
174
|
+
@classmethod
|
|
175
|
+
def _get_tool_config_class(cls) -> type[ToolConfig]:
|
|
176
|
+
for base in getattr(cls, "__orig_bases__", ()):
|
|
177
|
+
if getattr(base, "__origin__", None) is BaseTool:
|
|
178
|
+
type_args = get_args(base)
|
|
179
|
+
if len(type_args) == ARGS_COUNT:
|
|
180
|
+
config_model = type_args[2]
|
|
181
|
+
if issubclass(config_model, BaseToolConfig):
|
|
182
|
+
return cast(type[ToolConfig], config_model)
|
|
183
|
+
|
|
184
|
+
for base_class in cls.__bases__:
|
|
185
|
+
if base_class is object or base_class is ABC:
|
|
186
|
+
continue
|
|
187
|
+
try:
|
|
188
|
+
return base_class._get_tool_config_class()
|
|
189
|
+
except (TypeError, AttributeError):
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
raise TypeError(
|
|
193
|
+
f"Could not determine ToolConfig for {cls.__name__}. "
|
|
194
|
+
"Ensure it inherits from BaseTool with concrete type arguments."
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
@classmethod
|
|
198
|
+
def _get_tool_state_class(cls) -> type[ToolState]:
|
|
199
|
+
for base in getattr(cls, "__orig_bases__", ()):
|
|
200
|
+
if getattr(base, "__origin__", None) is BaseTool:
|
|
201
|
+
type_args = get_args(base)
|
|
202
|
+
if len(type_args) == ARGS_COUNT:
|
|
203
|
+
state_model = type_args[3]
|
|
204
|
+
if issubclass(state_model, BaseToolState):
|
|
205
|
+
return cast(type[ToolState], state_model)
|
|
206
|
+
|
|
207
|
+
for base_class in cls.__bases__:
|
|
208
|
+
if base_class is object or base_class is ABC:
|
|
209
|
+
continue
|
|
210
|
+
try:
|
|
211
|
+
return base_class._get_tool_state_class()
|
|
212
|
+
except (TypeError, AttributeError):
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
raise TypeError(
|
|
216
|
+
f"Could not determine ToolState for {cls.__name__}. "
|
|
217
|
+
"Ensure it inherits from BaseTool with concrete type arguments."
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
@classmethod
|
|
221
|
+
def _get_tool_args_results(cls) -> tuple[type[ToolArgs], type[ToolResult]]:
|
|
222
|
+
"""Extract <ToolArgs, ToolResult> from the annotated signature of `run`.
|
|
223
|
+
Works even when `from __future__ import annotations` is in effect.
|
|
224
|
+
"""
|
|
225
|
+
run_fn = cls.run.__func__ if isinstance(cls.run, classmethod) else cls.run
|
|
226
|
+
|
|
227
|
+
type_hints = get_type_hints(
|
|
228
|
+
run_fn,
|
|
229
|
+
globalns=vars(sys.modules[cls.__module__]),
|
|
230
|
+
localns={
|
|
231
|
+
cls.__name__: cls,
|
|
232
|
+
"InvokeContext": InvokeContext,
|
|
233
|
+
"AsyncGenerator": AsyncGenerator,
|
|
234
|
+
"ToolStreamEvent": ToolStreamEvent,
|
|
235
|
+
},
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
args_model = type_hints["args"]
|
|
240
|
+
return_annotation = type_hints["return"]
|
|
241
|
+
except KeyError as e:
|
|
242
|
+
raise TypeError(
|
|
243
|
+
f"{cls.__name__}.run must be annotated with args and return type"
|
|
244
|
+
) from e
|
|
245
|
+
|
|
246
|
+
result_model = cls._extract_result_type(return_annotation)
|
|
247
|
+
|
|
248
|
+
if not issubclass(args_model, BaseModel):
|
|
249
|
+
raise TypeError(
|
|
250
|
+
f"{cls.__name__}.run args annotation must be a Pydantic model; "
|
|
251
|
+
f"got {args_model!r}"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
if not issubclass(result_model, BaseModel):
|
|
255
|
+
raise TypeError(
|
|
256
|
+
f"{cls.__name__}.run must yield a Pydantic model as result; "
|
|
257
|
+
f"got {result_model!r}"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
return cast(type[ToolArgs], args_model), cast(type[ToolResult], result_model)
|
|
261
|
+
|
|
262
|
+
@classmethod
|
|
263
|
+
def _extract_result_type(cls, return_annotation: Any) -> type:
|
|
264
|
+
"""Extract the ToolResult type from AsyncGenerator[ToolStreamEvent | ToolResult, None]."""
|
|
265
|
+
origin = get_origin(return_annotation)
|
|
266
|
+
if origin is not AsyncGenerator:
|
|
267
|
+
if isinstance(return_annotation, type):
|
|
268
|
+
return return_annotation
|
|
269
|
+
raise TypeError(f"Could not extract result type from {return_annotation!r}")
|
|
270
|
+
|
|
271
|
+
gen_args = get_args(return_annotation)
|
|
272
|
+
if not gen_args:
|
|
273
|
+
raise TypeError(f"Could not extract result type from {return_annotation!r}")
|
|
274
|
+
|
|
275
|
+
yield_type = gen_args[0]
|
|
276
|
+
yield_origin = get_origin(yield_type)
|
|
277
|
+
|
|
278
|
+
# Handle Union types (X | Y or Union[X, Y])
|
|
279
|
+
if yield_origin is Union or isinstance(yield_type, types.UnionType):
|
|
280
|
+
for arg in get_args(yield_type):
|
|
281
|
+
if arg is not ToolStreamEvent and isinstance(arg, type):
|
|
282
|
+
return arg
|
|
283
|
+
|
|
284
|
+
# Handle single type
|
|
285
|
+
if isinstance(yield_type, type):
|
|
286
|
+
return yield_type
|
|
287
|
+
|
|
288
|
+
raise TypeError(f"Could not extract result type from {return_annotation!r}")
|
|
289
|
+
|
|
290
|
+
@classmethod
|
|
291
|
+
def get_parameters(cls) -> dict[str, Any]:
|
|
292
|
+
"""Return a cleaned-up JSON-schema dict describing the arguments model
|
|
293
|
+
with which this concrete tool was parametrised.
|
|
294
|
+
"""
|
|
295
|
+
args_model, _ = cls._get_tool_args_results()
|
|
296
|
+
schema = args_model.model_json_schema()
|
|
297
|
+
schema.pop("title", None)
|
|
298
|
+
schema.pop("description", None)
|
|
299
|
+
|
|
300
|
+
if "properties" in schema:
|
|
301
|
+
for prop_details in schema["properties"].values():
|
|
302
|
+
prop_details.pop("title", None)
|
|
303
|
+
|
|
304
|
+
if "$defs" in schema:
|
|
305
|
+
for def_details in schema["$defs"].values():
|
|
306
|
+
def_details.pop("title", None)
|
|
307
|
+
if "properties" in def_details:
|
|
308
|
+
for prop_details in def_details["properties"].values():
|
|
309
|
+
prop_details.pop("title", None)
|
|
310
|
+
|
|
311
|
+
return schema
|
|
312
|
+
|
|
313
|
+
@classmethod
|
|
314
|
+
def get_name(cls) -> str:
|
|
315
|
+
name = cls.__name__
|
|
316
|
+
snake_case = re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
|
|
317
|
+
return snake_case
|
|
318
|
+
|
|
319
|
+
@classmethod
|
|
320
|
+
def create_config_with_permission(
|
|
321
|
+
cls, permission: ToolPermission
|
|
322
|
+
) -> BaseToolConfig:
|
|
323
|
+
config_class = cls._get_tool_config_class()
|
|
324
|
+
return config_class(permission=permission)
|
|
325
|
+
|
|
326
|
+
def check_allowlist_denylist(self, args: ToolArgs) -> ToolPermission | None:
|
|
327
|
+
"""Check if args match allowlist/denylist patterns.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
ToolPermission.ALWAYS if allowlisted
|
|
331
|
+
ToolPermission.NEVER if denylisted
|
|
332
|
+
None if no match (proceed with normal permission check)
|
|
333
|
+
|
|
334
|
+
Base implementation returns None. Override in subclasses for specific logic.
|
|
335
|
+
"""
|
|
336
|
+
return None
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncGenerator
|
|
4
|
+
from typing import ClassVar, cast
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from vibe.core.tools.base import (
|
|
9
|
+
BaseTool,
|
|
10
|
+
BaseToolConfig,
|
|
11
|
+
BaseToolState,
|
|
12
|
+
InvokeContext,
|
|
13
|
+
ToolError,
|
|
14
|
+
ToolPermission,
|
|
15
|
+
)
|
|
16
|
+
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
|
17
|
+
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Choice(BaseModel):
|
|
21
|
+
label: str = Field(description="Short label for the choice (1-5 words)")
|
|
22
|
+
description: str = Field(
|
|
23
|
+
default="", description="Optional explanation of this choice"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Question(BaseModel):
|
|
28
|
+
question: str = Field(description="The question text")
|
|
29
|
+
header: str = Field(
|
|
30
|
+
default="",
|
|
31
|
+
description="Short header for the question (1-2 words, e.g. 'Auth', 'Database')",
|
|
32
|
+
max_length=12,
|
|
33
|
+
)
|
|
34
|
+
options: list[Choice] = Field(
|
|
35
|
+
description="Available options (2-4, not including 'Other'). An 'Other' option for free text is automatically added.",
|
|
36
|
+
min_length=2,
|
|
37
|
+
max_length=4,
|
|
38
|
+
)
|
|
39
|
+
multi_select: bool = Field(
|
|
40
|
+
default=False, description="If true, user can select multiple options"
|
|
41
|
+
)
|
|
42
|
+
hide_other: bool = Field(
|
|
43
|
+
default=False, description="If true, hide the 'Other' free text option"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AskUserQuestionArgs(BaseModel):
|
|
48
|
+
questions: list[Question] = Field(
|
|
49
|
+
description="Questions to ask (1-4). Displayed as tabs if multiple.",
|
|
50
|
+
min_length=1,
|
|
51
|
+
max_length=4,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Answer(BaseModel):
|
|
56
|
+
question: str = Field(description="The original question")
|
|
57
|
+
answer: str = Field(description="The user's answer")
|
|
58
|
+
is_other: bool = Field(
|
|
59
|
+
default=False, description="True if user typed a custom answer via 'Other'"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AskUserQuestionResult(BaseModel):
|
|
64
|
+
answers: list[Answer] = Field(description="List of answers")
|
|
65
|
+
cancelled: bool = Field(
|
|
66
|
+
default=False, description="True if user cancelled without answering"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AskUserQuestionConfig(BaseToolConfig):
|
|
71
|
+
permission: ToolPermission = ToolPermission.ALWAYS
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class AskUserQuestion(
|
|
75
|
+
BaseTool[
|
|
76
|
+
AskUserQuestionArgs, AskUserQuestionResult, AskUserQuestionConfig, BaseToolState
|
|
77
|
+
],
|
|
78
|
+
ToolUIData[AskUserQuestionArgs, AskUserQuestionResult],
|
|
79
|
+
):
|
|
80
|
+
description: ClassVar[str] = (
|
|
81
|
+
"Ask the user one or more questions and wait for their responses. "
|
|
82
|
+
"Each question has 2-4 choices plus an automatic 'Other' option for free text. "
|
|
83
|
+
"Use this to gather preferences, clarify requirements, or get decisions."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
|
|
88
|
+
if not isinstance(event.args, AskUserQuestionArgs):
|
|
89
|
+
return ToolCallDisplay(summary="Asking user")
|
|
90
|
+
|
|
91
|
+
args = event.args
|
|
92
|
+
count = len(args.questions)
|
|
93
|
+
|
|
94
|
+
if count == 1:
|
|
95
|
+
return ToolCallDisplay(summary=f"Asking: {args.questions[0].question}")
|
|
96
|
+
|
|
97
|
+
return ToolCallDisplay(summary=f"Asking {count} questions")
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
|
101
|
+
if event.error:
|
|
102
|
+
return ToolResultDisplay(success=False, message=event.error)
|
|
103
|
+
|
|
104
|
+
if not isinstance(event.result, AskUserQuestionResult):
|
|
105
|
+
return ToolResultDisplay(success=True, message="Questions answered")
|
|
106
|
+
|
|
107
|
+
result = event.result
|
|
108
|
+
|
|
109
|
+
if result.cancelled:
|
|
110
|
+
return ToolResultDisplay(success=False, message="User cancelled")
|
|
111
|
+
|
|
112
|
+
if len(result.answers) == 1:
|
|
113
|
+
answer = result.answers[0]
|
|
114
|
+
prefix = "(Other) " if answer.is_other else ""
|
|
115
|
+
return ToolResultDisplay(success=True, message=f"{prefix}{answer.answer}")
|
|
116
|
+
|
|
117
|
+
return ToolResultDisplay(
|
|
118
|
+
success=True, message=f"{len(result.answers)} answers received"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def get_status_text(cls) -> str:
|
|
123
|
+
return "Waiting for user input"
|
|
124
|
+
|
|
125
|
+
async def run(
|
|
126
|
+
self, args: AskUserQuestionArgs, ctx: InvokeContext | None = None
|
|
127
|
+
) -> AsyncGenerator[AskUserQuestionResult, None]:
|
|
128
|
+
if ctx is None or ctx.user_input_callback is None:
|
|
129
|
+
raise ToolError(
|
|
130
|
+
"User input not available. This tool requires an interactive UI."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
result = await ctx.user_input_callback(args)
|
|
134
|
+
yield cast(AskUserQuestionResult, result)
|