vibecore 0.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.
Potentially problematic release.
This version of vibecore might be problematic. Click here for more details.
- vibecore/__init__.py +0 -0
- vibecore/agents/default.py +79 -0
- vibecore/agents/prompts.py +12 -0
- vibecore/agents/task_agent.py +66 -0
- vibecore/cli.py +150 -0
- vibecore/context.py +24 -0
- vibecore/handlers/__init__.py +5 -0
- vibecore/handlers/stream_handler.py +231 -0
- vibecore/main.py +506 -0
- vibecore/main.tcss +0 -0
- vibecore/mcp/__init__.py +6 -0
- vibecore/mcp/manager.py +167 -0
- vibecore/mcp/server_wrapper.py +109 -0
- vibecore/models/__init__.py +5 -0
- vibecore/models/anthropic.py +239 -0
- vibecore/prompts/common_system_prompt.txt +64 -0
- vibecore/py.typed +0 -0
- vibecore/session/__init__.py +5 -0
- vibecore/session/file_lock.py +127 -0
- vibecore/session/jsonl_session.py +236 -0
- vibecore/session/loader.py +193 -0
- vibecore/session/path_utils.py +81 -0
- vibecore/settings.py +161 -0
- vibecore/tools/__init__.py +1 -0
- vibecore/tools/base.py +27 -0
- vibecore/tools/file/__init__.py +5 -0
- vibecore/tools/file/executor.py +282 -0
- vibecore/tools/file/tools.py +184 -0
- vibecore/tools/file/utils.py +78 -0
- vibecore/tools/python/__init__.py +1 -0
- vibecore/tools/python/backends/__init__.py +1 -0
- vibecore/tools/python/backends/terminal_backend.py +58 -0
- vibecore/tools/python/helpers.py +80 -0
- vibecore/tools/python/manager.py +208 -0
- vibecore/tools/python/tools.py +27 -0
- vibecore/tools/shell/__init__.py +5 -0
- vibecore/tools/shell/executor.py +223 -0
- vibecore/tools/shell/tools.py +156 -0
- vibecore/tools/task/__init__.py +5 -0
- vibecore/tools/task/executor.py +51 -0
- vibecore/tools/task/tools.py +51 -0
- vibecore/tools/todo/__init__.py +1 -0
- vibecore/tools/todo/manager.py +31 -0
- vibecore/tools/todo/models.py +36 -0
- vibecore/tools/todo/tools.py +111 -0
- vibecore/utils/__init__.py +5 -0
- vibecore/utils/text.py +28 -0
- vibecore/widgets/core.py +332 -0
- vibecore/widgets/core.tcss +63 -0
- vibecore/widgets/expandable.py +121 -0
- vibecore/widgets/expandable.tcss +69 -0
- vibecore/widgets/info.py +25 -0
- vibecore/widgets/info.tcss +17 -0
- vibecore/widgets/messages.py +232 -0
- vibecore/widgets/messages.tcss +85 -0
- vibecore/widgets/tool_message_factory.py +121 -0
- vibecore/widgets/tool_messages.py +483 -0
- vibecore/widgets/tool_messages.tcss +289 -0
- vibecore-0.2.0.dist-info/METADATA +407 -0
- vibecore-0.2.0.dist-info/RECORD +63 -0
- vibecore-0.2.0.dist-info/WHEEL +4 -0
- vibecore-0.2.0.dist-info/entry_points.txt +2 -0
- vibecore-0.2.0.dist-info/licenses/LICENSE +21 -0
vibecore/main.py
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import traceback
|
|
3
|
+
from collections import deque
|
|
4
|
+
from typing import ClassVar, Literal
|
|
5
|
+
|
|
6
|
+
from agents import (
|
|
7
|
+
Agent,
|
|
8
|
+
ModelSettings,
|
|
9
|
+
Runner,
|
|
10
|
+
RunResultStreaming,
|
|
11
|
+
StreamEvent,
|
|
12
|
+
TResponseInputItem,
|
|
13
|
+
)
|
|
14
|
+
from openai.types import Reasoning
|
|
15
|
+
from openai.types.responses.response_output_message import Content
|
|
16
|
+
from textual import log, work
|
|
17
|
+
from textual.app import App, ComposeResult
|
|
18
|
+
from textual.binding import Binding
|
|
19
|
+
from textual.reactive import reactive
|
|
20
|
+
from textual.widgets import Header
|
|
21
|
+
from textual.worker import Worker
|
|
22
|
+
|
|
23
|
+
from vibecore.context import VibecoreContext
|
|
24
|
+
from vibecore.handlers import AgentStreamHandler
|
|
25
|
+
from vibecore.session import JSONLSession
|
|
26
|
+
from vibecore.session.loader import SessionLoader
|
|
27
|
+
from vibecore.settings import settings
|
|
28
|
+
from vibecore.utils.text import TextExtractor
|
|
29
|
+
from vibecore.widgets.core import AppFooter, MainScroll, MyTextArea
|
|
30
|
+
from vibecore.widgets.info import Welcome
|
|
31
|
+
from vibecore.widgets.messages import AgentMessage, BaseMessage, MessageStatus, SystemMessage, UserMessage
|
|
32
|
+
|
|
33
|
+
AgentStatus = Literal["idle", "running"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def detect_reasoning_effort(prompt: str) -> Literal["low", "medium", "high"] | None:
|
|
37
|
+
"""Detect reasoning effort level from user prompt keywords.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
prompt: User input text
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Reasoning effort level or None if no keywords detected
|
|
44
|
+
"""
|
|
45
|
+
prompt_lower = prompt.lower()
|
|
46
|
+
|
|
47
|
+
# Check for highest priority keywords first
|
|
48
|
+
if "ultrathink" in prompt_lower:
|
|
49
|
+
return "high"
|
|
50
|
+
elif "think hard" in prompt_lower:
|
|
51
|
+
return "medium"
|
|
52
|
+
elif "think" in prompt_lower:
|
|
53
|
+
return "low"
|
|
54
|
+
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class VibecoreApp(App):
|
|
59
|
+
"""A Textual app to manage stopwatches."""
|
|
60
|
+
|
|
61
|
+
CSS_PATH: ClassVar = [
|
|
62
|
+
"widgets/core.tcss",
|
|
63
|
+
"widgets/messages.tcss",
|
|
64
|
+
"widgets/tool_messages.tcss",
|
|
65
|
+
"widgets/expandable.tcss",
|
|
66
|
+
"widgets/info.tcss",
|
|
67
|
+
"main.tcss",
|
|
68
|
+
]
|
|
69
|
+
BINDINGS: ClassVar = [
|
|
70
|
+
("ctrl+shift+d", "toggle_dark", "Toggle dark mode"),
|
|
71
|
+
Binding("escape", "cancel_agent", "Cancel agent", show=False),
|
|
72
|
+
Binding("ctrl+d", "exit_confirm", "Exit", show=False),
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
agent_status = reactive[AgentStatus]("idle")
|
|
76
|
+
_exit_confirmation_active = False
|
|
77
|
+
_exit_confirmation_task: asyncio.Task | None = None
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
context: VibecoreContext,
|
|
82
|
+
agent: Agent,
|
|
83
|
+
session_id: str | None = None,
|
|
84
|
+
print_mode: bool = False,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Initialize the Vibecore app with context and agent.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
context: The VibecoreContext instance
|
|
90
|
+
agent: The Agent instance to use
|
|
91
|
+
session_id: Optional session ID to load existing session
|
|
92
|
+
print_mode: Whether to run in print mode (useful for pipes)
|
|
93
|
+
"""
|
|
94
|
+
self.context = context
|
|
95
|
+
self.context.app = self # Set the app reference in context
|
|
96
|
+
self.agent = agent
|
|
97
|
+
self.input_items: list[TResponseInputItem] = []
|
|
98
|
+
self.current_result: RunResultStreaming | None = None
|
|
99
|
+
self.current_worker: Worker[None] | None = None
|
|
100
|
+
self._session_id_provided = session_id is not None # Track if continuing session
|
|
101
|
+
self.print_mode = print_mode
|
|
102
|
+
self.message_queue: deque[str] = deque() # Queue for user messages
|
|
103
|
+
|
|
104
|
+
# Initialize session based on settings
|
|
105
|
+
if settings.session.storage_type == "jsonl":
|
|
106
|
+
if session_id is None:
|
|
107
|
+
# Generate a new session ID based on current date/time
|
|
108
|
+
import datetime
|
|
109
|
+
|
|
110
|
+
session_id = f"chat-{datetime.datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
|
111
|
+
|
|
112
|
+
self.session = JSONLSession(
|
|
113
|
+
session_id=session_id,
|
|
114
|
+
project_path=None, # Will use current working directory
|
|
115
|
+
base_dir=settings.session.base_dir,
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
raise NotImplementedError("SQLite session support will be added later")
|
|
119
|
+
|
|
120
|
+
super().__init__()
|
|
121
|
+
|
|
122
|
+
def compose(self) -> ComposeResult:
|
|
123
|
+
"""Create child widgets for the app."""
|
|
124
|
+
yield Header()
|
|
125
|
+
yield AppFooter()
|
|
126
|
+
with MainScroll(id="messages"):
|
|
127
|
+
yield Welcome()
|
|
128
|
+
|
|
129
|
+
async def on_mount(self) -> None:
|
|
130
|
+
"""Called when the app is mounted."""
|
|
131
|
+
# Connect to MCP servers if configured
|
|
132
|
+
if self.context.mcp_manager:
|
|
133
|
+
try:
|
|
134
|
+
await self.context.mcp_manager.connect()
|
|
135
|
+
log(f"Connected to {len(self.context.mcp_manager.servers)} MCP servers")
|
|
136
|
+
except Exception as e:
|
|
137
|
+
log(f"Failed to connect to MCP servers: {e}")
|
|
138
|
+
# Continue without MCP servers rather than crashing
|
|
139
|
+
|
|
140
|
+
# Load session history if we're continuing from a previous session
|
|
141
|
+
if self._session_id_provided:
|
|
142
|
+
await self.load_session_history()
|
|
143
|
+
|
|
144
|
+
async def on_unmount(self) -> None:
|
|
145
|
+
"""Called when the app is being unmounted (shutdown)."""
|
|
146
|
+
# Cleanup MCP servers during unmount
|
|
147
|
+
if self.context.mcp_manager:
|
|
148
|
+
try:
|
|
149
|
+
log("Disconnecting from MCP servers...")
|
|
150
|
+
await self.context.mcp_manager.disconnect()
|
|
151
|
+
log("Disconnected from MCP servers")
|
|
152
|
+
except Exception as e:
|
|
153
|
+
log(f"Error disconnecting from MCP servers during unmount: {e}")
|
|
154
|
+
|
|
155
|
+
def extract_text_from_content(self, content: list[Content]) -> str:
|
|
156
|
+
"""Extract text from various content formats."""
|
|
157
|
+
return TextExtractor.extract_from_content(content)
|
|
158
|
+
|
|
159
|
+
async def add_message(self, message: BaseMessage) -> None:
|
|
160
|
+
"""Add a message widget to the main scroll area.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
message: The message to add
|
|
164
|
+
"""
|
|
165
|
+
main_scroll = self.query_one("#messages", MainScroll)
|
|
166
|
+
await main_scroll.mount(message)
|
|
167
|
+
|
|
168
|
+
async def handle_agent_message(self, message: BaseMessage) -> None:
|
|
169
|
+
"""Add a message widget to the main scroll area."""
|
|
170
|
+
await self.add_message(message)
|
|
171
|
+
|
|
172
|
+
async def handle_agent_update(self, new_agent: Agent) -> None:
|
|
173
|
+
"""Handle agent updates."""
|
|
174
|
+
log(f"Agent updated: {new_agent.name}")
|
|
175
|
+
self.agent = new_agent
|
|
176
|
+
|
|
177
|
+
async def handle_agent_error(self, error: Exception) -> None:
|
|
178
|
+
"""Handle errors during streaming."""
|
|
179
|
+
log(f"Error during agent response: {type(error).__name__}: {error!s}")
|
|
180
|
+
|
|
181
|
+
# Create an error message for the user
|
|
182
|
+
error_msg = f"❌ Error: {type(error).__name__}"
|
|
183
|
+
if str(error):
|
|
184
|
+
error_msg += f"\n\n{error!s}"
|
|
185
|
+
|
|
186
|
+
error_msg += f"\n\n```\n{traceback.format_exc()}\n```"
|
|
187
|
+
|
|
188
|
+
# Display the error to the user
|
|
189
|
+
# TODO(serialx): Use a dedicated error message widget
|
|
190
|
+
error_agent_msg = AgentMessage(error_msg, status=MessageStatus.ERROR)
|
|
191
|
+
await self.add_message(error_agent_msg)
|
|
192
|
+
|
|
193
|
+
async def handle_agent_finished(self) -> None:
|
|
194
|
+
"""Handle when the agent has finished processing."""
|
|
195
|
+
# Remove the last agent message if it is still executing (which means the agent run was cancelled)
|
|
196
|
+
main_scroll = self.query_one("#messages", MainScroll)
|
|
197
|
+
try:
|
|
198
|
+
last_message = main_scroll.query_one("BaseMessage:last-child", BaseMessage)
|
|
199
|
+
if last_message.status == MessageStatus.EXECUTING:
|
|
200
|
+
last_message.remove()
|
|
201
|
+
except Exception:
|
|
202
|
+
# No messages to clean up
|
|
203
|
+
pass
|
|
204
|
+
|
|
205
|
+
async def load_session_history(self) -> None:
|
|
206
|
+
"""Load and display messages from session history."""
|
|
207
|
+
loader = SessionLoader(self.session)
|
|
208
|
+
messages = await loader.load_history()
|
|
209
|
+
|
|
210
|
+
# Remove Welcome widget if we have messages
|
|
211
|
+
if messages:
|
|
212
|
+
welcome = self.query_one("#messages").query("Welcome")
|
|
213
|
+
if welcome:
|
|
214
|
+
welcome.first().remove()
|
|
215
|
+
|
|
216
|
+
# Add all messages to the UI
|
|
217
|
+
for message in messages:
|
|
218
|
+
await self.add_message(message)
|
|
219
|
+
|
|
220
|
+
def watch_agent_status(self, _old_status: AgentStatus, new_status: AgentStatus) -> None:
|
|
221
|
+
"""React to agent_status changes."""
|
|
222
|
+
footer = self.query_one(AppFooter)
|
|
223
|
+
if new_status == "running":
|
|
224
|
+
footer.show_loading()
|
|
225
|
+
else:
|
|
226
|
+
footer.hide_loading()
|
|
227
|
+
|
|
228
|
+
async def on_my_text_area_user_message(self, event: MyTextArea.UserMessage) -> None:
|
|
229
|
+
"""Handle user messages from the text area."""
|
|
230
|
+
if event.text:
|
|
231
|
+
# Check for special commands
|
|
232
|
+
text_strip = event.text.strip()
|
|
233
|
+
if text_strip == "/clear":
|
|
234
|
+
await self.handle_clear_command()
|
|
235
|
+
return
|
|
236
|
+
elif text_strip == "/help":
|
|
237
|
+
help_text = "Available commands:\n"
|
|
238
|
+
help_text += "• /clear - Clear the current session and start a new one\n"
|
|
239
|
+
help_text += "• /help - Show this help message\n\n"
|
|
240
|
+
help_text += "Keyboard shortcuts:\n"
|
|
241
|
+
help_text += "• Esc - Cancel current agent operation\n"
|
|
242
|
+
help_text += "• Ctrl+Shift+D - Toggle dark/light mode\n"
|
|
243
|
+
help_text += "• Up/Down arrows - Navigate message history\n"
|
|
244
|
+
await self.add_message(SystemMessage(help_text))
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
user_message = UserMessage(event.text)
|
|
248
|
+
await self.add_message(user_message)
|
|
249
|
+
user_message.scroll_visible()
|
|
250
|
+
|
|
251
|
+
# If agent is running, queue the message
|
|
252
|
+
if self.agent_status == "running":
|
|
253
|
+
self.message_queue.append(event.text)
|
|
254
|
+
log(f"Message queued: {event.text}")
|
|
255
|
+
footer = self.query_one(AppFooter)
|
|
256
|
+
# Update the loading message to show queued messages
|
|
257
|
+
queued_count = len(self.message_queue)
|
|
258
|
+
footer.show_loading(
|
|
259
|
+
status="Generating…", metadata=f"{queued_count} message{'s' if queued_count > 1 else ''} queued"
|
|
260
|
+
)
|
|
261
|
+
else:
|
|
262
|
+
# Detect reasoning effort from prompt keywords
|
|
263
|
+
detected_effort = detect_reasoning_effort(event.text)
|
|
264
|
+
reasoning_effort = detected_effort or settings.reasoning_effort
|
|
265
|
+
|
|
266
|
+
# Create agent with appropriate reasoning effort
|
|
267
|
+
agent_to_use = self.agent
|
|
268
|
+
if reasoning_effort is not None:
|
|
269
|
+
# Create a copy of the agent with updated model settings
|
|
270
|
+
current_settings = self.agent.model_settings or ModelSettings()
|
|
271
|
+
new_reasoning = Reasoning(effort=reasoning_effort, summary="auto")
|
|
272
|
+
updated_settings = ModelSettings(
|
|
273
|
+
include_usage=current_settings.include_usage,
|
|
274
|
+
reasoning=new_reasoning,
|
|
275
|
+
)
|
|
276
|
+
agent_to_use = Agent[VibecoreContext](
|
|
277
|
+
name=self.agent.name,
|
|
278
|
+
handoff_description=self.agent.handoff_description,
|
|
279
|
+
instructions=self.agent.instructions,
|
|
280
|
+
tools=self.agent.tools,
|
|
281
|
+
model=self.agent.model,
|
|
282
|
+
model_settings=updated_settings,
|
|
283
|
+
handoffs=self.agent.handoffs,
|
|
284
|
+
mcp_servers=self.agent.mcp_servers,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# Process the message immediately
|
|
288
|
+
result = Runner.run_streamed(
|
|
289
|
+
agent_to_use,
|
|
290
|
+
input=event.text, # Pass string directly when using session
|
|
291
|
+
context=self.context,
|
|
292
|
+
max_turns=settings.max_turns,
|
|
293
|
+
session=self.session,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
self.current_worker = self.handle_streamed_response(result)
|
|
297
|
+
|
|
298
|
+
@work(exclusive=True)
|
|
299
|
+
async def handle_streamed_response(self, result: RunResultStreaming) -> None:
|
|
300
|
+
self.agent_status = "running"
|
|
301
|
+
self.current_result = result
|
|
302
|
+
|
|
303
|
+
self.agent_stream_handler = AgentStreamHandler(self)
|
|
304
|
+
await self.agent_stream_handler.process_stream(result)
|
|
305
|
+
|
|
306
|
+
used = result.context_wrapper.usage.total_tokens
|
|
307
|
+
max_ctx = self._get_model_context_window()
|
|
308
|
+
log(f"Context usage: {used} / {max_ctx} total tokens")
|
|
309
|
+
self.context.context_fullness = min(1.0, float(used) / float(max_ctx))
|
|
310
|
+
footer = self.query_one(AppFooter)
|
|
311
|
+
footer.set_context_progress(self.context.context_fullness)
|
|
312
|
+
|
|
313
|
+
self.agent_status = "idle"
|
|
314
|
+
self.current_result = None
|
|
315
|
+
self.current_worker = None
|
|
316
|
+
|
|
317
|
+
await self.process_message_queue()
|
|
318
|
+
|
|
319
|
+
async def process_message_queue(self) -> None:
|
|
320
|
+
"""Process any messages that were queued while the agent was running."""
|
|
321
|
+
if self.message_queue:
|
|
322
|
+
# Get the next message from the queue
|
|
323
|
+
next_message = self.message_queue.popleft()
|
|
324
|
+
log(f"Processing queued message: {next_message}")
|
|
325
|
+
|
|
326
|
+
# Process the message
|
|
327
|
+
result = Runner.run_streamed(
|
|
328
|
+
self.agent,
|
|
329
|
+
input=next_message,
|
|
330
|
+
context=self.context,
|
|
331
|
+
max_turns=settings.max_turns,
|
|
332
|
+
session=self.session,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
self.current_worker = self.handle_streamed_response(result)
|
|
336
|
+
|
|
337
|
+
def on_click(self) -> None:
|
|
338
|
+
self.query_one("#input-textarea").focus()
|
|
339
|
+
|
|
340
|
+
def _get_model_context_window(self) -> int:
|
|
341
|
+
from vibecore.settings import settings
|
|
342
|
+
|
|
343
|
+
model_name = settings.default_model
|
|
344
|
+
log(f"Getting context window for model: {model_name}")
|
|
345
|
+
return 200000
|
|
346
|
+
|
|
347
|
+
def action_toggle_dark(self) -> None:
|
|
348
|
+
"""An action to toggle dark mode."""
|
|
349
|
+
self.theme = "textual-dark" if self.theme == "textual-light" else "textual-light"
|
|
350
|
+
|
|
351
|
+
def action_cancel_agent(self) -> None:
|
|
352
|
+
"""Cancel the current agent run."""
|
|
353
|
+
if self.agent_status == "running":
|
|
354
|
+
log("Cancelling agent run")
|
|
355
|
+
if self.current_result:
|
|
356
|
+
self.current_result.cancel()
|
|
357
|
+
if self.current_worker:
|
|
358
|
+
self.current_worker.cancel()
|
|
359
|
+
|
|
360
|
+
async def action_exit_confirm(self) -> None:
|
|
361
|
+
"""Handle Ctrl-D press for exit confirmation."""
|
|
362
|
+
if self._exit_confirmation_active:
|
|
363
|
+
# Second Ctrl-D within the timeframe - exit the app
|
|
364
|
+
self.exit()
|
|
365
|
+
else:
|
|
366
|
+
# First Ctrl-D - show confirmation message
|
|
367
|
+
self._exit_confirmation_active = True
|
|
368
|
+
|
|
369
|
+
# Cancel any existing confirmation task
|
|
370
|
+
if self._exit_confirmation_task and not self._exit_confirmation_task.done():
|
|
371
|
+
self._exit_confirmation_task.cancel()
|
|
372
|
+
|
|
373
|
+
# Show confirmation message
|
|
374
|
+
confirmation_msg = SystemMessage("Press Ctrl-D again to exit")
|
|
375
|
+
await self.add_message(confirmation_msg)
|
|
376
|
+
|
|
377
|
+
# Start the 1-second timer
|
|
378
|
+
self._exit_confirmation_task = asyncio.create_task(self._reset_exit_confirmation(confirmation_msg))
|
|
379
|
+
|
|
380
|
+
async def _reset_exit_confirmation(self, confirmation_msg: SystemMessage) -> None:
|
|
381
|
+
"""Reset exit confirmation after 1 second and remove the message."""
|
|
382
|
+
try:
|
|
383
|
+
# Wait for 1 second
|
|
384
|
+
await asyncio.sleep(1.0)
|
|
385
|
+
|
|
386
|
+
# Reset confirmation state
|
|
387
|
+
self._exit_confirmation_active = False
|
|
388
|
+
|
|
389
|
+
# Remove the confirmation message
|
|
390
|
+
confirmation_msg.remove()
|
|
391
|
+
except asyncio.CancelledError:
|
|
392
|
+
# Task was cancelled (new Ctrl-D pressed)
|
|
393
|
+
pass
|
|
394
|
+
|
|
395
|
+
async def run_print(self, prompt: str | None = None) -> str:
|
|
396
|
+
"""Run the agent and return the raw output for printing.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
prompt: Optional prompt text. If not provided, reads from stdin.
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
The agent's text output as a string
|
|
403
|
+
"""
|
|
404
|
+
import sys
|
|
405
|
+
|
|
406
|
+
# Use provided prompt or read from stdin
|
|
407
|
+
input_text = prompt.strip() if prompt else sys.stdin.read().strip()
|
|
408
|
+
|
|
409
|
+
if not input_text:
|
|
410
|
+
return ""
|
|
411
|
+
|
|
412
|
+
# Import needed event types
|
|
413
|
+
from agents import RawResponsesStreamEvent
|
|
414
|
+
from openai.types.responses import ResponseTextDeltaEvent
|
|
415
|
+
|
|
416
|
+
if self.context.mcp_manager:
|
|
417
|
+
await self.context.mcp_manager.connect()
|
|
418
|
+
|
|
419
|
+
# Run the agent
|
|
420
|
+
result = Runner.run_streamed(
|
|
421
|
+
self.agent,
|
|
422
|
+
input=input_text,
|
|
423
|
+
context=self.context,
|
|
424
|
+
max_turns=settings.max_turns,
|
|
425
|
+
session=self.session,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
# Collect all agent text output
|
|
429
|
+
agent_output = ""
|
|
430
|
+
|
|
431
|
+
async for event in result.stream_events():
|
|
432
|
+
# Handle text output from agent
|
|
433
|
+
match event:
|
|
434
|
+
case RawResponsesStreamEvent(data=data):
|
|
435
|
+
match data:
|
|
436
|
+
case ResponseTextDeltaEvent(delta=delta) if delta:
|
|
437
|
+
agent_output += delta
|
|
438
|
+
|
|
439
|
+
if self.context.mcp_manager:
|
|
440
|
+
await self.context.mcp_manager.disconnect()
|
|
441
|
+
|
|
442
|
+
return agent_output.strip()
|
|
443
|
+
|
|
444
|
+
async def handle_task_tool_event(self, tool_name: str, tool_call_id: str, event: StreamEvent) -> None:
|
|
445
|
+
"""Handle streaming events from task tool sub-agents.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
tool_name: Name of the tool (e.g., "task")
|
|
449
|
+
tool_call_id: Unique identifier for this tool call
|
|
450
|
+
event: The streaming event from the sub-agent
|
|
451
|
+
|
|
452
|
+
Note: The main app receives this event from the agent's task tool handler.
|
|
453
|
+
"""
|
|
454
|
+
await self.agent_stream_handler.handle_task_tool_event(tool_name, tool_call_id, event)
|
|
455
|
+
|
|
456
|
+
async def handle_clear_command(self) -> None:
|
|
457
|
+
"""Handle the /clear command to create a new session and clear the UI."""
|
|
458
|
+
log("Clearing session and creating new session")
|
|
459
|
+
|
|
460
|
+
# Cancel any running agent
|
|
461
|
+
if self.agent_status == "running":
|
|
462
|
+
self.action_cancel_agent()
|
|
463
|
+
|
|
464
|
+
# Clear message queue
|
|
465
|
+
self.message_queue.clear()
|
|
466
|
+
|
|
467
|
+
# Generate a new session ID
|
|
468
|
+
import datetime
|
|
469
|
+
|
|
470
|
+
new_session_id = f"chat-{datetime.datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
|
471
|
+
|
|
472
|
+
# Create new session
|
|
473
|
+
if settings.session.storage_type == "jsonl":
|
|
474
|
+
self.session = JSONLSession(
|
|
475
|
+
session_id=new_session_id,
|
|
476
|
+
project_path=None, # Will use current working directory
|
|
477
|
+
base_dir=settings.session.base_dir,
|
|
478
|
+
)
|
|
479
|
+
else:
|
|
480
|
+
raise NotImplementedError("SQLite session support will be added later")
|
|
481
|
+
|
|
482
|
+
# Reset context state
|
|
483
|
+
self.context.reset_state()
|
|
484
|
+
|
|
485
|
+
# Clear input items
|
|
486
|
+
self.input_items.clear()
|
|
487
|
+
|
|
488
|
+
# Clear the UI - remove all messages and add welcome back
|
|
489
|
+
main_scroll = self.query_one("#messages", MainScroll)
|
|
490
|
+
|
|
491
|
+
# Remove all existing messages
|
|
492
|
+
for message in main_scroll.query("BaseMessage"):
|
|
493
|
+
message.remove()
|
|
494
|
+
|
|
495
|
+
# Remove welcome if it exists
|
|
496
|
+
for welcome in main_scroll.query("Welcome"):
|
|
497
|
+
welcome.remove()
|
|
498
|
+
|
|
499
|
+
# Add welcome widget back
|
|
500
|
+
await main_scroll.mount(Welcome())
|
|
501
|
+
|
|
502
|
+
# Show system message to confirm the clear operation
|
|
503
|
+
system_message = SystemMessage(f"✨ Session cleared! Started new session: {new_session_id}")
|
|
504
|
+
await main_scroll.mount(system_message)
|
|
505
|
+
|
|
506
|
+
log(f"New session created: {new_session_id}")
|
vibecore/main.tcss
ADDED
|
File without changes
|
vibecore/mcp/__init__.py
ADDED
vibecore/mcp/manager.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""MCP server management for Vibecore."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from agents import Tool
|
|
6
|
+
from agents.mcp import (
|
|
7
|
+
MCPServer,
|
|
8
|
+
MCPServerSse,
|
|
9
|
+
MCPServerSseParams,
|
|
10
|
+
MCPServerStdio,
|
|
11
|
+
MCPServerStdioParams,
|
|
12
|
+
MCPServerStreamableHttp,
|
|
13
|
+
MCPServerStreamableHttpParams,
|
|
14
|
+
MCPUtil,
|
|
15
|
+
create_static_tool_filter,
|
|
16
|
+
)
|
|
17
|
+
from agents.run_context import RunContextWrapper
|
|
18
|
+
from textual import log
|
|
19
|
+
|
|
20
|
+
from vibecore.settings import MCPServerConfig
|
|
21
|
+
|
|
22
|
+
from .server_wrapper import NameOverridingMCPServer
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from agents import AgentBase
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MCPManager:
|
|
29
|
+
"""Manages MCP server connections and tool discovery."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, server_configs: list[MCPServerConfig]):
|
|
32
|
+
"""Initialize the MCP manager.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
server_configs: List of MCP server configurations.
|
|
36
|
+
"""
|
|
37
|
+
self.server_configs = server_configs
|
|
38
|
+
self.servers: list[MCPServer] = []
|
|
39
|
+
self._connected = False
|
|
40
|
+
self._server_contexts: list[Any] = [] # Store context managers
|
|
41
|
+
|
|
42
|
+
# Create servers immediately and wrap them
|
|
43
|
+
for config in self.server_configs:
|
|
44
|
+
actual_server = self._create_server(config)
|
|
45
|
+
# Wrap the server to override tool names
|
|
46
|
+
wrapped_server = NameOverridingMCPServer(actual_server)
|
|
47
|
+
self.servers.append(wrapped_server)
|
|
48
|
+
|
|
49
|
+
async def connect(self) -> None:
|
|
50
|
+
"""Connect to all configured MCP servers."""
|
|
51
|
+
if self._connected:
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
for server in self.servers:
|
|
55
|
+
await server.connect()
|
|
56
|
+
|
|
57
|
+
self._connected = True
|
|
58
|
+
|
|
59
|
+
async def disconnect(self) -> None:
|
|
60
|
+
"""Disconnect from all MCP servers."""
|
|
61
|
+
if not self._connected:
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
# Disconnect all servers sequentially to avoid anyio cancel scope issues
|
|
65
|
+
# anyio doesn't allow cancel scopes to be exited in a different task
|
|
66
|
+
for server in self.servers:
|
|
67
|
+
try:
|
|
68
|
+
log(f"Disconnecting from MCP server: {server.name}")
|
|
69
|
+
# Give each server 3 seconds to cleanup gracefully
|
|
70
|
+
# await asyncio.wait_for(server.cleanup(), timeout=3.0)
|
|
71
|
+
await server.cleanup()
|
|
72
|
+
log(f"Disconnected from MCP server: {server.name}")
|
|
73
|
+
except TimeoutError:
|
|
74
|
+
log(f"Timeout disconnecting from MCP server: {server.name}")
|
|
75
|
+
except Exception as e:
|
|
76
|
+
log(f"Error disconnecting from MCP server {server.name}: {e}")
|
|
77
|
+
|
|
78
|
+
# Clear the servers list to prevent any further operations
|
|
79
|
+
# self.servers.clear()
|
|
80
|
+
self._connected = False
|
|
81
|
+
|
|
82
|
+
async def get_tools(self, run_context: RunContextWrapper[Any], agent: "AgentBase") -> list[Tool]:
|
|
83
|
+
"""Get all tools from connected MCP servers.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
run_context: The current run context.
|
|
87
|
+
agent: The agent requesting tools.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
List of tools from all connected MCP servers.
|
|
91
|
+
"""
|
|
92
|
+
if not self._connected:
|
|
93
|
+
await self.connect()
|
|
94
|
+
|
|
95
|
+
# Get all tools using MCPUtil which handles the wrapped servers
|
|
96
|
+
return await MCPUtil.get_all_function_tools(
|
|
97
|
+
servers=self.servers,
|
|
98
|
+
convert_schemas_to_strict=True,
|
|
99
|
+
run_context=run_context,
|
|
100
|
+
agent=agent,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def _create_server(self, config: MCPServerConfig) -> MCPServer:
|
|
104
|
+
"""Create an MCP server instance from configuration.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
config: MCP server configuration.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Configured MCP server instance.
|
|
111
|
+
"""
|
|
112
|
+
tool_filter = create_static_tool_filter(
|
|
113
|
+
allowed_tool_names=config.allowed_tools,
|
|
114
|
+
blocked_tool_names=config.blocked_tools,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if config.type == "stdio":
|
|
118
|
+
if not config.command:
|
|
119
|
+
raise ValueError(f"stdio server '{config.name}' requires a command")
|
|
120
|
+
|
|
121
|
+
return MCPServerStdio(
|
|
122
|
+
name=config.name,
|
|
123
|
+
params=MCPServerStdioParams(
|
|
124
|
+
command=config.command,
|
|
125
|
+
args=config.args,
|
|
126
|
+
env=config.env,
|
|
127
|
+
),
|
|
128
|
+
cache_tools_list=config.cache_tools,
|
|
129
|
+
client_session_timeout_seconds=config.timeout_seconds,
|
|
130
|
+
tool_filter=tool_filter,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
elif config.type == "sse":
|
|
134
|
+
if not config.url:
|
|
135
|
+
raise ValueError(f"SSE server '{config.name}' requires a URL")
|
|
136
|
+
|
|
137
|
+
return MCPServerSse(
|
|
138
|
+
name=config.name,
|
|
139
|
+
params=MCPServerSseParams(url=config.url),
|
|
140
|
+
cache_tools_list=config.cache_tools,
|
|
141
|
+
client_session_timeout_seconds=config.timeout_seconds,
|
|
142
|
+
tool_filter=tool_filter,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
elif config.type == "http":
|
|
146
|
+
if not config.url:
|
|
147
|
+
raise ValueError(f"HTTP server '{config.name}' requires a URL")
|
|
148
|
+
|
|
149
|
+
return MCPServerStreamableHttp(
|
|
150
|
+
name=config.name,
|
|
151
|
+
params=MCPServerStreamableHttpParams(url=config.url),
|
|
152
|
+
cache_tools_list=config.cache_tools,
|
|
153
|
+
client_session_timeout_seconds=config.timeout_seconds,
|
|
154
|
+
tool_filter=tool_filter,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
else:
|
|
158
|
+
raise ValueError(f"Unknown MCP server type: {config.type}")
|
|
159
|
+
|
|
160
|
+
async def __aenter__(self) -> "MCPManager":
|
|
161
|
+
"""Enter async context manager."""
|
|
162
|
+
await self.connect()
|
|
163
|
+
return self
|
|
164
|
+
|
|
165
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
166
|
+
"""Exit async context manager."""
|
|
167
|
+
await self.disconnect()
|