vibecore 0.2.0__py3-none-any.whl → 0.3.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.
- vibecore/agents/default.py +6 -11
- vibecore/agents/{task_agent.py → task.py} +2 -6
- vibecore/auth/__init__.py +15 -0
- vibecore/auth/config.py +38 -0
- vibecore/auth/interceptor.py +141 -0
- vibecore/auth/manager.py +173 -0
- vibecore/auth/models.py +54 -0
- vibecore/auth/oauth_flow.py +129 -0
- vibecore/auth/pkce.py +29 -0
- vibecore/auth/storage.py +111 -0
- vibecore/auth/token_manager.py +131 -0
- vibecore/cli.py +98 -9
- vibecore/flow.py +105 -0
- vibecore/handlers/stream_handler.py +11 -0
- vibecore/main.py +28 -6
- vibecore/models/anthropic_auth.py +226 -0
- vibecore/settings.py +61 -5
- vibecore/tools/task/executor.py +1 -1
- vibecore/tools/webfetch/__init__.py +7 -0
- vibecore/tools/webfetch/executor.py +127 -0
- vibecore/tools/webfetch/models.py +22 -0
- vibecore/tools/webfetch/tools.py +46 -0
- vibecore/tools/websearch/__init__.py +5 -0
- vibecore/tools/websearch/base.py +27 -0
- vibecore/tools/websearch/ddgs/__init__.py +5 -0
- vibecore/tools/websearch/ddgs/backend.py +64 -0
- vibecore/tools/websearch/executor.py +43 -0
- vibecore/tools/websearch/models.py +20 -0
- vibecore/tools/websearch/tools.py +49 -0
- vibecore/widgets/tool_message_factory.py +24 -0
- vibecore/widgets/tool_messages.py +219 -0
- vibecore/widgets/tool_messages.tcss +94 -0
- {vibecore-0.2.0.dist-info → vibecore-0.3.0.dist-info}/METADATA +107 -1
- {vibecore-0.2.0.dist-info → vibecore-0.3.0.dist-info}/RECORD +37 -15
- vibecore-0.3.0.dist-info/entry_points.txt +2 -0
- vibecore-0.2.0.dist-info/entry_points.txt +0 -2
- {vibecore-0.2.0.dist-info → vibecore-0.3.0.dist-info}/WHEEL +0 -0
- {vibecore-0.2.0.dist-info → vibecore-0.3.0.dist-info}/licenses/LICENSE +0 -0
vibecore/auth/storage.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Secure storage for authentication credentials."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from vibecore.auth.models import AnthropicAuth, ApiKeyCredentials, OAuthCredentials
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SecureAuthStorage:
|
|
12
|
+
"""Secure storage for authentication credentials."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, app_name: str = "vibecore"):
|
|
15
|
+
"""
|
|
16
|
+
Initialize secure storage.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
app_name: Application name for storage directory.
|
|
20
|
+
"""
|
|
21
|
+
# Store in user's local data directory
|
|
22
|
+
self.storage_path = Path.home() / ".local" / "share" / app_name / "auth.json"
|
|
23
|
+
|
|
24
|
+
async def save(self, provider: str, credentials: AnthropicAuth) -> None:
|
|
25
|
+
"""
|
|
26
|
+
Save credentials securely.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
provider: Provider name (e.g., "anthropic").
|
|
30
|
+
credentials: Authentication credentials.
|
|
31
|
+
"""
|
|
32
|
+
# Ensure directory exists
|
|
33
|
+
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
|
|
35
|
+
# Load existing data
|
|
36
|
+
data = await self._load_all()
|
|
37
|
+
|
|
38
|
+
# Convert credentials to dict
|
|
39
|
+
if isinstance(credentials, OAuthCredentials):
|
|
40
|
+
cred_dict = {
|
|
41
|
+
"type": "oauth",
|
|
42
|
+
"refresh": credentials.refresh,
|
|
43
|
+
"access": credentials.access,
|
|
44
|
+
"expires": credentials.expires,
|
|
45
|
+
}
|
|
46
|
+
elif isinstance(credentials, ApiKeyCredentials):
|
|
47
|
+
cred_dict = {"type": "api", "key": credentials.key}
|
|
48
|
+
else:
|
|
49
|
+
raise ValueError(f"Unknown credential type: {type(credentials)}")
|
|
50
|
+
|
|
51
|
+
# Update credentials
|
|
52
|
+
data[provider] = cred_dict
|
|
53
|
+
|
|
54
|
+
# Write with secure permissions (owner read/write only)
|
|
55
|
+
self.storage_path.write_text(json.dumps(data, indent=2))
|
|
56
|
+
os.chmod(self.storage_path, 0o600)
|
|
57
|
+
|
|
58
|
+
async def load(self, provider: str) -> AnthropicAuth | None:
|
|
59
|
+
"""
|
|
60
|
+
Load credentials for a provider.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
provider: Provider name.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Authentication credentials or None if not found.
|
|
67
|
+
"""
|
|
68
|
+
data = await self._load_all()
|
|
69
|
+
cred_dict = data.get(provider)
|
|
70
|
+
|
|
71
|
+
if not cred_dict:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
# Convert dict back to credentials object
|
|
75
|
+
if cred_dict.get("type") == "oauth":
|
|
76
|
+
return OAuthCredentials(
|
|
77
|
+
type="oauth",
|
|
78
|
+
refresh=cred_dict.get("refresh", ""),
|
|
79
|
+
access=cred_dict.get("access", ""),
|
|
80
|
+
expires=cred_dict.get("expires", 0),
|
|
81
|
+
)
|
|
82
|
+
elif cred_dict.get("type") == "api":
|
|
83
|
+
return ApiKeyCredentials(type="api", key=cred_dict.get("key", ""))
|
|
84
|
+
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
async def remove(self, provider: str) -> None:
|
|
88
|
+
"""
|
|
89
|
+
Remove credentials for a provider.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
provider: Provider name.
|
|
93
|
+
"""
|
|
94
|
+
data = await self._load_all()
|
|
95
|
+
data.pop(provider, None)
|
|
96
|
+
self.storage_path.write_text(json.dumps(data, indent=2))
|
|
97
|
+
os.chmod(self.storage_path, 0o600)
|
|
98
|
+
|
|
99
|
+
async def _load_all(self) -> dict[str, Any]:
|
|
100
|
+
"""Load all stored credentials."""
|
|
101
|
+
if not self.storage_path.exists():
|
|
102
|
+
return {}
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
return json.loads(self.storage_path.read_text())
|
|
106
|
+
except (json.JSONDecodeError, OSError):
|
|
107
|
+
return {}
|
|
108
|
+
|
|
109
|
+
def exists(self) -> bool:
|
|
110
|
+
"""Check if any credentials are stored."""
|
|
111
|
+
return self.storage_path.exists() and self.storage_path.stat().st_size > 2
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Token management for OAuth authentication."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from vibecore.auth.config import ANTHROPIC_CONFIG
|
|
9
|
+
from vibecore.auth.models import OAuthCredentials
|
|
10
|
+
from vibecore.auth.storage import SecureAuthStorage
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TokenRefreshManager:
|
|
14
|
+
"""Manages OAuth token refresh with automatic renewal."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, storage: SecureAuthStorage):
|
|
17
|
+
"""
|
|
18
|
+
Initialize token refresh manager.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
storage: Secure storage for credentials.
|
|
22
|
+
"""
|
|
23
|
+
self.storage = storage
|
|
24
|
+
self.refresh_lock = asyncio.Lock()
|
|
25
|
+
self.refresh_task: asyncio.Task | None = None
|
|
26
|
+
|
|
27
|
+
async def get_valid_token(self) -> str:
|
|
28
|
+
"""
|
|
29
|
+
Get a valid access token, refreshing if necessary.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Valid access token.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
ValueError: If not authenticated or refresh fails.
|
|
36
|
+
"""
|
|
37
|
+
auth = await self.storage.load("anthropic")
|
|
38
|
+
|
|
39
|
+
if not auth:
|
|
40
|
+
raise ValueError("Not authenticated")
|
|
41
|
+
|
|
42
|
+
if auth.type == "api": # API key auth
|
|
43
|
+
return auth.key # type: ignore
|
|
44
|
+
|
|
45
|
+
# OAuth auth - check if token needs refresh
|
|
46
|
+
now = int(time.time() * 1000)
|
|
47
|
+
buffer_ms = ANTHROPIC_CONFIG.TOKEN_REFRESH_BUFFER_SECONDS * 1000
|
|
48
|
+
needs_refresh = not auth.access or auth.expires <= now + buffer_ms # type: ignore
|
|
49
|
+
|
|
50
|
+
if not needs_refresh:
|
|
51
|
+
return auth.access # type: ignore
|
|
52
|
+
|
|
53
|
+
# Refresh token with lock to prevent concurrent refreshes
|
|
54
|
+
async with self.refresh_lock:
|
|
55
|
+
# Re-check after acquiring lock
|
|
56
|
+
auth = await self.storage.load("anthropic")
|
|
57
|
+
if auth and auth.type == "oauth":
|
|
58
|
+
now = int(time.time() * 1000)
|
|
59
|
+
if auth.access and auth.expires > now + buffer_ms: # type: ignore
|
|
60
|
+
return auth.access # type: ignore
|
|
61
|
+
|
|
62
|
+
# Perform refresh
|
|
63
|
+
if auth and auth.type == "oauth":
|
|
64
|
+
return await self._refresh_token(auth.refresh) # type: ignore
|
|
65
|
+
else:
|
|
66
|
+
raise ValueError("Cannot refresh non-OAuth credentials")
|
|
67
|
+
|
|
68
|
+
async def _refresh_token(self, refresh_token: str) -> str:
|
|
69
|
+
"""
|
|
70
|
+
Refresh the access token.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
refresh_token: Refresh token.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
New access token.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
httpx.HTTPError: If refresh fails after retries.
|
|
80
|
+
"""
|
|
81
|
+
last_error: Exception | None = None
|
|
82
|
+
|
|
83
|
+
# Retry logic
|
|
84
|
+
for attempt in range(ANTHROPIC_CONFIG.TOKEN_MAX_RETRY_ATTEMPTS):
|
|
85
|
+
try:
|
|
86
|
+
# Exponential backoff for retries
|
|
87
|
+
if attempt > 0:
|
|
88
|
+
delay = ANTHROPIC_CONFIG.TOKEN_RETRY_DELAY_MS * (2 ** (attempt - 1)) / 1000
|
|
89
|
+
await asyncio.sleep(delay)
|
|
90
|
+
|
|
91
|
+
# Make refresh request
|
|
92
|
+
async with httpx.AsyncClient() as client:
|
|
93
|
+
response = await client.post(
|
|
94
|
+
ANTHROPIC_CONFIG.TOKEN_EXCHANGE,
|
|
95
|
+
headers={
|
|
96
|
+
"Content-Type": "application/json",
|
|
97
|
+
"Accept": "application/json",
|
|
98
|
+
},
|
|
99
|
+
json={
|
|
100
|
+
"grant_type": "refresh_token",
|
|
101
|
+
"refresh_token": refresh_token,
|
|
102
|
+
"client_id": ANTHROPIC_CONFIG.OAUTH_CLIENT_ID,
|
|
103
|
+
},
|
|
104
|
+
timeout=30.0,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if response.status_code != 200:
|
|
108
|
+
error_text = response.text
|
|
109
|
+
raise httpx.HTTPError(f"Token refresh failed: {response.status_code} - {error_text}")
|
|
110
|
+
|
|
111
|
+
tokens_data = response.json()
|
|
112
|
+
|
|
113
|
+
# Update stored credentials
|
|
114
|
+
new_credentials = OAuthCredentials(
|
|
115
|
+
type="oauth",
|
|
116
|
+
refresh=tokens_data.get("refresh_token", refresh_token),
|
|
117
|
+
access=tokens_data["access_token"],
|
|
118
|
+
expires=int(time.time() * 1000) + tokens_data["expires_in"] * 1000,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
await self.storage.save("anthropic", new_credentials)
|
|
122
|
+
return tokens_data["access_token"]
|
|
123
|
+
|
|
124
|
+
except Exception as error:
|
|
125
|
+
last_error = error
|
|
126
|
+
print(f"Token refresh attempt {attempt + 1} failed: {error}")
|
|
127
|
+
|
|
128
|
+
# All retries failed
|
|
129
|
+
raise ValueError(
|
|
130
|
+
f"Token refresh failed after {ANTHROPIC_CONFIG.TOKEN_MAX_RETRY_ATTEMPTS} attempts: {last_error}"
|
|
131
|
+
)
|
vibecore/cli.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Vibecore CLI interface using typer."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import logging
|
|
4
5
|
from importlib.metadata import version
|
|
5
6
|
from pathlib import Path
|
|
@@ -15,6 +16,10 @@ from vibecore.settings import settings
|
|
|
15
16
|
|
|
16
17
|
app = typer.Typer()
|
|
17
18
|
|
|
19
|
+
# Create auth subcommand group
|
|
20
|
+
auth_app = typer.Typer(help="Manage Anthropic authentication")
|
|
21
|
+
app.add_typer(auth_app, name="auth")
|
|
22
|
+
|
|
18
23
|
|
|
19
24
|
def version_callback(value: bool):
|
|
20
25
|
"""Handle --version flag."""
|
|
@@ -57,9 +62,15 @@ def find_latest_session(project_path: Path | None = None, base_dir: Path | None
|
|
|
57
62
|
return session_files[0].stem
|
|
58
63
|
|
|
59
64
|
|
|
60
|
-
@app.
|
|
61
|
-
def
|
|
62
|
-
|
|
65
|
+
@app.callback(invoke_without_command=True)
|
|
66
|
+
def main(
|
|
67
|
+
ctx: typer.Context,
|
|
68
|
+
prompt: str | None = typer.Option(
|
|
69
|
+
None,
|
|
70
|
+
"--prompt",
|
|
71
|
+
"-p",
|
|
72
|
+
help="Initial prompt to send to the agent (reads from stdin if -p is used without argument)",
|
|
73
|
+
),
|
|
63
74
|
continue_session: bool = typer.Option(
|
|
64
75
|
False,
|
|
65
76
|
"--continue",
|
|
@@ -75,7 +86,6 @@ def run(
|
|
|
75
86
|
print_mode: bool = typer.Option(
|
|
76
87
|
False,
|
|
77
88
|
"--print",
|
|
78
|
-
"-p",
|
|
79
89
|
help="Print response and exit (useful for pipes)",
|
|
80
90
|
),
|
|
81
91
|
version: bool | None = typer.Option(
|
|
@@ -87,6 +97,10 @@ def run(
|
|
|
87
97
|
),
|
|
88
98
|
):
|
|
89
99
|
"""Run the Vibecore TUI application."""
|
|
100
|
+
# If a subcommand was invoked, don't run the main app
|
|
101
|
+
if ctx.invoked_subcommand is not None:
|
|
102
|
+
return
|
|
103
|
+
|
|
90
104
|
# Set up logging
|
|
91
105
|
logging.basicConfig(
|
|
92
106
|
level="WARNING",
|
|
@@ -97,14 +111,14 @@ def run(
|
|
|
97
111
|
logger.addHandler(TextualHandler())
|
|
98
112
|
|
|
99
113
|
# Create context
|
|
100
|
-
|
|
114
|
+
vibecore_ctx = VibecoreContext()
|
|
101
115
|
|
|
102
116
|
# Initialize MCP manager if configured
|
|
103
117
|
mcp_servers = []
|
|
104
118
|
if settings.mcp_servers:
|
|
105
119
|
# Create MCP manager
|
|
106
120
|
mcp_manager = MCPManager(settings.mcp_servers)
|
|
107
|
-
|
|
121
|
+
vibecore_ctx.mcp_manager = mcp_manager
|
|
108
122
|
|
|
109
123
|
# Get the MCP servers from the manager
|
|
110
124
|
mcp_servers = mcp_manager.servers
|
|
@@ -125,7 +139,7 @@ def run(
|
|
|
125
139
|
typer.echo(f"Loading session: {session_to_load}")
|
|
126
140
|
|
|
127
141
|
# Create app
|
|
128
|
-
app_instance = VibecoreApp(
|
|
142
|
+
app_instance = VibecoreApp(vibecore_ctx, agent, session_id=session_to_load, print_mode=print_mode)
|
|
129
143
|
|
|
130
144
|
if print_mode:
|
|
131
145
|
# Run in print mode
|
|
@@ -141,10 +155,85 @@ def run(
|
|
|
141
155
|
app_instance.run()
|
|
142
156
|
|
|
143
157
|
|
|
144
|
-
|
|
158
|
+
@auth_app.command("login")
|
|
159
|
+
def auth_login(
|
|
160
|
+
provider: str = typer.Argument("anthropic", help="Authentication provider (currently only 'anthropic')"),
|
|
161
|
+
api_key: str = typer.Option(None, "--api-key", "-k", help="Use API key instead of OAuth"),
|
|
162
|
+
mode: str = typer.Option(
|
|
163
|
+
"max", "--mode", "-m", help="OAuth mode: 'max' for claude.ai, 'console' for console.anthropic.com"
|
|
164
|
+
),
|
|
165
|
+
):
|
|
166
|
+
"""Authenticate with Anthropic Pro/Max or API key."""
|
|
167
|
+
if provider.lower() != "anthropic":
|
|
168
|
+
typer.echo(f"❌ Provider '{provider}' not supported. Currently only 'anthropic' is supported.")
|
|
169
|
+
raise typer.Exit(1)
|
|
170
|
+
|
|
171
|
+
from vibecore.auth.manager import AnthropicAuthManager
|
|
172
|
+
|
|
173
|
+
auth_manager = AnthropicAuthManager()
|
|
174
|
+
|
|
175
|
+
if api_key:
|
|
176
|
+
# API key authentication
|
|
177
|
+
success = asyncio.run(auth_manager.authenticate_with_api_key(api_key))
|
|
178
|
+
if not success:
|
|
179
|
+
raise typer.Exit(1)
|
|
180
|
+
else:
|
|
181
|
+
# OAuth Pro/Max authentication
|
|
182
|
+
success = asyncio.run(auth_manager.authenticate_pro_max(mode))
|
|
183
|
+
if not success:
|
|
184
|
+
raise typer.Exit(1)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@auth_app.command("logout")
|
|
188
|
+
def auth_logout(
|
|
189
|
+
provider: str = typer.Argument("anthropic", help="Authentication provider"),
|
|
190
|
+
):
|
|
191
|
+
"""Remove stored authentication."""
|
|
192
|
+
if provider.lower() != "anthropic":
|
|
193
|
+
typer.echo(f"❌ Provider '{provider}' not supported. Currently only 'anthropic' is supported.")
|
|
194
|
+
raise typer.Exit(1)
|
|
195
|
+
|
|
196
|
+
from vibecore.auth.manager import AnthropicAuthManager
|
|
197
|
+
|
|
198
|
+
auth_manager = AnthropicAuthManager()
|
|
199
|
+
asyncio.run(auth_manager.logout())
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@auth_app.command("status")
|
|
203
|
+
def auth_status():
|
|
204
|
+
"""Check authentication status."""
|
|
205
|
+
from vibecore.auth.manager import AnthropicAuthManager
|
|
206
|
+
|
|
207
|
+
auth_manager = AnthropicAuthManager()
|
|
208
|
+
|
|
209
|
+
if asyncio.run(auth_manager.is_authenticated()):
|
|
210
|
+
auth_type = asyncio.run(auth_manager.get_auth_type())
|
|
211
|
+
if auth_type == "oauth":
|
|
212
|
+
typer.echo("✅ Authenticated with Anthropic Pro/Max (OAuth)")
|
|
213
|
+
else:
|
|
214
|
+
typer.echo("✅ Authenticated with Anthropic API key")
|
|
215
|
+
else:
|
|
216
|
+
typer.echo("❌ Not authenticated with Anthropic")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@auth_app.command("test")
|
|
220
|
+
def auth_test():
|
|
221
|
+
"""Test authentication by making a simple API call."""
|
|
222
|
+
from vibecore.auth.manager import AnthropicAuthManager
|
|
223
|
+
|
|
224
|
+
auth_manager = AnthropicAuthManager()
|
|
225
|
+
|
|
226
|
+
typer.echo("🔍 Testing authentication...")
|
|
227
|
+
success = asyncio.run(auth_manager.test_connection())
|
|
228
|
+
|
|
229
|
+
if not success:
|
|
230
|
+
raise typer.Exit(1)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def cli_main():
|
|
145
234
|
"""Entry point for the CLI."""
|
|
146
235
|
app()
|
|
147
236
|
|
|
148
237
|
|
|
149
238
|
if __name__ == "__main__":
|
|
150
|
-
|
|
239
|
+
cli_main()
|
vibecore/flow.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import threading
|
|
3
|
+
from collections.abc import Callable, Coroutine
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from agents import Agent
|
|
7
|
+
from textual.pilot import Pilot
|
|
8
|
+
|
|
9
|
+
from vibecore.context import VibecoreContext
|
|
10
|
+
from vibecore.main import AppIsExiting, VibecoreApp
|
|
11
|
+
from vibecore.widgets.core import MyTextArea
|
|
12
|
+
from vibecore.widgets.messages import SystemMessage
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UserInputFunc(Protocol):
|
|
16
|
+
"""Protocol for user input function with optional prompt parameter."""
|
|
17
|
+
|
|
18
|
+
async def __call__(self, prompt: str = "") -> str:
|
|
19
|
+
"""Get user input with optional prompt message.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
prompt: Optional prompt to display before getting input.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
The user's input string.
|
|
26
|
+
"""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def flow(
|
|
31
|
+
agent: Agent,
|
|
32
|
+
logic: Callable[[VibecoreApp, VibecoreContext, UserInputFunc], Coroutine],
|
|
33
|
+
headless: bool = False,
|
|
34
|
+
shutdown: bool = False,
|
|
35
|
+
disable_user_input: bool = True,
|
|
36
|
+
):
|
|
37
|
+
ctx = VibecoreContext()
|
|
38
|
+
app = VibecoreApp(ctx, agent, show_welcome=False)
|
|
39
|
+
|
|
40
|
+
app_ready_event = asyncio.Event()
|
|
41
|
+
|
|
42
|
+
def on_app_ready() -> None:
|
|
43
|
+
"""Called when app is ready to process events."""
|
|
44
|
+
app_ready_event.set()
|
|
45
|
+
|
|
46
|
+
async def run_app(app: VibecoreApp) -> None:
|
|
47
|
+
"""Run the apps message loop.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
app: App to run.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
with app._context():
|
|
54
|
+
try:
|
|
55
|
+
app._loop = asyncio.get_running_loop()
|
|
56
|
+
app._thread_id = threading.get_ident()
|
|
57
|
+
await app._process_messages(
|
|
58
|
+
ready_callback=on_app_ready,
|
|
59
|
+
headless=headless,
|
|
60
|
+
)
|
|
61
|
+
finally:
|
|
62
|
+
app_ready_event.set()
|
|
63
|
+
|
|
64
|
+
async def user_input(prompt: str = "") -> str:
|
|
65
|
+
if prompt:
|
|
66
|
+
await app.add_message(SystemMessage(prompt))
|
|
67
|
+
app.query_one(MyTextArea).disabled = False
|
|
68
|
+
app.query_one(MyTextArea).focus()
|
|
69
|
+
user_input = await app.wait_for_user_input()
|
|
70
|
+
if disable_user_input:
|
|
71
|
+
app.query_one(MyTextArea).disabled = True
|
|
72
|
+
return user_input
|
|
73
|
+
|
|
74
|
+
async def run_logic(app: VibecoreApp, ctx: VibecoreContext, user_input: UserInputFunc) -> None:
|
|
75
|
+
try:
|
|
76
|
+
await logic(app, ctx, user_input)
|
|
77
|
+
except AppIsExiting:
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
app_task = asyncio.create_task(run_app(app), name=f"with_app({app})")
|
|
81
|
+
await app_ready_event.wait()
|
|
82
|
+
pilot = Pilot(app)
|
|
83
|
+
logic_task: asyncio.Task | None = None
|
|
84
|
+
|
|
85
|
+
await pilot._wait_for_screen()
|
|
86
|
+
if disable_user_input:
|
|
87
|
+
app.query_one(MyTextArea).disabled = True
|
|
88
|
+
logic_task = asyncio.create_task(run_logic(app, ctx, user_input), name="logic_task")
|
|
89
|
+
done, pending = await asyncio.wait([logic_task, app_task], return_when=asyncio.FIRST_COMPLETED)
|
|
90
|
+
|
|
91
|
+
# If app has exited and logic is still running, cancel logic
|
|
92
|
+
if app_task in done and logic_task in pending:
|
|
93
|
+
logic_task.cancel()
|
|
94
|
+
# If logic is finished and app is still running
|
|
95
|
+
elif logic_task in done and app_task in pending:
|
|
96
|
+
if shutdown:
|
|
97
|
+
if not headless:
|
|
98
|
+
await pilot._wait_for_screen()
|
|
99
|
+
await asyncio.sleep(1.0)
|
|
100
|
+
app.exit()
|
|
101
|
+
else:
|
|
102
|
+
# Enable text input so users can interact freely
|
|
103
|
+
app.query_one(MyTextArea).disabled = False
|
|
104
|
+
# Wait until app is exited
|
|
105
|
+
await app_task
|
|
@@ -15,6 +15,7 @@ from agents import (
|
|
|
15
15
|
ToolCallOutputItem,
|
|
16
16
|
)
|
|
17
17
|
from openai.types.responses import (
|
|
18
|
+
ResponseCompletedEvent,
|
|
18
19
|
ResponseFunctionToolCall,
|
|
19
20
|
ResponseOutputItemAddedEvent,
|
|
20
21
|
ResponseOutputItemDoneEvent,
|
|
@@ -123,6 +124,7 @@ class AgentStreamHandler:
|
|
|
123
124
|
"""
|
|
124
125
|
match event:
|
|
125
126
|
case RawResponsesStreamEvent(data=data):
|
|
127
|
+
# log(f"RawResponsesStreamEvent data: {data.type}")
|
|
126
128
|
match data:
|
|
127
129
|
case ResponseOutputItemAddedEvent(item=ResponseReasoningItem() as item):
|
|
128
130
|
reasoning_id = item.id
|
|
@@ -180,7 +182,15 @@ class AgentStreamHandler:
|
|
|
180
182
|
else:
|
|
181
183
|
await self.handle_tool_call(tool_name, arguments, call_id)
|
|
182
184
|
|
|
185
|
+
case ResponseCompletedEvent():
|
|
186
|
+
# When in agent handoff or stop at tool situations, the tools should be in executing status.
|
|
187
|
+
# We find all the executing status tool messages and mark them as success.
|
|
188
|
+
for tool_message in self.tool_messages.values():
|
|
189
|
+
if tool_message.status == MessageStatus.EXECUTING:
|
|
190
|
+
tool_message.status = MessageStatus.SUCCESS
|
|
191
|
+
|
|
183
192
|
case RunItemStreamEvent(item=item):
|
|
193
|
+
# log(f"RunItemStreamEvent item: {item.type}")
|
|
184
194
|
match item:
|
|
185
195
|
case ToolCallItem():
|
|
186
196
|
pass
|
|
@@ -196,6 +206,7 @@ class AgentStreamHandler:
|
|
|
196
206
|
await self.handle_message_complete()
|
|
197
207
|
|
|
198
208
|
case AgentUpdatedStreamEvent(new_agent=new_agent):
|
|
209
|
+
# log(f"AgentUpdatedStreamEvent new_agent: {new_agent.name}")
|
|
199
210
|
await self.message_handler.handle_agent_update(new_agent)
|
|
200
211
|
|
|
201
212
|
async def handle_task_tool_event(self, tool_name: str, tool_call_id: str, event: StreamEvent) -> None:
|
vibecore/main.py
CHANGED
|
@@ -30,7 +30,11 @@ from vibecore.widgets.core import AppFooter, MainScroll, MyTextArea
|
|
|
30
30
|
from vibecore.widgets.info import Welcome
|
|
31
31
|
from vibecore.widgets.messages import AgentMessage, BaseMessage, MessageStatus, SystemMessage, UserMessage
|
|
32
32
|
|
|
33
|
-
AgentStatus = Literal["idle", "running"]
|
|
33
|
+
AgentStatus = Literal["idle", "running", "waiting_user_input"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AppIsExiting(Exception):
|
|
37
|
+
pass
|
|
34
38
|
|
|
35
39
|
|
|
36
40
|
def detect_reasoning_effort(prompt: str) -> Literal["low", "medium", "high"] | None:
|
|
@@ -82,6 +86,7 @@ class VibecoreApp(App):
|
|
|
82
86
|
agent: Agent,
|
|
83
87
|
session_id: str | None = None,
|
|
84
88
|
print_mode: bool = False,
|
|
89
|
+
show_welcome: bool = True,
|
|
85
90
|
) -> None:
|
|
86
91
|
"""Initialize the Vibecore app with context and agent.
|
|
87
92
|
|
|
@@ -90,6 +95,7 @@ class VibecoreApp(App):
|
|
|
90
95
|
agent: The Agent instance to use
|
|
91
96
|
session_id: Optional session ID to load existing session
|
|
92
97
|
print_mode: Whether to run in print mode (useful for pipes)
|
|
98
|
+
show_welcome: Whether to show the welcome message (default: True)
|
|
93
99
|
"""
|
|
94
100
|
self.context = context
|
|
95
101
|
self.context.app = self # Set the app reference in context
|
|
@@ -99,6 +105,7 @@ class VibecoreApp(App):
|
|
|
99
105
|
self.current_worker: Worker[None] | None = None
|
|
100
106
|
self._session_id_provided = session_id is not None # Track if continuing session
|
|
101
107
|
self.print_mode = print_mode
|
|
108
|
+
self.show_welcome = show_welcome
|
|
102
109
|
self.message_queue: deque[str] = deque() # Queue for user messages
|
|
103
110
|
|
|
104
111
|
# Initialize session based on settings
|
|
@@ -124,7 +131,8 @@ class VibecoreApp(App):
|
|
|
124
131
|
yield Header()
|
|
125
132
|
yield AppFooter()
|
|
126
133
|
with MainScroll(id="messages"):
|
|
127
|
-
|
|
134
|
+
if self.show_welcome:
|
|
135
|
+
yield Welcome()
|
|
128
136
|
|
|
129
137
|
async def on_mount(self) -> None:
|
|
130
138
|
"""Called when the app is mounted."""
|
|
@@ -162,6 +170,8 @@ class VibecoreApp(App):
|
|
|
162
170
|
Args:
|
|
163
171
|
message: The message to add
|
|
164
172
|
"""
|
|
173
|
+
if not self.is_running:
|
|
174
|
+
raise AppIsExiting("App is not running")
|
|
165
175
|
main_scroll = self.query_one("#messages", MainScroll)
|
|
166
176
|
await main_scroll.mount(message)
|
|
167
177
|
|
|
@@ -225,6 +235,14 @@ class VibecoreApp(App):
|
|
|
225
235
|
else:
|
|
226
236
|
footer.hide_loading()
|
|
227
237
|
|
|
238
|
+
async def wait_for_user_input(self) -> str:
|
|
239
|
+
"""Used in flow mode. See examples/basic_agent.py"""
|
|
240
|
+
self.agent_status = "waiting_user_input"
|
|
241
|
+
self.user_input_event = asyncio.Event()
|
|
242
|
+
await self.user_input_event.wait()
|
|
243
|
+
user_input = self.message_queue.pop()
|
|
244
|
+
return user_input
|
|
245
|
+
|
|
228
246
|
async def on_my_text_area_user_message(self, event: MyTextArea.UserMessage) -> None:
|
|
229
247
|
"""Handle user messages from the text area."""
|
|
230
248
|
if event.text:
|
|
@@ -248,8 +266,11 @@ class VibecoreApp(App):
|
|
|
248
266
|
await self.add_message(user_message)
|
|
249
267
|
user_message.scroll_visible()
|
|
250
268
|
|
|
251
|
-
|
|
269
|
+
if self.agent_status == "waiting_user_input":
|
|
270
|
+
self.message_queue.append(event.text)
|
|
271
|
+
self.user_input_event.set()
|
|
252
272
|
if self.agent_status == "running":
|
|
273
|
+
# If agent is running, queue the message
|
|
253
274
|
self.message_queue.append(event.text)
|
|
254
275
|
log(f"Message queued: {event.text}")
|
|
255
276
|
footer = self.query_one(AppFooter)
|
|
@@ -268,7 +289,7 @@ class VibecoreApp(App):
|
|
|
268
289
|
if reasoning_effort is not None:
|
|
269
290
|
# Create a copy of the agent with updated model settings
|
|
270
291
|
current_settings = self.agent.model_settings or ModelSettings()
|
|
271
|
-
new_reasoning = Reasoning(effort=reasoning_effort, summary=
|
|
292
|
+
new_reasoning = Reasoning(effort=reasoning_effort, summary=settings.reasoning_summary)
|
|
272
293
|
updated_settings = ModelSettings(
|
|
273
294
|
include_usage=current_settings.include_usage,
|
|
274
295
|
reasoning=new_reasoning,
|
|
@@ -496,8 +517,9 @@ class VibecoreApp(App):
|
|
|
496
517
|
for welcome in main_scroll.query("Welcome"):
|
|
497
518
|
welcome.remove()
|
|
498
519
|
|
|
499
|
-
# Add welcome widget back
|
|
500
|
-
|
|
520
|
+
# Add welcome widget back if show_welcome is True
|
|
521
|
+
if self.show_welcome:
|
|
522
|
+
await main_scroll.mount(Welcome())
|
|
501
523
|
|
|
502
524
|
# Show system message to confirm the clear operation
|
|
503
525
|
system_message = SystemMessage(f"✨ Session cleared! Started new session: {new_session_id}")
|