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.
@@ -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
+ }
@@ -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}")
@@ -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}