Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
.PHONY: dev backend frontend app electron electron-package test smoke build run lint setup reset_db sync_bots
.PHONY: dev backend frontend app electron electron-package test smoke build run lint setup reset_db sync_bots --include-llm-call-details

ELECTRON_DETAIL_ARGS := $(if $(filter --include-llm-call-details,$(MAKECMDGOALS)),--include-llm-call-details,)

setup: ## install backend and frontend dependencies, create .env from the template
cd backend && uv sync
Expand All @@ -15,7 +17,7 @@ frontend: ## Vite dev server, proxies /api to the backend
cd frontend && pnpm dev

electron: ## launch Electron with a Vite frontend and dedicated backend on :8001 (override ELECTRON_BACKEND_PORT)
./scripts/electron-dev.sh
./scripts/electron-dev.sh $(ELECTRON_DETAIL_ARGS)

app: ## alias for `make electron`; starts the Electron frontend and backend together
$(MAKE) electron
Expand Down Expand Up @@ -45,4 +47,4 @@ build: ## build the frontend for production serving by the backend
cd frontend && pnpm build

run: build ## production-style single process on :8000 serving the built UI, from the repo root
uv run --project backend uvicorn openbot.main:app --port 8000
uv run --project backend python -m openbot.cli --port 8000
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ make app # starts the backend, Vite, and Electron together
The desktop launcher runs its backend on port **8001** (override with
`ELECTRON_BACKEND_PORT`) so it can run alongside `make run`, whose production-style backend
continues to use port **8000**. It passes the selected backend URL to Electron so API and SSE
requests use the dedicated port. `make electron` is an equivalent launcher command. To run the
requests use the dedicated port. `make electron` is an equivalent launcher command. Electron omits detailed per-LLM-call token data by default; pass `make electron -- --include-llm-call-details` to opt in. The backend CLI also supports `--include-llm-call-details` and `--exclude-llm-call-details`; ordinary `make run` and browser workflows retain detailed call data by default. To run the
individual processes manually, use `make backend`, `make frontend`, and then `cd frontend &&
pnpm electron`; that manual workflow continues to use port 8000.

