visualagents 0.1.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.
- visualagents/__init__.py +20 -0
- visualagents/__main__.py +3 -0
- visualagents/_autoinstrument/__init__.py +0 -0
- visualagents/_autoinstrument/sitecustomize.py +40 -0
- visualagents/_autoinstrument/visualagents_runtime.py +864 -0
- visualagents/_hook/visualagents_boot.py +42 -0
- visualagents/blueprint.py +276 -0
- visualagents/cli.py +726 -0
- visualagents/collector.py +146 -0
- visualagents/demo/__init__.py +0 -0
- visualagents/demo/blueprint.json +68 -0
- visualagents/demo/research_crew.py +146 -0
- visualagents/detect.py +337 -0
- visualagents/home.py +139 -0
- visualagents/hooks.py +63 -0
- visualagents/hub.py +143 -0
- visualagents/launcher.py +87 -0
- visualagents/llm.py +314 -0
- visualagents/narrator.py +58 -0
- visualagents/patterns.py +81 -0
- visualagents/projects.py +140 -0
- visualagents/runner.py +355 -0
- visualagents/scanner.py +91 -0
- visualagents/sdk.py +18 -0
- visualagents/server.py +209 -0
- visualagents/static_scan.py +677 -0
- visualagents/termui.py +94 -0
- visualagents/tracker.py +501 -0
- visualagents/tui.py +240 -0
- visualagents/web/app.js +1753 -0
- visualagents/web/index.html +117 -0
- visualagents/web/styles.css +551 -0
- visualagents/wizard.py +331 -0
- visualagents-0.1.0.dist-info/METADATA +438 -0
- visualagents-0.1.0.dist-info/RECORD +38 -0
- visualagents-0.1.0.dist-info/WHEEL +4 -0
- visualagents-0.1.0.dist-info/entry_points.txt +3 -0
- visualagents-0.1.0.dist-info/licenses/LICENSE +21 -0
visualagents/launcher.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Lets the dashboard start the project (the "▶ Start" button) when nothing is running yet.
|
|
2
|
+
|
|
3
|
+
The agent runs in the terminal VisualAgents was started from, so it can ask its questions there.
|
|
4
|
+
If the saved start command contains `{task}` (e.g. `python main.py "{task}"`), the dashboard also
|
|
5
|
+
shows a box to type the task in.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shlex
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from . import detect, termui
|
|
17
|
+
from .hub import Hub
|
|
18
|
+
|
|
19
|
+
PLACEHOLDERS = ("{task}", "{prompt}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Launcher:
|
|
23
|
+
def __init__(self, project: Path, hub: Hub, server_url: str, *, instrument: bool = True,
|
|
24
|
+
fixed: detect.Candidate | None = None):
|
|
25
|
+
self.project = Path(project).resolve()
|
|
26
|
+
self.fixed = fixed # an exact command given on the command line wins over saved / detected ones
|
|
27
|
+
self.hub = hub
|
|
28
|
+
self.server_url = server_url
|
|
29
|
+
self.instrument = instrument
|
|
30
|
+
self.thread: threading.Thread | None = None
|
|
31
|
+
self.lock = threading.Lock()
|
|
32
|
+
hub.launcher = self
|
|
33
|
+
|
|
34
|
+
def candidate(self) -> detect.Candidate | None:
|
|
35
|
+
return self.fixed or detect.load_saved(self.project) or next(iter(detect.detect(self.project)), None)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def running(self) -> bool:
|
|
39
|
+
return self.thread is not None and self.thread.is_alive()
|
|
40
|
+
|
|
41
|
+
def info(self) -> dict[str, Any]:
|
|
42
|
+
cand = self.candidate()
|
|
43
|
+
if cand is None:
|
|
44
|
+
return {"command": None, "running": self.running,
|
|
45
|
+
"hint": f"visualagents run {self.project} -- <the command that starts it>"}
|
|
46
|
+
return {"command": cand.label(), "running": self.running,
|
|
47
|
+
"takes_task": any(ph in tok for tok in cand.command for ph in PLACEHOLDERS)}
|
|
48
|
+
|
|
49
|
+
def start(self, task: str | None = None) -> tuple[bool, str]:
|
|
50
|
+
with self.lock:
|
|
51
|
+
if self.running or self.hub.tracker.run["status"] == "running":
|
|
52
|
+
return False, "It's already running."
|
|
53
|
+
cand = template = self.candidate()
|
|
54
|
+
if cand is None:
|
|
55
|
+
return False, "VisualAgents doesn't know how to start this project yet."
|
|
56
|
+
argv = list(cand.command)
|
|
57
|
+
if any(ph in tok for tok in argv for ph in PLACEHOLDERS):
|
|
58
|
+
if not task:
|
|
59
|
+
return False, "Type what the agent should do first."
|
|
60
|
+
for ph in PLACEHOLDERS:
|
|
61
|
+
argv = [tok.replace(ph, task) for tok in argv]
|
|
62
|
+
cand = detect.Candidate(argv, cand.cwd, cand.source)
|
|
63
|
+
if self.fixed is None and detect.load_saved(self.project) is None:
|
|
64
|
+
detect.save_choice(self.project, template) # remember the command (with its {task} slot)
|
|
65
|
+
command, notes = detect.resolve(self.project, cand)
|
|
66
|
+
cwd = (self.project / cand.cwd).resolve()
|
|
67
|
+
self.thread = threading.Thread(target=self._run, args=(command, cwd, notes), name="visualagents-launch",
|
|
68
|
+
daemon=True)
|
|
69
|
+
self.thread.start()
|
|
70
|
+
return True, "Starting in your terminal - give it its task there."
|
|
71
|
+
|
|
72
|
+
def _run(self, command: list[str], cwd: Path, notes: list[str]) -> None:
|
|
73
|
+
from .runner import follow_new_logs, run_command
|
|
74
|
+
|
|
75
|
+
termui.info(f"Starting from the dashboard: {shlex.join(command)}")
|
|
76
|
+
for note in notes:
|
|
77
|
+
termui.info(note)
|
|
78
|
+
termui.info("The map comes alive as it works - answer its questions here in the terminal.")
|
|
79
|
+
stop = threading.Event()
|
|
80
|
+
threading.Thread(target=follow_new_logs, args=(self.project, self.hub), daemon=True,
|
|
81
|
+
kwargs={"since": time.time() - 1, "stop": stop}).start()
|
|
82
|
+
try:
|
|
83
|
+
code = run_command(command, self.hub, cwd=cwd, server_url=self.server_url, instrument=self.instrument)
|
|
84
|
+
termui.info(f"{self.project.name} {'finished' if code == 0 else f'exited with code {code}'}. "
|
|
85
|
+
"Press ▶ in the dashboard to run it again, or Ctrl+C here to close.")
|
|
86
|
+
finally:
|
|
87
|
+
stop.set()
|
visualagents/llm.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""LLM access for VisualAgents: codebase -> blueprint, and (optionally) live narration.
|
|
2
|
+
|
|
3
|
+
Providers (pick with --provider / --model, or VISUALAGENTS_PROVIDER / VISUALAGENTS_MODEL):
|
|
4
|
+
- anthropic: Claude via the official `anthropic` SDK (installed by default)
|
|
5
|
+
- openai: OpenAI or any OpenAI-compatible server (Ollama, LM Studio, vLLM via OPENAI_BASE_URL);
|
|
6
|
+
`pip install "visualagents[openai]"`
|
|
7
|
+
- google: Gemini via the official `google-genai` SDK (GEMINI_API_KEY, or Vertex AI);
|
|
8
|
+
`pip install "visualagents[google]"`
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
PROVIDERS = ("anthropic", "openai", "google")
|
|
19
|
+
# Default model for reading the codebase (quality matters) and for --smart narration (runs every few seconds).
|
|
20
|
+
DEFAULT_MODELS = {"anthropic": "claude-opus-5-5", "openai": "gpt-5", "google": "gemini-pro-latest"}
|
|
21
|
+
NARRATE_MODELS = {"anthropic": "claude-opus-5-5", "openai": "gpt-5", "google": "gemini-flash-lite-latest"}
|
|
22
|
+
PROVIDER_LABELS = {"anthropic": "Anthropic Claude", "openai": "OpenAI / compatible", "google": "Google Gemini"}
|
|
23
|
+
KEY_VARS = {
|
|
24
|
+
"anthropic": ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"),
|
|
25
|
+
"openai": ("OPENAI_API_KEY", "OPENAI_BASE_URL"),
|
|
26
|
+
"google": ("GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GENAI_USE_VERTEXAI"),
|
|
27
|
+
}
|
|
28
|
+
_DOTENV_VARS = {
|
|
29
|
+
*(v for vs in KEY_VARS.values() for v in vs), "ANTHROPIC_BASE_URL", "GOOGLE_CLOUD_PROJECT",
|
|
30
|
+
"GOOGLE_CLOUD_LOCATION", "VISUALAGENTS_PROVIDER", "VISUALAGENTS_MODEL", "VISUALAGENTS_NARRATE_MODEL",
|
|
31
|
+
}
|
|
32
|
+
# Models that accept the server-side refusal fallback (beta server-side-fallback-2026-07-01).
|
|
33
|
+
_FALLBACK_MODELS = ("claude-opus-5-5", "claude-opus-5", "claude-fable-5-1", "claude-sonnet-5-5")
|
|
34
|
+
_NO_EFFORT = ("claude-3", "claude-haiku-4", "claude-sonnet-4-5", "claude-sonnet-4-0", "claude-opus-4-0", "claude-opus-4-1")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LLMError(RuntimeError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_dotenv_keys(project: Path) -> None:
|
|
42
|
+
"""Pick up provider keys and model choices from the project's .env if they aren't already set."""
|
|
43
|
+
for candidate in (Path(project) / ".env", Path.cwd() / ".env"):
|
|
44
|
+
try:
|
|
45
|
+
lines = candidate.read_text(encoding="utf-8").splitlines()
|
|
46
|
+
except OSError:
|
|
47
|
+
continue
|
|
48
|
+
for line in lines:
|
|
49
|
+
line = line.strip()
|
|
50
|
+
if line.startswith("export "):
|
|
51
|
+
line = line[7:]
|
|
52
|
+
if "=" not in line or line.startswith("#"):
|
|
53
|
+
continue
|
|
54
|
+
k, v = line.split("=", 1)
|
|
55
|
+
k, v = k.strip(), v.strip().strip("'\"")
|
|
56
|
+
if k in _DOTENV_VARS and v and not os.environ.get(k):
|
|
57
|
+
os.environ[k] = v
|
|
58
|
+
from . import home
|
|
59
|
+
|
|
60
|
+
home.apply_saved() # keys and choices saved by the setup wizard come last
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def has_credentials(provider: str) -> bool:
|
|
64
|
+
if any(os.environ.get(v) for v in KEY_VARS.get(provider, ())):
|
|
65
|
+
return True
|
|
66
|
+
# `ant auth login` stores a profile the Anthropic SDK picks up without any env var
|
|
67
|
+
return provider == "anthropic" and bool(
|
|
68
|
+
os.environ.get("ANTHROPIC_PROFILE") or (Path.home() / ".config" / "anthropic").is_dir()
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def detect_provider(preferred: str = "auto") -> str | None:
|
|
73
|
+
"""--provider wins, then VISUALAGENTS_PROVIDER, then the first provider with credentials."""
|
|
74
|
+
for choice in (preferred, os.environ.get("VISUALAGENTS_PROVIDER", "")):
|
|
75
|
+
if choice and choice != "auto":
|
|
76
|
+
if choice not in PROVIDERS:
|
|
77
|
+
raise LLMError(f"unknown provider {choice!r} - use one of: {', '.join(PROVIDERS)}")
|
|
78
|
+
return choice
|
|
79
|
+
for provider in ("anthropic", "openai", "google"):
|
|
80
|
+
if any(os.environ.get(v) for v in KEY_VARS[provider]):
|
|
81
|
+
return provider
|
|
82
|
+
return "anthropic" if has_credentials("anthropic") else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def default_model(provider: str, purpose: str = "scan") -> str:
|
|
86
|
+
"""--model is handled by callers; this is the env override or the provider's default."""
|
|
87
|
+
if purpose == "narrate":
|
|
88
|
+
return os.environ.get("VISUALAGENTS_NARRATE_MODEL") or NARRATE_MODELS.get(provider, "")
|
|
89
|
+
return os.environ.get("VISUALAGENTS_MODEL") or DEFAULT_MODELS.get(provider, "")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def complete_json(
|
|
93
|
+
*,
|
|
94
|
+
provider: str,
|
|
95
|
+
model: str | None,
|
|
96
|
+
system: str,
|
|
97
|
+
prompt: str,
|
|
98
|
+
schema: dict[str, Any],
|
|
99
|
+
effort: str = "high",
|
|
100
|
+
max_tokens: int = 64000,
|
|
101
|
+
) -> dict[str, Any]:
|
|
102
|
+
model = model or default_model(provider)
|
|
103
|
+
if provider == "anthropic":
|
|
104
|
+
return _anthropic_json(model, system, prompt, schema, effort, max_tokens)
|
|
105
|
+
if provider == "openai":
|
|
106
|
+
return _openai_json(model, system, prompt, schema)
|
|
107
|
+
if provider == "google":
|
|
108
|
+
return _google_json(model, system, prompt, schema, max_tokens)
|
|
109
|
+
raise LLMError(f"unknown provider {provider!r} - use one of: {', '.join(PROVIDERS)}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _anthropic_json(model: str, system: str, prompt: str, schema: dict[str, Any], effort: str, max_tokens: int) -> dict[str, Any]:
|
|
113
|
+
try:
|
|
114
|
+
import anthropic
|
|
115
|
+
except ImportError as exc: # pragma: no cover - anthropic is a hard dependency
|
|
116
|
+
raise LLMError("the 'anthropic' package is missing: pip install anthropic") from exc
|
|
117
|
+
|
|
118
|
+
client = anthropic.Anthropic()
|
|
119
|
+
output_config: dict[str, Any] = {"format": {"type": "json_schema", "schema": schema}}
|
|
120
|
+
if not model.startswith(_NO_EFFORT):
|
|
121
|
+
output_config["effort"] = effort
|
|
122
|
+
kwargs: dict[str, Any] = {
|
|
123
|
+
"model": model,
|
|
124
|
+
"max_tokens": max_tokens,
|
|
125
|
+
"system": system,
|
|
126
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
127
|
+
"output_config": output_config,
|
|
128
|
+
}
|
|
129
|
+
try:
|
|
130
|
+
if model.startswith(_FALLBACK_MODELS):
|
|
131
|
+
with client.beta.messages.stream(
|
|
132
|
+
**kwargs, betas=["server-side-fallback-2026-07-01"], fallbacks="default"
|
|
133
|
+
) as stream:
|
|
134
|
+
message = stream.get_final_message()
|
|
135
|
+
else:
|
|
136
|
+
with client.messages.stream(**kwargs) as stream:
|
|
137
|
+
message = stream.get_final_message()
|
|
138
|
+
except anthropic.AuthenticationError as exc:
|
|
139
|
+
raise LLMError("Anthropic rejected the credentials - set ANTHROPIC_API_KEY") from exc
|
|
140
|
+
except anthropic.NotFoundError as exc:
|
|
141
|
+
raise LLMError(f"model {model!r} not found - pass --model") from exc
|
|
142
|
+
except anthropic.RateLimitError as exc:
|
|
143
|
+
raise LLMError("Anthropic rate limit hit - try again shortly") from exc
|
|
144
|
+
except anthropic.APIStatusError as exc:
|
|
145
|
+
raise LLMError(f"Anthropic API error {exc.status_code}: {exc.message}") from exc
|
|
146
|
+
except anthropic.APIConnectionError as exc:
|
|
147
|
+
raise LLMError("could not reach the Anthropic API - check your network") from exc
|
|
148
|
+
|
|
149
|
+
if message.stop_reason == "refusal":
|
|
150
|
+
raise LLMError("the model declined this request")
|
|
151
|
+
if message.stop_reason == "max_tokens":
|
|
152
|
+
raise LLMError("the model ran out of output tokens - try a smaller --max-chars")
|
|
153
|
+
text = next((b.text for b in message.content if b.type == "text"), "")
|
|
154
|
+
try:
|
|
155
|
+
return json.loads(text)
|
|
156
|
+
except ValueError as exc:
|
|
157
|
+
raise LLMError("the model returned invalid JSON") from exc
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _openai_json(model: str, system: str, prompt: str, schema: dict[str, Any]) -> dict[str, Any]:
|
|
161
|
+
try:
|
|
162
|
+
import openai
|
|
163
|
+
except ImportError as exc:
|
|
164
|
+
raise LLMError('OpenAI support needs: pip install "visualagents[openai]"') from exc
|
|
165
|
+
|
|
166
|
+
client = openai.OpenAI()
|
|
167
|
+
sys_prompt = (
|
|
168
|
+
f"{system}\n\nRespond with a single JSON object that matches this JSON schema exactly:\n"
|
|
169
|
+
f"{json.dumps(schema)}"
|
|
170
|
+
)
|
|
171
|
+
try:
|
|
172
|
+
resp = client.chat.completions.create(
|
|
173
|
+
model=model,
|
|
174
|
+
messages=[{"role": "system", "content": sys_prompt}, {"role": "user", "content": prompt}],
|
|
175
|
+
response_format={"type": "json_object"},
|
|
176
|
+
)
|
|
177
|
+
except openai.AuthenticationError as exc:
|
|
178
|
+
raise LLMError("OpenAI rejected the credentials - set OPENAI_API_KEY") from exc
|
|
179
|
+
except openai.NotFoundError as exc:
|
|
180
|
+
raise LLMError(f"model {model!r} not found - pass --model") from exc
|
|
181
|
+
except openai.RateLimitError as exc:
|
|
182
|
+
raise LLMError("OpenAI rate limit hit - try again shortly") from exc
|
|
183
|
+
except openai.APIStatusError as exc:
|
|
184
|
+
raise LLMError(f"OpenAI API error {exc.status_code}: {exc.message}") from exc
|
|
185
|
+
except openai.APIConnectionError as exc:
|
|
186
|
+
raise LLMError("could not reach the OpenAI-compatible endpoint") from exc
|
|
187
|
+
|
|
188
|
+
choice = resp.choices[0]
|
|
189
|
+
if getattr(choice.message, "refusal", None):
|
|
190
|
+
raise LLMError("the model declined this request")
|
|
191
|
+
try:
|
|
192
|
+
return json.loads(choice.message.content or "")
|
|
193
|
+
except ValueError as exc:
|
|
194
|
+
raise LLMError("the model returned invalid JSON") from exc
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _google_json(model: str, system: str, prompt: str, schema: dict[str, Any], max_tokens: int) -> dict[str, Any]:
|
|
198
|
+
try:
|
|
199
|
+
from google import genai
|
|
200
|
+
from google.genai import errors, types
|
|
201
|
+
except ImportError as exc:
|
|
202
|
+
raise LLMError('Gemini support needs: pip install "visualagents[google]"') from exc
|
|
203
|
+
|
|
204
|
+
# Reads GEMINI_API_KEY / GOOGLE_API_KEY, or Vertex AI via GOOGLE_GENAI_USE_VERTEXAI + project/location.
|
|
205
|
+
# Retry rate limits and "high demand" 5xx responses with backoff, like the Anthropic/OpenAI SDKs do.
|
|
206
|
+
client = genai.Client(http_options=types.HttpOptions(retry_options=types.HttpRetryOptions(
|
|
207
|
+
attempts=4, initial_delay=2.0, max_delay=20.0, http_status_codes=[408, 429, 500, 502, 503, 504],
|
|
208
|
+
)))
|
|
209
|
+
config = types.GenerateContentConfig(
|
|
210
|
+
system_instruction=system,
|
|
211
|
+
response_mime_type="application/json",
|
|
212
|
+
response_json_schema=schema,
|
|
213
|
+
max_output_tokens=min(max_tokens, 65536),
|
|
214
|
+
)
|
|
215
|
+
try:
|
|
216
|
+
resp = client.models.generate_content(model=model, contents=prompt, config=config)
|
|
217
|
+
except errors.ClientError as exc:
|
|
218
|
+
if exc.code in (401, 403):
|
|
219
|
+
raise LLMError("Google rejected the credentials - set GEMINI_API_KEY") from exc
|
|
220
|
+
if exc.code == 404:
|
|
221
|
+
raise LLMError(f"model {model!r} not found - see `visualagents models --provider google`") from exc
|
|
222
|
+
if exc.code == 429:
|
|
223
|
+
raise LLMError("Gemini rate limit or quota hit - try again shortly") from exc
|
|
224
|
+
raise LLMError(f"Gemini API error {exc.code}: {exc.message}") from exc
|
|
225
|
+
except errors.ServerError as exc:
|
|
226
|
+
raise LLMError(f"Gemini is unavailable right now ({exc.code}: {exc.message}) - retry, or pick another "
|
|
227
|
+
f"model with --model (see `visualagents models --provider google`)") from exc
|
|
228
|
+
except errors.APIError as exc:
|
|
229
|
+
raise LLMError(f"Gemini API error {exc.code}: {exc.message}") from exc
|
|
230
|
+
except OSError as exc:
|
|
231
|
+
raise LLMError("could not reach the Gemini API - check your network") from exc
|
|
232
|
+
|
|
233
|
+
feedback = getattr(resp, "prompt_feedback", None)
|
|
234
|
+
if feedback is not None and getattr(feedback, "block_reason", None):
|
|
235
|
+
raise LLMError("the model declined this request")
|
|
236
|
+
candidate = resp.candidates[0] if resp.candidates else None
|
|
237
|
+
finish = str(getattr(candidate, "finish_reason", "") or "")
|
|
238
|
+
if "MAX_TOKENS" in finish:
|
|
239
|
+
raise LLMError("the model ran out of output tokens - try a smaller --max-chars")
|
|
240
|
+
if any(r in finish for r in ("SAFETY", "PROHIBITED", "BLOCKLIST", "SPII", "RECITATION")):
|
|
241
|
+
raise LLMError("the model declined this request")
|
|
242
|
+
try:
|
|
243
|
+
return json.loads(resp.text or "")
|
|
244
|
+
except ValueError as exc:
|
|
245
|
+
raise LLMError("the model returned invalid JSON") from exc
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def list_models(provider: str) -> list[str]:
|
|
249
|
+
"""Model ids the configured account can use (best effort; listing models is free)."""
|
|
250
|
+
try:
|
|
251
|
+
if provider == "anthropic":
|
|
252
|
+
import anthropic
|
|
253
|
+
|
|
254
|
+
return [m.id for m in anthropic.Anthropic().models.list()]
|
|
255
|
+
if provider == "openai":
|
|
256
|
+
import openai
|
|
257
|
+
|
|
258
|
+
return sorted(m.id for m in openai.OpenAI().models.list())
|
|
259
|
+
if provider == "google":
|
|
260
|
+
from google import genai
|
|
261
|
+
|
|
262
|
+
client = genai.Client() # keep a reference: the pager needs the client open
|
|
263
|
+
out = []
|
|
264
|
+
for m in client.models.list():
|
|
265
|
+
actions = getattr(m, "supported_actions", None) or []
|
|
266
|
+
if "generateContent" in actions and "gemini" in (m.name or ""):
|
|
267
|
+
out.append(m.name.split("/")[-1])
|
|
268
|
+
return out
|
|
269
|
+
except ImportError as exc:
|
|
270
|
+
raise LLMError(f'the {provider} SDK is not installed: pip install "visualagents[{provider}]"') from exc
|
|
271
|
+
except Exception as exc: # each SDK has its own error types; listing is informational only
|
|
272
|
+
raise LLMError(f"could not list {provider} models: {exc}") from exc
|
|
273
|
+
raise LLMError(f"unknown provider {provider!r}")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# -- prompts ----------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
SCAN_SYSTEM = """You are the code analyst inside VisualAgents, a tool that draws a live map of multi-agent AI \
|
|
279
|
+
applications. You read a project's source and return its blueprint: which agents exist, what each one does, \
|
|
280
|
+
and how work flows between them. The blueprint is rendered as a node graph, and later used to recognise each \
|
|
281
|
+
agent in the program's printed chain-of-thought logs.
|
|
282
|
+
|
|
283
|
+
Guidelines:
|
|
284
|
+
- An agent is a distinct LLM-driven worker (own prompt / role / tools) or a graph node that performs a \
|
|
285
|
+
distinct reasoning step. Tools, LLM clients, routers that make no LLM call, and plain helpers are not agents; \
|
|
286
|
+
list a tool under the agent that calls it.
|
|
287
|
+
- A single-agent program yields one agent. Do not invent agents the code does not define.
|
|
288
|
+
- ids are stable snake_case names taken from the code: LangGraph node names, CrewAI agents.yaml keys, \
|
|
289
|
+
Agent(name=...) values, AutoGen agent names.
|
|
290
|
+
- aliases: every string that may identify the agent in logs - node name, variable names, class name, the \
|
|
291
|
+
name= value, CrewAI role text with template variables removed. Always include the name of the class (or \
|
|
292
|
+
function) that actually makes the LLM calls for this agent, e.g. the class holding the OpenAI client.
|
|
293
|
+
- keywords: 3-8 distinctive words or short phrases this agent's own output is likely to contain.
|
|
294
|
+
- edges: handoffs / control flow (who passes work to whom). conditional=true when a router, supervisor or \
|
|
295
|
+
LLM decision picks the next agent. Include loops such as critic -> writer.
|
|
296
|
+
- entry: the agents that receive the user's query first. exits: the agents that produce the final answer.
|
|
297
|
+
- steps: the general blueprint a typical query moves through, as 2-8 ordered phases, each naming its agents.
|
|
298
|
+
- file and line: where the agent is defined (use the line numbers shown in the source). line=0 if unsure.
|
|
299
|
+
- Static-analysis hints are provided; trust them for names, correct them when the code says otherwise."""
|
|
300
|
+
|
|
301
|
+
NARRATE_SYSTEM = """You watch the live console output of a multi-agent AI program for VisualAgents. Given the \
|
|
302
|
+
program's agents and its most recent log lines, decide which agent is working right now and describe what it \
|
|
303
|
+
is doing. Use an agent id from the list, or an empty string when the log does not say. activity is a \
|
|
304
|
+
present-tense phrase of at most 12 words, e.g. "Searching the web for 2024 battery benchmarks"."""
|
|
305
|
+
|
|
306
|
+
NARRATE_SCHEMA = {
|
|
307
|
+
"type": "object",
|
|
308
|
+
"properties": {
|
|
309
|
+
"agent": {"type": "string"},
|
|
310
|
+
"activity": {"type": "string"},
|
|
311
|
+
},
|
|
312
|
+
"required": ["agent", "activity"],
|
|
313
|
+
"additionalProperties": False,
|
|
314
|
+
}
|
visualagents/narrator.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Smart mode: periodically asks an LLM which agent is working and what it is doing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from . import llm
|
|
9
|
+
from .hub import Hub
|
|
10
|
+
from .patterns import clean
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Narrator:
|
|
14
|
+
def __init__(self, hub: Hub, *, provider: str, model: str | None, interval: float = 4.0):
|
|
15
|
+
self.hub = hub
|
|
16
|
+
self.provider = provider
|
|
17
|
+
self.model = model
|
|
18
|
+
self.interval = interval
|
|
19
|
+
self.stop = threading.Event()
|
|
20
|
+
self.thread = threading.Thread(target=self._loop, name="visualagents-narrator", daemon=True)
|
|
21
|
+
self._seen = 0
|
|
22
|
+
self._failures = 0
|
|
23
|
+
|
|
24
|
+
def start(self) -> "Narrator":
|
|
25
|
+
self.thread.start()
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
def _loop(self) -> None:
|
|
29
|
+
agents = "\n".join(
|
|
30
|
+
f"- {a['id']}: {a['name']} - {a['role']}" for a in self.hub.blueprint["agents"]
|
|
31
|
+
) or "- (no agents in the blueprint)"
|
|
32
|
+
while not self.stop.wait(self.interval * (1 + min(self._failures, 5))):
|
|
33
|
+
if self.hub.lines_seen == self._seen:
|
|
34
|
+
continue
|
|
35
|
+
self._seen = self.hub.lines_seen
|
|
36
|
+
lines = [clean(x) for x in list(self.hub.recent_lines)[-40:]]
|
|
37
|
+
lines = [x for x in lines if x.strip()]
|
|
38
|
+
if not lines:
|
|
39
|
+
continue
|
|
40
|
+
active = self.hub.tracker.active or "none yet"
|
|
41
|
+
prompt = (
|
|
42
|
+
f"Agents:\n{agents}\n\nCurrently highlighted agent: {active}\n\n"
|
|
43
|
+
"Most recent log lines (oldest first):\n<log>\n" + "\n".join(lines) + "\n</log>"
|
|
44
|
+
)
|
|
45
|
+
try:
|
|
46
|
+
started = time.time()
|
|
47
|
+
result = llm.complete_json(
|
|
48
|
+
provider=self.provider, model=self.model, system=llm.NARRATE_SYSTEM, prompt=prompt,
|
|
49
|
+
schema=llm.NARRATE_SCHEMA, effort="low", max_tokens=4000,
|
|
50
|
+
)
|
|
51
|
+
self._failures = 0
|
|
52
|
+
self.hub.narrate(result.get("agent") or None, str(result.get("activity") or "").strip())
|
|
53
|
+
if time.time() - started > self.interval * 3:
|
|
54
|
+
self.interval *= 1.5
|
|
55
|
+
except llm.LLMError as exc:
|
|
56
|
+
self._failures += 1
|
|
57
|
+
if self._failures in (1, 5):
|
|
58
|
+
self.hub.system(f"Smart narration paused: {exc}")
|
visualagents/patterns.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Log-line patterns used to attribute chain-of-thought output to agents.
|
|
2
|
+
|
|
3
|
+
Add a pattern here to teach VisualAgents a new framework's console format. Agent-switch
|
|
4
|
+
patterns capture the agent in a group named ``agent``; kind patterns classify the line.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import unicodedata
|
|
11
|
+
|
|
12
|
+
MARKER = "@@visualagents "
|
|
13
|
+
|
|
14
|
+
ANSI = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])")
|
|
15
|
+
BOX = "│┃|╭╮╰╯─━┌┐└┘├┤┬┴┼║═╔╗╚╝▏▕"
|
|
16
|
+
BOX_EDGES = re.compile(f"^[\\s{re.escape(BOX)}]+|[\\s{re.escape(BOX)}]+$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def clean(raw: str) -> str:
|
|
20
|
+
"""Strip ANSI codes, carriage-return overwrites and box-drawing borders (rich panels)."""
|
|
21
|
+
line = ANSI.sub("", raw.rstrip("\n"))
|
|
22
|
+
if "\r" in line:
|
|
23
|
+
parts = [p for p in line.split("\r") if p.strip()]
|
|
24
|
+
line = parts[-1] if parts else ""
|
|
25
|
+
line = BOX_EDGES.sub("", line)
|
|
26
|
+
return line.replace("\t", " ")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def strip_symbols(text: str) -> str:
|
|
30
|
+
"""Drop leading emoji / pictographs so '🔧 Using Tool: x' reads as 'Using Tool: x'."""
|
|
31
|
+
i = 0
|
|
32
|
+
while i < len(text) and (text[i].isspace() or text[i] == "️" or unicodedata.category(text[i]) in {"So", "Sk", "Cf", "Cn"}):
|
|
33
|
+
i += 1
|
|
34
|
+
return text[i:]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# (pattern, strength, kind). strength "strong": an unknown agent name creates a runtime agent;
|
|
38
|
+
# "weak": only applies when the name resolves to a known agent.
|
|
39
|
+
SWITCH_PATTERNS: list[tuple[re.Pattern[str], str, str]] = [
|
|
40
|
+
# CrewAI: "# Agent: Senior Researcher", rich panel "Agent: X", legacy "Working Agent: X"
|
|
41
|
+
(re.compile(r"^(?:#+\s*)?Agent(?:\s+Name)?\s*:\s*(?P<agent>.{2,80}?)\s*$"), "strong", "status"),
|
|
42
|
+
(re.compile(r"Working Agent:\s*(?P<agent>.{2,80}?)\s*$"), "strong", "status"),
|
|
43
|
+
# AutoGen 0.2: "researcher (to writer):"
|
|
44
|
+
(re.compile(r"^(?P<agent>[\w\-. ]{1,40}?)\s+\(to\s+(?P<to>[\w\-. ]{1,40})\)\s*:?\s*$"), "strong", "message"),
|
|
45
|
+
# AutoGen 0.4 console: "---------- TextMessage (researcher) ----------" / "---------- researcher ----------"
|
|
46
|
+
(re.compile(r"^-{4,}\s*(?:\w*(?:Message|Event)\s*)?\(?(?P<agent>[\w\-.]+)\)?\s*-{4,}\s*$"), "strong", "message"),
|
|
47
|
+
# LangGraph: "Update from node researcher:", message pretty_print "Name: researcher", stream dicts
|
|
48
|
+
(re.compile(r"^Update from (?:node|subgraph)\s+(?P<agent>[\w\-.]+)"), "strong", "status"),
|
|
49
|
+
(re.compile(r"^Name:\s*(?P<agent>[\w\-.]+)\s*$"), "weak", "status"),
|
|
50
|
+
(re.compile(r"""^\{\s*['"](?P<agent>[\w\-]+)['"]\s*:\s*[\{\[]"""), "weak", "status"),
|
|
51
|
+
# OpenAI Agents SDK / Swarm style
|
|
52
|
+
(re.compile(r"(?:Handoff|Handed off|Handing off|Transferr(?:ed|ing))\s+(?:control\s+)?to\s*:?\s*['\"`]?(?P<agent>[\w\- ]{2,40}?)['\"`]?(?:\s+agent)?\s*(?:[.,:!]|$)", re.I), "weak", "handoff"),
|
|
53
|
+
(re.compile(r"\btransfer_to_(?P<agent>\w+)"), "weak", "handoff"),
|
|
54
|
+
# Generic narration
|
|
55
|
+
(re.compile(r"(?:Delegating|Passing|Routing|Sending|Switching|Returning|Forwarding)\s+(?:[\w\-]+\s+){0,3}?to\s+(?:the\s+)?['\"`]?(?P<agent>[\w\- ]{2,40}?)['\"`]?(?:\s+agent)?\s*(?:[.,:!…]|$)", re.I), "weak", "handoff"),
|
|
56
|
+
(re.compile(r"(?:Next agent|Next node|next|goto|Selected agent|Speaker)\s*[:=]\s*['\"`]?(?P<agent>[\w\- ]{2,40}?)['\"`]?\s*$", re.I), "weak", "handoff"),
|
|
57
|
+
(re.compile(r"(?:Entering|Starting|Running|Invoking|Activating|Calling)\s+(?:the\s+)?(?:agent|node)\s*[:\-]?\s*['\"`]?(?P<agent>[\w\- ]{2,40}?)['\"`]?\s*(?:[.,:!…]|\.\.\.|$)", re.I), "weak", "status"),
|
|
58
|
+
(re.compile(r"^(?:=+|-{3,}|\*{3,}|#+|>+)\s*\[?(?P<agent>[A-Za-z][\w\- ]{1,40}?)\]?(?:\s+(?:agent|node))?\s*(?:=+|-{3,}|\*{3,}|#*)?\s*$", re.I), "weak", "status"),
|
|
59
|
+
(re.compile(r"^\[(?P<agent>[A-Za-z][\w\- .]{1,40})\]"), "weak", "log"),
|
|
60
|
+
(re.compile(r"^(?P<agent>[A-Za-z][\w\- ]{1,30}?)\s*(?:>|»|:|\||->)\s+\S"), "weak", "log"),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
KIND_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
64
|
+
(re.compile(r"(Traceback \(most recent call last\)|^\w*(?:Error|Exception)\b\s*[:(]|^ERROR\b|^\[ERROR\]|\bFAILED\b)"), "error"),
|
|
65
|
+
(re.compile(r"^#*\s*(?:Thought|Thinking|Reasoning|Plan)\s*:\s*(?P<text>.*)$", re.I), "thought"),
|
|
66
|
+
(re.compile(r"^#*\s*(?:Using Tool|Tool|Action|Calling tool|Tool call|Calling)\s*:\s*`?(?P<tool>[\w\-.]+)`?(?P<text>.*)$", re.I), "tool"),
|
|
67
|
+
(re.compile(r"^Invoking:\s*`(?P<tool>[^`]+)`(?P<text>.*)$"), "tool"),
|
|
68
|
+
(re.compile(r"^\*{3,}\s*Suggested tool call.*?:\s*(?P<tool>[\w\-.]+)", re.I), "tool"),
|
|
69
|
+
(re.compile(r"^#*\s*(?:Action Input|Tool Input|Tool Args|Arguments)\s*:\s*(?P<text>.*)$", re.I), "tool_input"),
|
|
70
|
+
(re.compile(r"^#*\s*(?:Observation|Tool Output|Tool Result|Tool Response|Result)\s*:\s*(?P<text>.*)$", re.I), "observation"),
|
|
71
|
+
(re.compile(r"^#*\s*(?:Final Answer|Final Output|Final Response|Answer|Output|Response)\s*:\s*(?P<text>.*)$", re.I), "output"),
|
|
72
|
+
(re.compile(r"^#*\s*(?:Task|Assigned Task|Goal)\s*:\s*(?P<text>.*)$", re.I), "task"),
|
|
73
|
+
(re.compile(r"^>?\s*Entering new (?P<text>[\w ]+) chain", re.I), "status"),
|
|
74
|
+
(re.compile(r"^>?\s*Finished chain", re.I), "status"),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
# Kinds whose following un-prefixed lines are a continuation (multi-line thoughts / answers).
|
|
78
|
+
CONTINUES = {"thought", "output", "observation", "tool_input", "message", "task"}
|
|
79
|
+
# Kinds that describe what an agent is doing, in order of how informative they are.
|
|
80
|
+
ACTIVITY_KINDS = {"thought": 5, "tool": 5, "task": 4, "output": 5, "handoff": 3, "message": 3, "narration": 6,
|
|
81
|
+
"observation": 2, "tool_input": 1, "log": 1}
|