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.
Files changed (23) hide show
  1. {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/PKG-INFO +3 -1
  2. {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/pyproject.toml +9 -1
  3. {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/__init__.py +1 -1
  4. {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/chat/ask.py +4 -0
  5. {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/chat/tui.py +4 -0
  6. {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/runner.py +95 -5
  7. {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/security.py +17 -6
  8. {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli.egg-info/PKG-INFO +3 -1
  9. {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli.egg-info/requires.txt +3 -0
  10. {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/README.md +0 -0
  11. {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/setup.cfg +0 -0
  12. {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/chat/__init__.py +0 -0
  13. {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/chat/command.py +0 -0
  14. {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/chat/history.py +0 -0
  15. {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/chat/sse.py +0 -0
  16. {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/chat/widgets.py +0 -0
  17. {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/config.py +0 -0
  18. {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/executor.py +0 -0
  19. {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli/main.py +0 -0
  20. {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli.egg-info/SOURCES.txt +0 -0
  21. {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
  22. {virtuai_cli-0.8.6 → virtuai_cli-0.8.8}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
  23. {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.6
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.6"
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.6"
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
- async for raw_line in proc.stdout:
180
- chunk = raw_line.decode("utf-8", errors="replace")
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
- _VIRTUAI_ENV_PREFIX = "VIRTUAI_"
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 + VIRTUAI_ vars + user opt-ins."""
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(_VIRTUAI_ENV_PREFIX):
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.6
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
 
@@ -6,3 +6,6 @@ keyring>=25.0
6
6
  typer>=0.12
7
7
  rich>=13.0
8
8
  textual>=0.86
9
+
10
+ [local]
11
+ langchain-ollama>=0.2
File without changes
File without changes