Expand Down
56 changes: 56 additions & 0 deletions backend/openbot/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Command-line entry point for the OpenBot backend."""
from __future__ import annotations

import argparse
import os

import uvicorn


def main() -> None:
args, uvicorn_args = _parse_args()
_apply_detail_setting(args)
uvicorn.run("openbot.main:app", **_uvicorn_options(uvicorn_args))


def _apply_detail_setting(args: argparse.Namespace) -> None:
"""Apply the optional detail setting without changing ordinary environment-based startup."""
if args.include_llm_call_details:
os.environ["OPENBOT_INCLUDE_LLM_CALL_DETAILS"] = "true"
elif args.exclude_llm_call_details:
os.environ["OPENBOT_INCLUDE_LLM_CALL_DETAILS"] = "false"


def _parse_args(argv: list[str] | None = None) -> tuple[argparse.Namespace, list[str]]:
parser = argparse.ArgumentParser(description="Run the OpenBot backend.")
details = parser.add_mutually_exclusive_group()
details.add_argument("--include-llm-call-details", action="store_true",
help="include per-model-call token details in activity events")
details.add_argument("--exclude-llm-call-details", action="store_true",
help="omit per-model-call token details from activity events")
return parser.parse_known_args(argv)


def _uvicorn_options(args: list[str]) -> dict[str, object]:
"""Translate the small set of launcher options used by Make/Electron."""
options: dict[str, object] = {}
i = 0
while i < len(args):
arg = args[i]
if arg == "--reload":
options["reload"] = True
elif arg == "--reload-dir":
i += 1
options.setdefault("reload_dirs", []).append(args[i])
elif arg == "--port":
i += 1
options["port"] = int(args[i])
elif arg == "--host":
i += 1
options["host"] = args[i]
i += 1
return options


if __name__ == "__main__":
main()
3 changes: 2 additions & 1 deletion backend/openbot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from pathlib import Path
from typing import Annotated

from pydantic import field_validator
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict

# Shared regex for validating Telegram Bot API tokens (format: <bot_id>:<token>).
Expand Down Expand Up @@ -53,6 +53,7 @@ class Settings(BaseSettings):
max_bot_hops: int = 20
# Token-efficiency controls. See README "Configuration".
prompt_caching: bool = True # add Anthropic cache breakpoints to every model call
include_llm_call_details: bool = Field(default=True, validation_alias="OPENBOT_INCLUDE_LLM_CALL_DETAILS") # expose per-call token details in activity events; Electron opts out
direct_anthropic: bool = True # send OpenRouter `anthropic/...` models to Anthropic directly when a key exists
tool_output_cap: int = 8000 # max chars of any single tool result the model sees (head + tail kept)
shell_output_cap: int = 4000 # tighter cap for run_shell, so dumping a file through cat/git show loses to read_file ranges
Expand Down
3 changes: 2 additions & 1 deletion backend/openbot/runtime/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,8 @@ async def _stream(self, agent, inputs, config, ctx: RunContext, run: Run, seq: i
await activity.record(self.s, "run.model_call", level="debug", thread_id=run.thread_id, actor_id=run.actor_id,
run_id=run.id,
summary=f"model replied: {len(m.tool_calls)} tool call(s), {len(_text(m))} chars of text",
tool_calls=[tc["name"] for tc in m.tool_calls], text_chars=len(_text(m)), usage=inc,
tool_calls=[tc["name"] for tc in m.tool_calls], text_chars=len(_text(m)),
usage=inc if self.s.settings.include_llm_call_details else None,
calls_so_far=usage["model_calls"])
for tc in m.tool_calls:
log.info("run %s tool_call %s(%s)", run.id, tc["name"], _preview(tc["args"]))
Expand Down
32 changes: 32 additions & 0 deletions backend/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,43 @@
These tests instead go through the real env-var / .env parsing path pydantic-settings uses at
startup, which is exactly where both bugs surfaced.
"""
import os
from pathlib import Path

from openbot.cli import _apply_detail_setting, _parse_args
from openbot.config import Settings


def test_cli_defaults_to_environment_behavior(monkeypatch):
monkeypatch.delenv("OPENBOT_INCLUDE_LLM_CALL_DETAILS", raising=False)
args, uvicorn_args = _parse_args(["--port", "8000"])
_apply_detail_setting(args)
assert uvicorn_args == ["--port", "8000"]
assert "OPENBOT_INCLUDE_LLM_CALL_DETAILS" not in os.environ


def test_cli_detail_flags_override_environment(monkeypatch):
monkeypatch.setenv("OPENBOT_INCLUDE_LLM_CALL_DETAILS", "false")
args, _ = _parse_args(["--include-llm-call-details"])
_apply_detail_setting(args)
assert os.environ["OPENBOT_INCLUDE_LLM_CALL_DETAILS"] == "true"
args, _ = _parse_args(["--exclude-llm-call-details"])
_apply_detail_setting(args)
assert os.environ["OPENBOT_INCLUDE_LLM_CALL_DETAILS"] == "false"


def test_llm_call_details_default_is_enabled(monkeypatch):
monkeypatch.delenv("OPENBOT_INCLUDE_LLM_CALL_DETAILS", raising=False)
settings = Settings(_env_file=None)
assert settings.include_llm_call_details is True


def test_llm_call_details_env_override(monkeypatch):
monkeypatch.setenv("OPENBOT_INCLUDE_LLM_CALL_DETAILS", "false")
settings = Settings(_env_file=None)
assert settings.include_llm_call_details is False


