Merge branch 'main' into bb/gui
This commit is contained in:
@@ -56,7 +56,7 @@ def get_camofox_url() -> str:
|
||||
def is_camofox_mode() -> bool:
|
||||
"""True when Camofox backend is configured and no CDP override is active.
|
||||
|
||||
When the user has explicitly connected to a live Chrome instance via
|
||||
When the user has explicitly connected to a live Chromium-family browser via
|
||||
``/browser connect`` (which sets ``BROWSER_CDP_URL``), the CDP connection
|
||||
takes priority over Camofox so the browser tools operate on the real
|
||||
browser instead of being silently routed to the Camofox backend.
|
||||
|
||||
+11
-10
@@ -358,8 +358,9 @@ def browser_cdp(
|
||||
if not endpoint:
|
||||
return tool_error(
|
||||
"No CDP endpoint is available. Run '/browser connect' to attach "
|
||||
"to a running Chrome, or set 'browser.cdp_url' in config.yaml. "
|
||||
"The Camofox backend is REST-only and does not expose CDP.",
|
||||
"to a running Chrome, Brave, Chromium, or Edge browser, or set "
|
||||
"'browser.cdp_url' in config.yaml. The Camofox backend is REST-only "
|
||||
"and does not expose CDP.",
|
||||
cdp_docs=CDP_DOCS_URL,
|
||||
)
|
||||
|
||||
@@ -367,8 +368,8 @@ def browser_cdp(
|
||||
return tool_error(
|
||||
f"CDP endpoint is not a WebSocket URL: {endpoint!r}. "
|
||||
"Expected ws://... or wss://... — the /browser connect "
|
||||
"resolver should have rewritten this. Check that Chrome is "
|
||||
"actually listening on the debug port."
|
||||
"resolver should have rewritten this. Check that a Chromium-family "
|
||||
"browser is actually listening on the debug port."
|
||||
)
|
||||
|
||||
call_params: Dict[str, Any] = params or {}
|
||||
@@ -431,12 +432,12 @@ BROWSER_CDP_SCHEMA: Dict[str, Any] = {
|
||||
"browser operations not covered by browser_navigate, browser_click, "
|
||||
"browser_console, etc.\n\n"
|
||||
"**Requires a reachable CDP endpoint.** Available when the user has "
|
||||
"run '/browser connect' to attach to a running Chrome, or when "
|
||||
"'browser.cdp_url' is set in config.yaml. Not currently wired up for "
|
||||
"cloud backends (Browserbase, Browser Use, Firecrawl) — those expose "
|
||||
"CDP per session but live-session routing is a follow-up. Camofox is "
|
||||
"REST-only and will never support CDP. If the tool is in your toolset "
|
||||
"at all, a CDP endpoint is already reachable.\n\n"
|
||||
"run '/browser connect' to attach to a running Chrome, Brave, Chromium, "
|
||||
"or Edge browser, or when 'browser.cdp_url' is set in config.yaml. "
|
||||
"Not currently wired up for cloud backends (Browserbase, Browser Use, "
|
||||
"Firecrawl) — those expose CDP per session but live-session routing is "
|
||||
"a follow-up. Camofox is REST-only and will never support CDP. If the "
|
||||
"tool is in your toolset at all, a CDP endpoint is already reachable.\n\n"
|
||||
f"**CDP method reference:** {CDP_DOCS_URL} — use web_extract on a "
|
||||
"method's URL (e.g. '/tot/Page/#method-handleJavaScriptDialog') "
|
||||
"to look up parameters and return shape.\n\n"
|
||||
|
||||
@@ -6,7 +6,7 @@ accept or dismiss.
|
||||
|
||||
Gated on the same ``_browser_cdp_check`` as ``browser_cdp`` so it only
|
||||
appears when a CDP endpoint is reachable (Browserbase with a
|
||||
``connectUrl``, local Chrome via ``/browser connect``, or
|
||||
``connectUrl``, local Chromium-family browser via ``/browser connect``, or
|
||||
``browser.cdp_url`` set in config).
|
||||
|
||||
See ``website/docs/developer-guide/browser-supervisor.md`` for the full
|
||||
@@ -40,7 +40,7 @@ BROWSER_DIALOG_SCHEMA: Dict[str, Any] = {
|
||||
"happens when a second dialog fires while the first is still open), "
|
||||
"pass ``dialog_id`` from the snapshot to disambiguate.\n\n"
|
||||
"**Availability:** only present when a CDP-capable backend is "
|
||||
"attached — Browserbase sessions, local Chrome via "
|
||||
"attached — Browserbase sessions, local Chromium-family browser via "
|
||||
"``/browser connect``, or ``browser.cdp_url`` in config.yaml. "
|
||||
"Not available on Camofox (REST-only) or the default Playwright "
|
||||
"local browser (CDP port is hidden)."
|
||||
|
||||
@@ -326,6 +326,44 @@ LINTERS = {
|
||||
'.rs': 'rustfmt --check {file} 2>&1',
|
||||
}
|
||||
|
||||
# Extensions where the per-file shell linter is structurally weaker than
|
||||
# a real LSP server AND produces phantom errors on real-world projects:
|
||||
#
|
||||
# - ``.ts``: ``tsc --noEmit FILE.ts`` ignores ``tsconfig.json`` and
|
||||
# defaults to no-lib / ES5, so every ES2015+ stdlib reference
|
||||
# (``Promise``, ``Map``, ``Set``, ``ReadonlySet``, ``Iterable``,
|
||||
# ``Math.imul``, ``Number.isFinite``, etc.) reports as missing. This
|
||||
# floods the agent's lint field with 20K+ tokens of false positives on
|
||||
# every edit. No supported tsc flag fixes the single-file invocation;
|
||||
# the canonical replacement is ``tsserver`` via LSP, which respects
|
||||
# tsconfig and gives true diagnostics.
|
||||
#
|
||||
# ``.tsx`` is intentionally NOT in ``LINTERS`` (and therefore not
|
||||
# here): it has no shell linter entry, so it falls through to the
|
||||
# ``ext not in LINTERS`` skip case unchanged. Pre-PR behavior:
|
||||
# ``.tsx`` was implicitly ``skipped``. Keeping it that way means
|
||||
# ``.tsx`` edits with LSP disabled get no per-file syntax check
|
||||
# (same as before this PR) instead of the broken ``tsc`` invocation
|
||||
# that ``.ts`` used to get. When LSP is enabled, ``.tsx`` is covered
|
||||
# by the LSP tier via ``_maybe_lsp_diagnostics`` exactly as ``.ts``.
|
||||
#
|
||||
# - ``.go``: ``go vet FILE.go`` fails outside a module / GOPATH with
|
||||
# "cannot find package" — already partially handled by
|
||||
# ``_LINTER_UNUSABLE_PATTERNS`` but only when the package error is the
|
||||
# ONLY output; mixed real+phantom output still leaks through.
|
||||
# ``gopls`` is the canonical replacement.
|
||||
#
|
||||
# - ``.rs``: ``rustfmt --check FILE.rs`` is style, not type-checking, and
|
||||
# rejects non-Cargo project files. ``rust-analyzer`` is the canonical
|
||||
# replacement.
|
||||
#
|
||||
# When the LSP service is configured AND ``enabled_for(path)`` for this
|
||||
# extension's file, ``_check_lint`` skips the shell linter for these
|
||||
# extensions — the ``lsp_diagnostics`` channel carries the real signal.
|
||||
# Everything else in ``LINTERS`` (Python ``py_compile``, ``node --check``)
|
||||
# is fast, file-local, and correct, so it runs unconditionally.
|
||||
_SHELL_LINTER_LSP_REDUNDANT = frozenset({'.ts', '.go', '.rs'})
|
||||
|
||||
|
||||
# Patterns that indicate the linter base command exists on PATH but
|
||||
# couldn't actually run — e.g. ``npx tsc`` when tsc isn't installed in
|
||||
@@ -1169,6 +1207,19 @@ class ShellFileOperations(FileOperations):
|
||||
if ext not in LINTERS:
|
||||
return LintResult(skipped=True, message=f"No linter for {ext} files")
|
||||
|
||||
# If a real LSP server is active and claims this file, skip the
|
||||
# shell linter for extensions whose per-file shell invocation is
|
||||
# structurally weaker / floods phantom errors. See
|
||||
# ``_SHELL_LINTER_LSP_REDUNDANT`` above for the rationale per ext.
|
||||
# The LSP tier runs separately via ``_maybe_lsp_diagnostics`` and
|
||||
# carries the real diagnostics in ``lsp_diagnostics`` on the
|
||||
# WriteResult / PatchResult.
|
||||
if ext in _SHELL_LINTER_LSP_REDUNDANT and self._lsp_will_handle(path):
|
||||
return LintResult(
|
||||
skipped=True,
|
||||
message=f"LSP server handles {ext} — shell linter skipped",
|
||||
)
|
||||
|
||||
linter_cmd = LINTERS[ext]
|
||||
# Extract the base command (first word)
|
||||
base_cmd = linter_cmd.split()[0]
|
||||
@@ -1332,6 +1383,40 @@ class ShellFileOperations(FileOperations):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _lsp_will_handle(self, path: str) -> bool:
|
||||
"""Return True iff the LSP service is active AND will lint this file.
|
||||
|
||||
Stronger than :meth:`_lsp_handles_extension` — that one only checks
|
||||
the static server registry. This one additionally requires the
|
||||
LSP service to be configured/enabled and the file to pass
|
||||
:meth:`agent.lsp.manager.LSPService.enabled_for` (which gates on
|
||||
workspace detection, disabled-server set, and the broken-pair
|
||||
short-circuit).
|
||||
|
||||
Used by :meth:`_check_lint` to decide whether to skip the per-file
|
||||
shell linter for extensions in ``_SHELL_LINTER_LSP_REDUNDANT``.
|
||||
|
||||
Best-effort: any failure path returns False so the shell linter
|
||||
runs as before — never suppress lint based on an LSP probe that
|
||||
couldn't actually answer the question.
|
||||
"""
|
||||
if not self._lsp_local_only():
|
||||
return False
|
||||
try:
|
||||
from agent.lsp import get_service
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
try:
|
||||
svc = get_service()
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
if svc is None:
|
||||
return False
|
||||
try:
|
||||
return bool(svc.enabled_for(path))
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
def _snapshot_lsp_baseline(self, path: str) -> None:
|
||||
"""Capture pre-edit LSP diagnostics so the post-write delta is correct.
|
||||
|
||||
|
||||
+41
-11
@@ -363,6 +363,12 @@ def apply_v4a_operations(operations: List[PatchOperation],
|
||||
files_created = []
|
||||
files_deleted = []
|
||||
all_diffs = []
|
||||
# Per-file LSP diagnostics blocks captured from underlying write_file
|
||||
# calls. V4A bypasses the WriteResult / PatchResult plumbing that
|
||||
# write_file and patch_replace use, so without explicit propagation
|
||||
# the LSP tier's output gets silently dropped — see
|
||||
# ``PatchResult.lsp_diagnostics`` aggregation below.
|
||||
lsp_blocks: List[str] = []
|
||||
errors = []
|
||||
|
||||
for op in operations:
|
||||
@@ -372,6 +378,8 @@ def apply_v4a_operations(operations: List[PatchOperation],
|
||||
if result[0]:
|
||||
files_created.append(op.file_path)
|
||||
all_diffs.append(result[1])
|
||||
if result[2]:
|
||||
lsp_blocks.append(result[2])
|
||||
else:
|
||||
errors.append(f"Failed to add {op.file_path}: {result[1]}")
|
||||
|
||||
@@ -396,6 +404,8 @@ def apply_v4a_operations(operations: List[PatchOperation],
|
||||
if result[0]:
|
||||
files_modified.append(op.file_path)
|
||||
all_diffs.append(result[1])
|
||||
if result[2]:
|
||||
lsp_blocks.append(result[2])
|
||||
else:
|
||||
errors.append(f"Failed to update {op.file_path}: {result[1]}")
|
||||
|
||||
@@ -411,6 +421,13 @@ def apply_v4a_operations(operations: List[PatchOperation],
|
||||
|
||||
combined_diff = '\n'.join(all_diffs)
|
||||
|
||||
# Combine per-file LSP diagnostics blocks. Each block already has
|
||||
# the ``<diagnostics file="...">`` header from
|
||||
# ``LSPService.report_for_file`` so concatenation is safe — the
|
||||
# agent (and any downstream parsers) can still attribute each
|
||||
# diagnostic to its file.
|
||||
combined_lsp = "\n\n".join(lsp_blocks) if lsp_blocks else None
|
||||
|
||||
if errors:
|
||||
return PatchResult(
|
||||
success=False,
|
||||
@@ -419,6 +436,7 @@ def apply_v4a_operations(operations: List[PatchOperation],
|
||||
files_created=files_created,
|
||||
files_deleted=files_deleted,
|
||||
lint=lint_results if lint_results else None,
|
||||
lsp_diagnostics=combined_lsp,
|
||||
error="Apply phase failed (state may be inconsistent — run `git diff` to assess):\n"
|
||||
+ "\n".join(f" • {e}" for e in errors),
|
||||
)
|
||||
@@ -430,11 +448,19 @@ def apply_v4a_operations(operations: List[PatchOperation],
|
||||
files_created=files_created,
|
||||
files_deleted=files_deleted,
|
||||
lint=lint_results if lint_results else None,
|
||||
lsp_diagnostics=combined_lsp,
|
||||
)
|
||||
|
||||
|
||||
def _apply_add(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
|
||||
"""Apply an add file operation."""
|
||||
def _apply_add(op: PatchOperation, file_ops: Any) -> Tuple[bool, str, Optional[str]]:
|
||||
"""Apply an add file operation.
|
||||
|
||||
Returns ``(success, diff_or_error, lsp_diagnostics)``. The third
|
||||
element carries the formatted ``<diagnostics>`` block from
|
||||
:class:`WriteResult.lsp_diagnostics` so V4A patches can surface
|
||||
semantic diagnostics from the LSP layer — without this, the LSP
|
||||
tier would silently swallow them on the V4A code path.
|
||||
"""
|
||||
# Extract content from hunks (all + lines)
|
||||
content_lines = []
|
||||
for hunk in op.hunks:
|
||||
@@ -446,12 +472,12 @@ def _apply_add(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
|
||||
|
||||
result = file_ops.write_file(op.file_path, content)
|
||||
if result.error:
|
||||
return False, result.error
|
||||
return False, result.error, None
|
||||
|
||||
diff = f"--- /dev/null\n+++ b/{op.file_path}\n"
|
||||
diff += '\n'.join(f"+{line}" for line in content_lines)
|
||||
|
||||
return True, diff
|
||||
return True, diff, getattr(result, "lsp_diagnostics", None)
|
||||
|
||||
|
||||
def _apply_delete(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
|
||||
@@ -485,8 +511,12 @@ def _apply_move(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
|
||||
return True, diff
|
||||
|
||||
|
||||
def _apply_update(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
|
||||
"""Apply an update file operation."""
|
||||
def _apply_update(op: PatchOperation, file_ops: Any) -> Tuple[bool, str, Optional[str]]:
|
||||
"""Apply an update file operation.
|
||||
|
||||
Returns ``(success, diff_or_error, lsp_diagnostics)`` — see
|
||||
:func:`_apply_add` for the rationale on the third element.
|
||||
"""
|
||||
# Deferred import: breaks the patch_parser ↔ fuzzy_match circular dependency
|
||||
from tools.fuzzy_match import fuzzy_find_and_replace
|
||||
|
||||
@@ -494,7 +524,7 @@ def _apply_update(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
|
||||
read_result = file_ops.read_file_raw(op.file_path)
|
||||
|
||||
if read_result.error:
|
||||
return False, f"Cannot read file: {read_result.error}"
|
||||
return False, f"Cannot read file: {read_result.error}", None
|
||||
|
||||
current_content = read_result.content
|
||||
|
||||
@@ -549,7 +579,7 @@ def _apply_update(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
|
||||
err_msg += format_no_match_hint(error, 0, search_pattern, new_content)
|
||||
except Exception:
|
||||
pass
|
||||
return False, err_msg
|
||||
return False, err_msg, None
|
||||
else:
|
||||
# Addition-only hunk (no context or removed lines).
|
||||
# Insert at the location indicated by the context hint, or at end of file.
|
||||
@@ -563,7 +593,7 @@ def _apply_update(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
|
||||
return False, (
|
||||
f"Addition-only hunk: context hint '{hunk.context_hint}' is ambiguous "
|
||||
f"({occurrences} occurrences) — provide a more unique hint"
|
||||
)
|
||||
), None
|
||||
else:
|
||||
hint_pos = new_content.find(hunk.context_hint)
|
||||
# Insert after the line containing the context hint
|
||||
@@ -578,7 +608,7 @@ def _apply_update(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
|
||||
# Write new content
|
||||
write_result = file_ops.write_file(op.file_path, new_content)
|
||||
if write_result.error:
|
||||
return False, write_result.error
|
||||
return False, write_result.error, None
|
||||
|
||||
# Generate diff
|
||||
diff_lines = difflib.unified_diff(
|
||||
@@ -589,4 +619,4 @@ def _apply_update(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]:
|
||||
)
|
||||
diff = ''.join(diff_lines)
|
||||
|
||||
return True, diff
|
||||
return True, diff, getattr(write_result, "lsp_diagnostics", None)
|
||||
|
||||
@@ -167,6 +167,7 @@ DEFAULT_XAI_VOICE_ID = "eve"
|
||||
DEFAULT_XAI_LANGUAGE = "en"
|
||||
DEFAULT_XAI_SAMPLE_RATE = 24000
|
||||
DEFAULT_XAI_BIT_RATE = 128000
|
||||
DEFAULT_XAI_AUTO_SPEECH_TAGS = False
|
||||
DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1"
|
||||
DEFAULT_GEMINI_TTS_MODEL = "gemini-2.5-flash-preview-tts"
|
||||
DEFAULT_GEMINI_TTS_VOICE = "Kore"
|
||||
@@ -892,6 +893,79 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any]
|
||||
# ===========================================================================
|
||||
# Provider: xAI TTS
|
||||
# ===========================================================================
|
||||
_XAI_INLINE_SPEECH_TAGS = (
|
||||
"pause",
|
||||
"long-pause",
|
||||
"hum-tune",
|
||||
"laugh",
|
||||
"chuckle",
|
||||
"giggle",
|
||||
"cry",
|
||||
"tsk",
|
||||
"tongue-click",
|
||||
"lip-smack",
|
||||
"breath",
|
||||
"inhale",
|
||||
"exhale",
|
||||
"sigh",
|
||||
)
|
||||
_XAI_WRAPPING_SPEECH_TAGS = (
|
||||
"soft",
|
||||
"whisper",
|
||||
"loud",
|
||||
"build-intensity",
|
||||
"decrease-intensity",
|
||||
"higher-pitch",
|
||||
"lower-pitch",
|
||||
"slow",
|
||||
"fast",
|
||||
"sing-song",
|
||||
"singing",
|
||||
"laugh-speak",
|
||||
"emphasis",
|
||||
)
|
||||
_XAI_SPEECH_TAG_RE = re.compile(
|
||||
r"(\[(?:" + "|".join(_XAI_INLINE_SPEECH_TAGS) + r")\]|</?(?:" + "|".join(_XAI_WRAPPING_SPEECH_TAGS) + r")>)",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
_XAI_FIRST_SENTENCE_RE = re.compile(r"^(.{12,120}?[.!?…])\s+(?=\S)", flags=re.DOTALL)
|
||||
|
||||
|
||||
def _xai_bool_config(value: Any, default: bool = False) -> bool:
|
||||
"""Coerce common YAML/env bool spellings without treating random strings as true."""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on", "enabled"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off", "disabled"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _apply_xai_auto_speech_tags(text: str) -> str:
|
||||
"""Add light xAI speech tags for more natural voice-mode replies.
|
||||
|
||||
The transform is intentionally conservative: it only inserts pauses. It
|
||||
never fabricates laughter or whispering, and it leaves explicit user/model
|
||||
speech tags untouched.
|
||||
"""
|
||||
clean = text.strip()
|
||||
if not clean or _XAI_SPEECH_TAG_RE.search(clean):
|
||||
return text
|
||||
|
||||
clean = re.sub(r"\n\s*\n+", " [pause] ", clean)
|
||||
clean = re.sub(r"\s*\n\s*", " ", clean)
|
||||
clean = _XAI_FIRST_SENTENCE_RE.sub(r"\1 [pause] ", clean, count=1)
|
||||
clean = re.sub(r"\s{2,}", " ", clean).strip()
|
||||
return clean
|
||||
|
||||
|
||||
def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Generate audio using xAI TTS.
|
||||
@@ -913,6 +987,12 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -
|
||||
language = str(xai_config.get("language", DEFAULT_XAI_LANGUAGE)).strip() or DEFAULT_XAI_LANGUAGE
|
||||
sample_rate = int(xai_config.get("sample_rate", DEFAULT_XAI_SAMPLE_RATE))
|
||||
bit_rate = int(xai_config.get("bit_rate", DEFAULT_XAI_BIT_RATE))
|
||||
auto_speech_tags = _xai_bool_config(
|
||||
xai_config.get("auto_speech_tags", xai_config.get("speech_tags")),
|
||||
DEFAULT_XAI_AUTO_SPEECH_TAGS,
|
||||
)
|
||||
if auto_speech_tags:
|
||||
text = _apply_xai_auto_speech_tags(text)
|
||||
base_url = str(
|
||||
xai_config.get("base_url")
|
||||
or creds.get("base_url")
|
||||
|
||||
Reference in New Issue
Block a user