From 70d2f2bb601adef7c04c7286f2780119ba567ebe Mon Sep 17 00:00:00 2001 From: namabeeru Date: Fri, 3 Jul 2026 12:16:36 +0900 Subject: [PATCH 01/28] Harden launcher preflight follow-up --- app/main.py | 4 + app/runtime_preflight.py | 245 ++++++++++++++++++++++++++ craftbot.py | 165 ++++++++++++++--- installer/api.py | 7 +- run.py | 15 +- startup_constants.py | 4 + tests/test_craftbot_service.py | 180 +++++++++++++++++++ tests/test_run_dependency_check.py | 110 ++++++++++++ tests/test_startup_constants_usage.py | 15 ++ 9 files changed, 718 insertions(+), 27 deletions(-) create mode 100644 app/runtime_preflight.py create mode 100644 startup_constants.py create mode 100644 tests/test_craftbot_service.py create mode 100644 tests/test_run_dependency_check.py create mode 100644 tests/test_startup_constants_usage.py diff --git a/app/main.py b/app/main.py index 02455d5b..ca05dfd1 100644 --- a/app/main.py +++ b/app/main.py @@ -51,6 +51,10 @@ def _suppress_console_logging_early() -> None: import argparse import asyncio +from app.runtime_preflight import ensure_current_runtime_dependencies + +ensure_current_runtime_dependencies() + # Register agent_core state provider and config before importing AgentBase # This ensures shared code can access state via get_state() from agent_core import StateRegistry, ConfigRegistry diff --git a/app/runtime_preflight.py b/app/runtime_preflight.py new file mode 100644 index 00000000..ba450ca2 --- /dev/null +++ b/app/runtime_preflight.py @@ -0,0 +1,245 @@ +# -*- coding: utf-8 -*- +"""Runtime dependency checks that can run before dependency-heavy imports.""" + +from dataclasses import dataclass +import json +import os +import subprocess +import sys +from typing import Dict, List, Optional, Tuple + + +_PREFLIGHT_OK_ENV = "CRAFTBOT_RUNTIME_PREFLIGHT_OK" +_MISSING_SENTINEL = "__CRAFTBOT_MISSING_RUNTIME_IMPORTS__" +# Conservative upper bound for slower conda environment startup. A timeout is +# inconclusive and warns/continues; it is not treated as missing dependencies. +RUNTIME_PROBE_TIMEOUT_SECONDS = 60 + +RUNTIME_IMPORT_CHECKS = { + # Packages imported during backend startup. Provider SDKs stay deferred so + # unused providers are not forced through unrelated SDK imports at startup. + "requests": "requests", + "pyyaml": "yaml", + "loguru": "loguru", + "nest-asyncio": "nest_asyncio", + "pymongo": "pymongo", + "tzlocal": "tzlocal", + "aiohttp": "aiohttp", + "chromadb": "chromadb", + "tiktoken": "tiktoken", + "mss": "mss", + "httpx": "httpx", + "websockets": "websockets", + "tenacity": "tenacity", + "gradio_client": "gradio_client", + "python-dotenv": "dotenv", + "scikit-learn": "sklearn", + "watchdog": "watchdog", + "croniter": "croniter", +} + + +@dataclass(frozen=True) +class RuntimeDependencyResult: + missing: List[str] + runtime_label: str + inconclusive_reason: Optional[str] = None + + @property + def is_inconclusive(self) -> bool: + return self.inconclusive_reason is not None + + +def _runtime_import_script(checks: Dict[str, str]) -> str: + return ( + "import importlib\n" + "import json\n" + f"checks = {list(checks.items())!r}\n" + "missing = []\n" + "for package_name, import_name in checks:\n" + " try:\n" + " importlib.import_module(import_name)\n" + " except Exception:\n" + " missing.append(package_name)\n" + f"print({_MISSING_SENTINEL!r} + json.dumps(missing))\n" + ) + + +def _runtime_import_command( + use_conda: bool, + env_name: Optional[str], + checks: Dict[str, str], + conda_command: str, +) -> Tuple[List[str], str]: + script = _runtime_import_script(checks) + if use_conda and env_name: + return ( + [ + conda_command, + "run", + "-n", + env_name, + "python", + "-c", + script, + ], + f"conda environment '{env_name}'", + ) + return ([sys.executable, "-c", script], sys.executable) + + +def check_runtime_dependencies( + *, + use_conda: bool, + env_name: Optional[str], + checks: Optional[Dict[str, str]] = None, + conda_command: str = "conda", + timeout: int = RUNTIME_PROBE_TIMEOUT_SECONDS, +) -> RuntimeDependencyResult: + """Probe imports for the Python runtime that will run the agent. + + Only a successful probe with valid sentinel JSON is treated as conclusive. + Probe infrastructure failures are warnings, not startup blockers. + """ + if checks is None: + checks = RUNTIME_IMPORT_CHECKS + cmd, runtime_label = _runtime_import_command( + use_conda, env_name, checks, conda_command + ) + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + except subprocess.TimeoutExpired: + return RuntimeDependencyResult( + [], + runtime_label, + f"dependency probe timed out after {timeout}s", + ) + except Exception as exc: + return RuntimeDependencyResult( + [], + runtime_label, + f"dependency probe could not run: {exc}", + ) + + if result.returncode != 0: + detail = (result.stderr or result.stdout or "").strip() + reason = "dependency probe exited before reporting imports" + if detail: + reason = f"{reason}: {detail.splitlines()[-1]}" + return RuntimeDependencyResult([], runtime_label, reason) + + for line in reversed(result.stdout.splitlines()): + if line.startswith(_MISSING_SENTINEL): + try: + missing = json.loads(line[len(_MISSING_SENTINEL) :]) + except json.JSONDecodeError: + return RuntimeDependencyResult( + [], + runtime_label, + "unexpected probe output: malformed dependency JSON", + ) + if not isinstance(missing, list) or not all( + isinstance(item, str) for item in missing + ): + return RuntimeDependencyResult( + [], + runtime_label, + "unexpected probe output: dependency JSON was not a string list", + ) + return RuntimeDependencyResult(missing, runtime_label) + + return RuntimeDependencyResult( + [], + runtime_label, + "unexpected probe output: missing dependency sentinel", + ) + + +def print_missing_runtime_dependencies( + *, + missing: List[str], + runtime_label: str, + use_conda: bool, + env_name: Optional[str], +) -> None: + print("\nError: CraftBot Python dependencies are missing.") + print(f"Runtime checked: {runtime_label}") + print("\nMissing imports:") + for package_name in missing: + print(f" - {package_name}") + + print( + "\nThis usually means CraftBot is running with a different Python " + "than the one used during install." + ) + print("\nFix:") + if use_conda and env_name: + print(" python install.py --conda") + print(f" conda run -n {env_name} python run.py") + else: + print(f" {sys.executable} install.py") + print(f" {sys.executable} run.py") + print("\nIf you installed CraftBot with another Python, start it with that Python.") + + +def print_inconclusive_runtime_dependency_warning( + *, + reason: str, + runtime_label: str, + use_conda: bool, + env_name: Optional[str], +) -> None: + print("\nWarning: CraftBot could not verify Python dependencies.") + print(f"Runtime checked: {runtime_label}") + print(f"Reason: {reason}") + print("Continuing startup. If imports fail, reinstall dependencies for this runtime:") + if use_conda and env_name: + print(" python install.py --conda") + else: + print(f" {sys.executable} install.py") + + +def ensure_runtime_dependencies( + *, + use_conda: bool, + env_name: Optional[str], + conda_command: str = "conda", + checks: Optional[Dict[str, str]] = None, +) -> None: + if getattr(sys, "frozen", False): + return + + result = check_runtime_dependencies( + use_conda=use_conda, + env_name=env_name, + conda_command=conda_command, + checks=checks, + ) + if result.is_inconclusive: + print_inconclusive_runtime_dependency_warning( + reason=result.inconclusive_reason or "unknown probe failure", + runtime_label=result.runtime_label, + use_conda=use_conda, + env_name=env_name, + ) + return + if result.missing: + print_missing_runtime_dependencies( + missing=result.missing, + runtime_label=result.runtime_label, + use_conda=use_conda, + env_name=env_name, + ) + sys.exit(1) + + +def mark_runtime_dependencies_checked() -> None: + os.environ[_PREFLIGHT_OK_ENV] = "1" + + +def ensure_current_runtime_dependencies() -> None: + """Check imports for direct app.main usage with the current interpreter.""" + if os.environ.get(_PREFLIGHT_OK_ENV) == "1": + return + ensure_runtime_dependencies(use_conda=False, env_name=None) diff --git a/craftbot.py b/craftbot.py index 8a3eeb7e..67de794a 100644 --- a/craftbot.py +++ b/craftbot.py @@ -63,6 +63,7 @@ def flush(self) -> None: sys.stderr = _NullIO() import os +import shlex import shutil import signal import subprocess @@ -74,6 +75,7 @@ def flush(self) -> None: from installer import helpers as _helpers from installer import metadata as _metadata from installer import payload as _payload +from startup_constants import CRAFTBOT_READY_MARKER # Store platform once so static analysers don't short-circuit platform branches _PLATFORM: str = sys.platform @@ -182,7 +184,9 @@ def installed_exe_path() -> Optional[str]: TASK_NAME = "CraftBot" # Windows Task Scheduler task name SYSTEMD_SERVICE = "craftbot" # Linux systemd service name LAUNCHD_LABEL = "com.craftbot.agent" # macOS launchd label -BROWSER_URL = "http://localhost:7925" +DEFAULT_FRONTEND_PORT = 7925 +DEFAULT_BACKEND_PORT = 7926 +BROWSER_URL = f"http://localhost:{DEFAULT_FRONTEND_PORT}" SHORTCUT_NAME = "CraftBot.lnk" # Bundled icons live in sys._MEIPASS in frozen mode (PyInstaller's runtime # extract dir) and alongside craftbot.py in source mode. _ensure_ico() copies @@ -322,6 +326,75 @@ def _remove_pid() -> None: pass +def _parse_port_arg(args: List[str], flag: str, default: int) -> int: + prefix = f"{flag}=" + for i, arg in enumerate(args): + value = None + if arg == flag and i + 1 < len(args): + value = args[i + 1] + elif arg.startswith(prefix): + value = arg[len(prefix) :] + if value is not None: + try: + return int(value) + except ValueError: + return default + return default + + +def _frontend_url(args: List[str]) -> str: + port = _parse_port_arg(args, "--frontend-port", DEFAULT_FRONTEND_PORT) + return f"http://localhost:{port}" + + +def _backend_url(args: List[str]) -> str: + port = _parse_port_arg(args, "--backend-port", DEFAULT_BACKEND_PORT) + return f"http://localhost:{port}" + + +def _port_args(args: List[str]) -> List[str]: + result = [] + skip_next = False + for i, arg in enumerate(args): + if skip_next: + skip_next = False + continue + if arg in ("--frontend-port", "--backend-port"): + if i + 1 < len(args): + result.extend([arg, args[i + 1]]) + skip_next = True + continue + if arg.startswith("--frontend-port=") or arg.startswith("--backend-port="): + result.append(arg) + return result + + +def _tail_log_lines(n: int = 30, start_offset: int = 0) -> str: + if not os.path.isfile(LOG_FILE): + return "" + try: + with open(LOG_FILE, "r", errors="replace") as f: + if start_offset: + f.seek(start_offset) + lines = f.readlines() + except Exception: + return "" + return "".join(lines[-n:]) + + +def _wait_for_startup_exit( + proc, timeout: float = 8.0, ready_log_offset: int = 0 +) -> Optional[int]: + deadline = time.time() + timeout + while time.time() < deadline: + try: + return proc.wait(timeout=0.1) + except subprocess.TimeoutExpired: + if CRAFTBOT_READY_MARKER in _tail_log_lines(80, ready_log_offset): + return None + return None + + def _is_running(pid: int) -> bool: """Return True if a process with the given PID is currently alive.""" if _PLATFORM == "win32": @@ -442,8 +515,12 @@ def _poll_and_open() -> None: subprocess.Popen([python, "-c", poll_script], **kwargs) -def cmd_start(extra_args: List[str]) -> None: - """Start CraftBot as a detached background process.""" +def cmd_start(extra_args: List[str]) -> bool: + """Start CraftBot as a detached background process. + + Returns True once the service survives the early startup check; False when + launch fails before CraftBot can be used. + """ pid = _read_pid() if pid and _is_running(pid): cmd_stop() @@ -462,7 +539,7 @@ def cmd_start(extra_args: List[str]) -> None: installed = installed_exe_path() if not installed: print("Error: no installed agent found — run install first.") - return + return False cmd = [installed] + run_args else: python = _python_exe() @@ -479,6 +556,7 @@ def cmd_start(extra_args: List[str]) -> None: log_fh.write(f"Command: {' '.join(cmd)}\n") log_fh.write(f"{'=' * 60}\n") log_fh.flush() + ready_log_offset = log_fh.tell() env = os.environ.copy() env["PYTHONIOENCODING"] = "utf-8" @@ -498,11 +576,27 @@ def cmd_start(extra_args: List[str]) -> None: except FileNotFoundError as e: log_fh.close() print(f" {RED}✗{RESET} {WHITE}Could not launch CraftBot — {e}{RESET}") - return + return False # Parent closes its copy — the child process (run.py) keeps the fd open log_fh.close() _write_pid(proc.pid) + + # Catch immediate startup failures before reporting success. This surfaces + # wrong-Python dependency errors from run.py instead of leaving a stale PID. + exit_code = _wait_for_startup_exit(proc, ready_log_offset=ready_log_offset) + + if exit_code is not None: + _remove_pid() + print( + f" {RED}✗{RESET} {WHITE}CraftBot failed to start{RESET} {DIM}exit {exit_code}{RESET}" + ) + log_tail = _tail_log_lines() + if log_tail: + print(f"\n{DIM}Last log lines:{RESET}\n{log_tail}", end="") + print(f"\nCheck logs: {sys.executable} craftbot.py logs") + return False + print( f" {GREEN}▸{RESET} {WHITE}CRAFTBOT STARTED{RESET} {DIM}PID {proc.pid}{RESET}" ) @@ -512,12 +606,15 @@ def cmd_start(extra_args: List[str]) -> None: if _PLATFORM == "win32": _create_desktop_shortcut_windows() else: - _create_desktop_shortcut_unix() + _create_desktop_shortcut_unix(extra_args) open_browser = "--cli" not in run_args and "--no-open-browser" not in extra_args if open_browser: - print(f" {DIM}░░{RESET} {ORANGE}{BROWSER_URL}{RESET}") - _open_browser_detached(BROWSER_URL) + browser_url = _frontend_url(extra_args) + print(f" {DIM}░░{RESET} {ORANGE}{browser_url}{RESET}") + _open_browser_detached(browser_url) + + return True def cmd_stop() -> None: @@ -613,10 +710,10 @@ def cmd_logs(n: int = 50) -> None: print(f" {RED}✗{RESET} {WHITE}Error reading log: {e}{RESET}") -def cmd_restart(extra_args: List[str]) -> None: +def cmd_restart(extra_args: List[str]) -> bool: cmd_stop() time.sleep(1) - cmd_start(extra_args) + return cmd_start(extra_args) # ─── Desktop shortcut ───────────────────────────────────────────────────────── @@ -759,16 +856,31 @@ def _create_desktop_shortcut_windows() -> None: print(f" (Could not create desktop shortcut: {e})") -def _create_desktop_shortcut_unix() -> None: +def _create_desktop_shortcut_unix(extra_args: Optional[List[str]] = None) -> None: """Create a desktop shortcut on Linux or macOS.""" + if extra_args is None: + extra_args = [] desktop = _find_desktop() if not desktop: return try: + browser_url = _frontend_url(extra_args) if _PLATFORM == "darwin": # macOS does not support XDG .desktop files — create a double-clickable .command script shortcut_path = os.path.join(desktop, "CraftBot.command") - content = f"#!/bin/sh\nopen '{BROWSER_URL}'\n" + backend_url = _backend_url(extra_args) + restart_args = _port_args(extra_args) + start_cmd = shlex.join([_python_exe(), "craftbot.py", "start"] + restart_args) + content = ( + "#!/bin/sh\n" + f"cd {shlex.quote(BASE_DIR)} || exit 1\n" + f"if curl -fsS {shlex.quote(browser_url)} >/dev/null 2>&1 " + f"&& curl -fsS {shlex.quote(backend_url)} >/dev/null 2>&1; then\n" + f" open {shlex.quote(browser_url)}\n" + "else\n" + f" exec {start_cmd}\n" + "fi\n" + ) with open(shortcut_path, "w") as f: f.write(content) os.chmod(shortcut_path, 0o755) @@ -785,7 +897,7 @@ def _create_desktop_shortcut_unix() -> None: "[Desktop Entry]\n" "Type=Application\n" "Name=CraftBot\n" - f"Exec={open_cmd} {BROWSER_URL}\n" + f"Exec={open_cmd} {browser_url}\n" "Icon=web-browser\n" "Terminal=false\n" ) @@ -1205,10 +1317,11 @@ def _full_install_frozen( )(run_args) # 6. Start the service via the extracted agent EXE - cmd_start(extra_args) + if not cmd_start(extra_args): + raise RuntimeError("CraftBot installed but failed to start.") -def cmd_install(extra_args: List[str]) -> None: +def cmd_install(extra_args: List[str]) -> bool: """Install dependencies (source mode) or copy-and-register (frozen mode), then start the service.""" if IS_FROZEN: @@ -1219,7 +1332,7 @@ def cmd_install(extra_args: List[str]) -> None: target_dir = default_install_location() print(f" {ORANGE}▸{RESET} {WHITE}Installing CraftBot to {target_dir}{RESET}") _full_install_frozen(target_dir, extra_args) - return + return True _warn_path_issues() # ── Step 1: Install dependencies via install.py ──────────────────────── @@ -1241,7 +1354,7 @@ def cmd_install(extra_args: List[str]) -> None: print( f" {DIM}Run 'python install.py' directly to see the full error.{RESET}" ) - return + return False # Verify critical packages are actually importable with this interpreter. # install.py may exit 0 while packages ended up in a different site-packages. @@ -1257,7 +1370,7 @@ def cmd_install(extra_args: List[str]) -> None: print( f" {DIM}Run 'python install.py' to reinstall with this Python.{RESET}" ) - return + return False print() else: print(f" {DIM}(install.py not found — skipping dependency install){RESET}\n") @@ -1281,13 +1394,16 @@ def cmd_install(extra_args: List[str]) -> None: # ── Step 3: Start the service now ────────────────────────────────────── _retro_step(3, 3, "Starting CraftBot") - cmd_start(extra_args) + if not cmd_start(extra_args): + print(f"\n {RED}✗{RESET} {WHITE}CraftBot failed to start.{RESET}") + return False print(f"\n {GREEN}▸{RESET} {WHITE}CRAFTBOT IS RUNNING IN THE BACKGROUND{RESET}") - print(f" {DIM}░░{RESET} {ORANGE}{BROWSER_URL}{RESET}") + print(f" {DIM}░░{RESET} {ORANGE}{_frontend_url(extra_args)}{RESET}") print("You can close this window now.") time.sleep(2) _close_console_window() + return True def _remove_desktop_shortcut() -> None: @@ -1541,13 +1657,15 @@ def main() -> None: rest = args[1:] if command == "start": - cmd_start(rest) + if not cmd_start(rest): + sys.exit(1) elif command == "stop": cmd_stop() elif command == "restart": - cmd_restart(rest) + if not cmd_restart(rest): + sys.exit(1) elif command == "status": cmd_status() @@ -1563,7 +1681,8 @@ def main() -> None: cmd_logs(n) elif command == "install": - cmd_install(rest) + if not cmd_install(rest): + sys.exit(1) elif command == "uninstall": cmd_uninstall() diff --git a/installer/api.py b/installer/api.py index 6338ccc1..b923f140 100644 --- a/installer/api.py +++ b/installer/api.py @@ -21,6 +21,7 @@ from typing import Callable, Optional import craftbot +from startup_constants import CRAFTBOT_READY_MARKER # webview imported lazily inside `attach` so a syntax error here doesn't # break source-mode tests that don't have pywebview installed. @@ -178,7 +179,7 @@ def _do_install(self, target_dir: str) -> None: craftbot._full_install_frozen(target_dir, [], progress_cb=self._on_progress) # Spin tailing off so the worker thread completes immediately — # otherwise worker_busy stays True for up to 90s while the tail - # waits for "CRAFTBOT IS READY", and JS keeps stop/repair/uninstall + # waits for the ready marker, and JS keeps stop/repair/uninstall # disabled the whole time. self._spawn_log_tail(start_offset) @@ -204,10 +205,10 @@ def _log_size() -> int: def _tail_log(self, start_offset: int, deadline_s: float = 90.0) -> None: """Stream new bytes appended to craftbot.log into the JS log panel. - Stops when "CRAFTBOT IS READY" appears (run.py prints this once the + Stops when the ready marker appears (run.py prints this once the frontend + agent are both up) or after `deadline_s` seconds.""" offset = start_offset - end_marker = "CRAFTBOT IS READY" + end_marker = CRAFTBOT_READY_MARKER end_time = time.monotonic() + deadline_s announced = False while time.monotonic() < end_time: diff --git a/run.py b/run.py index 7db8a51b..e5be6846 100644 --- a/run.py +++ b/run.py @@ -30,6 +30,12 @@ import atexit from typing import Tuple, Optional, Dict, Any, List +from app.runtime_preflight import ( + ensure_runtime_dependencies, + mark_runtime_dependencies_checked, +) +from startup_constants import CRAFTBOT_READY_MARKER + multiprocessing.freeze_support() # Configuration is loaded from settings.json via the agent startup @@ -795,7 +801,7 @@ def print_ready_banner(url: str): W = 62 print(f"\n{ORANGE}╔{'═' * W}╗{RESET}") print(f"{ORANGE}║{' ' * W}║{RESET}") - _r1 = " ▸ CRAFTBOT IS READY" + _r1 = f" ▸ {CRAFTBOT_READY_MARKER}" _r2 = f" ░░ {url}" print(f"{ORANGE}║{RESET}{GREEN}{_r1.ljust(W)}{RESET}{ORANGE}║{RESET}") print(f"{ORANGE}║{RESET}{ORANGE}{_r2.ljust(W)}{RESET}{ORANGE}║{RESET}") @@ -1254,6 +1260,13 @@ def launch_agent(env_name: Optional[str], conda_base: Optional[str], use_conda: print("Run 'python install.py' or 'python install.py --conda' first.\n") sys.exit(1) + ensure_runtime_dependencies( + use_conda=use_conda, + env_name=env_name, + conda_command=get_conda_command() if use_conda else "conda", + ) + mark_runtime_dependencies_checked() + # Start OmniParser only if GUI mode and it was installed if gui_mode and gui_installed: if not launch_omniparser(use_conda): diff --git a/startup_constants.py b/startup_constants.py new file mode 100644 index 00000000..c0d88235 --- /dev/null +++ b/startup_constants.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +"""Shared startup markers used by launchers and installer log tailing.""" + +CRAFTBOT_READY_MARKER = "CRAFTBOT IS READY" diff --git a/tests/test_craftbot_service.py b/tests/test_craftbot_service.py new file mode 100644 index 00000000..4175c859 --- /dev/null +++ b/tests/test_craftbot_service.py @@ -0,0 +1,180 @@ +# -*- coding: utf-8 -*- +import shlex +import subprocess +import sys + +import pytest + +import craftbot +from startup_constants import CRAFTBOT_READY_MARKER + + +class _ExitedProcess: + pid = 12345 + + def wait(self, timeout=None): + return 1 + + +class _RunningProcess: + pid = 23456 + + def wait(self, timeout=None): + raise subprocess.TimeoutExpired("craftbot", timeout) + + +class _DelayedFailureProcess: + pid = 34567 + + def __init__(self): + self.calls = 0 + + def wait(self, timeout=None): + self.calls += 1 + if self.calls == 1: + raise subprocess.TimeoutExpired("craftbot", timeout) + return 1 + + +def test_start_reports_immediate_child_failure(tmp_path, monkeypatch, capsys): + pid_file = tmp_path / "craftbot.pid" + log_file = tmp_path / "craftbot.log" + log_file.write_text("dependency failure\n", encoding="utf-8") + events = [] + + monkeypatch.setattr(craftbot, "PID_FILE", str(pid_file)) + monkeypatch.setattr(craftbot, "LOG_FILE", str(log_file)) + monkeypatch.setattr(craftbot, "RUN_SCRIPT", str(tmp_path / "run.py")) + monkeypatch.setattr( + craftbot, "_create_desktop_shortcut_unix", lambda args: events.append("shortcut") + ) + monkeypatch.setattr( + craftbot, "_open_browser_detached", lambda url: events.append("browser") + ) + + def fake_popen(*args, **kwargs): + return _ExitedProcess() + + monkeypatch.setattr(craftbot.subprocess, "Popen", fake_popen) + + assert craftbot.cmd_start([]) is False + + output = capsys.readouterr().out + assert "CraftBot failed to start" in output + assert "dependency failure" in output + assert not pid_file.exists() + assert events == [] + + +def test_macos_source_shortcut_uses_custom_backend_port( + tmp_path, monkeypatch, capsys +): + desktop = tmp_path / "Desktop" + desktop.mkdir() + base_dir = tmp_path / "Craft Bot" + base_dir.mkdir() + python_exe = "/Applications/Python 3.10/bin/python3.10" + + monkeypatch.setattr(craftbot, "_PLATFORM", "darwin") + monkeypatch.setattr(craftbot, "IS_FROZEN", False) + monkeypatch.setattr(craftbot, "BASE_DIR", str(base_dir)) + monkeypatch.setattr(craftbot, "_find_desktop", lambda: str(desktop)) + monkeypatch.setattr(craftbot, "_python_exe", lambda: python_exe) + + craftbot._create_desktop_shortcut_unix(["--backend-port", "8123"]) + + shortcut = desktop / "CraftBot.command" + content = shortcut.read_text() + assert f"cd {shlex.quote(str(base_dir))}" in content + assert "curl -fsS http://localhost:7925" in content + assert "curl -fsS http://localhost:8123" in content + assert "curl -fsS http://localhost:7926" not in content + assert "open http://localhost:7925" in content + assert f"exec {shlex.quote(python_exe)} craftbot.py start --backend-port 8123" in content + + output = capsys.readouterr().out + assert "Desktop shortcut created" in output + + +def test_macos_source_shortcut_accepts_equals_backend_port(tmp_path, monkeypatch): + desktop = tmp_path / "Desktop" + desktop.mkdir() + base_dir = tmp_path / "CraftBot" + base_dir.mkdir() + + monkeypatch.setattr(craftbot, "_PLATFORM", "darwin") + monkeypatch.setattr(craftbot, "IS_FROZEN", False) + monkeypatch.setattr(craftbot, "BASE_DIR", str(base_dir)) + monkeypatch.setattr(craftbot, "_find_desktop", lambda: str(desktop)) + monkeypatch.setattr(craftbot, "_python_exe", lambda: "/usr/local/bin/python3.10") + + craftbot._create_desktop_shortcut_unix(["--backend-port=8123"]) + + content = (desktop / "CraftBot.command").read_text() + assert "curl -fsS http://localhost:8123" in content + assert "craftbot.py start --backend-port=8123" in content + + +def test_start_ignores_stale_ready_marker_when_child_exits( + tmp_path, monkeypatch, capsys +): + pid_file = tmp_path / "craftbot.pid" + log_file = tmp_path / "craftbot.log" + log_file.write_text(f"old run\n{CRAFTBOT_READY_MARKER}\n", encoding="utf-8") + events = [] + + monkeypatch.setattr(craftbot, "PID_FILE", str(pid_file)) + monkeypatch.setattr(craftbot, "LOG_FILE", str(log_file)) + monkeypatch.setattr(craftbot, "RUN_SCRIPT", str(tmp_path / "run.py")) + monkeypatch.setattr( + craftbot, "_create_desktop_shortcut_unix", lambda args: events.append("shortcut") + ) + monkeypatch.setattr( + craftbot, "_open_browser_detached", lambda url: events.append(("browser", url)) + ) + + def fake_popen(*args, **kwargs): + return _DelayedFailureProcess() + + monkeypatch.setattr(craftbot.subprocess, "Popen", fake_popen) + + assert craftbot.cmd_start([]) is False + + output = capsys.readouterr().out + assert "CraftBot failed to start" in output + assert not pid_file.exists() + assert events == [] + + +def test_cli_start_exits_nonzero_when_start_fails(monkeypatch): + monkeypatch.setattr(sys, "argv", ["craftbot.py", "start"]) + monkeypatch.setattr(craftbot, "cmd_start", lambda args: False) + + with pytest.raises(SystemExit) as exc: + craftbot.main() + + assert exc.value.code == 1 + + +def test_source_install_returns_false_when_service_start_fails( + tmp_path, monkeypatch, capsys +): + monkeypatch.setattr(craftbot, "IS_FROZEN", False) + monkeypatch.setattr(craftbot, "BASE_DIR", str(tmp_path)) + monkeypatch.setattr(craftbot, "_PLATFORM", "darwin") + monkeypatch.setattr(craftbot, "_is_installed", lambda: True) + monkeypatch.setattr(craftbot, "cmd_start", lambda args: False) + monkeypatch.setattr( + craftbot, + "_close_console_window", + lambda: (_ for _ in ()).throw(AssertionError("should not close")), + ) + + assert craftbot.cmd_install([]) is False + + output = capsys.readouterr().out + assert "CraftBot failed to start" in output + + +def test_ready_marker_constant_is_shared(): + assert CRAFTBOT_READY_MARKER == "CRAFTBOT IS READY" diff --git a/tests/test_run_dependency_check.py b/tests/test_run_dependency_check.py new file mode 100644 index 00000000..66fca347 --- /dev/null +++ b/tests/test_run_dependency_check.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +import subprocess +import sys +import textwrap + +import pytest + +from app import runtime_preflight + + +def test_confirmed_missing_runtime_dependencies_exit(monkeypatch, capsys): + def fake_run(cmd, capture_output, text, timeout): + return subprocess.CompletedProcess( + cmd, + 0, + stdout='noise\n__CRAFTBOT_MISSING_RUNTIME_IMPORTS__["requests"]\n', + ) + + monkeypatch.setattr(runtime_preflight.subprocess, "run", fake_run) + + with pytest.raises(SystemExit) as exc: + runtime_preflight.ensure_runtime_dependencies( + use_conda=False, + env_name=None, + checks={"requests": "requests"}, + ) + + assert exc.value.code == 1 + output = capsys.readouterr().out + assert "CraftBot Python dependencies are missing" in output + assert "requests" in output + + +def test_probe_timeout_warns_and_continues(monkeypatch, capsys): + def fake_run(cmd, capture_output, text, timeout): + raise subprocess.TimeoutExpired(cmd, timeout) + + monkeypatch.setattr(runtime_preflight.subprocess, "run", fake_run) + + runtime_preflight.ensure_runtime_dependencies( + use_conda=False, + env_name=None, + checks={"requests": "requests"}, + ) + + output = capsys.readouterr().out + assert "Warning: CraftBot could not verify Python dependencies" in output + assert "timed out" in output + + +def test_malformed_probe_output_warns_and_continues(monkeypatch, capsys): + def fake_run(cmd, capture_output, text, timeout): + return subprocess.CompletedProcess( + cmd, + 0, + stdout="__CRAFTBOT_MISSING_RUNTIME_IMPORTS__not-json\n", + ) + + monkeypatch.setattr(runtime_preflight.subprocess, "run", fake_run) + + runtime_preflight.ensure_runtime_dependencies( + use_conda=False, + env_name=None, + checks={"requests": "requests"}, + ) + + output = capsys.readouterr().out + assert "Warning: CraftBot could not verify Python dependencies" in output + assert "unexpected probe output" in output + + +def test_app_main_runs_preflight_before_agent_core_import(): + code = textwrap.dedent( + """ + import importlib.abc + import sys + import types + + fake_preflight = types.ModuleType("app.runtime_preflight") + + def ensure_current_runtime_dependencies(): + raise SystemExit(77) + + fake_preflight.ensure_current_runtime_dependencies = ensure_current_runtime_dependencies + sys.modules["app.runtime_preflight"] = fake_preflight + + class BlockAgentCore(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == "agent_core" or fullname.startswith("agent_core."): + raise AssertionError("agent_core imported before runtime preflight") + return None + + sys.meta_path.insert(0, BlockAgentCore()) + + try: + import app.main # noqa: F401 + except SystemExit as exc: + assert exc.code == 77 + else: + raise AssertionError("app.main did not run runtime preflight") + """ + ) + + result = subprocess.run( + [sys.executable, "-c", code], + text=True, + capture_output=True, + ) + + assert result.returncode == 0, result.stderr diff --git a/tests/test_startup_constants_usage.py b/tests/test_startup_constants_usage.py new file mode 100644 index 00000000..d3b63a98 --- /dev/null +++ b/tests/test_startup_constants_usage.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +from pathlib import Path + +from startup_constants import CRAFTBOT_READY_MARKER + + +def test_ready_marker_literal_is_centralized(): + repo = Path(__file__).resolve().parents[1] + offenders = [] + for relative in ("craftbot.py", "run.py", "installer/api.py"): + text = (repo / relative).read_text(encoding="utf-8") + if f'"{CRAFTBOT_READY_MARKER}"' in text or f"'{CRAFTBOT_READY_MARKER}'" in text: + offenders.append(relative) + + assert offenders == [] From a7dcb2f13fcb7c828d0459f1da79fb2de2d240b6 Mon Sep 17 00:00:00 2001 From: namabeeru Date: Fri, 3 Jul 2026 22:53:39 +0900 Subject: [PATCH 02/28] fix: root pytest failures Signed-off-by: namabeeru --- pytest.ini | 2 ++ tests/test_trigger_router_and_parking.py | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/pytest.ini b/pytest.ini index 32af169e..ae89a87c 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,6 +1,8 @@ [pytest] markers = live: real end-to-end tests that hit live integrations and the real LLM (opt-in: `pytest -m live`) +testpaths = + tests # By default, skip live tests so plain `pytest` stays fast and offline. addopts = -m "not live" diff --git a/tests/test_trigger_router_and_parking.py b/tests/test_trigger_router_and_parking.py index 052e5907..bcdfcf88 100644 --- a/tests/test_trigger_router_and_parking.py +++ b/tests/test_trigger_router_and_parking.py @@ -21,9 +21,13 @@ def __init__(self, response: str): self.response = response self.calls = 0 - async def generate_response_async(self, system_prompt: str, user_prompt: str): + async def generate_response_async( + self, system_prompt: str, user_prompt: str, prompt_name=None, **_kwargs + ): self.calls += 1 + self.last_system_prompt = system_prompt self.last_prompt = user_prompt + self.last_prompt_name = prompt_name return self.response @@ -39,6 +43,7 @@ def test_route_to_existing_session(self): router = SessionRouter(llm, ROUTING_PROMPT) result = run(router.route("message", "continue that task", "sessions")) assert result["session_id"] == "abc123" + assert llm.last_prompt_name == "ROUTE_TO_SESSION" assert llm.calls == 1 def test_new_session_decision(self): From d4f58d685f3eca2b831b1522d6cc56593bb0558c Mon Sep 17 00:00:00 2001 From: namabeeru Date: Fri, 3 Jul 2026 23:27:45 +0900 Subject: [PATCH 03/28] fix: detect source checkout updates Signed-off-by: namabeeru --- app/updater.py | 173 +++++++++++++++++++++++++++++--------- scripts/updater.bat | 19 +++-- scripts/updater.sh | 54 ++++++++++++ tests/test_updater.py | 189 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 390 insertions(+), 45 deletions(-) create mode 100755 scripts/updater.sh create mode 100644 tests/test_updater.py diff --git a/app/updater.py b/app/updater.py index 3bd0f0f0..f882b155 100644 --- a/app/updater.py +++ b/app/updater.py @@ -38,50 +38,129 @@ def is_newer(remote: str, local: str) -> bool: # --------------------------------------------------------------------------- GITHUB_REPO = "CraftOS-dev/CraftBot" -GITHUB_LATEST_RELEASE_URL = f"https://api.github.com/repos/{GITHUB_REPO}/tags" +GITHUB_TAGS_URL = f"https://api.github.com/repos/{GITHUB_REPO}/tags" +GITHUB_LATEST_RELEASE_URL = GITHUB_TAGS_URL +UPDATE_BRANCH = "main" +GIT_PROBE_TIMEOUT = 15 async def check_for_update() -> Tuple[bool, str, str]: """Check whether a newer version is available on the remote repo. - Fetches the latest git tag from GitHub (e.g. ``v1.2.2``) and compares - it against the local version stored in settings.json. + Source checkouts can be ahead or behind the update branch while still + carrying the same tagged app version. Keep the release tag as the primary + version signal, then use a git comparison to catch source-checkout updates + when the release tag is unchanged. Returns: (update_available, current_version, latest_version) """ from app.config import get_app_version + current = get_app_version() + project_root = Path(__file__).resolve().parent.parent + + release_update = await _check_release_update(current) + if release_update[0]: + return release_update + + source_update = await _check_source_update(project_root, current) + if source_update is not None: + return source_update + + return release_update + + +async def _check_source_update( + project_root: Path, + current: str, + branch: str = UPDATE_BRANCH, +) -> Optional[Tuple[bool, str, str]]: + """Check whether this git checkout is behind the configured update branch. + + Returns ``None`` when the probe is inconclusive so callers can fall back to + the release-tag check instead of blocking update checks on git-specific + failures. + """ + if getattr(sys, "frozen", False): + return None + + try: + inside, _ = await _run_git( + ["git", "rev-parse", "--is-inside-work-tree"], str(project_root) + ) + if _decode_git_stdout(inside) != "true": + return None + + await _run_git( + ["git", "fetch", "origin", f"{branch}:refs/remotes/origin/{branch}"], + str(project_root), + ) + local_stdout, _ = await _run_git( + ["git", "rev-parse", "HEAD"], str(project_root) + ) + remote_stdout, _ = await _run_git( + ["git", "rev-parse", f"origin/{branch}"], str(project_root) + ) + + local_revision = _decode_git_stdout(local_stdout) + remote_revision = _decode_git_stdout(remote_stdout) + if not local_revision or not remote_revision: + return None + if local_revision == remote_revision: + return False, current, current + + count_stdout, _ = await _run_git( + [ + "git", + "rev-list", + "--left-right", + "--count", + f"HEAD...origin/{branch}", + ], + str(project_root), + ) + _ahead_text, behind_text = _decode_git_stdout(count_stdout).split() + behind = int(behind_text) + if behind > 0: + latest = f"{current}+{branch}.{remote_revision[:7]}" + return True, current, latest + + return False, current, current + except Exception: + return None + + +async def _check_release_update(current: str) -> Tuple[bool, str, str]: + """Check GitHub release tags against the local app version.""" import aiohttp - current = get_app_version() try: headers = {"Accept": "application/vnd.github.v3+json"} async with aiohttp.ClientSession() as session: async with session.get( - GITHUB_LATEST_RELEASE_URL, + GITHUB_TAGS_URL, headers=headers, timeout=aiohttp.ClientTimeout(total=15), ) as resp: tags = await resp.json(content_type=None) - - if not tags or not isinstance(tags, list): - return False, current, current - - # Find the highest semver tag (tags are not guaranteed sorted) - latest = "0.0.0" - for tag in tags: - name = tag.get("name", "") - try: - if parse_version(name) > parse_version(latest): - latest = name.strip().lstrip("vV") - except (ValueError, AttributeError): - continue - except Exception: - # Network error — treat as "no update available" + # Network error — treat as "no update available". + return False, current, current + + if not tags or not isinstance(tags, list): return False, current, current + # Find the highest semver tag. GitHub tags are not guaranteed sorted. + latest = "0.0.0" + for tag in tags: + name = tag.get("name", "") + try: + if parse_version(name) > parse_version(latest): + latest = name.strip().lstrip("vV") + except (ValueError, AttributeError): + continue + return is_newer(latest, current), current, latest @@ -95,14 +174,12 @@ async def check_for_update() -> Tuple[bool, str, str]: async def perform_update( progress_callback: Optional[Callable[[str], Awaitable[None]]] = None, ) -> None: - """Launch the external updater script in a new window, then shut down. - - The updater script (scripts/updater.bat on Windows) runs in its own - visible terminal and handles: waiting for us to exit, git pull, install, - and relaunch. This keeps the update logic out of the running Python - process — no in-process git mutation, no exit-code signalling, no - console-visibility hacks. If the updater fails, its window stays open - showing the error. + """Launch the external updater script, then shut down. + + The script waits for CraftBot to exit, pulls the update branch, installs + dependencies, and relaunches CraftBot. Running that work outside this + process avoids in-process git mutation and exit-code signalling. Failures + are written to updater.log. """ async def emit(msg: str) -> None: @@ -111,12 +188,8 @@ async def emit(msg: str) -> None: project_root = Path(__file__).resolve().parent.parent - target_branch = "main" - - if sys.platform == "win32": - updater_script = project_root / "scripts" / "updater.bat" - else: - updater_script = project_root / "scripts" / "updater.sh" + target_branch = UPDATE_BRANCH + updater_script = _updater_script_path(project_root) if not updater_script.exists(): raise RuntimeError(f"Updater script not found: {updater_script}") @@ -131,14 +204,14 @@ async def emit(msg: str) -> None: CREATE_NO_WINDOW = 0x08000000 DETACHED_PROCESS = 0x00000008 subprocess.Popen( - [str(updater_script), target_branch], + [str(updater_script), target_branch, sys.executable], cwd=str(project_root), creationflags=DETACHED_PROCESS | CREATE_NO_WINDOW, close_fds=True, ) else: subprocess.Popen( - ["sh", str(updater_script), target_branch], + ["sh", str(updater_script), target_branch, sys.executable], cwd=str(project_root), start_new_session=True, ) @@ -155,15 +228,39 @@ async def emit(msg: str) -> None: # --------------------------------------------------------------------------- -async def _run_git(cmd: list, cwd: str) -> Tuple[bytes, bytes]: +def _decode_git_stdout(stdout: bytes) -> str: + return stdout.decode("utf-8", errors="replace").strip() + + +def _updater_script_path(project_root: Path, platform: str = sys.platform) -> Path: + if platform == "win32": + return project_root / "scripts" / "updater.bat" + return project_root / "scripts" / "updater.sh" + + +async def _run_git( + cmd: list, cwd: str, timeout: int = GIT_PROBE_TIMEOUT +) -> Tuple[bytes, bytes]: """Run a git command asynchronously; raise on non-zero exit.""" + env = os.environ.copy() + env.setdefault("GIT_TERMINAL_PROMPT", "0") + proc = await asyncio.create_subprocess_exec( *cmd, cwd=cwd, + env=env, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) - stdout, stderr = await proc.communicate() + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError as exc: + proc.kill() + stdout, stderr = await proc.communicate() + raise RuntimeError( + f"{' '.join(cmd)} timed out after {timeout} seconds" + ) from exc + if proc.returncode != 0: err = ( stderr.decode("utf-8", errors="replace").strip() diff --git a/scripts/updater.bat b/scripts/updater.bat index 9a75c2ff..fd60cf30 100644 --- a/scripts/updater.bat +++ b/scripts/updater.bat @@ -2,43 +2,48 @@ setlocal rem Argument 1 is the git branch to pull (default: main). -set BRANCH=%1 +set "BRANCH=%~1" if "%BRANCH%"=="" set BRANCH=main +rem Argument 2 is the Python executable that launched CraftBot. +set "PYTHON_BIN=%~2" +if "%PYTHON_BIN%"=="" set PYTHON_BIN=python + rem Project root is the parent of scripts/ cd /d "%~dp0.." rem Log everything to updater.log so failures are debuggable (we run headlessly). -set LOG=%~dp0..\updater.log +set "LOG=%~dp0..\updater.log" echo. >> "%LOG%" echo ============================================ >> "%LOG%" echo %DATE% %TIME% - Updater start (branch=%BRANCH%) >> "%LOG%" echo CWD=%CD% >> "%LOG%" +echo Python=%PYTHON_BIN% >> "%LOG%" rem Wait for current CraftBot to fully terminate. timeout /t 3 /nobreak > nul echo --- git fetch --- >> "%LOG%" -git fetch origin %BRANCH% >> "%LOG%" 2>&1 +git fetch origin "%BRANCH%" >> "%LOG%" 2>&1 if errorlevel 1 goto :fail echo --- git checkout --- >> "%LOG%" -git checkout %BRANCH% >> "%LOG%" 2>&1 +git checkout "%BRANCH%" >> "%LOG%" 2>&1 if errorlevel 1 goto :fail echo --- git pull --- >> "%LOG%" -git pull origin %BRANCH% >> "%LOG%" 2>&1 +git pull --ff-only origin "%BRANCH%" >> "%LOG%" 2>&1 if errorlevel 1 goto :fail if exist install.py ( echo --- install.py --- >> "%LOG%" - python install.py >> "%LOG%" 2>&1 + "%PYTHON_BIN%" install.py >> "%LOG%" 2>&1 if errorlevel 1 goto :fail ) echo --- relaunching CraftBot --- >> "%LOG%" rem Launch the new CraftBot. This bat process exits and the new run.py takes over. -start "CraftBot" python run.py --conda +start "CraftBot" "%PYTHON_BIN%" run.py --conda echo %DATE% %TIME% - Updater done, relaunched CraftBot >> "%LOG%" exit /b 0 diff --git a/scripts/updater.sh b/scripts/updater.sh new file mode 100755 index 00000000..be01ad00 --- /dev/null +++ b/scripts/updater.sh @@ -0,0 +1,54 @@ +#!/bin/sh +set -u + +# Argument 1 is the git branch to pull (default: main). +BRANCH="${1:-main}" + +# Argument 2 is the Python executable that launched CraftBot. Reusing it keeps +# updates on the same interpreter and avoids python/python3 mismatches. +PYTHON_BIN="${2:-${PYTHON:-python3}}" + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ROOT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) +LOG="$ROOT_DIR/updater.log" + +log() { + printf '%s\n' "$*" >> "$LOG" +} + +fail() { + log "$(date) - UPDATE FAILED: $*" + exit 1 +} + +{ + printf '\n' + printf '============================================\n' + printf '%s - Updater start (branch=%s)\n' "$(date)" "$BRANCH" + printf 'CWD=%s\n' "$ROOT_DIR" + printf 'Python=%s\n' "$PYTHON_BIN" +} >> "$LOG" + +cd "$ROOT_DIR" || fail "could not cd to $ROOT_DIR" + +# Wait for current CraftBot to fully terminate. +sleep 3 + +log "--- git fetch ---" +git fetch origin "$BRANCH" >> "$LOG" 2>&1 || fail "git fetch failed" + +log "--- git checkout ---" +git checkout "$BRANCH" >> "$LOG" 2>&1 || fail "git checkout failed" + +log "--- git pull ---" +git pull --ff-only origin "$BRANCH" >> "$LOG" 2>&1 || fail "git pull failed" + +if [ -f install.py ]; then + log "--- install.py ---" + "$PYTHON_BIN" install.py >> "$LOG" 2>&1 || fail "install.py failed" +fi + +log "--- relaunching CraftBot ---" +nohup "$PYTHON_BIN" run.py --conda >> "$LOG" 2>&1 & +log "$(date) - Updater done, relaunched CraftBot" +exit 0 diff --git a/tests/test_updater.py b/tests/test_updater.py new file mode 100644 index 00000000..89f1eeb0 --- /dev/null +++ b/tests/test_updater.py @@ -0,0 +1,189 @@ +# -*- coding: utf-8 -*- + +import asyncio +from pathlib import Path + +import pytest + +from app import updater + + +INSIDE_WORK_TREE = ("git", "rev-parse", "--is-inside-work-tree") +FETCH_MAIN = ("git", "fetch", "origin", "main:refs/remotes/origin/main") +HEAD = ("git", "rev-parse", "HEAD") +ORIGIN_MAIN = ("git", "rev-parse", "origin/main") +COUNT_MAIN = ("git", "rev-list", "--left-right", "--count", "HEAD...origin/main") + +LOCAL_REVISION = b"1111111111111111111111111111111111111111\n" +REMOTE_REVISION = b"2222222222222222222222222222222222222222\n" +LOCAL_AHEAD_REVISION = b"3333333333333333333333333333333333333333\n" + + +def run(coro): + return asyncio.run(coro) + + +def stub_git(monkeypatch, responses): + calls = [] + + async def fake_run_git(cmd, cwd): + key = tuple(cmd) + calls.append(key) + if key not in responses: + raise AssertionError(f"unexpected git command: {cmd}") + return responses[key], b"" + + monkeypatch.setattr(updater, "_run_git", fake_run_git) + return calls + + +def test_source_update_detects_remote_main_ahead(monkeypatch, tmp_path): + calls = stub_git( + monkeypatch, + { + INSIDE_WORK_TREE: b"true\n", + FETCH_MAIN: b"", + HEAD: LOCAL_REVISION, + ORIGIN_MAIN: REMOTE_REVISION, + COUNT_MAIN: b"0\t3\n", + }, + ) + + result = run(updater._check_source_update(tmp_path, "1.3.4", branch="main")) + + assert result == (True, "1.3.4", "1.3.4+main.2222222") + assert FETCH_MAIN in calls + + +@pytest.mark.parametrize( + ("local_revision", "remote_revision", "count_output"), + [ + (LOCAL_REVISION, LOCAL_REVISION, None), + (LOCAL_AHEAD_REVISION, REMOTE_REVISION, b"2\t0\n"), + ], +) +def test_source_update_reports_no_update_without_remote_commits( + monkeypatch, tmp_path, local_revision, remote_revision, count_output +): + responses = { + INSIDE_WORK_TREE: b"true\n", + FETCH_MAIN: b"", + HEAD: local_revision, + ORIGIN_MAIN: remote_revision, + } + if count_output is not None: + responses[COUNT_MAIN] = count_output + stub_git(monkeypatch, responses) + + assert run(updater._check_source_update(tmp_path, "1.3.4", branch="main")) == ( + False, + "1.3.4", + "1.3.4", + ) + + +def test_source_update_returns_none_outside_git_checkout(monkeypatch, tmp_path): + async def fake_run_git(cmd, cwd): + raise RuntimeError("not a git checkout") + + monkeypatch.setattr(updater, "_run_git", fake_run_git) + + assert run(updater._check_source_update(tmp_path, "1.3.4", branch="main")) is None + + +def test_check_for_update_prefers_release_tag_when_semver_is_newer(monkeypatch): + async def fail_source_update(project_root, current, branch=updater.UPDATE_BRANCH): + raise AssertionError("source check should not run when release is newer") + + async def newer_release_check(current): + return True, current, "1.3.5" + + monkeypatch.setattr("app.config.get_app_version", lambda: "1.3.4") + monkeypatch.setattr(updater, "_check_source_update", fail_source_update) + monkeypatch.setattr(updater, "_check_release_update", newer_release_check) + + assert run(updater.check_for_update()) == (True, "1.3.4", "1.3.5") + + +def test_check_for_update_uses_source_checkout_when_release_tag_is_current(monkeypatch): + async def fake_source_update(project_root, current, branch=updater.UPDATE_BRANCH): + return True, current, "1.3.4+main.2222222" + + async def current_release_check(current): + return False, current, current + + monkeypatch.setattr("app.config.get_app_version", lambda: "1.3.4") + monkeypatch.setattr(updater, "_check_source_update", fake_source_update) + monkeypatch.setattr(updater, "_check_release_update", current_release_check) + + assert run(updater.check_for_update()) == ( + True, + "1.3.4", + "1.3.4+main.2222222", + ) + + +def test_check_for_update_falls_back_to_release_tags(monkeypatch): + async def no_source_update(project_root, current, branch=updater.UPDATE_BRANCH): + return None + + async def fake_release_check(current): + return False, current, "1.3.4" + + monkeypatch.setattr("app.config.get_app_version", lambda: "1.3.4") + monkeypatch.setattr(updater, "_check_source_update", no_source_update) + monkeypatch.setattr(updater, "_check_release_update", fake_release_check) + + assert run(updater.check_for_update()) == (False, "1.3.4", "1.3.4") + + +def test_perform_update_launches_posix_script_with_current_python(monkeypatch): + class ExitCalled(Exception): + def __init__(self, code): + self.code = code + super().__init__(code) + + launched = {} + + def fake_popen(args, **kwargs): + launched["args"] = args + launched["kwargs"] = kwargs + + async def no_sleep(delay): + return None + + def fake_exit(code): + raise ExitCalled(code) + + project_root = Path(__file__).resolve().parent.parent + updater_script = project_root / "scripts" / "updater.sh" + + monkeypatch.setattr(updater.sys, "platform", "linux") + monkeypatch.setattr(updater.subprocess, "Popen", fake_popen) + monkeypatch.setattr(updater.asyncio, "sleep", no_sleep) + monkeypatch.setattr(updater.os, "_exit", fake_exit) + + with pytest.raises(ExitCalled) as exc_info: + run(updater.perform_update()) + + assert exc_info.value.code == 0 + assert launched["args"] == [ + "sh", + str(updater_script), + updater.UPDATE_BRANCH, + updater.sys.executable, + ] + assert launched["kwargs"]["cwd"] == str(project_root) + assert launched["kwargs"]["start_new_session"] is True + + +@pytest.mark.parametrize( + ("platform", "expected"), + [("win32", "updater.bat"), ("darwin", "updater.sh"), ("linux", "updater.sh")], +) +def test_updater_script_path_is_platform_specific(platform, expected): + assert updater._updater_script_path(Path("/repo"), platform).name == expected + + +def test_posix_updater_script_is_versioned(): + assert (Path(__file__).resolve().parent.parent / "scripts" / "updater.sh").is_file() From fcb781c0dbdd03560257251f24d3b113cfbb38a4 Mon Sep 17 00:00:00 2001 From: CraftBot Date: Sun, 5 Jul 2026 15:11:49 +0900 Subject: [PATCH 04/28] fix agent status not update correctly issue --- app/ui_layer/adapters/browser_adapter.py | 24 +++++++++++++ .../src/hooks/useDerivedAgentStatus.ts | 25 +++++++++++--- .../frontend/src/store/slices/tasksSlice.ts | 15 ++++++-- .../browser/frontend/src/types/index.ts | 1 + app/ui_layer/controller/ui_controller.py | 34 +++++++++++++++++-- 5 files changed, 89 insertions(+), 10 deletions(-) diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index a5270539..da08bc80 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -520,6 +520,9 @@ async def add_item(self, item: ActionItem) -> None: "itemType": item.item_type, "parentId": item.parent_id, "createdAt": int(item.created_at * 1000), + "completedAt": ( + int(item.completed_at * 1000) if item.completed_at else None + ), "duration": item.duration, "input": item.input_data, "output": item.output_data, @@ -558,6 +561,11 @@ async def update_item(self, item_id: str, status: str) -> None: "data": { "id": item_id, "status": status, + "completedAt": ( + int(matched_item.completed_at * 1000) + if matched_item.completed_at + else None + ), "duration": matched_item.duration, "output": matched_item.output_data, "error": matched_item.error_message, @@ -630,6 +638,11 @@ async def update_item_by_name( "data": { "id": matched_item.id, "status": status, + "completedAt": ( + int(matched_item.completed_at * 1000) + if matched_item.completed_at + else None + ), "duration": matched_item.duration, "output": matched_item.output_data, "error": matched_item.error_message, @@ -709,6 +722,11 @@ async def update_item_data( "data": { "id": item_id, "status": matched_item.status, + "completedAt": ( + int(matched_item.completed_at * 1000) + if matched_item.completed_at + else None + ), "duration": matched_item.duration, "output": matched_item.output_data, "error": matched_item.error_message, @@ -8050,6 +8068,9 @@ async def _handle_action_history( "itemType": a.item_type, "parentId": a.parent_id, "createdAt": int(a.created_at * 1000), + "completedAt": ( + int(a.completed_at * 1000) if a.completed_at else None + ), "duration": a.duration, "input": a.input_data, "output": a.output_data, @@ -8720,6 +8741,9 @@ def _get_initial_state(self) -> Dict[str, Any]: "itemType": a.item_type, "parentId": a.parent_id, "createdAt": int(a.created_at * 1000), + "completedAt": ( + int(a.completed_at * 1000) if a.completed_at else None + ), "duration": a.duration, "input": a.input_data, "output": a.output_data, diff --git a/app/ui_layer/browser/frontend/src/hooks/useDerivedAgentStatus.ts b/app/ui_layer/browser/frontend/src/hooks/useDerivedAgentStatus.ts index 052ebb10..432a5eb6 100644 --- a/app/ui_layer/browser/frontend/src/hooks/useDerivedAgentStatus.ts +++ b/app/ui_layer/browser/frontend/src/hooks/useDerivedAgentStatus.ts @@ -64,14 +64,29 @@ export function useDerivedAgentStatus( } // Priority 3: If the last message is from user, agent is processing it - // (no running tasks yet means agent is still thinking/preparing) + // (no running tasks yet means agent is still thinking/preparing). + // + // Escape hatch: the agent may finish the work without ever posting a + // chat reply (e.g. the response is the task itself). If any task or + // action was started or finished after the user's message — and nothing + // is running any more (checked above) — the message has been handled, + // so don't report "working" forever. if (messages.length > 0) { const lastMessage = messages[messages.length - 1] if (lastMessage.style === 'user') { - return { - state: 'working' as AgentState, - message: 'Agent is working', - loading: true, + // ChatMessage.timestamp is epoch seconds; ActionItem times are ms. + const lastMessageMs = lastMessage.timestamp * 1000 + const agentActedSince = actions.some( + a => + (a.createdAt ?? 0) >= lastMessageMs || + (a.completedAt ?? 0) >= lastMessageMs + ) + if (!agentActedSince) { + return { + state: 'working' as AgentState, + message: 'Agent is working', + loading: true, + } } } } diff --git a/app/ui_layer/browser/frontend/src/store/slices/tasksSlice.ts b/app/ui_layer/browser/frontend/src/store/slices/tasksSlice.ts index 8aec16a1..a9d2a38e 100644 --- a/app/ui_layer/browser/frontend/src/store/slices/tasksSlice.ts +++ b/app/ui_layer/browser/frontend/src/store/slices/tasksSlice.ts @@ -52,6 +52,7 @@ const tasksSlice = createSlice({ updateStatus(state, action: PayloadAction<{ id: string status: ActionItem['status'] + completedAt?: number duration?: number output?: string error?: string @@ -59,6 +60,7 @@ const tasksSlice = createSlice({ const entry = state.entities[action.payload.id] if (!entry) return entry.status = action.payload.status + if (action.payload.completedAt != null) entry.completedAt = action.payload.completedAt if (action.payload.duration !== undefined) entry.duration = action.payload.duration if (action.payload.output !== undefined) entry.output = action.payload.output if (action.payload.error !== undefined) entry.error = action.payload.error @@ -96,7 +98,10 @@ const tasksSlice = createSlice({ }, markCancelled(state, action: PayloadAction<{ taskId: string }>) { const entry = state.entities[action.payload.taskId] - if (entry) entry.status = 'cancelled' + if (entry) { + entry.status = 'cancelled' + entry.completedAt = Date.now() + } state.cancellingTaskId = null }, setCompletingTaskId(state, action: PayloadAction) { @@ -104,7 +109,10 @@ const tasksSlice = createSlice({ }, markCompleted(state, action: PayloadAction<{ taskId: string }>) { const entry = state.entities[action.payload.taskId] - if (entry) entry.status = 'completed' + if (entry) { + entry.status = 'completed' + entry.completedAt = Date.now() + } state.completingTaskId = null }, setResumingTaskId(state, action: PayloadAction) { @@ -116,6 +124,7 @@ const tasksSlice = createSlice({ entry.status = 'running' // Clear the completed-at duration so the row stops showing the // final elapsed time and ticks live again. + entry.completedAt = undefined entry.duration = undefined entry.error = undefined } @@ -165,6 +174,7 @@ register('action_update', (data, dispatch) => { const d = data as { id: string status: string + completedAt?: number duration?: number output?: string error?: string @@ -172,6 +182,7 @@ register('action_update', (data, dispatch) => { dispatch(updateStatus({ id: d.id, status: d.status as ActionItem['status'], + completedAt: d.completedAt, duration: d.duration, output: d.output, error: d.error, diff --git a/app/ui_layer/browser/frontend/src/types/index.ts b/app/ui_layer/browser/frontend/src/types/index.ts index 7e13cb6d..a5f21f3e 100644 --- a/app/ui_layer/browser/frontend/src/types/index.ts +++ b/app/ui_layer/browser/frontend/src/types/index.ts @@ -46,6 +46,7 @@ export interface ActionItem { itemType: ItemType parentId?: string createdAt?: number + completedAt?: number input?: string output?: string error?: string diff --git a/app/ui_layer/controller/ui_controller.py b/app/ui_layer/controller/ui_controller.py index 2a02ca94..c729f143 100644 --- a/app/ui_layer/controller/ui_controller.py +++ b/app/ui_layer/controller/ui_controller.py @@ -336,7 +336,7 @@ async def _watch_agent_events(self) -> None: streams = self._agent.event_stream_manager.get_all_streams_with_ids() for task_id, stream in streams: for event in stream.as_list(): - key = (event.iso_ts, event.kind, event.message) + key = (task_id, event.iso_ts, event.kind, event.message) self._state_store.dispatch("MARK_EVENT_SEEN", key) # Rebuild UI state from restored events without emitting to UI ui_event = EventTransformer.transform(event, task_id) @@ -350,8 +350,13 @@ async def _watch_agent_events(self) -> None: for task_id, stream in streams: for event in stream.as_list(): - # Create deduplication key - key = (event.iso_ts, event.kind, event.message) + # Create deduplication key. task_id must be part of + # the key: iso_ts is seconds-precision and task_end + # messages are generic, so two tasks ending in the + # same second would otherwise collide and the second + # TASK_END would be dropped — leaving that task stuck + # "running" in every UI until restart. + key = (task_id, event.iso_ts, event.kind, event.message) # Skip if already seen if key in self._state_store.state.seen_event_keys: @@ -530,6 +535,29 @@ async def _consume_triggers(self) -> None: trigger = await self._agent.trigger_service.next() await self._agent.react(trigger) await self._agent.trigger_service.ack(trigger) + # A react cycle can end without a task_end or visible + # action (conversation-mode reply, or the agent ignoring + # the message). Nothing else resets the status in that + # case, so flip WORKING back to IDLE once the cycle + # settles with nothing running. WAITING_FOR_USER is left + # untouched. + if ( + self._state_store.state.agent_state + == AgentStateType.WORKING + and not self._state_store.state.has_running_items() + ): + self._state_store.dispatch( + "SET_AGENT_STATE", AgentStateType.IDLE.value + ) + self._event_bus.emit( + UIEvent( + type=UIEventType.AGENT_STATE_CHANGED, + data={ + "state": AgentStateType.IDLE.value, + "status_message": "Agent is idle", + }, + ) + ) except asyncio.CancelledError: # Shutdown: deliberately no ack/nack — the row stays # CLAIMED and is reclaimed (re-delivered) on next boot. From 3e8d0b16614af66f9da5889485a274cb99ae3905 Mon Sep 17 00:00:00 2001 From: CraftBot Date: Sun, 5 Jul 2026 15:29:30 +0900 Subject: [PATCH 05/28] fix task sorting after completion --- .../browser/frontend/src/pages/Chat/ChatPage.tsx | 14 +++++++++----- .../browser/frontend/src/pages/Tasks/TasksPage.tsx | 12 ++++++++---- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx b/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx index 4cd82c8d..bf2c7e5d 100644 --- a/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx @@ -90,17 +90,21 @@ export function ChatPage() { }, [setReplyTarget]) // Split tasks into "in-progress" (running / waiting / paused / pending) and - // "ended" (completed / error / cancelled). Each group is sorted newest-first - // by createdAt so a freshly-started task lands on top of its section, and a - // task that just ended pops to the top of the ended section. The combined + // "ended" (completed / error / cancelled). The active group is sorted + // newest-first by createdAt so a freshly-started task lands on top of its + // section; the ended group is sorted newest-first by completedAt (falling + // back to createdAt for rows persisted before that field existed) so a task + // that just ended pops to the top of the ended section. The combined // `tasks` array keeps active-then-ended order so the pagination hook's count // stays correct. const { tasks, activeTasks, endedTasks } = useMemo(() => { const taskItems = actions.filter(a => a.itemType === 'task') const isEnded = (s: string) => s === 'completed' || s === 'error' || s === 'cancelled' const byNewestFirst = (a: ActionItem, b: ActionItem) => (b.createdAt ?? 0) - (a.createdAt ?? 0) + const byNewestEnded = (a: ActionItem, b: ActionItem) => + (b.completedAt ?? b.createdAt ?? 0) - (a.completedAt ?? a.createdAt ?? 0) const active = taskItems.filter(t => !isEnded(t.status)).sort(byNewestFirst) - const ended = taskItems.filter(t => isEnded(t.status)).sort(byNewestFirst) + const ended = taskItems.filter(t => isEnded(t.status)).sort(byNewestEnded) return { tasks: [...active, ...ended], activeTasks: active, endedTasks: ended } }, [actions]) const [selectedTaskId, setSelectedTaskId] = useState(null) @@ -148,7 +152,7 @@ export function ChatPage() {
{mascotVisible && }
-

Tasks & Actions

+

All Tasks

{loadingOlderActions && ( diff --git a/app/ui_layer/browser/frontend/src/pages/Tasks/TasksPage.tsx b/app/ui_layer/browser/frontend/src/pages/Tasks/TasksPage.tsx index 97491d35..17bbe2e4 100644 --- a/app/ui_layer/browser/frontend/src/pages/Tasks/TasksPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Tasks/TasksPage.tsx @@ -586,17 +586,21 @@ export function TasksPage() { const [, forceTick] = useState(0) // Split tasks into "in-progress" (running / waiting / paused / pending) and - // "ended" (completed / error / cancelled). Each group is sorted newest-first - // by createdAt so a freshly-started task appears at the top of its section, - // and a task that just ended pops to the top of the ended section. The + // "ended" (completed / error / cancelled). The active group is sorted + // newest-first by createdAt so a freshly-started task appears at the top of + // its section; the ended group is sorted newest-first by completedAt + // (falling back to createdAt for rows persisted before that field existed) + // so a task that just ended pops to the top of the ended section. The // combined `tasks` array keeps active-then-ended order so pagination counts // and selection lookups work unchanged. const { tasks, activeTasks, endedTasks } = useMemo(() => { const taskItems = actions.filter(a => a.itemType === 'task') const isEnded = (s: string) => s === 'completed' || s === 'error' || s === 'cancelled' const byNewestFirst = (a: ActionItem, b: ActionItem) => (b.createdAt ?? 0) - (a.createdAt ?? 0) + const byNewestEnded = (a: ActionItem, b: ActionItem) => + (b.completedAt ?? b.createdAt ?? 0) - (a.completedAt ?? a.createdAt ?? 0) const active = taskItems.filter(t => !isEnded(t.status)).sort(byNewestFirst) - const ended = taskItems.filter(t => isEnded(t.status)).sort(byNewestFirst) + const ended = taskItems.filter(t => isEnded(t.status)).sort(byNewestEnded) return { tasks: [...active, ...ended], activeTasks: active, endedTasks: ended } }, [actions]) From a147b1901c3f62472f905f2a182947074de8a68c Mon Sep 17 00:00:00 2001 From: CraftBot Date: Sun, 5 Jul 2026 15:36:47 +0900 Subject: [PATCH 06/28] Fix LivingUI disappear on UI issue --- app/ui_layer/adapters/browser_adapter.py | 18 ++++++++++++++++++ .../frontend/src/contexts/WebSocketContext.tsx | 7 ++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index da08bc80..8c44c52d 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -1424,6 +1424,24 @@ async def _websocket_handler( "data": self._get_skill_meta(), } ) + # Push the Living UI list on connect instead of relying on the + # client to request it. The frontend's request is sent from an + # onOpen handler registered after React mounts; when the socket + # opens before that (middleware connects during store bootstrap), + # the request was never sent and the side panel stayed empty + # until the next reconnect. + await ws.send_json( + { + "type": "living_ui_list", + "data": { + "success": True, + "projects": [ + p.to_dict() + for p in self._living_ui_manager.list_projects() + ], + }, + } + ) except (ConnectionResetError, ClientConnectionResetError, RuntimeError): # Gracefully handle connection closing self._ws_clients.discard(ws) diff --git a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx index 5c41a3fa..28cc09d4 100644 --- a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx +++ b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx @@ -356,9 +356,14 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { client.connect() // If the singleton already opened before we subscribed (common: middleware - // boots earlier than React mounting), sync the initial state now. + // boots earlier than React mounting), sync the initial state now. Our + // onOpen handler above never fired for this connection, so also request + // the Living UI list here — otherwise the side panel stays empty until + // the next reconnect. (The backend also pushes the list on connect; this + // covers older backends and doubles as a resync.) if (client.isConnected) { setState(prev => ({ ...prev, connected: true })) + client.sendString(JSON.stringify({ type: 'living_ui_list' })) } return () => { From da0be4a9b7e315dcada2421aa4d18a429c187822 Mon Sep 17 00:00:00 2001 From: false200 <214800619+false200@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:01:57 +0530 Subject: [PATCH 07/28] Fix find_files slowness with SQLite FindIndex (#354). Replace per-call os.walk with a cached filename index and watchdog updates while keeping existing path semantics and output shape. --- .gitignore | 3 +- app/data/action/find_files.py | 67 ++--- app/utils/file_index.py | 459 ++++++++++++++++++++++++++++++++++ 3 files changed, 487 insertions(+), 42 deletions(-) create mode 100644 app/utils/file_index.py diff --git a/.gitignore b/.gitignore index 7419a084..98ea330f 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,5 @@ agent_file_system/TASK_HISTORY.md !build_template.py docs/LIVING_UI_DEVELOPER_GUIDE.md agent_file_system/ACTIONS.md -agent_bundle/ \ No newline at end of file +agent_bundle/ +**/.craftbot/ \ No newline at end of file diff --git a/app/data/action/find_files.py b/app/data/action/find_files.py index 6ad309d2..6afcca5d 100644 --- a/app/data/action/find_files.py +++ b/app/data/action/find_files.py @@ -1,6 +1,30 @@ from agent_core import action +def _find_files_impl(base_directory: str, file_pattern: str, recursive: bool) -> dict: + import glob + import os + + from app.utils import file_index + + if not recursive: + matches = [] + for path in glob.glob(os.path.join(base_directory, file_pattern)): + if os.path.isfile(path): + matches.append(os.path.abspath(path)) + else: + file_index.start_watcher(base_directory) + matches = file_index.search(base_directory, file_pattern) + + return { + "status": "success", + "matches": matches, + "message": "" + if matches + else f"No files matching '{file_pattern}' were found in '{base_directory}'.", + } + + @action( name="find_files", description="Finds files by name or pattern across the system. Supports wildcards and recursive search. Use absolute paths for base_directory.", @@ -45,7 +69,6 @@ ) def find_file_by_name(input_data: dict) -> dict: import os - import fnmatch pattern = (input_data.get("pattern") or "").strip() recursive = bool(input_data.get("recursive", True)) @@ -85,26 +108,7 @@ def find_file_by_name(input_data: dict) -> dict: else pattern ) - matches = [] - for root, dirs, files in os.walk(base_directory): - try: - for name in files: - if fnmatch.fnmatch(name, file_pattern): - matches.append(os.path.abspath(os.path.join(root, name))) - except PermissionError: - # Skip directories we don't have access to - continue - - if not recursive: - break - - return { - "status": "success", - "matches": matches, - "message": "" - if matches - else f"No files matching '{file_pattern}' were found in '{base_directory}'.", - } + return _find_files_impl(base_directory, file_pattern, recursive) @action( @@ -151,7 +155,6 @@ def find_file_by_name(input_data: dict) -> dict: ) def find_file_by_name_windows(input_data: dict) -> dict: import os - import fnmatch pattern = (input_data.get("pattern") or "").strip() recursive = bool(input_data.get("recursive", True)) @@ -194,22 +197,4 @@ def find_file_by_name_windows(input_data: dict) -> dict: else pattern ) - matches = [] - for root, dirs, files in os.walk(base_directory): - try: - for name in files: - if fnmatch.fnmatch(name, file_pattern): - matches.append(os.path.abspath(os.path.join(root, name))) - except PermissionError: - continue - - if not recursive: - break - - return { - "status": "success", - "matches": matches, - "message": "" - if matches - else f"No files matching '{file_pattern}' were found in '{base_directory}'.", - } + return _find_files_impl(base_directory, file_pattern, recursive) diff --git a/app/utils/file_index.py b/app/utils/file_index.py new file mode 100644 index 00000000..0928d7f9 --- /dev/null +++ b/app/utils/file_index.py @@ -0,0 +1,459 @@ +""" +CraftBot FindIndex — SQLite FTS5 trigram filename index with watchdog updates. + +Replaces live os.walk retrieval for find_files (issue #354). +Full crawl under the resolved base_directory — no directory skip list. +""" + +from __future__ import annotations + +import fnmatch +import os +import re +import sqlite3 +import threading +import time +from dataclasses import dataclass + +try: + from watchdog.events import FileSystemEventHandler + from watchdog.observers import Observer + + WATCHDOG_AVAILABLE = True +except ImportError: + WATCHDOG_AVAILABLE = False + + class FileSystemEventHandler: # type: ignore[no-redef] + pass + + Observer = None # type: ignore[misc, assignment] + +_DEBOUNCE_SECONDS = 5.0 +_DB_NAME = "findindex.db" +_META_ROOT = "indexed_root" +_META_BUILT_AT = "built_at" + +_build_lock = threading.Lock() +_watcher_lock = threading.Lock() +_watchers: dict[str, _RootWatcher] = {} +_needs_incremental: dict[str, bool] = {} + + +@dataclass +class IndexStats: + files_indexed: int + files_added: int + files_updated: int + files_removed: int + duration_seconds: float + + +def _index_dir(root: str) -> str: + return os.path.join(os.path.abspath(root), ".craftbot") + + +def _db_path(root: str) -> str: + return os.path.join(_index_dir(root), _DB_NAME) + + +def _connect(root: str) -> sqlite3.Connection: + os.makedirs(_index_dir(root), exist_ok=True) + conn = sqlite3.connect(_db_path(root), check_same_thread=False) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=30000") + return conn + + +def _init_schema(conn: sqlite3.Connection) -> None: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS files ( + rowid INTEGER PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + basename TEXT NOT NULL, + mtime REAL NOT NULL, + size INTEGER NOT NULL + ); + + CREATE VIRTUAL TABLE IF NOT EXISTS files_fts USING fts5( + basename, + content='files', + content_rowid='rowid', + tokenize='trigram' + ); + + CREATE TRIGGER IF NOT EXISTS files_ai AFTER INSERT ON files BEGIN + INSERT INTO files_fts(rowid, basename) VALUES (new.rowid, new.basename); + END; + + CREATE TRIGGER IF NOT EXISTS files_ad AFTER DELETE ON files BEGIN + INSERT INTO files_fts(files_fts, rowid, basename) + VALUES ('delete', old.rowid, old.basename); + END; + + CREATE TRIGGER IF NOT EXISTS files_au AFTER UPDATE ON files BEGIN + INSERT INTO files_fts(files_fts, rowid, basename) + VALUES ('delete', old.rowid, old.basename); + INSERT INTO files_fts(rowid, basename) VALUES (new.rowid, new.basename); + END; + """ + ) + conn.commit() + + +def _normalize_glob_part(part: str) -> str: + if any(ch in part for ch in "*?[]"): + return part + return f"{part}*" + + +def _split_or_patterns(pattern: str) -> list[str]: + if "|" in pattern: + parts = [part.strip() for part in pattern.split("|") if part.strip()] + elif re.search(r"\s+OR\s+", pattern, re.IGNORECASE): + parts = [ + part.strip() + for part in re.split(r"\s+OR\s+", pattern, flags=re.IGNORECASE) + if part.strip() + ] + else: + parts = [pattern] + return [_normalize_glob_part(part) for part in parts] + + +def _iter_files(root: str): + """Recursive file iterator mirroring os.walk (no skip list, no symlink follow).""" + stack = [root] + while stack: + dir_path = stack.pop() + try: + with os.scandir(dir_path) as entries: + for entry in entries: + try: + if entry.is_dir(follow_symlinks=False): + stack.append(entry.path) + elif entry.is_file(follow_symlinks=False): + yield entry + except OSError: + continue + except OSError: + continue + + +def _file_stat(entry: os.DirEntry) -> tuple[float, int] | None: + try: + st = entry.stat(follow_symlinks=False) + return (st.st_mtime, st.st_size) + except OSError: + return None + + +def build_index(root: str, force: bool = False) -> IndexStats: + """Build or incrementally refresh the filename index for *root*.""" + root = os.path.abspath(root) + started = time.perf_counter() + + with _build_lock: + conn = _connect(root) + try: + _init_schema(conn) + stored_root = conn.execute( + "SELECT value FROM meta WHERE key = ?", (_META_ROOT,) + ).fetchone() + has_rows = ( + conn.execute("SELECT 1 FROM files LIMIT 1").fetchone() is not None + ) + + if ( + not force + and has_rows + and stored_root + and stored_root[0] == root + and not _needs_incremental.get(root, False) + ): + total = conn.execute("SELECT COUNT(*) FROM files").fetchone()[0] + return IndexStats( + files_indexed=total, + files_added=0, + files_updated=0, + files_removed=0, + duration_seconds=time.perf_counter() - started, + ) + + if not force and has_rows and stored_root and stored_root[0] == root: + stats = _incremental_update(conn, root) + _needs_incremental[root] = False + conn.execute( + "INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)", + (_META_BUILT_AT, str(time.time())), + ) + conn.commit() + return IndexStats( + files_indexed=stats[0], + files_added=stats[1], + files_updated=stats[2], + files_removed=stats[3], + duration_seconds=time.perf_counter() - started, + ) + + conn.execute("DELETE FROM files") + conn.commit() + count = _full_crawl(conn, root) + conn.execute( + "INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)", + (_META_ROOT, root), + ) + conn.execute( + "INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)", + (_META_BUILT_AT, str(time.time())), + ) + conn.commit() + return IndexStats( + files_indexed=count, + files_added=count, + files_updated=0, + files_removed=0, + duration_seconds=time.perf_counter() - started, + ) + finally: + conn.close() + + +def _full_crawl(conn: sqlite3.Connection, root: str) -> int: + count = 0 + batch: list[tuple[str, str, float, int]] = [] + for entry in _iter_files(root): + stat = _file_stat(entry) + if stat is None: + continue + mtime, size = stat + batch.append((entry.path, entry.name, mtime, size)) + if len(batch) >= 5000: + conn.executemany( + "INSERT INTO files(path, basename, mtime, size) VALUES (?, ?, ?, ?)", + batch, + ) + count += len(batch) + batch.clear() + if batch: + conn.executemany( + "INSERT INTO files(path, basename, mtime, size) VALUES (?, ?, ?, ?)", + batch, + ) + count += len(batch) + conn.commit() + return count + + +def _incremental_update( + conn: sqlite3.Connection, root: str +) -> tuple[int, int, int, int]: + seen: set[str] = set() + added = updated = 0 + prefix = root if root.endswith(os.sep) else root + os.sep + + for entry in _iter_files(root): + stat = _file_stat(entry) + if stat is None: + continue + path = entry.path + seen.add(path) + mtime, size = stat + row = conn.execute( + "SELECT mtime, size FROM files WHERE path = ?", (path,) + ).fetchone() + if row is None: + conn.execute( + "INSERT INTO files(path, basename, mtime, size) VALUES (?, ?, ?, ?)", + (path, entry.name, mtime, size), + ) + added += 1 + elif row[0] != mtime or row[1] != size: + conn.execute( + "UPDATE files SET basename = ?, mtime = ?, size = ? WHERE path = ?", + (entry.name, mtime, size, path), + ) + updated += 1 + + removed = 0 + for (path,) in conn.execute("SELECT path FROM files"): + if path == root: + continue + if not path.startswith(prefix): + conn.execute("DELETE FROM files WHERE path = ?", (path,)) + removed += 1 + continue + if path not in seen: + conn.execute("DELETE FROM files WHERE path = ?", (path,)) + removed += 1 + + conn.commit() + total = conn.execute("SELECT COUNT(*) FROM files").fetchone()[0] + return total, added, updated, removed + + +def _fts_prefilter_sql(pattern: str) -> tuple[str, list[str]] | None: + if "*" not in pattern and "?" not in pattern and "[" not in pattern: + return ("f.basename = ?", [pattern]) + + if pattern.startswith("*"): + return None + + star = pattern.find("*") + if star > 0: + literal = pattern[:star] + if len(literal) >= 2 and literal.replace("_", "").isalnum(): + return ("files_fts.basename MATCH ?", [literal]) + return None + + +def _search_single( + conn: sqlite3.Connection, + root: str, + pattern: str, + limit: int | None, +) -> list[str]: + root_prefix = root if root.endswith(os.sep) else root + os.sep + prefilter = _fts_prefilter_sql(pattern) + matches: list[str] = [] + + if prefilter: + sql_fragment, params = prefilter + if "files_fts" in sql_fragment: + query = f""" + SELECT f.path, f.basename + FROM files f + INNER JOIN files_fts ON files_fts.rowid = f.rowid + WHERE f.path LIKE ? AND {sql_fragment} + """ + else: + query = f""" + SELECT f.path, f.basename + FROM files f + WHERE f.path LIKE ? AND {sql_fragment} + """ + rows = conn.execute(query, (root_prefix + "%", *params)) + else: + rows = conn.execute( + "SELECT path, basename FROM files WHERE path LIKE ?", + (root_prefix + "%",), + ) + + for path, basename in rows: + if fnmatch.fnmatch(basename, pattern): + matches.append(os.path.abspath(path)) + if limit is not None and len(matches) >= limit: + break + return matches + + +def search(root: str, pattern: str, limit: int | None = None) -> list[str]: + """Return all paths under *root* whose basename matches *pattern*.""" + root = os.path.abspath(root) + build_index(root, force=False) + + patterns = _split_or_patterns(pattern) + conn = _connect(root) + try: + seen: set[str] = set() + results: list[str] = [] + for part in patterns: + for path in _search_single(conn, root, part, limit): + if path not in seen: + seen.add(path) + results.append(path) + if limit is not None and len(results) >= limit: + return results + return results + finally: + conn.close() + + +def start_watcher(root: str) -> bool: + """Start a debounced watchdog observer for *root* (once per root).""" + if not WATCHDOG_AVAILABLE: + return False + + root = os.path.abspath(root) + with _watcher_lock: + if root in _watchers and _watchers[root].is_running: + return True + watcher = _RootWatcher(root, _DEBOUNCE_SECONDS) + if not watcher.start(): + return False + _watchers[root] = watcher + return True + + +class _RootWatcher: + def __init__(self, root: str, debounce_seconds: float) -> None: + self.root = root + self.debounce_seconds = debounce_seconds + self._observer: Observer | None = None + self._is_running = False + self._lock = threading.Lock() + self._debounce_timer: threading.Timer | None = None + self._pending = False + + @property + def is_running(self) -> bool: + return self._is_running + + def start(self) -> bool: + if not WATCHDOG_AVAILABLE or Observer is None: + return False + if not os.path.isdir(self.root): + return False + self._observer = Observer() + handler = _IndexEventHandler(self._on_change) + self._observer.schedule(handler, self.root, recursive=True) + self._observer.start() + self._is_running = True + return True + + def _on_change(self, _path: str, _event_type: str) -> None: + _needs_incremental[self.root] = True + with self._lock: + self._pending = True + if self._debounce_timer: + self._debounce_timer.cancel() + self._debounce_timer = threading.Timer(self.debounce_seconds, self._flush) + self._debounce_timer.start() + + def _flush(self) -> None: + with self._lock: + if not self._pending: + return + self._pending = False + self._debounce_timer = None + build_index(self.root, force=False) + + +class _IndexEventHandler(FileSystemEventHandler): + def __init__(self, callback) -> None: + super().__init__() + self._callback = callback + + def on_created(self, event) -> None: + if not event.is_directory: + self._callback(event.src_path, "created") + + def on_modified(self, event) -> None: + if not event.is_directory: + self._callback(event.src_path, "modified") + + def on_deleted(self, event) -> None: + if not event.is_directory: + self._callback(event.src_path, "deleted") + + def on_moved(self, event) -> None: + if event.is_directory: + return + self._callback(event.src_path, "deleted") + if event.dest_path: + self._callback(event.dest_path, "created") From 62771f6178c2023d1e79ad2507f1df89f78ccce4 Mon Sep 17 00:00:00 2001 From: false200 <214800619+false200@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:38:36 +0530 Subject: [PATCH 08/28] Address PR review feedback for find_files index. --- app/data/action/find_files.py | 16 ++++++++-------- app/utils/file_index.py | 12 +++++++++--- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/app/data/action/find_files.py b/app/data/action/find_files.py index 6afcca5d..a20d6df8 100644 --- a/app/data/action/find_files.py +++ b/app/data/action/find_files.py @@ -2,19 +2,19 @@ def _find_files_impl(base_directory: str, file_pattern: str, recursive: bool) -> dict: - import glob import os from app.utils import file_index + file_index.start_watcher(base_directory) + matches = file_index.search(base_directory, file_pattern) if not recursive: - matches = [] - for path in glob.glob(os.path.join(base_directory, file_pattern)): - if os.path.isfile(path): - matches.append(os.path.abspath(path)) - else: - file_index.start_watcher(base_directory) - matches = file_index.search(base_directory, file_pattern) + base_abs = os.path.abspath(base_directory) + matches = [ + path + for path in matches + if os.path.normcase(os.path.dirname(path)) == os.path.normcase(base_abs) + ] return { "status": "success", diff --git a/app/utils/file_index.py b/app/utils/file_index.py index 0928d7f9..d291d47f 100644 --- a/app/utils/file_index.py +++ b/app/utils/file_index.py @@ -173,7 +173,7 @@ def build_index(root: str, force: bool = False) -> IndexStats: not force and has_rows and stored_root - and stored_root[0] == root + and os.path.normcase(stored_root[0]) == os.path.normcase(root) and not _needs_incremental.get(root, False) ): total = conn.execute("SELECT COUNT(*) FROM files").fetchone()[0] @@ -302,13 +302,19 @@ def _fts_prefilter_sql(pattern: str) -> tuple[str, list[str]] | None: return ("f.basename = ?", [pattern]) if pattern.startswith("*"): + remainder = pattern[1:] + next_star = remainder.find("*") + literal = remainder[:next_star] if next_star >= 0 else remainder + if len(literal) >= 2 and "?" not in literal and "[" not in literal: + return ("files_fts.basename MATCH ?", [literal]) return None star = pattern.find("*") if star > 0: literal = pattern[:star] - if len(literal) >= 2 and literal.replace("_", "").isalnum(): - return ("files_fts.basename MATCH ?", [literal]) + if len(literal) >= 2 and "?" not in literal and "[" not in literal: + quoted = '"' + literal.replace('"', '""') + '"' + return ("files_fts.basename MATCH ?", [quoted]) return None From f71d591145f0c4d442755cac038e24245b353fb9 Mon Sep 17 00:00:00 2001 From: Tobias Garcia Date: Tue, 7 Jul 2026 15:14:31 +0900 Subject: [PATCH 09/28] Fix: Lazy index building on startup and file search parameter fixes --- agent_file_system/AGENT.md | 4 +- app/agent_base.py | 30 +++ app/config.py | 9 + app/config/settings.json | 3 + app/data/action/find_files.py | 155 ++++++------ app/data/agent_file_system_template/AGENT.md | 4 +- app/utils/file_index.py | 235 +++++++++++++++++-- 7 files changed, 347 insertions(+), 93 deletions(-) diff --git a/agent_file_system/AGENT.md b/agent_file_system/AGENT.md index 4b9e7c3f..0c9f2df8 100644 --- a/agent_file_system/AGENT.md +++ b/agent_file_system/AGENT.md @@ -1,5 +1,5 @@ --- -version: 3 +version: 5 purpose: agent operations manual --- @@ -782,6 +782,8 @@ comfortably within one response's output-token budget. ### find_files vs list_folder - `list_folder`: top-level listing of a single directory. - `find_files`: recursive name pattern search across a tree. +- Searching for several related name variants (e.g. "craftbot" or "craftos")? Combine them into ONE `find_files` call with `|` or `OR` in `pattern` (e.g. `*craftbot*|*craftos*`) instead of issuing multiple parallel `find_files` calls for the same base_directory. +- Searching multiple drives/roots (e.g. C: and D:)? Same rule applies: join them with `|` in `base_directory` (e.g. `C:/|D:/`), or pass `all_drives=true` to search every local fixed drive in one call — do not fire one `find_files` call per drive. ### convert_to_markdown vs read_pdf - `read_pdf`: direct PDF reading with page support. diff --git a/app/agent_base.py b/app/agent_base.py index d701b44e..48345c05 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -58,6 +58,7 @@ TELEGRAM_API_HASH, get_api_key, get_base_url, + is_prewarm_all_drives_enabled, ) from craftos_integrations import ( configure as _configure_integrations, @@ -3935,6 +3936,11 @@ def step(step_num: int, total: int, message: str) -> None: self._usage_reporter = get_usage_reporter() self._usage_reporter.start_background_flush() + # Pre-warm the find_files index for all local drives (background, + # non-blocking) so the first real search doesn't pay a cold-crawl cost. + if is_prewarm_all_drives_enabled(): + self._start_index_prewarm() + # Configure integrations + start external comms manager step(6, 7, "Initializing integrations") await self._initialize_external_libraries() @@ -3995,6 +4001,30 @@ def _on_dead_letter(trig, _error: str) -> None: # Resume triggers for tasks restored from previous session await self._schedule_restored_task_triggers() + def _start_index_prewarm(self) -> None: + """Warm the find_files index for every local drive in a background thread. + + Runs one drive at a time rather than one thread per drive: concurrent + full-drive crawls were observed contending with each other for the + GIL/disk with no net speedup (see app/utils/file_index.py find_files). + Fully non-blocking — boot() does not wait on this. + """ + import threading + + from app.utils import file_index + + def _prewarm() -> None: + try: + for drive in file_index.list_local_drives(): + file_index.build_index(drive) + file_index.start_watcher(drive) + except Exception as e: + logger.warning(f"[FILE_INDEX] Background pre-warm failed: {e}") + + threading.Thread( + target=_prewarm, daemon=True, name="file-index-prewarm" + ).start() + async def run( self, *, diff --git a/app/config.py b/app/config.py index 2f954952..ac92ea20 100644 --- a/app/config.py +++ b/app/config.py @@ -128,6 +128,9 @@ def _get_default_settings() -> Dict[str, Any]: "use_omniparser": False, "omniparser_url": "http://127.0.0.1:7861", }, + "file_index": { + "prewarm_all_drives": True, + }, } @@ -387,6 +390,12 @@ def get_web_search_cse_id() -> str: return settings.get("web_search", {}).get("google_cse_id", "") +def is_prewarm_all_drives_enabled() -> bool: + """Whether to pre-warm the find_files index for all local drives at startup.""" + settings = get_settings() + return settings.get("file_index", {}).get("prewarm_all_drives", True) + + def reload_settings() -> Dict[str, Any]: """Force reload settings from disk.""" return get_settings(reload=True) diff --git a/app/config/settings.json b/app/config/settings.json index 2b4fc7d8..4e2c6276 100644 --- a/app/config/settings.json +++ b/app/config/settings.json @@ -70,6 +70,9 @@ "port": 7926, "startup_ui": false }, + "file_index": { + "prewarm_all_drives": true + }, "api_keys_configured": { "openai": false, "anthropic": false, diff --git a/app/data/action/find_files.py b/app/data/action/find_files.py index a20d6df8..d0fc6328 100644 --- a/app/data/action/find_files.py +++ b/app/data/action/find_files.py @@ -1,30 +1,6 @@ from agent_core import action -def _find_files_impl(base_directory: str, file_pattern: str, recursive: bool) -> dict: - import os - - from app.utils import file_index - - file_index.start_watcher(base_directory) - matches = file_index.search(base_directory, file_pattern) - if not recursive: - base_abs = os.path.abspath(base_directory) - matches = [ - path - for path in matches - if os.path.normcase(os.path.dirname(path)) == os.path.normcase(base_abs) - ] - - return { - "status": "success", - "matches": matches, - "message": "" - if matches - else f"No files matching '{file_pattern}' were found in '{base_directory}'.", - } - - @action( name="find_files", description="Finds files by name or pattern across the system. Supports wildcards and recursive search. Use absolute paths for base_directory.", @@ -35,7 +11,7 @@ def _find_files_impl(base_directory: str, file_pattern: str, recursive: bool) -> "pattern": { "type": "string", "example": "*.pdf", - "description": "The file name or glob pattern to match. Supports wildcards like * and ?", + "description": "The file name or glob pattern to match. Supports wildcards like * and ?. To match any of several patterns in a single call, join them with '|' or ' OR ' instead of calling find_files multiple times, e.g. '*craftbot*|*craftos*' or '*.jpg OR *.png'.", }, "recursive": { "type": "boolean", @@ -45,7 +21,12 @@ def _find_files_impl(base_directory: str, file_pattern: str, recursive: bool) -> "base_directory": { "type": "string", "example": "/home/user/Documents", - "description": "Absolute path to the base directory to start searching from. Use full absolute paths (e.g., /home/user/Documents or /Users/name/Desktop).", + "description": "Absolute path to the base directory to start searching from. Use full absolute paths (e.g., /home/user/Documents or /Users/name/Desktop). To search multiple roots in one call, join them with '|' (e.g. '/home/user|/mnt/data'). Ignored if all_drives is true.", + }, + "all_drives": { + "type": "boolean", + "example": False, + "description": "If true, search every local fixed drive/mount in one call instead of just base_directory (which is then ignored). Use this instead of calling find_files once per drive.", }, }, output_schema={ @@ -70,34 +51,45 @@ def _find_files_impl(base_directory: str, file_pattern: str, recursive: bool) -> def find_file_by_name(input_data: dict) -> dict: import os + from app.utils import file_index + pattern = (input_data.get("pattern") or "").strip() recursive = bool(input_data.get("recursive", True)) + all_drives = bool(input_data.get("all_drives", False)) base_directory = (input_data.get("base_directory") or "").strip() if not pattern: return {"status": "error", "matches": [], "message": "Pattern is required."} - # Default to user's home directory if not provided - if not base_directory: - base_directory = os.path.expanduser("~") - - # Expand ~ and normalize base directory - base_directory = os.path.expanduser(base_directory) - base_directory = os.path.normpath(base_directory) - - if not os.path.exists(base_directory): - return { - "status": "error", - "matches": [], - "message": f"Base directory does not exist: {base_directory}", - } - - if not os.path.isdir(base_directory): - return { - "status": "error", - "matches": [], - "message": f"Base directory is not a directory: {base_directory}", - } + if all_drives: + base_directory = "" + else: + # Default to user's home directory if not provided + if not base_directory: + base_directory = os.path.expanduser("~") + + # base_directory may hold multiple '|'-joined roots; validate each. + roots = [part.strip() for part in base_directory.split("|") if part.strip()] + normalized_roots = [] + for root in roots: + root = os.path.normpath(os.path.expanduser(root)) + + if not os.path.exists(root): + return { + "status": "error", + "matches": [], + "message": f"Base directory does not exist: {root}", + } + + if not os.path.isdir(root): + return { + "status": "error", + "matches": [], + "message": f"Base directory is not a directory: {root}", + } + + normalized_roots.append(root) + base_directory = "|".join(normalized_roots) # Normalize the pattern (if user passes a path, only use its basename as the match pattern) pattern = os.path.expanduser(pattern) @@ -108,7 +100,7 @@ def find_file_by_name(input_data: dict) -> dict: else pattern ) - return _find_files_impl(base_directory, file_pattern, recursive) + return file_index.find_files(base_directory, file_pattern, recursive, all_drives) @action( @@ -121,7 +113,7 @@ def find_file_by_name(input_data: dict) -> dict: "pattern": { "type": "string", "example": "*.pdf", - "description": "The file name or glob pattern to match. Supports wildcards like * and ?", + "description": "The file name or glob pattern to match. Supports wildcards like * and ?. To match any of several patterns in a single call, join them with '|' or ' OR ' instead of calling find_files multiple times, e.g. '*craftbot*|*craftos*' or '*.jpg OR *.png'.", }, "recursive": { "type": "boolean", @@ -131,7 +123,12 @@ def find_file_by_name(input_data: dict) -> dict: "base_directory": { "type": "string", "example": "C:/Users/user/Documents", - "description": "Absolute path to the base directory to start searching from. Use full absolute paths (e.g., C:/Users/user/Documents or D:/Projects).", + "description": "Absolute path to the base directory to start searching from. Use full absolute paths (e.g., C:/Users/user/Documents or D:/Projects). To search multiple drives/roots in one call, join them with '|' (e.g. 'C:/|D:/'). Ignored if all_drives is true.", + }, + "all_drives": { + "type": "boolean", + "example": False, + "description": "If true, search every local fixed drive in one call instead of just base_directory (which is then ignored). Use this instead of calling find_files once per drive.", }, }, output_schema={ @@ -156,35 +153,47 @@ def find_file_by_name(input_data: dict) -> dict: def find_file_by_name_windows(input_data: dict) -> dict: import os + from app.utils import file_index + pattern = (input_data.get("pattern") or "").strip() recursive = bool(input_data.get("recursive", True)) + all_drives = bool(input_data.get("all_drives", False)) base_directory = (input_data.get("base_directory") or "").strip() if not pattern: return {"status": "error", "matches": [], "message": "Pattern is required."} - # Default to user's home directory if not provided - if not base_directory: - base_directory = os.path.expanduser("~") - - # Windows-friendly normalization - base_directory = base_directory.replace("/", "\\") - base_directory = os.path.expanduser(base_directory) - base_directory = os.path.normpath(base_directory) - - if not os.path.exists(base_directory): - return { - "status": "error", - "matches": [], - "message": f"Base directory does not exist: {base_directory}", - } - - if not os.path.isdir(base_directory): - return { - "status": "error", - "matches": [], - "message": f"Base directory is not a directory: {base_directory}", - } + if all_drives: + base_directory = "" + else: + # Default to user's home directory if not provided + if not base_directory: + base_directory = os.path.expanduser("~") + + # base_directory may hold multiple '|'-joined roots; validate each. + roots = [part.strip() for part in base_directory.split("|") if part.strip()] + normalized_roots = [] + for root in roots: + # Windows-friendly normalization + root = root.replace("/", "\\") + root = os.path.normpath(os.path.expanduser(root)) + + if not os.path.exists(root): + return { + "status": "error", + "matches": [], + "message": f"Base directory does not exist: {root}", + } + + if not os.path.isdir(root): + return { + "status": "error", + "matches": [], + "message": f"Base directory is not a directory: {root}", + } + + normalized_roots.append(root) + base_directory = "|".join(normalized_roots) pattern = pattern.replace("/", "\\") pattern = os.path.expanduser(pattern) @@ -197,4 +206,4 @@ def find_file_by_name_windows(input_data: dict) -> dict: else pattern ) - return _find_files_impl(base_directory, file_pattern, recursive) + return file_index.find_files(base_directory, file_pattern, recursive, all_drives) diff --git a/app/data/agent_file_system_template/AGENT.md b/app/data/agent_file_system_template/AGENT.md index 4b6980da..c43fa837 100644 --- a/app/data/agent_file_system_template/AGENT.md +++ b/app/data/agent_file_system_template/AGENT.md @@ -1,5 +1,5 @@ --- -version: 4 +version: 6 purpose: agent operations manual --- @@ -841,6 +841,8 @@ When you see that, the real content is in the file at ``. Retrieve it the ### find_files vs list_folder - `list_folder`: top-level listing of a single directory. - `find_files`: recursive name pattern search across a tree. +- Searching for several related name variants (e.g. "craftbot" or "craftos")? Combine them into ONE `find_files` call with `|` or `OR` in `pattern` (e.g. `*craftbot*|*craftos*`) instead of issuing multiple parallel `find_files` calls for the same base_directory. +- Searching multiple drives/roots (e.g. C: and D:)? Same rule applies: join them with `|` in `base_directory` (e.g. `C:/|D:/`), or pass `all_drives=true` to search every local fixed drive in one call — do not fire one `find_files` call per drive. ### convert_to_markdown vs read_pdf - `read_pdf`: direct PDF reading with page support. By default it returns just the text/tables (lean, to save context); pass `include_metadata=true` for page count and engine info, or `mode="layout"` when you need per-word positions for a spatial/edit task. diff --git a/app/utils/file_index.py b/app/utils/file_index.py index d291d47f..5f1cce47 100644 --- a/app/utils/file_index.py +++ b/app/utils/file_index.py @@ -2,7 +2,8 @@ CraftBot FindIndex — SQLite FTS5 trigram filename index with watchdog updates. Replaces live os.walk retrieval for find_files (issue #354). -Full crawl under the resolved base_directory — no directory skip list. +Full crawl under the resolved base_directory, skipping known noise +directories (VCS metadata, dependency/build caches, OS reserved folders). """ from __future__ import annotations @@ -33,10 +34,47 @@ class FileSystemEventHandler: # type: ignore[no-redef] _META_ROOT = "indexed_root" _META_BUILT_AT = "built_at" -_build_lock = threading.Lock() +_SKIP_DIR_NAMES = { + ".craftbot", + "node_modules", + ".git", + "__pycache__", + "venv", + ".venv", + "$recycle.bin", + "system volume information", +} + +_build_locks_registry_lock = threading.Lock() +_build_locks: dict[str, threading.Lock] = {} _watcher_lock = threading.Lock() _watchers: dict[str, _RootWatcher] = {} -_needs_incremental: dict[str, bool] = {} + +# normcased root -> set of changed absolute paths reported by that root's +# watcher since the last build_index() pass consumed them. An empty/missing +# entry means "no known pending changes" (build_index can use the cheap +# cached-count path). A non-empty entry lets build_index apply just those +# paths (_apply_targeted_changes) instead of re-walking the entire tree. +_pending_changes_lock = threading.Lock() +_pending_changes: dict[str, set[str]] = {} + + +def _get_build_lock(root: str) -> threading.Lock: + """Per-root build lock so unrelated roots never block each other.""" + key = os.path.normcase(root) + with _build_locks_registry_lock: + lock = _build_locks.get(key) + if lock is None: + lock = threading.Lock() + _build_locks[key] = lock + return lock + + +def _is_skip_path(path: str) -> bool: + """True if *path* has a _SKIP_DIR_NAMES component anywhere in it.""" + normalized = os.path.normcase(path) + parts = normalized.replace("/", os.sep).split(os.sep) + return any(part in _SKIP_DIR_NAMES for part in parts) @dataclass @@ -127,7 +165,7 @@ def _split_or_patterns(pattern: str) -> list[str]: def _iter_files(root: str): - """Recursive file iterator mirroring os.walk (no skip list, no symlink follow).""" + """Recursive file iterator mirroring os.walk (skips _SKIP_DIR_NAMES, no symlink follow).""" stack = [root] while stack: dir_path = stack.pop() @@ -136,6 +174,8 @@ def _iter_files(root: str): for entry in entries: try: if entry.is_dir(follow_symlinks=False): + if entry.name.lower() in _SKIP_DIR_NAMES: + continue stack.append(entry.path) elif entry.is_file(follow_symlinks=False): yield entry @@ -158,7 +198,7 @@ def build_index(root: str, force: bool = False) -> IndexStats: root = os.path.abspath(root) started = time.perf_counter() - with _build_lock: + with _get_build_lock(root): conn = _connect(root) try: _init_schema(conn) @@ -168,13 +208,16 @@ def build_index(root: str, force: bool = False) -> IndexStats: has_rows = ( conn.execute("SELECT 1 FROM files LIMIT 1").fetchone() is not None ) + root_matches_stored = bool( + stored_root + and os.path.normcase(stored_root[0]) == os.path.normcase(root) + ) if ( not force and has_rows - and stored_root - and os.path.normcase(stored_root[0]) == os.path.normcase(root) - and not _needs_incremental.get(root, False) + and root_matches_stored + and not _peek_has_pending_changes(root) ): total = conn.execute("SELECT COUNT(*) FROM files").fetchone()[0] return IndexStats( @@ -185,9 +228,16 @@ def build_index(root: str, force: bool = False) -> IndexStats: duration_seconds=time.perf_counter() - started, ) - if not force and has_rows and stored_root and stored_root[0] == root: - stats = _incremental_update(conn, root) - _needs_incremental[root] = False + if not force and has_rows and root_matches_stored: + # A non-empty pending set (reported directly by this root's + # watcher) lets us apply just those paths — O(changes), not + # O(total files indexed). Only fall back to the full re-walk + # when we have no such record (e.g. no watcher running yet). + pending = _pop_pending_changes(root) + if pending: + stats = _apply_targeted_changes(conn, root, pending) + else: + stats = _incremental_update(conn, root) conn.execute( "INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)", (_META_BUILT_AT, str(time.time())), @@ -201,9 +251,18 @@ def build_index(root: str, force: bool = False) -> IndexStats: duration_seconds=time.perf_counter() - started, ) + # Bulk (re)build: drop the per-row FTS sync triggers so the crawl's + # inserts don't each pay a trigram-index write, then rebuild the + # FTS index in one pass at the end (standard FTS5 external-content + # bulk-load pattern). _init_schema recreates the triggers after. + _pop_pending_changes(root) # full crawl already reflects current state + conn.execute("DROP TRIGGER IF EXISTS files_ai") + conn.execute("DROP TRIGGER IF EXISTS files_ad") + conn.execute("DROP TRIGGER IF EXISTS files_au") conn.execute("DELETE FROM files") - conn.commit() count = _full_crawl(conn, root) + conn.execute("INSERT INTO files_fts(files_fts) VALUES ('rebuild')") + _init_schema(conn) conn.execute( "INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)", (_META_ROOT, root), @@ -281,10 +340,11 @@ def _incremental_update( updated += 1 removed = 0 + normcased_prefix = os.path.normcase(prefix) for (path,) in conn.execute("SELECT path FROM files"): if path == root: continue - if not path.startswith(prefix): + if not os.path.normcase(path).startswith(normcased_prefix): conn.execute("DELETE FROM files WHERE path = ?", (path,)) removed += 1 continue @@ -297,6 +357,65 @@ def _incremental_update( return total, added, updated, removed +def _peek_has_pending_changes(root: str) -> bool: + key = os.path.normcase(root) + with _pending_changes_lock: + return bool(_pending_changes.get(key)) + + +def _pop_pending_changes(root: str) -> set[str]: + key = os.path.normcase(root) + with _pending_changes_lock: + return _pending_changes.pop(key, set()) + + +def _apply_targeted_changes( + conn: sqlite3.Connection, root: str, changed_paths: set[str] +) -> tuple[int, int, int, int]: + """Apply exactly the given changed paths to the index — no tree walk. + + Cost is O(len(changed_paths)), not O(total files indexed), which is what + keeps watcher-driven updates cheap even on huge, highly active roots + (unlike _incremental_update, which re-walks everything every time). + """ + added = updated = removed = 0 + for path in changed_paths: + try: + st = os.stat(path, follow_symlinks=False) + is_file = os.path.isfile(path) + except OSError: + st = None + is_file = False + + row = conn.execute( + "SELECT mtime, size FROM files WHERE path = ?", (path,) + ).fetchone() + + if st is None or not is_file: + if row is not None: + conn.execute("DELETE FROM files WHERE path = ?", (path,)) + removed += 1 + continue + + basename = os.path.basename(path) + if row is None: + conn.execute( + "INSERT INTO files(path, basename, mtime, size) VALUES (?, ?, ?, ?)", + (path, basename, st.st_mtime, st.st_size), + ) + added += 1 + elif row[0] != st.st_mtime or row[1] != st.st_size: + conn.execute( + "UPDATE files SET basename = ?, mtime = ?, size = ? WHERE path = ?", + (basename, st.st_mtime, st.st_size, path), + ) + updated += 1 + + conn.commit() + total = conn.execute("SELECT COUNT(*) FROM files").fetchone()[0] + return total, added, updated, removed + + def _fts_prefilter_sql(pattern: str) -> tuple[str, list[str]] | None: if "*" not in pattern and "?" not in pattern and "[" not in pattern: return ("f.basename = ?", [pattern]) @@ -306,7 +425,8 @@ def _fts_prefilter_sql(pattern: str) -> tuple[str, list[str]] | None: next_star = remainder.find("*") literal = remainder[:next_star] if next_star >= 0 else remainder if len(literal) >= 2 and "?" not in literal and "[" not in literal: - return ("files_fts.basename MATCH ?", [literal]) + quoted = '"' + literal.replace('"', '""') + '"' + return ("files_fts.basename MATCH ?", [quoted]) return None star = pattern.find("*") @@ -380,19 +500,94 @@ def search(root: str, pattern: str, limit: int | None = None) -> list[str]: conn.close() +def list_local_drives() -> list[str]: + """Return mount points of local fixed drives (cross-platform). + + Used both for boot-time index pre-warming and for the find_files + ``all_drives`` option, so "search everywhere" means the same set of + roots in both places. + """ + import psutil + + drives = [] + for part in psutil.disk_partitions(all=False): + opts = part.opts.lower().split(",") + if os.name == "nt" and "fixed" not in opts: + continue + drives.append(part.mountpoint) + return drives + + +def find_files( + base_directory: str, + file_pattern: str, + recursive: bool, + all_drives: bool = False, +) -> dict: + """Search one or more roots for basenames matching *file_pattern*. + + Shared implementation for the find_files action. Kept here (rather than + as a helper in the action module) because "internal" actions are + executed by extracting and exec'ing only the single decorated function's + own source — a call to a same-module sibling function is not resolvable + at that point, while an attribute access on an imported module is. + + *base_directory* may contain multiple absolute paths joined by '|' to + search several roots in one call. If *all_drives* is True, *base_directory* + is ignored and every local fixed drive is searched instead. + """ + if all_drives: + roots = list_local_drives() + if not roots: + return { + "status": "error", + "matches": [], + "message": "Could not determine local drives for all_drives search.", + } + else: + roots = [part.strip() for part in base_directory.split("|") if part.strip()] + + seen: set[str] = set() + matches: list[str] = [] + for root in roots: + start_watcher(root) + root_matches = search(root, file_pattern) + if not recursive: + root_abs = os.path.abspath(root) + root_matches = [ + path + for path in root_matches + if os.path.normcase(os.path.dirname(path)) == os.path.normcase(root_abs) + ] + for path in root_matches: + if path not in seen: + seen.add(path) + matches.append(path) + + scope = ", ".join(roots) if roots else base_directory + return { + "status": "success", + "matches": matches, + "message": "" + if matches + else f"No files matching '{file_pattern}' were found in '{scope}'.", + } + + def start_watcher(root: str) -> bool: """Start a debounced watchdog observer for *root* (once per root).""" if not WATCHDOG_AVAILABLE: return False root = os.path.abspath(root) + key = os.path.normcase(root) with _watcher_lock: - if root in _watchers and _watchers[root].is_running: + if key in _watchers and _watchers[key].is_running: return True watcher = _RootWatcher(root, _DEBOUNCE_SECONDS) if not watcher.start(): return False - _watchers[root] = watcher + _watchers[key] = watcher return True @@ -422,8 +617,12 @@ def start(self) -> bool: self._is_running = True return True - def _on_change(self, _path: str, _event_type: str) -> None: - _needs_incremental[self.root] = True + def _on_change(self, path: str, _event_type: str) -> None: + if _is_skip_path(path): + return + key = os.path.normcase(self.root) + with _pending_changes_lock: + _pending_changes.setdefault(key, set()).add(path) with self._lock: self._pending = True if self._debounce_timer: From bc74f26626570ba08169b7edea5502ba81b2cae3 Mon Sep 17 00:00:00 2001 From: namabeeru Date: Tue, 7 Jul 2026 23:44:23 +0900 Subject: [PATCH 10/28] fix: harden macos shortcut health checks --- craftbot.py | 21 ++++++++++++++++++--- tests/test_craftbot_service.py | 33 ++++++++++++++++++++++++++++++--- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/craftbot.py b/craftbot.py index 67de794a..83f30c3a 100644 --- a/craftbot.py +++ b/craftbot.py @@ -856,6 +856,16 @@ def _create_desktop_shortcut_windows() -> None: print(f" (Could not create desktop shortcut: {e})") +def _shortcut_start_command(extra_args: List[str]) -> Optional[str]: + restart_args = _port_args(extra_args) + if IS_FROZEN: + installed = installed_exe_path() + if not installed: + return None + return shlex.join([installed] + restart_args) + return shlex.join([_python_exe(), "craftbot.py", "start"] + restart_args) + + def _create_desktop_shortcut_unix(extra_args: Optional[List[str]] = None) -> None: """Create a desktop shortcut on Linux or macOS.""" if extra_args is None: @@ -869,13 +879,18 @@ def _create_desktop_shortcut_unix(extra_args: Optional[List[str]] = None) -> Non # macOS does not support XDG .desktop files — create a double-clickable .command script shortcut_path = os.path.join(desktop, "CraftBot.command") backend_url = _backend_url(extra_args) - restart_args = _port_args(extra_args) - start_cmd = shlex.join([_python_exe(), "craftbot.py", "start"] + restart_args) + start_cmd = _shortcut_start_command(extra_args) + if not start_cmd: + print(" (Could not create desktop shortcut: no installed agent found)") + return content = ( "#!/bin/sh\n" f"cd {shlex.quote(BASE_DIR)} || exit 1\n" + f"backend_status=$(curl -sS -o /dev/null -w '%{{http_code}}' " + f"{shlex.quote(backend_url)} 2>/dev/null || true)\n" f"if curl -fsS {shlex.quote(browser_url)} >/dev/null 2>&1 " - f"&& curl -fsS {shlex.quote(backend_url)} >/dev/null 2>&1; then\n" + '&& [ "$backend_status" -ge 100 ] 2>/dev/null ' + '&& [ "$backend_status" -lt 500 ] 2>/dev/null; then\n' f" open {shlex.quote(browser_url)}\n" "else\n" f" exec {start_cmd}\n" diff --git a/tests/test_craftbot_service.py b/tests/test_craftbot_service.py index 4175c859..b06ce78e 100644 --- a/tests/test_craftbot_service.py +++ b/tests/test_craftbot_service.py @@ -87,8 +87,11 @@ def test_macos_source_shortcut_uses_custom_backend_port( content = shortcut.read_text() assert f"cd {shlex.quote(str(base_dir))}" in content assert "curl -fsS http://localhost:7925" in content - assert "curl -fsS http://localhost:8123" in content - assert "curl -fsS http://localhost:7926" not in content + assert "backend_status=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8123" in content + assert "[ \"$backend_status\" -ge 100 ]" in content + assert "[ \"$backend_status\" -lt 500 ]" in content + assert "curl -fsS http://localhost:8123" not in content + assert "http://localhost:7926" not in content assert "open http://localhost:7925" in content assert f"exec {shlex.quote(python_exe)} craftbot.py start --backend-port 8123" in content @@ -111,10 +114,34 @@ def test_macos_source_shortcut_accepts_equals_backend_port(tmp_path, monkeypatch craftbot._create_desktop_shortcut_unix(["--backend-port=8123"]) content = (desktop / "CraftBot.command").read_text() - assert "curl -fsS http://localhost:8123" in content + assert "backend_status=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8123" in content assert "craftbot.py start --backend-port=8123" in content +def test_macos_frozen_shortcut_starts_installed_agent_executable( + tmp_path, monkeypatch +): + desktop = tmp_path / "Desktop" + desktop.mkdir() + base_dir = tmp_path / "CraftBot" + base_dir.mkdir() + installed_agent = "/Applications/CraftBot/CraftBot Agent" + + monkeypatch.setattr(craftbot, "_PLATFORM", "darwin") + monkeypatch.setattr(craftbot, "IS_FROZEN", True) + monkeypatch.setattr(craftbot, "BASE_DIR", str(base_dir)) + monkeypatch.setattr(craftbot, "_find_desktop", lambda: str(desktop)) + monkeypatch.setattr(craftbot, "_python_exe", lambda: "/bad/python") + monkeypatch.setattr(craftbot, "installed_exe_path", lambda: installed_agent) + + craftbot._create_desktop_shortcut_unix(["--frontend-port", "9000"]) + + content = (desktop / "CraftBot.command").read_text() + assert f"exec {shlex.quote(installed_agent)} --frontend-port 9000" in content + assert "craftbot.py start" not in content + assert "/bad/python" not in content + + def test_start_ignores_stale_ready_marker_when_child_exits( tmp_path, monkeypatch, capsys ): From fbf732c5ef398b14051801312e359b2a53da8f0b Mon Sep 17 00:00:00 2001 From: AlanAAG Date: Tue, 7 Jul 2026 13:30:43 -0600 Subject: [PATCH 11/28] fix/rendering issues for text based files on chat preview and mode toggle for .md files --- .../ui/AttachmentPreviewModal.module.css | 41 +++++++++++++++++++ .../components/ui/AttachmentPreviewModal.tsx | 40 ++++++++++++++++-- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/app/ui_layer/browser/frontend/src/components/ui/AttachmentPreviewModal.module.css b/app/ui_layer/browser/frontend/src/components/ui/AttachmentPreviewModal.module.css index d97049f7..40d2082f 100644 --- a/app/ui_layer/browser/frontend/src/components/ui/AttachmentPreviewModal.module.css +++ b/app/ui_layer/browser/frontend/src/components/ui/AttachmentPreviewModal.module.css @@ -3,6 +3,10 @@ } .metaBar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); padding: var(--space-2) var(--space-4); font-size: var(--text-xs); color: var(--text-secondary); @@ -10,6 +14,34 @@ flex-shrink: 0; } +.viewToggle { + display: flex; + gap: var(--space-1); + flex-shrink: 0; +} + +.viewToggleBtn, +.viewToggleBtnActive { + padding: var(--space-1) var(--space-3); + border: none; + border-radius: var(--radius-sm); + background: none; + font-size: var(--text-xs); + font-family: inherit; + cursor: pointer; + color: var(--text-secondary); +} + +.viewToggleBtn:hover { + color: var(--text-primary); +} + +.viewToggleBtnActive { + background: var(--bg-primary); + color: var(--text-primary); + font-weight: var(--font-semibold); +} + /* No fixed dimensions: the viewer hugs whichever content branch renders. Centering keeps a small image visually anchored when the modal's min-width forces the box wider than the image's intrinsic size. */ @@ -58,6 +90,15 @@ box-sizing: border-box; } +.markdownPreview { + width: min(1280px, 92vw); + height: calc(92vh - 100px); + padding: var(--space-4); + overflow: auto; + box-sizing: border-box; + background: var(--bg-primary); +} + .message { padding: var(--space-6) var(--space-8); color: var(--text-secondary); diff --git a/app/ui_layer/browser/frontend/src/components/ui/AttachmentPreviewModal.tsx b/app/ui_layer/browser/frontend/src/components/ui/AttachmentPreviewModal.tsx index 0f0fc3f7..7014d153 100644 --- a/app/ui_layer/browser/frontend/src/components/ui/AttachmentPreviewModal.tsx +++ b/app/ui_layer/browser/frontend/src/components/ui/AttachmentPreviewModal.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import { Modal } from './Modal' +import { MarkdownContent } from './MarkdownContent' import styles from './AttachmentPreviewModal.module.css' // A single shape that covers both pre-send attachments (base64 in memory) @@ -33,7 +34,8 @@ function classify(att: AttachmentPreviewItem) { || TEXT_MIMES.has(att.type) || TEXT_EXT_RE.test(att.name) ) - return { isImage, isPdf, isText } + const isMarkdown = isText && att.name.toLowerCase().endsWith('.md') + return { isImage, isPdf, isText, isMarkdown } } function formatFileSize(bytes: number): string { @@ -45,7 +47,10 @@ function formatFileSize(bytes: number): string { } export function AttachmentPreviewModal({ isOpen, attachment, onClose }: AttachmentPreviewModalProps) { - const kind = attachment ? classify(attachment) : null + // Memoized: classify() returns a new object each call, and an unstable + // identity here would re-trigger the fetch effect below on every render + // it itself causes (infinite fetch/abort loop). + const kind = useMemo(() => (attachment ? classify(attachment) : null), [attachment]) const imageSrc = useMemo(() => { if (!attachment || !kind?.isImage) return null @@ -74,8 +79,10 @@ export function AttachmentPreviewModal({ isOpen, attachment, onClose }: Attachme const [textContent, setTextContent] = useState(null) const [textLoading, setTextLoading] = useState(false) const [textError, setTextError] = useState(null) + const [mdView, setMdView] = useState<'preview' | 'source'>('preview') useEffect(() => { + setMdView('preview') if (!attachment || !kind?.isText) { setTextContent(null) setTextLoading(false) @@ -125,6 +132,7 @@ export function AttachmentPreviewModal({ isOpen, attachment, onClose }: Attachme {kind.isText && lineCount > 0 && <> · {lineCount} line{lineCount !== 1 ? 's' : ''}} ) + const showMarkdownToggle = kind.isMarkdown && textContent != null return ( -
{meta}
+
+ {meta} + {showMarkdownToggle && ( +
+ + +
+ )} +
{kind.isImage && imageSrc && ( {attachment.name} @@ -148,7 +176,11 @@ export function AttachmentPreviewModal({ isOpen, attachment, onClose }: Attachme ) : textError ? (
{textError}
) : textContent != null ? ( -
{textContent}
+ showMarkdownToggle && mdView === 'preview' ? ( + + ) : ( +
{textContent}
+ ) ) : null )} {!kind.isImage && !kind.isPdf && !kind.isText && ( From 8476a788effc70cbb9c3d8f322ec17f8bfc571fc Mon Sep 17 00:00:00 2001 From: Tobias Garcia Date: Wed, 8 Jul 2026 14:08:44 +0900 Subject: [PATCH 12/28] Fix: Targeted edge cases --- app/agent_base.py | 14 ++- app/data/action/find_files.py | 82 +++++----------- app/updater.py | 8 ++ app/utils/file_index.py | 180 +++++++++++++++++++++++++++++----- 4 files changed, 202 insertions(+), 82 deletions(-) diff --git a/app/agent_base.py b/app/agent_base.py index 48345c05..8a1b40e3 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -4015,11 +4015,19 @@ def _start_index_prewarm(self) -> None: def _prewarm() -> None: try: - for drive in file_index.list_local_drives(): + drives = file_index.list_local_drives() + except Exception as e: + logger.warning(f"[FILE_INDEX] Could not enumerate local drives: {e}") + return + + for drive in drives: + try: file_index.build_index(drive) file_index.start_watcher(drive) - except Exception as e: - logger.warning(f"[FILE_INDEX] Background pre-warm failed: {e}") + except Exception as e: + logger.warning( + f"[FILE_INDEX] Background pre-warm failed for {drive}: {e}" + ) threading.Thread( target=_prewarm, daemon=True, name="file-index-prewarm" diff --git a/app/data/action/find_files.py b/app/data/action/find_files.py index d0fc6328..397ae891 100644 --- a/app/data/action/find_files.py +++ b/app/data/action/find_files.py @@ -28,6 +28,11 @@ "example": False, "description": "If true, search every local fixed drive/mount in one call instead of just base_directory (which is then ignored). Use this instead of calling find_files once per drive.", }, + "limit": { + "type": "integer", + "example": 500, + "description": "Optional cap on the total number of matches returned across all searched roots. Useful with all_drives or broad patterns to avoid extremely large result sets. Default: unbounded.", + }, }, output_schema={ "status": {"type": "string", "example": "success"}, @@ -57,6 +62,7 @@ def find_file_by_name(input_data: dict) -> dict: recursive = bool(input_data.get("recursive", True)) all_drives = bool(input_data.get("all_drives", False)) base_directory = (input_data.get("base_directory") or "").strip() + limit = input_data.get("limit") if not pattern: return {"status": "error", "matches": [], "message": "Pattern is required."} @@ -64,32 +70,10 @@ def find_file_by_name(input_data: dict) -> dict: if all_drives: base_directory = "" else: - # Default to user's home directory if not provided - if not base_directory: - base_directory = os.path.expanduser("~") - - # base_directory may hold multiple '|'-joined roots; validate each. - roots = [part.strip() for part in base_directory.split("|") if part.strip()] - normalized_roots = [] - for root in roots: - root = os.path.normpath(os.path.expanduser(root)) - - if not os.path.exists(root): - return { - "status": "error", - "matches": [], - "message": f"Base directory does not exist: {root}", - } - - if not os.path.isdir(root): - return { - "status": "error", - "matches": [], - "message": f"Base directory is not a directory: {root}", - } - - normalized_roots.append(root) - base_directory = "|".join(normalized_roots) + roots, error = file_index.resolve_roots(base_directory, windows=False) + if error: + return error + base_directory = "|".join(roots) # Normalize the pattern (if user passes a path, only use its basename as the match pattern) pattern = os.path.expanduser(pattern) @@ -100,7 +84,9 @@ def find_file_by_name(input_data: dict) -> dict: else pattern ) - return file_index.find_files(base_directory, file_pattern, recursive, all_drives) + return file_index.find_files( + base_directory, file_pattern, recursive, all_drives, limit + ) @action( @@ -130,6 +116,11 @@ def find_file_by_name(input_data: dict) -> dict: "example": False, "description": "If true, search every local fixed drive in one call instead of just base_directory (which is then ignored). Use this instead of calling find_files once per drive.", }, + "limit": { + "type": "integer", + "example": 500, + "description": "Optional cap on the total number of matches returned across all searched roots. Useful with all_drives or broad patterns to avoid extremely large result sets. Default: unbounded.", + }, }, output_schema={ "status": {"type": "string", "example": "success"}, @@ -159,6 +150,7 @@ def find_file_by_name_windows(input_data: dict) -> dict: recursive = bool(input_data.get("recursive", True)) all_drives = bool(input_data.get("all_drives", False)) base_directory = (input_data.get("base_directory") or "").strip() + limit = input_data.get("limit") if not pattern: return {"status": "error", "matches": [], "message": "Pattern is required."} @@ -166,34 +158,10 @@ def find_file_by_name_windows(input_data: dict) -> dict: if all_drives: base_directory = "" else: - # Default to user's home directory if not provided - if not base_directory: - base_directory = os.path.expanduser("~") - - # base_directory may hold multiple '|'-joined roots; validate each. - roots = [part.strip() for part in base_directory.split("|") if part.strip()] - normalized_roots = [] - for root in roots: - # Windows-friendly normalization - root = root.replace("/", "\\") - root = os.path.normpath(os.path.expanduser(root)) - - if not os.path.exists(root): - return { - "status": "error", - "matches": [], - "message": f"Base directory does not exist: {root}", - } - - if not os.path.isdir(root): - return { - "status": "error", - "matches": [], - "message": f"Base directory is not a directory: {root}", - } - - normalized_roots.append(root) - base_directory = "|".join(normalized_roots) + roots, error = file_index.resolve_roots(base_directory, windows=True) + if error: + return error + base_directory = "|".join(roots) pattern = pattern.replace("/", "\\") pattern = os.path.expanduser(pattern) @@ -206,4 +174,6 @@ def find_file_by_name_windows(input_data: dict) -> dict: else pattern ) - return file_index.find_files(base_directory, file_pattern, recursive, all_drives) + return file_index.find_files( + base_directory, file_pattern, recursive, all_drives, limit + ) diff --git a/app/updater.py b/app/updater.py index f882b155..1d41ed20 100644 --- a/app/updater.py +++ b/app/updater.py @@ -245,12 +245,20 @@ async def _run_git( env = os.environ.copy() env.setdefault("GIT_TERMINAL_PROMPT", "0") + kwargs = {} + if sys.platform == "win32": + # Without this, spawning git.exe from this windowless process makes + # Windows flash a new console window per invocation (visible to the + # user every time an update check runs, e.g. on Settings page load). + kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW + proc = await asyncio.create_subprocess_exec( *cmd, cwd=cwd, env=env, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + **kwargs, ) try: stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) diff --git a/app/utils/file_index.py b/app/utils/file_index.py index 5f1cce47..d391b865 100644 --- a/app/utils/file_index.py +++ b/app/utils/file_index.py @@ -12,6 +12,7 @@ import os import re import sqlite3 +import stat import threading import time from dataclasses import dataclass @@ -38,9 +39,19 @@ class FileSystemEventHandler: # type: ignore[no-redef] ".craftbot", "node_modules", ".git", + ".svn", + ".hg", "__pycache__", "venv", ".venv", + ".env", + ".tox", + ".mypy_cache", + ".pytest_cache", + "dist", + "build", + ".idea", + ".vscode", "$recycle.bin", "system volume information", } @@ -55,8 +66,21 @@ class FileSystemEventHandler: # type: ignore[no-redef] # entry means "no known pending changes" (build_index can use the cheap # cached-count path). A non-empty entry lets build_index apply just those # paths (_apply_targeted_changes) instead of re-walking the entire tree. +# +# _needs_full_rewalk holds roots where a directory-level event (rename, +# move, delete of a whole subtree) was observed. A single directory event +# doesn't tell us which of its descendants changed, so those roots fall +# back to one full _incremental_update pass instead of a targeted one. +# +# _verified_roots holds roots that have had at least one real check (full +# crawl or incremental/targeted pass) *in this process*. Without it, a +# freshly restarted process with an existing, matching, non-empty index +# and no recorded pending changes would trust that index as fresh even +# though real changes may have happened while the process was down. _pending_changes_lock = threading.Lock() _pending_changes: dict[str, set[str]] = {} +_needs_full_rewalk: set[str] = set() +_verified_roots: set[str] = set() def _get_build_lock(root: str) -> threading.Lock: @@ -71,10 +95,16 @@ def _get_build_lock(root: str) -> threading.Lock: def _is_skip_path(path: str) -> bool: - """True if *path* has a _SKIP_DIR_NAMES component anywhere in it.""" - normalized = os.path.normcase(path) - parts = normalized.replace("/", os.sep).split(os.sep) - return any(part in _SKIP_DIR_NAMES for part in parts) + """True if *path* has a _SKIP_DIR_NAMES component anywhere in it. + + Lowercases explicitly rather than relying on os.path.normcase, which is + only case-insensitive on Windows (a no-op on POSIX) — this must match + _iter_files' always-case-insensitive entry.name.lower() check on every + platform, or a mixed-case noise directory would be excluded from the + crawl but not from watcher events on Linux/macOS. + """ + parts = path.replace("\\", "/").split("/") + return any(part.lower() in _SKIP_DIR_NAMES for part in parts) @dataclass @@ -213,10 +243,13 @@ def build_index(root: str, force: bool = False) -> IndexStats: and os.path.normcase(stored_root[0]) == os.path.normcase(root) ) + key = os.path.normcase(root) + if ( not force and has_rows and root_matches_stored + and key in _verified_roots and not _peek_has_pending_changes(root) ): total = conn.execute("SELECT COUNT(*) FROM files").fetchone()[0] @@ -231,13 +264,18 @@ def build_index(root: str, force: bool = False) -> IndexStats: if not force and has_rows and root_matches_stored: # A non-empty pending set (reported directly by this root's # watcher) lets us apply just those paths — O(changes), not - # O(total files indexed). Only fall back to the full re-walk - # when we have no such record (e.g. no watcher running yet). - pending = _pop_pending_changes(root) - if pending: - stats = _apply_targeted_changes(conn, root, pending) - else: + # O(total files indexed). Fall back to the full re-walk when + # a directory-level event was seen (a single event doesn't + # tell us which descendants changed) or when this root has + # no verified state yet in this process (e.g. right after a + # restart, before any watcher has run — an empty pending set + # there must not be mistaken for "nothing changed"). + pending, needs_full = _pop_pending_changes(root) + if needs_full or key not in _verified_roots or not pending: stats = _incremental_update(conn, root) + else: + stats = _apply_targeted_changes(conn, root, pending) + _verified_roots.add(key) conn.execute( "INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)", (_META_BUILT_AT, str(time.time())), @@ -256,6 +294,7 @@ def build_index(root: str, force: bool = False) -> IndexStats: # FTS index in one pass at the end (standard FTS5 external-content # bulk-load pattern). _init_schema recreates the triggers after. _pop_pending_changes(root) # full crawl already reflects current state + _verified_roots.add(key) conn.execute("DROP TRIGGER IF EXISTS files_ai") conn.execute("DROP TRIGGER IF EXISTS files_ad") conn.execute("DROP TRIGGER IF EXISTS files_au") @@ -353,20 +392,30 @@ def _incremental_update( removed += 1 conn.commit() - total = conn.execute("SELECT COUNT(*) FROM files").fetchone()[0] - return total, added, updated, removed + # files_indexed is discarded by every caller — see _apply_targeted_changes. + return -1, added, updated, removed def _peek_has_pending_changes(root: str) -> bool: key = os.path.normcase(root) with _pending_changes_lock: - return bool(_pending_changes.get(key)) + return bool(_pending_changes.get(key)) or key in _needs_full_rewalk + + +def _pop_pending_changes(root: str) -> tuple[set[str], bool]: + """Clear and return (changed_paths, needs_full_rewalk) for *root*.""" + key = os.path.normcase(root) + with _pending_changes_lock: + paths = _pending_changes.pop(key, set()) + needs_full = key in _needs_full_rewalk + _needs_full_rewalk.discard(key) + return paths, needs_full -def _pop_pending_changes(root: str) -> set[str]: +def _mark_needs_full_rewalk(root: str) -> None: key = os.path.normcase(root) with _pending_changes_lock: - return _pending_changes.pop(key, set()) + _needs_full_rewalk.add(key) def _apply_targeted_changes( @@ -381,8 +430,14 @@ def _apply_targeted_changes( added = updated = removed = 0 for path in changed_paths: try: + # lstat (not following symlinks) so a symlink is never indexed + # here as a "file" — matches _iter_files/_full_crawl, which + # also never treats symlinks as files. Deriving is_file from + # this same stat (rather than a separate os.path.isfile call, + # which *does* follow symlinks) keeps the two code paths from + # disagreeing about what counts as an indexable file. st = os.stat(path, follow_symlinks=False) - is_file = os.path.isfile(path) + is_file = stat.S_ISREG(st.st_mode) except OSError: st = None is_file = False @@ -412,8 +467,9 @@ def _apply_targeted_changes( updated += 1 conn.commit() - total = conn.execute("SELECT COUNT(*) FROM files").fetchone()[0] - return total, added, updated, removed + # files_indexed is discarded by every caller (search(), _flush()) — skip + # the O(total rows) COUNT(*) a real total would cost on every flush. + return -1, added, updated, removed def _fts_prefilter_sql(pattern: str) -> tuple[str, list[str]] | None: @@ -518,11 +574,60 @@ def list_local_drives() -> list[str]: return drives +def resolve_roots( + base_directory: str, windows: bool = False +) -> tuple[list[str], dict | None]: + """Validate and normalize a possibly '|'-joined base_directory string. + + Shared by both find_files action variants (posix/windows) via module + import — same reasoning as find_files' docstring below: a same-module + sibling helper in the action file itself would not be resolvable under + the sandboxed exec model those actions run under. + + Returns (roots, None) on success, or ([], error_dict) if base_directory + is empty/only separators/whitespace, or any listed root doesn't exist + or isn't a directory. + """ + if not base_directory: + base_directory = os.path.expanduser("~") + + raw_roots = [part.strip() for part in base_directory.split("|") if part.strip()] + if not raw_roots: + return [], { + "status": "error", + "matches": [], + "message": "base_directory must contain at least one non-empty path.", + } + + roots = [] + for root in raw_roots: + if windows: + root = root.replace("/", "\\") + root = os.path.normpath(os.path.expanduser(root)) + + if not os.path.exists(root): + return [], { + "status": "error", + "matches": [], + "message": f"Base directory does not exist: {root}", + } + if not os.path.isdir(root): + return [], { + "status": "error", + "matches": [], + "message": f"Base directory is not a directory: {root}", + } + roots.append(root) + + return roots, None + + def find_files( base_directory: str, file_pattern: str, recursive: bool, all_drives: bool = False, + limit: int | None = None, ) -> dict: """Search one or more roots for basenames matching *file_pattern*. @@ -534,7 +639,8 @@ def find_files( *base_directory* may contain multiple absolute paths joined by '|' to search several roots in one call. If *all_drives* is True, *base_directory* - is ignored and every local fixed drive is searched instead. + is ignored and every local fixed drive is searched instead. *limit*, if + given, caps the total number of matches across all roots combined. """ if all_drives: roots = list_local_drives() @@ -563,6 +669,10 @@ def find_files( if path not in seen: seen.add(path) matches.append(path) + if limit is not None and len(matches) >= limit: + break + if limit is not None and len(matches) >= limit: + break scope = ", ".join(roots) if roots else base_directory return { @@ -617,12 +727,19 @@ def start(self) -> bool: self._is_running = True return True - def _on_change(self, path: str, _event_type: str) -> None: + def _on_change(self, path: str, event_type: str) -> None: if _is_skip_path(path): return - key = os.path.normcase(self.root) - with _pending_changes_lock: - _pending_changes.setdefault(key, set()).add(path) + if event_type == "dir_changed": + # A single directory-level event (rename/move/delete of a whole + # subtree) doesn't tell us which descendants changed — the next + # build_index() pass does one full re-walk for this root instead + # of trusting an incomplete targeted-changes set. + _mark_needs_full_rewalk(self.root) + else: + key = os.path.normcase(self.root) + with _pending_changes_lock: + _pending_changes.setdefault(key, set()).add(path) with self._lock: self._pending = True if self._debounce_timer: @@ -645,19 +762,36 @@ def __init__(self, callback) -> None: self._callback = callback def on_created(self, event) -> None: + # A newly created directory has no stale descendants to reconcile — + # files created inside it later fire their own normal per-path + # events — so this isn't treated as a structural "dir_changed" + # event. Only a directory *move/rename* gets that treatment (see + # on_moved): that's the one case with no per-descendant events. if not event.is_directory: self._callback(event.src_path, "created") def on_modified(self, event) -> None: + # Directory "modified" events don't reliably signal an added/removed + # descendant (they can fire for metadata-only changes too), so they + # aren't treated as structural — only a directory move/rename is. if not event.is_directory: self._callback(event.src_path, "modified") def on_deleted(self, event) -> None: + # A deleted directory's contents already generate their own + # per-file delete events in the common case (recursive delete, or + # Explorer moving it to $RECYCLE.BIN as an on_moved) — the + # directory-level event itself adds no new information, so it's + # not treated as structural here either. if not event.is_directory: self._callback(event.src_path, "deleted") def on_moved(self, event) -> None: if event.is_directory: + # The one case that genuinely needs whole-subtree reconciliation: + # a rename/move doesn't generate per-descendant events, so we + # can't know what changed underneath without a real re-walk. + self._callback(event.src_path, "dir_changed") return self._callback(event.src_path, "deleted") if event.dest_path: From e04942fcce54219ec3905daae06458276b56eb89 Mon Sep 17 00:00:00 2001 From: Tobias Garcia Date: Wed, 8 Jul 2026 15:55:12 +0900 Subject: [PATCH 13/28] Move .craftbot index db to CraftBot directory, fixes for macOS --- .gitignore | 3 +- app/data/action/find_files.py | 4 +-- app/utils/file_index.py | 67 +++++++++++++++++++++++++++++++++-- 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 98ea330f..8cb33c08 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,5 @@ agent_file_system/TASK_HISTORY.md docs/LIVING_UI_DEVELOPER_GUIDE.md agent_file_system/ACTIONS.md agent_bundle/ -**/.craftbot/ \ No newline at end of file +**/.craftbot/ +app/data/.file_index/ \ No newline at end of file diff --git a/app/data/action/find_files.py b/app/data/action/find_files.py index 397ae891..9a82257b 100644 --- a/app/data/action/find_files.py +++ b/app/data/action/find_files.py @@ -31,7 +31,7 @@ "limit": { "type": "integer", "example": 500, - "description": "Optional cap on the total number of matches returned across all searched roots. Useful with all_drives or broad patterns to avoid extremely large result sets. Default: unbounded.", + "description": "Optional cap on the total number of matches returned across all searched roots. Useful with all_drives or broad patterns to avoid extremely large result sets. Pass 0 or omit for unlimited.", }, }, output_schema={ @@ -119,7 +119,7 @@ def find_file_by_name(input_data: dict) -> dict: "limit": { "type": "integer", "example": 500, - "description": "Optional cap on the total number of matches returned across all searched roots. Useful with all_drives or broad patterns to avoid extremely large result sets. Default: unbounded.", + "description": "Optional cap on the total number of matches returned across all searched roots. Useful with all_drives or broad patterns to avoid extremely large result sets. Pass 0 or omit for unlimited.", }, }, output_schema={ diff --git a/app/utils/file_index.py b/app/utils/file_index.py index d391b865..57aae221 100644 --- a/app/utils/file_index.py +++ b/app/utils/file_index.py @@ -9,6 +9,7 @@ from __future__ import annotations import fnmatch +import hashlib import os import re import sqlite3 @@ -17,6 +18,8 @@ import time from dataclasses import dataclass +from app.config import APP_DATA_PATH + try: from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer @@ -54,6 +57,9 @@ class FileSystemEventHandler: # type: ignore[no-redef] ".vscode", "$recycle.bin", "system volume information", + ".trash", + ".trashes", + "lost+found", } _build_locks_registry_lock = threading.Lock() @@ -116,8 +122,30 @@ class IndexStats: duration_seconds: float +_INDEX_STORAGE_ROOT = os.path.join(str(APP_DATA_PATH), ".file_index") + + def _index_dir(root: str) -> str: - return os.path.join(os.path.abspath(root), ".craftbot") + """Per-root index directory, centralized under CraftBot's own app-data + directory rather than inside the searched root itself. + + Keeping index storage out of the searched tree matters for two reasons: + it stays out of the way of arbitrary/mounted directories that get + searched (e.g. a Docker bind-mounted workspace volume), and it makes + "delete CraftBot's data" a single well-known location instead of + scattered .craftbot folders wherever a search happened to run. + + Keyed by a deterministic hash of the normalized root path (same + sha256(...)[:16] convention used elsewhere in the codebase, e.g. + activity_log.make_idem_key) so re-indexing the same root reuses its + existing db. A short sanitized slug is prefixed purely so a developer + browsing the storage directory can tell folders apart at a glance — + the hash alone guarantees uniqueness. + """ + normalized = os.path.normcase(os.path.abspath(root)) + digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16] + slug = re.sub(r"[^a-zA-Z0-9]+", "_", normalized).strip("_")[:40] + return os.path.join(_INDEX_STORAGE_ROOT, f"{slug}_{digest}") def _db_path(root: str) -> str: @@ -195,7 +223,21 @@ def _split_or_patterns(pattern: str) -> list[str]: def _iter_files(root: str): - """Recursive file iterator mirroring os.walk (skips _SKIP_DIR_NAMES, no symlink follow).""" + """Recursive file iterator mirroring os.walk (skips _SKIP_DIR_NAMES, stays + on one filesystem, no symlink follow). + + The filesystem-boundary check (mirrors `find -xdev`) matters most on + macOS/Linux, where "/" is the root of everything — without it, indexing + "/" would recurse into /proc, /sys, other mounted volumes under + /Volumes, network shares, etc. On Windows this is effectively a no-op + since crossing from one drive to another isn't reachable via plain + recursion anyway. + """ + try: + root_dev = os.stat(root).st_dev + except OSError: + return + stack = [root] while stack: dir_path = stack.pop() @@ -206,6 +248,18 @@ def _iter_files(root: str): if entry.is_dir(follow_symlinks=False): if entry.name.lower() in _SKIP_DIR_NAMES: continue + try: + # os.stat(entry.path, ...), NOT entry.stat(...): + # on Windows, DirEntry.stat() is served from + # scandir's cached WIN32_FIND_DATA, which has + # no device info and always reports st_dev=0 + # — comparing that against the real root_dev + # would treat every subdirectory as a + # different filesystem and skip it entirely. + if os.stat(entry.path, follow_symlinks=False).st_dev != root_dev: + continue + except OSError: + continue stack.append(entry.path) elif entry.is_file(follow_symlinks=False): yield entry @@ -640,8 +694,15 @@ def find_files( *base_directory* may contain multiple absolute paths joined by '|' to search several roots in one call. If *all_drives* is True, *base_directory* is ignored and every local fixed drive is searched instead. *limit*, if - given, caps the total number of matches across all roots combined. + given and positive, caps the total number of matches across all roots + combined. 0 (or any non-positive value) means unlimited, matching the + common "0 = no limit" convention — without this, a literal 0 would stop + the search after exactly one match, since `len(matches) >= 0` is true + the instant the first match is found. """ + if limit is not None and limit <= 0: + limit = None + if all_drives: roots = list_local_drives() if not roots: From 0912f3aa1335df4e03d4c2859d6c6dc7992642a1 Mon Sep 17 00:00:00 2001 From: namabeeru Date: Wed, 8 Jul 2026 16:41:20 +0900 Subject: [PATCH 14/28] refactor: remove startup marker module --- craftbot.py | 2 +- installer/api.py | 3 +-- run.py | 3 ++- startup_constants.py | 4 ---- tests/test_craftbot_service.py | 9 +++------ tests/test_startup_constants_usage.py | 15 --------------- 6 files changed, 7 insertions(+), 29 deletions(-) delete mode 100644 startup_constants.py delete mode 100644 tests/test_startup_constants_usage.py diff --git a/craftbot.py b/craftbot.py index 83f30c3a..ddaf64f5 100644 --- a/craftbot.py +++ b/craftbot.py @@ -75,7 +75,6 @@ def flush(self) -> None: from installer import helpers as _helpers from installer import metadata as _metadata from installer import payload as _payload -from startup_constants import CRAFTBOT_READY_MARKER # Store platform once so static analysers don't short-circuit platform branches _PLATFORM: str = sys.platform @@ -187,6 +186,7 @@ def installed_exe_path() -> Optional[str]: DEFAULT_FRONTEND_PORT = 7925 DEFAULT_BACKEND_PORT = 7926 BROWSER_URL = f"http://localhost:{DEFAULT_FRONTEND_PORT}" +CRAFTBOT_READY_MARKER = "CRAFTBOT IS READY" SHORTCUT_NAME = "CraftBot.lnk" # Bundled icons live in sys._MEIPASS in frozen mode (PyInstaller's runtime # extract dir) and alongside craftbot.py in source mode. _ensure_ico() copies diff --git a/installer/api.py b/installer/api.py index b923f140..e9f28f37 100644 --- a/installer/api.py +++ b/installer/api.py @@ -21,7 +21,6 @@ from typing import Callable, Optional import craftbot -from startup_constants import CRAFTBOT_READY_MARKER # webview imported lazily inside `attach` so a syntax error here doesn't # break source-mode tests that don't have pywebview installed. @@ -208,7 +207,7 @@ def _tail_log(self, start_offset: int, deadline_s: float = 90.0) -> None: Stops when the ready marker appears (run.py prints this once the frontend + agent are both up) or after `deadline_s` seconds.""" offset = start_offset - end_marker = CRAFTBOT_READY_MARKER + end_marker = craftbot.CRAFTBOT_READY_MARKER end_time = time.monotonic() + deadline_s announced = False while time.monotonic() < end_time: diff --git a/run.py b/run.py index e5be6846..52da7eae 100644 --- a/run.py +++ b/run.py @@ -34,10 +34,11 @@ ensure_runtime_dependencies, mark_runtime_dependencies_checked, ) -from startup_constants import CRAFTBOT_READY_MARKER multiprocessing.freeze_support() +CRAFTBOT_READY_MARKER = "CRAFTBOT IS READY" + # Configuration is loaded from settings.json via the agent startup # No .env file is used - all settings come from app/config/settings.json diff --git a/startup_constants.py b/startup_constants.py deleted file mode 100644 index c0d88235..00000000 --- a/startup_constants.py +++ /dev/null @@ -1,4 +0,0 @@ -# -*- coding: utf-8 -*- -"""Shared startup markers used by launchers and installer log tailing.""" - -CRAFTBOT_READY_MARKER = "CRAFTBOT IS READY" diff --git a/tests/test_craftbot_service.py b/tests/test_craftbot_service.py index b06ce78e..819dbe1f 100644 --- a/tests/test_craftbot_service.py +++ b/tests/test_craftbot_service.py @@ -6,7 +6,6 @@ import pytest import craftbot -from startup_constants import CRAFTBOT_READY_MARKER class _ExitedProcess: @@ -147,7 +146,9 @@ def test_start_ignores_stale_ready_marker_when_child_exits( ): pid_file = tmp_path / "craftbot.pid" log_file = tmp_path / "craftbot.log" - log_file.write_text(f"old run\n{CRAFTBOT_READY_MARKER}\n", encoding="utf-8") + log_file.write_text( + f"old run\n{craftbot.CRAFTBOT_READY_MARKER}\n", encoding="utf-8" + ) events = [] monkeypatch.setattr(craftbot, "PID_FILE", str(pid_file)) @@ -201,7 +202,3 @@ def test_source_install_returns_false_when_service_start_fails( output = capsys.readouterr().out assert "CraftBot failed to start" in output - - -def test_ready_marker_constant_is_shared(): - assert CRAFTBOT_READY_MARKER == "CRAFTBOT IS READY" diff --git a/tests/test_startup_constants_usage.py b/tests/test_startup_constants_usage.py deleted file mode 100644 index d3b63a98..00000000 --- a/tests/test_startup_constants_usage.py +++ /dev/null @@ -1,15 +0,0 @@ -# -*- coding: utf-8 -*- -from pathlib import Path - -from startup_constants import CRAFTBOT_READY_MARKER - - -def test_ready_marker_literal_is_centralized(): - repo = Path(__file__).resolve().parents[1] - offenders = [] - for relative in ("craftbot.py", "run.py", "installer/api.py"): - text = (repo / relative).read_text(encoding="utf-8") - if f'"{CRAFTBOT_READY_MARKER}"' in text or f"'{CRAFTBOT_READY_MARKER}'" in text: - offenders.append(relative) - - assert offenders == [] From 77c6ce2950185a2e001c2956d8e4d338768f2149 Mon Sep 17 00:00:00 2001 From: CraftBot Date: Sun, 12 Jul 2026 19:27:39 +0900 Subject: [PATCH 15/28] Optimize actions to save token usage --- app/data/action/http_request.py | 46 +- app/data/action/integrations/_helpers.py | 77 +- .../integrations/discord/discord_actions.py | 335 ++++- .../integrations/github/github_actions.py | 125 +- .../google_workspace/gmail_actions.py | 63 +- .../google_calendar_actions.py | 183 ++- .../google_workspace/google_docs_actions.py | 32 +- .../google_workspace/google_drive_actions.py | 11 +- .../google_youtube_actions.py | 143 ++- .../integrations/hubspot/hubspot_actions.py | 700 +++++++--- .../action/integrations/jira/jira_actions.py | 157 ++- .../action/integrations/lark/lark_actions.py | 331 ++++- .../lark_calendar/lark_calendar_actions.py | 185 ++- .../lark_drive/lark_drive_actions.py | 154 ++- .../integrations/linkedin/linkedin_actions.py | 109 +- .../integrations/notion/notion_actions.py | 282 +++- .../integrations/outlook/outlook_actions.py | 91 +- .../integrations/slack/slack_actions.py | 277 +++- .../integrations/stripe/stripe_actions.py | 1144 +++++++++++++---- .../integrations/telegram/telegram_actions.py | 288 ++++- .../integrations/twitter/twitter_actions.py | 59 +- .../integrations/whatsapp/whatsapp_actions.py | 77 +- .../integrations/github/__init__.py | 176 ++- .../integrations/google_drive/__init__.py | 9 +- .../integrations/jira/__init__.py | 136 +- .../integrations/outlook/__init__.py | 17 +- .../integrations/twitter/__init__.py | 75 +- .../integrations/whatsapp_web/__init__.py | 118 +- 28 files changed, 4527 insertions(+), 873 deletions(-) diff --git a/app/data/action/http_request.py b/app/data/action/http_request.py index e64ebc61..b4501568 100644 --- a/app/data/action/http_request.py +++ b/app/data/action/http_request.py @@ -75,6 +75,15 @@ "'body' is omitted." ), }, + "include_headers": { + "type": "boolean", + "example": False, + "description": ( + "False (default): 'response_headers' contains only the useful few " + "(Content-Type, Content-Length, Location, Retry-After, " + "WWW-Authenticate, X-RateLimit-*). True: the full header dict." + ), + }, }, output_schema={ "status": { @@ -90,7 +99,9 @@ "response_headers": { "type": "object", "example": {"Content-Type": "application/json"}, - "description": "Response headers returned by the server.", + "description": ( + "Key response headers (full set only when include_headers=true)." + ), }, "body": { "type": "string", @@ -112,11 +123,6 @@ "example": "application/zip", "description": "Response Content-Type (bare media type, no parameters).", }, - "response_json": { - "type": "object", - "example": {"ok": True}, - "description": "Parsed JSON body if available; otherwise omitted.", - }, "final_url": { "type": "string", "example": "https://api.example.com/v1/items?limit=10", @@ -183,6 +189,7 @@ def send_http_requests(input_data: dict) -> dict: verify_tls = bool(input_data.get("verify_tls", True)) save_to = input_data.get("save_to") save_to = str(save_to).strip() if save_to else "" + include_headers = bool(input_data.get("include_headers", False)) allowed = {"GET", "POST", "PUT", "PATCH", "DELETE"} if method not in allowed: return { @@ -409,6 +416,19 @@ def _stream_to_file(resp, path: str) -> int: # large downloads out of memory (we write them straight to disk). resp = requests.request(method, url, stream=True, **kwargs) resp_headers = {k: v for k, v in resp.headers.items()} + if not include_headers: + _useful = { + "content-type", + "content-length", + "location", + "retry-after", + "www-authenticate", + } + resp_headers = { + k: v + for k, v in resp_headers.items() + if k.lower() in _useful or k.lower().startswith("x-ratelimit") + } content_type = _bare_content_type(resp) # Decide whether this response is a file to save (binary-safe) or text @@ -453,15 +473,12 @@ def _stream_to_file(resp, path: str) -> int: else f"HTTP {resp.status_code}", } - # Textual response — return inline as before. + # Textual response — return inline as before. JSON bodies are returned + # once, as text in 'body' (the old 'response_json' field duplicated the + # entire payload a second time). body_text = resp.text elapsed_ms = int((time.time() - t0) * 1000) - parsed_json = None - try: - parsed_json = resp.json() - except Exception: - parsed_json = None - out = { + return { "status": "success" if resp.ok else "error", "status_code": resp.status_code, "response_headers": resp_headers, @@ -471,9 +488,6 @@ def _stream_to_file(resp, path: str) -> int: "elapsed_ms": elapsed_ms, "message": "" if resp.ok else f"HTTP {resp.status_code}", } - if parsed_json is not None: - out["response_json"] = parsed_json - return out except Exception as e: return { "status": "error", diff --git a/app/data/action/integrations/_helpers.py b/app/data/action/integrations/_helpers.py index e29fdb65..0edcb6ac 100644 --- a/app/data/action/integrations/_helpers.py +++ b/app/data/action/integrations/_helpers.py @@ -16,10 +16,15 @@ async def send_discord_message(input_data: dict) -> dict: For sync actions, use ``run_client_sync`` (same API, no await). Some clients return ``{"ok": True, "result": ...}`` / ``{"error": ...}`` -envelopes (Outlook, Jira, etc.). Pass ``unwrap_envelope=True`` to -extract the inner ``result`` on success or surface the inner ``error`` -message on failure. Pair with ``success_message="..."`` when the action -should report a fixed success string instead of the inner result. +envelopes (Outlook, Jira, etc.). ``_shape_result`` collapses that +transport envelope automatically — the agent never sees a nested +``{"ok": true, "result": ...}`` wrapper inside the action result, and +envelope failures surface as ``{"status": "error"}`` instead of being +buried under a success wrapper. ``unwrap_envelope=True`` is still +accepted for backward compatibility (it additionally treats ANY dict +containing an ``error`` key as a failure). Pair with +``success_message="..."`` when the action should report a fixed success +string instead of the inner result. Actions that do real pre/post-processing (parsing labels, recording to conversation history, building complex payloads) keep their explicit @@ -139,37 +144,77 @@ def _shape_result( success_message: Optional[str], fail_message: str, ) -> Dict[str, Any]: - """Translate a client return value into the action response envelope.""" - if unwrap_envelope and isinstance(raw, dict): - # Success envelope: {"ok": True, "result": ...} + """Translate a client return value into the action response envelope. + + The ``{"ok": ...}`` transport envelope some clients emit is ALWAYS + collapsed — previously (without ``unwrap_envelope=True``) the agent got + a double-nested ``{"status": "success", "result": {"ok": true, + "result": ...}}`` on every call, and envelope failures were wrapped as + successes. ``unwrap_envelope`` remains as an opt-in for the looser + "any dict containing an 'error' key is a failure" interpretation. + """ + if isinstance(raw, dict): + # Success envelope: {"ok": True, "result": ...} — or Slack-style + # bodies where "ok" sits alongside the payload fields. if raw.get("ok") is True: if success_message: return {"status": "success", "message": success_message} - return {"status": "success", "result": raw.get("result", raw)} + if set(raw.keys()) == {"ok", "result"}: + return {"status": "success", "result": raw["result"]} + return { + "status": "success", + "result": {k: v for k, v in raw.items() if k != "ok"}, + } # Explicit failure envelope: {"ok": False, "error": ...} if raw.get("ok") is False: return {"status": "error", "message": raw.get("error", fail_message)} # Implicit failure envelope from craftos_integrations.helpers.request: # 4xx/5xx HTTP responses (and caught exceptions) return # {"error": "API error: 403", "details": "..."} with NO "ok" key. - # Without this branch, the next clauses fall through and wrap the - # error as {"status": "success"}, hiding the failure from the agent. - if "error" in raw: + # Restricted to exactly that shape by default so a legitimate payload + # that merely *contains* an "error" field isn't misread as failure; + # unwrap_envelope=True keeps the looser historical behavior. + if "error" in raw and ( + unwrap_envelope or set(raw.keys()) <= {"error", "details"} + ): return { "status": "error", "message": raw.get("error", fail_message), "details": raw.get("details"), } - if success_message and isinstance(raw, dict) and raw.get("status") == "error": - return { - "status": "error", - "message": raw.get("message") or raw.get("error", fail_message), - } + # Clients that pre-wrap their own {"status": "error", ...} (e.g. the + # WhatsApp bridge) — surface the failure instead of re-wrapping it + # under a success envelope. + if raw.get("status") == "error": + return { + "status": "error", + "message": raw.get("message") or raw.get("error", fail_message), + } if success_message: return {"status": "success", "message": success_message} return {"status": "success", "result": raw} +def pick_result(res: Dict[str, Any], keys) -> Dict[str, Any]: + """Reduce a successful ``run_client`` result to the named top-level keys. + + Used by write/create/send actions whose provider returns the entire + mutated object: the agent only needs the id (+ a couple of key fields), + and can always fetch the full object with the matching ``get_*`` action. + Non-dict results, error results, and missing keys pass through untouched + so this is always safe to apply:: + + res = await run_client("stripe", "create_customer", ...) + return pick_result(res, ["id", "status"]) + """ + if res.get("status") == "success" and isinstance(res.get("result"), dict): + r = res["result"] + picked = {k: r.get(k) for k in keys if r.get(k) is not None} + if picked: + res = {**res, "result": picked} + return res + + async def run_client( integration: str, method_name: str, diff --git a/app/data/action/integrations/discord/discord_actions.py b/app/data/action/integrations/discord/discord_actions.py index e6f36f66..9393e9db 100644 --- a/app/data/action/integrations/discord/discord_actions.py +++ b/app/data/action/integrations/discord/discord_actions.py @@ -232,19 +232,63 @@ def unpin_discord_message(input_data: dict) -> dict: @action( name="list_discord_pinned_messages", - description="List pinned messages in a Discord channel.", + description="List pinned messages in a Discord channel. Lean messages by default; include_metadata=true returns raw message objects.", action_sets=["discord_messages", "discord"], input_schema={ "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + "include_metadata": { + "type": "boolean", + "description": "Return full raw message objects (default false = lean).", + "example": False, + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "Lean: {messages: [{id, content, author: {id, username, bot}, timestamp, attachments?}], count}.", + }, }, - output_schema={"status": {"type": "string", "example": "success"}}, ) def list_discord_pinned_messages(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( + res = run_client_sync( "discord", "list_pinned_messages", channel_id=input_data["channel_id"] ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + result = res.get("result") + if not isinstance(result, dict): + return res + lean = [] + for m in result.get("messages", []): + if not isinstance(m, dict): + continue + a = m.get("author") or {} + item = { + "id": m.get("id"), + "content": m.get("content"), + "author": { + "id": a.get("id"), + "username": a.get("username"), + "bot": a.get("bot", False), + }, + "timestamp": m.get("timestamp"), + } + atts = [ + { + "id": att.get("id"), + "filename": att.get("filename"), + "url": att.get("url"), + } + for att in m.get("attachments") or [] + if isinstance(att, dict) + ] + if atts: + item["attachments"] = atts + lean.append(item) + return {**res, "result": {"messages": lean, "count": result.get("count", len(lean))}} @action( @@ -637,7 +681,7 @@ def unarchive_discord_thread(input_data: dict) -> dict: @action( name="get_discord_channels", - description="Get all channels in a Discord guild.", + description="Get all channels in a Discord guild. Lean channel list by default; include_metadata=true returns raw channel objects plus type-grouped subsets.", action_sets=["discord_channels", "discord"], input_schema={ "guild_id": { @@ -645,15 +689,47 @@ def unarchive_discord_thread(input_data: dict) -> dict: "description": "Discord guild (server) ID.", "example": "123456789012345678", }, + "include_metadata": { + "type": "boolean", + "description": "Return full raw channel objects (default false = lean).", + "example": False, + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "Lean: {all_channels: [{id, name, type, parent_id, position?, topic?}]}.", + }, }, - output_schema={"status": {"type": "string", "example": "success"}}, ) def get_discord_channels(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( + res = run_client_sync( "discord", "get_guild_channels", guild_id=input_data["guild_id"] ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + result = res.get("result") + if not isinstance(result, dict): + return res + lean = [] + for c in result.get("all_channels", []): + if not isinstance(c, dict): + continue + ch = { + "id": c.get("id"), + "name": c.get("name"), + "type": c.get("type"), + "parent_id": c.get("parent_id"), + } + if c.get("position") is not None: + ch["position"] = c.get("position") + if c.get("topic"): + ch["topic"] = c.get("topic") + lean.append(ch) + return {**res, "result": {"all_channels": lean}} @action( @@ -957,19 +1033,59 @@ def delete_discord_invite(input_data: dict) -> dict: @action( name="list_discord_webhooks", - description="List webhooks in a channel.", + description="List webhooks in a channel. Lean by default; include_metadata=true returns full raw objects. The webhook token is never returned.", action_sets=["discord_channels", "discord"], input_schema={ "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + "include_metadata": { + "type": "boolean", + "description": "Return full raw webhook objects, minus token (default false = lean).", + "example": False, + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "Lean: {webhooks: [{id, name, type, channel_id, guild_id, application_id}], count}. Token is always omitted.", + }, }, - output_schema={"status": {"type": "string", "example": "success"}}, ) def list_discord_webhooks(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( + res = run_client_sync( "discord", "list_channel_webhooks", channel_id=input_data["channel_id"] ) + if res.get("status") != "success": + return res + result = res.get("result") + if not isinstance(result, dict): + return res + include = bool(input_data.get("include_metadata")) + shaped = [] + for w in result.get("webhooks", []): + if not isinstance(w, dict): + continue + if include: + wh = {k: v for k, v in w.items() if k != "token"} + u = wh.get("user") + if isinstance(u, dict): + wh["user"] = {"id": u.get("id"), "username": u.get("username")} + else: + wh = { + "id": w.get("id"), + "name": w.get("name"), + "type": w.get("type"), + "channel_id": w.get("channel_id"), + "guild_id": w.get("guild_id"), + "application_id": w.get("application_id"), + } + shaped.append(wh) + return { + **res, + "result": {"webhooks": shaped, "count": result.get("count", len(shaped))}, + } @action( @@ -1006,19 +1122,50 @@ def create_discord_webhook(input_data: dict) -> dict: @action( name="get_discord_webhook", - description="Get a webhook by ID.", + description="Get a webhook by ID. Lean by default; include_metadata=true returns the full raw object. The webhook token is never returned.", action_sets=["discord_channels"], input_schema={ "webhook_id": {"type": "string", "description": "Webhook ID.", "example": ""}, + "include_metadata": { + "type": "boolean", + "description": "Return the full raw webhook object, minus token (default false = lean).", + "example": False, + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "Lean: {id, name, type, channel_id, guild_id, application_id}. Token is always omitted.", + }, }, - output_schema={"status": {"type": "string", "example": "success"}}, ) def get_discord_webhook(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( + res = run_client_sync( "discord", "get_webhook", webhook_id=input_data["webhook_id"] ) + if res.get("status") != "success": + return res + result = res.get("result") + if not isinstance(result, dict): + return res + if input_data.get("include_metadata"): + wh = {k: v for k, v in result.items() if k != "token"} + u = wh.get("user") + if isinstance(u, dict): + wh["user"] = {"id": u.get("id"), "username": u.get("username")} + else: + wh = { + "id": result.get("id"), + "name": result.get("name"), + "type": result.get("type"), + "channel_id": result.get("channel_id"), + "guild_id": result.get("guild_id"), + "application_id": result.get("application_id"), + } + return {**res, "result": wh} @action( @@ -1136,7 +1283,7 @@ def execute_discord_webhook(input_data: dict) -> dict: @action( name="list_discord_guild_members", - description="List members of a guild.", + description="List members of a guild. Lean members by default; include_metadata=true returns raw member objects.", action_sets=["discord_members", "discord"], input_schema={ "guild_id": { @@ -1145,18 +1292,51 @@ def execute_discord_webhook(input_data: dict) -> dict: "example": "123456789012345678", }, "limit": {"type": "integer", "description": "Limit.", "example": 100}, + "include_metadata": { + "type": "boolean", + "description": "Return full raw member objects (default false = lean).", + "example": False, + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "Lean: {members: [{user: {id, username, global_name?}, nick?, roles, joined_at}]}.", + }, }, - output_schema={"status": {"type": "string", "example": "success"}}, ) def list_discord_guild_members(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( + res = run_client_sync( "discord", "list_guild_members", guild_id=input_data["guild_id"], limit=input_data.get("limit", 100), ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + result = res.get("result") + if not isinstance(result, dict): + return res + lean = [] + for m in result.get("members", []): + if not isinstance(m, dict): + continue + u = m.get("user") or {} + user = {"id": u.get("id"), "username": u.get("username")} + if u.get("global_name"): + user["global_name"] = u.get("global_name") + member = { + "user": user, + "roles": m.get("roles", []), + "joined_at": m.get("joined_at"), + } + if m.get("nick"): + member["nick"] = m.get("nick") + lean.append(member) + return {**res, "result": {"members": lean}} @action( @@ -1454,17 +1634,48 @@ def list_discord_guilds(input_data: dict) -> dict: @action( name="get_discord_guild", - description="Get info about a Discord guild.", + description="Get info about a Discord guild. Lean summary by default; include_metadata=true returns the raw guild object (roles, emojis, stickers, features).", action_sets=["discord_guild", "discord"], input_schema={ "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + "include_metadata": { + "type": "boolean", + "description": "Return the full raw guild object (default false = lean).", + "example": False, + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "Lean: {id, name, description, owner_id, member_count?, approximate_member_count?, premium_tier?, preferred_locale?}.", + }, }, - output_schema={"status": {"type": "string", "example": "success"}}, ) def get_discord_guild(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("discord", "get_guild", guild_id=input_data["guild_id"]) + res = run_client_sync("discord", "get_guild", guild_id=input_data["guild_id"]) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + g = res.get("result") + if not isinstance(g, dict): + return res + lean = { + "id": g.get("id"), + "name": g.get("name"), + "description": g.get("description"), + "owner_id": g.get("owner_id"), + } + for k in ( + "member_count", + "approximate_member_count", + "premium_tier", + "preferred_locale", + ): + if g.get(k) is not None: + lean[k] = g.get(k) + return {**res, "result": lean} @action( @@ -1821,14 +2032,25 @@ def delete_discord_scheduled_event(input_data: dict) -> dict: "example": "", }, "limit": {"type": "integer", "description": "1-100.", "example": 50}, + "include_metadata": { + "type": "boolean", + "description": "Return raw audit log incl. users[]/webhooks[] side tables (default false = lean entries).", + "example": False, + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "Lean: {audit_log_entries: [{id, action_type, user_id, target_id, reason?, changes?: [{key, old?, new?}]}]}.", + }, }, - output_schema={"status": {"type": "string", "example": "success"}}, ) def get_discord_audit_log(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync at = input_data.get("action_type") - return run_client_sync( + res = run_client_sync( "discord", "get_audit_log", guild_id=input_data["guild_id"], @@ -1837,6 +2059,37 @@ def get_discord_audit_log(input_data: dict) -> dict: before=input_data.get("before") or None, limit=input_data.get("limit", 50), ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + result = res.get("result") + if not isinstance(result, dict): + return res + lean = [] + for e in result.get("audit_log_entries", []): + if not isinstance(e, dict): + continue + entry = { + "id": e.get("id"), + "action_type": e.get("action_type"), + "user_id": e.get("user_id"), + "target_id": e.get("target_id"), + } + if e.get("reason"): + entry["reason"] = e.get("reason") + changes = [] + for ch in e.get("changes") or []: + if not isinstance(ch, dict): + continue + c = {"key": ch.get("key")} + if "old_value" in ch: + c["old"] = ch.get("old_value") + if "new_value" in ch: + c["new"] = ch.get("new_value") + changes.append(c) + if changes: + entry["changes"] = changes + lean.append(entry) + return {**res, "result": {"audit_log_entries": lean}} @action( @@ -2033,25 +2286,61 @@ def get_discord_user_relationships(input_data: dict) -> dict: @action( name="search_discord_guild_messages_as_user", - description="Search messages in a guild (selfbot — uses user token's search permission).", + description="Search messages in a guild (selfbot — uses user token's search permission). Lean flattened hits by default; include_metadata=true returns Discord's raw arrays-of-arrays.", action_sets=["discord_user"], input_schema={ "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, "query": {"type": "string", "description": "Search content.", "example": ""}, "limit": {"type": "integer", "description": "Max results.", "example": 25}, + "include_metadata": { + "type": "boolean", + "description": "Return raw search result groups (default false = lean flattened hits).", + "example": False, + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "Lean: {total_results, messages: [{id, channel_id, author: {id, username}, content, timestamp}]}.", + }, }, - output_schema={"status": {"type": "string", "example": "success"}}, ) def search_discord_guild_messages_as_user(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( + res = run_client_sync( "discord", "user_search_guild_messages", guild_id=input_data["guild_id"], query=input_data["query"], limit=input_data.get("limit", 25), ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + result = res.get("result") + if not isinstance(result, dict): + return res + hits = [] + for group in result.get("messages", []) or []: + items = group if isinstance(group, list) else [group] + items = [m for m in items if isinstance(m, dict)] + marked = [m for m in items if m.get("hit")] + for m in marked or items: + a = m.get("author") or {} + hits.append( + { + "id": m.get("id"), + "channel_id": m.get("channel_id"), + "author": {"id": a.get("id"), "username": a.get("username")}, + "content": m.get("content"), + "timestamp": m.get("timestamp"), + } + ) + return { + **res, + "result": {"total_results": result.get("total_results"), "messages": hits}, + } # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/app/data/action/integrations/github/github_actions.py b/app/data/action/integrations/github/github_actions.py index ae033b2f..328db515 100644 --- a/app/data/action/integrations/github/github_actions.py +++ b/app/data/action/integrations/github/github_actions.py @@ -39,7 +39,7 @@ async def list_github_issues(input_data: dict) -> dict: @action( name="get_github_issue", - description="Get details of a specific GitHub issue or PR by number.", + description="Get details of a specific GitHub issue or PR by number. Returns lean fields (title, state, body, user, labels, assignees, dates) by default; set include_metadata=true for the raw API payload.", action_sets=["github_issues", "github"], input_schema={ "repo": { @@ -52,15 +52,30 @@ async def list_github_issues(input_data: dict) -> dict: "description": "Issue or PR number.", "example": 1, }, + "include_metadata": { + "type": "boolean", + "description": "Return the full raw API payload instead of lean fields.", + "example": False, + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "Lean: number, title, state, body, user, labels, assignees, milestone, comments, dates, html_url, is_pr. Raw payload when include_metadata=true.", + }, }, - output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_github_issue(input_data: dict) -> dict: from app.data.action.integrations._helpers import with_client return await with_client( "github", - lambda c: c.get_issue(input_data["repo"], input_data["number"]), + lambda c: c.get_issue( + input_data["repo"], + input_data["number"], + include_metadata=bool(input_data.get("include_metadata", False)), + ), ) @@ -979,7 +994,7 @@ async def list_github_prs(input_data: dict) -> dict: @action( name="get_github_pr", - description="Get full details of a specific pull request.", + description="Get details of a specific pull request. Returns lean fields (title, state, body, merge status, base/head refs, diff stats) by default; set include_metadata=true for the raw API payload.", action_sets=["github_pulls", "github"], input_schema={ "repo": { @@ -992,15 +1007,30 @@ async def list_github_prs(input_data: dict) -> dict: "description": "Pull request number.", "example": 1, }, + "include_metadata": { + "type": "boolean", + "description": "Return the full raw API payload instead of lean fields.", + "example": False, + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "Lean: number, title, state, body, draft, merged, mergeable, merged_by, user, labels, assignees, requested_reviewers, base/head {ref, sha}, commits, additions, deletions, changed_files, dates, html_url. Raw payload when include_metadata=true.", + }, }, - output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_github_pr(input_data: dict) -> dict: from app.data.action.integrations._helpers import with_client return await with_client( "github", - lambda c: c.get_pull_request(input_data["repo"], input_data["number"]), + lambda c: c.get_pull_request( + input_data["repo"], + input_data["number"], + include_metadata=bool(input_data.get("include_metadata", False)), + ), ) @@ -1528,7 +1558,7 @@ async def list_github_repos(input_data: dict) -> dict: @action( name="get_github_repo", - description="Get repository metadata (default_branch, description, stars, fork status, etc.).", + description="Get repository metadata (default_branch, description, stars, fork status, etc.). Returns lean fields by default; set include_metadata=true for the raw API payload.", action_sets=["github_repos", "github"], input_schema={ "repo": { @@ -1536,13 +1566,30 @@ async def list_github_repos(input_data: dict) -> dict: "description": "Repository in owner/repo format.", "example": "octocat/hello-world", }, + "include_metadata": { + "type": "boolean", + "description": "Return the full raw API payload instead of lean fields.", + "example": False, + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "Lean: name, full_name, description, private, fork, default_branch, language, star/fork/issue counts, topics, archived, pushed_at, html_url, owner login. Raw payload when include_metadata=true.", + }, }, - output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_github_repo(input_data: dict) -> dict: from app.data.action.integrations._helpers import with_client - return await with_client("github", lambda c: c.get_repo(input_data["repo"])) + return await with_client( + "github", + lambda c: c.get_repo( + input_data["repo"], + include_metadata=bool(input_data.get("include_metadata", False)), + ), + ) @action( @@ -2208,7 +2255,7 @@ async def list_github_commits(input_data: dict) -> dict: @action( name="get_github_commit", - description="Get details of a specific commit (files changed, stats, author).", + description="Get details of a specific commit (files changed with patches, stats, author). Returns lean fields by default; set include_metadata=true for the raw API payload.", action_sets=["github_code"], input_schema={ "repo": { @@ -2217,15 +2264,30 @@ async def list_github_commits(input_data: dict) -> dict: "example": "octocat/hello-world", }, "sha": {"type": "string", "description": "Commit SHA.", "example": ""}, + "include_metadata": { + "type": "boolean", + "description": "Return the full raw API payload instead of lean fields.", + "example": False, + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "Lean: sha, message, author/committer {name, email, date, login}, stats, parent shas, files [{filename, status, additions, deletions, patch}], html_url. Raw payload when include_metadata=true.", + }, }, - output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_github_commit(input_data: dict) -> dict: from app.data.action.integrations._helpers import with_client return await with_client( "github", - lambda c: c.get_commit(input_data["repo"], input_data["sha"]), + lambda c: c.get_commit( + input_data["repo"], + input_data["sha"], + include_metadata=bool(input_data.get("include_metadata", False)), + ), ) @@ -2295,7 +2357,7 @@ async def list_github_releases(input_data: dict) -> dict: @action( name="get_github_release", - description="Get a release by ID, by tag, or the latest. Provide one of: release_id, tag, or latest=true.", + description="Get a release by ID, by tag, or the latest. Provide one of: release_id, tag, or latest=true. Returns lean fields by default; set include_metadata=true for the raw API payload.", action_sets=["github_releases"], input_schema={ "repo": { @@ -2314,8 +2376,19 @@ async def list_github_releases(input_data: dict) -> dict: "description": "Get the latest release (optional).", "example": False, }, + "include_metadata": { + "type": "boolean", + "description": "Return the full raw API payload instead of lean fields.", + "example": False, + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "Lean: id, tag_name, name, body, draft, prerelease, dates, html_url, author login, assets [{name, size, download_count, browser_download_url}]. Raw payload when include_metadata=true.", + }, }, - output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_github_release(input_data: dict) -> dict: from app.data.action.integrations._helpers import with_client @@ -2328,6 +2401,7 @@ async def get_github_release(input_data: dict) -> dict: release_id=rid if rid else None, tag=input_data.get("tag") or None, latest=bool(input_data.get("latest", False)), + include_metadata=bool(input_data.get("include_metadata", False)), ), ) @@ -3077,17 +3151,34 @@ async def list_github_gists(input_data: dict) -> dict: @action( name="get_github_gist", - description="Get a gist (full file contents) by ID.", + description="Get a gist (full file contents) by ID. Returns lean fields by default; set include_metadata=true for the raw API payload (history, forks, per-file URLs).", action_sets=["github_gists"], input_schema={ "gist_id": {"type": "string", "description": "Gist ID.", "example": ""}, + "include_metadata": { + "type": "boolean", + "description": "Return the full raw API payload instead of lean fields.", + "example": False, + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "Lean: id, description, public, html_url, dates, owner login, files {name: {filename, language, size, truncated, content}}. Raw payload when include_metadata=true.", + }, }, - output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_github_gist(input_data: dict) -> dict: from app.data.action.integrations._helpers import with_client - return await with_client("github", lambda c: c.get_gist(input_data["gist_id"])) + return await with_client( + "github", + lambda c: c.get_gist( + input_data["gist_id"], + include_metadata=bool(input_data.get("include_metadata", False)), + ), + ) @action( diff --git a/app/data/action/integrations/google_workspace/gmail_actions.py b/app/data/action/integrations/google_workspace/gmail_actions.py index 27391b0d..a3283aa2 100644 --- a/app/data/action/integrations/google_workspace/gmail_actions.py +++ b/app/data/action/integrations/google_workspace/gmail_actions.py @@ -475,7 +475,7 @@ def list_gmail_threads(input_data: dict) -> dict: @action( name="get_gmail_thread", - description="Get a thread (conversation) and its messages.", + description="Get a thread (conversation) and its messages. Default returns per-message {id, from, to, subject, date, snippet}; set include_metadata for the raw thread.", action_sets=["gmail_threads", "gmail"], input_schema={ "thread_id": {"type": "string", "description": "Thread ID.", "example": ""}, @@ -484,13 +484,18 @@ def list_gmail_threads(input_data: dict) -> dict: "description": "metadata | full | minimal.", "example": "metadata", }, + "include_metadata": { + "type": "boolean", + "description": "Return the raw thread resource (default false = lean).", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_gmail_thread(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( + res = run_client_sync( "gmail", "get_thread", unwrap_envelope=True, @@ -498,6 +503,32 @@ def get_gmail_thread(input_data: dict) -> dict: thread_id=input_data["thread_id"], fmt=input_data.get("fmt", "metadata"), ) + if not input_data.get("include_metadata") and res.get("status") == "success": + thread = res.get("result") + if isinstance(thread, dict): + lean_messages = [] + for msg in thread.get("messages", []) or []: + if not isinstance(msg, dict): + continue + headers = { + h.get("name", ""): h.get("value", "") + for h in msg.get("payload", {}).get("headers", []) + } + lean_messages.append( + { + "id": msg.get("id"), + "from": headers.get("From", ""), + "to": headers.get("To", ""), + "subject": headers.get("Subject", ""), + "date": headers.get("Date", ""), + "snippet": msg.get("snippet", ""), + } + ) + res = { + **res, + "result": {"id": thread.get("id"), "messages": lean_messages}, + } + return res @action( @@ -630,7 +661,7 @@ def list_gmail_drafts(input_data: dict) -> dict: @action( name="get_gmail_draft", - description="Get a Gmail draft by ID.", + description="Get a Gmail draft by ID. Default returns {id, message_id, to, subject, snippet}; set include_metadata for the raw draft.", action_sets=["gmail_drafts"], input_schema={ "draft_id": {"type": "string", "description": "Draft ID.", "example": ""}, @@ -639,13 +670,18 @@ def list_gmail_drafts(input_data: dict) -> dict: "description": "metadata | full | minimal.", "example": "metadata", }, + "include_metadata": { + "type": "boolean", + "description": "Return the raw draft resource (default false = lean).", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_gmail_draft(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( + res = run_client_sync( "gmail", "get_draft", unwrap_envelope=True, @@ -653,6 +689,25 @@ def get_gmail_draft(input_data: dict) -> dict: draft_id=input_data["draft_id"], fmt=input_data.get("fmt", "metadata"), ) + if not input_data.get("include_metadata") and res.get("status") == "success": + draft = res.get("result") + if isinstance(draft, dict): + msg = draft.get("message") or {} + headers = { + h.get("name", ""): h.get("value", "") + for h in msg.get("payload", {}).get("headers", []) + } + res = { + **res, + "result": { + "id": draft.get("id"), + "message_id": msg.get("id"), + "to": headers.get("To", ""), + "subject": headers.get("Subject", ""), + "snippet": msg.get("snippet", ""), + }, + } + return res @action( diff --git a/app/data/action/integrations/google_workspace/google_calendar_actions.py b/app/data/action/integrations/google_workspace/google_calendar_actions.py index 0f022638..fef1033a 100644 --- a/app/data/action/integrations/google_workspace/google_calendar_actions.py +++ b/app/data/action/integrations/google_workspace/google_calendar_actions.py @@ -1,6 +1,44 @@ from agent_core import action +def _lean_gcal_event(ev: dict) -> dict: + """Reduce a raw Calendar Event resource to the fields an agent acts on. + + NOTE: action handlers run via exec() on extracted source, so handlers + import this by full module path inside the function body (module-level + names are not in scope at handler runtime). + """ + out = { + k: ev.get(k) + for k in ( + "id", + "summary", + "description", + "location", + "start", + "end", + "status", + "recurrence", + "recurringEventId", + "htmlLink", + "hangoutLink", + ) + if ev.get(k) is not None + } + attendees = ev.get("attendees") + if attendees: + out["attendees"] = [ + { + k: a.get(k) + for k in ("email", "displayName", "responseStatus", "organizer") + if a.get(k) is not None + } + for a in attendees + if isinstance(a, dict) + ] + return out + + # ------------------------------------------------------------------ # Convenience helpers (kept as-is for backwards-compat) # ------------------------------------------------------------------ @@ -8,7 +46,7 @@ @action( name="create_google_meet", - description="Create a Google Calendar event with a Google Meet link.", + description="Create a Google Calendar event with a Google Meet link. Returns id, hangoutLink + key fields.", action_sets=["google_calendar_events", "google_calendar"], input_schema={ "event_data": { @@ -22,12 +60,18 @@ "example": "primary", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "example": {"id": "...", "hangoutLink": "https://meet.google.com/..."}, + }, + }, ) def create_google_meet(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync + from app.data.action.integrations._helpers import pick_result, run_client_sync - return run_client_sync( + res = run_client_sync( "google_calendar", "create_meet_event", unwrap_envelope=True, @@ -35,6 +79,9 @@ def create_google_meet(input_data: dict) -> dict: calendar_id=input_data.get("calendar_id", "primary"), event_data=input_data.get("event_data"), ) + return pick_result( + res, ["id", "summary", "start", "end", "htmlLink", "hangoutLink", "status"] + ) @action( @@ -173,10 +220,17 @@ def check_availability_and_schedule(input_data: dict) -> dict: "reason": "Google Calendar API error", "details": result, } + event = result.get("result", result) + if isinstance(event, dict): + event = { + k: event.get(k) + for k in ("id", "hangoutLink", "htmlLink", "start", "end") + if event.get(k) is not None + } return { "status": "success", "reason": "Meeting scheduled successfully.", - "event": result.get("result", result), + "event": event, } @@ -187,7 +241,7 @@ def check_availability_and_schedule(input_data: dict) -> dict: @action( name="list_google_calendar_events", - description="List events on a calendar between time_min and time_max. Returns expanded single events sorted by start time.", + description="List events on a calendar between time_min and time_max. Returns expanded single events sorted by start time. Lean event fields by default (id, summary, description, location, start, end, status, attendees, recurrence, htmlLink, hangoutLink); set include_metadata for raw Event resources.", action_sets=["google_calendar_events", "google_calendar"], input_schema={ "calendar_id": { @@ -210,13 +264,21 @@ def check_availability_and_schedule(input_data: dict) -> dict: "description": "Max events to return.", "example": 50, }, + "include_metadata": { + "type": "boolean", + "description": "Return full raw Event resources (default false = lean).", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def list_google_calendar_events(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + from app.data.action.integrations.google_workspace.google_calendar_actions import ( + _lean_gcal_event, + ) - return run_client_sync( + res = run_client_sync( "google_calendar", "list_events", unwrap_envelope=True, @@ -226,11 +288,21 @@ def list_google_calendar_events(input_data: dict) -> dict: time_max=input_data.get("time_max"), max_results=input_data.get("max_results", 50), ) + if not input_data.get("include_metadata") and res.get("status") == "success": + items = res.get("result") + if isinstance(items, list): + res = { + **res, + "result": [ + _lean_gcal_event(e) for e in items if isinstance(e, dict) + ], + } + return res @action( name="get_google_calendar_event", - description="Get a single event by ID.", + description="Get a single event by ID. Lean event fields by default; set include_metadata for the raw Event resource.", action_sets=["google_calendar_events", "google_calendar"], input_schema={ "event_id": {"type": "string", "description": "Event ID.", "example": ""}, @@ -239,13 +311,21 @@ def list_google_calendar_events(input_data: dict) -> dict: "description": "Calendar ID (default: primary).", "example": "primary", }, + "include_metadata": { + "type": "boolean", + "description": "Return the full raw Event resource (default false = lean).", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_google_calendar_event(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + from app.data.action.integrations.google_workspace.google_calendar_actions import ( + _lean_gcal_event, + ) - return run_client_sync( + res = run_client_sync( "google_calendar", "get_event", unwrap_envelope=True, @@ -253,11 +333,16 @@ def get_google_calendar_event(input_data: dict) -> dict: event_id=input_data["event_id"], calendar_id=input_data.get("calendar_id", "primary"), ) + if not input_data.get("include_metadata") and res.get("status") == "success": + ev = res.get("result") + if isinstance(ev, dict): + res = {**res, "result": _lean_gcal_event(ev)} + return res @action( name="create_google_calendar_event", - description="Create a calendar event. event_data is the full Event resource (summary, start, end, attendees, etc.). Use create_google_meet for events with a Meet link.", + description="Create a calendar event. event_data is the full Event resource (summary, start, end, attendees, etc.). Use create_google_meet for events with a Meet link. Returns id + key fields.", action_sets=["google_calendar_events", "google_calendar"], input_schema={ "event_data": { @@ -285,9 +370,9 @@ def get_google_calendar_event(input_data: dict) -> dict: parallelizable=False, ) def create_google_calendar_event(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync + from app.data.action.integrations._helpers import pick_result, run_client_sync - return run_client_sync( + res = run_client_sync( "google_calendar", "insert_event", unwrap_envelope=True, @@ -297,11 +382,14 @@ def create_google_calendar_event(input_data: dict) -> dict: send_updates=input_data.get("send_updates", "none"), supports_attachments=bool(input_data.get("supports_attachments", False)), ) + return pick_result( + res, ["id", "summary", "start", "end", "htmlLink", "hangoutLink", "status"] + ) @action( name="update_google_calendar_event", - description="Replace an event entirely (PUT). For partial updates use patch_google_calendar_event.", + description="Replace an event entirely (PUT). For partial updates use patch_google_calendar_event. Returns id + key fields.", action_sets=["google_calendar_events", "google_calendar"], input_schema={ "event_id": {"type": "string", "description": "Event ID.", "example": ""}, @@ -325,9 +413,9 @@ def create_google_calendar_event(input_data: dict) -> dict: parallelizable=False, ) def update_google_calendar_event(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync + from app.data.action.integrations._helpers import pick_result, run_client_sync - return run_client_sync( + res = run_client_sync( "google_calendar", "update_event", unwrap_envelope=True, @@ -337,11 +425,14 @@ def update_google_calendar_event(input_data: dict) -> dict: event_data=input_data["event_data"], send_updates=input_data.get("send_updates", "none"), ) + return pick_result( + res, ["id", "summary", "start", "end", "htmlLink", "hangoutLink", "status"] + ) @action( name="patch_google_calendar_event", - description="Patch (partial update) an event. event_data contains ONLY the fields to change.", + description="Patch (partial update) an event. event_data contains ONLY the fields to change. Returns id + key fields.", action_sets=["google_calendar_events", "google_calendar"], input_schema={ "event_id": {"type": "string", "description": "Event ID.", "example": ""}, @@ -365,9 +456,9 @@ def update_google_calendar_event(input_data: dict) -> dict: parallelizable=False, ) def patch_google_calendar_event(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync + from app.data.action.integrations._helpers import pick_result, run_client_sync - return run_client_sync( + res = run_client_sync( "google_calendar", "patch_event", unwrap_envelope=True, @@ -377,6 +468,9 @@ def patch_google_calendar_event(input_data: dict) -> dict: event_data=input_data["event_data"], send_updates=input_data.get("send_updates", "none"), ) + return pick_result( + res, ["id", "summary", "start", "end", "htmlLink", "hangoutLink", "status"] + ) @action( @@ -409,7 +503,7 @@ def delete_google_calendar_event(input_data: dict) -> dict: @action( name="move_google_calendar_event", - description="Move an event from one calendar to another.", + description="Move an event from one calendar to another. Returns id + key fields.", action_sets=["google_calendar_events"], input_schema={ "event_id": {"type": "string", "description": "Event ID.", "example": ""}, @@ -433,9 +527,9 @@ def delete_google_calendar_event(input_data: dict) -> dict: parallelizable=False, ) def move_google_calendar_event(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync + from app.data.action.integrations._helpers import pick_result, run_client_sync - return run_client_sync( + res = run_client_sync( "google_calendar", "move_event", unwrap_envelope=True, @@ -445,11 +539,14 @@ def move_google_calendar_event(input_data: dict) -> dict: destination_calendar_id=input_data["destination_calendar_id"], send_updates=input_data.get("send_updates", "none"), ) + return pick_result( + res, ["id", "summary", "start", "end", "htmlLink", "hangoutLink", "status"] + ) @action( name="quick_add_google_calendar_event", - description="Create an event from a natural-language string (e.g. 'Lunch with Alice tomorrow at noon').", + description="Create an event from a natural-language string (e.g. 'Lunch with Alice tomorrow at noon'). Returns id + key fields.", action_sets=["google_calendar_events", "google_calendar"], input_schema={ "text": { @@ -472,9 +569,9 @@ def move_google_calendar_event(input_data: dict) -> dict: parallelizable=False, ) def quick_add_google_calendar_event(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync + from app.data.action.integrations._helpers import pick_result, run_client_sync - return run_client_sync( + res = run_client_sync( "google_calendar", "quick_add_event", unwrap_envelope=True, @@ -483,11 +580,14 @@ def quick_add_google_calendar_event(input_data: dict) -> dict: text=input_data["text"], send_updates=input_data.get("send_updates", "none"), ) + return pick_result( + res, ["id", "summary", "start", "end", "htmlLink", "hangoutLink", "status"] + ) @action( name="list_google_calendar_event_instances", - description="Expand a recurring event into its individual instances.", + description="Expand a recurring event into its individual instances. Lean event fields by default; set include_metadata for raw Event resources.", action_sets=["google_calendar_events"], input_schema={ "event_id": { @@ -515,13 +615,21 @@ def quick_add_google_calendar_event(input_data: dict) -> dict: "description": "Max instances.", "example": 50, }, + "include_metadata": { + "type": "boolean", + "description": "Return full raw Event resources (default false = lean).", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def list_google_calendar_event_instances(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + from app.data.action.integrations.google_workspace.google_calendar_actions import ( + _lean_gcal_event, + ) - return run_client_sync( + res = run_client_sync( "google_calendar", "list_event_instances", unwrap_envelope=True, @@ -532,11 +640,25 @@ def list_google_calendar_event_instances(input_data: dict) -> dict: time_max=input_data.get("time_max"), max_results=input_data.get("max_results", 50), ) + if not input_data.get("include_metadata") and res.get("status") == "success": + result = res.get("result") + if isinstance(result, dict) and isinstance(result.get("instances"), list): + res = { + **res, + "result": { + "instances": [ + _lean_gcal_event(e) + for e in result["instances"] + if isinstance(e, dict) + ] + }, + } + return res @action( name="import_google_calendar_event", - description="Import a pre-existing event (with its own iCal UID) into a calendar — preserves identity across calendars. Distinct from create.", + description="Import a pre-existing event (with its own iCal UID) into a calendar — preserves identity across calendars. Distinct from create. Returns id + key fields.", action_sets=["google_calendar_events"], input_schema={ "event_data": { @@ -554,9 +676,9 @@ def list_google_calendar_event_instances(input_data: dict) -> dict: parallelizable=False, ) def import_google_calendar_event(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync + from app.data.action.integrations._helpers import pick_result, run_client_sync - return run_client_sync( + res = run_client_sync( "google_calendar", "import_event", unwrap_envelope=True, @@ -564,6 +686,9 @@ def import_google_calendar_event(input_data: dict) -> dict: calendar_id=input_data.get("calendar_id", "primary"), event_data=input_data["event_data"], ) + return pick_result( + res, ["id", "summary", "start", "end", "htmlLink", "hangoutLink", "status"] + ) # ------------------------------------------------------------------ diff --git a/app/data/action/integrations/google_workspace/google_docs_actions.py b/app/data/action/integrations/google_workspace/google_docs_actions.py index 8eafeb1e..e9cc2bb5 100644 --- a/app/data/action/integrations/google_workspace/google_docs_actions.py +++ b/app/data/action/integrations/google_workspace/google_docs_actions.py @@ -34,7 +34,7 @@ def create_google_doc(input_data: dict) -> dict: @action( name="get_google_doc", - description="Fetch the full structured content of a Google Doc.", + description="Fetch a Google Doc. Default returns {document_id, title, text} (body flattened to plain text); set include_metadata for the raw structured JSON (needed for index-based edits).", action_sets=["google_docs_files", "google_docs"], input_schema={ "document_id": { @@ -42,19 +42,47 @@ def create_google_doc(input_data: dict) -> dict: "description": "The Google Doc's document ID.", "example": "1abcDEF...", }, + "include_metadata": { + "type": "boolean", + "description": "Return the full structured document JSON (default false = plain text).", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_google_doc(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( + res = run_client_sync( "google_docs", "get_document", unwrap_envelope=True, fail_message="Failed to fetch document.", document_id=input_data["document_id"], ) + if not input_data.get("include_metadata") and res.get("status") == "success": + doc = res.get("result") + if isinstance(doc, dict): + # Same flattening as the google_docs client's get_document_text. + text_parts = [] + for elem in doc.get("body", {}).get("content", []) or []: + para = elem.get("paragraph") + if not para: + continue + for run in para.get("elements") or []: + tr = run.get("textRun") + if tr and tr.get("content"): + text_parts.append(tr["content"]) + res = { + **res, + "result": { + "document_id": doc.get("documentId") + or input_data["document_id"], + "title": doc.get("title", ""), + "text": "".join(text_parts), + }, + } + return res @action( diff --git a/app/data/action/integrations/google_workspace/google_drive_actions.py b/app/data/action/integrations/google_workspace/google_drive_actions.py index e8c2861f..ef70ea0e 100644 --- a/app/data/action/integrations/google_workspace/google_drive_actions.py +++ b/app/data/action/integrations/google_workspace/google_drive_actions.py @@ -430,9 +430,15 @@ def empty_drive_trash(input_data: dict) -> dict: @action( name="get_drive_about", - description="Get Drive account info: storage quota, max upload size, supported export/import formats, root folder ID.", + description="Get Drive account info: user, storage quota, max upload size. Set include_metadata to also get the supported export/import format maps.", action_sets=["google_drive_files", "google_drive"], - input_schema={}, + input_schema={ + "include_metadata": { + "type": "boolean", + "description": "Include exportFormats/importFormats maps (default false).", + "example": False, + }, + }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_drive_about(input_data: dict) -> dict: @@ -443,6 +449,7 @@ def get_drive_about(input_data: dict) -> dict: "get_drive_about", unwrap_envelope=True, fail_message="Failed to get Drive info.", + include_metadata=bool(input_data.get("include_metadata", False)), ) diff --git a/app/data/action/integrations/google_workspace/google_youtube_actions.py b/app/data/action/integrations/google_workspace/google_youtube_actions.py index ec9fee2a..712efac1 100644 --- a/app/data/action/integrations/google_workspace/google_youtube_actions.py +++ b/app/data/action/integrations/google_workspace/google_youtube_actions.py @@ -21,7 +21,7 @@ def get_my_youtube_channel(input_data: dict) -> dict: @action( name="search_youtube", - description="Search YouTube for videos, channels, or playlists.", + description="Search YouTube for videos, channels, or playlists. Lean results by default ({videoId/channelId/playlistId, title, channelTitle, publishedAt, description}); set include_metadata for raw results.", action_sets=["google_youtube"], input_schema={ "query": { @@ -39,13 +39,18 @@ def get_my_youtube_channel(input_data: dict) -> dict: "description": "Max number of results.", "example": 25, }, + "include_metadata": { + "type": "boolean", + "description": "Return raw search results (default false = lean).", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def search_youtube(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( + res = run_client_sync( "google_youtube", "search", unwrap_envelope=True, @@ -54,6 +59,30 @@ def search_youtube(input_data: dict) -> dict: type_filter=input_data.get("type", "video"), max_results=input_data.get("max_results", 25), ) + if not input_data.get("include_metadata") and res.get("status") == "success": + items = res.get("result") + if isinstance(items, list): + lean = [] + for it in items: + if not isinstance(it, dict): + continue + snippet = it.get("snippet") or {} + rid = it.get("id") or {} + entry = {} + for key in ("videoId", "channelId", "playlistId"): + if isinstance(rid, dict) and rid.get(key): + entry[key] = rid[key] + entry.update( + { + "title": snippet.get("title"), + "channelTitle": snippet.get("channelTitle"), + "publishedAt": snippet.get("publishedAt"), + "description": snippet.get("description"), + } + ) + lean.append(entry) + res = {**res, "result": lean} + return res @action( @@ -83,7 +112,7 @@ def get_youtube_video(input_data: dict) -> dict: @action( name="list_my_youtube_subscriptions", - description="List the channels the authenticated user is subscribed to.", + description="List the channels the authenticated user is subscribed to. Lean results by default ({channelId, title, description}); set include_metadata for raw results (needed for the subscription ID used by unsubscribe).", action_sets=["google_youtube"], input_schema={ "max_results": { @@ -91,24 +120,46 @@ def get_youtube_video(input_data: dict) -> dict: "description": "Max number of subscriptions to return.", "example": 50, }, + "include_metadata": { + "type": "boolean", + "description": "Return raw subscription resources (default false = lean).", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def list_my_youtube_subscriptions(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( + res = run_client_sync( "google_youtube", "list_my_subscriptions", unwrap_envelope=True, fail_message="Failed to list subscriptions.", max_results=input_data.get("max_results", 50), ) + if not input_data.get("include_metadata") and res.get("status") == "success": + items = res.get("result") + if isinstance(items, list): + lean = [] + for it in items: + if not isinstance(it, dict): + continue + snippet = it.get("snippet") or {} + entry = { + "channelId": (snippet.get("resourceId") or {}).get("channelId"), + "title": snippet.get("title"), + } + if snippet.get("description"): + entry["description"] = snippet["description"] + lean.append(entry) + res = {**res, "result": lean} + return res @action( name="list_my_youtube_playlists", - description="List playlists owned by the authenticated user.", + description="List playlists owned by the authenticated user. Lean results by default ({id, title, itemCount}); set include_metadata for raw results.", action_sets=["google_youtube"], input_schema={ "max_results": { @@ -116,24 +167,47 @@ def list_my_youtube_subscriptions(input_data: dict) -> dict: "description": "Max number of playlists to return.", "example": 50, }, + "include_metadata": { + "type": "boolean", + "description": "Return raw playlist resources (default false = lean).", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def list_my_youtube_playlists(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( + res = run_client_sync( "google_youtube", "list_my_playlists", unwrap_envelope=True, fail_message="Failed to list playlists.", max_results=input_data.get("max_results", 50), ) + if not input_data.get("include_metadata") and res.get("status") == "success": + items = res.get("result") + if isinstance(items, list): + res = { + **res, + "result": [ + { + "id": it.get("id"), + "title": (it.get("snippet") or {}).get("title"), + "itemCount": (it.get("contentDetails") or {}).get( + "itemCount" + ), + } + for it in items + if isinstance(it, dict) + ], + } + return res @action( name="list_youtube_playlist_items", - description="List videos in a YouTube playlist.", + description="List videos in a YouTube playlist. Lean results by default ({videoId, title, position, publishedAt}); set include_metadata for raw results.", action_sets=["google_youtube"], input_schema={ "playlist_id": { @@ -146,13 +220,18 @@ def list_my_youtube_playlists(input_data: dict) -> dict: "description": "Max number of items to return.", "example": 50, }, + "include_metadata": { + "type": "boolean", + "description": "Return raw playlistItem resources (default false = lean).", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def list_youtube_playlist_items(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( + res = run_client_sync( "google_youtube", "list_playlist_items", unwrap_envelope=True, @@ -160,6 +239,24 @@ def list_youtube_playlist_items(input_data: dict) -> dict: playlist_id=input_data["playlist_id"], max_results=input_data.get("max_results", 50), ) + if not input_data.get("include_metadata") and res.get("status") == "success": + items = res.get("result") + if isinstance(items, list): + lean = [] + for it in items: + if not isinstance(it, dict): + continue + snippet = it.get("snippet") or {} + lean.append( + { + "videoId": (snippet.get("resourceId") or {}).get("videoId"), + "title": snippet.get("title"), + "position": snippet.get("position"), + "publishedAt": snippet.get("publishedAt"), + } + ) + res = {**res, "result": lean} + return res @action( @@ -280,7 +377,7 @@ def post_youtube_comment(input_data: dict) -> dict: @action( name="get_youtube_video_comments", - description="Get top-level comments on a YouTube video, most recent first.", + description="Get top-level comments on a YouTube video, most recent first. Lean results by default ({author, text, likeCount, publishedAt, totalReplyCount}); set include_metadata for raw commentThread resources.", action_sets=["google_youtube"], input_schema={ "video_id": { @@ -293,13 +390,18 @@ def post_youtube_comment(input_data: dict) -> dict: "description": "Max number of comments to return.", "example": 50, }, + "include_metadata": { + "type": "boolean", + "description": "Return raw commentThread resources (default false = lean).", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_youtube_video_comments(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( + res = run_client_sync( "google_youtube", "get_video_comments", unwrap_envelope=True, @@ -307,3 +409,24 @@ def get_youtube_video_comments(input_data: dict) -> dict: video_id=input_data["video_id"], max_results=input_data.get("max_results", 50), ) + if not input_data.get("include_metadata") and res.get("status") == "success": + items = res.get("result") + if isinstance(items, list): + lean = [] + for it in items: + if not isinstance(it, dict): + continue + thread = it.get("snippet") or {} + comment = (thread.get("topLevelComment") or {}).get("snippet") or {} + lean.append( + { + "author": comment.get("authorDisplayName"), + "text": comment.get("textOriginal") + or comment.get("textDisplay"), + "likeCount": comment.get("likeCount"), + "publishedAt": comment.get("publishedAt"), + "totalReplyCount": thread.get("totalReplyCount"), + } + ) + res = {**res, "result": lean} + return res diff --git a/app/data/action/integrations/hubspot/hubspot_actions.py b/app/data/action/integrations/hubspot/hubspot_actions.py index 823fe33a..54048276 100644 --- a/app/data/action/integrations/hubspot/hubspot_actions.py +++ b/app/data/action/integrations/hubspot/hubspot_actions.py @@ -51,7 +51,7 @@ async def list_hubspot_contacts(input_data: dict) -> dict: props = input_data.get("properties", "") from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_contacts", limit=input_data.get("limit", 30), @@ -59,6 +59,17 @@ async def list_hubspot_contacts(input_data: dict) -> dict: properties=[p.strip() for p in props.split(",") if p.strip()] or None, archived=input_data.get("archived", False), ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res @action( @@ -100,7 +111,7 @@ async def get_hubspot_contact(input_data: dict) -> dict: @action( name="create_hubspot_contact", - description="Create a HubSpot contact. 'properties' is a flat dict like {email, firstname, lastname, phone, company}.", + description="Create a HubSpot contact. 'properties' is a flat dict like {email, firstname, lastname, phone, company}. Returns only {id}.", action_sets=["hubspot_contacts", "hubspot"], input_schema={ "properties": { @@ -113,22 +124,23 @@ async def get_hubspot_contact(input_data: dict) -> dict: }, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id}."}}, parallelizable=False, ) async def create_hubspot_contact(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "create_contact", properties=input_data["properties"], ) + return pick_result(res, ["id"]) @action( name="update_hubspot_contact", - description="Update a HubSpot contact's properties.", + description="Update a HubSpot contact's properties. Returns only {id}.", action_sets=["hubspot_contacts", "hubspot"], input_schema={ "contact_id": { @@ -142,18 +154,19 @@ async def create_hubspot_contact(input_data: dict) -> dict: "example": {"phone": "+1-555-0100"}, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id}."}}, parallelizable=False, ) async def update_hubspot_contact(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "update_contact", contact_id=input_data["contact_id"], properties=input_data["properties"], ) + return pick_result(res, ["id"]) @action( @@ -221,7 +234,7 @@ async def search_hubspot_contacts(input_data: dict) -> dict: props = input_data.get("properties", "") from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "search_contacts", query=input_data.get("query") or None, @@ -230,6 +243,17 @@ async def search_hubspot_contacts(input_data: dict) -> dict: limit=input_data.get("limit", 30), after=input_data.get("after") or None, ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res @action( @@ -264,7 +288,7 @@ async def batch_get_hubspot_contacts(input_data: dict) -> dict: @action( name="batch_create_hubspot_contacts", - description="Create up to 100 contacts in a single call. 'records' is a list of flat property dicts.", + description="Create up to 100 contacts in a single call. 'records' is a list of flat property dicts. Returns only the created ids (+ errors if any).", action_sets=["hubspot_contacts"], input_schema={ "records": { @@ -273,20 +297,32 @@ async def batch_get_hubspot_contacts(input_data: dict) -> dict: "example": [{"email": "a@x.com"}, {"email": "b@x.com"}], }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {ids, numErrors?, errors?}."}}, parallelizable=False, ) async def batch_create_hubspot_contacts(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "batch_create_contacts", records=input_data["records"] ) + r = res.get("result") + if ( + res.get("status") == "success" + and isinstance(r, dict) + and isinstance(r.get("results"), list) + ): + reduced = {"ids": [i.get("id") for i in r["results"] if isinstance(i, dict)]} + if r.get("numErrors"): + reduced["numErrors"] = r.get("numErrors") + reduced["errors"] = r.get("errors") + res = {**res, "result": reduced} + return res @action( name="merge_hubspot_contacts", - description="Merge two contacts. The primary contact survives; the secondary is archived with associations transferred.", + description="Merge two contacts. The primary contact survives; the secondary is archived with associations transferred. Returns only {id}.", action_sets=["hubspot_contacts"], input_schema={ "primary_id": { @@ -300,18 +336,19 @@ async def batch_create_hubspot_contacts(input_data: dict) -> dict: "example": "456", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id}."}}, parallelizable=False, ) async def merge_hubspot_contacts(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "merge_contacts", primary_id=input_data["primary_id"], id_to_merge=input_data["id_to_merge"], ) + return pick_result(res, ["id"]) # ================================================================== @@ -347,7 +384,7 @@ async def list_hubspot_companies(input_data: dict) -> dict: props = input_data.get("properties", "") from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_companies", limit=input_data.get("limit", 30), @@ -355,6 +392,17 @@ async def list_hubspot_companies(input_data: dict) -> dict: properties=[p.strip() for p in props.split(",") if p.strip()] or None, archived=input_data.get("archived", False), ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res @action( @@ -396,7 +444,7 @@ async def get_hubspot_company(input_data: dict) -> dict: @action( name="create_hubspot_company", - description="Create a HubSpot company. Typical properties: name, domain, industry, city, country.", + description="Create a HubSpot company. Typical properties: name, domain, industry, city, country. Returns only {id}.", action_sets=["hubspot_companies", "hubspot"], input_schema={ "properties": { @@ -405,20 +453,21 @@ async def get_hubspot_company(input_data: dict) -> dict: "example": {"name": "Acme Co", "domain": "acme.com"}, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id}."}}, parallelizable=False, ) async def create_hubspot_company(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "create_company", properties=input_data["properties"] ) + return pick_result(res, ["id"]) @action( name="update_hubspot_company", - description="Update a HubSpot company's properties.", + description="Update a HubSpot company's properties. Returns only {id}.", action_sets=["hubspot_companies"], input_schema={ "company_id": { @@ -432,18 +481,19 @@ async def create_hubspot_company(input_data: dict) -> dict: "example": {"industry": "Software"}, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id}."}}, parallelizable=False, ) async def update_hubspot_company(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "update_company", company_id=input_data["company_id"], properties=input_data["properties"], ) + return pick_result(res, ["id"]) @action( @@ -507,7 +557,7 @@ async def search_hubspot_companies(input_data: dict) -> dict: props = input_data.get("properties", "") from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "search_companies", query=input_data.get("query") or None, @@ -516,6 +566,17 @@ async def search_hubspot_companies(input_data: dict) -> dict: limit=input_data.get("limit", 30), after=input_data.get("after") or None, ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res @action( @@ -550,7 +611,7 @@ async def batch_get_hubspot_companies(input_data: dict) -> dict: @action( name="batch_create_hubspot_companies", - description="Create up to 100 companies in a single call.", + description="Create up to 100 companies in a single call. Returns only the created ids (+ errors if any).", action_sets=["hubspot_companies"], input_schema={ "records": { @@ -559,15 +620,27 @@ async def batch_get_hubspot_companies(input_data: dict) -> dict: "example": [{"name": "Acme"}, {"name": "Foo"}], }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {ids, numErrors?, errors?}."}}, parallelizable=False, ) async def batch_create_hubspot_companies(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "batch_create_companies", records=input_data["records"] ) + r = res.get("result") + if ( + res.get("status") == "success" + and isinstance(r, dict) + and isinstance(r.get("results"), list) + ): + reduced = {"ids": [i.get("id") for i in r["results"] if isinstance(i, dict)]} + if r.get("numErrors"): + reduced["numErrors"] = r.get("numErrors") + reduced["errors"] = r.get("errors") + res = {**res, "result": reduced} + return res # ================================================================== @@ -599,7 +672,7 @@ async def list_hubspot_deals(input_data: dict) -> dict: props = input_data.get("properties", "") from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_deals", limit=input_data.get("limit", 30), @@ -607,6 +680,17 @@ async def list_hubspot_deals(input_data: dict) -> dict: properties=[p.strip() for p in props.split(",") if p.strip()] or None, archived=input_data.get("archived", False), ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res @action( @@ -648,7 +732,7 @@ async def get_hubspot_deal(input_data: dict) -> dict: @action( name="create_hubspot_deal", - description="Create a HubSpot deal. Typical properties: dealname, amount, dealstage, pipeline, closedate, hubspot_owner_id.", + description="Create a HubSpot deal. Typical properties: dealname, amount, dealstage, pipeline, closedate, hubspot_owner_id. Returns only {id}.", action_sets=["hubspot_deals", "hubspot"], input_schema={ "properties": { @@ -661,20 +745,21 @@ async def get_hubspot_deal(input_data: dict) -> dict: }, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id}."}}, parallelizable=False, ) async def create_hubspot_deal(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "create_deal", properties=input_data["properties"] ) + return pick_result(res, ["id"]) @action( name="update_hubspot_deal", - description="Update a HubSpot deal's properties.", + description="Update a HubSpot deal's properties. Returns only {id}.", action_sets=["hubspot_deals", "hubspot"], input_schema={ "deal_id": { @@ -688,18 +773,19 @@ async def create_hubspot_deal(input_data: dict) -> dict: "example": {"amount": "75000"}, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id}."}}, parallelizable=False, ) async def update_hubspot_deal(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "update_deal", deal_id=input_data["deal_id"], properties=input_data["properties"], ) + return pick_result(res, ["id"]) @action( @@ -761,7 +847,7 @@ async def search_hubspot_deals(input_data: dict) -> dict: props = input_data.get("properties", "") from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "search_deals", query=input_data.get("query") or None, @@ -770,11 +856,22 @@ async def search_hubspot_deals(input_data: dict) -> dict: limit=input_data.get("limit", 30), after=input_data.get("after") or None, ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res @action( name="batch_create_hubspot_deals", - description="Create up to 100 deals in a single call.", + description="Create up to 100 deals in a single call. Returns only the created ids (+ errors if any).", action_sets=["hubspot_deals"], input_schema={ "records": { @@ -783,20 +880,32 @@ async def search_hubspot_deals(input_data: dict) -> dict: "example": [{"dealname": "A"}, {"dealname": "B"}], }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {ids, numErrors?, errors?}."}}, parallelizable=False, ) async def batch_create_hubspot_deals(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "batch_create_deals", records=input_data["records"] ) + r = res.get("result") + if ( + res.get("status") == "success" + and isinstance(r, dict) + and isinstance(r.get("results"), list) + ): + reduced = {"ids": [i.get("id") for i in r["results"] if isinstance(i, dict)]} + if r.get("numErrors"): + reduced["numErrors"] = r.get("numErrors") + reduced["errors"] = r.get("errors") + res = {**res, "result": reduced} + return res @action( name="move_hubspot_deal_stage", - description="Move a deal to a different pipeline stage. Helper around updating the 'dealstage' property.", + description="Move a deal to a different pipeline stage. Helper around updating the 'dealstage' property. Returns only {id}.", action_sets=["hubspot_deals", "hubspot"], input_schema={ "deal_id": { @@ -810,18 +919,19 @@ async def batch_create_hubspot_deals(input_data: dict) -> dict: "example": "closedwon", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id}."}}, parallelizable=False, ) async def move_hubspot_deal_stage(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "move_deal_stage", deal_id=input_data["deal_id"], stage_id=input_data["stage_id"], ) + return pick_result(res, ["id"]) @action( @@ -842,13 +952,24 @@ async def move_hubspot_deal_stage(input_data: dict) -> dict: async def list_hubspot_deals_by_pipeline(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_deals_by_pipeline", pipeline_id=input_data["pipeline_id"], limit=input_data.get("limit", 30), after=input_data.get("after") or None, ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res # ================================================================== @@ -880,7 +1001,7 @@ async def list_hubspot_tickets(input_data: dict) -> dict: props = input_data.get("properties", "") from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_tickets", limit=input_data.get("limit", 30), @@ -888,6 +1009,17 @@ async def list_hubspot_tickets(input_data: dict) -> dict: properties=[p.strip() for p in props.split(",") if p.strip()] or None, archived=input_data.get("archived", False), ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res @action( @@ -929,7 +1061,7 @@ async def get_hubspot_ticket(input_data: dict) -> dict: @action( name="create_hubspot_ticket", - description="Create a HubSpot support ticket. Typical properties: subject, content, hs_pipeline, hs_pipeline_stage, hs_ticket_priority (LOW/MEDIUM/HIGH/URGENT).", + description="Create a HubSpot support ticket. Typical properties: subject, content, hs_pipeline, hs_pipeline_stage, hs_ticket_priority (LOW/MEDIUM/HIGH/URGENT). Returns only {id}.", action_sets=["hubspot_tickets", "hubspot"], input_schema={ "properties": { @@ -942,20 +1074,21 @@ async def get_hubspot_ticket(input_data: dict) -> dict: }, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id}."}}, parallelizable=False, ) async def create_hubspot_ticket(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "create_ticket", properties=input_data["properties"] ) + return pick_result(res, ["id"]) @action( name="update_hubspot_ticket", - description="Update a HubSpot ticket's properties.", + description="Update a HubSpot ticket's properties. Returns only {id}.", action_sets=["hubspot_tickets"], input_schema={ "ticket_id": { @@ -969,18 +1102,19 @@ async def create_hubspot_ticket(input_data: dict) -> dict: "example": {"hs_ticket_priority": "URGENT"}, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id}."}}, parallelizable=False, ) async def update_hubspot_ticket(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "update_ticket", ticket_id=input_data["ticket_id"], properties=input_data["properties"], ) + return pick_result(res, ["id"]) @action( @@ -1044,7 +1178,7 @@ async def search_hubspot_tickets(input_data: dict) -> dict: props = input_data.get("properties", "") from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "search_tickets", query=input_data.get("query") or None, @@ -1053,11 +1187,22 @@ async def search_hubspot_tickets(input_data: dict) -> dict: limit=input_data.get("limit", 30), after=input_data.get("after") or None, ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res @action( name="close_hubspot_ticket", - description="Move a ticket to its closed stage. Helper around updating 'hs_pipeline_stage'.", + description="Move a ticket to its closed stage. Helper around updating 'hs_pipeline_stage'. Returns only {id}.", action_sets=["hubspot_tickets", "hubspot"], input_schema={ "ticket_id": { @@ -1071,18 +1216,19 @@ async def search_hubspot_tickets(input_data: dict) -> dict: "example": "4", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id}."}}, parallelizable=False, ) async def close_hubspot_ticket(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "close_ticket", ticket_id=input_data["ticket_id"], closed_stage_id=input_data["closed_stage_id"], ) + return pick_result(res, ["id"]) @action( @@ -1103,13 +1249,24 @@ async def close_hubspot_ticket(input_data: dict) -> dict: async def list_hubspot_tickets_by_pipeline(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_tickets_by_pipeline", pipeline_id=input_data["pipeline_id"], limit=input_data.get("limit", 30), after=input_data.get("after") or None, ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res # ================================================================== @@ -1136,18 +1293,29 @@ async def list_hubspot_tasks(input_data: dict) -> dict: props = input_data.get("properties", "") from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_tasks", limit=input_data.get("limit", 30), after=input_data.get("after") or None, properties=[p.strip() for p in props.split(",") if p.strip()] or None, ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res @action( name="create_hubspot_task", - description="Create a HubSpot task. Optionally associate it with a contact/company/deal/ticket.", + description="Create a HubSpot task. Optionally associate it with a contact/company/deal/ticket. Returns only {id}.", action_sets=["hubspot_engagements", "hubspot"], input_schema={ "subject": { @@ -1191,13 +1359,13 @@ async def list_hubspot_tasks(input_data: dict) -> dict: "example": "123456789", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id}."}}, parallelizable=False, ) async def create_hubspot_task(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "create_task", subject=input_data["subject"], @@ -1209,11 +1377,12 @@ async def create_hubspot_task(input_data: dict) -> dict: associated_object_type=input_data.get("associated_object_type") or None, associated_object_id=input_data.get("associated_object_id") or None, ) + return pick_result(res, ["id"]) @action( name="update_hubspot_task", - description="Update a HubSpot task. Common updates: hs_task_status, hs_task_priority, hs_task_subject.", + description="Update a HubSpot task. Common updates: hs_task_status, hs_task_priority, hs_task_subject. Returns only {id}.", action_sets=["hubspot_engagements"], input_schema={ "task_id": { @@ -1227,18 +1396,19 @@ async def create_hubspot_task(input_data: dict) -> dict: "example": {"hs_task_status": "COMPLETED"}, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id}."}}, parallelizable=False, ) async def update_hubspot_task(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "update_task", task_id=input_data["task_id"], properties=input_data["properties"], ) + return pick_result(res, ["id"]) @action( @@ -1280,18 +1450,29 @@ async def list_hubspot_notes(input_data: dict) -> dict: props = input_data.get("properties", "") from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_notes", limit=input_data.get("limit", 30), after=input_data.get("after") or None, properties=[p.strip() for p in props.split(",") if p.strip()] or None, ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res @action( name="create_hubspot_note", - description="Create a HubSpot note (typically attached to a contact/company/deal/ticket).", + description="Create a HubSpot note (typically attached to a contact/company/deal/ticket). Returns only {id}.", action_sets=["hubspot_engagements", "hubspot"], input_schema={ "body": { @@ -1311,13 +1492,13 @@ async def list_hubspot_notes(input_data: dict) -> dict: "example": "123456789", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id}."}}, parallelizable=False, ) async def create_hubspot_note(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "create_note", body=input_data["body"], @@ -1325,6 +1506,7 @@ async def create_hubspot_note(input_data: dict) -> dict: associated_object_type=input_data.get("associated_object_type") or None, associated_object_id=input_data.get("associated_object_id") or None, ) + return pick_result(res, ["id"]) @action( @@ -1366,18 +1548,29 @@ async def list_hubspot_calls(input_data: dict) -> dict: props = input_data.get("properties", "") from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_calls", limit=input_data.get("limit", 30), after=input_data.get("after") or None, properties=[p.strip() for p in props.split(",") if p.strip()] or None, ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res @action( name="log_hubspot_call", - description="Log a phone call as a HubSpot engagement.", + description="Log a phone call as a HubSpot engagement. Returns only {id}.", action_sets=["hubspot_engagements", "hubspot"], input_schema={ "title": { @@ -1432,13 +1625,13 @@ async def list_hubspot_calls(input_data: dict) -> dict: "example": "123456789", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id}."}}, parallelizable=False, ) async def log_hubspot_call(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "log_call", title=input_data["title"], @@ -1453,6 +1646,7 @@ async def log_hubspot_call(input_data: dict) -> dict: associated_object_type=input_data.get("associated_object_type") or None, associated_object_id=input_data.get("associated_object_id") or None, ) + return pick_result(res, ["id"]) @action( @@ -1474,18 +1668,29 @@ async def list_hubspot_emails(input_data: dict) -> dict: props = input_data.get("properties", "") from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_emails", limit=input_data.get("limit", 30), after=input_data.get("after") or None, properties=[p.strip() for p in props.split(",") if p.strip()] or None, ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res @action( name="log_hubspot_email", - description="Log an email as a HubSpot engagement (for record-keeping; doesn't actually send).", + description="Log an email as a HubSpot engagement (for record-keeping; doesn't actually send). Returns only {id}.", action_sets=["hubspot_engagements"], input_schema={ "subject": { @@ -1535,13 +1740,13 @@ async def list_hubspot_emails(input_data: dict) -> dict: "example": "123456789", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id}."}}, parallelizable=False, ) async def log_hubspot_email(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "log_email", subject=input_data["subject"], @@ -1555,6 +1760,7 @@ async def log_hubspot_email(input_data: dict) -> dict: associated_object_type=input_data.get("associated_object_type") or None, associated_object_id=input_data.get("associated_object_id") or None, ) + return pick_result(res, ["id"]) @action( @@ -1576,18 +1782,29 @@ async def list_hubspot_meetings(input_data: dict) -> dict: props = input_data.get("properties", "") from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_meetings", limit=input_data.get("limit", 30), after=input_data.get("after") or None, properties=[p.strip() for p in props.split(",") if p.strip()] or None, ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res @action( name="create_hubspot_meeting", - description="Create a HubSpot meeting engagement record.", + description="Create a HubSpot meeting engagement record. Returns only {id}.", action_sets=["hubspot_engagements"], input_schema={ "title": { @@ -1632,13 +1849,13 @@ async def list_hubspot_meetings(input_data: dict) -> dict: "example": "123456789", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id}."}}, parallelizable=False, ) async def create_hubspot_meeting(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "create_meeting", title=input_data["title"], @@ -1651,6 +1868,7 @@ async def create_hubspot_meeting(input_data: dict) -> dict: associated_object_type=input_data.get("associated_object_type") or None, associated_object_id=input_data.get("associated_object_id") or None, ) + return pick_result(res, ["id"]) @action( @@ -1701,12 +1919,23 @@ async def delete_hubspot_meeting(input_data: dict) -> dict: async def list_hubspot_lists(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_lists", limit=input_data.get("limit", 30), list_ids=input_data.get("list_ids") or None, ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res @action( @@ -1726,7 +1955,7 @@ async def get_hubspot_list(input_data: dict) -> dict: @action( name="create_hubspot_list", - description="Create a HubSpot list. processing_type=MANUAL for static (you add contacts yourself); DYNAMIC for filter-based.", + description="Create a HubSpot list. processing_type=MANUAL for static (you add contacts yourself); DYNAMIC for filter-based. Returns only {listId}.", action_sets=["hubspot_lists"], input_schema={ "name": { @@ -1750,13 +1979,13 @@ async def get_hubspot_list(input_data: dict) -> dict: "example": {}, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {listId}."}}, parallelizable=False, ) async def create_hubspot_list(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "create_list", name=input_data["name"], @@ -1764,6 +1993,13 @@ async def create_hubspot_list(input_data: dict) -> dict: processing_type=input_data.get("processing_type", "MANUAL"), filter_branch=input_data.get("filter_branch") or None, ) + r = res.get("result") + if res.get("status") == "success" and isinstance(r, dict): + lst = r.get("list") if isinstance(r.get("list"), dict) else r + list_id = lst.get("listId") or lst.get("id") + if list_id is not None: + res = {**res, "result": {"listId": list_id}} + return res @action( @@ -1855,9 +2091,20 @@ async def remove_contacts_from_hubspot_list(input_data: dict) -> dict: async def list_hubspot_pipelines(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_pipelines", object_type=input_data["object_type"] ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res @action( @@ -1891,7 +2138,7 @@ async def get_hubspot_pipeline(input_data: dict) -> dict: @action( name="create_hubspot_pipeline", - description="Create a new pipeline. 'stages' is a list of {label, displayOrder, metadata:{probability,...}} dicts.", + description="Create a new pipeline. 'stages' is a list of {label, displayOrder, metadata:{probability,...}} dicts. Returns only {id}.", action_sets=["hubspot_pipelines"], input_schema={ "object_type": { @@ -1917,13 +2164,13 @@ async def get_hubspot_pipeline(input_data: dict) -> dict: "example": 0, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id}."}}, parallelizable=False, ) async def create_hubspot_pipeline(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "create_pipeline", object_type=input_data["object_type"], @@ -1931,6 +2178,7 @@ async def create_hubspot_pipeline(input_data: dict) -> dict: stages=input_data["stages"], display_order=input_data.get("display_order", 0), ) + return pick_result(res, ["id"]) @action( @@ -1954,17 +2202,28 @@ async def create_hubspot_pipeline(input_data: dict) -> dict: async def list_hubspot_pipeline_stages(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_pipeline_stages", object_type=input_data["object_type"], pipeline_id=input_data["pipeline_id"], ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res @action( name="update_hubspot_pipeline_stage", - description="Update a pipeline stage's properties (label, displayOrder, metadata).", + description="Update a pipeline stage's properties (label, displayOrder, metadata). Returns only {id}.", action_sets=["hubspot_pipelines"], input_schema={ "object_type": { @@ -1988,13 +2247,13 @@ async def list_hubspot_pipeline_stages(input_data: dict) -> dict: "example": {"label": "Qualified — Buying"}, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id}."}}, parallelizable=False, ) async def update_hubspot_pipeline_stage(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "update_pipeline_stage", object_type=input_data["object_type"], @@ -2002,6 +2261,7 @@ async def update_hubspot_pipeline_stage(input_data: dict) -> dict: stage_id=input_data["stage_id"], properties=input_data["properties"], ) + return pick_result(res, ["id"]) # ================================================================== @@ -2030,12 +2290,23 @@ async def update_hubspot_pipeline_stage(input_data: dict) -> dict: async def list_hubspot_owners(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_owners", email=input_data.get("email") or None, limit=input_data.get("limit", 100), ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res @action( @@ -2074,9 +2345,20 @@ async def get_hubspot_owner(input_data: dict) -> dict: async def list_hubspot_properties(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_properties", object_type=input_data["object_type"] ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res @action( @@ -2110,7 +2392,7 @@ async def get_hubspot_property(input_data: dict) -> dict: @action( name="create_hubspot_property", - description="Create a new custom property. 'definition' must include name, label, type, fieldType, groupName.", + description="Create a new custom property. 'definition' must include name, label, type, fieldType, groupName. Returns only {id, name, type}.", action_sets=["hubspot_properties"], input_schema={ "object_type": { @@ -2130,23 +2412,24 @@ async def get_hubspot_property(input_data: dict) -> dict: }, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, name, type}."}}, parallelizable=False, ) async def create_hubspot_property(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "create_property", object_type=input_data["object_type"], definition=input_data["definition"], ) + return pick_result(res, ["id", "name", "type"]) @action( name="update_hubspot_property", - description="Update an existing property's definition (label, description, options).", + description="Update an existing property's definition (label, description, options). Returns only {id, name, type}.", action_sets=["hubspot_properties"], input_schema={ "object_type": { @@ -2165,19 +2448,20 @@ async def create_hubspot_property(input_data: dict) -> dict: "example": {"label": "Color preference"}, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, name, type}."}}, parallelizable=False, ) async def update_hubspot_property(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "update_property", object_type=input_data["object_type"], property_name=input_data["property_name"], definition=input_data["definition"], ) + return pick_result(res, ["id", "name", "type"]) @action( @@ -2226,11 +2510,22 @@ async def delete_hubspot_property(input_data: dict) -> dict: async def list_hubspot_property_groups(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_property_groups", object_type=input_data["object_type"], ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res # ================================================================== @@ -2240,7 +2535,7 @@ async def list_hubspot_property_groups(input_data: dict) -> dict: @action( name="create_hubspot_association", - description="Link two objects (e.g. attach a contact to a deal). Leaves association_type_id empty for the default association between the pair.", + description="Link two objects (e.g. attach a contact to a deal). Leaves association_type_id empty for the default association between the pair. Returns only {id}.", action_sets=["hubspot_associations", "hubspot"], input_schema={ "from_object_type": { @@ -2269,13 +2564,13 @@ async def list_hubspot_property_groups(input_data: dict) -> dict: "example": 0, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id}."}}, parallelizable=False, ) async def create_hubspot_association(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "create_association", from_object_type=input_data["from_object_type"], @@ -2284,6 +2579,7 @@ async def create_hubspot_association(input_data: dict) -> dict: to_object_id=input_data["to_object_id"], association_type_id=input_data.get("association_type_id") or None, ) + return pick_result(res, ["id"]) @action( @@ -2318,7 +2614,7 @@ async def create_hubspot_association(input_data: dict) -> dict: async def list_hubspot_associations(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_associations", from_object_type=input_data["from_object_type"], @@ -2327,6 +2623,17 @@ async def list_hubspot_associations(input_data: dict) -> dict: limit=input_data.get("limit", 100), after=input_data.get("after") or None, ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res @action( @@ -2392,12 +2699,23 @@ async def delete_hubspot_association(input_data: dict) -> dict: async def list_hubspot_association_types(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_association_types", from_object_type=input_data["from_object_type"], to_object_type=input_data["to_object_type"], ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res # ================================================================== @@ -2418,12 +2736,23 @@ async def list_hubspot_association_types(input_data: dict) -> dict: async def list_hubspot_forms(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_forms", limit=input_data.get("limit", 30), after=input_data.get("after") or None, ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res @action( @@ -2447,7 +2776,7 @@ async def get_hubspot_form(input_data: dict) -> dict: @action( name="submit_hubspot_form", - description="Programmatically submit a HubSpot form. 'fields' is a list of {name, value} dicts.", + description="Programmatically submit a HubSpot form. 'fields' is a list of {name, value} dicts. Returns only {id}.", action_sets=["hubspot_forms"], input_schema={ "portal_id": { @@ -2474,13 +2803,13 @@ async def get_hubspot_form(input_data: dict) -> dict: "example": {"pageName": "Demo Request"}, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id}."}}, parallelizable=False, ) async def submit_hubspot_form(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "submit_form", portal_id=input_data["portal_id"], @@ -2488,6 +2817,7 @@ async def submit_hubspot_form(input_data: dict) -> dict: fields=input_data["fields"], context=input_data.get("context") or None, ) + return pick_result(res, ["id"]) @action( @@ -2512,13 +2842,24 @@ async def submit_hubspot_form(input_data: dict) -> dict: async def list_hubspot_form_submissions(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_form_submissions", form_guid=input_data["form_guid"], limit=input_data.get("limit", 30), after=input_data.get("after") or None, ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res # ================================================================== @@ -2539,12 +2880,23 @@ async def list_hubspot_form_submissions(input_data: dict) -> dict: async def list_hubspot_marketing_emails(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_marketing_emails", limit=input_data.get("limit", 30), after=input_data.get("after") or None, ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res @action( @@ -2571,7 +2923,7 @@ async def get_hubspot_marketing_email(input_data: dict) -> dict: @action( name="send_hubspot_single_send", irreversible=True, - description="Send a one-off transactional email based on a pre-built marketing email template.", + description="Send a one-off transactional email based on a pre-built marketing email template. Returns only {id}.", action_sets=["hubspot_marketing_email", "hubspot"], input_schema={ "email_id": { @@ -2595,13 +2947,13 @@ async def get_hubspot_marketing_email(input_data: dict) -> dict: "example": {}, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id}."}}, parallelizable=False, ) async def send_hubspot_single_send(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "send_single_email", email_id=input_data["email_id"], @@ -2609,6 +2961,7 @@ async def send_hubspot_single_send(input_data: dict) -> dict: custom_properties=input_data.get("custom_properties") or None, contact_properties=input_data.get("contact_properties") or None, ) + return pick_result(res, ["id"]) @action( @@ -2641,7 +2994,7 @@ async def get_hubspot_marketing_email_statistics(input_data: dict) -> dict: @action( name="upload_hubspot_file", - description="Upload a local file to the HubSpot file manager. 'access' controls visibility: PUBLIC_INDEXABLE / PUBLIC_NOT_INDEXABLE / HIDDEN / PRIVATE.", + description="Upload a local file to the HubSpot file manager. 'access' controls visibility: PUBLIC_INDEXABLE / PUBLIC_NOT_INDEXABLE / HIDDEN / PRIVATE. Returns only {id, url}.", action_sets=["hubspot_files"], input_schema={ "file_path": { @@ -2665,13 +3018,13 @@ async def get_hubspot_marketing_email_statistics(input_data: dict) -> dict: "example": False, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, url}."}}, parallelizable=False, ) async def upload_hubspot_file(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "upload_file", file_path=input_data["file_path"], @@ -2679,6 +3032,7 @@ async def upload_hubspot_file(input_data: dict) -> dict: access=input_data.get("access", "PRIVATE"), overwrite=input_data.get("overwrite", False), ) + return pick_result(res, ["id", "url"]) @action( @@ -2733,12 +3087,23 @@ async def delete_hubspot_file(input_data: dict) -> dict: async def list_hubspot_folders(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_folders", limit=input_data.get("limit", 30), after=input_data.get("after") or None, ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res # ================================================================== @@ -2759,12 +3124,23 @@ async def list_hubspot_folders(input_data: dict) -> dict: async def list_hubspot_conversations(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_conversations", limit=input_data.get("limit", 30), after=input_data.get("after") or None, ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res @action( @@ -2806,19 +3182,30 @@ async def get_hubspot_conversation(input_data: dict) -> dict: async def list_hubspot_conversation_messages(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_conversation_messages", thread_id=input_data["thread_id"], limit=input_data.get("limit", 30), after=input_data.get("after") or None, ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res @action( name="send_hubspot_conversation_message", irreversible=True, - description="Send a message into a conversation thread. Requires the channel + channel-account IDs from the thread metadata.", + description="Send a message into a conversation thread. Requires the channel + channel-account IDs from the thread metadata. Returns only {id}.", action_sets=["hubspot_conversations"], input_schema={ "thread_id": { @@ -2860,13 +3247,13 @@ async def list_hubspot_conversation_messages(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id}."}}, parallelizable=False, ) async def send_hubspot_conversation_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "send_conversation_message", thread_id=input_data["thread_id"], @@ -2876,6 +3263,7 @@ async def send_hubspot_conversation_message(input_data: dict) -> dict: recipients=input_data["recipients"], sender_actor_id=input_data.get("sender_actor_id") or None, ) + return pick_result(res, ["id"]) # ================================================================== @@ -2899,16 +3287,27 @@ async def send_hubspot_conversation_message(input_data: dict) -> dict: async def list_hubspot_webhook_subscriptions(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "hubspot", "list_webhook_subscriptions", app_id=input_data["app_id"], ) + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res @action( name="create_hubspot_webhook_subscription", - description="Subscribe a HubSpot App to an event type (e.g. contact.creation, contact.propertyChange).", + description="Subscribe a HubSpot App to an event type (e.g. contact.creation, contact.propertyChange). Returns only {id}.", action_sets=["hubspot_webhooks"], input_schema={ "app_id": { @@ -2932,13 +3331,13 @@ async def list_hubspot_webhook_subscriptions(input_data: dict) -> dict: "example": True, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id}."}}, parallelizable=False, ) async def create_hubspot_webhook_subscription(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "hubspot", "create_webhook_subscription", app_id=input_data["app_id"], @@ -2946,6 +3345,7 @@ async def create_hubspot_webhook_subscription(input_data: dict) -> dict: property_name=input_data.get("property_name") or None, active=input_data.get("active", True), ) + return pick_result(res, ["id"]) @action( diff --git a/app/data/action/integrations/jira/jira_actions.py b/app/data/action/integrations/jira/jira_actions.py index ac93560d..478c90b9 100644 --- a/app/data/action/integrations/jira/jira_actions.py +++ b/app/data/action/integrations/jira/jira_actions.py @@ -13,7 +13,7 @@ @action( name="search_jira_issues", - description="Search for Jira issues using JQL (Jira Query Language).", + description="Search for Jira issues using JQL (Jira Query Language). Returns lean issues (summary, description, status, assignee, priority, issuetype, labels, dates) by default; set include_metadata=true for all fields incl. custom fields.", action_sets=["jira_issues", "jira"], input_schema={ "jql": { @@ -28,11 +28,22 @@ }, "fields": { "type": "string", - "description": "Comma-separated fields to return. Leave empty for defaults.", + "description": "Comma-separated fields to return. Leave empty for lean defaults.", "example": "summary,status,assignee,priority", }, + "include_metadata": { + "type": "boolean", + "description": "Return every field (incl. customfield_*) with full nested objects.", + "example": False, + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "total + issues. Lean: default field set, status/assignee/priority/issuetype collapsed to names. Full payload when include_metadata=true.", + }, }, - output_schema={"status": {"type": "string", "example": "success"}}, ) async def search_jira_issues(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client @@ -44,12 +55,13 @@ async def search_jira_issues(input_data: dict) -> dict: jql=input_data["jql"], max_results=input_data.get("max_results", 20), fields_list=fields_list, + include_metadata=bool(input_data.get("include_metadata", False)), ) @action( name="get_jira_issue", - description="Get details of a specific Jira issue by its key (e.g. PROJ-123).", + description="Get details of a specific Jira issue by its key (e.g. PROJ-123). Returns lean fields (summary, description, status, assignee, priority, issuetype, labels, dates) by default; set include_metadata=true for all fields incl. custom fields.", action_sets=["jira_issues", "jira"], input_schema={ "issue_key": { @@ -59,11 +71,22 @@ async def search_jira_issues(input_data: dict) -> dict: }, "fields": { "type": "string", - "description": "Comma-separated fields to return. Leave empty for all.", + "description": "Comma-separated fields to return. Leave empty for lean defaults.", "example": "summary,status,assignee,description", }, + "include_metadata": { + "type": "boolean", + "description": "Return every field (incl. customfield_*) with full nested objects.", + "example": False, + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "Lean: default field set, status/assignee/priority/issuetype collapsed to names. Full payload when include_metadata=true.", + }, }, - output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_jira_issue(input_data: dict) -> dict: from app.data.action.integrations._helpers import with_client @@ -71,7 +94,11 @@ async def get_jira_issue(input_data: dict) -> dict: fields_list = csv_list(input_data.get("fields", ""), default=None) return await with_client( "jira", - lambda c: c.get_issue(input_data["issue_key"], fields_list=fields_list), + lambda c: c.get_issue( + input_data["issue_key"], + fields_list=fields_list, + include_metadata=bool(input_data.get("include_metadata", False)), + ), ) @@ -886,7 +913,7 @@ async def create_jira_issue_link(input_data: dict) -> dict: @action( name="get_jira_issue_link", - description="Get a specific issue link by ID.", + description="Get a specific issue link by ID. Returns lean fields (id, type name, linked issue keys/summaries/statuses) by default; set include_metadata=true for the raw payload.", action_sets=["jira_links"], input_schema={ "link_id": { @@ -894,13 +921,29 @@ async def create_jira_issue_link(input_data: dict) -> dict: "description": "Issue link ID.", "example": "10000", }, + "include_metadata": { + "type": "boolean", + "description": "Return the full raw API payload instead of lean fields.", + "example": False, + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "Lean: id, type, inwardIssue/outwardIssue {key, summary, status}. Raw payload when include_metadata=true.", + }, }, - output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_jira_issue_link(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client("jira", "get_issue_link", link_id=input_data["link_id"]) + return await run_client( + "jira", + "get_issue_link", + link_id=input_data["link_id"], + include_metadata=bool(input_data.get("include_metadata", False)), + ) @action( @@ -1114,7 +1157,7 @@ async def create_jira_version(input_data: dict) -> dict: @action( name="update_jira_version", - description="Update a Jira version (e.g. mark as released, archived).", + description="Update a Jira version (e.g. mark as released, archived). Returns id, name, released.", action_sets=["jira_projects"], input_schema={ "version_id": { @@ -1144,13 +1187,19 @@ async def create_jira_version(input_data: dict) -> dict: "example": False, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "Updated version: id, name, released.", + }, + }, parallelizable=False, ) async def update_jira_version(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "jira", "update_version", version_id=input_data["version_id"], @@ -1160,6 +1209,7 @@ async def update_jira_version(input_data: dict) -> dict: released=input_data.get("released"), archived=input_data.get("archived"), ) + return pick_result(res, ["id", "name", "released"]) @action( @@ -1348,14 +1398,25 @@ async def get_jira_board(input_data: dict) -> dict: @action( name="get_jira_board_issues", - description="List issues currently on a board.", + description="List issues currently on a board. Returns lean issues (summary, description, status, assignee, priority, issuetype, labels, dates) by default; set include_metadata=true for all fields incl. custom fields.", action_sets=["jira_sprints"], input_schema={ "board_id": {"type": "integer", "description": "Board ID.", "example": 1}, "jql": {"type": "string", "description": "Optional JQL filter.", "example": ""}, "max_results": {"type": "integer", "description": "Max issues.", "example": 50}, + "include_metadata": { + "type": "boolean", + "description": "Return every field (incl. customfield_*) with full nested objects.", + "example": False, + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "total + issues. Lean: default field set, status/assignee/priority/issuetype collapsed to names. Full payload when include_metadata=true.", + }, }, - output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_jira_board_issues(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client @@ -1366,6 +1427,7 @@ async def get_jira_board_issues(input_data: dict) -> dict: board_id=input_data["board_id"], jql=input_data.get("jql") or None, max_results=input_data.get("max_results", 50), + include_metadata=bool(input_data.get("include_metadata", False)), ) @@ -1402,13 +1464,24 @@ async def get_jira_board_sprints(input_data: dict) -> dict: @action( name="get_jira_board_backlog", - description="Get the backlog issues for a board (issues not yet in any sprint).", + description="Get the backlog issues for a board (issues not yet in any sprint). Returns lean issues by default; set include_metadata=true for all fields incl. custom fields.", action_sets=["jira_sprints"], input_schema={ "board_id": {"type": "integer", "description": "Board ID.", "example": 1}, "max_results": {"type": "integer", "description": "Max issues.", "example": 50}, + "include_metadata": { + "type": "boolean", + "description": "Return every field (incl. customfield_*) with full nested objects.", + "example": False, + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "total + issues. Lean: default field set, status/assignee/priority/issuetype collapsed to names. Full payload when include_metadata=true.", + }, }, - output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_jira_board_backlog(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client @@ -1418,6 +1491,7 @@ async def get_jira_board_backlog(input_data: dict) -> dict: "get_board_backlog", board_id=input_data["board_id"], max_results=input_data.get("max_results", 50), + include_metadata=bool(input_data.get("include_metadata", False)), ) @@ -1438,14 +1512,25 @@ async def get_jira_sprint(input_data: dict) -> dict: @action( name="get_jira_sprint_issues", - description="List issues in a sprint.", + description="List issues in a sprint. Returns lean issues by default; set include_metadata=true for all fields incl. custom fields.", action_sets=["jira_sprints", "jira"], input_schema={ "sprint_id": {"type": "integer", "description": "Sprint ID.", "example": 42}, "jql": {"type": "string", "description": "Optional JQL filter.", "example": ""}, "max_results": {"type": "integer", "description": "Max issues.", "example": 50}, + "include_metadata": { + "type": "boolean", + "description": "Return every field (incl. customfield_*) with full nested objects.", + "example": False, + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "total + issues. Lean: default field set, status/assignee/priority/issuetype collapsed to names. Full payload when include_metadata=true.", + }, }, - output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_jira_sprint_issues(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client @@ -1456,6 +1541,7 @@ async def get_jira_sprint_issues(input_data: dict) -> dict: sprint_id=input_data["sprint_id"], jql=input_data.get("jql") or None, max_results=input_data.get("max_results", 50), + include_metadata=bool(input_data.get("include_metadata", False)), ) @@ -1509,7 +1595,7 @@ async def create_jira_sprint(input_data: dict) -> dict: @action( name="update_jira_sprint", - description="Update a sprint's name, state (active/closed/future), goal, or dates.", + description="Update a sprint's name, state (active/closed/future), goal, or dates. Returns id, name, state.", action_sets=["jira_sprints"], input_schema={ "sprint_id": {"type": "integer", "description": "Sprint ID.", "example": 42}, @@ -1527,13 +1613,19 @@ async def create_jira_sprint(input_data: dict) -> dict: }, "end_date": {"type": "string", "description": "ISO end date.", "example": ""}, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "Updated sprint: id, name, state.", + }, + }, parallelizable=False, ) async def update_jira_sprint(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "jira", "update_sprint", sprint_id=input_data["sprint_id"], @@ -1543,6 +1635,7 @@ async def update_jira_sprint(input_data: dict) -> dict: start_date=input_data.get("start_date") or None, end_date=input_data.get("end_date") or None, ) + return pick_result(res, ["id", "name", "state"]) @action( @@ -1638,7 +1731,7 @@ async def get_jira_epic(input_data: dict) -> dict: @action( name="get_jira_epic_issues", - description="List child issues of an epic.", + description="List child issues of an epic. Returns lean issues by default; set include_metadata=true for all fields incl. custom fields.", action_sets=["jira_sprints"], input_schema={ "epic_key": { @@ -1647,8 +1740,19 @@ async def get_jira_epic(input_data: dict) -> dict: "example": "PROJ-100", }, "max_results": {"type": "integer", "description": "Max issues.", "example": 50}, + "include_metadata": { + "type": "boolean", + "description": "Return every field (incl. customfield_*) with full nested objects.", + "example": False, + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "total + issues. Lean: default field set, status/assignee/priority/issuetype collapsed to names. Full payload when include_metadata=true.", + }, }, - output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_jira_epic_issues(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client @@ -1658,6 +1762,7 @@ async def get_jira_epic_issues(input_data: dict) -> dict: "get_epic_issues", epic_id_or_key=input_data["epic_key"], max_results=input_data.get("max_results", 50), + include_metadata=bool(input_data.get("include_metadata", False)), ) diff --git a/app/data/action/integrations/lark/lark_actions.py b/app/data/action/integrations/lark/lark_actions.py index 15ee198b..8dcbc55c 100644 --- a/app/data/action/integrations/lark/lark_actions.py +++ b/app/data/action/integrations/lark/lark_actions.py @@ -9,7 +9,7 @@ @action( name="send_lark_message", irreversible=True, - description="Send a plain text message in Lark. receive_id_type: open_id | user_id | email | chat_id | union_id.", + description="Send a plain text message in Lark. receive_id_type: open_id | user_id | email | chat_id | union_id. Returns {message_id}.", action_sets=["lark_messages", "lark"], input_schema={ "receive_id": { @@ -28,21 +28,22 @@ parallelizable=False, ) async def send_lark_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "lark", "send_text", receive_id=input_data["receive_id"], text=input_data["text"], receive_id_type=input_data.get("receive_id_type", "open_id"), ) + return pick_result(res, ["message_id"]) @action( name="reply_lark_message", irreversible=True, - description="Reply to a Lark message by message_id.", + description="Reply to a Lark message by message_id. Returns {message_id}.", action_sets=["lark_messages", "lark"], input_schema={ "message_id": { @@ -56,20 +57,21 @@ async def send_lark_message(input_data: dict) -> dict: parallelizable=False, ) async def reply_lark_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "lark", "reply_text", message_id=input_data["message_id"], text=input_data["text"], ) + return pick_result(res, ["message_id"]) @action( name="send_lark_rich_message", irreversible=True, - description="Send a generic Lark message. msg_type: text | post | image | file | audio | media | sticker | interactive | share_chat | share_user. content is the per-type dict (this action JSON-encodes it for you).", + description="Send a generic Lark message. msg_type: text | post | image | file | audio | media | sticker | interactive | share_chat | share_user. content is the per-type dict (this action JSON-encodes it for you). Returns {message_id}.", action_sets=["lark_messages", "lark"], input_schema={ "receive_id": {"type": "string", "description": "Recipient ID.", "example": ""}, @@ -98,9 +100,9 @@ async def reply_lark_message(input_data: dict) -> dict: parallelizable=False, ) async def send_lark_rich_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "lark", "send_message", receive_id=input_data["receive_id"], @@ -109,12 +111,13 @@ async def send_lark_rich_message(input_data: dict) -> dict: receive_id_type=input_data.get("receive_id_type", "open_id"), uuid=input_data.get("uuid") or None, ) + return pick_result(res, ["message_id"]) @action( name="send_lark_image", irreversible=True, - description="Send an image (use upload_lark_image first to get image_key).", + description="Send an image (use upload_lark_image first to get image_key). Returns {message_id}.", action_sets=["lark_messages", "lark"], input_schema={ "receive_id": {"type": "string", "description": "Recipient ID.", "example": ""}, @@ -133,21 +136,22 @@ async def send_lark_rich_message(input_data: dict) -> dict: parallelizable=False, ) async def send_lark_image(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "lark", "send_image_message", receive_id=input_data["receive_id"], image_key=input_data["image_key"], receive_id_type=input_data.get("receive_id_type", "open_id"), ) + return pick_result(res, ["message_id"]) @action( name="send_lark_file", irreversible=True, - description="Send a file (use upload_lark_im_file first to get file_key).", + description="Send a file (use upload_lark_im_file first to get file_key). Returns {message_id}.", action_sets=["lark_messages", "lark"], input_schema={ "receive_id": {"type": "string", "description": "Recipient ID.", "example": ""}, @@ -166,21 +170,22 @@ async def send_lark_image(input_data: dict) -> dict: parallelizable=False, ) async def send_lark_file(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "lark", "send_file_message", receive_id=input_data["receive_id"], file_key=input_data["file_key"], receive_id_type=input_data.get("receive_id_type", "open_id"), ) + return pick_result(res, ["message_id"]) @action( name="send_lark_card", irreversible=True, - description="Send an interactive card (Lark's Block Kit equivalent). card is the card schema dict.", + description="Send an interactive card (Lark's Block Kit equivalent). card is the card schema dict. Returns {message_id}.", action_sets=["lark_messages", "lark"], input_schema={ "receive_id": {"type": "string", "description": "Recipient ID.", "example": ""}, @@ -195,21 +200,22 @@ async def send_lark_file(input_data: dict) -> dict: parallelizable=False, ) async def send_lark_card(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "lark", "send_card_message", receive_id=input_data["receive_id"], card=input_data["card"], receive_id_type=input_data.get("receive_id_type", "open_id"), ) + return pick_result(res, ["message_id"]) @action( name="send_lark_post", irreversible=True, - description="Send a rich-text 'post' message (multi-line, styled). post is Lark's post schema: {zh_cn: {title, content: [[{tag,text}]]}}.", + description="Send a rich-text 'post' message (multi-line, styled). post is Lark's post schema: {zh_cn: {title, content: [[{tag,text}]]}}. Returns {message_id}.", action_sets=["lark_messages"], input_schema={ "receive_id": {"type": "string", "description": "Recipient ID.", "example": ""}, @@ -224,21 +230,22 @@ async def send_lark_card(input_data: dict) -> dict: parallelizable=False, ) async def send_lark_post(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "lark", "send_post_message", receive_id=input_data["receive_id"], post=input_data["post"], receive_id_type=input_data.get("receive_id_type", "open_id"), ) + return pick_result(res, ["message_id"]) @action( name="reply_lark_rich_message", irreversible=True, - description="Reply with non-text content (image / file / card / etc.). reply_in_thread starts a thread off the parent.", + description="Reply with non-text content (image / file / card / etc.). reply_in_thread starts a thread off the parent. Returns {message_id}.", action_sets=["lark_messages"], input_schema={ "message_id": { @@ -266,9 +273,9 @@ async def send_lark_post(input_data: dict) -> dict: parallelizable=False, ) async def reply_lark_rich_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "lark", "reply_message", message_id=input_data["message_id"], @@ -276,6 +283,7 @@ async def reply_lark_rich_message(input_data: dict) -> dict: content=input_data["content"], reply_in_thread=bool(input_data.get("reply_in_thread", False)), ) + return pick_result(res, ["message_id"]) @action( @@ -313,7 +321,7 @@ async def delete_lark_message(input_data: dict) -> dict: @action( name="update_lark_message", - description="Edit a previously-sent Lark message. Only text/interactive types are editable.", + description="Edit a previously-sent Lark message. Only text/interactive types are editable. Returns {message_id}.", action_sets=["lark_messages", "lark"], input_schema={ "message_id": {"type": "string", "description": "Message ID.", "example": ""}, @@ -332,21 +340,22 @@ async def delete_lark_message(input_data: dict) -> dict: parallelizable=False, ) async def update_lark_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "lark", "update_message", message_id=input_data["message_id"], msg_type=input_data["msg_type"], content=input_data["content"], ) + return pick_result(res, ["message_id"]) @action( name="forward_lark_message", irreversible=True, - description="Forward a message to another recipient.", + description="Forward a message to another recipient. Returns {message_id} of the forwarded copy.", action_sets=["lark_messages", "lark"], input_schema={ "message_id": {"type": "string", "description": "Message ID.", "example": ""}, @@ -370,9 +379,9 @@ async def update_lark_message(input_data: dict) -> dict: parallelizable=False, ) async def forward_lark_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "lark", "forward_message", message_id=input_data["message_id"], @@ -380,11 +389,12 @@ async def forward_lark_message(input_data: dict) -> dict: receive_id_type=input_data.get("receive_id_type", "open_id"), uuid=input_data.get("uuid") or None, ) + return pick_result(res, ["message_id"]) @action( name="list_lark_chat_messages", - description="List a chat's message history. container_id is usually a chat_id; start_time/end_time are unix seconds as strings.", + description="List a chat's message history. container_id is usually a chat_id; start_time/end_time are unix seconds as strings. Returns lean messages (message_id, msg_type, sender_id, create_time, text, root_id/parent_id); include_metadata=true for full raw.", action_sets=["lark_messages", "lark"], input_schema={ "container_id": { @@ -392,6 +402,11 @@ async def forward_lark_message(input_data: dict) -> dict: "description": "Chat/thread ID.", "example": "", }, + "include_metadata": { + "type": "boolean", + "description": "Return full raw message objects (default false = lean).", + "example": False, + }, "container_id_type": { "type": "string", "description": "chat (default) | thread.", @@ -422,9 +437,11 @@ async def forward_lark_message(input_data: dict) -> dict: output_schema={"status": {"type": "string", "example": "success"}}, ) async def list_lark_chat_messages(input_data: dict) -> dict: + import json + from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "lark", "list_messages", container_id=input_data["container_id"], @@ -435,6 +452,48 @@ async def list_lark_chat_messages(input_data: dict) -> dict: page_size=input_data.get("page_size", 50), page_token=input_data.get("page_token", ""), ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + result = res.get("result") + if not isinstance(result, dict) or not isinstance(result.get("items"), list): + return res + + def _lean_message(m: dict) -> dict: + out = { + "message_id": m.get("message_id"), + "msg_type": m.get("msg_type"), + "create_time": m.get("create_time"), + } + sender = m.get("sender") or {} + if sender.get("id"): + out["sender_id"] = sender["id"] + content = (m.get("body") or {}).get("content") + if m.get("msg_type") == "text" and isinstance(content, str): + try: + out["text"] = json.loads(content).get("text", content) + except (ValueError, AttributeError): + out["text"] = content + elif content is not None: + out["content"] = content + for key in ("root_id", "parent_id"): + if m.get(key): + out[key] = m[key] + mention_names = [ + x.get("name") + for x in m.get("mentions") or [] + if isinstance(x, dict) and x.get("name") + ] + if mention_names: + out["mentioned"] = mention_names + return out + + lean = { + "items": [_lean_message(m) for m in result["items"] if isinstance(m, dict)] + } + for key in ("has_more", "page_token"): + if result.get(key): + lean[key] = result[key] + return {**res, "result": lean} @action( @@ -651,7 +710,7 @@ async def send_lark_urgent(input_data: dict) -> dict: @action( name="batch_send_lark_message", - description="Broadcast the same message to many recipients in one call.", + description="Broadcast the same message to many recipients in one call. Returns {message_id} plus any invalid_*_ids.", action_sets=["lark_messages"], input_schema={ "msg_type": { @@ -684,9 +743,9 @@ async def send_lark_urgent(input_data: dict) -> dict: parallelizable=False, ) async def batch_send_lark_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "lark", "batch_send_message", msg_type=input_data["msg_type"], @@ -695,6 +754,15 @@ async def batch_send_lark_message(input_data: dict) -> dict: user_ids=input_data.get("user_ids") or None, department_ids=input_data.get("department_ids") or None, ) + return pick_result( + res, + [ + "message_id", + "invalid_open_ids", + "invalid_user_ids", + "invalid_department_ids", + ], + ) # ----- Resources (image / file upload + download) ----- @@ -822,21 +890,41 @@ async def download_lark_message_resource(input_data: dict) -> dict: @action( name="list_lark_chats", - description="List groups the bot is a member of.", + description="List groups the bot is a member of. Returns lean chats (chat_id, name, description, owner_id); include_metadata=true for full raw.", action_sets=["lark_chats", "lark"], input_schema={ "page_size": {"type": "integer", "description": "Max 100.", "example": 50}, + "include_metadata": { + "type": "boolean", + "description": "Return full raw chat objects (default false = lean).", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) async def list_lark_chats(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "lark", "list_chats", page_size=input_data.get("page_size", 50), ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + result = res.get("result") + if not isinstance(result, dict) or not isinstance(result.get("items"), list): + return res + + def _lean_chat(c: dict) -> dict: + keep = ("chat_id", "name", "description", "owner_id", "external", "chat_status") + return {k: c[k] for k in keep if c.get(k) not in (None, "")} + + lean = {"items": [_lean_chat(c) for c in result["items"] if isinstance(c, dict)]} + for key in ("has_more", "page_token"): + if result.get(key): + lean[key] = result[key] + return {**res, "result": lean} @action( @@ -1099,25 +1187,45 @@ async def remove_lark_chat_members(input_data: dict) -> dict: @action( name="search_lark_chats", - description="Search chats by name.", + description="Search chats by name. Returns lean chats (chat_id, name, description, owner_id); include_metadata=true for full raw.", action_sets=["lark_chats", "lark"], input_schema={ "query": {"type": "string", "description": "Search query.", "example": ""}, "page_size": {"type": "integer", "description": "Max 100.", "example": 50}, "page_token": {"type": "string", "description": "Cursor.", "example": ""}, + "include_metadata": { + "type": "boolean", + "description": "Return full raw chat objects (default false = lean).", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) async def search_lark_chats(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "lark", "search_chats", query=input_data["query"], page_size=input_data.get("page_size", 50), page_token=input_data.get("page_token", ""), ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + result = res.get("result") + if not isinstance(result, dict) or not isinstance(result.get("items"), list): + return res + + def _lean_chat(c: dict) -> dict: + keep = ("chat_id", "name", "description", "owner_id", "external", "chat_status") + return {k: c[k] for k in keep if c.get(k) not in (None, "")} + + lean = {"items": [_lean_chat(c) for c in result["items"] if isinstance(c, dict)]} + for key in ("has_more", "page_token"): + if result.get(key): + lean[key] = result[key] + return {**res, "result": lean} @action( @@ -1214,7 +1322,7 @@ async def set_lark_chat_moderation(input_data: dict) -> dict: @action( name="get_lark_user", - description="Get a single Lark user by ID.", + description="Get a single Lark user by ID. Returns a lean user (open_id, name, email, mobile, department_ids, job_title); include_metadata=true for full raw.", action_sets=["lark_contacts", "lark"], input_schema={ "user_id": {"type": "string", "description": "User ID.", "example": ""}, @@ -1228,24 +1336,54 @@ async def set_lark_chat_moderation(input_data: dict) -> dict: "description": "open_department_id | department_id.", "example": "open_department_id", }, + "include_metadata": { + "type": "boolean", + "description": "Return the full raw user object (default false = lean).", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_lark_user(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "lark", "get_user", user_id=input_data["user_id"], user_id_type=input_data.get("user_id_type", "open_id"), department_id_type=input_data.get("department_id_type", "open_department_id"), ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + result = res.get("result") + if not isinstance(result, dict) or not isinstance(result.get("user"), dict): + return res + + def _lean_user(u: dict) -> dict: + keep = ( + "open_id", + "user_id", + "name", + "en_name", + "email", + "enterprise_email", + "mobile", + "department_ids", + "job_title", + ) + out = {k: u[k] for k in keep if u.get(k) not in (None, "", [])} + status = u.get("status") + if isinstance(status, dict) and "is_activated" in status: + out["is_activated"] = status["is_activated"] + return out + + return {**res, "result": {"user": _lean_user(result["user"])}} @action( name="batch_get_lark_users", - description="Get multiple Lark users by ID in one call.", + description="Get multiple Lark users by ID in one call. Returns lean users (open_id, name, email, mobile, department_ids); include_metadata=true for full raw.", action_sets=["lark_contacts"], input_schema={ "user_ids": {"type": "array", "description": "User IDs.", "example": []}, @@ -1254,18 +1392,49 @@ async def get_lark_user(input_data: dict) -> dict: "description": "open_id | user_id | union_id.", "example": "open_id", }, + "include_metadata": { + "type": "boolean", + "description": "Return full raw user objects (default false = lean).", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) async def batch_get_lark_users(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "lark", "batch_get_users", user_ids=input_data["user_ids"], user_id_type=input_data.get("user_id_type", "open_id"), ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + result = res.get("result") + if not isinstance(result, dict) or not isinstance(result.get("items"), list): + return res + + def _lean_user(u: dict) -> dict: + keep = ( + "open_id", + "user_id", + "name", + "en_name", + "email", + "enterprise_email", + "mobile", + "department_ids", + "job_title", + ) + out = {k: u[k] for k in keep if u.get(k) not in (None, "", [])} + status = u.get("status") + if isinstance(status, dict) and "is_activated" in status: + out["is_activated"] = status["is_activated"] + return out + + lean = {"items": [_lean_user(u) for u in result["items"] if isinstance(u, dict)]} + return {**res, "result": lean} @action( @@ -1320,30 +1489,64 @@ async def batch_lookup_lark_users(input_data: dict) -> dict: @action( name="search_lark_users_by_name", - description="Search Lark users by name (visibility depends on app scope grants).", + description="Search Lark users by name (visibility depends on app scope grants). Returns lean users (open_id, name, department_ids); include_metadata=true for full raw.", action_sets=["lark_contacts", "lark"], input_schema={ "query": {"type": "string", "description": "Search query.", "example": ""}, "page_size": {"type": "integer", "description": "Max 50.", "example": 50}, "page_token": {"type": "string", "description": "Cursor.", "example": ""}, + "include_metadata": { + "type": "boolean", + "description": "Return full raw user objects (default false = lean).", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) async def search_lark_users_by_name(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "lark", "search_users_by_name", query=input_data["query"], page_size=input_data.get("page_size", 50), page_token=input_data.get("page_token", ""), ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + result = res.get("result") + if not isinstance(result, dict) or not isinstance(result.get("items"), list): + return res + + def _lean_user(u: dict) -> dict: + keep = ( + "open_id", + "user_id", + "name", + "en_name", + "email", + "enterprise_email", + "mobile", + "department_ids", + "job_title", + ) + out = {k: u[k] for k in keep if u.get(k) not in (None, "", [])} + status = u.get("status") + if isinstance(status, dict) and "is_activated" in status: + out["is_activated"] = status["is_activated"] + return out + + lean = {"items": [_lean_user(u) for u in result["items"] if isinstance(u, dict)]} + for key in ("has_more", "page_token"): + if result.get(key): + lean[key] = result[key] + return {**res, "result": lean} @action( name="list_lark_department_users", - description="List users in a department.", + description="List users in a department. Returns lean users (open_id, name, email, mobile, department_ids); include_metadata=true for full raw.", action_sets=["lark_contacts"], input_schema={ "department_id": { @@ -1351,6 +1554,11 @@ async def search_lark_users_by_name(input_data: dict) -> dict: "description": "Department ID.", "example": "", }, + "include_metadata": { + "type": "boolean", + "description": "Return full raw user objects (default false = lean).", + "example": False, + }, "user_id_type": { "type": "string", "description": "open_id | user_id | union_id.", @@ -1369,7 +1577,7 @@ async def search_lark_users_by_name(input_data: dict) -> dict: async def list_lark_department_users(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "lark", "list_department_users", department_id=input_data["department_id"], @@ -1378,6 +1586,35 @@ async def list_lark_department_users(input_data: dict) -> dict: page_size=input_data.get("page_size", 50), page_token=input_data.get("page_token", ""), ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + result = res.get("result") + if not isinstance(result, dict) or not isinstance(result.get("items"), list): + return res + + def _lean_user(u: dict) -> dict: + keep = ( + "open_id", + "user_id", + "name", + "en_name", + "email", + "enterprise_email", + "mobile", + "department_ids", + "job_title", + ) + out = {k: u[k] for k in keep if u.get(k) not in (None, "", [])} + status = u.get("status") + if isinstance(status, dict) and "is_activated" in status: + out["is_activated"] = status["is_activated"] + return out + + lean = {"items": [_lean_user(u) for u in result["items"] if isinstance(u, dict)]} + for key in ("has_more", "page_token"): + if result.get(key): + lean[key] = result[key] + return {**res, "result": lean} @action( diff --git a/app/data/action/integrations/lark_calendar/lark_calendar_actions.py b/app/data/action/integrations/lark_calendar/lark_calendar_actions.py index 8980d0d7..6ccd629f 100644 --- a/app/data/action/integrations/lark_calendar/lark_calendar_actions.py +++ b/app/data/action/integrations/lark_calendar/lark_calendar_actions.py @@ -81,7 +81,7 @@ async def get_lark_calendar(input_data: dict) -> dict: @action( name="create_lark_calendar", - description="Create a new secondary Lark calendar owned by the bot.", + description="Create a new secondary Lark calendar owned by the bot. Returns {calendar_id, summary}.", action_sets=["lark_calendar_calendars", "lark_calendar"], input_schema={ "summary": { @@ -119,7 +119,7 @@ async def get_lark_calendar(input_data: dict) -> dict: async def create_lark_calendar(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "lark_calendar", "create_calendar", summary=input_data["summary"], @@ -128,11 +128,19 @@ async def create_lark_calendar(input_data: dict) -> dict: color=input_data.get("color"), summary_alias=input_data.get("summary_alias", ""), ) + if res.get("status") == "success" and isinstance(res.get("result"), dict): + calendar = res["result"].get("calendar") + if isinstance(calendar, dict) and calendar.get("calendar_id"): + picked = {"calendar_id": calendar["calendar_id"]} + if calendar.get("summary"): + picked["summary"] = calendar["summary"] + res = {**res, "result": picked} + return res @action( name="update_lark_calendar", - description="Patch fields on an existing Lark calendar. Only fields you supply are changed.", + description="Patch fields on an existing Lark calendar. Only fields you supply are changed. Returns {calendar_id, summary}.", action_sets=["lark_calendar_calendars"], input_schema={ "calendar_id": { @@ -163,7 +171,7 @@ async def create_lark_calendar(input_data: dict) -> dict: async def update_lark_calendar(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "lark_calendar", "update_calendar", calendar_id=input_data["calendar_id"], @@ -177,6 +185,14 @@ async def update_lark_calendar(input_data: dict) -> dict: if input_data.get("summary_alias") is not None else None, ) + if res.get("status") == "success" and isinstance(res.get("result"), dict): + calendar = res["result"].get("calendar") + if isinstance(calendar, dict) and calendar.get("calendar_id"): + picked = {"calendar_id": calendar["calendar_id"]} + if calendar.get("summary"): + picked["summary"] = calendar["summary"] + res = {**res, "result": picked} + return res @action( @@ -293,7 +309,7 @@ async def unsubscribe_from_lark_calendar(input_data: dict) -> dict: @action( name="list_lark_calendar_events", - description="List events on a Lark calendar between two Unix timestamps (seconds).", + description="List events on a Lark calendar between two Unix timestamps (seconds). Returns lean events (event_id, summary, start/end, location, status); include_metadata=true for full raw.", action_sets=["lark_calendar_events", "lark_calendar"], input_schema={ "calendar_id": { @@ -316,6 +332,11 @@ async def unsubscribe_from_lark_calendar(input_data: dict) -> dict: "description": "Max events to return (capped at 1000).", "example": 50, }, + "include_metadata": { + "type": "boolean", + "description": "Return full raw event objects (default false = lean).", + "example": False, + }, }, output_schema={ "status": {"type": "string", "example": "success"}, @@ -325,7 +346,7 @@ async def unsubscribe_from_lark_calendar(input_data: dict) -> dict: async def list_lark_calendar_events(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "lark_calendar", "list_events", calendar_id=input_data["calendar_id"], @@ -333,6 +354,41 @@ async def list_lark_calendar_events(input_data: dict) -> dict: end_time=input_data["end_time"], page_size=input_data.get("page_size", 50), ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + result = res.get("result") + if not isinstance(result, dict) or not isinstance(result.get("items"), list): + return res + + def _lean_event(e: dict) -> dict: + out = {} + keep = ( + "event_id", + "summary", + "description", + "start_time", + "end_time", + "status", + "organizer_calendar_id", + "recurrence", + "app_link", + ) + for k in keep: + if e.get(k) not in (None, ""): + out[k] = e[k] + location = e.get("location") + if isinstance(location, dict) and location.get("name"): + out["location"] = location["name"] + vchat = e.get("vchat") + if isinstance(vchat, dict) and vchat.get("meeting_url"): + out["meeting_url"] = vchat["meeting_url"] + return out + + lean = {"items": [_lean_event(e) for e in result["items"] if isinstance(e, dict)]} + for key in ("has_more", "page_token"): + if result.get(key): + lean[key] = result[key] + return {**res, "result": lean} @action( @@ -369,7 +425,7 @@ async def get_lark_calendar_event(input_data: dict) -> dict: @action( name="create_lark_calendar_event", - description="Create a new event on a Lark calendar. To invite attendees, call add_lark_event_attendees afterwards with the returned event_id.", + description="Create a new event on a Lark calendar. To invite attendees, call add_lark_event_attendees afterwards with the returned event_id. Returns {event_id, summary, start/end, app_link, meeting_url}.", action_sets=["lark_calendar_events", "lark_calendar"], input_schema={ "calendar_id": { @@ -417,7 +473,7 @@ async def get_lark_calendar_event(input_data: dict) -> dict: async def create_lark_calendar_event(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "lark_calendar", "create_event", calendar_id=input_data["calendar_id"], @@ -428,11 +484,23 @@ async def create_lark_calendar_event(input_data: dict) -> dict: location=input_data.get("location", ""), with_video_meeting=input_data.get("with_video_meeting", False), ) + if res.get("status") == "success" and isinstance(res.get("result"), dict): + event = res["result"].get("event") + if isinstance(event, dict) and event.get("event_id"): + picked = {"event_id": event["event_id"]} + for k in ("summary", "start_time", "end_time", "app_link"): + if event.get(k): + picked[k] = event[k] + vchat = event.get("vchat") + if isinstance(vchat, dict) and vchat.get("meeting_url"): + picked["meeting_url"] = vchat["meeting_url"] + res = {**res, "result": picked} + return res @action( name="update_lark_calendar_event", - description="Patch fields on an existing Lark calendar event. Only fields you supply are changed.", + description="Patch fields on an existing Lark calendar event. Only fields you supply are changed. Returns {event_id, summary, start/end, app_link, meeting_url}.", action_sets=["lark_calendar_events", "lark_calendar"], input_schema={ "calendar_id": { @@ -480,7 +548,7 @@ async def create_lark_calendar_event(input_data: dict) -> dict: async def update_lark_calendar_event(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "lark_calendar", "update_event", calendar_id=input_data["calendar_id"], @@ -491,6 +559,18 @@ async def update_lark_calendar_event(input_data: dict) -> dict: end_time=input_data.get("end_time"), location=input_data.get("location"), ) + if res.get("status") == "success" and isinstance(res.get("result"), dict): + event = res["result"].get("event") + if isinstance(event, dict) and event.get("event_id"): + picked = {"event_id": event["event_id"]} + for k in ("summary", "start_time", "end_time", "app_link"): + if event.get(k): + picked[k] = event[k] + vchat = event.get("vchat") + if isinstance(vchat, dict) and vchat.get("meeting_url"): + picked["meeting_url"] = vchat["meeting_url"] + res = {**res, "result": picked} + return res @action( @@ -617,7 +697,7 @@ async def rsvp_lark_calendar_event(input_data: dict) -> dict: @action( name="list_lark_event_instances", - description="List the concrete occurrences of a recurring Lark event within a time window.", + description="List the concrete occurrences of a recurring Lark event within a time window. Returns lean events (event_id, summary, start/end, location, status); include_metadata=true for full raw.", action_sets=["lark_calendar_events"], input_schema={ "calendar_id": { @@ -645,6 +725,11 @@ async def rsvp_lark_calendar_event(input_data: dict) -> dict: "description": "Max instances.", "example": 50, }, + "include_metadata": { + "type": "boolean", + "description": "Return full raw event objects (default false = lean).", + "example": False, + }, }, output_schema={ "status": {"type": "string", "example": "success"}, @@ -654,7 +739,7 @@ async def rsvp_lark_calendar_event(input_data: dict) -> dict: async def list_lark_event_instances(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "lark_calendar", "list_event_instances", calendar_id=input_data["calendar_id"], @@ -663,6 +748,41 @@ async def list_lark_event_instances(input_data: dict) -> dict: end_time=input_data["end_time"], page_size=input_data.get("page_size", 50), ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + result = res.get("result") + if not isinstance(result, dict) or not isinstance(result.get("items"), list): + return res + + def _lean_event(e: dict) -> dict: + out = {} + keep = ( + "event_id", + "summary", + "description", + "start_time", + "end_time", + "status", + "organizer_calendar_id", + "recurrence", + "app_link", + ) + for k in keep: + if e.get(k) not in (None, ""): + out[k] = e[k] + location = e.get("location") + if isinstance(location, dict) and location.get("name"): + out["location"] = location["name"] + vchat = e.get("vchat") + if isinstance(vchat, dict) and vchat.get("meeting_url"): + out["meeting_url"] = vchat["meeting_url"] + return out + + lean = {"items": [_lean_event(e) for e in result["items"] if isinstance(e, dict)]} + for key in ("has_more", "page_token"): + if result.get(key): + lean[key] = result[key] + return {**res, "result": lean} # ------------------------------------------------------------------ @@ -673,7 +793,7 @@ async def list_lark_event_instances(input_data: dict) -> dict: @action( name="add_lark_event_attendees", - description="Invite attendees to a Lark calendar event. Pass user_ids (open_ids), emails (for external attendees), or chat_ids (invites everyone in a group).", + description="Invite attendees to a Lark calendar event. Pass user_ids (open_ids), emails (for external attendees), or chat_ids (invites everyone in a group). Returns {attendee_ids, attendees_added}.", action_sets=["lark_calendar_attendees", "lark_calendar"], input_schema={ "calendar_id": { @@ -716,7 +836,7 @@ async def list_lark_event_instances(input_data: dict) -> dict: async def add_lark_event_attendees(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "lark_calendar", "add_event_attendees", calendar_id=input_data["calendar_id"], @@ -726,6 +846,19 @@ async def add_lark_event_attendees(input_data: dict) -> dict: chat_ids=input_data.get("chat_ids"), need_notification=input_data.get("need_notification", True), ) + if res.get("status") == "success" and isinstance(res.get("result"), dict): + attendees = res["result"].get("attendees") + if isinstance(attendees, list): + ids = [ + a.get("attendee_id") + for a in attendees + if isinstance(a, dict) and a.get("attendee_id") + ] + res = { + **res, + "result": {"attendee_ids": ids, "attendees_added": len(attendees)}, + } + return res @action( @@ -869,7 +1002,7 @@ async def list_lark_event_chat_attendee_members(input_data: dict) -> dict: @action( name="book_lark_meeting_room", - description="Attach a meeting room to a Lark calendar event as a resource attendee (effectively booking it).", + description="Attach a meeting room to a Lark calendar event as a resource attendee (effectively booking it). Returns {attendee_ids, attendees_added}.", action_sets=["lark_calendar_attendees", "lark_calendar"], input_schema={ "calendar_id": { @@ -902,7 +1035,7 @@ async def list_lark_event_chat_attendee_members(input_data: dict) -> dict: async def book_lark_meeting_room(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "lark_calendar", "add_meeting_room_to_event", calendar_id=input_data["calendar_id"], @@ -910,6 +1043,19 @@ async def book_lark_meeting_room(input_data: dict) -> dict: meeting_room_id=input_data["meeting_room_id"], need_notification=input_data.get("need_notification", True), ) + if res.get("status") == "success" and isinstance(res.get("result"), dict): + attendees = res["result"].get("attendees") + if isinstance(attendees, list): + ids = [ + a.get("attendee_id") + for a in attendees + if isinstance(a, dict) and a.get("attendee_id") + ] + res = { + **res, + "result": {"attendee_ids": ids, "attendees_added": len(attendees)}, + } + return res # ------------------------------------------------------------------ @@ -944,7 +1090,7 @@ async def list_lark_calendar_acls(input_data: dict) -> dict: @action( name="share_lark_calendar_with_user", - description="Share a Lark calendar with a user by granting them a role (owner / reader / writer / free_busy_reader).", + description="Share a Lark calendar with a user by granting them a role (owner / reader / writer / free_busy_reader). Returns {acl_id, role}.", action_sets=["lark_calendar_sharing", "lark_calendar"], input_schema={ "calendar_id": { @@ -970,15 +1116,16 @@ async def list_lark_calendar_acls(input_data: dict) -> dict: parallelizable=False, ) async def share_lark_calendar_with_user(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "lark_calendar", "create_calendar_acl", calendar_id=input_data["calendar_id"], user_id=input_data["user_id"], role=input_data.get("role", "reader"), ) + return pick_result(res, ["acl_id", "role"]) @action( diff --git a/app/data/action/integrations/lark_drive/lark_drive_actions.py b/app/data/action/integrations/lark_drive/lark_drive_actions.py index cef75915..918e24fe 100644 --- a/app/data/action/integrations/lark_drive/lark_drive_actions.py +++ b/app/data/action/integrations/lark_drive/lark_drive_actions.py @@ -216,7 +216,7 @@ async def search_lark_drive_files(input_data: dict) -> dict: @action( name="copy_lark_drive_file", - description="Copy a file/doc/sheet/etc into a folder.", + description="Copy a file/doc/sheet/etc into a folder. Returns {token, name, url} of the new copy.", action_sets=["lark_drive_files", "lark_drive"], input_schema={ "file_token": {"type": "string", "description": "Source token.", "example": ""}, @@ -238,7 +238,7 @@ async def search_lark_drive_files(input_data: dict) -> dict: async def copy_lark_drive_file(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "lark_drive", "copy_file", file_token=input_data["file_token"], @@ -246,6 +246,15 @@ async def copy_lark_drive_file(input_data: dict) -> dict: folder_token=input_data["folder_token"], copy_type=input_data.get("copy_type", "file"), ) + if res.get("status") == "success" and isinstance(res.get("result"), dict): + new_file = res["result"].get("file") + if isinstance(new_file, dict): + picked = { + k: new_file[k] for k in ("token", "name", "url") if new_file.get(k) + } + if picked: + res = {**res, "result": picked} + return res @action( @@ -1071,7 +1080,7 @@ async def get_lark_doc_raw_content(input_data: dict) -> dict: @action( name="list_lark_doc_blocks", - description="List a Doc's blocks (paragraphs, headings, tables, etc.).", + description="List a Doc's blocks (paragraphs, headings, tables, etc.). Returns lean blocks (block_id, block_type, parent_id, text); include_metadata=true for full raw block objects.", action_sets=["lark_docs", "lark_drive"], input_schema={ "document_id": {"type": "string", "description": "Doc ID.", "example": ""}, @@ -1085,19 +1094,54 @@ async def get_lark_doc_raw_content(input_data: dict) -> dict: "description": "Pagination cursor.", "example": "", }, + "include_metadata": { + "type": "boolean", + "description": "Return full raw block objects (default false = lean).", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) async def list_lark_doc_blocks(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "lark_drive", "list_document_blocks", document_id=input_data["document_id"], page_size=input_data.get("page_size", 500), page_token=input_data.get("page_token", ""), ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + result = res.get("result") + if not isinstance(result, dict) or not isinstance(result.get("items"), list): + return res + + def _lean_block(b: dict) -> dict: + out = { + "block_id": b.get("block_id"), + "block_type": b.get("block_type"), + "parent_id": b.get("parent_id"), + } + # Concatenate text-run contents from the per-type payload + # (text / heading1..9 / bullet / etc. all share {elements: [{text_run}]}). + parts = [] + for v in b.values(): + if isinstance(v, dict) and isinstance(v.get("elements"), list): + for el in v["elements"]: + run = el.get("text_run") if isinstance(el, dict) else None + if isinstance(run, dict) and run.get("content"): + parts.append(run["content"]) + if parts: + out["text"] = "".join(parts) + return out + + lean = {"items": [_lean_block(b) for b in result["items"] if isinstance(b, dict)]} + for key in ("has_more", "page_token"): + if result.get(key): + lean[key] = result[key] + return {**res, "result": lean} @action( @@ -1123,7 +1167,7 @@ async def get_lark_doc_block(input_data: dict) -> dict: @action( name="append_lark_doc_blocks", - description="Append child blocks under a parent block. Pass document_id as block_id to add at top level. children is an array of block objects (paragraph / heading / bullet / etc.).", + description="Append child blocks under a parent block. Pass document_id as block_id to add at top level. children is an array of block objects (paragraph / heading / bullet / etc.). Returns {block_ids: [...]} of the new blocks.", action_sets=["lark_docs", "lark_drive"], input_schema={ "document_id": {"type": "string", "description": "Doc ID.", "example": ""}, @@ -1149,7 +1193,7 @@ async def get_lark_doc_block(input_data: dict) -> dict: async def append_lark_doc_blocks(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "lark_drive", "create_document_block_children", document_id=input_data["document_id"], @@ -1157,11 +1201,21 @@ async def append_lark_doc_blocks(input_data: dict) -> dict: children=input_data["children"], index=input_data.get("index", -1), ) + if res.get("status") == "success" and isinstance(res.get("result"), dict): + children = res["result"].get("children") + if isinstance(children, list): + ids = [ + c.get("block_id") + for c in children + if isinstance(c, dict) and c.get("block_id") + ] + res = {**res, "result": {"block_ids": ids}} + return res @action( name="update_lark_doc_block", - description="Update a block. update_payload uses Docx's update structures, e.g. {update_text_elements: {elements: [...]}} for a paragraph.", + description="Update a block. update_payload uses Docx's update structures, e.g. {update_text_elements: {elements: [...]}} for a paragraph. Returns {block_id}.", action_sets=["lark_docs", "lark_drive"], input_schema={ "document_id": {"type": "string", "description": "Doc ID.", "example": ""}, @@ -1178,13 +1232,20 @@ async def append_lark_doc_blocks(input_data: dict) -> dict: async def update_lark_doc_block(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "lark_drive", "update_document_block", document_id=input_data["document_id"], block_id=input_data["block_id"], update_payload=input_data["update_payload"], ) + if res.get("status") == "success" and isinstance(res.get("result"), dict): + block = res["result"].get("block") + block_id = ( + block.get("block_id") if isinstance(block, dict) else None + ) or input_data["block_id"] + res = {**res, "result": {"block_id": block_id}} + return res @action( @@ -1797,7 +1858,7 @@ async def list_lark_bitable_tables(input_data: dict) -> dict: @action( name="create_lark_bitable_table", - description="Create a new table in a Bitable.", + description="Create a new table in a Bitable. Returns {table_id}.", action_sets=["lark_bitable", "lark_drive"], input_schema={ "app_token": {"type": "string", "description": "Bitable token.", "example": ""}, @@ -1817,9 +1878,9 @@ async def list_lark_bitable_tables(input_data: dict) -> dict: parallelizable=False, ) async def create_lark_bitable_table(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "lark_drive", "create_bitable_table", app_token=input_data["app_token"], @@ -1827,6 +1888,7 @@ async def create_lark_bitable_table(input_data: dict) -> dict: default_view_name=input_data.get("default_view_name") or None, fields=input_data.get("fields") or None, ) + return pick_result(res, ["table_id", "name"]) @action( @@ -1921,7 +1983,7 @@ async def get_lark_bitable_record(input_data: dict) -> dict: @action( name="create_lark_bitable_record", - description="Create a record in a table. fields is a dict mapping field name → value (per the field's type).", + description="Create a record in a table. fields is a dict mapping field name → value (per the field's type). Returns {record_id}.", action_sets=["lark_bitable", "lark_drive"], input_schema={ "app_token": {"type": "string", "description": "Bitable token.", "example": ""}, @@ -1938,18 +2000,23 @@ async def get_lark_bitable_record(input_data: dict) -> dict: async def create_lark_bitable_record(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "lark_drive", "create_bitable_record", app_token=input_data["app_token"], table_id=input_data["table_id"], fields=input_data["fields"], ) + if res.get("status") == "success" and isinstance(res.get("result"), dict): + record = res["result"].get("record") + if isinstance(record, dict) and record.get("record_id"): + res = {**res, "result": {"record_id": record["record_id"]}} + return res @action( name="update_lark_bitable_record", - description="Update a record.", + description="Update a record. Returns {record_id}.", action_sets=["lark_bitable", "lark_drive"], input_schema={ "app_token": {"type": "string", "description": "Bitable token.", "example": ""}, @@ -1963,7 +2030,7 @@ async def create_lark_bitable_record(input_data: dict) -> dict: async def update_lark_bitable_record(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "lark_drive", "update_bitable_record", app_token=input_data["app_token"], @@ -1971,6 +2038,11 @@ async def update_lark_bitable_record(input_data: dict) -> dict: record_id=input_data["record_id"], fields=input_data["fields"], ) + if res.get("status") == "success" and isinstance(res.get("result"), dict): + record = res["result"].get("record") + if isinstance(record, dict) and record.get("record_id"): + res = {**res, "result": {"record_id": record["record_id"]}} + return res @action( @@ -1999,7 +2071,7 @@ async def delete_lark_bitable_record(input_data: dict) -> dict: @action( name="batch_create_lark_bitable_records", - description="Create multiple records in one call. records: [{fields: {...}}, ...].", + description="Create multiple records in one call. records: [{fields: {...}}, ...]. Returns {record_ids: [...]}.", action_sets=["lark_bitable", "lark_drive"], input_schema={ "app_token": {"type": "string", "description": "Bitable token.", "example": ""}, @@ -2016,18 +2088,28 @@ async def delete_lark_bitable_record(input_data: dict) -> dict: async def batch_create_lark_bitable_records(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "lark_drive", "batch_create_bitable_records", app_token=input_data["app_token"], table_id=input_data["table_id"], records=input_data["records"], ) + if res.get("status") == "success" and isinstance(res.get("result"), dict): + records = res["result"].get("records") + if isinstance(records, list): + ids = [ + r.get("record_id") + for r in records + if isinstance(r, dict) and r.get("record_id") + ] + res = {**res, "result": {"record_ids": ids}} + return res @action( name="batch_update_lark_bitable_records", - description="Update multiple records. records: [{record_id, fields}, ...].", + description="Update multiple records. records: [{record_id, fields}, ...]. Returns {record_ids: [...]}.", action_sets=["lark_bitable"], input_schema={ "app_token": {"type": "string", "description": "Bitable token.", "example": ""}, @@ -2044,13 +2126,23 @@ async def batch_create_lark_bitable_records(input_data: dict) -> dict: async def batch_update_lark_bitable_records(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "lark_drive", "batch_update_bitable_records", app_token=input_data["app_token"], table_id=input_data["table_id"], records=input_data["records"], ) + if res.get("status") == "success" and isinstance(res.get("result"), dict): + records = res["result"].get("records") + if isinstance(records, list): + ids = [ + r.get("record_id") + for r in records + if isinstance(r, dict) and r.get("record_id") + ] + res = {**res, "result": {"record_ids": ids}} + return res @action( @@ -2175,7 +2267,7 @@ async def list_lark_bitable_fields(input_data: dict) -> dict: @action( name="create_lark_bitable_field", - description="Create a new field. field_type: 1=Text, 2=Number, 3=SingleSelect, 4=MultiSelect, 5=DateTime, 7=Checkbox, 11=User, 13=Phone, 15=URL, 17=Attachment, 18=Link, 19=Lookup, 20=Formula, 22=Location, 23=Group, 1001=CreatedTime, 1002=ModifiedTime, 1003=CreatedUser, 1004=ModifiedUser, 1005=AutoNumber.", + description="Create a new field. field_type: 1=Text, 2=Number, 3=SingleSelect, 4=MultiSelect, 5=DateTime, 7=Checkbox, 11=User, 13=Phone, 15=URL, 17=Attachment, 18=Link, 19=Lookup, 20=Formula, 22=Location, 23=Group, 1001=CreatedTime, 1002=ModifiedTime, 1003=CreatedUser, 1004=ModifiedUser, 1005=AutoNumber. Returns {field_id, field_name}.", action_sets=["lark_bitable"], input_schema={ "app_token": {"type": "string", "description": "Bitable token.", "example": ""}, @@ -2199,7 +2291,7 @@ async def list_lark_bitable_fields(input_data: dict) -> dict: async def create_lark_bitable_field(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "lark_drive", "create_bitable_field", app_token=input_data["app_token"], @@ -2209,6 +2301,14 @@ async def create_lark_bitable_field(input_data: dict) -> dict: property=input_data.get("property") or None, description=input_data.get("description") or None, ) + if res.get("status") == "success" and isinstance(res.get("result"), dict): + field = res["result"].get("field") + if isinstance(field, dict) and field.get("field_id"): + picked = {"field_id": field["field_id"]} + if field.get("field_name"): + picked["field_name"] = field["field_name"] + res = {**res, "result": picked} + return res @action( @@ -2353,7 +2453,7 @@ async def get_lark_wiki_node(input_data: dict) -> dict: @action( name="create_lark_wiki_node", - description="Create a new wiki node. obj_type: doc | docx | sheet | bitable | mindnote | file | slides. node_type: origin (new doc) | shortcut (link to existing).", + description="Create a new wiki node. obj_type: doc | docx | sheet | bitable | mindnote | file | slides. node_type: origin (new doc) | shortcut (link to existing). Returns {node_token, obj_token}.", action_sets=["lark_wiki"], input_schema={ "space_id": {"type": "string", "description": "Space ID.", "example": ""}, @@ -2385,7 +2485,7 @@ async def get_lark_wiki_node(input_data: dict) -> dict: async def create_lark_wiki_node(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "lark_drive", "create_wiki_node", space_id=input_data["space_id"], @@ -2395,6 +2495,14 @@ async def create_lark_wiki_node(input_data: dict) -> dict: origin_node_token=input_data.get("origin_node_token", ""), title=input_data.get("title", ""), ) + if res.get("status") == "success" and isinstance(res.get("result"), dict): + node = res["result"].get("node") + if isinstance(node, dict) and node.get("node_token"): + picked = {"node_token": node["node_token"]} + if node.get("obj_token"): + picked["obj_token"] = node["obj_token"] + res = {**res, "result": picked} + return res @action( diff --git a/app/data/action/integrations/linkedin/linkedin_actions.py b/app/data/action/integrations/linkedin/linkedin_actions.py index a7f4f090..d82f25da 100644 --- a/app/data/action/integrations/linkedin/linkedin_actions.py +++ b/app/data/action/integrations/linkedin/linkedin_actions.py @@ -105,43 +105,140 @@ def get_linkedin_post(input_data: dict) -> dict: @action( name="get_my_linkedin_posts", - description="Get my posts.", + description="Get my posts. Lean posts ({id, text, created, lifecycleState, media}) by default; include_metadata=true returns the full raw ugcPosts.", action_sets=["linkedin"], - input_schema={"count": {"type": "integer", "description": "Count.", "example": 50}}, + input_schema={ + "count": {"type": "integer", "description": "Count.", "example": 50}, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean posts. True: full raw ugcPosts.", + "example": False, + }, + }, output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_my_linkedin_posts(input_data: dict) -> dict: from app.data.action.integrations._helpers import with_client - return await with_client( + res = await with_client( "linkedin", lambda c: c.get_posts_by_author( _person_urn(c), count=input_data.get("count", 50) ), ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + body = res.get("result") + # with_client wraps the raw client return — collapse its transport envelope + if isinstance(body, dict) and body.get("ok") is True and "result" in body: + body = body["result"] + if not isinstance(body, dict) or "error" in body: + return res + + posts = [] + for el in body.get("elements", []) or []: + if not isinstance(el, dict): + continue + share = (el.get("specificContent") or {}).get( + "com.linkedin.ugc.ShareContent" + ) or {} + p = { + "id": el.get("id"), + "text": (share.get("shareCommentary") or {}).get("text"), + "created": (el.get("created") or {}).get("time"), + "lifecycleState": el.get("lifecycleState"), + } + media = share.get("media") + if media: + p["media"] = [ + { + k: v + for k, v in m.items() + if k in ("media", "originalUrl", "status") + } + for m in media + if isinstance(m, dict) + ] + posts.append(p) + lean = {"posts": posts} + if isinstance(body.get("paging"), dict): + pg = body["paging"] + lean["paging"] = { + "start": pg.get("start"), + "count": pg.get("count"), + "total": pg.get("total"), + } + return {**res, "result": lean} @action( name="get_linkedin_organization_posts", - description="Get organization posts.", + description="Get organization posts. Lean posts ({id, text, created, lifecycleState, media}) by default; include_metadata=true returns the full raw ugcPosts.", action_sets=["linkedin"], input_schema={ "organization_urn": { "type": "string", "description": "Org URN.", "example": "urn:li:organization:123", - } + }, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean posts. True: full raw ugcPosts.", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_linkedin_organization_posts(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( + res = run_client_sync( "linkedin", "get_posts_by_author", author_urn=input_data["organization_urn"], ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + body = res.get("result") + if isinstance(body, dict) and body.get("ok") is True and "result" in body: + body = body["result"] + if not isinstance(body, dict) or "error" in body: + return res + + posts = [] + for el in body.get("elements", []) or []: + if not isinstance(el, dict): + continue + share = (el.get("specificContent") or {}).get( + "com.linkedin.ugc.ShareContent" + ) or {} + p = { + "id": el.get("id"), + "text": (share.get("shareCommentary") or {}).get("text"), + "created": (el.get("created") or {}).get("time"), + "lifecycleState": el.get("lifecycleState"), + } + media = share.get("media") + if media: + p["media"] = [ + { + k: v + for k, v in m.items() + if k in ("media", "originalUrl", "status") + } + for m in media + if isinstance(m, dict) + ] + posts.append(p) + lean = {"posts": posts} + if isinstance(body.get("paging"), dict): + pg = body["paging"] + lean["paging"] = { + "start": pg.get("start"), + "count": pg.get("count"), + "total": pg.get("total"), + } + return {**res, "result": lean} @action( diff --git a/app/data/action/integrations/notion/notion_actions.py b/app/data/action/integrations/notion/notion_actions.py index 0a0115cb..b4e9eeeb 100644 --- a/app/data/action/integrations/notion/notion_actions.py +++ b/app/data/action/integrations/notion/notion_actions.py @@ -8,7 +8,7 @@ @action( name="search_notion", - description="Search Notion workspace for pages and databases.", + description="Search Notion workspace for pages and databases. Lean results ({id, object, title, url}) by default; include_metadata=true returns the full raw objects (properties, timestamps, parents, ...).", action_sets=["notion"], input_schema={ "query": { @@ -21,18 +21,56 @@ "description": "Optional: 'page' or 'database'.", "example": "page", }, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean {id, object, title, url} per result. True: full raw.", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def search_notion(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( + res = run_client_sync( "notion", "search", query=input_data["query"], filter_type=input_data.get("filter_type"), ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + items = res.get("result") + if not isinstance(items, list): + return res + + def _plain(rt) -> str: + return "".join( + x.get("plain_text", "") for x in (rt or []) if isinstance(x, dict) + ) + + lean = [] + for it in items: + if not isinstance(it, dict) or "error" in it: + lean.append(it) + continue + if isinstance(it.get("title"), list): # database object + title = _plain(it["title"]) + else: # page object — title lives in the title-type property + title = "" + for p in (it.get("properties") or {}).values(): + if isinstance(p, dict) and p.get("type") == "title": + title = _plain(p.get("title")) + break + lean.append( + { + "id": it.get("id"), + "object": it.get("object"), + "title": title, + "url": it.get("url"), + } + ) + return {**res, "result": lean} # ------------------------------------------------------------------ @@ -42,7 +80,7 @@ def search_notion(input_data: dict) -> dict: @action( name="get_notion_page", - description="Get a Notion page by ID (returns metadata + properties, not block content).", + description="Get a Notion page by ID (returns metadata + properties, not block content). Lean {id, url, archived, properties: {name: plain value}} by default; include_metadata=true returns the full raw page object.", action_sets=["notion_pages", "notion"], input_schema={ "page_id": { @@ -50,13 +88,71 @@ def search_notion(input_data: dict) -> dict: "description": "Notion page ID.", "example": "abc123", }, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean page with plain property values. True: full raw.", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_notion_page(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("notion", "get_page", page_id=input_data["page_id"]) + res = run_client_sync("notion", "get_page", page_id=input_data["page_id"]) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + body = res.get("result") + if not isinstance(body, dict): + return res + + def _plain(rt) -> str: + return "".join( + x.get("plain_text", "") for x in (rt or []) if isinstance(x, dict) + ) + + def _prop_value(p): + if not isinstance(p, dict): + return p + t = p.get("type") + v = p.get(t) + if t in ("title", "rich_text"): + return _plain(v) + if t in ("select", "status"): + return (v or {}).get("name") + if t == "multi_select": + return [o.get("name") for o in (v or []) if isinstance(o, dict)] + if t == "date": + return ( + {"start": v.get("start"), "end": v.get("end")} + if isinstance(v, dict) + else None + ) + if t == "people": + return [ + u.get("name") or u.get("id") for u in (v or []) if isinstance(u, dict) + ] + if t == "relation": + return [r.get("id") for r in (v or []) if isinstance(r, dict)] + if t in ("formula", "rollup"): + inner = (v or {}).get("type") + return (v or {}).get(inner) + if t in ("created_by", "last_edited_by"): + return (v or {}).get("name") or (v or {}).get("id") + if t == "files": + return [f.get("name") for f in (v or []) if isinstance(f, dict)] + return v + + lean = { + "id": body.get("id"), + "url": body.get("url"), + "archived": body.get("archived"), + "properties": { + name: _prop_value(p) + for name, p in (body.get("properties") or {}).items() + }, + } + return {**res, "result": lean} @action( @@ -85,13 +181,16 @@ def get_notion_page(input_data: dict) -> dict: "example": [], }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "description": "{id, url} of the new page."}, + }, parallelizable=False, ) def create_notion_page(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync + from app.data.action.integrations._helpers import pick_result, run_client_sync - return run_client_sync( + res = run_client_sync( "notion", "create_page", parent_id=input_data["parent_id"], @@ -99,6 +198,7 @@ def create_notion_page(input_data: dict) -> dict: properties=input_data["properties"], children=input_data.get("children"), ) + return pick_result(res, ["id", "url"]) @action( @@ -117,18 +217,22 @@ def create_notion_page(input_data: dict) -> dict: "example": {}, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "description": "{id, url} of the updated page."}, + }, parallelizable=False, ) def update_notion_page(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync + from app.data.action.integrations._helpers import pick_result, run_client_sync - return run_client_sync( + res = run_client_sync( "notion", "update_page", page_id=input_data["page_id"], properties=input_data["properties"], ) + return pick_result(res, ["id", "url"]) @action( @@ -201,7 +305,7 @@ def get_notion_page_property(input_data: dict) -> dict: @action( name="get_notion_database_schema", - description="Get a Notion database schema by ID.", + description="Get a Notion database schema by ID. Lean {id, title, url, properties: {name: type (+options for select/multi_select/status)}} by default; include_metadata=true returns the full raw database object.", action_sets=["notion_databases", "notion"], input_schema={ "database_id": { @@ -209,6 +313,11 @@ def get_notion_page_property(input_data: dict) -> dict: "description": "Database ID.", "example": "abc123", }, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean schema (property name -> type). True: full raw.", + "example": False, + }, }, output_schema={ "status": {"type": "string", "example": "success"}, @@ -218,14 +327,45 @@ def get_notion_page_property(input_data: dict) -> dict: def get_notion_database_schema(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( + res = run_client_sync( "notion", "get_database", database_id=input_data["database_id"] ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + body = res.get("result") + if not isinstance(body, dict): + return res + + def _plain(rt) -> str: + return "".join( + x.get("plain_text", "") for x in (rt or []) if isinstance(x, dict) + ) + + props = {} + for name, p in (body.get("properties") or {}).items(): + if not isinstance(p, dict): + continue + t = p.get("type") + if t in ("select", "multi_select", "status"): + options = (p.get(t) or {}).get("options") or [] + props[name] = { + "type": t, + "options": [o.get("name") for o in options if isinstance(o, dict)], + } + else: + props[name] = t + lean = { + "id": body.get("id"), + "title": _plain(body.get("title")), + "url": body.get("url"), + "properties": props, + } + return {**res, "result": lean} @action( name="query_notion_database", - description="Query a Notion database with optional filters and sorts.", + description="Query a Notion database with optional filters and sorts. Lean rows ({id, url, properties: {name: plain value}}) by default; include_metadata=true returns the full raw page objects.", action_sets=["notion_databases", "notion"], input_schema={ "database_id": { @@ -243,19 +383,84 @@ def get_notion_database_schema(input_data: dict) -> dict: "description": "Optional sort array.", "example": [], }, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean rows with plain property values. True: full raw.", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def query_notion_database(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( + res = run_client_sync( "notion", "query_database", database_id=input_data["database_id"], filter_obj=input_data.get("filter"), sorts=input_data.get("sorts"), ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + body = res.get("result") + if not isinstance(body, dict): + return res + + def _plain(rt) -> str: + return "".join( + x.get("plain_text", "") for x in (rt or []) if isinstance(x, dict) + ) + + def _prop_value(p): + if not isinstance(p, dict): + return p + t = p.get("type") + v = p.get(t) + if t in ("title", "rich_text"): + return _plain(v) + if t in ("select", "status"): + return (v or {}).get("name") + if t == "multi_select": + return [o.get("name") for o in (v or []) if isinstance(o, dict)] + if t == "date": + return ( + {"start": v.get("start"), "end": v.get("end")} + if isinstance(v, dict) + else None + ) + if t == "people": + return [ + u.get("name") or u.get("id") for u in (v or []) if isinstance(u, dict) + ] + if t == "relation": + return [r.get("id") for r in (v or []) if isinstance(r, dict)] + if t in ("formula", "rollup"): + inner = (v or {}).get("type") + return (v or {}).get(inner) + if t in ("created_by", "last_edited_by"): + return (v or {}).get("name") or (v or {}).get("id") + if t == "files": + return [f.get("name") for f in (v or []) if isinstance(f, dict)] + return v + + lean = { + "results": [ + { + "id": row.get("id"), + "url": row.get("url"), + "properties": { + name: _prop_value(p) + for name, p in (row.get("properties") or {}).items() + }, + } + for row in body.get("results", []) or [] + if isinstance(row, dict) + ], + "has_more": body.get("has_more"), + "next_cursor": body.get("next_cursor"), + } + return {**res, "result": lean} @action( @@ -295,13 +500,16 @@ def query_notion_database(input_data: dict) -> dict: }, "cover": {"type": "object", "description": "Cover (optional).", "example": {}}, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "description": "{id, url} of the new database."}, + }, parallelizable=False, ) def create_notion_database(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync + from app.data.action.integrations._helpers import pick_result, run_client_sync - return run_client_sync( + res = run_client_sync( "notion", "create_database", parent_page_id=input_data["parent_page_id"], @@ -312,6 +520,7 @@ def create_notion_database(input_data: dict) -> dict: icon=input_data.get("icon") or None, cover=input_data.get("cover") or None, ) + return pick_result(res, ["id", "url"]) @action( @@ -341,13 +550,19 @@ def create_notion_database(input_data: dict) -> dict: "example": False, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "{id, url} of the updated database.", + }, + }, parallelizable=False, ) def update_notion_database(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync + from app.data.action.integrations._helpers import pick_result, run_client_sync - return run_client_sync( + res = run_client_sync( "notion", "update_database", database_id=input_data["database_id"], @@ -356,6 +571,7 @@ def update_notion_database(input_data: dict) -> dict: properties=input_data.get("properties"), is_inline=input_data["is_inline"] if "is_inline" in input_data else None, ) + return pick_result(res, ["id", "url"]) @action( @@ -471,7 +687,7 @@ def _simplify(b: dict) -> dict: @action( name="append_notion_page_content", - description="Append content blocks to a Notion page (or any block).", + description="Append content blocks to a Notion page (or any block). Returns {appended: count, ids: [block ids]}.", action_sets=["notion_blocks", "notion"], input_schema={ "page_id": { @@ -485,18 +701,28 @@ def _simplify(b: dict) -> dict: "example": [], }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "description": "{appended, ids}."}, + }, parallelizable=False, ) def append_notion_page_content(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( + res = run_client_sync( "notion", "append_block_children", block_id=input_data["page_id"], children=input_data["children"], ) + if res.get("status") != "success": + return res + body = res.get("result") + if not isinstance(body, dict) or not isinstance(body.get("results"), list): + return res + ids = [b.get("id") for b in body["results"] if isinstance(b, dict)] + return {**res, "result": {"appended": len(ids), "ids": ids}} @action( @@ -526,18 +752,22 @@ def get_notion_block(input_data: dict) -> dict: "example": {"paragraph": {"rich_text": [{"text": {"content": "Updated"}}]}}, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "description": "{id} of the updated block."}, + }, parallelizable=False, ) def update_notion_block(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync + from app.data.action.integrations._helpers import pick_result, run_client_sync - return run_client_sync( + res = run_client_sync( "notion", "update_block", block_id=input_data["block_id"], block_update=input_data["block_update"], ) + return pick_result(res, ["id"]) @action( diff --git a/app/data/action/integrations/outlook/outlook_actions.py b/app/data/action/integrations/outlook/outlook_actions.py index 61da8556..e8b089b2 100644 --- a/app/data/action/integrations/outlook/outlook_actions.py +++ b/app/data/action/integrations/outlook/outlook_actions.py @@ -85,7 +85,7 @@ def list_outlook_emails(input_data: dict) -> dict: @action( name="get_outlook_email", - description="Get full details of a specific Outlook email by message ID.", + description="Get full details of a specific Outlook email by message ID. Body is plain text by default; set include_metadata for the HTML body.", action_sets=["outlook_mail", "outlook"], input_schema={ "message_id": { @@ -93,6 +93,11 @@ def list_outlook_emails(input_data: dict) -> dict: "description": "Outlook message ID.", "example": "AAMk...", }, + "include_metadata": { + "type": "boolean", + "description": "Return the HTML body instead of plain text (default false).", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -105,12 +110,13 @@ def get_outlook_email(input_data: dict) -> dict: unwrap_envelope=True, fail_message="Failed to get email.", message_id=input_data["message_id"], + include_metadata=bool(input_data.get("include_metadata", False)), ) @action( name="read_top_outlook_emails", - description="Read the top N recent Outlook emails with details.", + description="Read the top N recent Outlook emails with details. With full_body=true, bodies are plain text by default; set include_metadata for HTML bodies.", action_sets=["outlook_mail", "outlook"], input_schema={ "count": { @@ -123,6 +129,11 @@ def get_outlook_email(input_data: dict) -> dict: "description": "Include full body text.", "example": False, }, + "include_metadata": { + "type": "boolean", + "description": "With full_body, return HTML bodies instead of plain text (default false).", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -136,6 +147,7 @@ def read_top_outlook_emails(input_data: dict) -> dict: fail_message="Failed to read emails.", n=input_data.get("count", 5), full_body=input_data.get("full_body", False), + include_metadata=bool(input_data.get("include_metadata", False)), ) @@ -978,38 +990,101 @@ def list_outlook_folder_messages(input_data: dict) -> dict: @action( name="get_outlook_mailbox_settings", - description="Get the user's mailbox settings (timezone, locale, working hours, etc.).", + description="Get the user's mailbox settings. Default returns {timeZone, language, workingHours, automaticRepliesSetting.status}; set include_metadata for the raw settings.", action_sets=["outlook_settings"], - input_schema={}, + input_schema={ + "include_metadata": { + "type": "boolean", + "description": "Return the raw mailboxSettings resource (default false = lean).", + "example": False, + }, + }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_outlook_mailbox_settings(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( + res = run_client_sync( "outlook", "get_mailbox_settings", unwrap_envelope=True, fail_message="Failed to get settings.", ) + if not input_data.get("include_metadata") and res.get("status") == "success": + settings = res.get("result") + if isinstance(settings, dict): + lean = {"timeZone": settings.get("timeZone")} + language = settings.get("language") or {} + if language.get("displayName"): + lean["language"] = {"displayName": language["displayName"]} + wh = settings.get("workingHours") or {} + if wh: + lean["workingHours"] = { + k: wh.get(k) + for k in ("daysOfWeek", "startTime", "endTime") + if wh.get(k) is not None + } + ars = settings.get("automaticRepliesSetting") or {} + if ars.get("status"): + lean["automaticRepliesSetting"] = {"status": ars["status"]} + res = {**res, "result": lean} + return res @action( name="get_outlook_automatic_replies", - description="Get the current out-of-office / automatic reply settings.", + description="Get the current out-of-office / automatic reply settings. Default returns {status, schedule, reply messages as plain text}; set include_metadata for the raw setting.", action_sets=["outlook_settings", "outlook"], - input_schema={}, + input_schema={ + "include_metadata": { + "type": "boolean", + "description": "Return the raw automaticRepliesSetting (default false = lean, HTML stripped).", + "example": False, + }, + }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_outlook_automatic_replies(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( + res = run_client_sync( "outlook", "get_automatic_replies", unwrap_envelope=True, fail_message="Failed to get auto-replies.", ) + if not input_data.get("include_metadata") and res.get("status") == "success": + setting = res.get("result") + if isinstance(setting, dict): + import html + import re + + def _strip_html(value): + if not isinstance(value, str): + return value + return html.unescape(re.sub(r"<[^>]+>", "", value)).strip() + + res = { + **res, + "result": { + k: v + for k, v in { + "status": setting.get("status"), + "scheduledStartDateTime": setting.get( + "scheduledStartDateTime" + ), + "scheduledEndDateTime": setting.get("scheduledEndDateTime"), + "internalReplyMessage": _strip_html( + setting.get("internalReplyMessage") + ), + "externalReplyMessage": _strip_html( + setting.get("externalReplyMessage") + ), + }.items() + if v is not None + }, + } + return res @action( diff --git a/app/data/action/integrations/slack/slack_actions.py b/app/data/action/integrations/slack/slack_actions.py index a7cd8f15..15ef97e1 100644 --- a/app/data/action/integrations/slack/slack_actions.py +++ b/app/data/action/integrations/slack/slack_actions.py @@ -28,19 +28,26 @@ "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "{channel, ts} of the posted message.", + }, + }, parallelizable=False, ) async def send_slack_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "slack", "send_message", recipient=input_data["channel"], text=input_data["text"], thread_ts=input_data.get("thread_ts"), ) + return pick_result(res, ["channel", "ts"]) @action( @@ -69,13 +76,19 @@ async def send_slack_message(input_data: dict) -> dict: "example": [], }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "{channel, ts} of the edited message.", + }, + }, parallelizable=False, ) def update_slack_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync + from app.data.action.integrations._helpers import pick_result, run_client_sync - return run_client_sync( + res = run_client_sync( "slack", "update_message", channel=input_data["channel"], @@ -83,6 +96,7 @@ def update_slack_message(input_data: dict) -> dict: text=input_data["text"] if "text" in input_data else None, blocks=input_data["blocks"] if "blocks" in input_data else None, ) + return pick_result(res, ["channel", "ts"]) @action( @@ -139,13 +153,19 @@ def delete_slack_message(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "{message_ts} of the ephemeral message.", + }, + }, parallelizable=False, ) def send_slack_ephemeral(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync + from app.data.action.integrations._helpers import pick_result, run_client_sync - return run_client_sync( + res = run_client_sync( "slack", "post_ephemeral", channel=input_data["channel"], @@ -154,6 +174,7 @@ def send_slack_ephemeral(input_data: dict) -> dict: blocks=input_data["blocks"] if "blocks" in input_data else None, thread_ts=input_data.get("thread_ts") or None, ) + return pick_result(res, ["channel", "message_ts"]) @action( @@ -183,13 +204,19 @@ def send_slack_ephemeral(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "{scheduled_message_id, channel, post_at}.", + }, + }, parallelizable=False, ) def schedule_slack_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync + from app.data.action.integrations._helpers import pick_result, run_client_sync - return run_client_sync( + res = run_client_sync( "slack", "schedule_message", channel=input_data["channel"], @@ -198,6 +225,7 @@ def schedule_slack_message(input_data: dict) -> dict: blocks=input_data["blocks"] if "blocks" in input_data else None, thread_ts=input_data.get("thread_ts") or None, ) + return pick_result(res, ["scheduled_message_id", "channel", "post_at"]) @action( @@ -282,7 +310,7 @@ def get_slack_message_permalink(input_data: dict) -> dict: @action( name="get_slack_thread_replies", - description="Get all messages in a Slack thread (the parent + all replies).", + description="Get all messages in a Slack thread (the parent + all replies). Lean messages (user, text, ts, thread_ts, reply_count, reactions) by default; include_metadata=true returns full raw messages (blocks, team, bot_profile, ...).", action_sets=["slack_messages", "slack"], input_schema={ "channel": { @@ -296,19 +324,54 @@ def get_slack_message_permalink(input_data: dict) -> dict: "example": "", }, "limit": {"type": "integer", "description": "Max messages.", "example": 100}, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean messages. True: full raw.", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_slack_thread_replies(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( + res = run_client_sync( "slack", "get_thread_replies", channel=input_data["channel"], ts=input_data["ts"], limit=input_data.get("limit", 100), ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + body = res.get("result") + if not isinstance(body, dict): + return res + + def _lean(m: dict) -> dict: + out = {"user": m.get("user"), "text": m.get("text"), "ts": m.get("ts")} + if m.get("thread_ts"): + out["thread_ts"] = m["thread_ts"] + if m.get("reply_count") is not None: + out["reply_count"] = m["reply_count"] + if m.get("subtype"): + out["subtype"] = m["subtype"] + if m.get("reactions"): + out["reactions"] = [ + {"name": r.get("name"), "count": r.get("count")} + for r in m["reactions"] + if isinstance(r, dict) + ] + return out + + lean = { + "messages": [ + _lean(m) for m in body.get("messages", []) or [] if isinstance(m, dict) + ] + } + if body.get("has_more"): + lean["has_more"] = True + return {**res, "result": lean} # ----- Reactions ----- @@ -509,7 +572,7 @@ def list_slack_pins(input_data: dict) -> dict: @action( name="list_slack_channels", - description="List channels in the Slack workspace.", + description="List channels in the Slack workspace. Lean channels (id, name, is_private, is_archived, is_member, num_members, topic, purpose) by default; include_metadata=true returns full raw channel objects.", action_sets=["slack_conversations", "slack"], input_schema={ "limit": { @@ -517,6 +580,11 @@ def list_slack_pins(input_data: dict) -> dict: "description": "Max channels to return.", "example": 100, }, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean channels. True: full raw.", + "example": False, + }, }, output_schema={ "status": {"type": "string", "example": "success"}, @@ -526,7 +594,36 @@ def list_slack_pins(input_data: dict) -> dict: def list_slack_channels(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("slack", "list_channels", limit=input_data.get("limit", 100)) + res = run_client_sync("slack", "list_channels", limit=input_data.get("limit", 100)) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + body = res.get("result") + if not isinstance(body, dict): + return res + + def _lean(c: dict) -> dict: + out = { + "id": c.get("id"), + "name": c.get("name"), + "is_private": c.get("is_private"), + "is_archived": c.get("is_archived"), + "num_members": c.get("num_members"), + "topic": (c.get("topic") or {}).get("value"), + "purpose": (c.get("purpose") or {}).get("value"), + } + if "is_member" in c: + out["is_member"] = c.get("is_member") + return out + + lean = { + "channels": [ + _lean(c) for c in body.get("channels", []) or [] if isinstance(c, dict) + ] + } + cursor = (body.get("response_metadata") or {}).get("next_cursor") + if cursor: + lean["next_cursor"] = cursor + return {**res, "result": lean} @action( @@ -550,7 +647,7 @@ def get_slack_channel_info(input_data: dict) -> dict: @action( name="get_slack_channel_history", - description="Get message history from a Slack channel.", + description="Get message history from a Slack channel. Lean messages (user, text, ts, thread_ts, reply_count, reactions) by default; include_metadata=true returns full raw messages (blocks, team, bot_profile, ...).", action_sets=["slack_conversations", "slack"], input_schema={ "channel": { @@ -559,6 +656,11 @@ def get_slack_channel_info(input_data: dict) -> dict: "example": "C01234567", }, "limit": {"type": "integer", "description": "Max messages.", "example": 50}, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean messages. True: full raw.", + "example": False, + }, }, output_schema={ "status": {"type": "string", "example": "success"}, @@ -568,12 +670,42 @@ def get_slack_channel_info(input_data: dict) -> dict: def get_slack_channel_history(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( + res = run_client_sync( "slack", "get_channel_history", channel=input_data["channel"], limit=input_data.get("limit", 50), ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + body = res.get("result") + if not isinstance(body, dict): + return res + + def _lean(m: dict) -> dict: + out = {"user": m.get("user"), "text": m.get("text"), "ts": m.get("ts")} + if m.get("thread_ts"): + out["thread_ts"] = m["thread_ts"] + if m.get("reply_count") is not None: + out["reply_count"] = m["reply_count"] + if m.get("subtype"): + out["subtype"] = m["subtype"] + if m.get("reactions"): + out["reactions"] = [ + {"name": r.get("name"), "count": r.get("count")} + for r in m["reactions"] + if isinstance(r, dict) + ] + return out + + lean = { + "messages": [ + _lean(m) for m in body.get("messages", []) or [] if isinstance(m, dict) + ] + } + if body.get("has_more"): + lean["has_more"] = True + return {**res, "result": lean} @action( @@ -912,7 +1044,7 @@ def upload_slack_file(input_data: dict) -> dict: @action( name="list_slack_files", - description="List files in the workspace (optionally filter by channel, user, or types like 'images,zips').", + description="List files in the workspace (optionally filter by channel, user, or types like 'images,zips'). Lean files (id, name, title, mimetype, size, created, user, permalink) by default; include_metadata=true returns full raw file objects (thumbnails, share info, ...).", action_sets=["slack_files", "slack"], input_schema={ "channel": { @@ -932,13 +1064,18 @@ def upload_slack_file(input_data: dict) -> dict: }, "count": {"type": "integer", "description": "Max results.", "example": 100}, "page": {"type": "integer", "description": "Page number.", "example": 1}, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean files. True: full raw.", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def list_slack_files(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( + res = run_client_sync( "slack", "list_files", channel=input_data.get("channel") or None, @@ -947,6 +1084,30 @@ def list_slack_files(input_data: dict) -> dict: count=input_data.get("count", 100), page=input_data.get("page", 1), ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + body = res.get("result") + if not isinstance(body, dict): + return res + lean = { + "files": [ + { + "id": f.get("id"), + "name": f.get("name"), + "title": f.get("title"), + "mimetype": f.get("mimetype"), + "size": f.get("size"), + "created": f.get("created"), + "user": f.get("user"), + "permalink": f.get("permalink"), + } + for f in body.get("files", []) or [] + if isinstance(f, dict) + ] + } + if isinstance(body.get("paging"), dict): + lean["paging"] = body["paging"] + return {**res, "result": lean} @action( @@ -987,7 +1148,7 @@ def delete_slack_file(input_data: dict) -> dict: @action( name="list_slack_users", - description="List users in the Slack workspace.", + description="List users in the Slack workspace. Lean members (id, name, real_name, display_name, email, is_bot, is_admin, tz, deleted) by default; include_metadata=true returns full raw user objects (avatar URLs, full profile, ...).", action_sets=["slack_users", "slack"], input_schema={ "limit": { @@ -995,6 +1156,11 @@ def delete_slack_file(input_data: dict) -> dict: "description": "Max users to return.", "example": 100, }, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean members. True: full raw.", + "example": False, + }, }, output_schema={ "status": {"type": "string", "example": "success"}, @@ -1004,7 +1170,38 @@ def delete_slack_file(input_data: dict) -> dict: def list_slack_users(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("slack", "list_users", limit=input_data.get("limit", 100)) + res = run_client_sync("slack", "list_users", limit=input_data.get("limit", 100)) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + body = res.get("result") + if not isinstance(body, dict): + return res + + def _lean(m: dict) -> dict: + profile = m.get("profile") or {} + out = { + "id": m.get("id"), + "name": m.get("name"), + "real_name": m.get("real_name") or profile.get("real_name"), + "display_name": profile.get("display_name"), + "email": profile.get("email"), + "is_bot": m.get("is_bot"), + "tz": m.get("tz"), + "deleted": m.get("deleted"), + } + if "is_admin" in m: + out["is_admin"] = m.get("is_admin") + return out + + lean = { + "members": [ + _lean(m) for m in body.get("members", []) or [] if isinstance(m, dict) + ] + } + cursor = (body.get("response_metadata") or {}).get("next_cursor") + if cursor: + lean["next_cursor"] = cursor + return {**res, "result": lean} @action( @@ -1333,7 +1530,7 @@ def get_slack_team_info(input_data: dict) -> dict: @action( name="search_slack_messages", - description="Search for messages in the Slack workspace (requires user token / search:read).", + description="Search for messages in the Slack workspace (requires user token / search:read). Lean matches (user, text, ts, channel {id, name}, permalink) by default; include_metadata=true returns full raw matches (blocks, score, pagination, ...).", action_sets=["slack_workspace", "slack"], input_schema={ "query": { @@ -1342,18 +1539,50 @@ def get_slack_team_info(input_data: dict) -> dict: "example": "project update", }, "count": {"type": "integer", "description": "Max results.", "example": 20}, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean matches. True: full raw.", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def search_slack_messages(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( + res = run_client_sync( "slack", "search_messages", query=input_data["query"], count=input_data.get("count", 20), ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + body = res.get("result") + if not isinstance(body, dict) or not isinstance(body.get("messages"), dict): + return res + msgs = body["messages"] + + def _lean(m: dict) -> dict: + ch = m.get("channel") or {} + out = { + "user": m.get("user"), + "text": m.get("text"), + "ts": m.get("ts"), + "channel": {"id": ch.get("id"), "name": ch.get("name")}, + "permalink": m.get("permalink"), + } + if m.get("thread_ts"): + out["thread_ts"] = m["thread_ts"] + return out + + lean = { + "total": msgs.get("total"), + "matches": [ + _lean(m) for m in msgs.get("matches", []) or [] if isinstance(m, dict) + ], + } + return {**res, "result": lean} @action( diff --git a/app/data/action/integrations/stripe/stripe_actions.py b/app/data/action/integrations/stripe/stripe_actions.py index 2b9b0d9f..7eb8c1f8 100644 --- a/app/data/action/integrations/stripe/stripe_actions.py +++ b/app/data/action/integrations/stripe/stripe_actions.py @@ -81,7 +81,7 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "list_customers", limit=input_data.get("limit", 10), @@ -92,6 +92,19 @@ def _csv(v): created_lte=input_data.get("created_lte"), expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( @@ -120,17 +133,30 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "get_customer", customer_id=input_data["customer_id"], expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( name="create_stripe_customer", - description="Create a Stripe customer. At minimum pass email or name. Returns the new cus_… ID.", + description="Create a Stripe customer. At minimum pass email or name. Returns the new cus_… ID. Returns only {id, status}.", action_sets=["stripe_customers", "stripe"], input_schema={ "email": { @@ -192,13 +218,13 @@ def _csv(v): "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def create_stripe_customer(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "create_customer", email=input_data.get("email") or None, @@ -213,11 +239,12 @@ async def create_stripe_customer(input_data: dict) -> dict: tax_exempt=input_data.get("tax_exempt") or None, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( name="update_stripe_customer", - description="Update a Stripe customer. 'properties' is the flat update dict (email, name, phone, address, metadata, …).", + description="Update a Stripe customer. 'properties' is the flat update dict (email, name, phone, address, metadata, …). Returns only {id, status}.", action_sets=["stripe_customers", "stripe"], input_schema={ "customer_id": { @@ -236,19 +263,20 @@ async def create_stripe_customer(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def update_stripe_customer(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "update_customer", customer_id=input_data["customer_id"], properties=input_data.get("properties") or {}, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( @@ -311,7 +339,7 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "search_customers", query=input_data["query"], @@ -319,6 +347,19 @@ def _csv(v): page=input_data.get("page") or None, expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res # ================================================================== @@ -369,7 +410,7 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "list_payment_intents", limit=input_data.get("limit", 10), @@ -380,6 +421,19 @@ def _csv(v): created_lte=input_data.get("created_lte") or None, expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( @@ -408,17 +462,30 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "get_payment_intent", payment_intent_id=input_data["payment_intent_id"], expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( name="create_stripe_payment_intent", - description="Create a PaymentIntent. 'amount' is in the smallest currency unit ($10 USD = 1000). Defaults to automatic_payment_methods when neither payment_method nor payment_method_types is set.", + description="Create a PaymentIntent. 'amount' is in the smallest currency unit ($10 USD = 1000). Defaults to automatic_payment_methods when neither payment_method nor payment_method_types is set. Returns only {id, status}.", action_sets=["stripe_payments", "stripe"], input_schema={ "amount": { @@ -492,13 +559,13 @@ def _csv(v): "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def create_stripe_payment_intent(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "create_payment_intent", amount=input_data["amount"], @@ -516,11 +583,12 @@ async def create_stripe_payment_intent(input_data: dict) -> dict: metadata=input_data.get("metadata") or None, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( name="update_stripe_payment_intent", - description="Update a PaymentIntent's properties (amount, metadata, description, etc.). Cannot update once succeeded.", + description="Update a PaymentIntent's properties (amount, metadata, description, etc.). Cannot update once succeeded. Returns only {id, status}.", action_sets=["stripe_payments"], input_schema={ "payment_intent_id": { @@ -539,24 +607,25 @@ async def create_stripe_payment_intent(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def update_stripe_payment_intent(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "update_payment_intent", payment_intent_id=input_data["payment_intent_id"], properties=input_data.get("properties") or {}, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( name="confirm_stripe_payment_intent", - description="Confirm a PaymentIntent server-side. For off-session repeat charges or completing a flow started client-side. May return 'requires_action' (3DS/SCA).", + description="Confirm a PaymentIntent server-side. For off-session repeat charges or completing a flow started client-side. May return 'requires_action' (3DS/SCA). Returns only {id, status}.", action_sets=["stripe_payments"], input_schema={ "payment_intent_id": { @@ -590,13 +659,13 @@ async def update_stripe_payment_intent(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def confirm_stripe_payment_intent(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "confirm_payment_intent", payment_intent_id=input_data["payment_intent_id"], @@ -606,11 +675,12 @@ async def confirm_stripe_payment_intent(input_data: dict) -> dict: setup_future_usage=input_data.get("setup_future_usage") or None, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( name="capture_stripe_payment_intent", - description="Capture funds for a PaymentIntent previously authorized with capture_method='manual'. Optional partial capture via amount_to_capture.", + description="Capture funds for a PaymentIntent previously authorized with capture_method='manual'. Optional partial capture via amount_to_capture. Returns only {id, status}.", action_sets=["stripe_payments", "stripe"], input_schema={ "payment_intent_id": { @@ -634,13 +704,13 @@ async def confirm_stripe_payment_intent(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def capture_stripe_payment_intent(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "capture_payment_intent", payment_intent_id=input_data["payment_intent_id"], @@ -648,11 +718,12 @@ async def capture_stripe_payment_intent(input_data: dict) -> dict: statement_descriptor=input_data.get("statement_descriptor") or None, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( name="cancel_stripe_payment_intent", - description="Cancel a PaymentIntent. Only allowed for PIs in requires_payment_method, requires_capture, requires_confirmation, or requires_action.", + description="Cancel a PaymentIntent. Only allowed for PIs in requires_payment_method, requires_capture, requires_confirmation, or requires_action. Returns only {id, status}.", action_sets=["stripe_payments"], input_schema={ "payment_intent_id": { @@ -671,19 +742,20 @@ async def capture_stripe_payment_intent(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def cancel_stripe_payment_intent(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "cancel_payment_intent", payment_intent_id=input_data["payment_intent_id"], cancellation_reason=input_data.get("cancellation_reason") or None, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( @@ -722,7 +794,7 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "search_payment_intents", query=input_data["query"], @@ -730,6 +802,19 @@ def _csv(v): page=input_data.get("page") or None, expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( @@ -775,7 +860,7 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "list_charges", limit=input_data.get("limit", 10), @@ -786,6 +871,19 @@ def _csv(v): transfer_group=input_data.get("transfer_group") or None, expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( @@ -814,17 +912,30 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "get_charge", charge_id=input_data["charge_id"], expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( name="create_stripe_refund", - description="Refund a PaymentIntent or Charge. Pass exactly ONE of payment_intent / charge. Omit 'amount' for a full refund.", + description="Refund a PaymentIntent or Charge. Pass exactly ONE of payment_intent / charge. Omit 'amount' for a full refund. Returns only {id, status}.", action_sets=["stripe_payments", "stripe"], input_schema={ "payment_intent": { @@ -858,13 +969,13 @@ def _csv(v): "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def create_stripe_refund(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "create_refund", payment_intent=input_data.get("payment_intent") or None, @@ -874,6 +985,7 @@ async def create_stripe_refund(input_data: dict) -> dict: metadata=input_data.get("metadata") or None, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( @@ -902,12 +1014,25 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "get_refund", refund_id=input_data["refund_id"], expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( @@ -948,7 +1073,7 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "list_refunds", limit=input_data.get("limit", 10), @@ -958,6 +1083,19 @@ def _csv(v): charge=input_data.get("charge") or None, expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res # ================================================================== @@ -1003,7 +1141,7 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "list_payment_methods", customer=input_data.get("customer") or None, @@ -1013,6 +1151,19 @@ def _csv(v): ending_before=input_data.get("ending_before") or None, expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( @@ -1041,17 +1192,30 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "get_payment_method", payment_method_id=input_data["payment_method_id"], expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( name="attach_stripe_payment_method", - description="Attach a PaymentMethod to a Customer so it can be used for future off-session charges.", + description="Attach a PaymentMethod to a Customer so it can be used for future off-session charges. Returns only {id, status}.", action_sets=["stripe_payment_methods"], input_schema={ "payment_method_id": { @@ -1070,24 +1234,25 @@ def _csv(v): "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def attach_stripe_payment_method(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "attach_payment_method", payment_method_id=input_data["payment_method_id"], customer=input_data["customer"], idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( name="detach_stripe_payment_method", - description="Detach a PaymentMethod from its Customer. Future charges against it will fail.", + description="Detach a PaymentMethod from its Customer. Future charges against it will fail. Returns only {id, status}.", action_sets=["stripe_payment_methods"], input_schema={ "payment_method_id": { @@ -1101,23 +1266,24 @@ async def attach_stripe_payment_method(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def detach_stripe_payment_method(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "detach_payment_method", payment_method_id=input_data["payment_method_id"], idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( name="update_stripe_payment_method", - description="Update a PaymentMethod's metadata or billing details. Card brand/number CANNOT be updated.", + description="Update a PaymentMethod's metadata or billing details. Card brand/number CANNOT be updated. Returns only {id, status}.", action_sets=["stripe_payment_methods"], input_schema={ "payment_method_id": { @@ -1139,19 +1305,20 @@ async def detach_stripe_payment_method(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def update_stripe_payment_method(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "update_payment_method", payment_method_id=input_data["payment_method_id"], properties=input_data.get("properties") or {}, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) # ================================================================== @@ -1197,7 +1364,7 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "list_products", limit=input_data.get("limit", 10), @@ -1207,6 +1374,19 @@ def _csv(v): ids=input_data.get("ids") or None, expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( @@ -1235,17 +1415,30 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "get_product", product_id=input_data["product_id"], expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( name="create_stripe_product", - description="Create a Product. Pass default_price_data to create the product and its first price atomically.", + description="Create a Product. Pass default_price_data to create the product and its first price atomically. Returns only {id, status}.", action_sets=["stripe_products", "stripe"], input_schema={ "name": { @@ -1309,13 +1502,13 @@ def _csv(v): "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def create_stripe_product(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "create_product", name=input_data["name"], @@ -1331,11 +1524,12 @@ async def create_stripe_product(input_data: dict) -> dict: url=input_data.get("url") or None, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( name="update_stripe_product", - description="Update a Product's properties.", + description="Update a Product's properties. Returns only {id, status}.", action_sets=["stripe_products"], input_schema={ "product_id": { @@ -1354,19 +1548,20 @@ async def create_stripe_product(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def update_stripe_product(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "update_product", product_id=input_data["product_id"], properties=input_data.get("properties") or {}, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( @@ -1446,7 +1641,7 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "list_prices", limit=input_data.get("limit", 10), @@ -1459,6 +1654,19 @@ def _csv(v): recurring_interval=input_data.get("recurring_interval") or None, expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( @@ -1487,17 +1695,30 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "get_price", price_id=input_data["price_id"], expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( name="create_stripe_price", - description="Create a Price for an existing Product (or set product_data inline). Pass 'recurring' for subscriptions, omit for one-time. unit_amount is in smallest currency unit.", + description="Create a Price for an existing Product (or set product_data inline). Pass 'recurring' for subscriptions, omit for one-time. unit_amount is in smallest currency unit. Returns only {id, status}.", action_sets=["stripe_products", "stripe"], input_schema={ "currency": { @@ -1561,13 +1782,13 @@ def _csv(v): "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def create_stripe_price(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "create_price", currency=input_data.get("currency") or None, @@ -1583,11 +1804,12 @@ async def create_stripe_price(input_data: dict) -> dict: metadata=input_data.get("metadata") or None, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( name="update_stripe_price", - description="Update a Price. Most fields are immutable; nickname, active, metadata, tax_behavior are updatable.", + description="Update a Price. Most fields are immutable; nickname, active, metadata, tax_behavior are updatable. Returns only {id, status}.", action_sets=["stripe_products"], input_schema={ "price_id": { @@ -1606,19 +1828,20 @@ async def create_stripe_price(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def update_stripe_price(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "update_price", price_id=input_data["price_id"], properties=input_data.get("properties") or {}, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) # ================================================================== @@ -1684,7 +1907,7 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "list_invoices", limit=input_data.get("limit", 10), @@ -1698,6 +1921,19 @@ def _csv(v): created_lte=input_data.get("created_lte") or None, expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( @@ -1726,17 +1962,30 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "get_invoice", invoice_id=input_data["invoice_id"], expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( name="create_stripe_invoice", - description="Create a draft Invoice for a customer. Add line items first via create_stripe_invoice_item, then finalize via finalize_stripe_invoice.", + description="Create a draft Invoice for a customer. Add line items first via create_stripe_invoice_item, then finalize via finalize_stripe_invoice. Returns only {id, status}.", action_sets=["stripe_invoices", "stripe"], input_schema={ "customer": { @@ -1800,13 +2049,13 @@ def _csv(v): "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def create_stripe_invoice(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "create_invoice", customer=input_data["customer"], @@ -1823,11 +2072,12 @@ async def create_stripe_invoice(input_data: dict) -> dict: or None, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( name="update_stripe_invoice", - description="Update an Invoice. Most fields are only mutable while the invoice is in 'draft' status.", + description="Update an Invoice. Most fields are only mutable while the invoice is in 'draft' status. Returns only {id, status}.", action_sets=["stripe_invoices"], input_schema={ "invoice_id": { @@ -1846,19 +2096,20 @@ async def create_stripe_invoice(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def update_stripe_invoice(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "update_invoice", invoice_id=input_data["invoice_id"], properties=input_data.get("properties") or {}, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( @@ -1887,7 +2138,7 @@ async def delete_stripe_invoice(input_data: dict) -> dict: @action( name="finalize_stripe_invoice", - description="Finalize a draft Invoice — locks line items and computes totals. Once finalized the invoice is open and can be sent or paid.", + description="Finalize a draft Invoice — locks line items and computes totals. Once finalized the invoice is open and can be sent or paid. Returns only {id, status, hosted_invoice_url}.", action_sets=["stripe_invoices", "stripe"], input_schema={ "invoice_id": { @@ -1906,24 +2157,25 @@ async def delete_stripe_invoice(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status, hosted_invoice_url}."}}, parallelizable=False, ) async def finalize_stripe_invoice(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "finalize_invoice", invoice_id=input_data["invoice_id"], auto_advance=input_data.get("auto_advance"), idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status", "hosted_invoice_url"]) @action( name="send_stripe_invoice", - description="Email a finalized Invoice to the customer. Only valid for invoices with collection_method='send_invoice'.", + description="Email a finalized Invoice to the customer. Only valid for invoices with collection_method='send_invoice'. Returns only {id, status, hosted_invoice_url}.", action_sets=["stripe_invoices", "stripe"], input_schema={ "invoice_id": { @@ -1937,23 +2189,24 @@ async def finalize_stripe_invoice(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status, hosted_invoice_url}."}}, parallelizable=False, ) async def send_stripe_invoice(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "send_invoice", invoice_id=input_data["invoice_id"], idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status", "hosted_invoice_url"]) @action( name="pay_stripe_invoice", - description="Attempt payment on an open Invoice. Optionally specify a payment method or mark paid_out_of_band for offline payments.", + description="Attempt payment on an open Invoice. Optionally specify a payment method or mark paid_out_of_band for offline payments. Returns only {id, status}.", action_sets=["stripe_invoices"], input_schema={ "invoice_id": { @@ -1987,13 +2240,13 @@ async def send_stripe_invoice(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def pay_stripe_invoice(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "pay_invoice", invoice_id=input_data["invoice_id"], @@ -2003,11 +2256,12 @@ async def pay_stripe_invoice(input_data: dict) -> dict: forgive=input_data.get("forgive"), idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( name="void_stripe_invoice", - description="Void a finalized open Invoice. Irreversible. Use this to cancel a finalized invoice that should never be paid.", + description="Void a finalized open Invoice. Irreversible. Use this to cancel a finalized invoice that should never be paid. Returns only {id, status}.", action_sets=["stripe_invoices"], input_schema={ "invoice_id": { @@ -2021,23 +2275,24 @@ async def pay_stripe_invoice(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def void_stripe_invoice(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "void_invoice", invoice_id=input_data["invoice_id"], idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( name="mark_stripe_invoice_uncollectible", - description="Mark an open Invoice as uncollectible (write-off). The modern alternative to void for invoices you've decided not to collect.", + description="Mark an open Invoice as uncollectible (write-off). The modern alternative to void for invoices you've decided not to collect. Returns only {id, status}.", action_sets=["stripe_invoices"], input_schema={ "invoice_id": { @@ -2051,18 +2306,19 @@ async def void_stripe_invoice(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def mark_stripe_invoice_uncollectible(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "mark_invoice_uncollectible", invoice_id=input_data["invoice_id"], idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( @@ -2106,7 +2362,7 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "get_upcoming_invoice", customer=input_data.get("customer") or None, @@ -2115,6 +2371,19 @@ def _csv(v): coupon=input_data.get("coupon") or None, expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( @@ -2160,7 +2429,7 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "list_invoice_items", limit=input_data.get("limit", 10), @@ -2171,11 +2440,24 @@ def _csv(v): pending=input_data.get("pending"), expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( name="create_stripe_invoice_item", - description="Create an Invoice Item for a customer. If 'invoice' is set, attaches to that draft invoice; otherwise it's pending for the next invoice.", + description="Create an Invoice Item for a customer. If 'invoice' is set, attaches to that draft invoice; otherwise it's pending for the next invoice. Returns only {id, status}.", action_sets=["stripe_invoices", "stripe"], input_schema={ "customer": { @@ -2234,13 +2516,13 @@ def _csv(v): "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def create_stripe_invoice_item(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "create_invoice_item", customer=input_data["customer"], @@ -2255,6 +2537,7 @@ async def create_stripe_invoice_item(input_data: dict) -> dict: tax_rates=input_data.get("tax_rates") or None, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( @@ -2334,7 +2617,7 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "list_subscriptions", limit=input_data.get("limit", 10), @@ -2346,6 +2629,19 @@ def _csv(v): collection_method=input_data.get("collection_method") or None, expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( @@ -2374,17 +2670,30 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "get_subscription", subscription_id=input_data["subscription_id"], expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( name="create_stripe_subscription", - description="Create a Subscription for a customer. 'items' is a list like [{price: 'price_xxx', quantity: 1}]. Requires the customer to have a default payment method or one provided.", + description="Create a Subscription for a customer. 'items' is a list like [{price: 'price_xxx', quantity: 1}]. Requires the customer to have a default payment method or one provided. Returns only {id, status}.", action_sets=["stripe_subscriptions", "stripe"], input_schema={ "customer": { @@ -2463,13 +2772,13 @@ def _csv(v): "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def create_stripe_subscription(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "create_subscription", customer=input_data["customer"], @@ -2488,11 +2797,12 @@ async def create_stripe_subscription(input_data: dict) -> dict: metadata=input_data.get("metadata") or None, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( name="update_stripe_subscription", - description="Update a Subscription. To change items: pass items=[{id: 'si_…', price: 'price_xxx', quantity: 1}]. To schedule cancel at period end: properties={'cancel_at_period_end': true}.", + description="Update a Subscription. To change items: pass items=[{id: 'si_…', price: 'price_xxx', quantity: 1}]. To schedule cancel at period end: properties={'cancel_at_period_end': true}. Returns only {id, status}.", action_sets=["stripe_subscriptions"], input_schema={ "subscription_id": { @@ -2511,24 +2821,25 @@ async def create_stripe_subscription(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def update_stripe_subscription(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "update_subscription", subscription_id=input_data["subscription_id"], properties=input_data.get("properties") or {}, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( name="cancel_stripe_subscription", - description="Cancel a Subscription IMMEDIATELY (DELETE). For end-of-period cancellation use update_stripe_subscription({cancel_at_period_end: true}) instead.", + description="Cancel a Subscription IMMEDIATELY (DELETE). For end-of-period cancellation use update_stripe_subscription({cancel_at_period_end: true}) instead. Returns only {id, status}.", action_sets=["stripe_subscriptions", "stripe"], input_schema={ "subscription_id": { @@ -2557,13 +2868,13 @@ async def update_stripe_subscription(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def cancel_stripe_subscription(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "cancel_subscription", subscription_id=input_data["subscription_id"], @@ -2572,11 +2883,12 @@ async def cancel_stripe_subscription(input_data: dict) -> dict: cancellation_details=input_data.get("cancellation_details") or None, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( name="resume_stripe_subscription", - description="Resume a paused Subscription.", + description="Resume a paused Subscription. Returns only {id, status}.", action_sets=["stripe_subscriptions"], input_schema={ "subscription_id": { @@ -2600,13 +2912,13 @@ async def cancel_stripe_subscription(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def resume_stripe_subscription(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "resume_subscription", subscription_id=input_data["subscription_id"], @@ -2614,6 +2926,7 @@ async def resume_stripe_subscription(input_data: dict) -> dict: proration_behavior=input_data.get("proration_behavior") or None, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) # ================================================================== @@ -2669,7 +2982,7 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "list_checkout_sessions", limit=input_data.get("limit", 10), @@ -2681,6 +2994,19 @@ def _csv(v): status=input_data.get("status") or None, expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( @@ -2709,17 +3035,30 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "get_checkout_session", session_id=input_data["session_id"], expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( name="create_stripe_checkout_session", - description="Create a hosted Checkout Session. 'mode' is 'payment' (one-time), 'subscription', or 'setup'. Returns the hosted page URL in 'url'.", + description="Create a hosted Checkout Session. 'mode' is 'payment' (one-time), 'subscription', or 'setup'. Returns the hosted page URL in 'url'. Returns only {id, status, url}.", action_sets=["stripe_checkout", "stripe"], input_schema={ "mode": { @@ -2793,13 +3132,13 @@ def _csv(v): "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status, url}."}}, parallelizable=False, ) async def create_stripe_checkout_session(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "create_checkout_session", mode=input_data["mode"], @@ -2817,11 +3156,12 @@ async def create_stripe_checkout_session(input_data: dict) -> dict: metadata=input_data.get("metadata") or None, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status", "url"]) @action( name="expire_stripe_checkout_session", - description="Expire an open Checkout Session — the URL becomes invalid for the customer.", + description="Expire an open Checkout Session — the URL becomes invalid for the customer. Returns only {id, status, url}.", action_sets=["stripe_checkout"], input_schema={ "session_id": { @@ -2835,18 +3175,19 @@ async def create_stripe_checkout_session(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status, url}."}}, parallelizable=False, ) async def expire_stripe_checkout_session(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "expire_checkout_session", session_id=input_data["session_id"], idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status", "url"]) @action( @@ -2882,7 +3223,7 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "list_checkout_line_items", session_id=input_data["session_id"], @@ -2891,6 +3232,19 @@ def _csv(v): ending_before=input_data.get("ending_before") or None, expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res # ================================================================== @@ -2931,7 +3285,7 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "list_payment_links", limit=input_data.get("limit", 10), @@ -2940,6 +3294,19 @@ def _csv(v): active=input_data.get("active"), expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( @@ -2968,17 +3335,30 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "get_payment_link", payment_link_id=input_data["payment_link_id"], expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( name="create_stripe_payment_link", - description="Create a Payment Link — a shareable URL that opens Stripe's hosted checkout. Persists across sessions; useful for invoices, donations, embedded buttons.", + description="Create a Payment Link — a shareable URL that opens Stripe's hosted checkout. Persists across sessions; useful for invoices, donations, embedded buttons. Returns only {id, status, url}.", action_sets=["stripe_payment_links", "stripe"], input_schema={ "line_items": { @@ -3032,13 +3412,13 @@ def _csv(v): "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status, url}."}}, parallelizable=False, ) async def create_stripe_payment_link(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "create_payment_link", line_items=input_data["line_items"], @@ -3053,11 +3433,12 @@ async def create_stripe_payment_link(input_data: dict) -> dict: or None, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status", "url"]) @action( name="update_stripe_payment_link", - description="Update a Payment Link. Limited to active flag, line item quantities, after_completion, metadata, etc.", + description="Update a Payment Link. Limited to active flag, line item quantities, after_completion, metadata, etc. Returns only {id, status, url}.", action_sets=["stripe_payment_links"], input_schema={ "payment_link_id": { @@ -3076,24 +3457,25 @@ async def create_stripe_payment_link(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status, url}."}}, parallelizable=False, ) async def update_stripe_payment_link(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "update_payment_link", payment_link_id=input_data["payment_link_id"], properties=input_data.get("properties") or {}, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status", "url"]) @action( name="create_stripe_billing_portal_session", - description="Create a Stripe Customer Portal session — short-lived URL where the customer manages their own subscriptions, payment methods, and invoices.", + description="Create a Stripe Customer Portal session — short-lived URL where the customer manages their own subscriptions, payment methods, and invoices. Returns only {id, status, url}.", action_sets=["stripe_payment_links"], input_schema={ "customer": { @@ -3122,13 +3504,13 @@ async def update_stripe_payment_link(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status, url}."}}, parallelizable=False, ) async def create_stripe_billing_portal_session(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "create_billing_portal_session", customer=input_data["customer"], @@ -3137,6 +3519,7 @@ async def create_stripe_billing_portal_session(input_data: dict) -> dict: locale=input_data.get("locale") or None, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status", "url"]) # ================================================================== @@ -3172,7 +3555,7 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "list_coupons", limit=input_data.get("limit", 10), @@ -3180,6 +3563,19 @@ def _csv(v): ending_before=input_data.get("ending_before") or None, expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( @@ -3208,17 +3604,30 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "get_coupon", coupon_id=input_data["coupon_id"], expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( name="create_stripe_coupon", - description="Create a Coupon. Pass exactly ONE of amount_off (with currency) or percent_off. duration='once' charges discount one period; 'repeating' requires duration_in_months; 'forever' lasts the subscription's lifetime.", + description="Create a Coupon. Pass exactly ONE of amount_off (with currency) or percent_off. duration='once' charges discount one period; 'repeating' requires duration_in_months; 'forever' lasts the subscription's lifetime. Returns only {id, status}.", action_sets=["stripe_promotions"], input_schema={ "id": { @@ -3277,13 +3686,13 @@ def _csv(v): "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def create_stripe_coupon(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "create_coupon", id=input_data.get("id") or None, @@ -3298,11 +3707,12 @@ async def create_stripe_coupon(input_data: dict) -> dict: metadata=input_data.get("metadata") or None, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( name="update_stripe_coupon", - description="Update a Coupon. Only 'name' and 'metadata' are mutable — duration/amount/percent/currency are write-once.", + description="Update a Coupon. Only 'name' and 'metadata' are mutable — duration/amount/percent/currency are write-once. Returns only {id, status}.", action_sets=["stripe_promotions"], input_schema={ "coupon_id": { @@ -3321,19 +3731,20 @@ async def create_stripe_coupon(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def update_stripe_coupon(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "update_coupon", coupon_id=input_data["coupon_id"], properties=input_data.get("properties") or {}, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( @@ -3404,7 +3815,7 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "list_promotion_codes", limit=input_data.get("limit", 10), @@ -3416,11 +3827,24 @@ def _csv(v): customer=input_data.get("customer") or None, expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( name="create_stripe_promotion_code", - description="Create a Promotion Code (customer-facing string) for an existing Coupon. Optionally restrict to a customer, first-time-only, expiry, max redemptions.", + description="Create a Promotion Code (customer-facing string) for an existing Coupon. Optionally restrict to a customer, first-time-only, expiry, max redemptions. Returns only {id, status}.", action_sets=["stripe_promotions"], input_schema={ "coupon": { @@ -3469,13 +3893,13 @@ def _csv(v): "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def create_stripe_promotion_code(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "create_promotion_code", coupon=input_data["coupon"], @@ -3488,11 +3912,12 @@ async def create_stripe_promotion_code(input_data: dict) -> dict: metadata=input_data.get("metadata") or None, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( name="update_stripe_promotion_code", - description="Update a Promotion Code. Only active and metadata are mutable.", + description="Update a Promotion Code. Only active and metadata are mutable. Returns only {id, status}.", action_sets=["stripe_promotions"], input_schema={ "promotion_code_id": { @@ -3511,19 +3936,20 @@ async def create_stripe_promotion_code(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def update_stripe_promotion_code(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "update_promotion_code", promotion_code_id=input_data["promotion_code_id"], properties=input_data.get("properties") or {}, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) # ================================================================== @@ -3569,7 +3995,7 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "list_disputes", limit=input_data.get("limit", 10), @@ -3579,6 +4005,19 @@ def _csv(v): payment_intent=input_data.get("payment_intent") or None, expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( @@ -3607,17 +4046,30 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "get_dispute", dispute_id=input_data["dispute_id"], expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( name="update_stripe_dispute", - description="Save (or submit) dispute evidence. Pass submit=True to finalize and submit to the card network — IRREVERSIBLE. Without submit, saves as draft for further edits.", + description="Save (or submit) dispute evidence. Pass submit=True to finalize and submit to the card network — IRREVERSIBLE. Without submit, saves as draft for further edits. Returns only {id, status}.", action_sets=["stripe_disputes"], input_schema={ "dispute_id": { @@ -3649,13 +4101,13 @@ def _csv(v): "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def update_stripe_dispute(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "update_dispute", dispute_id=input_data["dispute_id"], @@ -3664,11 +4116,12 @@ async def update_stripe_dispute(input_data: dict) -> dict: metadata=input_data.get("metadata") or None, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( name="close_stripe_dispute", - description="Forfeit a dispute — accept the chargeback as final. Once closed, cannot be reopened.", + description="Forfeit a dispute — accept the chargeback as final. Once closed, cannot be reopened. Returns only {id, status}.", action_sets=["stripe_disputes"], input_schema={ "dispute_id": { @@ -3682,18 +4135,19 @@ async def update_stripe_dispute(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def close_stripe_dispute(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "close_dispute", dispute_id=input_data["dispute_id"], idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) # ================================================================== @@ -3739,7 +4193,7 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "list_payouts", limit=input_data.get("limit", 10), @@ -3749,6 +4203,19 @@ def _csv(v): destination=input_data.get("destination") or None, expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( @@ -3773,17 +4240,30 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "get_payout", payout_id=input_data["payout_id"], expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( name="create_stripe_payout", - description="Trigger a payout from your Stripe balance to your bank. method='standard' (1-2 day) or 'instant' (fee, requires eligibility).", + description="Trigger a payout from your Stripe balance to your bank. method='standard' (1-2 day) or 'instant' (fee, requires eligibility). Returns only {id, status}.", action_sets=["stripe_payouts"], input_schema={ "amount": { @@ -3832,13 +4312,13 @@ def _csv(v): "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def create_stripe_payout(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "create_payout", amount=input_data["amount"], @@ -3851,11 +4331,12 @@ async def create_stripe_payout(input_data: dict) -> dict: metadata=input_data.get("metadata") or None, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( name="cancel_stripe_payout", - description="Cancel a Payout. Only valid if status is 'pending'.", + description="Cancel a Payout. Only valid if status is 'pending'. Returns only {id, status}.", action_sets=["stripe_payouts"], input_schema={ "payout_id": {"type": "string", "description": "Payout ID.", "example": "po_…"}, @@ -3865,18 +4346,19 @@ async def create_stripe_payout(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def cancel_stripe_payout(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "cancel_payout", payout_id=input_data["payout_id"], idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( @@ -3889,7 +4371,20 @@ async def cancel_stripe_payout(input_data: dict) -> dict: async def get_stripe_balance(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client("stripe", "get_balance") + res = await run_client("stripe", "get_balance") + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( @@ -3940,7 +4435,7 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "list_balance_transactions", limit=input_data.get("limit", 10), @@ -3952,6 +4447,19 @@ def _csv(v): payout=input_data.get("payout") or None, expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res # ================================================================== @@ -3997,7 +4505,7 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "list_quotes", limit=input_data.get("limit", 10), @@ -4007,6 +4515,19 @@ def _csv(v): status=input_data.get("status") or None, expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( @@ -4031,17 +4552,30 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "get_quote", quote_id=input_data["quote_id"], expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( name="create_stripe_quote", - description="Create a draft Quote for a customer. After creation, call finalize_stripe_quote to lock it, then send the URL or wait for accept_stripe_quote.", + description="Create a draft Quote for a customer. After creation, call finalize_stripe_quote to lock it, then send the URL or wait for accept_stripe_quote. Returns only {id, status}.", action_sets=["stripe_quotes"], input_schema={ "customer": { @@ -4100,13 +4634,13 @@ def _csv(v): "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def create_stripe_quote(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "create_quote", customer=input_data["customer"], @@ -4121,11 +4655,12 @@ async def create_stripe_quote(input_data: dict) -> dict: metadata=input_data.get("metadata") or None, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( name="update_stripe_quote", - description="Update a draft Quote. Once finalized most fields are locked.", + description="Update a draft Quote. Once finalized most fields are locked. Returns only {id, status}.", action_sets=["stripe_quotes"], input_schema={ "quote_id": {"type": "string", "description": "Quote ID.", "example": "qt_…"}, @@ -4140,24 +4675,25 @@ async def create_stripe_quote(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def update_stripe_quote(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "update_quote", quote_id=input_data["quote_id"], properties=input_data.get("properties") or {}, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( name="finalize_stripe_quote", - description="Finalize a draft Quote — it becomes 'open' and is ready for the customer to accept.", + description="Finalize a draft Quote — it becomes 'open' and is ready for the customer to accept. Returns only {id, status}.", action_sets=["stripe_quotes"], input_schema={ "quote_id": { @@ -4176,24 +4712,25 @@ async def update_stripe_quote(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def finalize_stripe_quote(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "finalize_quote", quote_id=input_data["quote_id"], expires_at=input_data.get("expires_at") or None, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( name="accept_stripe_quote", - description="Accept an open Quote on the customer's behalf — creates the invoice / subscription per the quote terms.", + description="Accept an open Quote on the customer's behalf — creates the invoice / subscription per the quote terms. Returns only {id, status}.", action_sets=["stripe_quotes"], input_schema={ "quote_id": { @@ -4207,23 +4744,24 @@ async def finalize_stripe_quote(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def accept_stripe_quote(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "accept_quote", quote_id=input_data["quote_id"], idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( name="cancel_stripe_quote", - description="Cancel a draft or open Quote. Cannot cancel an accepted quote.", + description="Cancel a draft or open Quote. Cannot cancel an accepted quote. Returns only {id, status}.", action_sets=["stripe_quotes"], input_schema={ "quote_id": {"type": "string", "description": "Quote ID.", "example": "qt_…"}, @@ -4233,18 +4771,19 @@ async def accept_stripe_quote(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def cancel_stripe_quote(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "cancel_quote", quote_id=input_data["quote_id"], idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) # ================================================================== @@ -4254,9 +4793,14 @@ async def cancel_stripe_quote(input_data: dict) -> dict: @action( name="list_stripe_events", - description="List Events from the Stripe event log. Filter by type ('invoice.paid', 'customer.created', etc.) or multiple types.", + description="List Events from the Stripe event log. Filter by type ('invoice.paid', 'customer.created', etc.) or multiple types. Returns lean events {id, type, created, data.object.id}; set include_metadata=true for full payloads.", action_sets=["stripe_webhooks"], input_schema={ + "include_metadata": { + "type": "boolean", + "description": "True returns full event payloads. Default false (lean).", + "example": False, + }, "limit": { "type": "integer", "description": "Max results (1-100).", @@ -4295,7 +4839,7 @@ async def cancel_stripe_quote(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Lean events {id, type, created, data.object.id} + has_more unless include_metadata=true."}}, ) async def list_stripe_events(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client @@ -4305,7 +4849,7 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "list_events", limit=input_data.get("limit", 10), @@ -4318,21 +4862,48 @@ def _csv(v): created_lte=input_data.get("created_lte") or None, expand=_csv(input_data.get("expand")), ) + if not input_data.get("include_metadata"): + r = res.get("result") + if ( + res.get("status") == "success" + and isinstance(r, dict) + and isinstance(r.get("data"), list) + ): + lean = [] + for ev in r["data"]: + if not isinstance(ev, dict): + continue + obj = (ev.get("data") or {}).get("object") or {} + lean.append( + { + "id": ev.get("id"), + "type": ev.get("type"), + "created": ev.get("created"), + "data": {"object": {"id": obj.get("id")}}, + } + ) + res = {**res, "result": {"data": lean, "has_more": r.get("has_more")}} + return res @action( name="get_stripe_event", - description="Retrieve a single Event by ID.", + description="Retrieve a single Event by ID. Returns lean {id, type, created, data.object.id}; set include_metadata=true for the full payload.", action_sets=["stripe_webhooks"], input_schema={ "event_id": {"type": "string", "description": "Event ID.", "example": "evt_…"}, + "include_metadata": { + "type": "boolean", + "description": "True returns the full event payload. Default false (lean).", + "example": False, + }, "expand": { "type": "string", "description": "Comma-separated fields to expand.", "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Lean {id, type, created, data.object.id} unless include_metadata=true."}}, ) async def get_stripe_event(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client @@ -4342,12 +4913,26 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "get_event", event_id=input_data["event_id"], expand=_csv(input_data.get("expand")), ) + if not input_data.get("include_metadata"): + r = res.get("result") + if res.get("status") == "success" and isinstance(r, dict): + obj = (r.get("data") or {}).get("object") or {} + res = { + **res, + "result": { + "id": r.get("id"), + "type": r.get("type"), + "created": r.get("created"), + "data": {"object": {"id": obj.get("id")}}, + }, + } + return res @action( @@ -4378,7 +4963,7 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "list_webhook_endpoints", limit=input_data.get("limit", 10), @@ -4386,6 +4971,19 @@ def _csv(v): ending_before=input_data.get("ending_before") or None, expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( @@ -4414,17 +5012,30 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "get_webhook_endpoint", endpoint_id=input_data["endpoint_id"], expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( name="create_stripe_webhook_endpoint", - description="Register a Webhook Endpoint. URL must be publicly reachable HTTPS. 'enabled_events' must explicitly list event types or ['*'].", + description="Register a Webhook Endpoint. URL must be publicly reachable HTTPS. 'enabled_events' must explicitly list event types or ['*']. Returns only {id, status, secret}.", action_sets=["stripe_webhooks"], input_schema={ "url": { @@ -4463,13 +5074,13 @@ def _csv(v): "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status, secret}."}}, parallelizable=False, ) async def create_stripe_webhook_endpoint(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "create_webhook_endpoint", url=input_data["url"], @@ -4480,11 +5091,12 @@ async def create_stripe_webhook_endpoint(input_data: dict) -> dict: metadata=input_data.get("metadata") or None, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status", "secret"]) @action( name="update_stripe_webhook_endpoint", - description="Update a Webhook Endpoint's URL, enabled_events, description, disabled flag, etc.", + description="Update a Webhook Endpoint's URL, enabled_events, description, disabled flag, etc. Returns only {id, status}.", action_sets=["stripe_webhooks"], input_schema={ "endpoint_id": { @@ -4503,24 +5115,25 @@ async def create_stripe_webhook_endpoint(input_data: dict) -> dict: "example": "", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def update_stripe_webhook_endpoint(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "update_webhook_endpoint", endpoint_id=input_data["endpoint_id"], properties=input_data.get("properties") or {}, idempotency_key=input_data.get("idempotency_key") or None, ) + return pick_result(res, ["id", "status"]) @action( name="delete_stripe_webhook_endpoint", - description="Delete a Webhook Endpoint. Stripe stops POSTing to its URL immediately.", + description="Delete a Webhook Endpoint. Stripe stops POSTing to its URL immediately. Returns only {id, status}.", action_sets=["stripe_webhooks"], input_schema={ "endpoint_id": { @@ -4529,17 +5142,18 @@ async def update_stripe_webhook_endpoint(input_data: dict) -> dict: "example": "we_…", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def delete_stripe_webhook_endpoint(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "delete_webhook_endpoint", endpoint_id=input_data["endpoint_id"], ) + return pick_result(res, ["id", "status"]) # ================================================================== @@ -4549,7 +5163,7 @@ async def delete_stripe_webhook_endpoint(input_data: dict) -> dict: @action( name="upload_stripe_file", - description="Upload a file to Stripe (multipart). 'purpose' constrains downstream use — most commonly 'dispute_evidence' (then reference the returned file_id in update_stripe_dispute's evidence object).", + description="Upload a file to Stripe (multipart). 'purpose' constrains downstream use — most commonly 'dispute_evidence' (then reference the returned file_id in update_stripe_dispute's evidence object). Returns only {id, status}.", action_sets=["stripe_files"], input_schema={ "file_path": { @@ -4573,13 +5187,13 @@ async def delete_stripe_webhook_endpoint(input_data: dict) -> dict: "example": 0, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object", "description": "Only {id, status}."}}, parallelizable=False, ) async def upload_stripe_file(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "stripe", "upload_file", file_path=input_data["file_path"], @@ -4587,6 +5201,7 @@ async def upload_stripe_file(input_data: dict) -> dict: link_create=input_data.get("link_create"), link_expires_at=input_data.get("link_expires_at") or None, ) + return pick_result(res, ["id", "status"]) @action( @@ -4611,12 +5226,25 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "get_file", file_id=input_data["file_id"], expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res @action( @@ -4652,7 +5280,7 @@ def _csv(v): return None return [s.strip() for s in str(v).split(",") if s.strip()] or None - return await run_client( + res = await run_client( "stripe", "list_files", limit=input_data.get("limit", 10), @@ -4661,6 +5289,19 @@ def _csv(v): purpose=input_data.get("purpose") or None, expand=_csv(input_data.get("expand")), ) + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res # ================================================================== @@ -4678,7 +5319,20 @@ def _csv(v): async def get_stripe_account(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client("stripe", "get_account") + res = await run_client("stripe", "get_account") + r = res.get("result") + items = ( + [r] + (r.get("data") if isinstance(r.get("data"), list) else []) + if isinstance(r, dict) + else [] + ) + for it in items: + if isinstance(it, dict): + it.pop("livemode", None) + it.pop("object", None) + if it.get("metadata") == {}: + it.pop("metadata", None) + return res # ================================================================== diff --git a/app/data/action/integrations/telegram/telegram_actions.py b/app/data/action/integrations/telegram/telegram_actions.py index dec9554e..0656dc69 100644 --- a/app/data/action/integrations/telegram/telegram_actions.py +++ b/app/data/action/integrations/telegram/telegram_actions.py @@ -46,16 +46,18 @@ }, output_schema={ "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "example": {"message_id": 42}}, }, ) async def send_telegram_bot_message(input_data: dict) -> dict: from app.data.action.integrations._helpers import ( + pick_result, record_outgoing_message, run_client, ) record_outgoing_message("Telegram", input_data["chat_id"], input_data["text"]) - return await run_client( + res = await run_client( "telegram_bot", "send_message", recipient=input_data["chat_id"], @@ -65,6 +67,7 @@ async def send_telegram_bot_message(input_data: dict) -> dict: disable_web_page_preview=input_data.get("disable_web_page_preview"), reply_markup=input_data.get("reply_markup"), ) + return pick_result(res, ["message_id"]) @action( @@ -105,12 +108,15 @@ async def send_telegram_bot_message(input_data: dict) -> dict: "example": {}, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "example": {"message_id": 42}}, + }, ) async def send_telegram_text_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "telegram_bot", "send_text_message", chat_id=input_data["chat_id"], @@ -121,6 +127,7 @@ async def send_telegram_text_message(input_data: dict) -> dict: disable_notification=input_data.get("disable_notification"), reply_markup=input_data.get("reply_markup"), ) + return pick_result(res, ["message_id"]) @action( @@ -142,12 +149,15 @@ async def send_telegram_text_message(input_data: dict) -> dict: "example": {}, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "example": {"message_id": 42}}, + }, ) async def edit_telegram_message_text(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "telegram_bot", "edit_message_text", chat_id=input_data["chat_id"], @@ -156,6 +166,7 @@ async def edit_telegram_message_text(input_data: dict) -> dict: parse_mode=input_data.get("parse_mode"), reply_markup=input_data.get("reply_markup"), ) + return pick_result(res, ["message_id"]) @action( @@ -181,12 +192,15 @@ async def edit_telegram_message_text(input_data: dict) -> dict: "example": {}, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "example": {"message_id": 42}}, + }, ) async def edit_telegram_message_caption(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "telegram_bot", "edit_message_caption", chat_id=input_data["chat_id"], @@ -195,6 +209,7 @@ async def edit_telegram_message_caption(input_data: dict) -> dict: parse_mode=input_data.get("parse_mode"), reply_markup=input_data.get("reply_markup"), ) + return pick_result(res, ["message_id"]) @action( @@ -210,18 +225,22 @@ async def edit_telegram_message_caption(input_data: dict) -> dict: "example": {}, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "example": {"message_id": 42}}, + }, ) async def edit_telegram_message_reply_markup(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "telegram_bot", "edit_message_reply_markup", chat_id=input_data["chat_id"], message_id=input_data["message_id"], reply_markup=input_data.get("reply_markup"), ) + return pick_result(res, ["message_id"]) @action( @@ -329,18 +348,22 @@ async def copy_telegram_message(input_data: dict) -> dict: }, "message_id": {"type": "integer", "description": "Message ID.", "example": 1}, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "example": {"message_id": 42}}, + }, ) async def forward_telegram_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "telegram_bot", "forward_message", chat_id=input_data["chat_id"], from_chat_id=input_data["from_chat_id"], message_id=input_data["message_id"], ) + return pick_result(res, ["message_id"]) @action( @@ -365,18 +388,33 @@ async def forward_telegram_message(input_data: dict) -> dict: "example": [1, 2, 3], }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "example": {"message_ids": [43, 44]}}, + }, ) async def forward_telegram_messages(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "telegram_bot", "forward_messages", chat_id=input_data["chat_id"], from_chat_id=input_data["from_chat_id"], message_ids=input_data["message_ids"], ) + if res.get("status") == "success" and isinstance(res.get("result"), list): + res = { + **res, + "result": { + "message_ids": [ + m.get("message_id") + for m in res["result"] + if isinstance(m, dict) and m.get("message_id") is not None + ] + }, + } + return res @action( @@ -528,18 +566,22 @@ async def send_telegram_chat_action(input_data: dict) -> dict: }, "caption": {"type": "string", "description": "Caption.", "example": "Cool pic"}, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "example": {"message_id": 42}}, + }, ) async def send_telegram_photo(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "telegram_bot", "send_photo", chat_id=input_data["chat_id"], photo=input_data["photo"], caption=input_data.get("caption"), ) + return pick_result(res, ["message_id"]) @action( @@ -560,18 +602,22 @@ async def send_telegram_photo(input_data: dict) -> dict: "example": "Here is the file", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "example": {"message_id": 42}}, + }, ) async def send_telegram_document(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "telegram_bot", "send_document", chat_id=input_data["chat_id"], document=input_data["document"], caption=input_data.get("caption"), ) + return pick_result(res, ["message_id"]) @action( @@ -598,12 +644,15 @@ async def send_telegram_document(input_data: dict) -> dict: "example": True, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "example": {"message_id": 42}}, + }, ) async def send_telegram_video(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "telegram_bot", "send_video", chat_id=input_data["chat_id"], @@ -612,6 +661,7 @@ async def send_telegram_video(input_data: dict) -> dict: duration=input_data.get("duration"), supports_streaming=input_data.get("supports_streaming"), ) + return pick_result(res, ["message_id"]) @action( @@ -630,12 +680,15 @@ async def send_telegram_video(input_data: dict) -> dict: "title": {"type": "string", "description": "Track title.", "example": "Song"}, "performer": {"type": "string", "description": "Artist.", "example": "Artist"}, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "example": {"message_id": 42}}, + }, ) async def send_telegram_audio(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "telegram_bot", "send_audio", chat_id=input_data["chat_id"], @@ -644,6 +697,7 @@ async def send_telegram_audio(input_data: dict) -> dict: title=input_data.get("title"), performer=input_data.get("performer"), ) + return pick_result(res, ["message_id"]) @action( @@ -665,12 +719,15 @@ async def send_telegram_audio(input_data: dict) -> dict: "example": 10, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "example": {"message_id": 42}}, + }, ) async def send_telegram_voice(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "telegram_bot", "send_voice", chat_id=input_data["chat_id"], @@ -678,6 +735,7 @@ async def send_telegram_voice(input_data: dict) -> dict: caption=input_data.get("caption"), duration=input_data.get("duration"), ) + return pick_result(res, ["message_id"]) @action( @@ -699,12 +757,15 @@ async def send_telegram_voice(input_data: dict) -> dict: }, "length": {"type": "integer", "description": "Side length.", "example": 240}, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "example": {"message_id": 42}}, + }, ) async def send_telegram_video_note(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "telegram_bot", "send_video_note", chat_id=input_data["chat_id"], @@ -712,6 +773,7 @@ async def send_telegram_video_note(input_data: dict) -> dict: duration=input_data.get("duration"), length=input_data.get("length"), ) + return pick_result(res, ["message_id"]) @action( @@ -728,18 +790,22 @@ async def send_telegram_video_note(input_data: dict) -> dict: }, "caption": {"type": "string", "description": "Caption.", "example": ""}, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "example": {"message_id": 42}}, + }, ) async def send_telegram_animation(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "telegram_bot", "send_animation", chat_id=input_data["chat_id"], animation=input_data["animation"], caption=input_data.get("caption"), ) + return pick_result(res, ["message_id"]) @action( @@ -755,17 +821,21 @@ async def send_telegram_animation(input_data: dict) -> dict: "example": "CAACAgQA...", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "example": {"message_id": 42}}, + }, ) async def send_telegram_sticker(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "telegram_bot", "send_sticker", chat_id=input_data["chat_id"], sticker=input_data["sticker"], ) + return pick_result(res, ["message_id"]) @action( @@ -787,12 +857,15 @@ async def send_telegram_sticker(input_data: dict) -> dict: "example": 60, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "example": {"message_id": 42}}, + }, ) async def send_telegram_location(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "telegram_bot", "send_location", chat_id=input_data["chat_id"], @@ -800,6 +873,7 @@ async def send_telegram_location(input_data: dict) -> dict: longitude=input_data["longitude"], live_period=input_data.get("live_period"), ) + return pick_result(res, ["message_id"]) @action( @@ -822,12 +896,15 @@ async def send_telegram_location(input_data: dict) -> dict: "example": "1 Main St", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "example": {"message_id": 42}}, + }, ) async def send_telegram_venue(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "telegram_bot", "send_venue", chat_id=input_data["chat_id"], @@ -836,6 +913,7 @@ async def send_telegram_venue(input_data: dict) -> dict: title=input_data["title"], address=input_data["address"], ) + return pick_result(res, ["message_id"]) @action( @@ -857,12 +935,15 @@ async def send_telegram_venue(input_data: dict) -> dict: }, "last_name": {"type": "string", "description": "Last name.", "example": "Doe"}, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "example": {"message_id": 42}}, + }, ) async def send_telegram_contact(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "telegram_bot", "send_contact", chat_id=input_data["chat_id"], @@ -870,6 +951,7 @@ async def send_telegram_contact(input_data: dict) -> dict: first_name=input_data["first_name"], last_name=input_data.get("last_name"), ) + return pick_result(res, ["message_id"]) @action( @@ -885,17 +967,21 @@ async def send_telegram_contact(input_data: dict) -> dict: "example": "🎲", }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "example": {"message_id": 42}}, + }, ) async def send_telegram_dice(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "telegram_bot", "send_dice", chat_id=input_data["chat_id"], emoji=input_data.get("emoji"), ) + return pick_result(res, ["message_id"]) @action( @@ -936,12 +1022,15 @@ async def send_telegram_dice(input_data: dict) -> dict: "example": 0, }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "example": {"message_id": 42}}, + }, ) async def send_telegram_poll(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client + from app.data.action.integrations._helpers import pick_result, run_client - return await run_client( + res = await run_client( "telegram_bot", "send_poll", chat_id=input_data["chat_id"], @@ -952,6 +1041,7 @@ async def send_telegram_poll(input_data: dict) -> dict: allows_multiple_answers=input_data.get("allows_multiple_answers"), correct_option_id=input_data.get("correct_option_id"), ) + return pick_result(res, ["message_id"]) @action( @@ -995,17 +1085,32 @@ async def stop_telegram_poll(input_data: dict) -> dict: ], }, }, - output_schema={"status": {"type": "string", "example": "success"}}, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": {"type": "object", "example": {"message_ids": [43, 44]}}, + }, ) async def send_telegram_media_group(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "telegram_bot", "send_media_group", chat_id=input_data["chat_id"], media=input_data["media"], ) + if res.get("status") == "success" and isinstance(res.get("result"), list): + res = { + **res, + "result": { + "message_ids": [ + m.get("message_id") + for m in res["result"] + if isinstance(m, dict) and m.get("message_id") is not None + ] + }, + } + return res @action( @@ -2108,7 +2213,7 @@ async def get_telegram_webhook_info(input_data: dict) -> dict: @action( name="get_telegram_updates", - description="Get incoming updates (messages) for the Telegram bot.", + description="Get incoming updates (messages) for the Telegram bot. Returns lean per-update summaries by default; set include_metadata=true for raw Update objects.", action_sets=["telegram_messages", "telegram"], input_schema={ "limit": { @@ -2121,21 +2226,80 @@ async def get_telegram_webhook_info(input_data: dict) -> dict: "description": "Update offset for pagination.", "example": 0, }, + "include_metadata": { + "type": "boolean", + "description": "Return raw Update objects (default false = lean summaries).", + "example": False, + }, }, output_schema={ "status": {"type": "string", "example": "success"}, - "updates": {"type": "array", "description": "List of update objects."}, + "result": { + "type": "array", + "description": "Lean: [{update_id, message_id, chat_id, from, text, date, type?}]. Raw Updates with include_metadata=true.", + }, }, ) async def get_telegram_updates(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "telegram_bot", "get_updates", offset=input_data.get("offset"), limit=input_data.get("limit", 100), ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + updates = res.get("result") + if not isinstance(updates, list): + return res + lean = [] + for u in updates: + if not isinstance(u, dict): + continue + item = {"update_id": u.get("update_id")} + kind = next((k for k in u if k != "update_id"), None) + if kind and kind != "message": + item["type"] = kind + payload = u.get(kind) if kind else None + msg = payload if isinstance(payload, dict) else {} + if kind == "callback_query": + item["callback_query_id"] = msg.get("id") + if msg.get("data") is not None: + item["data"] = msg.get("data") + sender = msg.get("from") or {} + msg = msg.get("message") or {} + else: + sender = msg.get("from") or {} + if msg: + item["message_id"] = msg.get("message_id") + chat = msg.get("chat") or {} + item["chat_id"] = chat.get("id") + frm = chat.get("title") + if not frm: + frm = " ".join( + p + for p in (sender.get("first_name"), sender.get("last_name")) + if p + ) + if sender.get("username"): + frm = ( + f"{frm} (@{sender['username']})" + if frm + else f"@{sender['username']}" + ) + if frm: + item["from"] = frm + text = msg.get("text") + if text is None: + text = msg.get("caption") + if text is not None: + item["text"] = text + if msg.get("date") is not None: + item["date"] = msg.get("date") + lean.append(item) + return {**res, "result": lean} @action( diff --git a/app/data/action/integrations/twitter/twitter_actions.py b/app/data/action/integrations/twitter/twitter_actions.py index c00f97e8..1bfddbbe 100644 --- a/app/data/action/integrations/twitter/twitter_actions.py +++ b/app/data/action/integrations/twitter/twitter_actions.py @@ -108,7 +108,7 @@ async def get_tweet(input_data: dict) -> dict: @action( name="lookup_tweets", - description="Batch-lookup up to 100 tweets by their IDs.", + description="Batch-lookup up to 100 tweets by their IDs. Lean tweets (id, text, created_at, author_id) by default; include_metadata=true adds public_metrics and edit history.", action_sets=["twitter_tweets"], input_schema={ "tweet_ids": { @@ -116,6 +116,11 @@ async def get_tweet(input_data: dict) -> dict: "description": "List of tweet IDs (max 100).", "example": ["123", "456"], }, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean tweets. True: full raw incl. public_metrics.", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -123,13 +128,16 @@ async def lookup_tweets(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client return await run_client( - "twitter", "lookup_tweets", tweet_ids=input_data["tweet_ids"] + "twitter", + "lookup_tweets", + tweet_ids=input_data["tweet_ids"], + include_metadata=bool(input_data.get("include_metadata", False)), ) @action( name="search_tweets", - description="Search recent tweets on Twitter/X.", + description="Search recent tweets on Twitter/X. Lean tweets (id, text, created_at, author_id) by default; include_metadata=true adds public_metrics and edit history.", action_sets=["twitter_tweets", "twitter"], input_schema={ "query": { @@ -142,6 +150,11 @@ async def lookup_tweets(input_data: dict) -> dict: "description": "Max results (10-100).", "example": 10, }, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean tweets. True: full raw incl. public_metrics.", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -151,14 +164,16 @@ async def search_tweets(input_data: dict) -> dict: return await with_client( "twitter", lambda c: c.search_tweets( - input_data["query"], max_results=input_data.get("max_results", 10) + input_data["query"], + max_results=input_data.get("max_results", 10), + include_metadata=bool(input_data.get("include_metadata", False)), ), ) @action( name="get_twitter_timeline", - description="Get recent tweets from a user's timeline.", + description="Get recent tweets from a user's timeline. Lean tweets (id, text, created_at, author_id) by default; include_metadata=true adds public_metrics and edit history.", action_sets=["twitter_tweets", "twitter"], input_schema={ "user_id": { @@ -171,6 +186,11 @@ async def search_tweets(input_data: dict) -> dict: "description": "Max tweets to return.", "example": 10, }, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean tweets. True: full raw incl. public_metrics.", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -182,12 +202,13 @@ async def get_twitter_timeline(input_data: dict) -> dict: "get_user_timeline", user_id=input_data.get("user_id") or None, max_results=input_data.get("max_results", 10), + include_metadata=bool(input_data.get("include_metadata", False)), ) @action( name="get_twitter_mentions", - description="Get recent mentions of a user (defaults to the authenticated user).", + description="Get recent mentions of a user (defaults to the authenticated user). Lean tweets (id, text, created_at, author_id) by default; include_metadata=true adds conversation_id and edit history.", action_sets=["twitter_tweets", "twitter"], input_schema={ "user_id": { @@ -200,6 +221,11 @@ async def get_twitter_timeline(input_data: dict) -> dict: "description": "Max mentions.", "example": 10, }, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean tweets. True: full raw incl. conversation_id.", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -211,6 +237,7 @@ async def get_twitter_mentions(input_data: dict) -> dict: "get_user_mentions", user_id=input_data.get("user_id") or None, max_results=input_data.get("max_results", 10), + include_metadata=bool(input_data.get("include_metadata", False)), ) @@ -442,7 +469,7 @@ async def remove_twitter_bookmark(input_data: dict) -> dict: @action( name="list_twitter_bookmarks", - description="List the authenticated user's bookmarked tweets.", + description="List the authenticated user's bookmarked tweets. Lean tweets (id, text, created_at, author_id) by default; include_metadata=true adds public_metrics and edit history.", action_sets=["twitter_engagement", "twitter"], input_schema={ "max_results": { @@ -450,6 +477,11 @@ async def remove_twitter_bookmark(input_data: dict) -> dict: "description": "Max results.", "example": 50, }, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean tweets. True: full raw incl. public_metrics.", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -457,7 +489,10 @@ async def list_twitter_bookmarks(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client return await run_client( - "twitter", "list_bookmarks", max_results=input_data.get("max_results", 50) + "twitter", + "list_bookmarks", + max_results=input_data.get("max_results", 50), + include_metadata=bool(input_data.get("include_metadata", False)), ) @@ -970,7 +1005,7 @@ async def list_twitter_list_members(input_data: dict) -> dict: @action( name="list_twitter_list_tweets", - description="List recent tweets in a Twitter/X list.", + description="List recent tweets in a Twitter/X list. Lean tweets (id, text, created_at, author_id) by default; include_metadata=true adds public_metrics and edit history.", action_sets=["twitter_lists"], input_schema={ "list_id": { @@ -983,6 +1018,11 @@ async def list_twitter_list_members(input_data: dict) -> dict: "description": "Max tweets.", "example": 100, }, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean tweets. True: full raw incl. public_metrics.", + "example": False, + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -994,6 +1034,7 @@ async def list_twitter_list_tweets(input_data: dict) -> dict: "list_list_tweets", list_id=input_data["list_id"], max_results=input_data.get("max_results", 100), + include_metadata=bool(input_data.get("include_metadata", False)), ) diff --git a/app/data/action/integrations/whatsapp/whatsapp_actions.py b/app/data/action/integrations/whatsapp/whatsapp_actions.py index 8ae80062..99c3f5c4 100644 --- a/app/data/action/integrations/whatsapp/whatsapp_actions.py +++ b/app/data/action/integrations/whatsapp/whatsapp_actions.py @@ -379,7 +379,7 @@ async def send_whatsapp_typing_state(input_data: dict) -> dict: @action( name="get_whatsapp_chat_history", - description="Get chat message history.", + description="Get chat message history. Lean messages by default; include_metadata=true returns the raw message list.", action_sets=["whatsapp_chats", "whatsapp"], input_schema={ "phone_number": { @@ -388,18 +388,52 @@ async def send_whatsapp_typing_state(input_data: dict) -> dict: "example": "1234567890", }, "limit": {"type": "integer", "description": "Max messages.", "example": 50}, + "include_metadata": { + "type": "boolean", + "description": "Return raw message objects (default false = lean).", + "example": False, + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "Lean: {messages: [{id, from, to?, body, timestamp, from_me, has_media, type?}]}.", + }, }, - output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_whatsapp_chat_history(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "whatsapp_web", "get_chat_messages", phone_number=input_data["phone_number"], limit=input_data.get("limit", 50), ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + result = res.get("result") + if not isinstance(result, dict) or not isinstance(result.get("messages"), list): + return res + lean = [] + for m in result["messages"]: + if not isinstance(m, dict): + continue + item = { + "id": m.get("id"), + "from": m.get("from"), + "body": m.get("body"), + "timestamp": m.get("timestamp"), + "from_me": m.get("from_me"), + "has_media": bool(m.get("has_media")), + } + if m.get("to") is not None: + item["to"] = m.get("to") + if m.get("type") and m.get("type") != "chat": + item["type"] = m.get("type") + lean.append(item) + return {**res, "result": {**result, "messages": lean}} @action( @@ -894,7 +928,7 @@ async def get_whatsapp_contact(input_data: dict) -> dict: @action( name="get_whatsapp_all_contacts", - description="List all contacts. By default filters to 'my contacts' (saved in phonebook). Set my_contacts_only=false to include everyone the user has ever interacted with.", + description="List all contacts. By default filters to 'my contacts' (saved in phonebook). Set my_contacts_only=false to include everyone the user has ever interacted with. Lean contacts by default; include_metadata=true returns the raw contact list.", action_sets=["whatsapp_contacts", "whatsapp"], input_schema={ "my_contacts_only": { @@ -903,18 +937,49 @@ async def get_whatsapp_contact(input_data: dict) -> dict: "example": True, }, "limit": {"type": "integer", "description": "Max results.", "example": 500}, + "include_metadata": { + "type": "boolean", + "description": "Return raw contact objects (default false = lean).", + "example": False, + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "description": "Lean: {contacts: [{id, number, name?, pushname?, is_business?, is_my_contact?}], count}.", + }, }, - output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_whatsapp_all_contacts(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( + res = await run_client( "whatsapp_web", "get_all_contacts", my_contacts_only=bool(input_data.get("my_contacts_only", True)), limit=input_data.get("limit", 500), ) + if input_data.get("include_metadata") or res.get("status") != "success": + return res + result = res.get("result") + if not isinstance(result, dict) or not isinstance(result.get("contacts"), list): + return res + lean = [] + for c in result["contacts"]: + if not isinstance(c, dict): + continue + item = {"id": c.get("id"), "number": c.get("number")} + if c.get("name"): + item["name"] = c.get("name") + if c.get("pushname"): + item["pushname"] = c.get("pushname") + if c.get("is_business"): + item["is_business"] = True + if c.get("is_my_contact") is False: + item["is_my_contact"] = False + lean.append(item) + return {**res, "result": {**result, "contacts": lean}} @action( diff --git a/craftos_integrations/integrations/github/__init__.py b/craftos_integrations/integrations/github/__init__.py index 685ae578..4767acce 100644 --- a/craftos_integrations/integrations/github/__init__.py +++ b/craftos_integrations/integrations/github/__init__.py @@ -508,12 +508,32 @@ async def list_repos(self, per_page: int = 30, sort: str = "updated") -> Result: }, ) - async def get_repo(self, owner_repo: str) -> Result: + async def get_repo(self, owner_repo: str, include_metadata: bool = True) -> Result: + transform = None + if not include_metadata: + transform = lambda d: { # noqa: E731 + "name": d.get("name"), + "full_name": d.get("full_name"), + "description": d.get("description"), + "private": d.get("private"), + "fork": d.get("fork"), + "default_branch": d.get("default_branch"), + "language": d.get("language"), + "stargazers_count": d.get("stargazers_count"), + "forks_count": d.get("forks_count"), + "open_issues_count": d.get("open_issues_count"), + "topics": d.get("topics", []), + "archived": d.get("archived"), + "pushed_at": d.get("pushed_at"), + "html_url": d.get("html_url"), + "owner": (d.get("owner") or {}).get("login"), + } return await arequest( "GET", f"{GITHUB_API}/repos/{owner_repo}", headers=self._headers(), expected=(200,), + transform=transform, ) async def list_issues( @@ -543,12 +563,33 @@ async def list_issues( }, ) - async def get_issue(self, owner_repo: str, number: int) -> Result: + async def get_issue( + self, owner_repo: str, number: int, include_metadata: bool = True + ) -> Result: + transform = None + if not include_metadata: + transform = lambda d: { # noqa: E731 + "number": d.get("number"), + "title": d.get("title"), + "state": d.get("state"), + "body": d.get("body"), + "user": (d.get("user") or {}).get("login"), + "labels": [label.get("name") for label in d.get("labels", [])], + "assignees": [a.get("login") for a in d.get("assignees", [])], + "milestone": (d.get("milestone") or {}).get("title"), + "comments": d.get("comments"), + "created_at": d.get("created_at"), + "updated_at": d.get("updated_at"), + "closed_at": d.get("closed_at"), + "html_url": d.get("html_url"), + "is_pr": "pull_request" in d, + } return await arequest( "GET", f"{GITHUB_API}/repos/{owner_repo}/issues/{number}", headers=self._headers(), expected=(200,), + transform=transform, ) async def create_issue( @@ -1022,12 +1063,46 @@ async def list_commits( }, ) - async def get_commit(self, owner_repo: str, sha: str) -> Result: + async def get_commit( + self, owner_repo: str, sha: str, include_metadata: bool = True + ) -> Result: + transform = None + if not include_metadata: + + def _person(d: Dict[str, Any], key: str) -> Dict[str, Any]: + git_person = (d.get("commit") or {}).get(key) or {} + return { + "name": git_person.get("name"), + "email": git_person.get("email"), + "date": git_person.get("date"), + "login": (d.get(key) or {}).get("login"), + } + + transform = lambda d: { # noqa: E731 + "sha": d.get("sha"), + "message": (d.get("commit") or {}).get("message"), + "author": _person(d, "author"), + "committer": _person(d, "committer"), + "stats": d.get("stats"), + "parents": [p.get("sha") for p in d.get("parents", [])], + "files": [ + { + "filename": f.get("filename"), + "status": f.get("status"), + "additions": f.get("additions"), + "deletions": f.get("deletions"), + "patch": f.get("patch"), + } + for f in d.get("files", []) + ], + "html_url": d.get("html_url"), + } return await arequest( "GET", f"{GITHUB_API}/repos/{owner_repo}/commits/{sha}", headers=self._headers(), expected=(200,), + transform=transform, ) async def compare_commits(self, owner_repo: str, base: str, head: str) -> Result: @@ -1057,12 +1132,53 @@ async def compare_commits(self, owner_repo: str, base: str, head: str) -> Result # Pull requests # ------------------------------------------------------------------ - async def get_pull_request(self, owner_repo: str, number: int) -> Result: + async def get_pull_request( + self, owner_repo: str, number: int, include_metadata: bool = True + ) -> Result: + transform = None + if not include_metadata: + transform = lambda d: { # noqa: E731 + "number": d.get("number"), + "title": d.get("title"), + "state": d.get("state"), + "body": d.get("body"), + "draft": d.get("draft"), + "merged": d.get("merged"), + "mergeable": d.get("mergeable"), + "merged_by": (d.get("merged_by") or {}).get("login"), + "user": (d.get("user") or {}).get("login"), + "labels": [label.get("name") for label in d.get("labels", [])], + "assignees": [a.get("login") for a in d.get("assignees", [])], + "requested_reviewers": [ + u.get("login") for u in d.get("requested_reviewers", []) + ], + "milestone": (d.get("milestone") or {}).get("title"), + "base": { + "ref": (d.get("base") or {}).get("ref"), + "sha": (d.get("base") or {}).get("sha"), + }, + "head": { + "ref": (d.get("head") or {}).get("ref"), + "sha": (d.get("head") or {}).get("sha"), + "repo": ((d.get("head") or {}).get("repo") or {}).get("full_name"), + }, + "commits": d.get("commits"), + "additions": d.get("additions"), + "deletions": d.get("deletions"), + "changed_files": d.get("changed_files"), + "comments": d.get("comments"), + "created_at": d.get("created_at"), + "updated_at": d.get("updated_at"), + "closed_at": d.get("closed_at"), + "merged_at": d.get("merged_at"), + "html_url": d.get("html_url"), + } return await arequest( "GET", f"{GITHUB_API}/repos/{owner_repo}/pulls/{number}", headers=self._headers(), expected=(200,), + transform=transform, ) async def create_pull_request( @@ -1756,6 +1872,7 @@ async def get_release( release_id: Optional[int] = None, tag: Optional[str] = None, latest: bool = False, + include_metadata: bool = True, ) -> Result: if latest: url = f"{GITHUB_API}/repos/{owner_repo}/releases/latest" @@ -1765,7 +1882,32 @@ async def get_release( url = f"{GITHUB_API}/repos/{owner_repo}/releases/{release_id}" else: return {"error": "Must provide release_id, tag, or latest=True"} - return await arequest("GET", url, headers=self._headers(), expected=(200,)) + transform = None + if not include_metadata: + transform = lambda d: { # noqa: E731 + "id": d.get("id"), + "tag_name": d.get("tag_name"), + "name": d.get("name"), + "body": d.get("body"), + "draft": d.get("draft"), + "prerelease": d.get("prerelease"), + "created_at": d.get("created_at"), + "published_at": d.get("published_at"), + "html_url": d.get("html_url"), + "author": (d.get("author") or {}).get("login"), + "assets": [ + { + "name": a.get("name"), + "size": a.get("size"), + "download_count": a.get("download_count"), + "browser_download_url": a.get("browser_download_url"), + } + for a in d.get("assets", []) + ], + } + return await arequest( + "GET", url, headers=self._headers(), expected=(200,), transform=transform + ) async def create_release( self, @@ -2180,12 +2322,34 @@ async def list_gists(self, per_page: int = 30) -> Result: }, ) - async def get_gist(self, gist_id: str) -> Result: + async def get_gist(self, gist_id: str, include_metadata: bool = True) -> Result: + transform = None + if not include_metadata: + transform = lambda d: { # noqa: E731 + "id": d.get("id"), + "description": d.get("description"), + "public": d.get("public"), + "html_url": d.get("html_url"), + "created_at": d.get("created_at"), + "updated_at": d.get("updated_at"), + "owner": (d.get("owner") or {}).get("login"), + "files": { + name: { + "filename": f.get("filename"), + "language": f.get("language"), + "size": f.get("size"), + "truncated": f.get("truncated"), + "content": f.get("content"), + } + for name, f in (d.get("files") or {}).items() + }, + } return await arequest( "GET", f"{GITHUB_API}/gists/{gist_id}", headers=self._headers(), expected=(200,), + transform=transform, ) async def create_gist( diff --git a/craftos_integrations/integrations/google_drive/__init__.py b/craftos_integrations/integrations/google_drive/__init__.py index 2fb017d9..c83fdfa9 100644 --- a/craftos_integrations/integrations/google_drive/__init__.py +++ b/craftos_integrations/integrations/google_drive/__init__.py @@ -279,14 +279,15 @@ def empty_drive_trash(self) -> Result: transform=lambda _d: {"emptied": True}, ) - def get_drive_about(self) -> Result: + def get_drive_about(self, include_metadata: bool = True) -> Result: + fields = "user,storageQuota,maxUploadSize,canCreateDrives" + if include_metadata: + fields += ",exportFormats,importFormats" return http_request( "GET", f"{DRIVE_API_BASE}/about", headers=self._auth_header(), - params={ - "fields": "user,storageQuota,maxUploadSize,exportFormats,importFormats,canCreateDrives" - }, + params={"fields": fields}, expected=(200,), ) diff --git a/craftos_integrations/integrations/jira/__init__.py b/craftos_integrations/integrations/jira/__init__.py index 8eea1d77..ca35df47 100644 --- a/craftos_integrations/integrations/jira/__init__.py +++ b/craftos_integrations/integrations/jira/__init__.py @@ -34,6 +34,47 @@ POLL_INTERVAL = 10 RETRY_DELAY = 15 +# Fields requested from the API when the caller wants a lean issue payload +# (include_metadata=False and no explicit fields list). Restricting server-side +# avoids the full customfield_* dump Jira returns by default. +_LEAN_ISSUE_FIELDS = [ + "summary", + "description", + "status", + "assignee", + "priority", + "issuetype", + "labels", + "created", + "updated", +] + + +def _slim_issue(issue: Any) -> Any: + """Collapse an issue's verbose nested field objects to their names. + + status -> status.name, assignee -> displayName, priority -> name, + issuetype -> name. Drops top-level ``self``/``expand`` noise. Safe on + non-dict input (returned unchanged). + """ + if not isinstance(issue, dict): + return issue + fields = issue.get("fields") + if isinstance(fields, dict): + fields = dict(fields) + if isinstance(fields.get("status"), dict): + fields["status"] = fields["status"].get("name") + if isinstance(fields.get("assignee"), dict): + fields["assignee"] = fields["assignee"].get("displayName") + if isinstance(fields.get("priority"), dict): + fields["priority"] = fields["priority"].get("name") + if isinstance(fields.get("issuetype"), dict): + fields["issuetype"] = fields["issuetype"].get("name") + slim = {k: v for k, v in issue.items() if k not in ("self", "expand", "fields")} + if fields is not None: + slim["fields"] = fields + return slim + @dataclass class JiraCredential: @@ -594,11 +635,17 @@ async def get_myself(self) -> Result: ) async def search_issues( - self, jql: str, max_results: int = 50, fields_list: Optional[List[str]] = None + self, + jql: str, + max_results: int = 50, + fields_list: Optional[List[str]] = None, + include_metadata: bool = True, ) -> Result: payload: Dict[str, Any] = {"jql": jql, "maxResults": min(max_results, 100)} if fields_list: payload["fields"] = fields_list + elif not include_metadata: + payload["fields"] = _LEAN_ISSUE_FIELDS return await arequest( "POST", f"{self._base_url()}/search/jql", @@ -608,22 +655,30 @@ async def search_issues( expected=(200,), transform=lambda d: { "total": d.get("total", 0), - "issues": d.get("issues", []), + "issues": [_slim_issue(i) for i in d.get("issues", [])] + if not include_metadata + else d.get("issues", []), }, ) async def get_issue( - self, issue_key: str, fields_list: Optional[List[str]] = None + self, + issue_key: str, + fields_list: Optional[List[str]] = None, + include_metadata: bool = True, ) -> Result: params: Dict[str, Any] = {} if fields_list: params["fields"] = ",".join(fields_list) + elif not include_metadata: + params["fields"] = ",".join(_LEAN_ISSUE_FIELDS) return await arequest( "GET", f"{self._base_url()}/issue/{issue_key}", headers=self._headers(), params=params, expected=(200,), + transform=None if include_metadata else _slim_issue, ) async def create_issue( @@ -1129,12 +1184,37 @@ async def create_issue_link( }, ) - async def get_issue_link(self, link_id: str) -> Result: + async def get_issue_link( + self, link_id: str, include_metadata: bool = True + ) -> Result: + transform = None + if not include_metadata: + + def _link_end(issue: Any) -> Optional[Dict[str, Any]]: + if not isinstance(issue, dict): + return None + fields = issue.get("fields") or {} + status = fields.get("status") + return { + "key": issue.get("key"), + "summary": fields.get("summary"), + "status": status.get("name") + if isinstance(status, dict) + else status, + } + + transform = lambda d: { # noqa: E731 + "id": d.get("id"), + "type": (d.get("type") or {}).get("name"), + "inwardIssue": _link_end(d.get("inwardIssue")), + "outwardIssue": _link_end(d.get("outwardIssue")), + } return await arequest( "GET", f"{self._base_url()}/issueLink/{link_id}", headers=self._headers(), expected=(200,), + transform=transform, ) async def delete_issue_link(self, link_id: str) -> Result: @@ -1399,11 +1479,17 @@ async def get_board(self, board_id: int) -> Result: ) async def get_board_issues( - self, board_id: int, jql: Optional[str] = None, max_results: int = 50 + self, + board_id: int, + jql: Optional[str] = None, + max_results: int = 50, + include_metadata: bool = True, ) -> Result: params: Dict[str, Any] = {"maxResults": max_results} if jql: params["jql"] = jql + if not include_metadata: + params["fields"] = ",".join(_LEAN_ISSUE_FIELDS) return await arequest( "GET", f"{self._agile_base_url()}/board/{board_id}/issue", @@ -1411,7 +1497,9 @@ async def get_board_issues( params=params, expected=(200,), transform=lambda d: { - "issues": d.get("issues", []), + "issues": [_slim_issue(i) for i in d.get("issues", [])] + if not include_metadata + else d.get("issues", []), "total": d.get("total", 0), }, ) @@ -1444,15 +1532,22 @@ async def get_board_sprints( }, ) - async def get_board_backlog(self, board_id: int, max_results: int = 50) -> Result: + async def get_board_backlog( + self, board_id: int, max_results: int = 50, include_metadata: bool = True + ) -> Result: + params: Dict[str, Any] = {"maxResults": max_results} + if not include_metadata: + params["fields"] = ",".join(_LEAN_ISSUE_FIELDS) return await arequest( "GET", f"{self._agile_base_url()}/board/{board_id}/backlog", headers=self._headers(), - params={"maxResults": max_results}, + params=params, expected=(200,), transform=lambda d: { - "issues": d.get("issues", []), + "issues": [_slim_issue(i) for i in d.get("issues", [])] + if not include_metadata + else d.get("issues", []), "total": d.get("total", 0), }, ) @@ -1468,11 +1563,17 @@ async def get_sprint(self, sprint_id: int) -> Result: ) async def get_sprint_issues( - self, sprint_id: int, jql: Optional[str] = None, max_results: int = 50 + self, + sprint_id: int, + jql: Optional[str] = None, + max_results: int = 50, + include_metadata: bool = True, ) -> Result: params: Dict[str, Any] = {"maxResults": max_results} if jql: params["jql"] = jql + if not include_metadata: + params["fields"] = ",".join(_LEAN_ISSUE_FIELDS) return await arequest( "GET", f"{self._agile_base_url()}/sprint/{sprint_id}/issue", @@ -1480,7 +1581,9 @@ async def get_sprint_issues( params=params, expected=(200,), transform=lambda d: { - "issues": d.get("issues", []), + "issues": [_slim_issue(i) for i in d.get("issues", [])] + if not include_metadata + else d.get("issues", []), "total": d.get("total", 0), }, ) @@ -1586,16 +1689,21 @@ async def get_epic(self, epic_id_or_key: str) -> Result: ) async def get_epic_issues( - self, epic_id_or_key: str, max_results: int = 50 + self, epic_id_or_key: str, max_results: int = 50, include_metadata: bool = True ) -> Result: + params: Dict[str, Any] = {"maxResults": max_results} + if not include_metadata: + params["fields"] = ",".join(_LEAN_ISSUE_FIELDS) return await arequest( "GET", f"{self._agile_base_url()}/epic/{epic_id_or_key}/issue", headers=self._headers(), - params={"maxResults": max_results}, + params=params, expected=(200,), transform=lambda d: { - "issues": d.get("issues", []), + "issues": [_slim_issue(i) for i in d.get("issues", [])] + if not include_metadata + else d.get("issues", []), "total": d.get("total", 0), }, ) diff --git a/craftos_integrations/integrations/outlook/__init__.py b/craftos_integrations/integrations/outlook/__init__.py index 47de28b2..8a8a45c8 100644 --- a/craftos_integrations/integrations/outlook/__init__.py +++ b/craftos_integrations/integrations/outlook/__init__.py @@ -386,7 +386,11 @@ def _shape(d): transform=_shape, ) - def get_email(self, message_id: str) -> Result: + def get_email(self, message_id: str, include_metadata: bool = True) -> Result: + """``include_metadata=False`` asks Graph for a plain-text body via the + ``Prefer: outlook.body-content-type="text"`` header; the default keeps + the historical HTML body for existing callers.""" + def _shape(msg): from_obj = msg.get("from", {}).get("emailAddress", {}) to_list = [ @@ -402,10 +406,13 @@ def _shape(msg): "body": msg.get("body", {}).get("content", ""), } + headers = self._auth_header() + if not include_metadata: + headers["Prefer"] = 'outlook.body-content-type="text"' return http_request( "GET", f"{GRAPH_API_BASE}/me/messages/{message_id}", - headers=self._auth_header(), + headers=headers, params={ "$select": "id,from,toRecipients,subject,body,receivedDateTime,conversationId" }, @@ -443,7 +450,9 @@ def list_folders(self) -> Result: }, ) - def read_top_emails(self, n: int = 5, full_body: bool = False) -> Result: + def read_top_emails( + self, n: int = 5, full_body: bool = False, include_metadata: bool = True + ) -> Result: listing = self.list_emails(n=n, unread_only=False) if "error" in listing: return listing @@ -452,7 +461,7 @@ def read_top_emails(self, n: int = 5, full_body: bool = False) -> Result: return {"ok": True, "result": emails_summary} detailed = [] for e_info in emails_summary: - detail = self.get_email(e_info["id"]) + detail = self.get_email(e_info["id"], include_metadata=include_metadata) detailed.append( detail.get("result", e_info) if "error" not in detail else e_info ) diff --git a/craftos_integrations/integrations/twitter/__init__.py b/craftos_integrations/integrations/twitter/__init__.py index c9870696..5b6224de 100644 --- a/craftos_integrations/integrations/twitter/__init__.py +++ b/craftos_integrations/integrations/twitter/__init__.py @@ -70,6 +70,25 @@ def _twitter_config_file() -> str: return (stem[:-5] if stem.endswith(".json") else stem) + "_config.json" +def _lean_tweet_body(body: Any) -> Any: + """Read-shaping for ``include_metadata=False``: drop the response ``meta`` + block and per-tweet ``edit_history_tweet_ids`` noise. Applied as an + ``arequest`` transform, so it only ever runs on successful responses.""" + if not isinstance(body, dict): + return body if body is not None else {} + body = dict(body) + body.pop("meta", None) + data = body.get("data") + if isinstance(data, list): + body["data"] = [ + {k: v for k, v in t.items() if k != "edit_history_tweet_ids"} + if isinstance(t, dict) + else t + for t in data + ] + return body + + def _oauth1_header( method: str, url: str, @@ -519,7 +538,10 @@ async def delete_tweet(self, tweet_id: str) -> Result: ) async def get_user_timeline( - self, user_id: Optional[str] = None, max_results: int = 10 + self, + user_id: Optional[str] = None, + max_results: int = 10, + include_metadata: bool = True, ) -> Result: cred = self._load() uid = user_id or cred.user_id @@ -528,7 +550,9 @@ async def get_user_timeline( url = f"{TWITTER_API}/users/{uid}/tweets" params = { "max_results": str(max_results), - "tweet.fields": "created_at,public_metrics,text", + "tweet.fields": "created_at,public_metrics,text" + if include_metadata + else "created_at,author_id", } return await arequest( "GET", @@ -536,14 +560,19 @@ async def get_user_timeline( headers=self._auth_header("GET", url, params), params=params, expected=(200,), + transform=None if include_metadata else _lean_tweet_body, ) - async def search_tweets(self, query: str, max_results: int = 10) -> Result: + async def search_tweets( + self, query: str, max_results: int = 10, include_metadata: bool = True + ) -> Result: url = f"{TWITTER_API}/tweets/search/recent" params = { "query": query, "max_results": str(max_results), - "tweet.fields": "created_at,author_id,public_metrics,text", + "tweet.fields": "created_at,author_id,public_metrics,text" + if include_metadata + else "created_at,author_id", "expansions": "author_id", "user.fields": "username", } @@ -553,6 +582,7 @@ async def search_tweets(self, query: str, max_results: int = 10) -> Result: headers=self._auth_header("GET", url, params), params=params, expected=(200,), + transform=None if include_metadata else _lean_tweet_body, ) async def like_tweet(self, tweet_id: str) -> Result: @@ -616,12 +646,16 @@ async def get_tweet(self, tweet_id: str) -> Result: transform=lambda d: d.get("data", d), ) - async def lookup_tweets(self, tweet_ids: List[str]) -> Result: + async def lookup_tweets( + self, tweet_ids: List[str], include_metadata: bool = True + ) -> Result: """Batch-lookup multiple tweets by id (up to 100 per call).""" url = f"{TWITTER_API}/tweets" params = { "ids": ",".join(tweet_ids[:100]), - "tweet.fields": "created_at,author_id,public_metrics,text", + "tweet.fields": "created_at,author_id,public_metrics,text" + if include_metadata + else "created_at,author_id", } return await arequest( "GET", @@ -629,10 +663,14 @@ async def lookup_tweets(self, tweet_ids: List[str]) -> Result: headers=self._auth_header("GET", url, params), params=params, expected=(200,), + transform=None if include_metadata else _lean_tweet_body, ) async def get_user_mentions( - self, user_id: Optional[str] = None, max_results: int = 10 + self, + user_id: Optional[str] = None, + max_results: int = 10, + include_metadata: bool = True, ) -> Result: """Recent mentions of a user (defaults to the authed user).""" cred = self._load() @@ -642,7 +680,9 @@ async def get_user_mentions( url = f"{TWITTER_API}/users/{uid}/mentions" params = { "max_results": str(max_results), - "tweet.fields": "created_at,author_id,text,conversation_id", + "tweet.fields": "created_at,author_id,text,conversation_id" + if include_metadata + else "created_at,author_id", "expansions": "author_id", "user.fields": "username,name", } @@ -652,6 +692,7 @@ async def get_user_mentions( headers=self._auth_header("GET", url, params), params=params, expected=(200,), + transform=None if include_metadata else _lean_tweet_body, ) async def post_quote_tweet(self, text: str, quoted_tweet_id: str) -> Result: @@ -757,12 +798,16 @@ async def remove_bookmark(self, tweet_id: str) -> Result: transform=lambda d: d.get("data", d), ) - async def list_bookmarks(self, max_results: int = 50) -> Result: + async def list_bookmarks( + self, max_results: int = 50, include_metadata: bool = True + ) -> Result: cred = self._load() url = f"{TWITTER_API}/users/{cred.user_id}/bookmarks" params = { "max_results": str(max_results), - "tweet.fields": "created_at,author_id,public_metrics,text", + "tweet.fields": "created_at,author_id,public_metrics,text" + if include_metadata + else "created_at,author_id", } return await arequest( "GET", @@ -770,6 +815,7 @@ async def list_bookmarks(self, max_results: int = 50) -> Result: headers=self._auth_header("GET", url, params), params=params, expected=(200,), + transform=None if include_metadata else _lean_tweet_body, ) async def list_liking_users(self, tweet_id: str, max_results: int = 50) -> Result: @@ -1038,11 +1084,15 @@ async def list_list_members(self, list_id: str, max_results: int = 100) -> Resul expected=(200,), ) - async def list_list_tweets(self, list_id: str, max_results: int = 100) -> Result: + async def list_list_tweets( + self, list_id: str, max_results: int = 100, include_metadata: bool = True + ) -> Result: url = f"{TWITTER_API}/lists/{list_id}/tweets" params = { "max_results": str(max_results), - "tweet.fields": "created_at,author_id,public_metrics,text", + "tweet.fields": "created_at,author_id,public_metrics,text" + if include_metadata + else "created_at,author_id", } return await arequest( "GET", @@ -1050,6 +1100,7 @@ async def list_list_tweets(self, list_id: str, max_results: int = 100) -> Result headers=self._auth_header("GET", url, params), params=params, expected=(200,), + transform=None if include_metadata else _lean_tweet_body, ) # ----- Direct Messages ----- diff --git a/craftos_integrations/integrations/whatsapp_web/__init__.py b/craftos_integrations/integrations/whatsapp_web/__init__.py index a0a2c57b..75c4c9be 100644 --- a/craftos_integrations/integrations/whatsapp_web/__init__.py +++ b/craftos_integrations/integrations/whatsapp_web/__init__.py @@ -242,6 +242,22 @@ async def status(self) -> Tuple[bool, str]: # ════════════════════════════════════════════════════════════════════════ +def _bridge_result(result: Dict[str, Any], ok: Optional[bool] = None) -> Dict[str, Any]: + """Wrap a bridge response for return: derive ``status`` and drop the + bridge's redundant ``success`` bool — ``status`` already carries it, and + shipping both doubled the envelope on every WhatsApp action result. + + ``ok`` overrides the derived status; when omitted, a missing ``success`` + key counts as success (matching the call sites that hard-coded it). + """ + if ok is None: + ok = bool(result.get("success", True)) + return { + "status": "success" if ok else "error", + **{k: v for k, v in result.items() if k != "success"}, + } + + @register_client class WhatsAppWebClient(BasePlatformClient): spec = WHATSAPP_WEB @@ -320,7 +336,7 @@ async def send_message(self, recipient: str, text: str, **kwargs) -> Dict[str, A msg_id = result.get("message_id") if msg_id: self._agent_sent_ids.add(msg_id) - return {"status": "success" if result.get("success") else "error", **result} + return _bridge_result(result) async def send_media( self, @@ -348,7 +364,7 @@ async def send_media( msg_id = result.get("message_id") if msg_id: self._agent_sent_ids.add(msg_id) - return {"status": "success" if result.get("success") else "error", **result} + return _bridge_result(result) async def send_location( self, recipient: str, latitude: float, longitude: float, description: str = "" @@ -358,7 +374,7 @@ async def send_location( return {"status": "error", "error": "Bridge not ready"} resolved = self._resolve_recipient(recipient) result = await bridge.send_location(resolved, latitude, longitude, description) - return {"status": "success" if result.get("success") else "error", **result} + return _bridge_result(result) async def send_reply( self, recipient: str, text: str, quoted_message_id: str @@ -372,14 +388,14 @@ async def send_reply( msg_id = result.get("message_id") if msg_id: self._agent_sent_ids.add(msg_id) - return {"status": "success" if result.get("success") else "error", **result} + return _bridge_result(result) async def edit_message(self, message_id: str, new_body: str) -> Dict[str, Any]: bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} result = await bridge.edit_message(message_id, new_body) - return {"status": "success" if result.get("success") else "error", **result} + return _bridge_result(result) async def delete_message( self, message_id: str, everyone: bool = False @@ -388,7 +404,7 @@ async def delete_message( if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} result = await bridge.delete_message(message_id, everyone) - return {"status": "success" if result.get("success") else "error", **result} + return _bridge_result(result) async def forward_message(self, message_id: str, recipient: str) -> Dict[str, Any]: bridge = self._get_bridge() @@ -396,14 +412,14 @@ async def forward_message(self, message_id: str, recipient: str) -> Dict[str, An return {"status": "error", "error": "Bridge not ready"} resolved = self._resolve_recipient(recipient) result = await bridge.forward_message(message_id, resolved) - return {"status": "success" if result.get("success") else "error", **result} + return _bridge_result(result) async def react_message(self, message_id: str, emoji: str) -> Dict[str, Any]: bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} result = await bridge.react_message(message_id, emoji) - return {"status": "success" if result.get("success") else "error", **result} + return _bridge_result(result) async def star_message( self, message_id: str, starred: bool = True @@ -412,7 +428,7 @@ async def star_message( if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} result = await bridge.star_message(message_id, starred) - return {"status": "success" if result.get("success") else "error", **result} + return _bridge_result(result) async def download_message_media( self, message_id: str, dest_path: str @@ -425,7 +441,7 @@ async def download_message_media( return {"status": "error", "error": "Bridge not ready"} result = await bridge.download_message_media(message_id) if not result.get("success"): - return {"status": "error", **result} + return _bridge_result(result, ok=False) data_b64 = result.get("data_b64", "") if not data_b64: return {"status": "error", "error": "No media data returned"} @@ -451,7 +467,7 @@ async def get_quoted_message(self, message_id: str) -> Dict[str, Any]: if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} result = await bridge.get_quoted_message(message_id) - return {"status": "success" if result.get("success") else "error", **result} + return _bridge_result(result) # ----- Chat operations ----- @@ -459,25 +475,25 @@ async def mark_chat_read(self, chat_id: str) -> Dict[str, Any]: bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} - return {"status": "success", **(await bridge.mark_chat_read(chat_id))} + return _bridge_result(await bridge.mark_chat_read(chat_id)) async def mark_chat_unread(self, chat_id: str) -> Dict[str, Any]: bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} - return {"status": "success", **(await bridge.mark_chat_unread(chat_id))} + return _bridge_result(await bridge.mark_chat_unread(chat_id)) async def archive_chat(self, chat_id: str, archive: bool = True) -> Dict[str, Any]: bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} - return {"status": "success", **(await bridge.archive_chat(chat_id, archive))} + return _bridge_result(await bridge.archive_chat(chat_id, archive)) async def pin_chat(self, chat_id: str, pin: bool = True) -> Dict[str, Any]: bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} - return {"status": "success", **(await bridge.pin_chat(chat_id, pin))} + return _bridge_result(await bridge.pin_chat(chat_id, pin)) async def mute_chat( self, chat_id: str, mute: bool = True, unmute_date: Optional[int] = None @@ -485,22 +501,19 @@ async def mute_chat( bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} - return { - "status": "success", - **(await bridge.mute_chat(chat_id, mute, unmute_date)), - } + return _bridge_result(await bridge.mute_chat(chat_id, mute, unmute_date)) async def clear_chat_messages(self, chat_id: str) -> Dict[str, Any]: bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} - return {"status": "success", **(await bridge.clear_chat_messages(chat_id))} + return _bridge_result(await bridge.clear_chat_messages(chat_id)) async def delete_chat(self, chat_id: str) -> Dict[str, Any]: bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} - return {"status": "success", **(await bridge.delete_chat(chat_id))} + return _bridge_result(await bridge.delete_chat(chat_id)) async def send_typing_state( self, chat_id: str, state: str = "typing" @@ -508,7 +521,7 @@ async def send_typing_state( bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} - return {"status": "success", **(await bridge.send_typing_state(chat_id, state))} + return _bridge_result(await bridge.send_typing_state(chat_id, state)) # ----- Groups ----- @@ -517,7 +530,7 @@ async def create_group(self, name: str, participants: list) -> Dict[str, Any]: if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} result = await bridge.create_group(name, participants) - return {"status": "success" if result.get("success") else "error", **result} + return _bridge_result(result) async def group_add_participants( self, group_id: str, participants: list @@ -525,10 +538,7 @@ async def group_add_participants( bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} - return { - "status": "success", - **(await bridge.group_add_participants(group_id, participants)), - } + return _bridge_result(await bridge.group_add_participants(group_id, participants)) async def group_remove_participants( self, group_id: str, participants: list @@ -536,10 +546,7 @@ async def group_remove_participants( bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} - return { - "status": "success", - **(await bridge.group_remove_participants(group_id, participants)), - } + return _bridge_result(await bridge.group_remove_participants(group_id, participants)) async def group_promote_participants( self, group_id: str, participants: list @@ -547,10 +554,7 @@ async def group_promote_participants( bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} - return { - "status": "success", - **(await bridge.group_promote_participants(group_id, participants)), - } + return _bridge_result(await bridge.group_promote_participants(group_id, participants)) async def group_demote_participants( self, group_id: str, participants: list @@ -558,19 +562,13 @@ async def group_demote_participants( bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} - return { - "status": "success", - **(await bridge.group_demote_participants(group_id, participants)), - } + return _bridge_result(await bridge.group_demote_participants(group_id, participants)) async def group_set_subject(self, group_id: str, subject: str) -> Dict[str, Any]: bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} - return { - "status": "success", - **(await bridge.group_set_subject(group_id, subject)), - } + return _bridge_result(await bridge.group_set_subject(group_id, subject)) async def group_set_description( self, group_id: str, description: str @@ -578,40 +576,37 @@ async def group_set_description( bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} - return { - "status": "success", - **(await bridge.group_set_description(group_id, description)), - } + return _bridge_result(await bridge.group_set_description(group_id, description)) async def group_get_info(self, group_id: str) -> Dict[str, Any]: bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} - return {"status": "success", **(await bridge.group_get_info(group_id))} + return _bridge_result(await bridge.group_get_info(group_id)) async def group_leave(self, group_id: str) -> Dict[str, Any]: bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} - return {"status": "success", **(await bridge.group_leave(group_id))} + return _bridge_result(await bridge.group_leave(group_id)) async def group_invite_code(self, group_id: str) -> Dict[str, Any]: bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} - return {"status": "success", **(await bridge.group_invite_code(group_id))} + return _bridge_result(await bridge.group_invite_code(group_id)) async def group_revoke_invite(self, group_id: str) -> Dict[str, Any]: bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} - return {"status": "success", **(await bridge.group_revoke_invite(group_id))} + return _bridge_result(await bridge.group_revoke_invite(group_id)) async def accept_group_invite(self, invite_code: str) -> Dict[str, Any]: bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} - return {"status": "success", **(await bridge.accept_group_invite(invite_code))} + return _bridge_result(await bridge.accept_group_invite(invite_code)) # ----- Contacts ----- @@ -621,19 +616,19 @@ async def block_contact( bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} - return {"status": "success", **(await bridge.block_contact(contact_id, block))} + return _bridge_result(await bridge.block_contact(contact_id, block)) async def get_profile_pic_url(self, contact_id: str) -> Dict[str, Any]: bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} - return {"status": "success", **(await bridge.get_profile_pic_url(contact_id))} + return _bridge_result(await bridge.get_profile_pic_url(contact_id)) async def get_contact(self, contact_id: str) -> Dict[str, Any]: bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} - return {"status": "success", **(await bridge.get_contact(contact_id))} + return _bridge_result(await bridge.get_contact(contact_id)) async def get_all_contacts( self, my_contacts_only: bool = True, limit: int = 500 @@ -641,16 +636,13 @@ async def get_all_contacts( bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} - return { - "status": "success", - **(await bridge.get_all_contacts(my_contacts_only, limit)), - } + return _bridge_result(await bridge.get_all_contacts(my_contacts_only, limit)) async def check_number_on_whatsapp(self, number: str) -> Dict[str, Any]: bridge = self._get_bridge() if not bridge.is_ready: return {"status": "error", "error": "Bridge not ready"} - return {"status": "success", **(await bridge.check_number_on_whatsapp(number))} + return _bridge_result(await bridge.check_number_on_whatsapp(number)) async def get_chat_messages( self, phone_number: str, limit: int = 50 @@ -659,21 +651,21 @@ async def get_chat_messages( if not bridge.is_ready: return {"success": False, "error": "Bridge not ready"} result = await bridge.get_chat_messages(chat_id=phone_number, limit=limit) - return {"status": "success" if result.get("success") else "error", **result} + return _bridge_result(result) async def get_unread_chats(self) -> Dict[str, Any]: bridge = self._get_bridge() if not bridge.is_ready: return {"success": False, "error": "Bridge not ready"} result = await bridge.get_unread_chats() - return {"status": "success" if result.get("success") else "error", **result} + return _bridge_result(result) async def search_contact(self, name: str) -> Dict[str, Any]: bridge = self._get_bridge() if not bridge.is_ready: return {"success": False, "error": "Bridge not ready"} result = await bridge.search_contact(name=name) - return {"status": "success" if result.get("success") else "error", **result} + return _bridge_result(result) async def get_session_status(self) -> Optional[Dict[str, Any]]: bridge = self._get_bridge() From 6b94deadb2dd816bfd6bbee3c1c2f20d7ec2d1a9 Mon Sep 17 00:00:00 2001 From: CraftBot Date: Mon, 13 Jul 2026 09:02:00 +0900 Subject: [PATCH 16/28] Fix chatgpt subscription client issue when invoking describe image action --- .../models/chatgpt_subscription_client.py | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/agent_core/core/models/chatgpt_subscription_client.py b/agent_core/core/models/chatgpt_subscription_client.py index 9a3dc140..30fd29bf 100644 --- a/agent_core/core/models/chatgpt_subscription_client.py +++ b/agent_core/core/models/chatgpt_subscription_client.py @@ -344,12 +344,46 @@ def _consume_stream(stream: Any) -> Dict[str, Any]: } +def _normalize_content_part(part: Any, role: str) -> Any: + """Translate one Chat-Completions content part into the Responses dialect. + + - ``{"type": "text", ...}`` → ``input_text`` (``output_text`` for + assistant role) + - ``{"type": "image_url", ...}`` → ``input_image`` with ``image_url`` as + a plain string (Chat Completions nests it as ``{"url": ...}``); + ``detail`` is preserved when present. + + Parts already typed in the Responses dialect (``input_text``, + ``input_image``, ``output_text``, ...) and anything unrecognized pass + through unchanged. + """ + if not isinstance(part, dict): + return part + part_type = part.get("type") + if part_type == "text": + text_type = "output_text" if role == "assistant" else "input_text" + return {"type": text_type, "text": part.get("text", "")} + if part_type == "image_url": + image_url = part.get("image_url") + detail = None + if isinstance(image_url, dict): + detail = image_url.get("detail") + image_url = image_url.get("url", "") + translated: Dict[str, Any] = {"type": "input_image", "image_url": image_url} + if detail: + translated["detail"] = detail + return translated + return part + + def _normalize_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Coerce Chat-Completions message items into Responses-API ``input`` items. For string content we wrap into the typed parts shape the Responses API expects (``{"type":"input_text"...}`` for non-assistant roles, - ``{"type":"output_text"...}`` for assistant). + ``{"type":"output_text"...}`` for assistant). List content has each + part translated from the Chat-Completions dialect (``text``, + ``image_url``) via ``_normalize_content_part``. Also strips any ``id`` field from each item. Under ``store=false`` Codex tries to resolve item ids server-side and 404s when it can't @@ -363,9 +397,12 @@ def _normalize_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: if isinstance(content, str): part_type = "output_text" if role == "assistant" else "input_text" item = {"role": role, "content": [{"type": part_type, "text": content}]} + elif isinstance(content, list): + item = { + "role": role, + "content": [_normalize_content_part(p, role) for p in content], + } else: - # Already-typed content (image parts, etc.) — pass through, - # but still drop any top-level id below. item = {"role": role, "content": content} # id is intentionally NOT copied even if present on m. normalized.append(item) From d6c59f5bc1789f391c19611ce3fcb3d35ba35883 Mon Sep 17 00:00:00 2001 From: CraftBot Date: Fri, 17 Jul 2026 07:04:44 +0900 Subject: [PATCH 17/28] Fix Grok API error incorrect issue --- agent_core/core/impl/llm/errors.py | 25 ++++++++-- agent_core/core/models/connection_tester.py | 14 +++++- agent_core/core/models/factory.py | 55 ++++++++++++++++++++- app/ui_layer/adapters/browser_adapter.py | 33 ++++++++++++- 4 files changed, 117 insertions(+), 10 deletions(-) diff --git a/agent_core/core/impl/llm/errors.py b/agent_core/core/impl/llm/errors.py index 916c17e9..87a0ef1b 100644 --- a/agent_core/core/impl/llm/errors.py +++ b/agent_core/core/impl/llm/errors.py @@ -573,11 +573,13 @@ def _classify_anthropic(exc: Exception, provider: str) -> LLMErrorInfo: def _classify_httpx_status(exc: Exception, provider: Optional[str]) -> LLMErrorInfo: - """httpx.HTTPStatusError — covers Gemini and BytePlus paths. + """httpx.HTTPStatusError — covers Gemini, BytePlus and connection-tester paths. Gemini body: {"error":{"code":400,"message":"...","status":"INVALID_ARGUMENT", "details":[{"reason":"API_KEY_INVALID",...}]}} BytePlus body: {"error":{"code":"AuthenticationError","message":"..."}} + xAI body: {"code":"invalid-argument","error":"Incorrect API key provided..."} + (``error`` is a plain string, not an object) """ if httpx is None: # pragma: no cover return _fallback_unknown(exc, provider or "unknown") @@ -587,10 +589,14 @@ def _classify_httpx_status(exc: Exception, provider: Optional[str]) -> LLMErrorI text = response.text if response is not None else "" body_dict = _safe_json(text) - err = body_dict.get("error") if isinstance(body_dict.get("error"), dict) else {} - raw_message = ( - err.get("message") if isinstance(err.get("message"), str) else str(exc) - ) + error_field = body_dict.get("error") + err = error_field if isinstance(error_field, dict) else {} + if isinstance(error_field, str) and error_field.strip(): + raw_message = error_field + elif isinstance(err.get("message"), str): + raw_message = err["message"] + else: + raw_message = str(exc) # Detect Gemini specifically by reason field reason: Optional[str] = None @@ -616,6 +622,15 @@ def _classify_httpx_status(exc: Exception, provider: Optional[str]) -> LLMErrorI # BytePlus encodes auth errors via err.code = "AuthenticationError" if isinstance(err.get("code"), str) and "auth" in err["code"].lower(): category = ErrorCategory.AUTH + # xAI (Grok) returns 400 — not 401 — for rejected bearers; sniff the + # body the same way the OpenAI-SDK path does for BadRequestError. + lower = raw_message.lower() + if status == 400 and ( + "api key" in lower + or "api_key" in lower + or "access token" in lower + ): + category = ErrorCategory.AUTH retry_after = None if response is not None: diff --git a/agent_core/core/models/connection_tester.py b/agent_core/core/models/connection_tester.py index 7d3bde4d..703f5d00 100644 --- a/agent_core/core/models/connection_tester.py +++ b/agent_core/core/models/connection_tester.py @@ -703,8 +703,18 @@ def _test_grok( if response.status_code == 200: return _success("grok", model) if response.status_code in (400, 422) and model is None: - # Hardcoded test model probably hit a tier restriction; auth still OK. - return _success("grok", None) + # Hardcoded test model probably hit a tier restriction; auth still + # OK — but xAI returns 400 (not 401) for rejected bearers too + # ({"code":"invalid-argument","error":"Incorrect API key + # provided..."}), so only call it a success when the body isn't + # complaining about credentials. + lower = response.text.lower() + if not ( + "api key" in lower + or "api_key" in lower + or "access token" in lower + ): + return _success("grok", None) response.raise_for_status() return { "success": False, diff --git a/agent_core/core/models/factory.py b/agent_core/core/models/factory.py index ffef81f4..462c4a20 100644 --- a/agent_core/core/models/factory.py +++ b/agent_core/core/models/factory.py @@ -66,8 +66,19 @@ def _create_openai_client( api_key: str, base_url: Optional[str] = None, default_headers: Optional[dict] = None, + oauth_provider: Optional[str] = None, ): - """Create an OpenAI SDK client for OpenAI-compatible providers.""" + """Create an OpenAI SDK client for OpenAI-compatible providers. + + When ``oauth_provider`` is set, the client authenticates with that + provider's subscription OAuth bearer, re-resolved on every request. + Subscription access tokens expire within hours; a token baked in at + construction goes stale and every call starts failing with 400 + ("The OAuth2 access token could not be validated") until the LLM is + manually reinitialized. The SDK evaluates ``auth_headers`` per request, + so resolving through ``tokens.get_bearer`` there picks up the + refresh-on-expiry contract that module already implements. + """ try: from openai import OpenAI except ImportError as exc: @@ -84,7 +95,45 @@ def _create_openai_client( kwargs["base_url"] = base_url if default_headers: kwargs["default_headers"] = default_headers - return OpenAI(**kwargs) + if oauth_provider is None: + return OpenAI(**kwargs) + + class _SubscriptionOpenAI(OpenAI): + @property + def auth_headers(self) -> dict: + try: + from craftos_integrations.integrations.llm_oauth.tokens import ( + get_bearer, + ) + + bearer = get_bearer(oauth_provider) + if bearer is not None: + # Keep the latest good token so the fallback below and + # any SDK code reading ``api_key`` stay current. + self.api_key = bearer[0] + else: + # Credential removed mid-session (user disconnected the + # subscription). The token we hold is dead — fail with + # an actionable message instead of an opaque 400. + raise RuntimeError( + f"The {oauth_provider} subscription this model was " + "using has been disconnected. Save your model " + "settings (or reconnect the subscription) to switch " + "to API-key auth." + ) + except RuntimeError: + # Credential exists but refresh failed (or was removed) — + # surface the actionable message instead of letting a stale + # token 400 with an opaque provider error. + raise + except Exception as e: + logger.warning( + f"[FACTORY] {oauth_provider} bearer re-resolve failed; " + f"using last known token: {e}" + ) + return {"Authorization": f"Bearer {self.api_key}"} + + return _SubscriptionOpenAI(**kwargs) def _create_anthropic_client(*, api_key: str): @@ -283,6 +332,7 @@ def create( api_key=access_token, base_url=sub_base_url, default_headers=extra_headers, + oauth_provider=provider, ) return { "provider": provider, @@ -406,6 +456,7 @@ def create( api_key=access_token, base_url=sub_base_url or resolved_base_url, default_headers=extra_headers, + oauth_provider=provider, ), "gemini_client": None, "remote_url": None, diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index 8c44c52d..b98085ae 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -5988,9 +5988,39 @@ def _activate_provider_via_settings( return None async def _handle_model_subscription_disconnect(self, provider: str) -> None: - """Remove stored OAuth credentials for the given provider.""" + """Remove stored OAuth credentials for the given provider. + + If the disconnected provider is the active LLM, the live interface + still holds a client authenticated with the (now deleted) OAuth + bearer, so every call would keep failing with "The OAuth2 access + token could not be validated" until an app restart. Reinitialize so + the factory rebuilds the client — falling back to the stored API key + now that the subscription credential is gone. + """ try: success, message = disconnect_subscription(provider) + warning = None + if success: + try: + from app.ui_layer.settings.provider_settings import ( + get_current_provider, + ) + + if get_current_provider() == provider: + self._controller.agent.reinitialize_llm(provider) + logger.info( + f"[BROWSER] LLM reinitialized with provider {provider} " + "after subscription disconnect (API-key mode)" + ) + except Exception as e: + logger.warning( + f"[BROWSER] LLM reinit after {provider} subscription " + f"disconnect failed: {e}" + ) + warning = ( + "Subscription disconnected, but the model could not be " + f"reinitialized: {e}" + ) await self._broadcast( { "type": "model_subscription_disconnect", @@ -5998,6 +6028,7 @@ async def _handle_model_subscription_disconnect(self, provider: str) -> None: "success": success, "provider": provider, "message": message, + "warning": warning, "status": get_subscription_status(provider), }, } From c2224dbf590d81848e2c83268b89580c984d601b Mon Sep 17 00:00:00 2001 From: CraftBot Date: Fri, 17 Jul 2026 09:24:13 +0900 Subject: [PATCH 18/28] fix saved model not displayed correctly issue and updated the default Anthorpic model --- agent_core/core/models/model_registry.py | 4 ++-- agent_file_system/AGENT.md | 6 +++--- app/data/agent_file_system_template/AGENT.md | 6 +++--- .../frontend/src/pages/Settings/ModelSettings.tsx | 11 ++++++++--- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/agent_core/core/models/model_registry.py b/agent_core/core/models/model_registry.py index 938c4219..7cf2e175 100644 --- a/agent_core/core/models/model_registry.py +++ b/agent_core/core/models/model_registry.py @@ -19,8 +19,8 @@ InterfaceType.VIDEO_GEN: "veo-3.1-generate-preview", }, "anthropic": { - InterfaceType.LLM: "claude-sonnet-4-5-20250929", - InterfaceType.VLM: "claude-sonnet-4-5-20250929", + InterfaceType.LLM: "claude-sonnet-4-6", + InterfaceType.VLM: "claude-sonnet-4-6", InterfaceType.EMBEDDING: None, # Anthropic does not provide native embedding models InterfaceType.IMAGE_GEN: None, InterfaceType.VIDEO_GEN: None, diff --git a/agent_file_system/AGENT.md b/agent_file_system/AGENT.md index 0c9f2df8..0feb347e 100644 --- a/agent_file_system/AGENT.md +++ b/agent_file_system/AGENT.md @@ -1780,7 +1780,7 @@ memory: model: llm_provider: "openai" | "anthropic" | "google" | "byteplus" | "remote" vlm_provider: same options - llm_model: string | null (null = provider default; e.g. "claude-sonnet-4-5-20250929") + llm_model: string | null (null = provider default; e.g. "claude-sonnet-4-6") vlm_model: string | null slow_mode: bool (true throttles requests for rate-limited providers) slow_mode_tpm_limit: int (tokens per minute when slow_mode is true) @@ -1978,7 +1978,7 @@ Switch LLM provider: read_file app/config/settings.json stream_edit app/config/settings.json model.llm_provider: "openai" → "anthropic" - model.llm_model: "" → "claude-sonnet-4-5-20250929" + model.llm_model: "" → "claude-sonnet-4-6" api_keys.anthropic must be set or the next LLM call fails (see ## Models). ``` @@ -2868,7 +2868,7 @@ From [MODEL_REGISTRY](agent_core/core/models/model_registry.py): provider LLM default model VLM default model EMBEDDING default notes ───────── ────────────────────── ────────────────────── ────────────────────── ───────────────────────────── openai gpt-5.2-2025-12-11 gpt-5.2-2025-12-11 text-embedding-3-small OpenAI-hosted -anthropic claude-sonnet-4-5-20250929 claude-sonnet-4-5-20250929 (none — no embedding) Claude models +anthropic claude-sonnet-4-6 claude-sonnet-4-6 (none — no embedding) Claude models gemini gemini-2.5-pro gemini-2.5-pro text-embedding-004 Google Gemini byteplus seed-2-0-pro-260328 seed-2-0-pro-260328 skylark-embedding-... BytePlus-hosted remote llama3.2:3b llava:7b nomic-embed-text Ollama or OpenAI-compat diff --git a/app/data/agent_file_system_template/AGENT.md b/app/data/agent_file_system_template/AGENT.md index c43fa837..193e5b29 100644 --- a/app/data/agent_file_system_template/AGENT.md +++ b/app/data/agent_file_system_template/AGENT.md @@ -1846,7 +1846,7 @@ memory: model: llm_provider: "openai" | "anthropic" | "google" | "byteplus" | "remote" vlm_provider: same options - llm_model: string | null (null = provider default; e.g. "claude-sonnet-4-5-20250929") + llm_model: string | null (null = provider default; e.g. "claude-sonnet-4-6") vlm_model: string | null slow_mode: bool (true throttles requests for rate-limited providers) slow_mode_tpm_limit: int (tokens per minute when slow_mode is true) @@ -2044,7 +2044,7 @@ Switch LLM provider: read_file app/config/settings.json stream_edit app/config/settings.json model.llm_provider: "openai" → "anthropic" - model.llm_model: "" → "claude-sonnet-4-5-20250929" + model.llm_model: "" → "claude-sonnet-4-6" api_keys.anthropic must be set or the next LLM call fails (see ## Models). ``` @@ -2934,7 +2934,7 @@ From [MODEL_REGISTRY](agent_core/core/models/model_registry.py): provider LLM default model VLM default model EMBEDDING default notes ───────── ────────────────────── ────────────────────── ────────────────────── ───────────────────────────── openai gpt-5.2-2025-12-11 gpt-5.2-2025-12-11 text-embedding-3-small OpenAI-hosted -anthropic claude-sonnet-4-5-20250929 claude-sonnet-4-5-20250929 (none — no embedding) Claude models +anthropic claude-sonnet-4-6 claude-sonnet-4-6 (none — no embedding) Claude models gemini gemini-2.5-pro gemini-2.5-pro text-embedding-004 Google Gemini byteplus seed-2-0-pro-260328 seed-2-0-pro-260328 skylark-embedding-... BytePlus-hosted remote llama3.2:3b llava:7b nomic-embed-text Ollama or OpenAI-compat diff --git a/app/ui_layer/browser/frontend/src/pages/Settings/ModelSettings.tsx b/app/ui_layer/browser/frontend/src/pages/Settings/ModelSettings.tsx index f915dacc..43973dab 100644 --- a/app/ui_layer/browser/frontend/src/pages/Settings/ModelSettings.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Settings/ModelSettings.tsx @@ -357,13 +357,18 @@ export function ModelSettings() { return () => cleanups.forEach(cleanup => cleanup()) }, [isConnected, onMessage, send, dispatch, testBeforeSave, provider, newApiKey, newBaseUrl, baseUrls, selectedPullModel, currentLlmModel, currentVlmModel, showToast, newAwsAccessKeyId, newAwsSecretAccessKey, newAwsSessionToken, newAwsRegion, newLlmModel, newVlmModel]) - // Load initial data only once when connected, cached across remounts. + // Load initial data when connected. Providers/slow-mode are cached across + // remounts, but settings are ALWAYS refetched: the page must show what's + // actually saved. With the old load-once cache, a tab that outlived a + // backend restart (the socket reconnects without a page reload) kept + // rendering stale Redux state, so the model field showed the registry + // default instead of the user's saved model. useEffect(() => { if (!isConnected) return if (!hasLoadedProviders) send('model_providers_get') - if (!hasLoadedSettings) send('model_settings_get') + send('model_settings_get') if (!hasLoadedSlowMode) send('slow_mode_get') - }, [isConnected, send, hasLoadedProviders, hasLoadedSettings, hasLoadedSlowMode]) + }, [isConnected, send, hasLoadedProviders, hasLoadedSlowMode]) // Fetch Ollama models whenever the active provider is 'remote' useEffect(() => { From 9fcab95ae86df0370a0c30d4d656b077302069b7 Mon Sep 17 00:00:00 2001 From: CraftBot Date: Sun, 19 Jul 2026 09:00:37 +0900 Subject: [PATCH 19/28] Update README with LivingUI section still pending --- README.md | 4 + mkdocs/.gitignore | 2 + mkdocs/docs/assets/android-chrome-192x192.png | Bin 0 -> 4361 bytes .../craftbot_logo_text_no_border_dark.png | Bin 0 -> 14917 bytes .../craftbot_logo_text_no_border_light.png | Bin 0 -> 15557 bytes mkdocs/docs/assets/favicon.ico | Bin 15406 -> 15406 bytes mkdocs/docs/connections.md | 230 ---------- mkdocs/docs/core/commands/builtin.md | 172 +++++++ mkdocs/docs/core/commands/cli-anything.md | 83 ++++ mkdocs/docs/core/commands/index.md | 62 +++ .../core/concepts/actions-and-action-sets.md | 103 +++++ mkdocs/docs/core/concepts/agent-bundles.md | 95 ++++ .../docs/core/concepts/agent-file-system.md | 83 ++++ mkdocs/docs/core/concepts/agent-loop.md | 98 ++++ mkdocs/docs/core/concepts/context-engine.md | 96 ++++ mkdocs/docs/core/concepts/event-stream.md | 106 +++++ mkdocs/docs/core/concepts/logs.md | 100 ++++ mkdocs/docs/core/concepts/memory.md | 92 ++++ mkdocs/docs/core/concepts/prompts.md | 73 +++ mkdocs/docs/core/concepts/scheduling.md | 108 +++++ mkdocs/docs/core/concepts/skills.md | 97 ++++ mkdocs/docs/core/concepts/sub-agents.md | 66 +++ mkdocs/docs/core/concepts/task-sessions.md | 112 +++++ mkdocs/docs/core/concepts/triggers.md | 123 +++++ .../core/configuration/agent-config-yaml.md | 98 ++++ mkdocs/docs/core/configuration/config-json.md | 189 ++++++++ mkdocs/docs/core/configuration/index.md | 47 ++ mkdocs/docs/core/index.md | 123 +++++ mkdocs/docs/core/interfaces/browser.md | 106 +++++ mkdocs/docs/core/interfaces/cli.md | 91 ++++ mkdocs/docs/core/interfaces/index.md | 66 +++ mkdocs/docs/core/interfaces/ui-layer.md | 74 +++ mkdocs/docs/core/modes/complex-task.md | 138 ++++++ mkdocs/docs/core/modes/index.md | 65 +++ mkdocs/docs/core/modes/proactive.md | 133 ++++++ mkdocs/docs/core/modes/simple-task.md | 90 ++++ mkdocs/docs/core/modes/special-workflows.md | 63 +++ mkdocs/docs/core/providers/index.md | 65 +++ mkdocs/docs/core/providers/llm.md | 106 +++++ .../docs/core/providers/subscription-auth.md | 71 +++ mkdocs/docs/core/providers/vlm-and-media.md | 82 ++++ mkdocs/docs/develop/architecture.md | 177 +++++++ mkdocs/docs/develop/contributing.md | 103 +++++ mkdocs/docs/develop/custom-action.md | 225 +++++++++ mkdocs/docs/develop/custom-agent.md | 169 +++++++ mkdocs/docs/develop/custom-integration.md | 281 ++++++++++++ mkdocs/docs/develop/index.md | 85 ++++ mkdocs/docs/develop/skills/craftbot-skill.md | 204 +++++++++ mkdocs/docs/develop/skills/external-skill.md | 69 +++ mkdocs/docs/develop/skills/index.md | 37 ++ mkdocs/docs/getting-started.md | 124 ----- mkdocs/docs/guides/daily-briefing.md | 142 ++++++ mkdocs/docs/guides/first-skill.md | 192 ++++++++ mkdocs/docs/guides/github-pr-review.md | 144 ++++++ mkdocs/docs/guides/index.md | 45 ++ mkdocs/docs/guides/mcp-server.md | 147 ++++++ mkdocs/docs/guides/telegram-assistant.md | 123 +++++ mkdocs/docs/index.md | 133 ++++-- mkdocs/docs/integrations/credentials.md | 80 ++++ mkdocs/docs/integrations/discord.md | 226 +++++++++ mkdocs/docs/integrations/github.md | 265 +++++++++++ mkdocs/docs/integrations/gmail.md | 131 ++++++ mkdocs/docs/integrations/google-calendar.md | 130 ++++++ mkdocs/docs/integrations/google-docs.md | 130 ++++++ mkdocs/docs/integrations/google-drive.md | 142 ++++++ mkdocs/docs/integrations/google-youtube.md | 96 ++++ mkdocs/docs/integrations/hubspot.md | 283 ++++++++++++ mkdocs/docs/integrations/index.md | 66 +++ mkdocs/docs/integrations/jira.md | 191 ++++++++ mkdocs/docs/integrations/lark.md | 351 ++++++++++++++ mkdocs/docs/integrations/line.md | 196 ++++++++ mkdocs/docs/integrations/linkedin.md | 155 +++++++ mkdocs/docs/integrations/mcp.md | 105 +++++ mkdocs/docs/integrations/notion.md | 149 ++++++ mkdocs/docs/integrations/outlook.md | 137 ++++++ mkdocs/docs/integrations/slack.md | 212 +++++++++ mkdocs/docs/integrations/stripe.md | 274 +++++++++++ mkdocs/docs/integrations/telegram-bot.md | 197 ++++++++ mkdocs/docs/integrations/telegram-user.md | 115 +++++ mkdocs/docs/integrations/twitter.md | 197 ++++++++ mkdocs/docs/integrations/whatsapp-business.md | 98 ++++ mkdocs/docs/integrations/whatsapp-web.md | 157 +++++++ mkdocs/docs/living-ui/index.md | 17 + mkdocs/docs/reference/agent-md-files.md | 239 ++++++++++ mkdocs/docs/reference/env-vars.md | 164 +++++++ mkdocs/docs/reference/events.md | 89 ++++ mkdocs/docs/reference/faq.md | 87 ++++ mkdocs/docs/reference/index.md | 34 ++ .../reference/troubleshooting/connections.md | 62 +++ .../docs/reference/troubleshooting/index.md | 51 +++ .../reference/troubleshooting/providers.md | 81 ++++ .../docs/reference/troubleshooting/runtime.md | 94 ++++ mkdocs/docs/start/first-task.md | 118 +++++ mkdocs/docs/start/index.md | 68 +++ mkdocs/docs/start/install.md | 174 +++++++ mkdocs/docs/start/learning-path.md | 78 ++++ mkdocs/docs/start/onboarding.md | 103 +++++ mkdocs/docs/start/quickstart.md | 147 ++++++ mkdocs/docs/start/service-mode.md | 128 ++++++ mkdocs/docs/stylesheets/extra.css | 432 +++++++++++++++--- mkdocs/mkdocs.yml | 188 +++++++- mkdocs/overrides/partials/logo.html | 15 + mkdocs/overrides/partials/social.html | 33 ++ mkdocs/requirements.txt | 5 + mkdocs/scripts/gen_default_actions.py | 194 ++++++++ mkdocs/scripts/gen_ref_pages.py | 3 - 106 files changed, 12117 insertions(+), 482 deletions(-) create mode 100644 mkdocs/.gitignore create mode 100644 mkdocs/docs/assets/android-chrome-192x192.png create mode 100644 mkdocs/docs/assets/craftbot_logo_text_no_border_dark.png create mode 100644 mkdocs/docs/assets/craftbot_logo_text_no_border_light.png delete mode 100644 mkdocs/docs/connections.md create mode 100644 mkdocs/docs/core/commands/builtin.md create mode 100644 mkdocs/docs/core/commands/cli-anything.md create mode 100644 mkdocs/docs/core/commands/index.md create mode 100644 mkdocs/docs/core/concepts/actions-and-action-sets.md create mode 100644 mkdocs/docs/core/concepts/agent-bundles.md create mode 100644 mkdocs/docs/core/concepts/agent-file-system.md create mode 100644 mkdocs/docs/core/concepts/agent-loop.md create mode 100644 mkdocs/docs/core/concepts/context-engine.md create mode 100644 mkdocs/docs/core/concepts/event-stream.md create mode 100644 mkdocs/docs/core/concepts/logs.md create mode 100644 mkdocs/docs/core/concepts/memory.md create mode 100644 mkdocs/docs/core/concepts/prompts.md create mode 100644 mkdocs/docs/core/concepts/scheduling.md create mode 100644 mkdocs/docs/core/concepts/skills.md create mode 100644 mkdocs/docs/core/concepts/sub-agents.md create mode 100644 mkdocs/docs/core/concepts/task-sessions.md create mode 100644 mkdocs/docs/core/concepts/triggers.md create mode 100644 mkdocs/docs/core/configuration/agent-config-yaml.md create mode 100644 mkdocs/docs/core/configuration/config-json.md create mode 100644 mkdocs/docs/core/configuration/index.md create mode 100644 mkdocs/docs/core/index.md create mode 100644 mkdocs/docs/core/interfaces/browser.md create mode 100644 mkdocs/docs/core/interfaces/cli.md create mode 100644 mkdocs/docs/core/interfaces/index.md create mode 100644 mkdocs/docs/core/interfaces/ui-layer.md create mode 100644 mkdocs/docs/core/modes/complex-task.md create mode 100644 mkdocs/docs/core/modes/index.md create mode 100644 mkdocs/docs/core/modes/proactive.md create mode 100644 mkdocs/docs/core/modes/simple-task.md create mode 100644 mkdocs/docs/core/modes/special-workflows.md create mode 100644 mkdocs/docs/core/providers/index.md create mode 100644 mkdocs/docs/core/providers/llm.md create mode 100644 mkdocs/docs/core/providers/subscription-auth.md create mode 100644 mkdocs/docs/core/providers/vlm-and-media.md create mode 100644 mkdocs/docs/develop/architecture.md create mode 100644 mkdocs/docs/develop/contributing.md create mode 100644 mkdocs/docs/develop/custom-action.md create mode 100644 mkdocs/docs/develop/custom-agent.md create mode 100644 mkdocs/docs/develop/custom-integration.md create mode 100644 mkdocs/docs/develop/index.md create mode 100644 mkdocs/docs/develop/skills/craftbot-skill.md create mode 100644 mkdocs/docs/develop/skills/external-skill.md create mode 100644 mkdocs/docs/develop/skills/index.md delete mode 100644 mkdocs/docs/getting-started.md create mode 100644 mkdocs/docs/guides/daily-briefing.md create mode 100644 mkdocs/docs/guides/first-skill.md create mode 100644 mkdocs/docs/guides/github-pr-review.md create mode 100644 mkdocs/docs/guides/index.md create mode 100644 mkdocs/docs/guides/mcp-server.md create mode 100644 mkdocs/docs/guides/telegram-assistant.md create mode 100644 mkdocs/docs/integrations/credentials.md create mode 100644 mkdocs/docs/integrations/discord.md create mode 100644 mkdocs/docs/integrations/github.md create mode 100644 mkdocs/docs/integrations/gmail.md create mode 100644 mkdocs/docs/integrations/google-calendar.md create mode 100644 mkdocs/docs/integrations/google-docs.md create mode 100644 mkdocs/docs/integrations/google-drive.md create mode 100644 mkdocs/docs/integrations/google-youtube.md create mode 100644 mkdocs/docs/integrations/hubspot.md create mode 100644 mkdocs/docs/integrations/index.md create mode 100644 mkdocs/docs/integrations/jira.md create mode 100644 mkdocs/docs/integrations/lark.md create mode 100644 mkdocs/docs/integrations/line.md create mode 100644 mkdocs/docs/integrations/linkedin.md create mode 100644 mkdocs/docs/integrations/mcp.md create mode 100644 mkdocs/docs/integrations/notion.md create mode 100644 mkdocs/docs/integrations/outlook.md create mode 100644 mkdocs/docs/integrations/slack.md create mode 100644 mkdocs/docs/integrations/stripe.md create mode 100644 mkdocs/docs/integrations/telegram-bot.md create mode 100644 mkdocs/docs/integrations/telegram-user.md create mode 100644 mkdocs/docs/integrations/twitter.md create mode 100644 mkdocs/docs/integrations/whatsapp-business.md create mode 100644 mkdocs/docs/integrations/whatsapp-web.md create mode 100644 mkdocs/docs/living-ui/index.md create mode 100644 mkdocs/docs/reference/agent-md-files.md create mode 100644 mkdocs/docs/reference/env-vars.md create mode 100644 mkdocs/docs/reference/events.md create mode 100644 mkdocs/docs/reference/faq.md create mode 100644 mkdocs/docs/reference/index.md create mode 100644 mkdocs/docs/reference/troubleshooting/connections.md create mode 100644 mkdocs/docs/reference/troubleshooting/index.md create mode 100644 mkdocs/docs/reference/troubleshooting/providers.md create mode 100644 mkdocs/docs/reference/troubleshooting/runtime.md create mode 100644 mkdocs/docs/start/first-task.md create mode 100644 mkdocs/docs/start/index.md create mode 100644 mkdocs/docs/start/install.md create mode 100644 mkdocs/docs/start/learning-path.md create mode 100644 mkdocs/docs/start/onboarding.md create mode 100644 mkdocs/docs/start/quickstart.md create mode 100644 mkdocs/docs/start/service-mode.md create mode 100644 mkdocs/overrides/partials/logo.html create mode 100644 mkdocs/overrides/partials/social.html create mode 100644 mkdocs/requirements.txt create mode 100644 mkdocs/scripts/gen_default_actions.py diff --git a/README.md b/README.md index 148d5bf2..54654dc8 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,10 @@ Beyond that, CraftBot has all the core capabilities of a general-purpose agent h Discord + + + Ask DeepWiki +

diff --git a/mkdocs/.gitignore b/mkdocs/.gitignore new file mode 100644 index 00000000..b44ca6cb --- /dev/null +++ b/mkdocs/.gitignore @@ -0,0 +1,2 @@ +site/ +.cache/ diff --git a/mkdocs/docs/assets/android-chrome-192x192.png b/mkdocs/docs/assets/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..115f4c5d856662a268a4c5d197f07c2507d12e5a GIT binary patch literal 4361 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 literal 0 HcmV?d00001 diff --git a/mkdocs/docs/assets/craftbot_logo_text_no_border_dark.png b/mkdocs/docs/assets/craftbot_logo_text_no_border_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..d5943133e88b429db749fcfefadb479680d972a7 GIT binary patch literal 14917 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 Dd#fOY literal 0 HcmV?d00001 diff --git a/mkdocs/docs/assets/craftbot_logo_text_no_border_light.png b/mkdocs/docs/assets/craftbot_logo_text_no_border_light.png new file mode 100644 index 0000000000000000000000000000000000000000..4874b22e365417a069dccebc12487d88ad91d555 GIT binary patch literal 15557 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 literal 15406 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 [name]` | Register a guild after adding the bot | -| `/discord login ` | Connect your own Discord bot | -| `/discord login-user ` | Connect a Discord user account | -| `/discord status` | Show all Discord connections | -| `/discord logout [id]` | Remove a connection | - -### Prerequisites - -- **Invite:** Requires `DISCORD_SHARED_BOT_ID` env var (set by CraftOS admin) -- **Login:** Create a bot at [discord.com/developers](https://discord.com/developers/applications), copy the bot token - ---- - -## Slack - -Send messages, list channels/users, search messages, read history, and upload files. - -**Available actions:** `send_slack_message`, `list_slack_channels`, `get_slack_channel_history`, `list_slack_users`, `search_slack_messages`, `upload_slack_file` - -### Connect - -| Command | Description | -|---------|-------------| -| `/slack invite` | Install the CraftOS Slack app to your workspace (OAuth flow) | -| `/slack login [workspace_name]` | Connect your own Slack bot token | -| `/slack status` | Show connected workspaces | -| `/slack logout [workspace_id]` | Remove a workspace connection | - -### Prerequisites - -- **Invite:** Requires `SLACK_SHARED_CLIENT_ID` and `SLACK_SHARED_CLIENT_SECRET` env vars -- **Login:** Create a Slack app at [api.slack.com/apps](https://api.slack.com/apps), install to workspace, copy the Bot User OAuth Token (`xoxb-...`) - ---- - -## Telegram - -Send messages/photos, get updates, look up chats, and search contacts. - -**Bot actions:** `send_telegram_bot_message`, `send_telegram_photo`, `get_telegram_updates`, `get_telegram_chat`, `search_telegram_contact` - -**User account actions:** `send_telegram_user_message`, `send_telegram_user_file`, `get_telegram_chats`, `read_telegram_messages`, `search_telegram_user_contacts` - -### Connect - -| Command | Description | -|---------|-------------| -| `/telegram invite` | Connect the CraftOS Telegram bot (opens t.me link) | -| `/telegram login ` | Connect your own bot from @BotFather | -| `/telegram login-user ` | Step 1: Send verification code to your phone | -| `/telegram login-user [2fa_password]` | Step 2: Complete user account login | -| `/telegram status` | Show all Telegram connections | -| `/telegram logout [id]` | Remove a connection | - -### Prerequisites - -- **Invite:** Requires `TELEGRAM_SHARED_BOT_TOKEN` and `TELEGRAM_SHARED_BOT_USERNAME` env vars -- **Login:** Message [@BotFather](https://t.me/BotFather) on Telegram to create a bot and get the token -- **Login-user:** Requires `TELEGRAM_API_ID` and `TELEGRAM_API_HASH` env vars. Get them from [my.telegram.org](https://my.telegram.org) → API development tools. Also requires Telethon (`pip install telethon`). - ---- - -## Notion - -Search pages/databases, create and update pages, query databases. - -**Available actions:** `search_notion`, `get_notion_page`, `create_notion_page`, `query_notion_database`, `update_notion_page` - -### Connect - -| Command | Description | -|---------|-------------| -| `/notion invite` | Authorize the CraftOS Notion integration (OAuth flow) | -| `/notion login ` | Connect your own Notion integration | -| `/notion status` | Show connected workspaces | -| `/notion logout [workspace_id]` | Remove a workspace connection | - -### Prerequisites - -- **Invite:** Requires `NOTION_SHARED_CLIENT_ID` and `NOTION_SHARED_CLIENT_SECRET` env vars -- **Login:** Create an integration at [notion.so/my-integrations](https://www.notion.so/my-integrations), copy the Internal Integration Secret - ---- - -## Google Workspace - -Send/read emails (Gmail), create calendar events with Google Meet, manage Google Drive files. - -**Available actions:** `send_gmail`, `list_gmail`, `get_gmail`, `read_top_emails`, `create_google_meet`, `check_calendar_availability`, `list_drive_files`, `create_drive_folder`, `move_drive_file` - -### Connect - -| Command | Description | -|---------|-------------| -| `/google login` | Authenticate via Google OAuth (opens browser) | -| `/google status` | Show connected Google accounts | -| `/google logout [email]` | Remove a Google account | - -### Prerequisites - -Requires `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` env vars. Create OAuth credentials in [Google Cloud Console](https://console.cloud.google.com/apis/credentials). - -Scopes requested: Gmail, Calendar, Drive, Contacts, UserInfo. - ---- - -## LinkedIn - -View profile, create posts, search jobs, get connections, send messages. - -**Available actions:** `get_linkedin_profile`, `create_linkedin_post`, `search_linkedin_jobs`, `get_linkedin_connections`, `send_linkedin_message` - -### Connect - -| Command | Description | -|---------|-------------| -| `/linkedin login` | Authenticate via LinkedIn OAuth (opens browser) | -| `/linkedin status` | Show connected accounts | -| `/linkedin logout [linkedin_id]` | Remove an account | - -### Prerequisites - -Requires `LINKEDIN_CLIENT_ID` and `LINKEDIN_CLIENT_SECRET` env vars. Create an app at [linkedin.com/developers](https://www.linkedin.com/developers/). - ---- - -## Zoom - -Create, list, get details, and delete Zoom meetings. - -**Available actions:** `create_zoom_meeting`, `list_zoom_meetings`, `get_zoom_meeting`, `delete_zoom_meeting` - -### Connect - -| Command | Description | -|---------|-------------| -| `/zoom login` | Authenticate via Zoom OAuth (opens browser) | -| `/zoom status` | Show connected Zoom accounts | -| `/zoom logout [zoom_user_id]` | Remove an account | - -### Prerequisites - -Requires `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` env vars. Create an OAuth app at [marketplace.zoom.us](https://marketplace.zoom.us/). - ---- - -## WhatsApp - -Send text and media messages via WhatsApp Web. - -**Available actions:** `send_whatsapp_web_text_message`, `send_whatsapp_web_media_message`, `get_whatsapp_chat_history`, `get_whatsapp_unread_chats`, `search_whatsapp_contact` - -### Connect - -| Command | Description | -|---------|-------------| -| `/whatsapp login [phone_number]` | Connect via WhatsApp Web (scan QR code) | -| `/whatsapp status` | Show all WhatsApp connections | -| `/whatsapp logout [id]` | Remove a connection | - -### Prerequisites - -Requires Playwright (`pip install playwright && playwright install chromium`). A QR code will open in your browser — scan it with your phone's WhatsApp camera to connect. - ---- - -## Recall.ai - -Create meeting bots that join calls to record and transcribe (Zoom, Google Meet, Teams). - -**Available actions:** `create_recall_bot`, `get_recall_bot`, `get_recall_transcript`, `recall_leave_meeting` - -### Connect - -| Command | Description | -|---------|-------------| -| `/recall login [region]` | Connect with Recall.ai API key (region: `us` or `eu`) | -| `/recall status` | Show connection status | -| `/recall logout` | Remove the credential | - -### Prerequisites - -Get an API key from [recall.ai](https://www.recall.ai/). Default region is `us`. - ---- - -## Environment Variables Reference - -Set these in your environment or `.env` file: - -| Variable | Integration | Required for | -|----------|-------------|-------------| -| `GOOGLE_CLIENT_ID` | Google | `/google login` | -| `GOOGLE_CLIENT_SECRET` | Google | `/google login` | -| `LINKEDIN_CLIENT_ID` | LinkedIn | `/linkedin login` | -| `LINKEDIN_CLIENT_SECRET` | LinkedIn | `/linkedin login` | -| `ZOOM_CLIENT_ID` | Zoom | `/zoom login` | -| `ZOOM_CLIENT_SECRET` | Zoom | `/zoom login` | -| `DISCORD_SHARED_BOT_TOKEN` | Discord | Shared bot operations | -| `DISCORD_SHARED_BOT_ID` | Discord | `/discord invite` | -| `SLACK_SHARED_CLIENT_ID` | Slack | `/slack invite` | -| `SLACK_SHARED_CLIENT_SECRET` | Slack | `/slack invite` | -| `TELEGRAM_SHARED_BOT_TOKEN` | Telegram | `/telegram invite` | -| `TELEGRAM_SHARED_BOT_USERNAME` | Telegram | `/telegram invite` | -| `TELEGRAM_API_ID` | Telegram | `/telegram login-user` | -| `TELEGRAM_API_HASH` | Telegram | `/telegram login-user` | -| `NOTION_SHARED_CLIENT_ID` | Notion | `/notion invite` | -| `NOTION_SHARED_CLIENT_SECRET` | Notion | `/notion invite` | diff --git a/mkdocs/docs/core/commands/builtin.md b/mkdocs/docs/core/commands/builtin.md new file mode 100644 index 00000000..7f2e5b49 --- /dev/null +++ b/mkdocs/docs/core/commands/builtin.md @@ -0,0 +1,172 @@ +# Built-in commands + +Every slash command shipped with CraftBot, with its aliases, subcommands, and behavior. All of them work in both [interfaces](../interfaces/index.md). The two marked otherwise say so. + +At a glance: + +| Command | Aliases | What it does | +|---|---|---| +| [`/help [command]`](#help) | `/h`, `/?` | List commands, or detail one | +| [`/clear`](#clear) | `/cls` | Clear the chat and action log | +| [`/clear-tasks`](#clear-tasks) | `/cleartasks` | Remove finished tasks from the task panel | +| [`/reset`](#reset) | — | Reset agent state and clear history | +| [`/exit`](#exit) | `/quit`, `/q` | Shut down CraftBot | +| [`/menu`](#menu) | — | Open the settings menu (browser only) | +| [`/provider [name] [key]`](#provider) | — | View or change the LLM provider | +| [`/mcp `](#mcp) | — | Manage MCP servers | +| [`/skill `](#skill) | — | Manage skills | +| [`/cred `](#cred) | — | Credentials and integration status | +| [`/update [--check]`](#update) | `/upgrade` | Check for and install updates | + +Beyond these, the registry also holds [integration commands](#integration-commands) (`/gmail`, `/slack`, ...) and [skill commands](#skill-commands) (`/pdf`, `/docx`, ...), covered at the end. + +## /help + +``` +/help # list all commands with descriptions and aliases +/help mcp # usage, subcommands, and examples for one command +``` + +The leading slash on the argument is optional (`/help mcp` and `/help /mcp` both work). Skill shortcuts are hidden from the main list to keep it short. `/skill list` shows them. + +## /clear + +Clears the chat transcript and the action log in the current interface, and also drops the agent's persisted conversation memory, so a restart won't resurrect the cleared chat. Task history and dashboard data are unaffected. Use it when the conversation is cluttered. Use [`/reset`](#reset) when the *agent* needs a fresh start. + +## /clear-tasks + +Removes tasks whose status is completed, failed, or cancelled from the task panel, along with their child actions. Running and waiting tasks are preserved, and dashboard usage data and task history are untouched. Requires an action panel, so it's effectively a browser command. In the CLI it reports that no action panel is available. + +## /reset + +Resets the agent to its initial state: clears the current task, action history, and conversation context, and wipes the chat view. Saved settings and credentials are **not** affected. Feedback arrives as system messages while the reset runs in the background. + +## /exit + +Stops the agent cleanly and ends the session. In [service mode](../../start/service-mode.md) the service manager may restart it. Use `python craftbot.py stop` to keep it down. + +## /menu + +Opens the settings menu. Browser only. In the CLI it points you to `/help` instead. (In practice you'll click **Settings** in the sidebar; the command exists mainly for keyboard-first use.) + +## /provider + +View or switch the LLM provider without opening settings. + +``` +/provider # show current provider and masked API key +/provider anthropic # switch provider (keeps any stored key) +/provider anthropic sk-ant-... # switch and set the key in one line +``` + +Accepted names: + +| Name | Provider | Key | +|---|---|---| +| `openai` | OpenAI | `OPENAI_API_KEY` | +| `gemini` | Google Gemini | `GOOGLE_API_KEY` | +| `anthropic` | Anthropic | `ANTHROPIC_API_KEY` | +| `byteplus` | BytePlus | `BYTEPLUS_API_KEY` | +| `deepseek` | DeepSeek | `DEEPSEEK_API_KEY` | +| `grok` | Grok (xAI) | `XAI_API_KEY` | +| `glm` | Z.ai (GLM) | `ZAI_API_KEY` | +| `fugu` | Sakana (Fugu) | `SAKANA_API_KEY` | +| `openrouter` | OpenRouter | `OPENROUTER_API_KEY` | +| `remote` | Ollama (local) | none | + +The change is saved to `settings.json` and the LLM reinitializes immediately, with no restart. Model selection, base URLs, and subscription login live in **Settings → Model**; see [LLM providers](../providers/llm.md). + +## /mcp + +Manage [MCP servers](../../integrations/mcp.md). `/mcp` with no arguments prints usage. + +| Subcommand | Does | +|---|---| +| `list [--all]` | List enabled servers; `--all` includes disabled ones | +| `add --transport stdio -- ` | Add a stdio server (everything after `--` is the launch command) | +| `add --transport http ` | Add an HTTP server | +| `add-json ''` | Add a server from a JSON config block | +| `remove ` | Remove a server | +| `enable ` / `disable ` | Toggle a server without removing it | +| `env ` | Set an environment variable for a server | + +``` +/mcp add myserver --transport stdio -- python server.py +/mcp add remote-tools --transport http https://example.com/mcp +/mcp env myserver API_KEY my-secret-key +/mcp list --all +``` + +The browser's **Settings → MCPs** page edits the same configuration. + +## /skill + +Manage [skills](../concepts/skills.md). `/skill` with no arguments prints usage. + +| Subcommand | Does | +|---|---| +| `list [--all]` | List enabled skills; `--all` includes disabled ones | +| `info ` | Description, version, author, path, and the skill's actions | +| `enable ` / `disable ` | Toggle a skill — this also registers/unregisters its slash command | +| `install ` | Install from a local directory | +| `install ` | Install from a GitHub/GitLab URL | +| `create [description]` | Scaffold a new skill | +| `remove ` | Remove a skill | +| `reload` | Re-scan skills from disk | +| `dirs` | Show the directories scanned for skills | + +``` +/skill info pdf +/skill enable cli-anything +/skill install https://github.com/user/skill.git +/skill create my_skill "My custom skill" +``` + +## /cred + +Read-only overview of credentials and integrations. Connecting happens with the [per-integration commands](#integration-commands) or **Settings → Integrations**. + +| Subcommand | Does | +|---|---| +| `list` | Every integration with connected / not connected | +| `status` | Same, with account names for connected integrations and a connected count | +| `integrations` | Every available integration with its `/command` and description | + +## /update + +``` +/update # check GitHub for a newer version and install it +/update --check # check only, don't install +``` + +An update pulls the latest code, installs dependencies, and restarts CraftBot automatically, streaming progress as system messages. If you're already current, it says so. + +## Integration commands + +Every available [integration](../../integrations/index.md) registers its own command named after itself: `/gmail`, `/slack`, `/discord`, `/telegram_bot`, `/notion`, and so on (run `/cred integrations` for the live list). Each supports: + +``` +/ # help, including integration-specific subcommands +/ connect # start the connect flow (token, OAuth, or interactive/QR) +/ disconnect +/ status # connection state and accounts +``` + +Token-based integrations take their credentials as arguments, like `/telegram_bot connect ` (running `connect` bare tells you which fields it needs). OAuth ones open the provider's flow. Interactive ones (like WhatsApp Web) walk you through it. Some handlers add extra subcommands (QR login, invites), listed in that integration's help. Details per service are on the [integration pages](../../integrations/index.md). + +## Skill commands + +Every **enabled** skill is also a slash command: `/pdf`, `/docx`, `/xlsx`, and whatever else you've enabled. These don't run UI code. They hand your text to the agent with that skill pre-selected: + +``` +/pdf merge report-a.pdf and report-b.pdf into final.pdf +``` + +Arguments flow into the skill's instructions through `$ARGUMENTS` substitution (skills can also grab positional pieces with `$ARGUMENTS[0]`, `$1`, ...). Invoked bare, the agent asks what you need. Enabling or disabling a skill registers or removes its command immediately, and a skill whose name collides with an existing command is skipped. They're hidden from `/help`. See them with `/skill list` or the browser's `/` autocomplete. + +## Related + +- [Commands overview](index.md): how dispatch works +- [CLI-anything](cli-anything.md): the desktop-app automation skill +- [UI layer](../interfaces/ui-layer.md): the registry these commands live in +- [Credentials](../../integrations/credentials.md): where connected secrets are stored diff --git a/mkdocs/docs/core/commands/cli-anything.md b/mkdocs/docs/core/commands/cli-anything.md new file mode 100644 index 00000000..e381992f --- /dev/null +++ b/mkdocs/docs/core/commands/cli-anything.md @@ -0,0 +1,83 @@ +# CLI-anything + +CLI-anything is a bundled [skill](../concepts/skills.md) that lets the agent drive real desktop applications (GIMP, Blender, LibreOffice, Audacity, and two dozen others) from the command line, on Windows, macOS, and Linux. You describe the task ("convert report.docx to PDF", "resize photo.jpg to 1920×1080"). The agent picks the right app, installs it if it's missing, runs it, and reports the result. You never name the app, and you never run a command yourself. + +!!! note "Disabled by default" + CLI-anything ships in the `disabled_skills` list of `app/config/skills_config.json`. Turn it on with `/skill enable cli-anything` or in **Settings → Skills**. Once enabled, it activates automatically whenever a task matches a supported app. Like any enabled skill, it also gets a `/cli-anything` slash command. + +## What it can automate + +Each app is driven through a cross-platform harness command, `cli-anything-`, so the agent never touches platform-specific binaries or paths: + +| Task | App | Harness | +|---|---|---| +| Resize / crop / filter / convert images | GIMP | `cli-anything-gimp` | +| SVG and vector graphics, logo export | Inkscape | `cli-anything-inkscape` | +| Digital painting, `.kra` export | Krita | `cli-anything-krita` | +| DOCX / XLSX / PPTX → PDF, office macros | LibreOffice | `cli-anything-libreoffice` | +| Trim / convert / export audio | Audacity | `cli-anything-audacity` | +| Render and edit video | Kdenlive, Shotcut | `cli-anything-kdenlive`, `cli-anything-shotcut` | +| Screen recording and streaming | OBS Studio | `cli-anything-obs` | +| 3D modeling and rendering, `.blend` files | Blender | `cli-anything-blender` | +| Diagrams (`.drawio`) | Draw.io | `cli-anything-draw-io` | +| Render Mermaid diagram code | Mermaid | `cli-anything-mermaid` | +| AI image generation | Stable Diffusion, ComfyUI | `cli-anything-stable-diffusion`, `cli-anything-comfyui` | +| Run a local LLM | Ollama | `cli-anything-ollama` | +| AI content generation | AnyGen | `cli-anything-anygen` | +| AI research / PDF summarization | NotebookLM | `cli-anything-notebooklm` | +| Execute Jupyter notebooks | JupyterLab | `cli-anything-jupyterlab` | +| CAD, `.fcstd` → STL/STEP | FreeCAD | `cli-anything-freecad` | +| GIS maps, `.qgz` export | QGIS | `cli-anything-qgis` | +| Monitoring dashboards | Grafana | `cli-anything-grafana` | +| Git hosting, repo creation | Gitea, GitLab | `cli-anything-gitea` | +| CI/CD pipelines | Jenkins | `cli-anything-jenkins` | +| Cloud file sync | NextCloud | `cli-anything-nextcloud` | +| Network-wide ad blocking | AdGuard Home | `cli-anything-adguard-home` | +| Video conferencing | Zoom | `cli-anything-zoom` | +| Knowledge outlines | Mubu | `cli-anything-mubu` | + +Ask the agent "what can cli-anything do" and it replies with this catalogue directly, without running anything. + +## How routing works + +The skill's instructions (`skills/cli-anything/SKILL.md`) contain a routing table mapping task descriptions to apps. When the skill is enabled and your request matches ("convert this DOCX", "render this .blend file") the agent selects the app and follows a fixed execution flow: + +1. **Detect the OS** (Windows / macOS / Linux). +2. **Check the app is installed** (`gimp --version` and equivalents). +3. **Install it if missing**, one attempt only, via the platform's package manager: `winget` on Windows, `brew` on macOS, `apt-get` on Linux. A few apps use their own path instead (ComfyUI and Stable Diffusion via `git clone`, Mermaid via npm, JupyterLab via pip, Ollama on Linux via its install script; web apps like Mubu and NotebookLM need no install and are driven through the browser-automation skill). +4. **Check the harness** (`cli-anything- --version`); if missing, install `cli-anything-hub` via pip and pull the harness with `cli-hub install `. If the hub fails, the agent generates a minimal harness itself. +5. **Run the task** using only harness commands, for example: + + ``` + cli-anything-gimp image resize input.jpg output.jpg 1920 1080 + cli-anything-libreoffice convert doc.docx output.pdf + cli-anything-blender render scene.blend --output frames/ --format PNG + ``` + +6. **Report** in a sentence or two: what was produced and where. + +Every step runs as a shell action, so you can watch the whole flow (version checks, installs, the task command) in the [task panel](../interfaces/browser.md#tasks), and it all lands in [logs](../concepts/logs.md). + +The skill hard-bans the failure modes of driving desktop apps directly: no `.exe` suffixes, no hardcoded `C:\Program Files\...` paths, no `&&` command chaining, no raw `soffice`/`gimp`/`blender` invocations. The harness resolves app locations and flags per platform, which is what makes the same task work on all three OSes. + +## Python fallback + +CLI-anything is the first choice, not the only one. If a harness command fails after one retry, the agent falls back to a pure-Python route (PIL for images, python-docx for documents, pydub for audio, moviepy for video), completes the task anyway, and tells you what it actually used (with a note that installing the app gives better results next time). Installs are never retried, timeouts are never looped on, and after repeated failures on one step the agent stops and reports rather than spinning. + +## Requirements + +- **Enable the skill** (see the note above). It does nothing while disabled. +- **Action sets:** the skill declares `shell` and `file_operations`. It works through ordinary shell actions, no extra plumbing. +- **A package manager** for auto-install: `winget` (Windows), Homebrew (macOS), or `apt` (Linux, where installs run under `sudo`). `pip` is needed for the harness hub. +- **Internet access** the first time any given app or harness is installed. +- **File paths:** give the agent full paths to input files (`C:\Users\you\Desktop\photo.jpg`, `/home/user/photo.jpg`) for the smoothest run. + +!!! warning "It installs software" + By design, this skill can install real applications on your machine (silently, with license agreements auto-accepted) and run them with your privileges. Each install is a visible shell action in the task panel, and installs are attempted at most once. If you don't want the agent installing anything, keep the skill disabled or preinstall the apps you care about. + +## Related + +- [Skills](../concepts/skills.md): how skills are enabled, discovered, and invoked +- [Built-in commands](builtin.md#skill): `/skill enable cli-anything` and friends +- [Actions and action sets](../concepts/actions-and-action-sets.md): the shell actions underneath +- [Living UI](../../living-ui/index.md): a different kind of "agent builds it for you" diff --git a/mkdocs/docs/core/commands/index.md b/mkdocs/docs/core/commands/index.md new file mode 100644 index 00000000..7de332d2 --- /dev/null +++ b/mkdocs/docs/core/commands/index.md @@ -0,0 +1,62 @@ +# Commands + +Commands are slash-prefixed inputs (`/help`, `/provider anthropic sk-...`, `/skill enable pdf`) that the [UI layer](../interfaces/ui-layer.md) intercepts before anything reaches the agent. They're how you configure providers, integrations, skills, and MCP servers (instantly, without spending tokens or starting a task) and they work identically in the [browser](../interfaces/browser.md) and the [CLI](../interfaces/cli.md). + +
+ +- :material-book-alphabet:{ .lg .middle } __[Built-in commands](builtin.md)__ + + --- + + The complete reference: every command, alias, and subcommand. + +- :material-console-line:{ .lg .middle } __[CLI-anything](cli-anything.md)__ + + --- + + The bundled skill that automates desktop apps (GIMP, Blender, LibreOffice, ...) through cross-platform command-line harnesses. + +
+ +## How dispatch works + +When you submit input, the UI controller offers it to the command executor first. Input starting with `/` is split into a name and arguments, resolved against the command registry (aliases included, case-insensitive), and executed; the result comes back as a system message. Anything not starting with `/` goes to the agent as a normal chat message. + +An unknown command like `/frobnicate` returns `Unknown command`. It is **not** forwarded to the agent. Commands and conversation are handled separately. + +In the browser, typing `/` opens an autocomplete listing everything registered, so you rarely need to memorize names. In the CLI, `/help` prints the same list. + +## Four kinds of commands + +The registry is populated from four sources at startup: + +| Kind | Examples | Where they come from | +|---|---|---| +| **Built-in** | `/help`, `/provider`, `/mcp`, `/skill`, `/cred`, `/update` | Shipped in `app/ui_layer/commands/builtin/` — always present | +| **Integration** | `/gmail`, `/slack`, `/telegram_bot`, `/notion` | One per available [integration](../../integrations/index.md), each with `connect` / `disconnect` / `status` plus integration-specific subcommands | +| **Skill** | `/pdf`, `/docx`, `/pptx` | One per **enabled** [skill](../concepts/skills.md); registered and unregistered live as you toggle skills | +| **Agent-provided** | varies | Commands the agent runtime registers programmatically, wrapped into the same registry | + +Built-in and integration commands run immediately in the UI layer. Skill commands are different: `/pdf merge these three files` doesn't run UI code. It routes your text to the agent with that skill pre-selected, and the argument text is substituted into the skill's instructions via `$ARGUMENTS`. It's a shortcut for "do this task, using this skill." + +## The commands you'll actually use + +``` +/help # list every command; /help mcp for details on one +/provider [name] [key] # view or switch the LLM provider +/mcp list # manage MCP servers +/skill list # manage skills +/cred status # see which integrations are connected +/update --check # check for a new CraftBot version +/clear # clear the chat /reset # reset agent state +/exit # shut down +``` + +The full catalogue with every subcommand is in [Built-in commands](builtin.md). + +## Related + +- [Interfaces](../interfaces/index.md): where you type commands +- [UI layer](../interfaces/ui-layer.md): the registry and executor behind dispatch +- [Skills](../concepts/skills.md): the packages behind skill slash commands +- [MCP](../../integrations/mcp.md): what `/mcp` manages diff --git a/mkdocs/docs/core/concepts/actions-and-action-sets.md b/mkdocs/docs/core/concepts/actions-and-action-sets.md new file mode 100644 index 00000000..89d48386 --- /dev/null +++ b/mkdocs/docs/core/concepts/actions-and-action-sets.md @@ -0,0 +1,103 @@ +# Actions and action sets + +An **action** is one concrete thing the agent can do: write a file, search the web, send a Slack message, run a shell command. The action registry is the agent's entire vocabulary: if no action exists for something, the agent cannot do it. Action **sets** group related actions so each task carries only the vocabulary it needs. + +## Overview +Three layers, each answering a different question: + +| Layer | Question it answers | When it's decided | +|---|---|---| +| **Registry** | What can this agent do at all? | At startup (import time), plus whenever MCP servers connect | +| **Action sets** | What can *this task* do? | Once, at task creation (adjustable mid-task) | +| **Router** | What happens *this turn*? | Every iteration of the [agent loop](agent-loop.md) | + +CraftBot ships 1,176 actions: 56 core actions (task management, files, web, documents, media, shell, scheduling, memory, messaging) and 1,120 integration actions under `app/data/action/integrations//`. The full catalogue is in the [actions reference](default-actions.md). + +## Anatomy of an action + +An action is a Python function with an `@action` decorator that registers it at import time. The metadata (not the implementation) is what the selection LLM sees, so the `description` field does most of the work: + +| Field | What it does | +|---|---| +| `name` | Unique identifier, e.g. `web_search` | +| `description` | What the LLM reads to decide when to pick this action | +| `input_schema` / `output_schema` | Parameter and result contracts, shown to the LLM | +| `action_sets` | Which sets contain this action (an action can be in several) | +| `mode` | Interface visibility — which interface contexts offer the action; `"ALL"` means everywhere | +| `execution_mode` | `"internal"` (in-process) or `"sandboxed"` (isolated venv) | +| `platforms` | `windows` / `linux` / `darwin` / `all` — see platform dispatch below | +| `requirement` | pip packages the action needs; installed automatically before it runs | +| `parallelizable` | Whether it may run alongside other actions in one turn (`False` for writes, state changes, `send_message`) | +| `irreversible` | Marks side effects that can't be undone once they reach the outside world (send email, post publicly) | +| `default`, `test_payload` | Always-available flag (legacy; prefer `action_sets`), and data for simulated test runs | + +**Irreversible actions get a crash guard.** Before an `irreversible=True` action executes, its intent is durably recorded in an activity ledger. After execution the outcome is recorded too. If CraftBot crashes between the send and the record, the guard refuses a blind re-execution and surfaces a warning instead. The agent verifies or asks you rather than sending your email twice. + +**Platform dispatch.** The registry stores implementations per platform. One logical name like `run_shell` can have Windows, macOS, and Linux variants. Lookup picks the current platform's implementation and falls back to the generic `all` one. + +## Action sets + +Sets are labels declared in each action's metadata. The registry discovers them dynamically by scanning, so a custom action declaring `action_sets=["my_tools"]` creates the `my_tools` set with no other registration step. The built-in sets: + +| Set | Contains | +|---|---| +| `core` | 35 always-included actions: messaging, task control, file operations, search, web research, shell and HTTP, clipboard, scheduling, integration management, skill and set management, memory search, and sub-agent spawning | +| `document_processing` | PDF reading, editing, and conversion, markdown conversion, OCR, image description, video understanding | +| `image` | `describe_image`, `generate_image`, `perform_ocr`, `understand_video` | +| `video` | `generate_video`, `perform_ocr`, `understand_video` | +| `content_creation` | `generate_image`, `generate_video` | +| `scheduler` | Schedule management actions (also present in `core`) | +| `proactive` | Recurring-task management for `PROACTIVE.md` plus schedule management | +| `living_ui` | The 7 Living UI lifecycle actions (scaffold, launch, restart, import, data access) | + +Connected integrations contribute their own sets, and each connected [MCP server](../../integrations/mcp.md) becomes a set named `mcp_` (see below). + +## How a task gets its actions + +When a task starts, one LLM call selects both the [skills](skills.md) and the action sets for it, based on the task description: a report-writing task gets `document_processing`, a Living UI build gets `living_ui`. Sets recommended by the selected skill are merged in automatically, `core` is always included, and the union is compiled into a static action list that the task carries for its lifetime. + +This compile-once design is deliberate: during execution there is no retrieval step and no searching for tools. The task's vocabulary is a fixed list the router reads directly. + +The list can still change, though. Mid-task, the agent can call `list_action_sets`, `add_action_sets`, and `remove_action_sets` (all in `core`, so always available) to expand or trim its own vocabulary when it discovers it needs something. These calls appear in the action panel when a task discovers mid-way that it needs another capability. + +## Per-turn selection + +Every iteration of the [agent loop](agent-loop.md), the router makes **one LLM call** that returns reasoning plus a list of one *or more* actions: + +```json +{"reasoning": "...", "actions": [{"action_name": "web_search", "parameters": {...}}, + {"action_name": "task_update_todos", "parameters": {...}}]} +``` + +Rules applied to that list before execution: + +- **Parallel execution.** Multiple actions in one decision run concurrently, up to 10 per batch. +- **Non-parallelizable wins alone.** If any selected action has `parallelizable=False`, it runs by itself and the rest are dropped with an error the agent sees next turn. +- **Format errors retry, then abort.** Malformed LLM output gets up to 3 retries with the parse error fed back. After that the task aborts rather than wasting tokens. +- **Conversation mode is narrow.** Outside a task, the candidates are only `send_message`, `task_start`, `ignore`, plus messaging actions for connected platforms. Real work requires a task. + +Each execution logs `action_start` / `action_end` events to the task's [event stream](event-stream.md), which is what the action panel in the browser renders live. + +## Internal vs sandboxed execution + +`execution_mode` decides where the function body runs: + +- **`internal`** runs in the CraftBot process. Used by actions that touch agent state (task management, messaging, memory) and by MCP tools. Declared `requirement` packages are pip-installed into the main environment before the first run. +- **`sandboxed`** runs in a separate process using a persistent virtual environment (`~/.craftbot/sandbox_venv`, created lazily on first use and reused). Requirements install into the sandbox venv once and persist across calls. A timeout kills runaway executions. + +Either way, the action returns a dict that flows back into the event stream as the observation for the next turn. + +## MCP tools join the same registry + +Servers configured in `app/config/mcp_config.json` (or managed via the `/mcp` command and **Settings**) expose tools that are converted into ordinary actions at connect time: each tool becomes an action named `mcp__`, its JSON Schema becomes the `input_schema`, and all of a server's tools land in an action set named `mcp_` (configurable per server). From that point nothing downstream knows the difference: MCP sets appear in task-creation selection, MCP actions appear in the router's candidates, and disabling a server unregisters its actions. Details and server setup: [MCP](../../integrations/mcp.md). + +!!! note "Implementation files" + Registry and decorator: `agent_core/core/action_framework/registry.py`. Set compilation: `app/action/action_set.py`. Set/skill selection at task creation: `app/internal_action_interface.py`. Per-turn routing: `agent_core/core/impl/action/router.py`. Execution: `agent_core/core/impl/action/manager.py` and `executor.py`. MCP conversion: `agent_core/core/impl/mcp/adapter.py`. + +## Next + +- [Actions reference](default-actions.md): the full catalogue, action by action +- [Write a custom action](../../develop/custom-action.md): one file, one decorator +- [Skills](skills.md): strategy injected on top of the action vocabulary +- [Agent loop](agent-loop.md): where selection and execution sit in the cycle +- [MCP](../../integrations/mcp.md): connecting external tool servers diff --git a/mkdocs/docs/core/concepts/agent-bundles.md b/mkdocs/docs/core/concepts/agent-bundles.md new file mode 100644 index 00000000..c8a61e84 --- /dev/null +++ b/mkdocs/docs/core/concepts/agent-bundles.md @@ -0,0 +1,95 @@ +# Agent bundles and profiles + +An agent profile is the portable part of a CraftBot agent: its personality files, enabled skills, enabled MCP servers, and Living UI apps, packaged into a single `.craftbot` file. Exporting a profile lets you move your configured agent to another machine or share it. Importing a profile turns a stock CraftBot into a configured specialist in one step. + +An **agent bundle** is a pre-built profile authored for a specific role (CEO agent, senior Python engineer, finance agent, and others). CraftOS publishes 42 of them in the [CraftBot Agent Bundles repository](https://github.com/CraftOS-dev/craftbot-agent-bundles), free to download and import. + +## Example bundles + +The 42 bundles are grouped by domain. Each one wires the agent with the skills, MCP servers, and operating rules a senior practitioner of that role uses, so the agent executes the work rather than only describing it. A sample: + +| Bundle | Domain | What it does | +|---|---|---| +| `ceo-agent` | Executive | Drafts strategy documents, board packs, and investor updates, and tracks OKRs. | +| `finance-agent` | Finance | Builds FP&A models, runs capital-allocation analysis, and drafts term sheets. | +| `project-manager` | Operations | Writes charters, work-breakdown structures, Gantt schedules, and status reports. | +| `senior-python-engineer` | Engineering | Reviews Python code, debugs failures, and proposes optimizations. | +| `devops-engineer` | Engineering | Works with containers, Kubernetes, infrastructure-as-code, and GitOps pipelines. | +| `marketing-agent` | Marketing | Plans campaigns and produces content across channels. | +| `seo-specialist` | Marketing | Audits sites, researches keywords, and drafts on-page and technical SEO fixes. | +| `sales-agent` | Sales | Researches prospects, drafts outreach, and maintains pipeline records. | +| `data-analyst` | Research | Queries data, runs analysis, and turns results into reports and charts. | +| `legal-counsel` | Legal | Reviews contracts, flags risk, and drafts standard clauses. | +| `recruiter` | People | Writes job descriptions, sources candidates, and screens applicants. | +| `personal-assistant` | Personal | Manages your inbox, calendar, and day-to-day tasks. | + +The repository lists all 42 with a description of each, spanning executive and strategy, engineering, marketing and growth, sales and customer, content and documentation, research and analytics, legal and compliance, and people and product roles. + +## What a profile contains + +A `.craftbot` file is a ZIP archive. It carries the parts of an agent that are portable between installs: + +| Included | Notes | +|---|---| +| Personality files | `SOUL.md`, `USER.md`, and the other `agent_file_system` markdown that defines behavior. See [Agent file system](agent-file-system.md). | +| Enabled skills | The skill folders you have enabled. Skills already present on the target install are not re-shipped. See [Skills](skills.md). | +| Enabled MCP servers | Server definitions from `mcp_config.json`, with secret environment values stripped out. See [MCP servers](../../integrations/mcp.md). | +| Living UI apps | Any apps you built. See [Living UI](../../living-ui/index.md). | +| `manifest.json` and `README.md` | Bundle metadata and a human-readable summary of the contents. | + +Four things are deliberately **never** included, so a bundle is safe to share: + +- API keys and provider credentials. +- OAuth tokens and integration secrets. +- Personal memory (`MEMORY.md` and the memory index). +- Conversation history. + +## Importing a profile + +You import a profile from the browser interface. + +1. Open **Settings → General** and choose **Import Agent Profile**. +2. Select or drag in a `.craftbot` file. To use a pre-built bundle, download one from the [agent bundles repository](https://github.com/CraftOS-dev/craftbot-agent-bundles) first (see [Using a pre-built bundle](#using-a-pre-built-bundle) below). +3. CraftBot inspects the bundle and shows a preview: which skills and MCP servers it contains, which of those you already have installed, and which MCP servers need environment values (API keys) that were stripped on export. +4. Choose an import mode: + +| Mode | Effect on existing personality files | +|---|---| +| **Merge and Replace** | Applies the bundle's skills, MCP servers, and apps, and replaces the personality files the bundle provides. Files the bundle does not include are left alone. | +| **Overwrite** | Replaces the personality files with the bundle's versions. | + +5. Confirm. CraftBot installs the bundle's skills into your `skills/` directory and enables them, adds its MCP servers (disabled until you supply the missing keys), applies the personality files, and registers any Living UI apps. + +### What import does not change + +- **Your model and provider stay the same.** A bundle's `agent.yaml` names a recommended model, but importing never switches your configured provider or API key. Set the model yourself from **Settings → Model** if you want the recommended one. See [LLM providers](../providers/llm.md). +- **Your existing credentials stay.** The importer only adds skills, servers, personality files, and apps. It does not touch your keys or connected integrations. +- **Skills are added, not deleted.** If a skill folder already exists, the importer keeps it and reports it as skipped rather than overwriting your version. + +After import, the agent typically asks a few questions about your routines and proposes recurring tasks for `PROACTIVE.md`. See [Proactive mode](../modes/proactive.md). + +### Using a pre-built bundle + +1. Open the [CraftBot Agent Bundles repository](https://github.com/CraftOS-dev/craftbot-agent-bundles). +2. Open the `bundles/` folder and download the file for the role you want. Files are named `-.craftbot`, for example `ceo-agent-20260611.craftbot`. The most recent date is the current version. +3. Import it with the steps above. +4. Fill in API keys for the MCP servers you actually plan to use. The preview lists exactly which servers need keys. You do not need to configure servers for tools you will not use. + +The repository lists all 42 bundles grouped by domain (executive, engineering, marketing, sales, research, legal, and others), each with a description of what it does. + +## Exporting your own profile + +Once you have configured an agent you like, you can export it. + +1. Open **Settings → General** and choose **Export Agent Profile**. +2. Optionally add a description. CraftBot writes a `.craftbot` file named `craftbot--.craftbot`. + +The export includes only enabled skills and enabled MCP servers, so the file stays small and does not carry the roughly 157 disabled default servers or machine-specific command paths. Secrets are stripped, so the recipient supplies their own keys. + +Use export to move your agent to another machine, back up a configuration before experimenting, or share a persona you built. To publish a polished persona to the public repository, follow the authoring and submission process in [Create an agent bundle](../../develop/custom-agent.md). + +## Next + +- [Create an agent bundle](../../develop/custom-agent.md): author your own persona and publish it to the repository +- [Agent bundle config](../configuration/agent-config-yaml.md): the `agent.yaml` manifest fields +- [Skills](skills.md) and [MCP servers](../../integrations/mcp.md): the two capability layers a bundle carries diff --git a/mkdocs/docs/core/concepts/agent-file-system.md b/mkdocs/docs/core/concepts/agent-file-system.md new file mode 100644 index 00000000..2d7cc9fc --- /dev/null +++ b/mkdocs/docs/core/concepts/agent-file-system.md @@ -0,0 +1,83 @@ +# Agent file system + +`agent_file_system/` at the project root is the agent's home directory: a dozen markdown files that hold its identity, its knowledge of you, and its memory of everything that's happened, plus a `workspace/` where task outputs land. Understanding who writes each file, and which ones you're meant to edit, is the single highest-leverage way to tune your agent. + +## Overview +The files fall into three categories. Some are **yours to write**: the personality brief, your profile, the style guide. Some are **the agent's working notes**: the ops manual it consults and the task list it maintains. The rest are **records**: event logs, task history, and distilled memory, maintained by harness subsystems rather than by you or the agent. + +The agent reads and writes these files with ordinary file actions, and a few (SOUL.md, AGENT.md pointers, USER.md) feed directly into every LLM call. Edit one and behavior changes on the next turn, with no restart. + +The directory is seeded from templates in `app/data/agent_file_system_template/` on first run, and the `/reset` command restores the markdown files from those templates. + +## The files + +| File | Who writes it | May I edit it? | What it does | +|---|---|---|---| +| `SOUL.md` | You (agent only on your explicit request) | **Yes — the main personality knob** | Personality, tone, behavior. Injected into the system prompt every turn | +| `USER.md` | Onboarding wizard; agent, after confirming with you | **Yes** | Your profile: identity, timezone, communication preferences, life goals | +| `FORMAT.md` | You | **Yes** | Formatting standards the agent reads before generating any document | +| `GLOBAL_LIVING_UI.md` | You | **Yes** | Global design preferences for every [Living UI](../../living-ui/index.md) project — colors, theme, enforced rules | +| `AGENT.md` | Ships with CraftBot; agent appends learned operational fixes | Yes, carefully | The agent's versioned operations manual — runtime, errors, integrations, conventions. The agent greps it by `## ` | +| `PROACTIVE.md` | `recurring_*` actions and the planners | Prefer the actions; preserve the `` markers | Recurring proactive tasks plus the planner's Goals / Plan / Status — see [Proactive mode](../modes/proactive.md) | +| `MEMORY.md` | Memory processor only (nightly job) | **No** | Distilled long-term memory, one timestamped fact per line — see [Memory](memory.md) | +| `EVENT.md` | Event stream manager | **No** | Append-only chronological log of every event (actions, messages, errors) | +| `EVENT_UNPROCESSED.md` | Event stream manager | **No** | Staging buffer of events awaiting the nightly memory run; cleared after each run | +| `TASK_HISTORY.md` | Appended on every `task_end` | **No** | One summary section per finished task: status, timestamps, outcome, skills used | +| `CONVERSATION_HISTORY.md` | Event stream manager | **No** | Rolling transcript of every user ↔ agent exchange, never auto-cleared | +| `MISSION_INDEX_TEMPLATE.md` | Static template | **No** | Copied into `workspace/missions//INDEX.md` when a mission starts | + +The "No" files are harness-managed. Hand-editing them creates inconsistencies the agent can't recover from: the memory pipeline expects `MEMORY.md` in its exact line format, and the event logs are the ground truth other subsystems replay. Read them freely, but never write to them. + +## Files you should edit + +Three files do most of the customization work: + +**SOUL.md** shapes *how* the agent behaves. It's injected into the system prompt on every single turn, so edits take effect immediately and affect every interaction. Want it more terse, more playful, stricter about asking before acting? Say so here, or just tell the agent to update its soul, and it will ask for confirmation before saving. + +**USER.md** shapes *who it's working for*. Onboarding fills the skeleton (identity, timezone, communication preferences, goals). Keep it current as things change. The agent reads it at the start of user-facing tasks and only writes durable, confirmed facts back. One-off requests don't land here. + +**FORMAT.md** shapes *what it produces*. The agent consults it before generating any file. A `## global` section sets universal rules (colors, typography, writing style), and per-filetype sections (`## pptx`, `## docx`, `## xlsx`, `## pdf`) override it for that format. If every deck the agent makes has the wrong brand color, fix it once here and every future document follows. + +`GLOBAL_LIVING_UI.md` plays the same role for generated apps: design preferences and enforced rules applied to every Living UI project, with per-project answers overriding when they conflict. + +A useful side-effect to know: `AGENT.md`, `PROACTIVE.md`, `MEMORY.md`, `USER.md`, and `EVENT_UNPROCESSED.md` are indexed for the agent's semantic memory search, and a file watcher re-indexes them the moment they change, so edits to these files become retrievable knowledge, not just prompt text. + +## workspace/ + +Everything a task produces lands under `agent_file_system/workspace/`. Four zones with different lifecycles: + +```text +workspace/ +├── Persistent task outputs — reports, exports, +│ anything you asked for. Never auto-cleaned. +├── tmp// Per-task scratch: drafts, downloads, +│ intermediate state. Auto-created when the task +│ starts; auto-deleted on task end AND at startup. +├── missions// Multi-session initiatives. INDEX.md (from the +│ template) records goal, findings, next steps — +│ it's what a future task reads to restore context. +│ Never auto-cleaned. +└── living_ui/_/ Living UI projects — self-contained apps managed + by their own lifecycle actions. Don't rename or + delete these by hand. +``` + +The practical rules: + +- **Deliverables go in the workspace root.** That's where "save it as frameworks.md" ends up, and where you go looking for outputs. +- **Anything in `tmp/` is disposable by design.** If a task saved something there that you want, move it out before the task ends. +- **Missions are for work bigger than one task** (a job hunt, a research program). The mission's `INDEX.md` is the durable state. Individual tasks come and go. + +## Configuration and limits + +- **Location:** `agent_file_system/` in the project root. The template lives at `app/data/agent_file_system_template/`. +- **Edits apply on the next trigger**, with no restart. `SOUL.md` in particular takes effect on the very next turn. +- **Reset:** `/reset` deletes the markdown files and re-copies the templates. Workspace contents are handled separately, and Living UI projects are preserved by the generic reset (they have their own teardown). +- **Growth:** `EVENT.md` auto-rotates on size. `CONVERSATION_HISTORY.md` and `TASK_HISTORY.md` grow indefinitely, and `EVENT_UNPROCESSED.md` is cleared by each successful memory run. +- **Not for secrets:** API keys and credentials live in `app/config/settings.json` and `.credentials/`, not in these markdown files. + +## Next + +- [Memory](memory.md): how events become `MEMORY.md` facts, and how the agent retrieves them +- [Proactive mode](../modes/proactive.md): the system that reads and maintains `PROACTIVE.md` +- [Living UI](../../living-ui/index.md): the projects living under `workspace/living_ui/` diff --git a/mkdocs/docs/core/concepts/agent-loop.md b/mkdocs/docs/core/concepts/agent-loop.md new file mode 100644 index 00000000..18e0138d --- /dev/null +++ b/mkdocs/docs/core/concepts/agent-loop.md @@ -0,0 +1,98 @@ +# Agent loop + +The agent loop is the cycle CraftBot runs every time something wakes it up: claim a [trigger](triggers.md), route it to a workflow, let the LLM pick actions, execute them, and queue the follow-up. This cycle explains why tasks tick forward one step at a time, why the agent can wait hours for your reply without burning tokens, and why a restart doesn't lose work in flight. + +## Overview +CraftBot does not run continuously. The agent sleeps until a trigger fires (your message, a schedule, a task's own "continue" note) then runs **exactly one turn** and goes back to sleep. The design has three properties: + +1. **One trigger, one turn.** A turn is a single pass through the loop: the LLM picks one or more [actions](actions-and-action-sets.md), CraftBot executes them, and the results land on the [event stream](event-stream.md). +2. **Continuation is a new trigger, not a loop.** A ten-step task is not a `while` loop held in memory. Each turn ends by enqueuing a fresh continuation trigger for the same session. The next turn picks it up. Waiting is just a trigger with a `fire_at` timestamp in the future. +3. **State lives outside the process.** Progress is recorded in the task's todos, its event stream, and the durable trigger queue, so a crash or restart between turns re-delivers the pending trigger and the task resumes where it left off. + +This is also why several tasks can run "at once": their triggers interleave through the same loop, each turn scoped to its own [session](task-sessions.md). + +## The outer loop + +A single consumer drives everything: + +| Step | What happens | +|---|---| +| 1. Claim | `trigger_service.next()` waits for the next due trigger and marks its durable record as claimed | +| 2. React | `agent.react(trigger)` runs one full turn (everything below) | +| 3. Settle | On success the trigger is `ack()`ed (done); on an exception it is `nack()`ed, which retries it with backoff | + +A crash between claim and settle leaves the trigger claimed. The next boot re-delivers it. The guarantee is *at-least-once*, never silently lost. The details are on the [Triggers](triggers.md) page. + +!!! note "Implementation files" + The consumer is `_consume_triggers()` in `app/ui_layer/controller/ui_controller.py`. The turn itself is `AgentBase.react()` in `app/agent_base.py`. Claim/ack/nack live in `app/triggers/service.py`. + +## Inside a turn: routing + +`react()` checks the trigger, then the session's state, in a fixed order. First match wins: + +| Order | Condition | What runs | +|---|---|---| +| 1 | Trigger is a restart notice | Posts the prebuilt "I was restarted" message to chat and returns — no LLM call | +| 2 | Trigger source is `memory` | Memory workflow — spawns a task that distills recent events into long-term [memory](memory.md) | +| 3 | Trigger source is `proactive_heartbeat` / `proactive_planner` | [Proactive](../modes/proactive.md) workflow — collects due recurring tasks or runs a planner | +| 4 | Task waiting for your reply, and this trigger carries no message | Re-schedules the wait for another 3 hours and returns — the task keeps sleeping | +| 5 | Session has a running **complex** task | Complex-task workflow — todo-driven, approval-gated | +| 6 | Session has a running **simple** task | Simple-task workflow — linear, auto-completing | +| 7 | Anything else | Conversation workflow — no task exists yet | + +Before steps 4–7, the turn initializes the session and, if the trigger carries a user message routed in mid-task, records it onto the event stream so the LLM sees it. + +The three main workflows (5–7) differ in prompt shape, todo handling, and caching (compared side by side in [Task modes](../modes/index.md)) but they all execute the same four-phase pipeline. + +## The turn pipeline + +Every conversation, simple-task, and complex-task turn runs the same four phases: + +1. **Select.** One LLM call chooses one or more actions and their inputs, based on the task instruction, todos, and the event stream. In conversation mode the menu is deliberately tiny: reply, start a task (several in parallel is allowed), or deliberately ignore a message that needs no reaction. +2. **Prepare.** Each selected action is resolved by name from the task's action sets and its inputs are bound. +3. **Execute.** The actions run, in parallel when more than one was selected. Every action logs `action_start` / `action_end` events, which is what the action panel in the browser renders live. +4. **Finalize.** The action output is inspected: did it create a task? ask for a delay (`wait`)? flag `waiting_for_user_reply`? Then a **new continuation trigger** is enqueued for the session (or for each task that a parallel `task_start` created) and the turn ends. + +The finalize phase drives multi-step work. A complex task making twenty tool calls is roughly twenty turns, each handed to the next by a `task_continuation` trigger. Between turns the agent is idle, free to run a different task's turn or to sleep. + +## What happens when you send a message + +Putting it together, end to end: + +1. Your message is durably recorded, then [session routing](task-sessions.md) decides whether it continues an existing task or opens a fresh session. +2. A `user_message` trigger fires. The consumer claims it and calls `react()`. +3. No task is running for the fresh session, so the conversation workflow runs: the LLM either answers directly (`send_message`) or calls `task_start`. +4. If a task started, finalize queues a continuation trigger. Each subsequent turn works a todo, until the agent sends you a result and (for complex tasks) waits for your approval before `task_end`. +5. If the agent asked you something mid-task, the task flips to waiting-for-reply and its trigger sleeps. Your answer routes back and wakes it immediately. + +## Watch it run + +- **In the browser.** The task card, todo list, and action panel are a live rendering of the loop: each visible action is one entry in a turn's execute phase. +- **In the logs.** Every run writes to `logs/` ([Logs](logs.md)). Grep for the loop's own tags: + +```bash +grep -E "\[REACT\]|\[WORKFLOW|\[ACTION\]|\[TRIGGER" logs/.log +``` + +```text +[REACT] starting... +[WORKFLOW: CONVERSATION] Query: what's the weather in Tokyo +[ACTION] Ready to run 1 action(s): ['task_start'] +[TRIGGER] Creating new trigger for session: 4f2c1a +``` + +- **On disk.** Every event a turn produces is also appended to `agent_file_system/EVENT.md` ([Event stream](event-stream.md)). + +## Limits and error handling + +- **Per-task budgets.** Each task counts its actions and tokens. At 80% of either limit the agent gets a warning event telling it to wrap up. At 100% the task pauses and you get a Continue/Abort choice in chat. Nothing runs unbounded. +- **Waiting costs nothing.** A task waiting for your reply re-schedules itself in 3-hour hops without invoking the LLM (step 4 in the routing table). +- **Errors don't kill the loop.** Exceptions inside a turn are caught by `react()` itself, logged, and surfaced to the affected session. The consumer keeps running. Failures that escape a turn entirely cause a `nack()`: retry with exponential backoff, then a dead-letter message in chat rather than silent loss (see [Triggers](triggers.md)). +- **Feature switches.** Disabling memory or proactive mode in settings makes their triggers no-ops. Routing steps 2 and 3 return without doing anything. + +## Next + +- [Triggers](triggers.md): everything that wakes the loop, and what survives a restart +- [Task sessions](task-sessions.md): how messages find the right task, and how tasks live and end +- [Event stream](event-stream.md): the record each turn reads from and writes to +- [Task modes](../modes/index.md): conversation vs simple vs complex, compared diff --git a/mkdocs/docs/core/concepts/context-engine.md b/mkdocs/docs/core/concepts/context-engine.md new file mode 100644 index 00000000..f7a91807 --- /dev/null +++ b/mkdocs/docs/core/concepts/context-engine.md @@ -0,0 +1,96 @@ +# Context engine + +Every time CraftBot calls the LLM, the context engine decides what the model actually sees: who the agent is, who you are, what's happening right now, and what it's being asked to decide. Understanding its layout explains most of CraftBot's token costs, most of its speed, and most of "why did the agent know that?" + +## Overview +Every LLM call is two halves: + +| Half | Contents | Changes between calls? | Cached? | +|---|---|---|---| +| **Static prefix** (system prompt) | Agent identity, your profile, personality, policy, environment, file-system map | No — byte-identical within a session | Yes — provider KV cache | +| **Dynamic tail** (user prompt) | The decision template, current task, conversation history, live event stream, your query | Yes — every call | Only incrementally | + +The split is the whole design. LLM providers cache a prompt *prefix*: as long as the opening bytes of a call are identical to a previous call, those tokens are nearly free and fast. So the engine pushes everything stable to the front and everything volatile to the back. A follow-up call in a long task pays full price only for the events that happened since the last call, not for the agent's entire identity again. + +One consequence worth internalizing: **anything that varies call-to-call is banned from the prefix.** The clearest example is the current date and time. It would be natural to put "it is 14:32 on Thursday" in the system prompt, but that would change the prefix every call and bust the cache (Gemini's implicit caching is prefix-based, so even one changed byte invalidates everything after it). The engine deliberately keeps date/time out of the cached prefix. A dedicated `current_datetime_block` renders it for the dynamic tail, and every event in the stream carries its own timestamp, so the model still knows when things happened. + +## System prompt contents + +The engine assembles the system prompt from fixed sections in a fixed order: + +| # | Section | What it contains | You control it via | +|---|---|---|---| +| 1 | Agent info | Capabilities, task system, working ethic, format standards | — (built-in) | +| 2 | User profile | Your `USER.md`, verbatim | Edit [`USER.md`](agent-file-system.md) | +| 3 | Soul | Your `SOUL.md`, verbatim — personality and tone | Edit [`SOUL.md`](agent-file-system.md) | +| 4 | Language instruction | "Use the user's preferred language" rule | Language preference in `USER.md` | +| 5 | Policy | Safety, privacy, prompt-injection defense | — (built-in) | +| 6 | Role info | Agent name + role persona | [Onboarding](../../start/onboarding.md) sets the name | +| 7 | Environment | Timezone, working directory, OS — stable facts only | — (detected) | +| 8 | File system | Map of `agent_file_system/` — what each file is for | — (built-in) | +| 9 | Base instruction | One-line closing instruction | — (built-in) | + +Sections 2 and 3 are read from disk at prompt-build time, which is why editing `USER.md` or `SOUL.md` changes behavior on the very next call, with no restart. It also means an edit invalidates the cached prefix once. The first call after the edit pays full price, then caching resumes. The prompt templates behind each section are covered in [Prompts](prompts.md). + +## Per-turn message contents + +The tail is built per call and per session. Its ingredients, roughly back-to-front: + +**The decision template.** Which one depends on what's being decided: conversation-mode action selection, in-task selection, session routing, and so on (see [Prompts](prompts.md)). Within the tail, static template text still comes first and volatile content last, for the same caching reason. + +**``** holds the active task's name, instruction, and mode, plus **``** (the instructions of any skill selected for the task) and agent state. + +**``** holds the most recent user/agent messages (default **20**) from *before* the current task. This is context, not work: it lets a task understand "the thing we discussed a minute ago" without those messages polluting the task's own record. + +**``** is the live snapshot of the current session's [event stream](event-stream.md): every action started and finished, every message, every error, in order, with timestamps. This is the working memory of the task. + +The two are easy to conflate but behave differently: + +| | `` | `` | +|---|---|---| +| Contains | Chat messages before the task | Everything during the task | +| Scope | Global, shared context | One per task session | +| Growth | Capped at recent 20 messages | Grows until summarized | +| Marked as | "historical context" | "the current situation" | + +**``** appears when the triggering message came from an external platform (Telegram, Slack, Discord, ...). This small block identifies the platform, whether it's you or a third party, the sender, and the channel. This is how the agent replies on the right platform and how it knows a third-party message isn't an instruction from you. + +**Memory** is deliberately *not* injected by the engine itself. When a message arrives or a task starts, the memory system logs a single `relevant_memories` event into the event stream: pointers to matching facts, not full content. The model sees memory as just another event, right next to the message that triggered the lookup. Full lifecycle in [Memory](memory.md). + +## Cache behavior and cost + +CraftBot uses two cache levels: + +- **Prefix cache**: the static system prompt. Used for every call, including plain conversation. After the first call, the identity/profile/policy block is served from cache. +- **Session cache**: for tasks, the growing context is cached per task and per call type, and subsequent calls send only *delta events*, the events appended since the last sync. A 50-step task doesn't resend 49 steps of history on step 50. + +Conversation mode uses prefix caching only. Tasks add session caching on top. The mechanics differ per provider (Anthropic uses `cache_control` blocks, Gemini an explicit context cache, BytePlus server-side prefix/session caches, OpenAI-style providers cache automatically), but the engine's prompt layout is what makes any of them effective. + +The cost implication: a long task's per-step price is dominated by *new* events, not accumulated context. The corollary: anything that invalidates the prefix (editing `SOUL.md` mid-task, switching models) makes the next call pay full price. And when the event stream hits its summarization threshold, older events are compacted and session sync points reset. The next call repopulates the cache from the summarized stream. + +## Inspecting the assembled prompt + +- **Cache metrics in logs.** Grep `logs/` for `[CACHE METRICS]` lines. They report hits, misses, and the percentage of tokens served from cache per provider and call type. A healthy long task shows a high token-cache rate after the first few steps. +- **Memory injections.** `relevant_memories` events appear in the event stream panel like any other event, so you can see exactly which memories the model saw and when. +- **`[CONTEXT]` warnings** in logs flag failures to read `USER.md`/`SOUL.md`. If your profile edits seem ignored, look here first. + +## Configuration + +Cache behavior is tuned in the `cache` section of [`settings.json`](../configuration/config-json.md): + +| Key | Default | Meaning | +|---|---|---| +| `cache.prefix_ttl` | `3600` | Seconds the system-prompt prefix cache is kept | +| `cache.session_ttl` | `7200` | Seconds a per-task session cache is kept (long tasks) | +| `cache.min_tokens` | `500` | Skip caching for prompts shorter than this | + +The conversation-history window (20 messages) and the section order are code-level defaults, not settings. The event stream's summarization thresholds (which bound how large the dynamic tail can grow) are covered in [Event stream](event-stream.md). + +!!! note "Implementation files" + The engine is `agent_core/core/impl/context/engine.py` (`ContextEngine`). `make_prompt()` assembles the system sections in the order above. `get_event_stream()`, `get_task_state()`, and `get_message_source_block()` build the dynamic tail. `get_event_stream_delta()` / `mark_event_stream_synced()` implement session-cache delta tracking. Prompt templates live in `agent_core/core/prompts/`. + +## Next + +- [Prompts](prompts.md): the templates the engine assembles, and the files you edit to steer them +- [Event stream](event-stream.md): the dynamic half: summarization, delta tracking, thresholds +- [Memory](memory.md): how `relevant_memories` events get into the stream diff --git a/mkdocs/docs/core/concepts/event-stream.md b/mkdocs/docs/core/concepts/event-stream.md new file mode 100644 index 00000000..a6856764 --- /dev/null +++ b/mkdocs/docs/core/concepts/event-stream.md @@ -0,0 +1,106 @@ +# Event stream + +The event stream is the agent's working record: an append-only log of everything that happens in a [task session](task-sessions.md) (messages, reasoning, action starts and results, task boundaries). It is simultaneously what the chat UI renders, what the LLM reads as history on every turn, and the raw material the [memory pipeline](memory.md) distills. If you want to know "what did the agent actually see when it made that decision", the answer is always: its event stream at that moment. + +## Overview +- **One stream per session.** A main stream carries conversation-mode activity. Every task gets its own stream when it starts, so parallel tasks never read each other's history. +- **Recent events stay verbatim while old events get folded.** Each stream keeps a tail of full-fidelity events plus a rolling `head_summary`. When the tail grows past a token threshold, the oldest chunk is summarized by the LLM into the head and dropped from the tail. +- **Everything is an `Event`**: a message, a typed category, a severity, and optional structured fields (action inputs/outputs, platform, task status). Repeated identical events are collapsed into one record with a repeat counter instead of flooding the log. + +What the LLM sees each turn is the stream's *prompt snapshot* (the head summary followed by the recent tail) assembled into context by the [context engine](context-engine.md). + +## Event types + +Every event carries a typed category. This is a closed set. Consumers route on it, never on message text: + +| Event type | Recorded when | +|---|---| +| `user_message` | You send a message (locally or via a connected platform) | +| `agent_message` | The agent replies — this is what appears as a chat bubble | +| `reasoning` | The LLM explains why it picked the next action(s) | +| `action_start` / `action_end` | An action begins / finishes; carries the action name, a paired id, and structured input/output | +| `task_start` / `task_end` | A task's boundaries; `task_end` carries the final status | +| `todos` | The todo list changed | +| `waiting_for_user` | The task paused for your reply | +| `relevant_memories` | Memory retrieval injected context pointers | +| `system` / `error` | Harness notices and failures | +| `internal` | Bookkeeping the UI hides | + +## How the UI renders it + +The chat and the action panel are direct projections of streams: + +- The UI watches all streams (main + every task) and routes each event **by its `event_type` only**: `agent_message` becomes a chat bubble, `action_start`/`action_end` become the live action rows, `todos` updates the checklist, `waiting_for_user` flips the status bar. +- `action_start` and `action_end` share an `action_id`, so the panel can pair them even when several copies of the same action run in parallel. +- Events may carry a shorter `display_message` for the UI while keeping the full `message` for the LLM and for debugging. + +Nothing happens off the record: if the agent did it, there is an event for it, and the UI shows the ones that concern you. + +!!! note "Implementation files" + The event model and type enum are `agent_core/core/event_stream/event.py`. The per-stream mechanics (tail, summary, snapshots) are `agent_core/core/impl/event_stream/event_stream.py`. Stream creation per task and the file logging below are `EventStreamManager` in `agent_core/core/impl/event_stream/manager.py`. + +## EVENT.md and EVENT_UNPROCESSED.md + +Every event is also appended to markdown files in `agent_file_system/` (see [Agent file system](agent-file-system.md)), one line per event: + +| File | Contents | +|---|---| +| `EVENT.md` | The complete history — every event from every stream, in `[YYYY/MM/DD HH:MM:SS] [kind]: message` format. Auto-rotated when it grows too large. | +| `EVENT_UNPROCESSED.md` | The staging buffer for the [memory pipeline](memory.md) — the subset of events awaiting distillation into `MEMORY.md`, cleared after each processing run. | + +Routine event kinds that the memory processor would always discard (action starts/ends, reasoning, todos, errors, waiting notices, memory-retrieval pointers) are filtered out at write time, so `EVENT_UNPROCESSED.md` contains only dialogue and meaningful state changes. During a memory-processing task the buffer is frozen entirely, so the processor's own events can't loop back into it. + +These files are also the agent's own audit trail: when it troubleshoots itself, `EVENT.md` is the first place it greps. + +## Automatic stream summarization + +The stream is re-read by the LLM every turn, so each stream compacts itself: + +1. When the tail exceeds **30,000 tokens**, the oldest events (down to a **10,000-token** surviving tail) are packaged with the existing head summary and sent to the LLM. +2. The LLM returns an updated summary. It replaces the head, and the summarized events are dropped from the tail. +3. A few protected event kinds (notably the task's recorded requirements) are never folded into a summary. They survive verbatim so the task's definition of done can't be summarized away. +4. If the LLM provider is failing, the stream falls back to pruning the oldest events *without* a summary rather than hammering a dead endpoint. + +You can see this in the [logs](logs.md): + +```text +[EventStream] Triggering summarization: 31204 tokens >= 30000 threshold +[EventStream] Summarization complete. Tokens: 9845 +``` + +Separately, any single event message longer than about **16,000 characters** (a huge web page, a big file read) never enters the stream at all. It is written to the task's temp directory and replaced by a pointer event containing the file path and extracted keywords. The agent reads the file back with its file actions only if it actually needs the content. One oversized action result can't blow up every subsequent turn's prompt. + +## Relation to caching and memory + +- **Prompt caching.** Streams track per-call-type sync points so that, on cached turns, only events added since the last call are sent as a delta instead of re-sending the whole history. Summarization invalidates those sync points (the indices shift), which triggers a cache rebuild. The full story is in [Context engine](context-engine.md). +- **Long-term memory.** The stream is working memory. It ends with its task. Anything worth keeping across sessions flows through `EVENT_UNPROCESSED.md` into the memory pipeline (see [Memory](memory.md)). + +## Where events appear in the UI and logs + +- **The chat itself.** Bubbles, action rows, and todo updates are the stream, rendered. +- **On disk.** Follow the master log while you interact: + +```bash +tail -f agent_file_system/EVENT.md +``` + +```text +[2026/07/17 10:14:02] [action_start]: web_search +[2026/07/17 10:14:04] [action_end]: web_search -> success (5 results) +[2026/07/17 10:14:09] [agent message to platform: CraftBot Interface]: Here's what I found... +``` + +- **In logs.** Grep `EventStream` in `logs/` for summarization and stream lifecycle activity. + +## Limits + +- The summarization thresholds (30k trigger / 10k keep) are constructor defaults of the stream, not user settings. They are tuned to balance context quality against per-turn cost. +- A summary is lossy by design. Recent events are exact. Older history is the LLM's condensation of it. Durable facts belong in [memory](memory.md), not in the stream. +- Task streams are removed when their task ends. The permanent records are `EVENT.md`, `TASK_HISTORY.md`, and whatever memory distilled. + +## Next + +- [Agent loop](agent-loop.md): the producer: every turn writes here +- [Task sessions](task-sessions.md): why each task gets its own stream +- [Context engine](context-engine.md): how snapshots and deltas reach the LLM +- [Memory](memory.md): how events become long-term memory diff --git a/mkdocs/docs/core/concepts/logs.md b/mkdocs/docs/core/concepts/logs.md new file mode 100644 index 00000000..b73fb587 --- /dev/null +++ b/mkdocs/docs/core/concepts/logs.md @@ -0,0 +1,100 @@ +# Logs + +When the agent does something unexpected (a task stalls, a schedule doesn't fire, an action errors) the logs are the ground truth. Every run writes a timestamped folder under `logs/` at the project root, capturing what every subsystem did, down to module and line number. + +## Overview +CraftBot logs with **Loguru**, and each process run gets **one folder**: `logs//` (e.g. `logs/20260717085754/`). Inside, the same stream is split three ways by *who was speaking*: + +| File | Contains | Read it when | +|---|---|---| +| `main.log` | Only the main agent (plus framework startup) | You want the primary agent's story without sub-agent noise | +| `all.log` | Everything, interleaved in true time order — main agent and every sub-agent | You're debugging anything that crosses agents, or just want the full picture. **Start here** | +| `sub__.log` | One file per sub-agent spawned during the run (e.g. `sub_research_agent_2a707e74.log`) | A specific delegated job misbehaved — see [Sub-agents](sub-agents.md) | + +The split works through an attribution tag: every line carries an `agent` field: `main` for the main agent, `sub::` for lines emitted inside a sub-agent's run (including its actions and LLM calls). `main.log` and the per-sub-agent files are filtered views of the same stream. `all.log` keeps the cross-agent ordering that the filtered files lose. + +## Reading a line + +``` +2026-07-17 02:17:32.811 | INFO | main | app.scheduler.manager:initialize:83 - [SCHEDULER] Initialized with 5 schedule(s) +^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^ ^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +timestamp level agent module:function:line message +``` + +- **Level:** `DEBUG` < `INFO` < `WARNING` < `ERROR`. The file threshold is INFO, and the harness narrates generously at INFO, so most context is captured by default. +- **Agent:** `main` or `sub::` (the per-sub-agent files omit this column because the filename already says it). +- **module:function:line** points at the exact source location. Open the module and jump to the line for full context. +- Errors include full tracebacks (`backtrace` and `diagnose` are enabled). + +Note that the loguru sinks are file-only. The console is not a log sink, so tail the files rather than watching the terminal. + +## Subsystem tags + +Most subsystems prefix their messages with a bracketed tag, which makes grep the natural interface: + +| Tag | Covers | +|---|---| +| `[REACT]` | The agent loop — each trigger consumed, each reaction; `[REACT ERROR]` for caught loop-level exceptions | +| `[ACTION]` | Action preparation and execution | +| `[TASK]` | Task lifecycle — create, update, end | +| `[MEMORY]` | Memory indexing, processing, retrieval. See [Memory](memory.md) | +| `[MCP]` | MCP server init, connection, tool calls | +| `[SCHEDULER]` | Schedule loops: sleep-until times, wakes, fires. See [Scheduling](scheduling.md) | +| `[PROACTIVE]` | Proactive heartbeat and planners. See [Proactive mode](../modes/proactive.md) | +| `[LIMIT]` | Action/token budget warnings and the continue/abort gate | + +## Grep recipes + +Find the newest run first, since it's the one you almost always want: + +```bash +cd logs && ls -t | head -2 # newest run folders +``` + +**Why did a task fail?** Errors first, then rewind for the story leading up to them: + +```bash +grep -n "ERROR" logs//all.log | tail -20 +grep -n "\[REACT ERROR\]\|\[TASK\]" logs//all.log +``` + +Then open `all.log` at the line numbers you found and read upward. The `[ACTION]` and `[REACT]` lines just before an error usually name the exact action and input that broke. + +**Follow one action end to end.** Every action is logged by name at preparation and execution: + +```bash +grep -n "web_fetch" logs//all.log # one action's full trail +grep -n "\[ACTION\]" logs//all.log | tail # recent action activity +``` + +**Watch the scheduler live.** This shows whether a schedule fired and when it fires next: + +```bash +tail -f logs//all.log | grep "\[SCHEDULER\]" +``` + +You'll see each loop's `sleeping until