virtuai-cli 0.8.6__tar.gz → 0.8.8__tar.gz
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.
- {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/PKG-INFO +3 -1
- {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/pyproject.toml +9 -1
- {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/__init__.py +1 -1
- {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/chat/ask.py +4 -0
- {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/chat/tui.py +4 -0
- {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/runner.py +95 -5
- {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/security.py +17 -6
- {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli.egg-info/PKG-INFO +3 -1
- {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli.egg-info/requires.txt +3 -0
- {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/README.md +0 -0
- {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/setup.cfg +0 -0
- {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/chat/__init__.py +0 -0
- {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/chat/command.py +0 -0
- {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/chat/history.py +0 -0
- {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/chat/sse.py +0 -0
- {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/chat/widgets.py +0 -0
- {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/config.py +0 -0
- {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/executor.py +0 -0
- {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/main.py +0 -0
- {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli.egg-info/SOURCES.txt +0 -0
- {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
- {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
- {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: virtuai-cli
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.8
|
|
4
4
|
Summary: Run VirtuAI deep agents on your local machine
|
|
5
5
|
Author-email: uCloudStore <lmoreno@ucloudstore.com>
|
|
6
6
|
License: Proprietary
|
|
@@ -20,6 +20,8 @@ Requires-Dist: keyring>=25.0
|
|
|
20
20
|
Requires-Dist: typer>=0.12
|
|
21
21
|
Requires-Dist: rich>=13.0
|
|
22
22
|
Requires-Dist: textual>=0.86
|
|
23
|
+
Provides-Extra: local
|
|
24
|
+
Requires-Dist: langchain-ollama>=0.2; extra == "local"
|
|
23
25
|
|
|
24
26
|
# VirtuAI CLI
|
|
25
27
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "virtuai-cli"
|
|
7
|
-
version = "0.8.
|
|
7
|
+
version = "0.8.8"
|
|
8
8
|
description = "Run VirtuAI deep agents on your local machine"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -30,6 +30,14 @@ dependencies = [
|
|
|
30
30
|
"textual>=0.86",
|
|
31
31
|
]
|
|
32
32
|
|
|
33
|
+
# Optional: local model inference. Pulls langchain-ollama (+ langchain-core) so
|
|
34
|
+
# the CLI can answer the server's model_generate frames from a locally-running
|
|
35
|
+
# Ollama. Install with: pip install 'virtuai-cli[local]'
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
local = [
|
|
38
|
+
"langchain-ollama>=0.2",
|
|
39
|
+
]
|
|
40
|
+
|
|
33
41
|
[project.urls]
|
|
34
42
|
Homepage = "https://imvituai.com"
|
|
35
43
|
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""VirtuAI local CLI."""
|
|
2
|
-
__version__ = "0.8.
|
|
2
|
+
__version__ = "0.8.7"
|
|
@@ -134,6 +134,10 @@ async def _run_ask(
|
|
|
134
134
|
err.print(f"[dim]✓ {first[0][:80] if first else ''}[/dim]")
|
|
135
135
|
else:
|
|
136
136
|
err.print("[dim]✓ done[/dim]")
|
|
137
|
+
elif etype == "notice" and mode == "stream":
|
|
138
|
+
msg = event.get("message", "")
|
|
139
|
+
color = {"warn": "yellow", "error": "red"}.get(event.get("level"), "dim")
|
|
140
|
+
err.print(f"[{color}]⚠ {msg}[/{color}]")
|
|
137
141
|
elif etype == "error":
|
|
138
142
|
msg = event.get("error") or event.get("message") or "unknown"
|
|
139
143
|
err.print(f"[red]error: {msg}[/red]")
|
|
@@ -871,6 +871,10 @@ class ChatApp(App):
|
|
|
871
871
|
"\n\n*[response truncated — try raising the agent's max_tokens]*"
|
|
872
872
|
)
|
|
873
873
|
turn._truncated_shown = True
|
|
874
|
+
elif etype == "notice":
|
|
875
|
+
msg = event.get("message") or ""
|
|
876
|
+
if msg:
|
|
877
|
+
await turn.append_token(f"\n\n*⚠ {msg}*\n\n")
|
|
874
878
|
elif etype == "error":
|
|
875
879
|
msg = event.get("error") or event.get("message") or "unknown error"
|
|
876
880
|
await turn.append_token(f"\n\n*[error: {msg}]*")
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import json
|
|
7
7
|
import logging
|
|
8
|
+
import os
|
|
8
9
|
import platform
|
|
9
10
|
import sys
|
|
10
11
|
import time
|
|
@@ -13,6 +14,9 @@ from typing import Callable, Optional
|
|
|
13
14
|
|
|
14
15
|
_AUDIT_DIR = Path.home() / ".virtuai"
|
|
15
16
|
|
|
17
|
+
# Local Ollama endpoint used to answer the server's model_generate frames.
|
|
18
|
+
OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
|
19
|
+
|
|
16
20
|
import websockets
|
|
17
21
|
from rich.console import Console
|
|
18
22
|
|
|
@@ -101,6 +105,84 @@ def _default_on_status(level: str, msg: str) -> None:
|
|
|
101
105
|
console.print(f"[{style}]{msg}[/{style}]" if style else msg)
|
|
102
106
|
|
|
103
107
|
|
|
108
|
+
async def _probe_ollama() -> dict:
|
|
109
|
+
"""Return {"reachable": bool, "models": [name, ...]} for the local Ollama.
|
|
110
|
+
|
|
111
|
+
Reported in the ready frame so the server knows up-front whether it can
|
|
112
|
+
proxy a model turn here or should fall back to a cloud model.
|
|
113
|
+
"""
|
|
114
|
+
try:
|
|
115
|
+
import httpx
|
|
116
|
+
except Exception:
|
|
117
|
+
return {"reachable": False, "models": []}
|
|
118
|
+
try:
|
|
119
|
+
async with httpx.AsyncClient(timeout=2.0) as client:
|
|
120
|
+
resp = await client.get(f"{OLLAMA_BASE_URL.rstrip('/')}/api/tags")
|
|
121
|
+
resp.raise_for_status()
|
|
122
|
+
data = resp.json()
|
|
123
|
+
models = [m.get("name", "") for m in data.get("models", []) if m.get("name")]
|
|
124
|
+
return {"reachable": True, "models": models}
|
|
125
|
+
except Exception:
|
|
126
|
+
return {"reachable": False, "models": []}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def _handle_model_generate(frame: dict, ws, on_status: StatusCallback) -> None:
|
|
130
|
+
"""Answer a server model_generate frame from the local Ollama.
|
|
131
|
+
|
|
132
|
+
Streams text deltas as model_token frames, then a terminal model_result
|
|
133
|
+
carrying tool_calls + usage. On any failure replies model_error so the
|
|
134
|
+
server can fall back to a cloud model.
|
|
135
|
+
"""
|
|
136
|
+
fid = frame.get("id")
|
|
137
|
+
|
|
138
|
+
async def _error(reason: str) -> None:
|
|
139
|
+
await ws.send(json.dumps({
|
|
140
|
+
"v": 1, "id": fid, "type": "model_error", "error": reason,
|
|
141
|
+
}))
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
from langchain_ollama import ChatOllama
|
|
145
|
+
from langchain_core.messages import messages_from_dict
|
|
146
|
+
except Exception:
|
|
147
|
+
await _error(
|
|
148
|
+
"local model support not installed — run: pip install 'virtuai-cli[local]'"
|
|
149
|
+
)
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
model = frame.get("model") or ""
|
|
153
|
+
on_status("info", f"local model: {model}")
|
|
154
|
+
try:
|
|
155
|
+
messages = messages_from_dict(frame.get("messages", []))
|
|
156
|
+
tools = frame.get("tools") or []
|
|
157
|
+
kwargs = {"model": model, "base_url": OLLAMA_BASE_URL}
|
|
158
|
+
if frame.get("temperature") is not None:
|
|
159
|
+
kwargs["temperature"] = frame["temperature"]
|
|
160
|
+
if frame.get("num_predict") is not None:
|
|
161
|
+
kwargs["num_predict"] = frame["num_predict"]
|
|
162
|
+
llm = ChatOllama(**kwargs)
|
|
163
|
+
if tools:
|
|
164
|
+
llm = llm.bind_tools(tools)
|
|
165
|
+
|
|
166
|
+
gathered = None
|
|
167
|
+
async for chunk in llm.astream(messages):
|
|
168
|
+
gathered = chunk if gathered is None else gathered + chunk
|
|
169
|
+
text = chunk.content if isinstance(chunk.content, str) else ""
|
|
170
|
+
if text:
|
|
171
|
+
await ws.send(json.dumps({
|
|
172
|
+
"v": 1, "id": fid, "type": "model_token", "content": text,
|
|
173
|
+
}))
|
|
174
|
+
|
|
175
|
+
tool_calls = list(getattr(gathered, "tool_calls", []) or []) if gathered else []
|
|
176
|
+
usage = dict(getattr(gathered, "usage_metadata", None) or {}) if gathered else {}
|
|
177
|
+
await ws.send(json.dumps({
|
|
178
|
+
"v": 1, "id": fid, "type": "model_result",
|
|
179
|
+
"tool_calls": tool_calls, "usage": usage,
|
|
180
|
+
}))
|
|
181
|
+
except Exception as exc:
|
|
182
|
+
logger.warning("model_generate failed: %s", exc)
|
|
183
|
+
await _error(str(exc))
|
|
184
|
+
|
|
185
|
+
|
|
104
186
|
async def _handle_frame(
|
|
105
187
|
frame: dict,
|
|
106
188
|
workdir: Path,
|
|
@@ -124,11 +206,16 @@ async def _handle_frame(
|
|
|
124
206
|
"workdir": str(workdir),
|
|
125
207
|
"local_skills": _read_local_skills(workdir),
|
|
126
208
|
"local_memory": _read_local_memory(workdir),
|
|
209
|
+
"ollama": await _probe_ollama(),
|
|
127
210
|
}
|
|
128
211
|
await ws.send(json.dumps(reply))
|
|
129
212
|
on_status("info", f"Connected. Workdir: {workdir}")
|
|
130
213
|
return
|
|
131
214
|
|
|
215
|
+
if ftype == "model_generate":
|
|
216
|
+
await _handle_model_generate(frame, ws, on_status)
|
|
217
|
+
return
|
|
218
|
+
|
|
132
219
|
if ftype == "ping":
|
|
133
220
|
await ws.send(json.dumps({"v": 1, "id": fid, "type": "pong", "ts": time.time()}))
|
|
134
221
|
return
|
|
@@ -176,15 +263,18 @@ async def _handle_frame(
|
|
|
176
263
|
|
|
177
264
|
try:
|
|
178
265
|
async with asyncio.timeout(timeout):
|
|
179
|
-
|
|
180
|
-
|
|
266
|
+
# Read in chunks rather than lines so partial-line output
|
|
267
|
+
# (e.g. interactive prompts like "Enter verification code: "
|
|
268
|
+
# that have no trailing newline) is forwarded immediately.
|
|
269
|
+
while True:
|
|
270
|
+
chunk_bytes = await proc.stdout.read(4096)
|
|
271
|
+
if not chunk_bytes:
|
|
272
|
+
break
|
|
273
|
+
chunk = chunk_bytes.decode("utf-8", errors="replace")
|
|
181
274
|
chunks.append(chunk)
|
|
182
275
|
total_bytes += len(chunk)
|
|
183
276
|
if on_output and fid:
|
|
184
277
|
on_output(fid, chunk)
|
|
185
|
-
# Forward chunk to server — server may show it in the agent
|
|
186
|
-
# trace or buffer it; either way the agent gets the full
|
|
187
|
-
# execute_result below.
|
|
188
278
|
try:
|
|
189
279
|
await ws.send(json.dumps({
|
|
190
280
|
"v": 1, "id": fid, "type": "execute_output",
|
|
@@ -65,24 +65,35 @@ _PASS_THROUGH_ENV = {
|
|
|
65
65
|
# Some build tools need these
|
|
66
66
|
"USER", "LOGNAME",
|
|
67
67
|
"XDG_RUNTIME_DIR",
|
|
68
|
+
# GCP / Cloud SDK — needed for gcloud auth and ADC to work
|
|
69
|
+
"CLOUDSDK_CONFIG",
|
|
70
|
+
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
71
|
+
"GOOGLE_CLOUD_PROJECT",
|
|
72
|
+
"GCLOUD_PROJECT",
|
|
73
|
+
"GOOGLE_CLOUD_QUOTA_PROJECT",
|
|
68
74
|
}
|
|
69
75
|
|
|
70
|
-
|
|
76
|
+
# Env var prefixes whose entire namespace is passed through
|
|
77
|
+
_PASS_THROUGH_PREFIXES = ("VIRTUAI_", "GOOGLE_", "GCLOUD_", "CLOUDSDK_", "AWS_", "AZURE_")
|
|
71
78
|
|
|
72
79
|
|
|
73
80
|
def scrub_env(workdir: Path, extra_allow: list[str] | None = None) -> dict[str, str]:
|
|
74
|
-
"""Build a clean environment: allowlist +
|
|
81
|
+
"""Build a clean environment: allowlist + cloud-tool prefixes + user opt-ins.
|
|
82
|
+
|
|
83
|
+
HOME is intentionally kept as-is so local tools (gcloud, aws, git, npm …)
|
|
84
|
+
can find their credentials and config in the user's real home directory.
|
|
85
|
+
The agent runs on the user's own machine with their explicit permission,
|
|
86
|
+
so restricting HOME would only break authentication without adding security.
|
|
87
|
+
REAL_HOME is also set as a convenience alias for scripts that need it.
|
|
88
|
+
"""
|
|
75
89
|
allowed = set(_PASS_THROUGH_ENV)
|
|
76
90
|
if extra_allow:
|
|
77
91
|
allowed.update(extra_allow)
|
|
78
92
|
|
|
79
93
|
env: dict[str, str] = {}
|
|
80
94
|
for key, value in os.environ.items():
|
|
81
|
-
if key in allowed or key.startswith(
|
|
95
|
+
if key in allowed or any(key.startswith(p) for p in _PASS_THROUGH_PREFIXES):
|
|
82
96
|
env[key] = value
|
|
83
97
|
|
|
84
|
-
# Keep REAL_HOME so the agent can resolve user paths, but override HOME
|
|
85
|
-
# so tools that write to ~/.cache/~/.config don't escape the workdir
|
|
86
98
|
env["REAL_HOME"] = env.get("HOME", os.path.expanduser("~"))
|
|
87
|
-
env["HOME"] = str(workdir)
|
|
88
99
|
return env
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: virtuai-cli
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.8
|
|
4
4
|
Summary: Run VirtuAI deep agents on your local machine
|
|
5
5
|
Author-email: uCloudStore <lmoreno@ucloudstore.com>
|
|
6
6
|
License: Proprietary
|
|
@@ -20,6 +20,8 @@ Requires-Dist: keyring>=25.0
|
|
|
20
20
|
Requires-Dist: typer>=0.12
|
|
21
21
|
Requires-Dist: rich>=13.0
|
|
22
22
|
Requires-Dist: textual>=0.86
|
|
23
|
+
Provides-Extra: local
|
|
24
|
+
Requires-Dist: langchain-ollama>=0.2; extra == "local"
|
|
23
25
|
|
|
24
26
|
# VirtuAI CLI
|
|
25
27
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|