def test_empty_frontend_dist_env_uses_default_not_repo_root(monkeypatch):
# Path("") normalizes to Path(".") -- the repo root, which always exists -- so without the
# fallback validator this would make main.py mount the entire repo as static files.
Expand Down
18 changes: 18 additions & 0 deletions frontend/electron/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ const mainSource = readFileSync(path.join(__dirname, "main.cjs"), "utf8");
const builderSource = readFileSync(path.join(__dirname, "../../electron-builder.yml"), "utf8");
const backendScriptSource = readFileSync(path.join(__dirname, "../../scripts/electron-backend.sh"), "utf8");
const devScriptPath = path.join(__dirname, "../../scripts/electron-dev.sh");
const devScriptSource = readFileSync(devScriptPath, "utf8");

describe("Electron launcher detail controls", () => {
it("defaults development Electron to omit details and forwards the opt-in", () => {
expect(devScriptSource).toContain('DETAILS_FLAG="--exclude-llm-call-details"');
expect(devScriptSource).toContain('[[ "${1:-}" == "--include-llm-call-details" ]]');
expect(devScriptSource).toContain('shift');
expect(devScriptSource).toContain('python -m openbot.cli "$DETAILS_FLAG"');
});

it("forwards the documented opt-in through make", () => {
const output = execFileSync("make", ["-n", "electron", "--", "--include-llm-call-details"], {
cwd: path.resolve(__dirname, "../.."),
encoding: "utf8",
});
expect(output).toContain("./scripts/electron-dev.sh --include-llm-call-details");
});
});

describe("Electron launcher paths", () => {
it("resolves the repository from the script location when launched elsewhere", () => {
Expand Down
6 changes: 5 additions & 1 deletion scripts/electron-backend.sh
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,8 @@ fi
# No .venv ships in the bundle (see electron-builder.yml): a venv uv builds has an absolute
# shebang back to wherever it was built, so a pre-built one would work only on the machine that
# packaged the app. `uv run` here builds one rooted in this actual installed copy instead.
exec "$UV" run --project "$PROJECT_ROOT/backend" uvicorn openbot.main:app --host 127.0.0.1 --port "$PORT"
DETAILS_FLAG="--exclude-llm-call-details"
if [ "${OPENBOT_INCLUDE_LLM_CALL_DETAILS:-false}" = "true" ]; then
DETAILS_FLAG="--include-llm-call-details"
fi
exec "$UV" run --project "$PROJECT_ROOT/backend" python -m openbot.cli "$DETAILS_FLAG" --host 127.0.0.1 --port "$PORT"
7 changes: 6 additions & 1 deletion scripts/electron-dev.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ fi

ELECTRON_BACKEND_PORT="${ELECTRON_BACKEND_PORT:-8001}"
FRONTEND_PORT="${FRONTEND_PORT:-5173}"
DETAILS_FLAG="--exclude-llm-call-details"
if [[ "${1:-}" == "--include-llm-call-details" ]]; then
DETAILS_FLAG="--include-llm-call-details"
shift
fi
BACKEND_URL="http://localhost:${ELECTRON_BACKEND_PORT}"
FRONTEND_URL="http://localhost:${FRONTEND_PORT}"

Expand Down Expand Up @@ -78,7 +83,7 @@ echo "Starting Electron backend on ${BACKEND_URL}"
# repo root recursively -- .venv, node_modules, and every git worktree checked out under it -- which
# can exceed 100k files and has wedged the reload watcher outright during unrelated git activity
# elsewhere in the tree.
run_in_process_group uv run --project backend uvicorn openbot.main:app --reload --reload-dir backend/openbot --reload-dir tools --port "$ELECTRON_BACKEND_PORT" &
run_in_process_group uv run --project backend python -m openbot.cli "$DETAILS_FLAG" --reload --reload-dir backend/openbot --reload-dir tools --port "$ELECTRON_BACKEND_PORT" &
backend_pid=$!

echo "Starting Vite frontend on ${FRONTEND_URL}"
Expand Down
Loading