diff --git a/.gitignore b/.gitignore index 419a0480..f7b5f20e 100644 --- a/.gitignore +++ b/.gitignore @@ -49,9 +49,10 @@ htmlcov-report experiment_service_pb2.py experiment_service_pb2_grpc.py settings.json -laumch.json +launch.json pkg_main.py _version.py +.wl_opencode.json # Ignore extensions *.onnx diff --git a/README.md b/README.md index 7297a035..776001c5 100644 --- a/README.md +++ b/README.md @@ -321,6 +321,20 @@ Find our documentation [online](https://grayboxtech.github.io/weightslab/latest/ +
+Agent: chat with your training run (OpenCode) + +
+ +WeightsLab ships two distinct agent surfaces — the backend SDK agent for data-manipulation +queries, and a local [OpenCode](https://opencode.ai)-backed agent with a full bash/file +toolset that can restart training, edit your code, and run recurring `/loop` monitoring +jobs. See the [Agent docs](https://grayboxtech.github.io/weightslab/latest/agent.html) for +how the two connect, how to point either one at a local model, and the full `/loop` +reference. + +
+
diff --git a/agent_config.yaml b/agent_config.yaml index ca1ef1d5..3a3aa2f9 100644 --- a/agent_config.yaml +++ b/agent_config.yaml @@ -4,25 +4,20 @@ # or directly in the config file. # Config file values will override env. variables if both are set. # If cloned, env. variables can be defined in a .env file at the root of the repository. +# +# OpenCode (opencode.ai) is the only supported agent backend: a local OpenCode +# server backs every LLM call. There is no API key here -- the credential +# lives in OpenCode's own config, entered once via `opencode auth login` or +# the Weights Studio landing page's login modal. agent: - # Select the model provider. - # Local: 'ollama' - # Remote: 'openrouter' - # provider: openrouter # Default to OpenRouter if API key is provided, otherwise fallback to local Ollama. This can be overridden by env variable PREFERRED_PROVIDER. + # URL of the local OpenCode server (can also be set as env variable + # OPENCODE_URL). Defaults to http://127.0.0.1:4096. This is the SAME shared + # root env var the frontend reads, so set it once and both sides point at + # one server. + opencode_url: http://127.0.0.1:4096 - # Local Settings - fallback_to_local: false - ollama_model: llama3.2:3b - - # # Remote Model Selection - # Default is a fast flash-class model. The intent-planning task is simple JSON - # generation, so a small/fast model responds in ~2-4s where a 70B model took - # ~15-30s for no accuracy gain. Switch back to a large model here if you see - # accuracy issues (speed/accuracy tradeoff). - openrouter_model: bytedance-seed/seed-2.0-lite # Open router model name (can also be set as env variable OPENROUTER_MODEL). Fast alternatives: openai/gpt-4o-mini, meta-llama/llama-3.1-8b-instruct. Accurate/slow: ~google/gemini-flash-latest - # openrouter_api_key: # Open router API key (can also be set as env variable OPENROUTER_API_KEY) - openrouter_base_url: https://openrouter.ai/api/v1 # Open router base URL (can also be set as env variable OPENROUTER_BASE_URL) - openrouter_request_timeout: 60.0 # Timeout for OpenRouter API requests in seconds (can also be set as env variable OPENROUTER_REQUEST_TIMEOUT) - openrouter_max_tokens: 2048 # Max completion length. OpenRouter reserves max_tokens*price against the key budget BEFORE generating, so an uncapped value can 402 ("more credits, or fewer max_tokens") on a credit/weekly-limited key. Raise only if responses get truncated (env: OPENROUTER_MAX_TOKENS) - openrouter_provider_sort: throughput # Bias OpenRouter's upstream routing to avoid slow providers: 'throughput' | 'latency' | 'price'. Empty string = let OpenRouter choose (env: OPENROUTER_PROVIDER_SORT) - openrouter_structured_output: false # Ask the model for a schema-validated plan directly (skips free-form JSON + regex repair). More reliable, but only works on models whose OpenRouter route supports structured/JSON-schema output (e.g. Gemini, GPT-4o). env: OPENROUTER_STRUCTURED_OUTPUT=1 + # OpenCode model, "providerID/modelID" (can also be set as env variable + # OPENCODE_MODEL). Empty string self-heals to whatever OpenCode's own + # config was last set to, or a configured provider default, falling back + # to the free-tier "opencode/deepseek-v4-flash-free" if neither resolves. + opencode_model: "opencode/deepseek-v4-flash-free" diff --git a/docs/agent.rst b/docs/agent.rst index 9f00ed89..4fd6e570 100644 --- a/docs/agent.rst +++ b/docs/agent.rst @@ -16,6 +16,52 @@ leaves the process except the prompt text sent to the configured LLM provider. Describe what you want in plain English; the agent translates it into a safe, reviewable plan of dataframe and model operations and executes it. +Two agent surfaces, one OpenCode server +----------------------------------------- + +WeightsLab's agent capability is backed entirely by `OpenCode +`_ — a local ``opencode serve`` process that WeightsLab +starts (or reuses) for you. There is no separate OpenRouter/Ollama +integration to configure: OpenCode itself is the provider layer, and its own +config (``opencode auth login``, or the login modal described below) holds +whatever credentials you use — OpenRouter, Anthropic, a local Ollama model, +anything OpenCode supports. + +That one server backs **two very different agent surfaces**, and knowing +which one you're talking to matters — everything on the rest of this page +describes the first one: + +.. list-table:: + :header-rows: 1 + :widths: 20 40 40 + + * - + - Backend SDK agent + - "Frontend" / OpenCode agent + * - Drives + - The normal query bar and chat-history-panel conversation + (``DataManipulationAgent``, ``weightslab/trainer/services/agent/agent.py``) + - The landing-page chat (pre-experiment) and ``/loop`` (during an experiment) + * - Toolset + - None — every mutating tool (``write``/``edit``/``patch``/``bash``) is + explicitly disabled on every call (``opencode_chat.py``'s + ``_MUTATING_TOOLS``) + - Full toolset — bash, file read/write/edit/patch + * - Memory + - ``self.history``, cleared/summarized by ``/clear`` and ``/compact`` + - An OpenCode session (server-side); cleared/summarized the same way, via + OpenCode's own session delete/summarize endpoints + * - Talks to OpenCode + - In one-shot mode: send a prompt, get text back, no side effects + - Interactively: it can restart training, edit your code, discard/tag + data, run reports + +**During an active experiment, the only way to reach the frontend/OpenCode +agent is** ``/loop`` **from the experiment agent bar.** The landing-page chat +only exists pre-experiment — once you're connected to a running experiment, +that surface is gone, and ``/loop`` (see the "``/loop`` reference" section +near the end of this page) is the sole entry point to the same kind of agent. + What the agent can do --------------------- @@ -171,12 +217,15 @@ dataframe state: .. code-block:: bash - export UTEST_AGENT_PROMPT_EVALUATION=sk-or-... # OpenRouter API key - export UTEST_AGENT_PROMPT_EVALUATION_MODEL=openai/gpt-4o-mini # optional + # Requires a local OpenCode server already running and authenticated + # (opencode has no API-key env var of its own -- see "Initializing the + # agent" below). + export UTEST_AGENT_PROMPT_EVALUATION=1 + export OPENCODE_MODEL=openrouter/anthropic/claude-opus-4.6 # optional pytest weightslab/tests/trainer/services/test_agent_live_prompt_evaluation.py -v Without ``UTEST_AGENT_PROMPT_EVALUATION`` set, the suite logs a note and -skips entirely (it never runs by accident in CI or consumes API credits +skips entirely (it never runs by accident in CI or against a real model unintentionally). A small always-on sanity check for the harness itself (fixture shape, op-runner correctness) still runs regardless. @@ -241,15 +290,48 @@ scenario end-to-end against a real model. Initializing the agent ---------------------- -The agent needs an LLM provider before it can serve requests. Two provider -families are supported: +The agent needs a reachable OpenCode server before it can serve requests -- +OpenCode is the only supported backend. Nothing to install beyond WeightsLab +itself: ``opencode-ai``'s bundled binary ships with the UI's dependencies, and +the UI server (``weightslab/ui/server.py``) starts an ``opencode serve`` child +process on first use, rooted at your experiment directory, tearing it down +when the UI server exits. + +Both agent surfaces (see above) converge on the **same** OpenCode server via +one shared environment variable: -- **OpenRouter** — cloud-hosted models (recommended; interactive onboarding in - the UI). -- **Ollama** — local inference, available immediately at backend startup when - configured in ``agent_config.yaml``. +.. code-block:: bash -You can initialize it three ways. + export OPENCODE_URL=http://127.0.0.1:4096 # or wherever your own `opencode serve` is running + +If ``OPENCODE_URL`` is set and reachable, the UI server adopts it directly +instead of spawning a child; the backend SDK agent reads the same variable +(``agent.py``'s ``_load_config``) — set it once and both sides talk to the one +server, so a model you authenticate once is available everywhere. +``OPENCODE_MODEL`` (or ``agent_config.yaml``'s ``agent.opencode_model``) picks +the default model for the backend SDK agent, as an OpenCode +``providerID/modelID`` string (e.g. ``openrouter/anthropic/claude-opus-4.6``). +Leave it unset to fall back, in order, to: whatever model OpenCode's own +``/config`` was last set to (the model picker's own pick, e.g. from the +Weights Studio landing page), and otherwise the free-tier +``opencode/deepseek-v4-flash-free`` automatically — a provider's own +reported default used to be tried in between, but that could itself be an +arbitrary, non-text-reasoning model whenever any provider had credentials +configured, so it no longer overrides this. + +Credentials and provider setup live in OpenCode itself, never in WeightsLab: + +.. code-block:: bash + + opencode auth login # OpenRouter, Anthropic, a local Ollama endpoint, anything OpenCode supports + +or, from the browser, the landing page's login modal drives the same flow +without a terminal. For a fully local setup, point OpenCode's own config at +Ollama (or any other local provider it supports) — WeightsLab needs no +changes on its side; it just asks OpenCode for whichever model you've +selected. + +You can initialize the backend SDK agent three ways. Option 1 — Weights Studio UI (recommended) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -265,13 +347,12 @@ Type one of these commands into the chat bar: * - Command - Effect * - ``/init`` - - Opens the OpenRouter onboarding modal. Choose **A — Enter OpenRouter API - key** (paste an ``sk-or-…`` key) or **B — Get API key from OpenRouter** - (OAuth flow), then pick a model. On success the placeholder switches to a + - Connects to the OpenCode server (see ``OPENCODE_URL`` above) and lets + you pick a model. On success the placeholder switches to a ready-to-use example query. * - ``/model`` - - Opens the model browser to switch the active OpenRouter model without - re-entering the API key. + - Opens the model browser to switch the active OpenCode model without + reconnecting. * - ``/reset`` - Clears the current connection and returns the agent to the uninitialized state. @@ -294,9 +375,9 @@ the interactive console exposes an ``agent`` verb: .. code-block:: text agent status # Is the agent available? - agent init --api-key sk-or-... --model openai/gpt-4o-mini [--timeout 20] - agent models # List available OpenRouter models - agent model ~google/gemini-flash-latest # Switch model + agent init [--model openrouter/anthropic/claude-opus-4.6] + agent models # List available OpenCode models + agent model openrouter/openai/gpt-5 # Switch model agent reset # Clear the connection agent query # Run a natural-language request query # Shortcut for `agent query` @@ -314,40 +395,33 @@ UI). For example: Option 3 — Startup configuration file ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -To have a provider ready the moment the backend starts (no ``/init`` needed), -configure ``agent_config.yaml`` and/or environment variables. This is the only -way to enable the local Ollama provider. +To have the agent ready the moment the backend starts (no ``/init`` needed), +configure ``agent_config.yaml`` and/or environment variables. .. code-block:: yaml # agent_config.yaml (repo root, package root, cwd, or $AGENT_CONFIG_PATH) agent: - provider: openrouter # or "ollama" - openrouter_model: ~google/gemini-flash-latest - fallback_to_local: false - # Local Ollama alternative: - ollama_model: llama3.2:3b - ollama_host: localhost - ollama_port: 11435 + opencode_url: http://127.0.0.1:4096 + opencode_model: "" # empty = use OpenCode's own configured default .. code-block:: bash - # Prefer secrets via environment variables over YAML. - export OPENROUTER_API_KEY=your_openrouter_key + # Equivalent environment variables (config file wins if both are set). + export OPENCODE_URL=http://127.0.0.1:4096 + export OPENCODE_MODEL=openrouter/anthropic/claude-opus-4.6 See :doc:`configuration` for the full list of agent environment variables, the ``agent_config.yaml`` lookup order, and every supported YAML key. .. note:: - "Available" means the credentials were actually confirmed to work, not - just that a client object was constructed. A key configured via - ``agent_config.yaml``/environment variables (Option 3) is probed once at - backend startup exactly like the ``/init`` UI flow already does, and if a - live query ever gets rejected with 401, the connection is immediately - marked unavailable rather than continuing to report "ready" until the - next restart. If health checks and real requests ever disagree, that's a - bug — the two are kept in sync by design. + "Available" only means a client object was constructed against + ``OPENCODE_URL`` -- OpenCode's own constructor never eagerly connects, so + there is nothing to probe at backend startup the way a cloud API key + needed a connectivity check. Actual unreachability (server not running, or + later restarted) surfaces on the first real query instead, which is + reported through the normal "Internal Agent Error"/reconnect path. Using the agent effectively ---------------------------- @@ -646,6 +720,57 @@ expression it builds for you. comparisons are ``False``) — the query never errors, it just excludes those rows. +``/loop`` reference +---------------------- + +``/loop``, typed into the **experiment agent bar**, is the other agent +surface described at the top of this page: it starts a recurring check-in +against a dedicated OpenCode session — the same kind of session the +landing-page chat uses, with the same full toolset. It never touches the +backend SDK agent directly. + +.. code-block:: text + + /loop 30m Watch the training loss and loss_shape trends; if the run stalls or diverges, pause it and tell me why + /loop list + /loop stop + +- **Syntax**: ``/loop m|h `` to start (minimum interval: 60s), + ``/loop list`` to see running jobs, ``/loop stop `` to cancel one. +- **What it can do**: the loop's OpenCode session is told about the local + ``weightslab`` CLI, reachable over bash against the live training process: + + - ``weightslab pause`` / ``weightslab resume`` — freeze/resume weight updates + - ``weightslab discard `` — discard a sample by id + - ``weightslab agent query ""`` — hands the request to the + **backend SDK agent's** own intent pipeline, e.g. ``weightslab agent + query "discard samples where loss > 5 and tag them hard_examples"``. This + is how the loop reaches back into the database/history: it can't ask the + backend agent directly, but it can drive it through the CLI. + - ``weightslab status`` — a snapshot of hyperparameters/model/training state + + These four are what the loop's system prompt explicitly calls out, but bash + access means any other ``weightslab`` CLI verb is reachable too — e.g. + ``weightslab report`` to generate a narrative report for the loop to read + and act on. It may also read/edit training code directly and attempt to + restart a crashed process via bash — this is best-effort (no supervisor or + PID handoff): it looks for the process, stops it if still running, and + re-launches from whatever it can determine (shell history, a run script, + logs). There is no dedicated restart command. +- **Concurrency cap**: at most 3 loops at once, shared across both chat + surfaces (they hit the same registry). A 4th ``/loop start`` is rejected + with an error rather than silently stopping an older job — stop one first + with ``/loop stop ``. +- **Managing running jobs**: a panel pinned at the top of the chat-history + window lists every running job with a live countdown to its next check-in, + and lets you edit a job's prompt/interval in place or stop it — no need to + remember ``/loop stop `` if the panel is in view. ``/loop list``/``/loop + stop`` also work from the landing-page chat pre-experiment, hitting the + same registry. +- **Persistence**: a loop is tied to the running ``weightslab start`` process, + not the browser tab — it survives a page reload or closed tab, but not a + full restart of the UI server. + Workflow pattern ---------------- diff --git a/docs/configuration.rst b/docs/configuration.rst index a9c70387..3aa327a5 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -718,10 +718,15 @@ Evaluation Mode ``0`` disables the absolute override and uses the dynamic formula only. -AI / LLM API Keys -~~~~~~~~~~~~~~~~~ +AI / LLM Configuration +~~~~~~~~~~~~~~~~~~~~~~ -These keys are required only when using the agentic data-query features. +OpenCode (`opencode.ai `_) is the agent's only supported +backend -- there is no API key to set here. The credential lives in +OpenCode's own config (``opencode auth login``, or the Weights Studio landing +page's login modal), which can point at OpenRouter, Anthropic, a local Ollama +endpoint, or anything else OpenCode supports; WeightsLab itself only needs to +know which server to talk to. .. list-table:: :header-rows: 1 @@ -730,19 +735,24 @@ These keys are required only when using the agentic data-query features. * - Variable - Default - Description - * - ``OPENROUTER_API_KEY`` + * - ``OPENCODE_URL`` + - ``http://127.0.0.1:4096`` + - URL of the local OpenCode server. Shared with the frontend (Weights + Studio's landing-page chat and ``/loop``) via the same variable, so + set it once and both sides talk to the one server. + * - ``OPENCODE_MODEL`` - *(empty)* - - OpenRouter API key ? required for cloud agent setup in Weights Studio. + - Default model for the backend SDK agent, as an OpenCode + ``providerID/modelID`` string (e.g. + ``openrouter/anthropic/claude-opus-4.6``). Empty falls back to + OpenCode's own last-picked model, and otherwise the free-tier + ``opencode/deepseek-v4-flash-free`` automatically. Agent Configuration ~~~~~~~~~~~~~~~~~~~ These variables control how the data-query agent finds its YAML configuration. -The agent supports two provider families: - -- ``ollama`` for local inference -- ``openrouter`` for cloud-hosted models .. list-table:: :header-rows: 1 @@ -779,7 +789,7 @@ Agent Provider Setup ~~~~~~~~~~~~~~~~~~~~ The runtime agent is configured from ``agent_config.yaml`` plus optional -environment variables such as ``OPENROUTER_API_KEY``. +environment variables such as ``OPENCODE_URL``. Supported YAML keys ^^^^^^^^^^^^^^^^^^^ @@ -791,73 +801,34 @@ Supported YAML keys * - Key - Example - Description - * - ``agent.provider`` - - ``ollama`` - - Active provider. Common values: ``ollama`` or ``openrouter``. - * - ``agent.ollama_model`` - - ``llama3.2:3b`` - - Local Ollama model name. - * - ``agent.ollama_host`` - - ``localhost`` - - Ollama host. - * - ``agent.ollama_port`` - - ``11435`` - - Ollama HTTP port used by WeightsLab. - * - ``agent.openrouter_model`` - - ``~google/gemini-flash-latest`` - - Default OpenRouter model. - * - ``agent.openrouter_base_url`` - - ``https://openrouter.ai/api/v1`` - - OpenRouter-compatible base URL. - * - ``agent.openrouter_request_timeout`` - - ``15.0`` - - Request timeout in seconds for OpenRouter calls. - * - ``agent.openrouter_api_key`` - - *(secret)* - - Optional API key in YAML. Prefer environment variables or UI init when possible. - * - ``agent.fallback_to_local`` - - ``false`` - - If enabled, WeightsLab also tries the local Ollama provider as fallback. - -Local Ollama example -^^^^^^^^^^^^^^^^^^^^ - -Use this mode when you want the agent available immediately at backend startup. - -.. code-block:: yaml - - agent: - provider: ollama - ollama_model: llama3.2:3b - ollama_host: localhost - ollama_port: 11435 - fallback_to_local: false - -Operational steps: + * - ``agent.opencode_url`` + - ``http://127.0.0.1:4096`` + - URL of the local OpenCode server. + * - ``agent.opencode_model`` + - ``openrouter/anthropic/claude-opus-4.6`` + - Default model, as an OpenCode ``providerID/modelID`` string. Empty + falls back to OpenCode's own last-picked model, and otherwise the + free-tier ``opencode/deepseek-v4-flash-free`` automatically. -1. Install Ollama. -2. Pull a model, for example ``ollama pull llama3.2:3b``. -3. Start the Ollama server. -4. Start WeightsLab. -5. Open Weights Studio and query the agent directly. - -Cloud OpenRouter example -^^^^^^^^^^^^^^^^^^^^^^^^ - -Use this mode when you want hosted models and interactive setup from Weights Studio. +Example +^^^^^^^ .. code-block:: yaml agent: - provider: openrouter - openrouter_model: ~google/gemini-flash-latest - fallback_to_local: false - -Recommended secret handling: + opencode_url: http://127.0.0.1:4096 + opencode_model: "" # empty = self-heal to OpenCode's own default, or "opencode/deepseek-v4-flash-free" -.. code-block:: bash +Setup steps: - export OPENROUTER_API_KEY=your_openrouter_key +1. Have a local OpenCode server running (WeightsLab starts one for you on + first use; see :doc:`agent`), or point ``OPENCODE_URL`` at your own. +2. Authenticate it once: ``opencode auth login`` (or the login modal from + Weights Studio's landing page) -- OpenRouter, Anthropic, a local Ollama + endpoint, anything OpenCode supports. +3. Start WeightsLab. +4. Open Weights Studio and query the agent directly, or type ``/init`` first + to pick a specific model. Weights Studio commands ^^^^^^^^^^^^^^^^^^^^^^^ @@ -865,11 +836,10 @@ Weights Studio commands When using Weights Studio, the agent bar supports these runtime commands: 1. ``/init`` - Opens the OpenRouter onboarding flow. - Users can enter an API key manually or use the OAuth flow, then select a model. + Connects to the OpenCode server and lets you pick a model. 2. ``/model`` - Opens the model browser and switches the active OpenRouter model without - requiring a full reinitialization. + Opens the model browser and switches the active OpenCode model without + reconnecting. 3. ``/reset`` Clears the current runtime connection state and returns the agent to the uninitialized status. @@ -877,13 +847,13 @@ When using Weights Studio, the agent bar supports these runtime commands: Notes ^^^^^ -- The default OpenRouter model is ``~google/gemini-flash-latest``. -- The model browser fetches the available models from OpenRouter using the - configured API key. +- The model browser fetches the available models from the OpenCode server's + own provider catalog. - Connection and model-change actions are recorded in the agent history as log-style entries. -- ``/reset`` clears the current runtime agent state. If your startup config is - local-only and you want that provider back immediately, restart the backend. +- ``/reset`` clears the current runtime agent state. If your startup config + points at a server that's still running, ``/init`` reconnects immediately; + otherwise restart the backend once the server is back. Testing diff --git a/docs/experiment_reports.rst b/docs/experiment_reports.rst index 85c25c10..83fdc472 100644 --- a/docs/experiment_reports.rst +++ b/docs/experiment_reports.rst @@ -30,6 +30,7 @@ they produce the same artifact: wl.ai_report_generation(signals=["train_loss"]) # specific signals wl.ai_report_generation(use_agent=False) # skip the LLM call wl.ai_report_generation(output_path="reports/run.html") # choose the file + wl.ai_report_generation(distributions=["train_loss"]) # + a histogram section It returns the path written. @@ -42,6 +43,7 @@ they produce the same artifact: report train_loss val_loss report --output /tmp/run_42.html report --no-agent + report --distributions train_loss,val_loss The reply gives the path, the number of signals included, and whether the written analysis made it in. @@ -62,6 +64,56 @@ they produce the same artifact: Generate an experiment report on train_loss and val_loss. + To add a value-distribution histogram for a specific column (see + `Distributions`_ below), including as a follow-up on a report you already + generated: + + .. code-block:: text + + Generate an experiment report and include a histogram of train_loss. + Add a distribution of val_loss to the report. + + This always goes through the SAME single backend action — the agent must + never break "generate a report" into several separate analysis questions + and hand-write its own summary; that would skip the plots/styling below + entirely. + +Updating a report vs. generating a new one +--------------------------------------------- + +Every path above always writes a *fresh*, separately timestamped file by +default. When you ask through chat, though, wording matters: + +.. code-block:: text + + Generate a report. # always a NEW file + Update the report with a histogram of val_loss. # overwrites the last one + Add a histogram of val_loss to the report. # overwrites the last one + Also include the confidence signal in it. # overwrites the last one + +"Generate"/"create"/"how is this going" (no reference to one already made) +always produces a new file. Wording that refers to an *existing* report +("update", "add X to **the** report", "also include Y in **it**") overwrites +the most recently generated report for this experiment instead — the agent's +reply says which happened ("updated"/"generated ... experiment report") and +still names the file. Asking to "update" when nothing has been generated yet +isn't an error: it just creates the first one, same as a plain "generate" +would. + +A follow-up "add" is intentionally cumulative — asking to add a histogram of +``val_loss`` after already having one for ``train_loss`` keeps both in the +updated file, not just the newest one, as long as the request stays in the +same conversation. There's no server-side memory of a report's contents +behind this — the agent reasons about what to keep from what you (and it) +said earlier in the chat, so it works within one back-and-forth but doesn't +persist across separate sessions. + +Python/CLI callers that want the same overwrite-in-place behavior can pass +the previous run's own path back in as ``output_path`` +(:func:`ai_report_generation`) / ``--output`` (the CLI's ``report`` command) +— they already have direct control over the file, so there's no separate +"update" flag for them. + - **Weights Studio button**: the bar-chart icon immediately left of the notebook button in the connected app's header. Left-click generates a report (checking agent availability first — see below); right-click opens @@ -107,6 +159,14 @@ What's in the report history swung the most (``max - min``). Both are ranked *inside DuckDB* (``LoggerQueue.top_k_samples_by_reduce``) and only the top few ever leave the database — see `Why per-sample data doesn't blow up the report`_. +- **Distributions** *(optional — only when asked for)* — a value-distribution + histogram plus n/mean/std/range for each column named via ``distributions`` + (see `Generating a report`_ above). Unlike a Signals card, this reads the + *current* per-sample dataframe, not the aggregated training curve — so it + answers "how spread out is train_loss across samples right now", not "how + did it move over training". A name that doesn't resolve to a column, or + resolves to one with no numeric values, still gets a card saying so rather + than being silently dropped. Not present at all when nobody asked for one. - **Loss-Shape Classification** — if per-sample loss-shape classification has already been computed for this experiment (:doc:`logger`'s ``wl.write_loss_shapes`` / the background auto-tagger), a count of samples @@ -116,6 +176,18 @@ What's in the report - **Dataset** — total sample count, discard count/rate, per-split counts (the ``origin`` column), and a breakdown of any ``tag:*`` columns present. +Light / dark mode +-------------------- + +The report follows the browser's ``prefers-color-scheme`` automatically, and +also has its own toggle button (top-right of the banner) for overriding that +— the choice is remembered (via ``localStorage``, scoped to that report file) +so reopening the same report keeps the theme you picked. Signal/distribution +plots are rendered once by matplotlib on a fixed white canvas, so they sit in +a small always-light thumbnail card in either theme — this keeps their own +text and gridlines legible instead of rendering (and shipping) two copies of +every plot. + Why per-sample data doesn't blow up the report -------------------------------------------------- diff --git a/docs/usage/parameters.rst b/docs/usage/parameters.rst index c7fb22f0..254fa0f9 100644 --- a/docs/usage/parameters.rst +++ b/docs/usage/parameters.rst @@ -463,6 +463,9 @@ Audit logging LLM / agent integration (optional) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The agent is backed entirely by a local OpenCode server (see :doc:`../agent`) +— there is no API key here; the credential lives in OpenCode's own config. + .. list-table:: :header-rows: 1 :widths: 35 15 50 @@ -470,27 +473,15 @@ LLM / agent integration (optional) * - Variable - Default - Description - * - ``OPENROUTER_API_KEY`` - - *(unset)* - - API key for OpenRouter. Required only when using WeightsLab's - LLM-assisted analysis features. - * - ``OPENROUTER_MODEL`` - - *(unset)* - - Model identifier forwarded to OpenRouter (e.g. - ``"openai/gpt-4o"``). - * - ``OPENROUTER_REQUEST_TIMEOUT`` + * - ``OPENCODE_URL`` + - ``http://127.0.0.1:4096`` + - URL of the local OpenCode server. Shared with the frontend, so set it + once and both sides talk to the one server. + * - ``OPENCODE_MODEL`` - *(unset)* - - Per-request timeout in seconds for OpenRouter calls. - * - ``OPENROUTER_MAX_TOKENS`` - - ``2048`` - - Maximum completion length requested from OpenRouter. OpenRouter - pre-authorizes ``max_tokens × completion_price`` against the key's - remaining budget *before* generating, so leaving this uncapped makes - the model request its full output window and can fail with a ``402`` - ("requires more credits, or fewer max_tokens") on a credit- or - weekly-limited key — even though the model is otherwise usable. The - default is ample for intent planning; raise it only if you see - truncated responses. + - Default model, as an OpenCode ``providerID/modelID`` string (e.g. + ``"openrouter/anthropic/claude-opus-4.6"``). Unset uses OpenCode's own + configured default. Telemetry ~~~~~~~~~~ diff --git a/docs/user_commands.rst b/docs/user_commands.rst index 7dd9dd29..0eb7a009 100644 --- a/docs/user_commands.rst +++ b/docs/user_commands.rst @@ -532,9 +532,9 @@ sub-verb reference, examples, and setup: see :doc:`agent`. .. code-block:: text agent status - agent init --api-key sk-or-... --model openai/gpt-4o-mini --timeout 20 + agent init --model openrouter/anthropic/claude-opus-4.6 agent models - agent model google/gemini-flash-latest + agent model openrouter/openai/gpt-5 ask tag train samples with loss > 1.2 as goldset Experiment report diff --git a/docs/weights_studio.rst b/docs/weights_studio.rst index b90e5fd1..3f66bbab 100644 --- a/docs/weights_studio.rst +++ b/docs/weights_studio.rst @@ -195,45 +195,41 @@ Agent Usage in Weights Studio ------------------------------ Weights Studio includes an agent bar and an expandable agent history window. -The agent can run with either: +The agent is backed entirely by a local OpenCode server (`opencode.ai +`_) — see :doc:`agent` for the full setup story and the +distinction between this chat-bar agent and the separate ``/loop``/landing-page +OpenCode agent. -- a local Ollama provider configured on the backend -- a cloud OpenRouter provider configured at startup or initialized from the UI - -Local Ollama workflow -~~~~~~~~~~~~~~~~~~~~~ +OpenCode workflow +~~~~~~~~~~~~~~~~~~ -If the backend is configured with ``provider: ollama`` and the Ollama server is -running, the agent is available immediately after backend startup. +WeightsLab starts (or reuses) a local ``opencode serve`` process for you, so +there's normally nothing to configure before the agent is available. If the +backend isn't connected to it yet, Weights Studio shows the agent as +unconfigured and the input placeholder instructs the user to type ``/init``. -Typical local setup: +Typical setup: -1. Start Ollama. +1. Authenticate OpenCode once, if you haven't already: ``opencode auth + login`` (or the landing page's login modal) — OpenRouter, Anthropic, a + local Ollama endpoint, anything OpenCode supports. 2. Start WeightsLab (``wl.serve(serving_grpc=True)``). 3. Start Weights Studio (``weightslab start``). -4. Ask questions in the agent bar. - -Cloud OpenRouter workflow -~~~~~~~~~~~~~~~~~~~~~~~~~ - -If the backend is not initialized with a cloud key yet, Weights Studio shows -the agent as unconfigured and the input placeholder instructs the user to type -``/init``. +4. Ask questions in the agent bar, or type ``/init`` first to pick a specific + model. ``/init`` flow: 1. Type ``/init`` in the agent input. -2. Choose manual API key entry or the OpenRouter OAuth flow. +2. Weights Studio connects to the OpenCode server. 3. Select a model from the available model list. 4. Confirm to initialize the runtime connection. -The default cloud model is ``~google/gemini-flash-latest``. - Available agent commands ~~~~~~~~~~~~~~~~~~~~~~~~ -- ``/init`` — initialize OpenRouter from the UI -- ``/model`` — open the model chooser to switch the active OpenRouter model +- ``/init`` — connect to the OpenCode server from the UI +- ``/model`` — open the model chooser to switch the active OpenCode model - ``/reset`` — clear the current agent runtime connection and status History behavior diff --git a/pyproject.toml b/pyproject.toml index 6f7936a5..ea344329 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,10 +81,12 @@ dependencies = [ # Environment variable loading (used by agent service) "python-dotenv>=1,<2", - # Agent service runtime deps (imported by default module graph) + # Agent service runtime deps (imported by default module graph). Only + # langchain-core is needed -- it provides the Runnable/ChatPromptTemplate + # abstraction OpenCodeChat.as_runnable() plugs into. No OpenCode-specific + # SDK package exists; its client is hand-rolled in opencode_chat.py via + # stdlib urllib/threading. "langchain-core>=0.3,<2", - "langchain-ollama>=0.2,<2", - "langchain-openai>=0.2,<2", # Jupyter "ipykernel>=6.29,<7", @@ -172,6 +174,15 @@ weightslab = [ "ui/static/**/*", # Logo used to brand generated experiment reports (weightslab/reporting.py). "assets/**/*", + # weightslab-integration grounding for the landing chat's preset prompts + # and for agents dropped directly into a workspace (see opencode_process's + # WORKSPACE_SEED_FILES) -- lives inside the package, not just at the + # repo root, so a `pip install weightslab` ships it too. + "AGENTS.md", + # Seeded next to AGENTS.md in every experiment workspace. OpenCode reads a + # project config from the directory it is started in, which is that same + # workspace; this one points `instructions` at AGENTS.md. + "opencode.json", ] [tool.setuptools.exclude-package-data] diff --git a/tests/backend/test_cli_additional_unit.py b/tests/backend/test_cli_additional_unit.py index 0cbe947c..aa60576e 100644 --- a/tests/backend/test_cli_additional_unit.py +++ b/tests/backend/test_cli_additional_unit.py @@ -71,29 +71,27 @@ def test_add_tag_uses_sample_id_helper_for_multiple_samples(self): self.assertTrue(result["ok"]) tag_mock.assert_called_once_with(sample_ids=["sample_001", "sample_002", "sample_003"], tag="goldset", mode="add") - def test_agent_init_accepts_api_key_model_and_timeout(self): + def test_agent_init_accepts_a_model(self): agent = MagicMock() - agent.openrouter_request_timeout = 15.0 - agent.openrouter_model = "initial-model" - agent.initialize_with_cloud_key.return_value = (True, "Agent initialized successfully. Ready to help you.") + agent.opencode_model = "initial-model" + agent.initialize_with_cloud_key.return_value = (True, "Agent initialized successfully via OpenCode. Ready to help you.") cli_backend.set_cli_agent(agent) - result = _handle_command("agent init --api-key test-key --model openai/gpt-4o-mini --timeout 22") + result = _handle_command("agent init --model openrouter/openai/gpt-5") self.assertTrue(result["ok"]) - agent.initialize_with_cloud_key.assert_called_once_with("test-key", "openrouter", "openai/gpt-4o-mini") - self.assertEqual(agent.openrouter_request_timeout, 22.0) + agent.initialize_with_cloud_key.assert_called_once_with("", "opencode", "openrouter/openai/gpt-5") def test_agent_model_command_switches_model(self): agent = MagicMock() - agent.openrouter_model = "google/gemini-2.5-flash" + agent.opencode_model = "openrouter/anthropic/claude-opus-4.6" agent.change_model.return_value = (True, "Model switched") cli_backend.set_cli_agent(agent) - result = _handle_command("agent model google/gemini-2.5-flash") + result = _handle_command("agent model openrouter/anthropic/claude-opus-4.6") self.assertTrue(result["ok"]) - agent.change_model.assert_called_once_with("google/gemini-2.5-flash") + agent.change_model.assert_called_once_with("openrouter/anthropic/claude-opus-4.6") def test_agent_query_uses_data_service_when_available(self): mock_response = MagicMock( diff --git a/tests/gRPC/test_grpc_user_actions.py b/tests/gRPC/test_grpc_user_actions.py index 530d8e7d..6f2fcafe 100644 --- a/tests/gRPC/test_grpc_user_actions.py +++ b/tests/gRPC/test_grpc_user_actions.py @@ -290,7 +290,7 @@ def _make_real_data_service(self): # vectorized path and doesn't need it, but GetDataSamples does). ds._data_executor = ThreadPoolExecutor(max_workers=2) ds._agent = MagicMock() - ds._agent.is_ollama_available.return_value = True + ds._agent.is_available.return_value = True ds.audit_logger = MagicMock() return ds, df_manager diff --git a/tests/test_opencode_process.py b/tests/test_opencode_process.py new file mode 100644 index 00000000..1098cd59 --- /dev/null +++ b/tests/test_opencode_process.py @@ -0,0 +1,187 @@ +"""Tests for weightslab/opencode_process.py -- the cross-process discovery/ +spawn handshake that lets the backend SDK agent (agent.py's +DataManipulationAgent) and the UI server's _OpencodeSession (server.py, +backing the browser landing-page chat and /loop jobs) converge on ONE +OpenCode server for a given workspace directory, regardless of which one +needs it first. + +CI has no real `opencode` binary, so the real-spawn tests point +resolve_opencode_argv at a tiny stand-in HTTP server (started via +`python -c`, same pattern tests/ui/test_server_agent.py already uses) that +answers /global/health the way the real OpenCode server does -- everything +else here is exercised via that real subprocess + real lock-file I/O in a +temp directory, not mocked away. +""" + +import os +import sys +import tempfile +import unittest +from unittest.mock import patch + +from weightslab import opencode_process + + +_FAKE_OPENCODE_SRC = r""" +import sys, json +from http.server import BaseHTTPRequestHandler, HTTPServer + +def _port(): + for i, a in enumerate(sys.argv): + if a == "--port": + return int(sys.argv[i + 1]) + return 4096 + +class H(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + def do_GET(self): + if self.path == "/global/health": + body = json.dumps({"version": "0.0.0-fake"}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: + self.send_response(404) + self.end_headers() + +HTTPServer(("127.0.0.1", _port()), H).serve_forever() +""" + +_FAKE_ARGV = [sys.executable, "-c", _FAKE_OPENCODE_SRC] + + +class TestLockFileRoundtrip(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + + def test_absent_lock_reads_as_none(self): + self.assertIsNone(opencode_process.read_lock(self.tmp)) + + def test_write_then_read_roundtrips(self): + opencode_process.write_lock(self.tmp, "http://127.0.0.1:9999", pid=1234) + lock = opencode_process.read_lock(self.tmp) + self.assertEqual(lock["url"], "http://127.0.0.1:9999") + self.assertEqual(lock["pid"], 1234) + + def test_malformed_lock_file_reads_as_none_not_an_exception(self): + with open(opencode_process.lock_path(self.tmp), "w") as f: + f.write("{not json") + self.assertIsNone(opencode_process.read_lock(self.tmp)) + + def test_lock_file_with_no_url_reads_as_none(self): + with open(opencode_process.lock_path(self.tmp), "w") as f: + f.write('{"pid": 1}') + self.assertIsNone(opencode_process.read_lock(self.tmp)) + + +class TestResolveOrSpawnUnit(unittest.TestCase): + """The precedence chain (env > lockfile > spawn), mocked so it runs in + milliseconds -- the real-subprocess path is covered separately below.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp() + os.environ.pop("OPENCODE_URL", None) + + def test_healthy_env_var_wins_outright(self): + os.environ["OPENCODE_URL"] = "http://127.0.0.1:1111" + try: + with patch.object(opencode_process, "opencode_healthy", return_value=True): + result = opencode_process.resolve_or_spawn_opencode(self.tmp) + finally: + del os.environ["OPENCODE_URL"] + self.assertEqual(result, {"ok": True, "url": "http://127.0.0.1:1111", "source": "env"}) + + def test_unhealthy_env_var_is_ignored_in_favor_of_the_lockfile(self): + os.environ["OPENCODE_URL"] = "http://127.0.0.1:1111" + opencode_process.write_lock(self.tmp, "http://127.0.0.1:2222") + try: + with patch.object(opencode_process, "opencode_healthy", + side_effect=lambda url, timeout=1.5: url == "http://127.0.0.1:2222"): + result = opencode_process.resolve_or_spawn_opencode(self.tmp) + finally: + del os.environ["OPENCODE_URL"] + self.assertEqual(result, {"ok": True, "url": "http://127.0.0.1:2222", "source": "lockfile"}) + + def test_healthy_lockfile_is_adopted_without_spawning(self): + opencode_process.write_lock(self.tmp, "http://127.0.0.1:3333") + with patch.object(opencode_process, "opencode_healthy", return_value=True), \ + patch("subprocess.Popen") as popen: + result = opencode_process.resolve_or_spawn_opencode(self.tmp) + popen.assert_not_called() + self.assertEqual(result, {"ok": True, "url": "http://127.0.0.1:3333", "source": "lockfile"}) + + def test_stale_lockfile_is_ignored_and_a_fresh_server_is_spawned(self): + # The process the file names is gone -- health check on ITS url fails, + # but the newly-spawned one's succeeds. + opencode_process.write_lock(self.tmp, "http://127.0.0.1:4444") + with patch.object(opencode_process, "opencode_healthy", + side_effect=lambda url, timeout=1.5: url != "http://127.0.0.1:4444"), \ + patch.object(opencode_process, "resolve_opencode_argv", return_value=_FAKE_ARGV), \ + patch.object(opencode_process, "free_port", return_value=15000): + result = opencode_process.resolve_or_spawn_opencode(self.tmp) + self.assertTrue(result["ok"], result) + self.assertEqual(result["source"], "spawned") + # The stale entry was overwritten with the freshly-spawned server. + self.assertEqual(opencode_process.read_lock(self.tmp)["url"], result["url"]) + + def test_no_opencode_or_npx_available_reports_a_clear_error(self): + with patch.object(opencode_process, "resolve_opencode_argv", return_value=None): + result = opencode_process.resolve_or_spawn_opencode(self.tmp) + self.assertFalse(result["ok"]) + self.assertIn("opencode", result["error"]) + + +class TestResolveOrSpawnRealSubprocess(unittest.TestCase): + """Exercises the actual subprocess.Popen + health-poll + lock-file-write + path against a real (fake-OpenCode) child process, not a mock.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp() + os.environ.pop("OPENCODE_URL", None) + self._timeout_patch = patch.object(opencode_process, "OPENCODE_START_TIMEOUT", 5.0) + self._timeout_patch.start() + + def tearDown(self): + self._timeout_patch.stop() + + def test_first_caller_spawns_and_writes_the_lock_file(self): + with patch.object(opencode_process, "resolve_opencode_argv", return_value=_FAKE_ARGV): + result = opencode_process.resolve_or_spawn_opencode(self.tmp, origin="http://localhost:5173") + self.assertTrue(result["ok"], result) + self.assertEqual(result["source"], "spawned") + lock = opencode_process.read_lock(self.tmp) + self.assertEqual(lock["url"], result["url"]) + self.assertTrue(opencode_process.opencode_healthy(result["url"])) + + def test_second_caller_for_the_same_workspace_adopts_instead_of_spawning(self): + """The actual cross-process handshake this feature exists for: call + it once (simulating whichever side -- backend agent or UI server -- + happens to start first), then again for the SAME workspace_dir + (simulating the other side starting later) and confirm the second + call adopts the first call's server rather than spawning a second + one.""" + with patch.object(opencode_process, "resolve_opencode_argv", return_value=_FAKE_ARGV): + first = opencode_process.resolve_or_spawn_opencode(self.tmp) + self.assertEqual(first["source"], "spawned") + + with patch("subprocess.Popen") as popen: + second = opencode_process.resolve_or_spawn_opencode(self.tmp) + popen.assert_not_called() + + self.assertEqual(second["source"], "lockfile") + self.assertEqual(second["url"], first["url"]) + + def test_never_becoming_healthy_times_out_and_reports_an_error(self): + hanging_argv = [sys.executable, "-c", "import time; time.sleep(60)"] + with patch.object(opencode_process, "resolve_opencode_argv", return_value=hanging_argv): + result = opencode_process.resolve_or_spawn_opencode(self.tmp) + self.assertFalse(result["ok"]) + self.assertIn("did not come up", result["error"]) + self.assertIsNone(opencode_process.read_lock(self.tmp)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_opencode_shared_server_integration.py b/tests/test_opencode_shared_server_integration.py new file mode 100644 index 00000000..d62ee742 --- /dev/null +++ b/tests/test_opencode_shared_server_integration.py @@ -0,0 +1,131 @@ +"""End-to-end proof that the backend SDK agent (OpenCodeChat, reached via the +gRPC query bar) and the UI server's _OpencodeSession (backing the browser +landing-page chat and /loop jobs) converge on ONE OpenCode server for a +shared workspace directory -- regardless of which one needs a server first. + +Each side's own half of this handshake already has focused unit coverage +(tests/test_opencode_process.py for the shared resolve_or_spawn_opencode +precedence chain; tests/ui/test_server_agent.py and +tests/trainer/services/test_opencode_chat.py for each side's own call into +it). This file instead drives BOTH real classes together against one real +(fake-OpenCode) subprocess, proving the actual scenario end to end rather +than trusting that the separately-tested pieces compose correctly. +""" + +import sys +import tempfile +import unittest +from unittest.mock import patch + +from weightslab.trainer.services.agent.opencode_chat import OpenCodeChat +from weightslab.ui import server as ui_server + +_FAKE_OPENCODE_SRC = r""" +import sys, json +from http.server import BaseHTTPRequestHandler, HTTPServer + +def _port(): + for i, a in enumerate(sys.argv): + if a == "--port": + return int(sys.argv[i + 1]) + return 4096 + +class H(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + def do_GET(self): + if self.path == "/global/health": + body = json.dumps({"version": "0.0.0-fake"}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: + self.send_response(404) + self.end_headers() + +HTTPServer(("127.0.0.1", _port()), H).serve_forever() +""" + +_FAKE_ARGV = [sys.executable, "-c", _FAKE_OPENCODE_SRC] + +# The real opencode_healthy, forced to treat OpenCode's literal default +# address as dead regardless of the actual machine's state -- a real, +# unrelated `opencode serve` left running on the default port (easy to +# accumulate: nothing in this codebase kills one automatically once +# started, confirmed live more than once during development) would +# otherwise make _ensure_reachable's "already healthy, leave it alone" +# branch fire for real here, which is correct behaviour but defeats the +# point of THIS test -- it wants to force the "was dead, needed +# resolving" path deterministically. Every other address still gets a +# real health check. +import weightslab.opencode_process as _ocp # noqa: E402 + +_real_opencode_healthy = _ocp.opencode_healthy + + +def _healthy_except_bare_default(url: str, timeout: float = 1.5) -> bool: + if url.rstrip("/") == "http://127.0.0.1:4096": + return False + return _real_opencode_healthy(url, timeout=timeout) + + +class TestBackendAgentStartsFirst(unittest.TestCase): + """Order (a) from the user's own description: the backend SDK agent + needs a server before `weightslab start` ever calls /agent-server/start + for the same experiment directory.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp() + + def test_ui_server_adopts_the_backend_agents_server_instead_of_spawning(self): + with patch("weightslab.opencode_process.opencode_healthy", side_effect=_healthy_except_bare_default): + chat = OpenCodeChat("http://127.0.0.1:4096", workspace_dir=self.tmp, url_is_explicit=False) + with patch("weightslab.opencode_process.resolve_opencode_argv", return_value=_FAKE_ARGV): + chat._ensure_reachable() # the backend agent spawns first + backend_url = chat.base_url + self.assertNotEqual(backend_url, "http://127.0.0.1:4096", "the dead default should have been replaced") + + session = ui_server._OpencodeSession() + try: + with patch.object(ui_server, "_resolve_opencode_argv") as argv_mock, \ + patch.object(ui_server, "_opencode_healthy", side_effect=_healthy_except_bare_default): + result = session.ensure(self.tmp, "http://localhost:5173") + finally: + session.shutdown() + + argv_mock.assert_not_called() # no second server spawned + self.assertTrue(result["ok"], result) + self.assertEqual(result["url"], backend_url) + self.assertEqual(result.get("adopted"), "lockfile") + + +class TestUiServerStartsFirst(unittest.TestCase): + """Order (b): `weightslab start` (the UI server) needs a server first, + and the backend SDK agent's first query comes along afterward.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp() + + def test_backend_agent_adopts_the_ui_servers_server_instead_of_spawning(self): + with patch("weightslab.opencode_process.opencode_healthy", side_effect=_healthy_except_bare_default), \ + patch.object(ui_server, "_opencode_healthy", side_effect=_healthy_except_bare_default): + session = ui_server._OpencodeSession() + try: + with patch.object(ui_server, "_resolve_opencode_argv", return_value=_FAKE_ARGV): + started = session.ensure(self.tmp, "http://localhost:5173") + self.assertTrue(started["ok"], started) + + chat = OpenCodeChat("http://127.0.0.1:4096", workspace_dir=self.tmp, url_is_explicit=False) + with patch("weightslab.opencode_process.resolve_opencode_argv") as argv_mock: + chat._ensure_reachable() + finally: + session.shutdown() + + argv_mock.assert_not_called() # no second server spawned + self.assertEqual(chat.base_url, started["url"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_report_entrypoints.py b/tests/test_report_entrypoints.py index fc4a1c89..c0dd61b1 100644 --- a/tests/test_report_entrypoints.py +++ b/tests/test_report_entrypoints.py @@ -146,6 +146,19 @@ def test_no_logger_raises(self): wl_src.ai_report_generation() self.assertIn("no experiment logger", str(ctx.exception)) + def test_distributions_param_adds_the_section(self): + import pandas as pd + + df = pd.DataFrame({"sample_id": [1, 2, 3], "train_loss": [0.5, 0.9, 0.1]}).set_index("sample_id") + manager = MagicMock() + manager.get_combined_df.return_value = df + + with mock.patch.object(wl_src, "get_dataframe", return_value=manager): + path = wl_src.ai_report_generation(distributions=["train_loss"], use_agent=False) + + html = open(path, encoding="utf-8").read() + self.assertIn("Distributions", html) + def test_exported_on_the_package(self): import weightslab as wl @@ -187,6 +200,20 @@ def test_no_agent_flag_skips_the_analysis(self): self.assertIn("no written analysis", result["message"]) self.agent.generate_report_narrative.assert_not_called() + def test_distributions_flag_adds_the_section(self): + import pandas as pd + + df = pd.DataFrame({"sample_id": [1, 2, 3], "train_loss": [0.5, 0.9, 0.1]}).set_index("sample_id") + manager = MagicMock() + manager.get_combined_df.return_value = df + + with mock.patch.object(wl_src, "get_dataframe", return_value=manager): + result = _handle_command("report --distributions train_loss") + + self.assertTrue(result["ok"]) + html = open(result["path"], encoding="utf-8").read() + self.assertIn("Distributions", html) + def test_output_flag_selects_the_file(self): out = os.path.join(self.tmp.name, "cli_report.html") result = _handle_command(f"report --output {out}") @@ -219,17 +246,20 @@ class TestAgentActionSharesTheSamePath(unittest.TestCase): the Python and CLI entry points — it is the same ``generate_report`` call, only with the live service's own logger/dataframe/agent.""" - def _run_action(self, root_log_dir, logger_q, narrative_fn): + def _run_action(self, root_log_dir, logger_q, narrative_fn, df_manager=None, + distributions=None, update_existing=False): from weightslab.trainer.services.data_service import DataService service = types.SimpleNamespace( _resolve_checkpoint_manager=lambda: None, _root_log_dir=root_log_dir, - _df_manager=None, + _df_manager=df_manager, _agent=types.SimpleNamespace(generate_report_narrative=narrative_fn), ) with mock.patch("weightslab.backend.ledgers.get_logger", return_value=logger_q): - return DataService._agent_generate_experiment_report(service) + return DataService._agent_generate_experiment_report( + service, distributions=distributions, update_existing=update_existing, + ) def test_action_writes_the_report_and_names_it_in_the_reply(self): logger_q = _lg_with("train_loss") @@ -257,6 +287,43 @@ def test_action_reports_failures_as_text(self): message = self._run_action(tmp, None, lambda _s: "unused") self.assertIn("no experiment logger available", message) + def test_update_existing_overwrites_the_prior_report_and_says_updated(self): + logger_q = _lg_with("train_loss") + with tempfile.TemporaryDirectory() as tmp: + first_message = self._run_action(tmp, logger_q, lambda _s: "Healthy run.") + self.assertTrue(first_message.startswith("Action: generated experiment report")) + first_path = first_message.split(" at ", 1)[1] + + second_message = self._run_action( + tmp, logger_q, lambda _s: "Still healthy.", update_existing=True, + ) + self.assertTrue(second_message.startswith("Action: updated experiment report")) + second_path = second_message.split(" at ", 1)[1] + self.assertEqual(first_path, second_path) + self.assertIn("Still healthy.", open(second_path, encoding="utf-8").read()) + + def test_update_existing_without_a_prior_report_still_generates_one(self): + logger_q = _lg_with("train_loss") + with tempfile.TemporaryDirectory() as tmp: + message = self._run_action(tmp, logger_q, lambda _s: "Healthy run.", update_existing=True) + self.assertTrue(message.startswith("Action: generated experiment report")) + + def test_distributions_param_is_forwarded_to_the_report(self): + import pandas as pd + + logger_q = _lg_with("train_loss") + df = pd.DataFrame({"sample_id": [1, 2, 3], "train_loss": [0.5, 0.9, 0.1]}).set_index("sample_id") + df_manager = MagicMock() + df_manager.get_combined_df.return_value = df + + with tempfile.TemporaryDirectory() as tmp: + message = self._run_action( + tmp, logger_q, lambda _s: "Healthy run.", + df_manager=df_manager, distributions=["train_loss"], + ) + path = message.split(" at ", 1)[1] + self.assertIn("Distributions", open(path, encoding="utf-8").read()) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_reporting.py b/tests/test_reporting.py index be1f2c8f..cbcc458d 100644 --- a/tests/test_reporting.py +++ b/tests/test_reporting.py @@ -11,6 +11,8 @@ - summarize_context_for_llm (plot-free, bounded LLM payload) - generate_report (the shared collect -> narrate -> render path behind the Studio button, wl.ai_report_generation and the CLI `report` command) +- light/dark mode (theme toggle + CSS variables always present in the output) +- distributions (opt-in histogram section: column resolution, stats, HTML) """ import base64 @@ -264,6 +266,128 @@ def test_no_narrative_shows_fallback_text(self): html = open(path, encoding="utf-8").read() self.assertIn("No narrative was generated", html) + def test_report_supports_light_and_dark_mode(self): + lg = _lg() + with tempfile.TemporaryDirectory() as tmp: + ctx = reporting.collect_report_context(tmp, lg, None) + path = reporting.render_report(ctx, reporting.default_report_path(tmp)) + html = open(path, encoding="utf-8").read() + # A manual toggle button, persisted via localStorage... + self.assertIn('id="wl-theme-toggle"', html) + self.assertIn("wl-report-theme", html) + # ...and a system-preference fallback for readers who never touch it. + self.assertIn("prefers-color-scheme: dark", html) + self.assertIn("--wl-bg", html) + + +# --------------------------------------------------------------------------- +# Distributions (opt-in histogram section) +# --------------------------------------------------------------------------- + +class TestResolveDistributionColumn(unittest.TestCase): + + def test_exact_match(self): + df = pd.DataFrame({"train_loss": [1.0]}) + self.assertEqual(reporting._resolve_distribution_column(df, "train_loss"), "train_loss") + + def test_nested_suffix_match(self): + df = pd.DataFrame({"signals//train_loss": [1.0]}) + self.assertEqual( + reporting._resolve_distribution_column(df, "train_loss"), "signals//train_loss", + ) + + def test_substring_fallback_match(self): + df = pd.DataFrame({"my_train_loss_v2": [1.0]}) + self.assertEqual( + reporting._resolve_distribution_column(df, "train_loss"), "my_train_loss_v2", + ) + + def test_no_match_returns_none(self): + df = pd.DataFrame({"accuracy": [1.0]}) + self.assertIsNone(reporting._resolve_distribution_column(df, "train_loss")) + + def test_none_df_returns_none(self): + self.assertIsNone(reporting._resolve_distribution_column(None, "train_loss")) + + +class TestComputeDistributionEntries(unittest.TestCase): + + def test_empty_request_returns_empty_list(self): + df = pd.DataFrame({"train_loss": [1.0, 2.0]}) + self.assertEqual(reporting.compute_distribution_entries(df, None), []) + self.assertEqual(reporting.compute_distribution_entries(df, []), []) + + def test_none_df_returns_empty_list(self): + self.assertEqual(reporting.compute_distribution_entries(None, ["train_loss"]), []) + + def test_unresolved_column_is_flagged_not_dropped(self): + df = pd.DataFrame({"accuracy": [1.0, 2.0]}) + entries = reporting.compute_distribution_entries(df, ["train_loss"]) + self.assertEqual(len(entries), 1) + self.assertFalse(entries[0]["resolved"]) + self.assertEqual(entries[0]["name"], "train_loss") + + def test_resolved_numeric_column_has_stats_and_no_plot_without_matplotlib(self): + df = pd.DataFrame({"train_loss": [1.0, 2.0, 3.0, 4.0]}) + entries = reporting.compute_distribution_entries(df, ["train_loss"], plt=None) + self.assertEqual(len(entries), 1) + entry = entries[0] + self.assertTrue(entry["resolved"]) + self.assertEqual(entry["n"], 4) + self.assertEqual(entry["mean"], 2.5) + self.assertIsNone(entry["plot_b64"]) + + def test_non_numeric_column_reports_zero_n(self): + df = pd.DataFrame({"origin": ["train", "val"]}) + entries = reporting.compute_distribution_entries(df, ["origin"]) + self.assertTrue(entries[0]["resolved"]) + self.assertEqual(entries[0]["n"], 0) + + +class TestDistributionsInReport(unittest.TestCase): + + def test_omitted_when_not_requested(self): + lg = _lg() + df = pd.DataFrame({"sample_id": [1, 2], "train_loss": [0.5, 0.9]}).set_index("sample_id") + with tempfile.TemporaryDirectory() as tmp: + ctx = reporting.collect_report_context(tmp, lg, df) + self.assertEqual(ctx["distributions"], []) + path = reporting.render_report(ctx, reporting.default_report_path(tmp)) + html = open(path, encoding="utf-8").read() + self.assertNotIn("Distributions", html) + + def test_included_when_requested(self): + lg = _lg() + df = pd.DataFrame({"sample_id": [1, 2, 3], "train_loss": [0.5, 0.9, 0.1]}).set_index("sample_id") + with tempfile.TemporaryDirectory() as tmp: + ctx = reporting.collect_report_context(tmp, lg, df, distributions=["train_loss"]) + self.assertEqual(len(ctx["distributions"]), 1) + path = reporting.render_report(ctx, reporting.default_report_path(tmp)) + html = open(path, encoding="utf-8").read() + self.assertIn("Distributions", html) + self.assertIn("train_loss", html) + + def test_generate_report_forwards_distributions(self): + lg = _lg() + _seed_signal(lg, "train_loss", [1.0, 0.8, 0.6]) + df = pd.DataFrame({"sample_id": [1, 2], "train_loss": [0.5, 0.9]}).set_index("sample_id") + with tempfile.TemporaryDirectory() as tmp: + result = reporting.generate_report(tmp, lg, df, distributions=["train_loss"]) + html = open(result["path"], encoding="utf-8").read() + self.assertIn("Distributions", html) + + def test_summarize_context_for_llm_drops_distribution_plots(self): + context = { + "signals": [], + "distributions": [{"name": "train_loss", "n": 3, "mean": 0.5, "plot_b64": "AAAA" * 500}], + "loss_shape_tags": [], + "dataframe": {}, + } + payload = reporting.summarize_context_for_llm(context) + self.assertNotIn("plot_b64", payload) + self.assertNotIn("AAAA", payload) + self.assertIn("train_loss", payload) + # --------------------------------------------------------------------------- # summarize_context_for_llm / generate_report @@ -366,5 +490,98 @@ def test_unreadable_output_path_raises_rather_than_reporting_success(self): reporting.generate_report(tmp, lg, None, output_path=clash) +# --------------------------------------------------------------------------- +# list_reports / latest_report_path +# --------------------------------------------------------------------------- + +class TestListReports(unittest.TestCase): + + def test_no_reports_dir_returns_empty(self): + with tempfile.TemporaryDirectory() as tmp: + self.assertEqual(reporting.list_reports(tmp), []) + self.assertIsNone(reporting.latest_report_path(tmp)) + + def test_newest_first_by_mtime(self): + with tempfile.TemporaryDirectory() as tmp: + reports_dir = os.path.join(tmp, "reports") + os.makedirs(reports_dir) + older = os.path.join(reports_dir, "experiment_report_20260101_000000.html") + newer = os.path.join(reports_dir, "experiment_report_20260102_000000.html") + open(older, "w").close() + open(newer, "w").close() + os.utime(older, (1_000_000, 1_000_000)) + os.utime(newer, (2_000_000, 2_000_000)) + + result = reporting.list_reports(tmp) + self.assertEqual([p.name for p in result], [os.path.basename(newer), os.path.basename(older)]) + self.assertEqual(reporting.latest_report_path(tmp).name, os.path.basename(newer)) + + def test_non_html_files_are_ignored(self): + with tempfile.TemporaryDirectory() as tmp: + reports_dir = os.path.join(tmp, "reports") + os.makedirs(reports_dir) + open(os.path.join(reports_dir, "notes.txt"), "w").close() + self.assertEqual(reporting.list_reports(tmp), []) + + +# --------------------------------------------------------------------------- +# generate_report(update_existing=...) +# --------------------------------------------------------------------------- + +class TestGenerateReportUpdateExisting(unittest.TestCase): + + def _seeded_logger(self): + lg = _lg() + _seed_signal(lg, "train_loss", [1.0, 0.8, 0.6, 0.4]) + return lg + + def test_no_prior_report_falls_back_to_creating_one(self): + lg = self._seeded_logger() + with tempfile.TemporaryDirectory() as tmp: + result = reporting.generate_report(tmp, lg, None, update_existing=True) + self.assertFalse(result["updated_existing"]) + self.assertTrue(os.path.isfile(result["path"])) + + def test_overwrites_the_latest_report_in_place(self): + lg = self._seeded_logger() + df = pd.DataFrame({"sample_id": [1, 2], "train_loss": [0.5, 0.9]}).set_index("sample_id") + with tempfile.TemporaryDirectory() as tmp: + existing = os.path.join(tmp, "reports", "experiment_report_20260101_000000.html") + reporting.generate_report(tmp, lg, None, output_path=existing) + + result = reporting.generate_report(tmp, lg, df, update_existing=True, + distributions=["train_loss"]) + + self.assertTrue(result["updated_existing"]) + self.assertEqual(os.path.abspath(result["path"]), os.path.abspath(existing)) + self.assertEqual(len(reporting.list_reports(tmp)), 1) + self.assertIn("Distributions", open(existing, encoding="utf-8").read()) + + def test_default_call_always_creates_a_separate_file(self): + lg = self._seeded_logger() + with tempfile.TemporaryDirectory() as tmp: + path_a = os.path.join(tmp, "reports", "a.html") + path_b = os.path.join(tmp, "reports", "b.html") + with mock.patch.object(reporting, "default_report_path", side_effect=[path_a, path_b]): + first = reporting.generate_report(tmp, lg, None) + second = reporting.generate_report(tmp, lg, None) + + self.assertFalse(first["updated_existing"]) + self.assertFalse(second["updated_existing"]) + self.assertNotEqual(first["path"], second["path"]) + self.assertEqual(len(reporting.list_reports(tmp)), 2) + + def test_explicit_output_path_overrides_update_existing(self): + lg = self._seeded_logger() + with tempfile.TemporaryDirectory() as tmp: + reporting.generate_report(tmp, lg, None) # an existing report to (not) update + chosen = os.path.join(tmp, "explicit.html") + + result = reporting.generate_report(tmp, lg, None, update_existing=True, output_path=chosen) + + self.assertFalse(result["updated_existing"]) + self.assertEqual(os.path.abspath(result["path"]), os.path.abspath(chosen)) + + if __name__ == "__main__": unittest.main() diff --git a/tests/trainer/services/test_agent_live_prompt_evaluation.py b/tests/trainer/services/test_agent_live_prompt_evaluation.py index ceaedeaa..1139289d 100644 --- a/tests/trainer/services/test_agent_live_prompt_evaluation.py +++ b/tests/trainer/services/test_agent_live_prompt_evaluation.py @@ -2,30 +2,25 @@ Live-LLM evaluation of the Data Manipulation Agent against a batch of realistic user prompts. -This suite is OPT-IN: every test calls a REAL LLM through OpenRouter -(consuming API credits and real wall-clock time), so it is skipped entirely -unless an OpenRouter API key can be resolved. The key/model are resolved, in -priority order, from: - - 1. The dedicated ``UTEST_AGENT_PROMPT_EVALUATION`` / - ``UTEST_AGENT_PROMPT_EVALUATION_MODEL`` env vars (explicit opt-in). - 2. The standard ``OPENROUTER_API_KEY`` / ``OPENROUTER_MODEL`` env vars — - including any loaded from a repo ``.env`` file — so the same credentials - the running agent uses also drive this suite with no extra setup. - -So any of these work from the command line: - - # reuse your existing OpenRouter config (.env or exported env var) +This suite is OPT-IN: every test calls a REAL LLM through a local OpenCode +server (opencode.ai) (consuming real wall-clock time), so it is skipped +entirely unless explicitly turned on via the ``UTEST_AGENT_PROMPT_EVALUATION`` +env var (any non-empty value). Unlike the old OpenRouter-backed version of +this suite, there is no API key to resolve here -- OpenCode's credential +lives in its own config (``opencode auth login``), not in an env var -- so +opting in just means "run me", and ``OPENCODE_URL``/``OPENCODE_MODEL`` (the +SAME vars the agent itself reads) pick which server/model to run against. + +So this works from the command line, with a local OpenCode server already +running and authenticated: + + set UTEST_AGENT_PROMPT_EVALUATION=1 pytest weightslab/tests/trainer/services/test_agent_live_prompt_evaluation.py -v - # or pass explicitly for this run only (PowerShell) - $env:OPENROUTER_API_KEY="sk-or-..."; $env:OPENROUTER_MODEL="google/gemini-flash-latest"; pytest ... -v + # or target a non-default server/model for this run only (PowerShell) + $env:UTEST_AGENT_PROMPT_EVALUATION="1"; $env:OPENCODE_URL="http://127.0.0.1:4096"; $env:OPENCODE_MODEL="openrouter/anthropic/claude-opus-4.6"; pytest ... -v - # or the dedicated opt-in vars (cmd.exe) - set UTEST_AGENT_PROMPT_EVALUATION=sk-or-... - pytest ... -v - -The model defaults to the agent's own default OpenRouter model when unset. +The model defaults to the OpenCode server's own configured default when unset. Each test reproduces a specific, previously-reported bug/scenario and verifies the agent's plan, once executed against a realistic synthetic @@ -58,13 +53,16 @@ logger = logging.getLogger(__name__) -def _resolve_live_credentials() -> "tuple[str, str | None]": - """Resolve the OpenRouter (key, model) for the live suite. +def _resolve_live_opencode_config() -> "tuple[bool, str]": + """Resolve whether to run the live suite, and which OpenCode model to + request. - Mirrors the agent's own config loading: pull in any repo ``.env`` first, - then prefer the dedicated UTEST_* opt-in vars, falling back to the standard - OPENROUTER_* vars so the same credentials the agent runs on also drive this - suite without duplicating them. + OpenCode has no API-key concept (its credential lives in the OpenCode + server's own config, entered once via ``opencode auth login``), so opting + in is just a plain on/off switch -- ``UTEST_AGENT_PROMPT_EVALUATION`` set + to any non-empty value. ``OPENCODE_URL``/``OPENCODE_MODEL`` (the SAME env + vars the agent itself reads) pick which server/model to run against; any + repo ``.env`` is loaded first so they can live there too. """ if load_dotenv is not None: # weightslab/tests/trainer/services/ -> parents[4] = repo root, @@ -75,26 +73,19 @@ def _resolve_live_credentials() -> "tuple[str, str | None]": load_dotenv(dotenv_path=candidate, override=False) load_dotenv(override=False) - key = ( - os.environ.get("UTEST_AGENT_PROMPT_EVALUATION", "").strip() - or os.environ.get("OPENROUTER_API_KEY", "").strip() - ) - model = ( - os.environ.get("UTEST_AGENT_PROMPT_EVALUATION_MODEL", "").strip() - or os.environ.get("OPENROUTER_MODEL", "").strip() - or None - ) - return key, model + run_live = bool(os.environ.get("UTEST_AGENT_PROMPT_EVALUATION", "").strip()) + model = os.environ.get("OPENCODE_MODEL", "").strip() or None + return run_live, model -API_KEY, MODEL = _resolve_live_credentials() +RUN_LIVE, MODEL = _resolve_live_opencode_config() -if not API_KEY: +if not RUN_LIVE: logger.info( - "[test_agent_live_prompt_evaluation] No OpenRouter key found " - "(checked UTEST_AGENT_PROMPT_EVALUATION and OPENROUTER_API_KEY, incl. .env) -- " - "skipping live-LLM agent prompt evaluation tests. Set one of those (and optionally " - "OPENROUTER_MODEL) to run this suite against a real model." + "[test_agent_live_prompt_evaluation] UTEST_AGENT_PROMPT_EVALUATION not set -- " + "skipping live-LLM agent prompt evaluation tests. Set it to any value, with a " + "local OpenCode server running and authenticated (see OPENCODE_URL/OPENCODE_MODEL), " + "to run this suite against a real model." ) @@ -146,7 +137,7 @@ def _make_live_agent(df: pd.DataFrame, exp_ctx=None) -> DataManipulationAgent: # for the data-only tests (no model registered). ctx = SimpleNamespace(_all_datasets_df=df, _ctx=exp_ctx) agent = DataManipulationAgent(ctx) - ok, message = agent.initialize_with_cloud_key(API_KEY, "openrouter", MODEL or agent.openrouter_model) + ok, message = agent.initialize_with_cloud_key("", "opencode", MODEL) if not ok: raise RuntimeError(f"Failed to initialize live agent for testing: {message}") return agent @@ -246,7 +237,7 @@ def _run_ops(df: pd.DataFrame, ops: list, model_service=None) -> "tuple[pd.DataF return df, messages -@unittest.skipUnless(API_KEY, "UTEST_AGENT_PROMPT_EVALUATION not set; skipping live-LLM agent evaluation") +@unittest.skipUnless(RUN_LIVE, "UTEST_AGENT_PROMPT_EVALUATION not set; skipping live-LLM agent evaluation") class TestAgentLivePromptEvaluation(unittest.TestCase): """Runs a battery of realistic user prompts against a REAL LLM and verifies the resulting dataframe/message state. Each test is a @@ -403,7 +394,7 @@ def test_reset_view_is_recognized(self): self.assertTrue(any(op.get("params", {}).get("__agent_reset__") for op in ops), ops) -@unittest.skipUnless(API_KEY, "UTEST_AGENT_PROMPT_EVALUATION not set; skipping live-LLM agent evaluation") +@unittest.skipUnless(RUN_LIVE, "UTEST_AGENT_PROMPT_EVALUATION not set; skipping live-LLM agent evaluation") class TestAgentRstDocumentedPrompts(unittest.TestCase): """ One test per example prompt listed in docs/agent.rst's "Example prompts diff --git a/tests/trainer/services/test_agent_model_and_safety_unit.py b/tests/trainer/services/test_agent_model_and_safety_unit.py index 6f475cda..93e4d50d 100644 --- a/tests/trainer/services/test_agent_model_and_safety_unit.py +++ b/tests/trainer/services/test_agent_model_and_safety_unit.py @@ -16,13 +16,9 @@ def _install_agent_dependency_stubs(): stubs = { - "langchain_ollama": types.ModuleType("langchain_ollama"), - "langchain_openai": types.ModuleType("langchain_openai"), "langchain_core": types.ModuleType("langchain_core"), "langchain_core.prompts": types.ModuleType("langchain_core.prompts"), } - stubs["langchain_ollama"].ChatOllama = object - stubs["langchain_openai"].ChatOpenAI = object stubs["langchain_core.prompts"].ChatPromptTemplate = object return stubs @@ -37,9 +33,7 @@ def _make_agent(df=None): # `_ctx=None` means `_setup_model_schema` bails out early (no live model), # matching how a standalone agent behaves before any model is registered. ctx = SimpleNamespace(_all_datasets_df=df, _ctx=None) - - with mock.patch.object(agent_mod, "ChatOpenAI", None), mock.patch.object(agent_mod, "ChatOllama", None): - agent = agent_mod.DataManipulationAgent(ctx) + agent = agent_mod.DataManipulationAgent(ctx) return agent_mod, agent @@ -134,8 +128,6 @@ def fake_try_query_provider(provider, instruction, system_prompt): return None agent._try_query_provider = fake_try_query_provider - agent.preferred_provider = "openrouter" - agent.fallback_to_local = False agent.query("keep only validation or test samples") @@ -168,8 +160,6 @@ def fake_try_query_provider(provider, instruction, system_prompt): return next(it) agent._try_query_provider = fake_try_query_provider - agent.preferred_provider = "openrouter" - agent.fallback_to_local = False return agent, calls def test_history_starts_empty(self): @@ -231,64 +221,17 @@ def test_history_unaffected_by_a_failed_query(self): self.assertEqual(agent.history, []) -class TestStartupProviderVerification(unittest.TestCase): +class TestQueryAuthFailureHandling(unittest.TestCase): """Reported bug: CheckAgentHealth said the agent was available, but a - real query then failed with "401 Unauthorized". Root cause: - is_available() only checks that a provider CLIENT OBJECT exists, not - that its credentials were ever confirmed to work -- true for any key - loaded from agent_config.yaml/env vars, which (unlike the /init UI flow) - never runs a connectivity check. _verify_startup_providers() now probes - once at construction time, and a 401 detected during a real query - invalidates the cached connection so is_available() reflects reality - immediately instead of staying stale until the process restarts.""" - - def test_startup_verification_disables_a_bad_openrouter_key(self): - agent_mod, agent = _make_agent() - - class _FailingChatModel: - def __init__(self, *a, **kw): pass - def invoke(self, prompt): - raise RuntimeError("401 Unauthorized") - - agent.openrouter_api_key = "bad-key" - with mock.patch.object(agent_mod, "ChatOpenAI", _FailingChatModel): - agent._setup_providers() - agent._verify_startup_providers() - - self.assertIsNone(agent.chain_openrouter) - self.assertFalse(agent.is_available()) - - def test_startup_verification_keeps_a_good_openrouter_key(self): - agent_mod, agent = _make_agent() - - class _OkChatModel: - def __init__(self, *a, **kw): pass - def invoke(self, prompt): - return SimpleNamespace(content="OK") - - agent.openrouter_api_key = "good-key" - with mock.patch.object(agent_mod, "ChatOpenAI", _OkChatModel): - agent._setup_providers() - agent._verify_startup_providers() - - self.assertIsNotNone(agent.chain_openrouter) - self.assertTrue(agent.is_available()) - - def test_startup_verification_skips_probe_when_no_chain_was_built(self): - # No openrouter_api_key configured at all -> chain_openrouter stays - # None -> nothing to probe, must not raise. - _, agent = _make_agent() - agent.chain_openrouter = None - - agent._verify_startup_providers() # should be a no-op, not raise - - self.assertIsNone(agent.chain_openrouter) + real query then failed with "401 Unauthorized". A 401 detected during a + real query invalidates the cached OpenCode connection so is_available() + reflects reality immediately instead of staying stale until the process + restarts; a non-auth (e.g. transient/timeout) failure must NOT do that, + since the connection might still be perfectly valid.""" def test_401_during_query_invalidates_the_cached_connection(self): _, agent = _make_agent() - agent.chain_openrouter = object() # simulates an already-"available" cached client - agent.preferred_provider = "openrouter" - agent.fallback_to_local = False + agent.chain_opencode = object() # simulates an already-"available" cached client def fake_try_query_provider(provider, instruction, system_prompt): agent._last_query_error = RuntimeError("401 Unauthorized") @@ -299,7 +242,7 @@ def fake_try_query_provider(provider, instruction, system_prompt): self.assertTrue(agent.is_available()) # stale "available" before the query result = agent.query("do something") - self.assertIsNone(agent.chain_openrouter) + self.assertIsNone(agent.chain_opencode) self.assertFalse(agent.is_available()) self.assertIn("Agent not connected", result[0]["params"]["reason"]) @@ -307,9 +250,7 @@ def test_non_auth_failure_does_not_invalidate_the_connection(self): # A transient/non-auth failure (e.g. timeout) must NOT disable a # connection that might still be perfectly valid. _, agent = _make_agent() - agent.chain_openrouter = object() - agent.preferred_provider = "openrouter" - agent.fallback_to_local = False + agent.chain_opencode = object() def fake_try_query_provider(provider, instruction, system_prompt): agent._last_query_error = RuntimeError("Connection timed out") @@ -319,7 +260,7 @@ def fake_try_query_provider(provider, instruction, system_prompt): agent.query("do something") - self.assertIsNotNone(agent.chain_openrouter) + self.assertIsNotNone(agent.chain_opencode) self.assertTrue(agent.is_available()) diff --git a/tests/trainer/services/test_agent_opencode_provider.py b/tests/trainer/services/test_agent_opencode_provider.py new file mode 100644 index 00000000..4f33aeb8 --- /dev/null +++ b/tests/trainer/services/test_agent_opencode_provider.py @@ -0,0 +1,448 @@ +"""Tests for DataManipulationAgent's OpenCode provider: config loading, +_setup_providers wiring self.chain_opencode, initialize_with_cloud_key/ +change_model/get_available_models/reset_connection, and clear_history/ +compact_history. OpenCode is the only supported agent backend. + +Follows the exact same "_install_agent_dependency_stubs + _make_agent" pattern +already used in test_agent_model_and_safety_unit.py / test_agent_prompt_unit.py +(duplicated per-file by this repo's own convention, not imported across test +files). OpenCodeChat itself is mocked out here -- its own HTTP/SSE behavior is +covered by test_opencode_chat.py against a real fake server. +""" + +import importlib +import json +import sys +import types +import unittest +from types import SimpleNamespace +from unittest import mock +from unittest.mock import MagicMock + +import pandas as pd + +# `_make_agent()` (like test_agent_model_and_safety_unit.py's identical helper) +# wraps each import in `mock.patch.dict(sys.modules, stubs, clear=False)`, which +# restores sys.modules to its EXACT pre-`with` snapshot on exit -- including +# evicting every module (torch, numpy, and their transitive dependency tree) +# that wasn't already resident when the block started. Those C extensions +# cannot be safely re-initialized after eviction, and the failure is +# order-dependent: it only shows up on the SECOND-and-later `_make_agent()` +# call in a run where nothing had already pulled in agent.py's full transitive +# import tree first. The sibling test file (test_agent_model_and_safety_unit.py) +# avoids this via its own top-level `from weightslab.trainer.services.data_service +# import ...`, which happens to import that whole tree before any stubbing runs. +# Importing the same module here for the same reason, not because this file +# needs DataService itself. +from weightslab.trainer.services.data_service import DataService # noqa: F401 + + +def _install_agent_dependency_stubs(): + stubs = { + "langchain_core": types.ModuleType("langchain_core"), + "langchain_core.prompts": types.ModuleType("langchain_core.prompts"), + } + stubs["langchain_core.prompts"].ChatPromptTemplate = object + return stubs + + +def _make_agent(df=None): + with mock.patch.dict(sys.modules, _install_agent_dependency_stubs(), clear=False): + agent_mod = importlib.import_module("weightslab.trainer.services.agent.agent") + + if df is None: + df = pd.DataFrame({"loss": [0.1, 0.9], "discarded": [False, False]}) + + ctx = SimpleNamespace(_all_datasets_df=df, _ctx=None) + agent = agent_mod.DataManipulationAgent(ctx) + + return agent_mod, agent + + +class TestOpenCodeConfigLoading(unittest.TestCase): + def test_opencode_url_and_model_default(self): + with mock.patch.dict("os.environ", {}, clear=False): + for key in ("OPENCODE_URL", "OPENCODE_MODEL"): + import os + os.environ.pop(key, None) + _, agent = _make_agent() + self.assertEqual(agent.opencode_url, "http://127.0.0.1:4096") + self.assertEqual(agent.opencode_model, "") + + def test_opencode_url_and_model_read_from_env(self): + with mock.patch.dict("os.environ", { + "OPENCODE_URL": "http://127.0.0.1:5555", + "OPENCODE_MODEL": "openrouter/anthropic/claude-opus-4.6", + }, clear=False): + _, agent = _make_agent() + self.assertEqual(agent.opencode_url, "http://127.0.0.1:5555") + self.assertEqual(agent.opencode_model, "openrouter/anthropic/claude-opus-4.6") + + def test_default_url_is_not_marked_explicit(self): + # Nobody chose this address -- OpenCodeChat._ensure_reachable must be + # free to replace it via auto-discovery/spawn if it's ever dead. + import os + with mock.patch.dict("os.environ", {}, clear=False): + os.environ.pop("OPENCODE_URL", None) + _, agent = _make_agent() + self.assertFalse(agent._opencode_url_explicit) + + def test_env_provided_url_is_marked_explicit(self): + # The opposite: an operator who deliberately set OPENCODE_URL is + # opting OUT of auto-discovery, not asking for it. + with mock.patch.dict("os.environ", {"OPENCODE_URL": "http://127.0.0.1:5555"}, clear=False): + _, agent = _make_agent() + self.assertTrue(agent._opencode_url_explicit) + + def test_workspace_dir_follows_weightslab_root_log_dir(self): + # The same directory `weightslab start ` roots the browser + # landing-page agent at -- the shared key opencode_process.py's lock + # file is discovered/published under. + with mock.patch.dict("os.environ", {"WEIGHTSLAB_ROOT_LOG_DIR": "/tmp/some-experiment"}, clear=False): + _, agent = _make_agent() + self.assertEqual(agent.opencode_workspace_dir, "/tmp/some-experiment") + + def test_workspace_dir_falls_back_to_cwd_when_unset(self): + import os + with mock.patch.dict("os.environ", {}, clear=False): + os.environ.pop("WEIGHTSLAB_ROOT_LOG_DIR", None) + _, agent = _make_agent() + self.assertEqual(agent.opencode_workspace_dir, os.getcwd()) + + +class TestSetupProvidersOpenCode(unittest.TestCase): + def test_opencode_chain_is_built(self): + agent_mod, agent = _make_agent() + fake_runnable = MagicMock() + with mock.patch.object(agent_mod, "OpenCodeChat") as mock_cls: + mock_cls.return_value.as_runnable.return_value = fake_runnable + initialized = agent._setup_providers() + + mock_cls.assert_called_once_with( + agent.opencode_url, agent.opencode_model, + workspace_dir=agent.opencode_workspace_dir, + url_is_explicit=agent._opencode_url_explicit, + model_is_explicit=agent._opencode_model_explicit, + ) + self.assertTrue(initialized) + self.assertIs(agent.chain_opencode, fake_runnable) + + def test_no_api_key_gate(self): + """OpenCode has no API-key concept at all -- the credential lives in + OpenCode's own config.""" + agent_mod, agent = _make_agent() + with mock.patch.object(agent_mod, "OpenCodeChat") as mock_cls: + mock_cls.return_value.as_runnable.return_value = MagicMock() + initialized = agent._setup_providers() + self.assertTrue(initialized) # succeeded with no key configured anywhere + + def test_setup_error_is_caught_and_reported_as_not_initialized(self): + agent_mod, agent = _make_agent() + with mock.patch.object(agent_mod, "OpenCodeChat", side_effect=RuntimeError("boom")): + initialized = agent._setup_providers() + self.assertFalse(initialized) + self.assertIsNone(agent.chain_opencode) + + +class TestInitializeWithCloudKeyOpenCode(unittest.TestCase): + def test_accepts_opencode_and_ignores_empty_api_key(self): + agent_mod, agent = _make_agent() + with mock.patch.object(agent_mod, "OpenCodeChat") as mock_cls: + mock_cls.return_value.as_runnable.return_value = MagicMock() + success, message = agent.initialize_with_cloud_key("", "opencode", "openrouter/openai/gpt-5") + + self.assertTrue(success) + self.assertIn("OpenCode", message) + self.assertEqual(agent.preferred_provider, "opencode") + self.assertEqual(agent.opencode_model, "openrouter/openai/gpt-5") + + def test_reports_failure_when_opencode_unreachable(self): + agent_mod, agent = _make_agent() + with mock.patch.object(agent_mod, "OpenCodeChat", side_effect=RuntimeError("connection refused")): + success, message = agent.initialize_with_cloud_key("", "opencode", None) + self.assertFalse(success) + self.assertIn("OpenCode", message) + + def test_rejects_any_provider_other_than_opencode(self): + _, agent = _make_agent() + success, message = agent.initialize_with_cloud_key("key", "anthropic-direct", None) + self.assertFalse(success) + self.assertIn("Only OpenCode", message) + + def test_rejects_openrouter_now_that_it_is_removed(self): + _, agent = _make_agent() + success, message = agent.initialize_with_cloud_key("sk-or-test", "openrouter", "openai/gpt-5") + self.assertFalse(success) + self.assertIn("Only OpenCode", message) + + +class TestChangeModelOpenCode(unittest.TestCase): + def test_switches_opencode_model(self): + agent_mod, agent = _make_agent() + with mock.patch.object(agent_mod, "OpenCodeChat") as mock_cls: + mock_cls.return_value.as_runnable.return_value = MagicMock() + success, message = agent.change_model("openrouter/openai/gpt-5-mini") + self.assertTrue(success) + self.assertEqual(agent.opencode_model, "openrouter/openai/gpt-5-mini") + + def test_reports_failure_when_opencode_unreachable(self): + agent_mod, agent = _make_agent() + with mock.patch.object(agent_mod, "OpenCodeChat", side_effect=RuntimeError("down")): + success, message = agent.change_model("openrouter/openai/gpt-5-mini") + self.assertFalse(success) + + def test_empty_model_is_rejected(self): + _, agent = _make_agent() + success, message = agent.change_model(" ") + self.assertFalse(success) + self.assertIn("Model cannot be empty", message) + + +class TestGetAvailableModelsOpenCode(unittest.TestCase): + def test_flattens_providers_into_provider_slash_model_strings(self): + _, agent = _make_agent() + # Stub out the self-heal itself (covered on its own in + # TestOpencodeBaseUrlHelper below) so this test only exercises the + # response-flattening logic against a fixed URL. + agent._opencode_chat._ensure_reachable = MagicMock() + agent._opencode_chat.base_url = "http://127.0.0.1:4096" + + fake_payload = { + "providers": [ + {"id": "openrouter", "models": {"anthropic/claude-opus-4.6": {}, "openai/gpt-5": {}}}, + {"id": "ollama", "models": {"llama3.2:3b": {}}}, + ], + } + + class _FakeResp: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def read(self): + import json + return json.dumps(fake_payload).encode() + + with mock.patch("urllib.request.urlopen", return_value=_FakeResp()): + ok, models, message = agent.get_available_models() + + self.assertTrue(ok) + self.assertEqual( + models, + sorted([ + "openrouter/anthropic/claude-opus-4.6", + "openrouter/openai/gpt-5", + "ollama/llama3.2:3b", + ]), + ) + + def test_reports_failure_when_opencode_server_unreachable(self): + _, agent = _make_agent() + agent._opencode_chat._ensure_reachable = MagicMock() + with mock.patch("urllib.request.urlopen", side_effect=OSError("refused")): + ok, models, message = agent.get_available_models() + self.assertFalse(ok) + self.assertEqual(models, []) + self.assertIn("OpenCode", message) + + def test_self_heals_the_base_url_before_querying(self): + """The bug this covers: `agent models` used to hit the raw + OPENCODE_URL directly and fail outright ("connection refused") if + nothing had spawned/discovered an OpenCode server yet -- unlike a + chat turn, which self-heals via OpenCodeChat._ensure_reachable. Pins + down that get_available_models now goes through the same self-heal + (via _opencode_base_url) before querying /config/providers.""" + _, agent = _make_agent() + with mock.patch.object(agent._opencode_chat, "_ensure_reachable") as ensure_mock: + with mock.patch("urllib.request.urlopen", side_effect=OSError("refused")): + agent.get_available_models() + ensure_mock.assert_called_once() + + +class TestOpencodeBaseUrlHelper(unittest.TestCase): + def test_falls_back_to_opencode_url_when_chat_not_yet_built(self): + _, agent = _make_agent() + agent._opencode_chat = None + agent.opencode_url = "http://127.0.0.1:7777" + self.assertEqual(agent._opencode_base_url(), "http://127.0.0.1:7777") + + def test_delegates_to_the_chats_self_healed_base_url(self): + _, agent = _make_agent() + agent._opencode_chat = MagicMock(base_url="http://127.0.0.1:9999") + self.assertEqual(agent._opencode_base_url(), "http://127.0.0.1:9999") + agent._opencode_chat._ensure_reachable.assert_called_once() + + +class TestGetContextUsage(unittest.TestCase): + """DataManipulationAgent.get_context_usage() -- backs the /context + command. Combines OpenCodeChat.last_usage (mocked directly here; its own + population from real SSE events is covered by test_opencode_chat.py) with + a context-window lookup via /config/providers (mocked urllib, same + _FakeResp pattern as TestGetAvailableModelsOpenCode above).""" + + class _FakeResp: + def __init__(self, payload): + self._payload = payload + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def read(self): + return json.dumps(self._payload).encode() + + def test_not_configured_when_opencode_chat_is_none(self): + _, agent = _make_agent() + agent._opencode_chat = None + + ok, usage, message = agent.get_context_usage() + + self.assertFalse(ok) + self.assertEqual(usage, {}) + self.assertIn("/init", message) + + def test_reports_no_turns_yet_when_last_usage_is_none(self): + _, agent = _make_agent() + agent.opencode_model = "" + agent._opencode_chat = MagicMock(last_usage=None) + + ok, usage, message = agent.get_context_usage() + + self.assertTrue(ok) + self.assertEqual(usage["context_window"], 0) + self.assertIn("No agent turns yet", message) + + def test_combines_last_usage_with_the_models_context_window(self): + _, agent = _make_agent() + agent.opencode_url = "http://127.0.0.1:4096" + agent.opencode_model = "openrouter/anthropic/claude-opus-4.6" + agent._opencode_chat = MagicMock(base_url="http://127.0.0.1:4096", last_usage={ + "input": 100, "output": 20, "reasoning": 5, "cache_read": 60, "cache_write": 10, + }) + + payload = { + "providers": [ + {"id": "openrouter", "models": { + "anthropic/claude-opus-4.6": {"limit": {"context": 200000, "output": 8192}}, + }}, + ], + } + with mock.patch("urllib.request.urlopen", return_value=self._FakeResp(payload)): + ok, usage, message = agent.get_context_usage() + + self.assertTrue(ok) + self.assertEqual(message, "") + self.assertEqual(usage, { + "model": "openrouter/anthropic/claude-opus-4.6", + "context_window": 200000, + "input_tokens": 100, + "output_tokens": 20, + "reasoning_tokens": 5, + "cache_read_tokens": 60, + "cache_write_tokens": 10, + }) + + def test_context_window_defaults_to_zero_when_the_server_is_unreachable(self): + """A failed /config/providers lookup must not sink the whole command -- + usage numbers are still worth showing without a window/percentage.""" + _, agent = _make_agent() + agent.opencode_model = "openrouter/anthropic/claude-opus-4.6" + agent._opencode_chat = MagicMock(base_url="http://127.0.0.1:4096", last_usage={ + "input": 10, "output": 2, "reasoning": 0, "cache_read": 0, "cache_write": 0, + }) + + with mock.patch("urllib.request.urlopen", side_effect=OSError("refused")): + ok, usage, message = agent.get_context_usage() + + self.assertTrue(ok) + self.assertEqual(usage["context_window"], 0) + self.assertEqual(usage["input_tokens"], 10) + + +class TestResetConnection(unittest.TestCase): + def test_reset_clears_opencode_chain_and_model(self): + agent_mod, agent = _make_agent() + agent.chain_opencode = MagicMock() + agent.opencode_model = "openrouter/openai/gpt-5" + + success, message = agent.reset_connection() + + self.assertTrue(success) + self.assertIsNone(agent.chain_opencode) + self.assertEqual(agent.preferred_provider, "opencode") + + +class _FakePipedRunnable: + """Stands in for `(ChatPromptTemplate | chain)` -- skips actual prompt + formatting (irrelevant to what compact_history does with the result) and + just forwards straight to the underlying chain's `.invoke`, matching real + LangChain's RunnableSequence semantics for this purpose.""" + + def __init__(self, chain): + self._chain = chain + + def invoke(self, variables): + return self._chain.invoke(variables) + + +class _FakeChatPromptTemplate: + @classmethod + def from_messages(cls, messages): + return cls() + + def __or__(self, chain): + return _FakePipedRunnable(chain) + + +class TestClearAndCompactHistory(unittest.TestCase): + def test_clear_history_empties_and_reports_count(self): + _, agent = _make_agent() + agent.history = ["User: a", "Action: 1 ops executed", "User: b", "Action: 2 ops executed"] + + success, message = agent.clear_history() + + self.assertTrue(success) + self.assertEqual(agent.history, []) + self.assertIn("4", message) + + def test_compact_history_on_empty_history_is_a_no_op_success(self): + # Short-circuits before touching ChatPromptTemplate at all -- no patch needed. + _, agent = _make_agent() + agent.history = [] + success, message = agent.compact_history() + self.assertTrue(success) + self.assertEqual(agent.history, []) + + def test_compact_history_replaces_history_with_one_summary(self): + agent_mod, agent = _make_agent() + agent.history = ["User: discard bad samples", "Action: 3 ops executed"] + + fake_reply = SimpleNamespace(content="Discarded 3 low-quality samples per user request.") + agent.chain_opencode = MagicMock(invoke=MagicMock(return_value=fake_reply)) + + with mock.patch.object(agent_mod, "ChatPromptTemplate", _FakeChatPromptTemplate): + success, message = agent.compact_history() + + self.assertTrue(success) + self.assertEqual(len(agent.history), 1) + self.assertIn("Discarded 3 low-quality samples", agent.history[0]) + + def test_compact_history_fails_cleanly_when_no_provider_available(self): + agent_mod, agent = _make_agent() + agent.history = ["User: x"] + agent.chain_opencode = None + + with mock.patch.object(agent_mod, "ChatPromptTemplate", _FakeChatPromptTemplate): + success, message = agent.compact_history() + + self.assertFalse(success) + # History is left untouched on failure -- nothing was actually compacted. + self.assertEqual(agent.history, ["User: x"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/trainer/services/test_agent_prompt_unit.py b/tests/trainer/services/test_agent_prompt_unit.py index 116d7cdb..093d992a 100644 --- a/tests/trainer/services/test_agent_prompt_unit.py +++ b/tests/trainer/services/test_agent_prompt_unit.py @@ -10,13 +10,9 @@ def _install_agent_dependency_stubs(): stubs = { - "langchain_ollama": types.ModuleType("langchain_ollama"), - "langchain_openai": types.ModuleType("langchain_openai"), "langchain_core": types.ModuleType("langchain_core"), "langchain_core.prompts": types.ModuleType("langchain_core.prompts"), } - stubs["langchain_ollama"].ChatOllama = object - stubs["langchain_openai"].ChatOpenAI = object stubs["langchain_core.prompts"].ChatPromptTemplate = object return stubs @@ -37,18 +33,6 @@ def _rewrite_origin_literals(self, code): return code -class _FakeChatModel: - def __init__(self, *args, **kwargs): - self.args = args - self.kwargs = kwargs - - def with_structured_output(self, schema): - return self - - def invoke(self, prompt): - return SimpleNamespace(content="OK") - - class TestAgentPromptUnit(unittest.TestCase): def test_intent_prompt_contains_expected_placeholders(self): self.assertIn("{row_count}", INTENT_PROMPT) @@ -134,44 +118,7 @@ def test_agent_models_and_handlers(self): ) self.assertEqual(action["function"], "action.save") - def test_initialize_with_cloud_key_checks_chat_connectivity(self): - with unittest.mock.patch.dict(sys.modules, _install_agent_dependency_stubs(), clear=False): - agent_mod = importlib.import_module("weightslab.trainer.services.agent.agent") - - ctx = SimpleNamespace( - _all_datasets_df=agent_mod.pd.DataFrame({"metric": [1.0, 2.0]}), - ) - - with mock.patch.object(agent_mod, "ChatOpenAI", _FakeChatModel), mock.patch.object(agent_mod, "ChatOllama", _FakeChatModel): - agent = agent_mod.DataManipulationAgent(ctx) - ok, message = agent.initialize_with_cloud_key("test-key", "openrouter", "google/gemini-2.5-flash") - - self.assertTrue(ok) - self.assertIn("initialized successfully", message) - self.assertIsNotNone(agent.chain_openrouter) - self.assertEqual(agent.openrouter_model, "google/gemini-2.5-flash") - - def test_initialize_with_cloud_key_fails_when_probe_fails(self): - with unittest.mock.patch.dict(sys.modules, _install_agent_dependency_stubs(), clear=False): - agent_mod = importlib.import_module("weightslab.trainer.services.agent.agent") - - class _FailingChatModel(_FakeChatModel): - def invoke(self, prompt): - raise RuntimeError("401 Unauthorized") - - ctx = SimpleNamespace( - _all_datasets_df=agent_mod.pd.DataFrame({"metric": [1.0, 2.0]}), - ) - - with mock.patch.object(agent_mod, "ChatOpenAI", _FailingChatModel), mock.patch.object(agent_mod, "ChatOllama", _FakeChatModel): - agent = agent_mod.DataManipulationAgent(ctx) - ok, message = agent.initialize_with_cloud_key("bad-key", "openrouter", "~google/gemini-flash-latest") - - self.assertFalse(ok) - self.assertIn("connectivity check failed", message) - self.assertIsNone(agent.chain_openrouter) - - def test_initialize_with_cloud_key_rejects_non_openrouter_provider(self): + def test_initialize_with_cloud_key_rejects_non_opencode_provider(self): with unittest.mock.patch.dict(sys.modules, _install_agent_dependency_stubs(), clear=False): agent_mod = importlib.import_module("weightslab.trainer.services.agent.agent") @@ -179,12 +126,11 @@ def test_initialize_with_cloud_key_rejects_non_openrouter_provider(self): _all_datasets_df=agent_mod.pd.DataFrame({"metric": [1.0, 2.0]}), ) - with mock.patch.object(agent_mod, "ChatOpenAI", _FakeChatModel), mock.patch.object(agent_mod, "ChatOllama", _FakeChatModel): - agent = agent_mod.DataManipulationAgent(ctx) - ok, message = agent.initialize_with_cloud_key("test-key", "grok", "grok-3-mini") + agent = agent_mod.DataManipulationAgent(ctx) + ok, message = agent.initialize_with_cloud_key("test-key", "grok", "grok-3-mini") self.assertFalse(ok) - self.assertIn("Only OpenRouter", message) + self.assertIn("Only OpenCode", message) def test_build_python_mask_keeps_string_literals(self): with unittest.mock.patch.dict(sys.modules, _install_agent_dependency_stubs(), clear=False): @@ -202,8 +148,7 @@ def test_build_python_mask_keeps_string_literals(self): ), ) - with mock.patch.object(agent_mod, "ChatOpenAI", _FakeChatModel), mock.patch.object(agent_mod, "ChatOllama", _FakeChatModel): - agent = agent_mod.DataManipulationAgent(ctx) + agent = agent_mod.DataManipulationAgent(ctx) mask = agent._build_python_mask( [agent_mod.Condition(column="origin", op="==", value="train")] @@ -234,8 +179,7 @@ def test_compact_schema_for_prompt_separates_index_levels_from_columns(self): ), ) - with mock.patch.object(agent_mod, "ChatOpenAI", _FakeChatModel), mock.patch.object(agent_mod, "ChatOllama", _FakeChatModel): - agent = agent_mod.DataManipulationAgent(ctx) + agent = agent_mod.DataManipulationAgent(ctx) schema_text = agent._compact_schema_for_prompt() @@ -321,8 +265,7 @@ def test_filter_by_origin_and_loss(self): ), ) - with mock.patch.object(agent_mod, "ChatOpenAI", _FakeChatModel), mock.patch.object(agent_mod, "ChatOllama", _FakeChatModel): - agent = agent_mod.DataManipulationAgent(ctx) + agent = agent_mod.DataManipulationAgent(ctx) cond1 = agent_mod.Condition(column="origin", op="==", value="train") cond2 = agent_mod.Condition(column="loss", op="<", value=0.3) @@ -368,8 +311,7 @@ def test_tag_high_loss_samples(self): ), ) - with mock.patch.object(agent_mod, "ChatOpenAI", _FakeChatModel), mock.patch.object(agent_mod, "ChatOllama", _FakeChatModel): - agent = agent_mod.DataManipulationAgent(ctx) + agent = agent_mod.DataManipulationAgent(ctx) step = agent_mod.AtomicIntent( kind="transform", @@ -401,8 +343,7 @@ def test_tag_from_quantile_computation(self): ), ) - with mock.patch.object(agent_mod, "ChatOpenAI", _FakeChatModel), mock.patch.object(agent_mod, "ChatOllama", _FakeChatModel): - agent = agent_mod.DataManipulationAgent(ctx) + agent = agent_mod.DataManipulationAgent(ctx) step = agent_mod.AtomicIntent( kind="transform", @@ -496,8 +437,7 @@ def test_analysis_train_loss_stddev(self): ), ) - with mock.patch.object(agent_mod, "ChatOpenAI", _FakeChatModel), mock.patch.object(agent_mod, "ChatOllama", _FakeChatModel): - agent = agent_mod.DataManipulationAgent(ctx) + agent = agent_mod.DataManipulationAgent(ctx) step = agent_mod.AtomicIntent( kind="analysis", @@ -531,8 +471,7 @@ def test_tag_outliers_by_stddev(self): ), ) - with mock.patch.object(agent_mod, "ChatOpenAI", _FakeChatModel), mock.patch.object(agent_mod, "ChatOllama", _FakeChatModel): - agent = agent_mod.DataManipulationAgent(ctx) + agent = agent_mod.DataManipulationAgent(ctx) step = agent_mod.AtomicIntent( kind="transform", @@ -563,8 +502,7 @@ def test_tag_outliers_by_iqr(self): ), ) - with mock.patch.object(agent_mod, "ChatOpenAI", _FakeChatModel), mock.patch.object(agent_mod, "ChatOllama", _FakeChatModel): - agent = agent_mod.DataManipulationAgent(ctx) + agent = agent_mod.DataManipulationAgent(ctx) step = agent_mod.AtomicIntent( kind="transform", @@ -597,8 +535,7 @@ def test_filter_and_tag_combination(self): ), ) - with mock.patch.object(agent_mod, "ChatOpenAI", _FakeChatModel), mock.patch.object(agent_mod, "ChatOllama", _FakeChatModel): - agent_mod.DataManipulationAgent(ctx) + agent_mod.DataManipulationAgent(ctx) # First filter: keep only train filt_cond = agent_mod.Condition(column="origin", op="==", value="train") @@ -635,8 +572,7 @@ def test_untag_operation(self): ), ) - with mock.patch.object(agent_mod, "ChatOpenAI", _FakeChatModel), mock.patch.object(agent_mod, "ChatOllama", _FakeChatModel): - agent = agent_mod.DataManipulationAgent(ctx) + agent = agent_mod.DataManipulationAgent(ctx) step = agent_mod.AtomicIntent( kind="transform", @@ -667,8 +603,7 @@ def test_rename_tag_operation(self): ), ) - with mock.patch.object(agent_mod, "ChatOpenAI", _FakeChatModel), mock.patch.object(agent_mod, "ChatOllama", _FakeChatModel): - agent = agent_mod.DataManipulationAgent(ctx) + agent = agent_mod.DataManipulationAgent(ctx) step = agent_mod.AtomicIntent( kind="transform", diff --git a/tests/trainer/services/test_agent_service_unit.py b/tests/trainer/services/test_agent_service_unit.py index fb26bd6c..c3d394ed 100644 --- a/tests/trainer/services/test_agent_service_unit.py +++ b/tests/trainer/services/test_agent_service_unit.py @@ -24,9 +24,11 @@ def test_check_agent_health_reports_ready_when_available(self): self.assertTrue(response.available) self.assertIn('Ready to help you.', response.message) - def test_initialize_agent_delegates_to_agent_with_openrouter(self): + def test_initialize_agent_rejects_openrouter_now_that_it_is_removed(self): + """PROVIDER_OPENROUTER (0) is kept in the .proto only for wire + compatibility with older frontends -- requesting it must be rejected + cleanly rather than reaching the (now opencode-only) agent.""" agent = MagicMock() - agent.initialize_with_cloud_key.return_value = (True, 'ok') service, _ = self._make_service(agent=agent) response = service.InitializeAgent( @@ -38,13 +40,9 @@ def test_initialize_agent_delegates_to_agent_with_openrouter(self): None, ) - agent.initialize_with_cloud_key.assert_called_once_with( - 'sk-or-test', - 'openrouter', - '~google/gemini-flash-latest', - ) - self.assertTrue(response.success) - self.assertEqual(response.message, 'ok') + agent.initialize_with_cloud_key.assert_not_called() + self.assertFalse(response.success) + self.assertIn('Only OpenCode', response.message) def test_initialize_agent_rejects_unsupported_provider(self): agent = MagicMock() @@ -61,7 +59,7 @@ def test_initialize_agent_rejects_unsupported_provider(self): agent.initialize_with_cloud_key.assert_not_called() self.assertFalse(response.success) - self.assertIn('Only OpenRouter', response.message) + self.assertIn('Only OpenCode', response.message) def test_change_get_and_reset_agent_delegate_to_agent(self): agent = MagicMock() @@ -99,6 +97,89 @@ def test_methods_fail_cleanly_when_agent_backend_missing(self): self.assertEqual(list(list_response.models), []) self.assertIn('not running', init_response.message) + def test_initialize_agent_accepts_opencode_provider(self): + agent = MagicMock() + agent.initialize_with_cloud_key.return_value = (True, 'Agent initialized successfully via OpenCode. Ready to help you.') + service, _ = self._make_service(agent=agent) + + response = service.InitializeAgent( + pb2.InitializeAgentRequest( + api_key='', # ignored for opencode -- credential lives in OpenCode's own config + provider=pb2.PROVIDER_OPENCODE, + model='openrouter/anthropic/claude-opus-4.6', + ), + None, + ) + + agent.initialize_with_cloud_key.assert_called_once_with( + '', 'opencode', 'openrouter/anthropic/claude-opus-4.6', + ) + self.assertTrue(response.success) + + def test_clear_agent_history_delegates_to_agent(self): + agent = MagicMock() + agent.clear_history.return_value = (True, 'Cleared 4 history entries.') + service, _ = self._make_service(agent=agent) + + response = service.ClearAgentHistory(pb2.Empty(), None) + + agent.clear_history.assert_called_once_with() + self.assertTrue(response.success) + self.assertEqual(response.message, 'Cleared 4 history entries.') + + def test_compact_agent_history_delegates_to_agent(self): + agent = MagicMock() + agent.compact_history.return_value = (True, 'Compacted 4 entries into one summary.') + service, _ = self._make_service(agent=agent) + + response = service.CompactAgentHistory(pb2.Empty(), None) + + agent.compact_history.assert_called_once_with() + self.assertTrue(response.success) + + def test_clear_and_compact_history_fail_cleanly_when_agent_backend_missing(self): + service, _ = self._make_service(agent=None) + + clear_response = service.ClearAgentHistory(pb2.Empty(), None) + compact_response = service.CompactAgentHistory(pb2.Empty(), None) + + self.assertFalse(clear_response.success) + self.assertFalse(compact_response.success) + self.assertIn('not running', clear_response.message) + + def test_get_agent_context_usage_delegates_to_agent(self): + agent = MagicMock() + agent.get_context_usage.return_value = (True, { + 'model': 'openrouter/anthropic/claude-opus-4.6', + 'context_window': 200000, + 'input_tokens': 100, + 'output_tokens': 20, + 'reasoning_tokens': 5, + 'cache_read_tokens': 60, + 'cache_write_tokens': 10, + }, '') + service, _ = self._make_service(agent=agent) + + response = service.GetAgentContextUsage(pb2.Empty(), None) + + agent.get_context_usage.assert_called_once_with() + self.assertTrue(response.success) + self.assertEqual(response.model, 'openrouter/anthropic/claude-opus-4.6') + self.assertEqual(response.context_window, 200000) + self.assertEqual(response.input_tokens, 100) + self.assertEqual(response.output_tokens, 20) + self.assertEqual(response.reasoning_tokens, 5) + self.assertEqual(response.cache_read_tokens, 60) + self.assertEqual(response.cache_write_tokens, 10) + + def test_get_agent_context_usage_fails_cleanly_when_agent_backend_missing(self): + service, _ = self._make_service(agent=None) + + response = service.GetAgentContextUsage(pb2.Empty(), None) + + self.assertFalse(response.success) + self.assertIn('not running', response.message) + if __name__ == '__main__': unittest.main() diff --git a/tests/trainer/services/test_opencode_chat.py b/tests/trainer/services/test_opencode_chat.py new file mode 100644 index 00000000..81468e21 --- /dev/null +++ b/tests/trainer/services/test_opencode_chat.py @@ -0,0 +1,448 @@ +"""Tests for OpenCodeChat (weightslab/trainer/services/agent/opencode_chat.py). + +Exercises the module against a REAL minimal HTTP server implementing the +subset of OpenCode's protocol this class needs (POST /session, POST +/session/{id}/message, GET /event as text/event-stream) rather than mocking +urllib -- the class's correctness hinges on stream-first ordering and SSE +event parsing, which a mocked urlopen would not honestly exercise. Mirrors +the fake-server technique already used in tests/ui/test_server_agent.py for +the same reason. +""" + +import json +import threading +import time +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from unittest import mock + +from weightslab.trainer.services.agent.opencode_chat import OpenCodeChat, OpenCodeError + + +class _FakeOpenCodeHandler(BaseHTTPRequestHandler): + """Implements just enough of OpenCode's HTTP+SSE surface to drive + OpenCodeChat through a full create -> send -> stream -> idle cycle. + Configured per-server-instance via class attributes the test sets before + starting it (see _FakeOpenCodeServer below).""" + + def log_message(self, *a): + pass + + def _send_json(self, obj): + body = json.dumps(obj).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self): + length = int(self.headers.get("Content-Length", "0") or 0) + raw = self.rfile.read(length) if length else b"" + try: + body = json.loads(raw.decode("utf-8")) if raw else {} + except ValueError: + body = {} + + if self.path == "/session": + self.server.recorded_session_titles.append(body.get("title")) + self._send_json({"id": self.server.session_id}) + return + + if self.path == f"/session/{self.server.session_id}/message": + self.server.recorded_messages.append(body) + # A real server holds this open for the whole turn; this fake + # returns immediately -- OpenCodeChat must not rely on this + # response for content, only on the SSE stream (see its own + # docstring). Sleep briefly so the message genuinely arrives + # after the stream has had a chance to open, exercising the + # stream-first ordering rather than accidentally passing by luck. + time.sleep(0.05) + self._send_json({}) + return + + self.send_response(404) + self.end_headers() + + def do_GET(self): + if self.path != "/event": + self.send_response(404) + self.end_headers() + return + + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.end_headers() + + def emit(event: dict) -> None: + payload = f"data: {json.dumps(event)}\n\n".encode("utf-8") + self.wfile.write(payload) + self.wfile.flush() + + session_id = self.server.session_id + # Wait for the message POST to actually land before emitting anything + # -- proves OpenCodeChat is genuinely reading a live stream, not just + # replaying a canned response. + deadline = time.monotonic() + 5 + while not self.server.recorded_messages and time.monotonic() < deadline: + time.sleep(0.01) + + info = {"id": "msg_1", "role": "assistant", "sessionID": session_id} + if self.server.reply_tokens is not None: + info["tokens"] = self.server.reply_tokens + emit({"type": "message.updated", "properties": {"info": info}}) + for delta in self.server.reply_deltas: + emit({"type": "message.part.updated", "properties": {"part": { + "id": f"prt_{delta[:4]}", "type": "text", "text": delta, "messageID": "msg_1", "sessionID": session_id, + }}}) + if self.server.emit_error: + emit({"type": "session.error", "properties": {"sessionID": session_id}}) + else: + emit({"type": "session.idle", "properties": {"sessionID": session_id}}) + # Keep the connection open a moment so OpenCodeChat's break-on-idle has + # definitely already fired before we tear down. + time.sleep(0.05) + + +class _FakeOpenCodeServer(ThreadingHTTPServer): + daemon_threads = True + + def __init__(self, *args, reply_deltas, emit_error=False, reply_tokens=None, **kwargs): + super().__init__(*args, **kwargs) + self.session_id = "ses_test1" + self.reply_deltas = reply_deltas + self.emit_error = emit_error + self.reply_tokens = reply_tokens + self.recorded_messages = [] + self.recorded_session_titles = [] + + +class _ServerTestCase(unittest.TestCase): + def _start_server(self, reply_deltas, emit_error=False, reply_tokens=None): + self.httpd = _FakeOpenCodeServer( + ("127.0.0.1", 0), _FakeOpenCodeHandler, + reply_deltas=reply_deltas, emit_error=emit_error, reply_tokens=reply_tokens, + ) + self.port = self.httpd.server_address[1] + self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) + self.thread.start() + + def tearDown(self): + if hasattr(self, "httpd"): + self.httpd.shutdown() + self.thread.join(timeout=5) + + +class TestOpenCodeChatCall(_ServerTestCase): + def test_collects_streamed_text_and_returns_an_ai_message(self): + self._start_server(reply_deltas=["Here is ", "the answer."]) + chat = OpenCodeChat(f"http://127.0.0.1:{self.port}", model="openrouter/openai/gpt-5", timeout=10) + + result = chat.as_runnable().invoke("do the thing") + + self.assertEqual(result.content, "Here is the answer.") + + def test_sends_the_model_ref_split_on_first_slash_only(self): + self._start_server(reply_deltas=["ok"]) + chat = OpenCodeChat(f"http://127.0.0.1:{self.port}", model="openrouter/anthropic/claude-opus-4.6", timeout=10) + + chat._call("hello") + + self.assertEqual(len(self.httpd.recorded_messages), 1) + self.assertEqual( + self.httpd.recorded_messages[0]["model"], + {"providerID": "openrouter", "modelID": "anthropic/claude-opus-4.6"}, + ) + + def test_disables_every_mutating_tool_on_the_outgoing_message(self): + """This wrapper backs the SDK agent's text/JSON call sites, which parse + the reply themselves -- unlike the Weights Studio landing chat, it must + never let OpenCode write files as a side effect.""" + self._start_server(reply_deltas=["ok"]) + chat = OpenCodeChat(f"http://127.0.0.1:{self.port}", model=None, timeout=10) + + chat._call("hello") + + tools = self.httpd.recorded_messages[0]["tools"] + self.assertEqual(tools, {"write": False, "edit": False, "patch": False, "bash": False}) + + def test_omits_model_field_when_none_configured(self): + self._start_server(reply_deltas=["ok"]) + chat = OpenCodeChat(f"http://127.0.0.1:{self.port}", model=None, timeout=10) + + chat._call("hello") + + self.assertNotIn("model", self.httpd.recorded_messages[0]) + + def test_creates_a_fresh_session_per_call(self): + self._start_server(reply_deltas=["a"]) + chat = OpenCodeChat(f"http://127.0.0.1:{self.port}", model=None, timeout=10) + + chat._call("first") + chat._call("second") + + # A fresh session per call (self.history on DataManipulationAgent + # already carries cross-call context) -- both calls hit /session, not + # a reused id. + self.assertEqual(len(self.httpd.recorded_session_titles), 2) + + def test_degrades_to_empty_string_on_session_error_rather_than_raising(self): + self._start_server(reply_deltas=["partial"], emit_error=True) + chat = OpenCodeChat(f"http://127.0.0.1:{self.port}", model=None, timeout=10) + + # session.error still ends the read loop cleanly; whatever text arrived + # before the error is still returned rather than raising. + result = chat._call("hello") + self.assertEqual(result.content, "partial") + + def test_raises_opencode_error_when_the_server_is_unreachable(self): + chat = OpenCodeChat("http://127.0.0.1:1", model=None, timeout=2) # nothing listens on port 1 + with self.assertRaises(OpenCodeError): + chat._call("hello") + + def test_populates_last_usage_from_the_assistant_messages_tokens(self): + self._start_server( + reply_deltas=["ok"], + reply_tokens={"input": 120, "output": 30, "reasoning": 5, "cache": {"read": 80, "write": 10}}, + ) + chat = OpenCodeChat(f"http://127.0.0.1:{self.port}", model=None, timeout=10) + + self.assertIsNone(chat.last_usage) + chat._call("hello") + + self.assertEqual( + chat.last_usage, + {"input": 120, "output": 30, "reasoning": 5, "cache_read": 80, "cache_write": 10}, + ) + + def test_last_usage_is_none_when_the_reply_carries_no_tokens_field(self): + self._start_server(reply_deltas=["ok"]) # reply_tokens defaults to None + chat = OpenCodeChat(f"http://127.0.0.1:{self.port}", model=None, timeout=10) + + chat._call("hello") + + self.assertIsNone(chat.last_usage) + + +class TestModelRefParsing(unittest.TestCase): + def test_splits_on_first_slash_only(self): + chat = OpenCodeChat("http://x", model="openrouter/anthropic/claude-opus-4.6") + self.assertEqual(chat._model_ref(), {"providerID": "openrouter", "modelID": "anthropic/claude-opus-4.6"}) + + def test_none_when_no_model_configured(self): + chat = OpenCodeChat("http://x", model=None) + self.assertIsNone(chat._model_ref()) + + def test_none_when_model_has_no_slash(self): + chat = OpenCodeChat("http://x", model="justamodel") + self.assertIsNone(chat._model_ref()) + + +class TestHandleEvent(unittest.TestCase): + """Unit-level checks on the event state machine, independent of the + network -- complements the end-to-end server tests above.""" + + def test_ignores_parts_from_a_message_not_yet_known_to_be_assistant(self): + text_parts = {} + assistant_ids = set() + payload = json.dumps({ + "type": "message.part.updated", + "properties": {"part": {"id": "p1", "type": "text", "text": "x", "messageID": "msg_unknown", "sessionID": "s1"}}, + }) + outcome = OpenCodeChat._handle_event(payload, "s1", assistant_ids, text_parts) + self.assertIsNone(outcome) + self.assertEqual(text_parts, {}) + + def test_ignores_events_for_a_different_session(self): + text_parts = {} + assistant_ids = {"msg_1"} + payload = json.dumps({ + "type": "message.part.updated", + "properties": {"part": {"id": "p1", "type": "text", "text": "x", "messageID": "msg_1", "sessionID": "OTHER"}}, + }) + outcome = OpenCodeChat._handle_event(payload, "s1", assistant_ids, text_parts) + self.assertIsNone(outcome) + self.assertEqual(text_parts, {}) + + def test_malformed_payload_is_ignored_not_raised(self): + outcome = OpenCodeChat._handle_event("not json", "s1", set(), {}) + self.assertIsNone(outcome) + + def test_session_idle_signals_completion(self): + outcome = OpenCodeChat._handle_event( + json.dumps({"type": "session.idle", "properties": {"sessionID": "s1"}}), "s1", set(), {}, + ) + self.assertEqual(outcome, "idle") + + def test_populates_the_usage_dict_when_the_assistant_message_carries_tokens(self): + usage = {} + payload = json.dumps({ + "type": "message.updated", + "properties": {"info": { + "id": "msg_1", "role": "assistant", "sessionID": "s1", + "tokens": {"input": 10, "output": 2, "reasoning": 0, "cache": {"read": 5, "write": 1}}, + }}, + }) + outcome = OpenCodeChat._handle_event(payload, "s1", set(), {}, usage) + self.assertIsNone(outcome) + self.assertEqual(usage, {"input": 10, "output": 2, "reasoning": 0, "cache_read": 5, "cache_write": 1}) + + def test_usage_param_is_optional_and_ignored_when_omitted(self): + # Existing call sites that predate the usage tracking must keep working. + payload = json.dumps({ + "type": "message.updated", + "properties": {"info": {"id": "msg_1", "role": "assistant", "sessionID": "s1", "tokens": {"input": 1}}}, + }) + outcome = OpenCodeChat._handle_event(payload, "s1", set(), {}) + self.assertIsNone(outcome) + + +class TestEnsureReachable(unittest.TestCase): + """The other half of the cross-process handoff opencode_process.py + implements: this side self-heals its own base_url via that module's + resolve_or_spawn_opencode instead of staying pointed at a dead address + forever. The module itself (env/lockfile/spawn precedence, real + subprocess spawn+poll) is covered by tests/test_opencode_process.py -- + these mock it out to pin down exactly when OpenCodeChat calls it.""" + + def test_explicit_url_is_never_auto_replaced_even_if_dead(self): + chat = OpenCodeChat("http://127.0.0.1:1", workspace_dir="/tmp/x", url_is_explicit=True) + with mock.patch("weightslab.opencode_process.opencode_healthy") as healthy, \ + mock.patch("weightslab.opencode_process.resolve_or_spawn_opencode") as resolve: + chat._ensure_reachable() + healthy.assert_not_called() + resolve.assert_not_called() + self.assertEqual(chat.base_url, "http://127.0.0.1:1") + + def test_already_healthy_non_explicit_url_is_left_alone(self): + chat = OpenCodeChat("http://127.0.0.1:4096", workspace_dir="/tmp/x", url_is_explicit=False) + with mock.patch("weightslab.opencode_process.opencode_healthy", return_value=True) as healthy, \ + mock.patch("weightslab.opencode_process.resolve_or_spawn_opencode") as resolve: + chat._ensure_reachable() + healthy.assert_called_once_with("http://127.0.0.1:4096") + resolve.assert_not_called() + self.assertEqual(chat.base_url, "http://127.0.0.1:4096") + + def test_dead_non_explicit_url_resolves_or_spawns_a_replacement(self): + chat = OpenCodeChat("http://127.0.0.1:4096", workspace_dir="/tmp/x", url_is_explicit=False) + with mock.patch("weightslab.opencode_process.opencode_healthy", return_value=False), \ + mock.patch("weightslab.opencode_process.resolve_or_spawn_opencode", + return_value={"ok": True, "url": "http://127.0.0.1:9999"}) as resolve: + chat._ensure_reachable() + resolve.assert_called_once_with("/tmp/x") + self.assertEqual(chat.base_url, "http://127.0.0.1:9999") + + def test_failed_resolve_leaves_the_dead_url_in_place(self): + # A visible connection error on the next real call is more honest + # than silently pretending nothing changed. + chat = OpenCodeChat("http://127.0.0.1:4096", workspace_dir="/tmp/x", url_is_explicit=False) + with mock.patch("weightslab.opencode_process.opencode_healthy", return_value=False), \ + mock.patch("weightslab.opencode_process.resolve_or_spawn_opencode", + return_value={"ok": False, "error": "no opencode"}): + chat._ensure_reachable() + self.assertEqual(chat.base_url, "http://127.0.0.1:4096") + + def test_missing_workspace_dir_falls_back_to_the_current_directory(self): + chat = OpenCodeChat("http://127.0.0.1:4096", url_is_explicit=False) # workspace_dir defaults to None + with mock.patch("weightslab.opencode_process.opencode_healthy", return_value=False), \ + mock.patch("weightslab.opencode_process.resolve_or_spawn_opencode", + return_value={"ok": True, "url": "http://127.0.0.1:9999"}) as resolve: + chat._ensure_reachable() + resolve.assert_called_once_with(".") + + def test_call_invokes_ensure_reachable_before_creating_a_session(self): + # _call is the single real entry point all three of + # DataManipulationAgent's call sites go through (see module + # docstring) -- this pins down that self-healing actually happens + # on the path real turns take, not just when called directly. + chat = OpenCodeChat("http://127.0.0.1:4096", url_is_explicit=False) + calls = [] + chat._ensure_reachable = lambda: calls.append("ensure_reachable") + chat._ensure_model_resolved = lambda: calls.append("ensure_model_resolved") + chat._create_session = lambda: (calls.append("create_session") or "ses_x") + chat._collect_reply = lambda session_id, text: (calls.append("collect_reply") or "ok") + chat._call("hello") + self.assertEqual( + calls, + ["ensure_reachable", "ensure_model_resolved", "create_session", "collect_reply"], + ) + + +class TestEnsureModelResolved(unittest.TestCase): + """Confirmed live: leaving `model` unset does NOT mean "OpenCode picks a + sensible default" -- it means OpenCode picks whatever's configured, + arbitrarily (an image-generation preview model, in the case that + surfaced this). _ensure_model_resolved is the fix: resolve a REAL + default (the user's last actual pick, or a provider's own configured + default) instead of leaving it to chance. Mirrors TestEnsureReachable's + own mocked-request style -- the wire format itself (GET /config, + GET /config/providers) is exercised for real in + TestGetAvailableModelsOpenCode (test_agent_opencode_provider.py).""" + + def _fake_response(self, payload): + class _Resp: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def read(self): + return json.dumps(payload).encode() + return _Resp() + + def test_explicit_model_is_never_auto_replaced(self): + chat = OpenCodeChat("http://127.0.0.1:4096", model="openrouter/anthropic/claude-opus-4.6", model_is_explicit=True) + with mock.patch.object(chat, "_request") as request_mock: + chat._ensure_model_resolved() + request_mock.assert_not_called() + self.assertEqual(chat.model, "openrouter/anthropic/claude-opus-4.6") + + def test_already_set_non_explicit_model_is_left_alone(self): + # Already resolved once (e.g. a prior call) -- don't re-resolve or + # re-request every single turn. + chat = OpenCodeChat("http://127.0.0.1:4096", model="openrouter/openai/gpt-5", model_is_explicit=False) + with mock.patch.object(chat, "_request") as request_mock: + chat._ensure_model_resolved() + request_mock.assert_not_called() + self.assertEqual(chat.model, "openrouter/openai/gpt-5") + + def test_unset_model_resolves_from_config_own_model_field(self): + chat = OpenCodeChat("http://127.0.0.1:4096", model=None, model_is_explicit=False) + with mock.patch.object(chat, "_request", return_value=self._fake_response({"model": "anthropic/claude-haiku-4.5"})) as request_mock: + chat._ensure_model_resolved() + request_mock.assert_called_once_with("/config") + self.assertEqual(chat.model, "anthropic/claude-haiku-4.5") + + def test_falls_back_to_the_hardcoded_default_when_config_has_no_model(self): + # Confirmed live: a provider-reported default (/config/providers, no + # longer consulted at all -- see _ensure_model_resolved's own + # docstring) could itself be an arbitrary, non-text-reasoning model + # whenever ANY provider had credentials configured, silently + # pre-empting this default every time. A single /config miss now + # goes straight to the known-good free model, with no second request. + chat = OpenCodeChat("http://127.0.0.1:4096", model=None, model_is_explicit=False) + with mock.patch.object(chat, "_request", return_value=self._fake_response({})) as request_mock: + chat._ensure_model_resolved() + request_mock.assert_called_once_with("/config") + self.assertEqual(chat.model, "opencode/deepseek-v4-flash-free") + + def test_no_resolvable_model_falls_back_to_the_hardcoded_default(self): + chat = OpenCodeChat("http://127.0.0.1:4096", model=None, model_is_explicit=False) + with mock.patch.object(chat, "_request", side_effect=OSError("refused")): + chat._ensure_model_resolved() # must not raise + self.assertEqual(chat.model, "opencode/deepseek-v4-flash-free") + + def test_config_field_that_is_not_provider_slash_model_falls_through(self): + # A malformed/unexpected `model` field (missing the "/") is treated + # the same as absent, not used as-is -- falls to the hardcoded + # default, not a second request. + chat = OpenCodeChat("http://127.0.0.1:4096", model=None, model_is_explicit=False) + with mock.patch.object(chat, "_request", return_value=self._fake_response({"model": "not-a-provider-model-pair"})) as request_mock: + chat._ensure_model_resolved() + request_mock.assert_called_once_with("/config") + self.assertEqual(chat.model, "opencode/deepseek-v4-flash-free") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/trainer/services/test_trainer_services_unit.py b/tests/trainer/services/test_trainer_services_unit.py index 28d5ac0f..9cb8f17b 100644 --- a/tests/trainer/services/test_trainer_services_unit.py +++ b/tests/trainer/services/test_trainer_services_unit.py @@ -53,6 +53,35 @@ def test_get_latest_logger_data_full_history_nested(self): self.assertEqual(response.points[0].model_age, 0) self.assertEqual(response.points[1].model_age, 1) + def test_get_latest_logger_data_full_history_downsample_keeps_last_point(self): + # Regression test: a fixed-stride slice (signal_history[::step]) starts at + # index 0 and drops the tail of a fixed-length run. With 10000 points and + # max_points=1000, step=10 lands the last kept index at 9990, silently + # dropping steps 9991-9999. _downsample_uniform must be used instead so the + # most recent point is always present. + signal_logger = MagicMock() + signal_logger.get_signal_history.return_value = [ + { + "metric_name": "train/loss_CE", + "model_age": age, + "metric_value": 1.0 / (age + 1), + "experiment_hash": "exp-hash", + } + for age in range(10000) + ] + ctx = _DummyCtx(components={"signal_logger": signal_logger}) + with patch("weightslab.trainer.services.experiment_service.DataService"): + service = ExperimentService(ctx) + + request = pb2.GetLatestLoggerDataRequest(request_full_history=True, max_points=1000, break_by_slices=False) + response = service.GetLatestLoggerData(request, None) + + model_ages = [p.model_age for p in response.points] + self.assertLessEqual(len(model_ages), 1000) + self.assertEqual(model_ages[0], 0) + self.assertEqual(max(model_ages), 9999) + self.assertEqual(model_ages[-1], 9999) + def test_get_latest_logger_data_queue_mode(self): signal_logger = MagicMock() signal_logger.get_and_clear_queue.return_value = [ diff --git a/tests/ui/fake_opencode.py b/tests/ui/fake_opencode.py new file mode 100644 index 00000000..35832891 --- /dev/null +++ b/tests/ui/fake_opencode.py @@ -0,0 +1,261 @@ +"""A fake OpenCode server, over real HTTP, for the backend agent tests. + +WHY. ``weightslab/ui/server.py`` talks to OpenCode with three stdlib helpers: +``_opencode_json_request`` (plain JSON round trip), ``_opencode_get_messages`` +(a passthrough), and ``_opencode_send_and_collect`` -- which is where all the +real complexity lives. That one opens the SSE event stream FIRST, sends the +prompt from a second thread, then reads the stream until this session goes +idle, assembling reply text per part id and distinguishing "the turn ended +having said nothing" from "the turn failed". + +Every existing test patches those helpers out (see test_server_loop.py's own +header), which is right for exercising ``_LoopRegistry``'s scheduling logic but +means the wire layer itself -- stream-before-send ordering, SSE framing, +keep-alive comments, which events count and which are ignored, error vs idle +termination -- has never been executed against anything. This serves the real +protocol on a real socket so it is. + +Deliberately stdlib-only, matching the module it tests (``ui/server.py`` is +stdlib-only by design so ``weightslab start`` needs no extra dependency). + +USAGE:: + + with FakeOpencode() as server: + server.script = [ + {"type": "message.updated", + "properties": {"info": {"id": "m1", "role": "assistant", + "sessionID": "ses_1"}}}, + {"type": "message.part.updated", + "properties": {"part": {"id": "p1", "messageID": "m1", + "sessionID": "ses_1", + "type": "text", "text": "hello"}}}, + {"type": "session.idle", "properties": {"sessionID": "ses_1"}}, + ] + text, error = ui_server._opencode_send_and_collect( + server.base_url, "ses_1", "check in") + +The scripted events are emitted when the prompt POST arrives, which is what a +real server does: the turn only starts once the message is sent. +""" + +import json +import queue +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +class FakeOpencode: + """A scriptable OpenCode stand-in on 127.0.0.1, speaking real HTTP/SSE.""" + + def __init__(self): + #: Events emitted (in order) once the prompt POST arrives. + self.script: list = [] + #: HTTP status the prompt POST answers with. + self.post_status: int = 200 + #: Seconds the prompt POST blocks before answering, to exercise the + #: "the send itself is what times out" path. + self.post_delay: float = 0.0 + #: Emit a `:` keep-alive comment ahead of the scripted events -- real + #: servers do, and the reader must skip them rather than treat one as + #: a truncated event. + self.send_keepalive: bool = True + #: Replies for the plain JSON routes, by "METHOD /path". + self.responses: dict = {} + #: What GET /session//message returns. + self.messages: list = [] + + #: Every request received, as (method, path, parsed-body-or-None). + self.requests: list = [] + #: Bodies POSTed to /session//message specifically. + self.prompt_bodies: list = [] + + self._events: "queue.Queue" = queue.Queue() + self._server = ThreadingHTTPServer(("127.0.0.1", 0), _make_handler(self)) + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + + # -- lifecycle --------------------------------------------------------- + @property + def base_url(self) -> str: + host, port = self._server.server_address[:2] + return f"http://{host}:{port}" + + def start(self) -> "FakeOpencode": + self._thread.start() + return self + + def stop(self) -> None: + # Unblock any streaming handler still parked on the queue, so + # shutdown() isn't waiting on a request that never finishes. + self._events.put(None) + self._server.shutdown() + self._server.server_close() + + def __enter__(self) -> "FakeOpencode": + return self.start() + + def __exit__(self, *_exc) -> None: + self.stop() + + # -- scripting --------------------------------------------------------- + def emit(self, event: dict) -> None: + """Push one event onto the live stream, outside the scripted batch.""" + self._events.put(event) + + def end_stream(self) -> None: + """Close the event stream from the server side (EOF), as a restarting + or crashing server would.""" + self._events.put(None) + + def _release_script(self) -> None: + for event in self.script: + self._events.put(event) + + +def _make_handler(state: FakeOpencode): + class Handler(BaseHTTPRequestHandler): + # HTTP/1.0: the response body is delimited by connection close, so a + # streamed SSE body needs no chunked framing and the client's + # line-by-line read still sees each event as it is written. + protocol_version = "HTTP/1.0" + + def log_message(self, *_args) -> None: + pass # keep the test output clean + + # -- helpers -- + def _read_body(self): + length = int(self.headers.get("Content-Length") or 0) + if not length: + return None + raw = self.rfile.read(length) + try: + return json.loads(raw.decode("utf-8")) + except (ValueError, UnicodeDecodeError): + return raw + + def _send_json(self, payload, status: int = 200) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _stream_events(self) -> None: + """GET /event -- hold the connection open and write SSE frames as + they are queued, until a None sentinel or the client hangs up.""" + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + try: + if state.send_keepalive: + self.wfile.write(b": keep-alive\n\n") + self.wfile.flush() + while True: + event = state._events.get() + if event is None: + return + frame = f"data: {json.dumps(event)}\n\n".encode("utf-8") + self.wfile.write(frame) + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError, OSError): + # The reader breaks out on session.idle/session.error and + # closes its end -- an expected, ordinary hang-up here. + return + + # -- verbs -- + def do_GET(self) -> None: + state.requests.append(("GET", self.path, None)) + if self.path == "/event": + self._stream_events() + return + if self.path.startswith("/session/") and self.path.endswith("/message"): + self._send_json(state.messages) + return + key = f"GET {self.path}" + if key in state.responses: + self._send_json(state.responses[key]) + return + self._send_json({}) + + def do_POST(self) -> None: + body = self._read_body() + state.requests.append(("POST", self.path, body)) + + if self.path.startswith("/session/") and self.path.endswith("/message"): + state.prompt_bodies.append(body) + # The turn starts on send -- release the scripted events now, + # which is also what makes the stream-first ordering in + # _opencode_send_and_collect observable: had it sent first and + # subscribed after, it would miss everything below. + state._release_script() + if state.post_delay: + import time + time.sleep(state.post_delay) + if state.post_status != 200: + self._send_json({"error": "prompt rejected"}, status=state.post_status) + return + self._send_json({"ok": True}) + return + + key = f"POST {self.path}" + if key in state.responses: + self._send_json(state.responses[key]) + return + self._send_json({}) + + def do_DELETE(self) -> None: + state.requests.append(("DELETE", self.path, None)) + self._send_json({}) + + return Handler + + +# -------------------------------------------------------------------------- +# Event builders -- the shapes ui/server.py's reader actually looks for, in one +# place so a protocol change lands here rather than across every test. +# -------------------------------------------------------------------------- + +def assistant_message(message_id: str, session_id: str) -> dict: + return { + "type": "message.updated", + "properties": {"info": {"id": message_id, "role": "assistant", "sessionID": session_id}}, + } + + +def user_message(message_id: str, session_id: str) -> dict: + return { + "type": "message.updated", + "properties": {"info": {"id": message_id, "role": "user", "sessionID": session_id}}, + } + + +def text_part(part_id: str, message_id: str, session_id: str, text: str) -> dict: + return { + "type": "message.part.updated", + "properties": {"part": { + "id": part_id, "messageID": message_id, "sessionID": session_id, + "type": "text", "text": text, + }}, + } + + +def tool_part(part_id: str, message_id: str, session_id: str, tool: str = "bash") -> dict: + return { + "type": "message.part.updated", + "properties": {"part": { + "id": part_id, "messageID": message_id, "sessionID": session_id, + "type": "tool", "tool": tool, "state": {"status": "completed"}, + }}, + } + + +def session_idle(session_id: str) -> dict: + return {"type": "session.idle", "properties": {"sessionID": session_id}} + + +def session_error(session_id: str, name: str, message: str = None) -> dict: + error = {"name": name} + if message is not None: + error["data"] = {"message": message} + return {"type": "session.error", "properties": {"sessionID": session_id, "error": error}} diff --git a/tests/ui/test_server_agent.py b/tests/ui/test_server_agent.py new file mode 100644 index 00000000..0cf419ff --- /dev/null +++ b/tests/ui/test_server_agent.py @@ -0,0 +1,538 @@ +"""Tests for weightslab/ui/server.py's OpenCode-agent supervisor: + +- POST /agent-server/start -- spawns (or reuses) a local OpenCode server rooted + at the experiment directory, so the browser never has to run `opencode serve` + by hand. Mirrors /local-notebook's "the browser can't spawn a process, so it + asks us to" shape. +- GET /agent-server/status -- none / running / killed, for the composer's status + line to poll. + +CI has no real `opencode` binary, so every test replaces +`ui_server._resolve_opencode_argv` with a tiny stand-in HTTP server (started via +`python -c`) that serves /global/health the same way the real OpenCode server +does. That is the only thing `_OpencodeSession` depends on to decide the child +started successfully, so it exercises the real spawn/health-poll/status code +path without depending on Node or the OpenCode package being installed. +""" + +import json +import os +import sys +import tempfile +import threading +import time +import unittest +import urllib.error +import urllib.request +from unittest.mock import patch + +from weightslab.ui import server as ui_server + +# A minimal stand-in for `opencode serve`: binds the --port it was given and +# answers /global/health like the real server does. Reads --port out of +# sys.argv positionally rather than assuming argv[0] is anything in particular, +# since "python -c serve --hostname H --port N" hands the child an argv +# whose exact shape depends on the platform's python launcher. +_FAKE_OPENCODE_SRC = r""" +import sys, json +from http.server import BaseHTTPRequestHandler, HTTPServer + +def _port(): + for i, a in enumerate(sys.argv): + if a == "--port": + return int(sys.argv[i + 1]) + return 4096 + +class H(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + def do_GET(self): + if self.path == "/global/health": + body = json.dumps({"version": "0.0.0-fake"}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: + self.send_response(404) + self.end_headers() + +HTTPServer(("127.0.0.1", _port()), H).serve_forever() +""" + +_FAKE_ARGV = [sys.executable, "-c", _FAKE_OPENCODE_SRC] + +# A stand-in that never becomes healthy -- models a child that starts but never +# binds (a bad flag, a crash loop) so ensure()'s timeout path is exercised. +_HANGING_ARGV = [sys.executable, "-c", "import time; time.sleep(60)"] + + +class TestOpencodeSessionUnit(unittest.TestCase): + """Exercises _OpencodeSession directly -- no HTTP layer, no real OpenCode.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.session = ui_server._OpencodeSession() + # Keep polling fast so a genuine failure test doesn't sit for 45s. + self._timeout_patch = patch.object(ui_server, "_OPENCODE_START_TIMEOUT", 3.0) + self._timeout_patch.start() + + def tearDown(self): + self.session.shutdown() + self._timeout_patch.stop() + + def test_starts_and_reports_running(self): + with patch.object(ui_server, "_resolve_opencode_argv", return_value=_FAKE_ARGV): + result = self.session.ensure(self.tmp, "http://localhost:5173") + self.assertTrue(result["ok"], result) + self.assertFalse(result["reused"]) + self.assertEqual(result["workspace"], self.tmp) + self.assertTrue(result["url"].startswith("http://127.0.0.1:")) + + status = self.session.status() + self.assertEqual(status["state"], "running") + self.assertEqual(status["workspace"], self.tmp) + + def test_second_call_reuses_the_same_process(self): + with patch.object(ui_server, "_resolve_opencode_argv", return_value=_FAKE_ARGV): + first = self.session.ensure(self.tmp, "http://localhost:5173") + second = self.session.ensure(self.tmp, "http://localhost:5173") + self.assertFalse(first["reused"]) + self.assertTrue(second["reused"]) + self.assertEqual(first["url"], second["url"]) + + def test_missing_binary_and_missing_npx_reports_a_clear_error(self): + with patch.object(ui_server, "_resolve_opencode_argv", return_value=None): + result = self.session.ensure(self.tmp, "http://localhost:5173") + self.assertFalse(result["ok"]) + self.assertIn("opencode", result["error"].lower()) + + status = self.session.status() + self.assertEqual(status["state"], "none") + + def test_child_that_never_becomes_healthy_times_out_and_is_killed(self): + with patch.object(ui_server, "_resolve_opencode_argv", return_value=_HANGING_ARGV): + result = self.session.ensure(self.tmp, "http://localhost:5173") + self.assertFalse(result["ok"]) + self.assertIn("did not come up", result["error"]) + + # The timed-out child must actually be killed, not leaked. + status = self.session.status() + self.assertEqual(status["state"], "killed") + + def test_shutdown_stops_a_running_process(self): + with patch.object(ui_server, "_resolve_opencode_argv", return_value=_FAKE_ARGV): + result = self.session.ensure(self.tmp, "http://localhost:5173") + self.assertTrue(result["ok"]) + process = self.session._process + self.session.shutdown() + self.assertIsNotNone(process.poll()) # exited + + def test_ensure_drops_agents_md_into_a_fresh_workspace(self): + # This test process runs from an actual repo checkout, so AGENTS.md + # resolves for real via _repo_doc_path -- no mocking needed to prove + # ensure() actually reaches the workspace with it. + with patch.object(ui_server, "_resolve_opencode_argv", return_value=_FAKE_ARGV): + result = self.session.ensure(self.tmp, "http://localhost:5173") + self.assertTrue(result["ok"], result) + copied = os.path.join(self.tmp, "AGENTS.md") + self.assertTrue(os.path.isfile(copied)) + with open(copied, encoding="utf-8") as fh: + self.assertTrue(fh.read().strip()) + + def test_ensure_never_overwrites_a_workspace_own_agents_md(self): + # A workspace's own AGENTS.md might be the USER's project + # instructions -- ensure() must never clobber it with ours. + own_path = os.path.join(self.tmp, "AGENTS.md") + with open(own_path, "w", encoding="utf-8") as fh: + fh.write("this workspace's own instructions") + with patch.object(ui_server, "_resolve_opencode_argv", return_value=_FAKE_ARGV): + result = self.session.ensure(self.tmp, "http://localhost:5173") + self.assertTrue(result["ok"], result) + with open(own_path, encoding="utf-8") as fh: + self.assertEqual(fh.read(), "this workspace's own instructions") + + def test_ensure_copy_is_best_effort_when_no_source_seed_files_exist(self): + # No source to copy from (e.g. a stripped-down install) must not + # fail ensure() outright -- the agent server should still start. + with patch.object(ui_server.opencode_process, "_packaged_file", return_value=None): + with patch.object(ui_server, "_resolve_opencode_argv", return_value=_FAKE_ARGV): + result = self.session.ensure(self.tmp, "http://localhost:5173") + self.assertTrue(result["ok"], result) + for filename in ui_server.opencode_process.WORKSPACE_SEED_FILES: + self.assertFalse(os.path.isfile(os.path.join(self.tmp, filename))) + + def test_successful_spawn_writes_a_lock_file_for_this_workspace(self): + # The other half of the cross-process handoff: the backend SDK agent + # (agent.py's OpenCodeChat) discovers THIS server via the same file + # -- see test_opencode_process.py's cross-process test for the full + # round trip. + with patch.object(ui_server, "_resolve_opencode_argv", return_value=_FAKE_ARGV): + result = self.session.ensure(self.tmp, "http://localhost:5173") + self.assertTrue(result["ok"], result) + lock = ui_server.opencode_process.read_lock(self.tmp) + self.assertEqual(lock["url"], result["url"]) + + def test_adopts_a_healthy_lockfile_instead_of_spawning_a_second_server(self): + # Simulates order (a) from the tabbed-agent-window plan: the backend + # SDK agent already published a server for this workspace before + # `weightslab start` (this session) ever called ensure(). + other = ui_server._OpencodeSession() + try: + with patch.object(ui_server, "_resolve_opencode_argv", return_value=_FAKE_ARGV): + published = other.ensure(self.tmp, None) + self.assertTrue(published["ok"], published) + + with patch.object(ui_server, "_resolve_opencode_argv") as argv_mock: + result = self.session.ensure(self.tmp, "http://localhost:5173") + finally: + other.shutdown() + + argv_mock.assert_not_called() + self.assertTrue(result["ok"], result) + self.assertEqual(result["url"], published["url"]) + self.assertEqual(result.get("adopted"), "lockfile") + # Adopted, not spawned -- nothing of this session's own to kill. + self.assertIsNone(self.session._process) + + def test_stale_lockfile_is_ignored_and_a_fresh_server_is_spawned(self): + # The process a stale lock file names is long gone -- must fall + # through to a normal spawn rather than failing or hanging. + ui_server.opencode_process.write_lock(self.tmp, "http://127.0.0.1:1", pid=999999) + with patch.object(ui_server, "_resolve_opencode_argv", return_value=_FAKE_ARGV): + result = self.session.ensure(self.tmp, "http://localhost:5173") + self.assertTrue(result["ok"], result) + self.assertNotEqual(result["url"], "http://127.0.0.1:1") + # The stale entry was overwritten with the newly-spawned server. + self.assertEqual(ui_server.opencode_process.read_lock(self.tmp)["url"], result["url"]) + + +class TestEnsureWorkspaceAgentFiles(unittest.TestCase): + """ensure_workspace_agent_files directly -- no OpenCode process involved. + + Both seeded files matter for the same reason: OpenCode reads a project + AGENTS.md and a project opencode.json out of the directory it is started + in, which is this workspace. A workspace missing either one silently + downgrades what the agent knows. + """ + + def setUp(self): + self.tmp = tempfile.mkdtemp() + + def test_copies_both_seed_files_from_the_installed_package_location(self): + ui_server._ensure_workspace_agent_files(self.tmp) + for filename in ui_server.opencode_process.WORKSPACE_SEED_FILES: + target = os.path.join(self.tmp, filename) + self.assertTrue(os.path.isfile(target), f"{filename} was not seeded") + with open(target, encoding="utf-8") as fh: + self.assertEqual(fh.read(), ui_server._read_repo_doc(filename)) + + def test_the_seeded_config_points_opencode_at_agents_md(self): + """The whole reason opencode.json is seeded at all -- without the + `instructions` entry it would be an empty config doing nothing.""" + ui_server._ensure_workspace_agent_files(self.tmp) + with open(os.path.join(self.tmp, "opencode.json"), encoding="utf-8") as fh: + config = json.load(fh) + self.assertIn("AGENTS.md", config["instructions"]) + self.assertEqual(config["$schema"], "https://opencode.ai/config.json") + + def test_is_a_no_op_when_the_workspace_already_has_one(self): + for filename in ui_server.opencode_process.WORKSPACE_SEED_FILES: + with open(os.path.join(self.tmp, filename), "w", encoding="utf-8") as fh: + fh.write("mine") + ui_server._ensure_workspace_agent_files(self.tmp) + for filename in ui_server.opencode_process.WORKSPACE_SEED_FILES: + with open(os.path.join(self.tmp, filename), encoding="utf-8") as fh: + self.assertEqual(fh.read(), "mine", f"{filename} was overwritten") + + def test_seeds_the_other_file_when_one_is_already_present(self): + with open(os.path.join(self.tmp, "AGENTS.md"), "w", encoding="utf-8") as fh: + fh.write("mine") + ui_server._ensure_workspace_agent_files(self.tmp) + self.assertTrue(os.path.isfile(os.path.join(self.tmp, "opencode.json"))) + + def test_does_nothing_when_no_source_is_found(self): + with patch.object(ui_server.opencode_process, "_packaged_file", return_value=None): + ui_server._ensure_workspace_agent_files(self.tmp) + for filename in ui_server.opencode_process.WORKSPACE_SEED_FILES: + self.assertFalse(os.path.isfile(os.path.join(self.tmp, filename))) + + +class TestCorsOriginVariants(unittest.TestCase): + """The localhost <-> 127.0.0.1 expansion is the #1 way this feature goes + silently wrong -- a mismatch here makes every request look like the agent + server is simply not there.""" + + def test_expands_localhost_to_127_0_0_1(self): + variants = ui_server._cors_origin_variants("http://localhost:5173") + self.assertIn("http://localhost:5173", variants) + self.assertIn("http://127.0.0.1:5173", variants) + + def test_expands_127_0_0_1_to_localhost(self): + variants = ui_server._cors_origin_variants("http://127.0.0.1:8080") + self.assertIn("http://127.0.0.1:8080", variants) + self.assertIn("http://localhost:8080", variants) + + def test_leaves_a_non_loopback_origin_alone(self): + variants = ui_server._cors_origin_variants("https://weightslab.example.com") + self.assertEqual(variants, ["https://weightslab.example.com"]) + + def test_none_origin_yields_no_variants(self): + self.assertEqual(ui_server._cors_origin_variants(None), []) + + +class _ServerTestCase(unittest.TestCase): + """Spins up a real serve_ui() on 127.0.0.1: per test, rooted at a + fresh temp dir passed as experiment_dir -- same shape as + test_server_experiment_reports.py.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.httpd = ui_server.serve_ui( + ui_host="127.0.0.1", ui_port=0, + backend_host="localhost", backend_port=50051, + open_browser=False, block=False, + experiment_dir=self.tmp, + ) + self.port = self.httpd.server_address[1] + self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) + self.thread.start() + time.sleep(0.1) + + # The module-level singleton is shared across the whole test process; + # swap in a fresh one per test so a leftover child from another test + # can't make this one see {reused: true} unexpectedly. + self._orig_session = ui_server._opencode_session + ui_server._opencode_session = ui_server._OpencodeSession() + self._timeout_patch = patch.object(ui_server, "_OPENCODE_START_TIMEOUT", 3.0) + self._timeout_patch.start() + + def tearDown(self): + self._timeout_patch.stop() + ui_server._opencode_session.shutdown() + ui_server._opencode_session = self._orig_session + self.httpd.shutdown() + self.thread.join(timeout=5) + + def _get(self, path): + return urllib.request.urlopen(f"http://127.0.0.1:{self.port}{path}", timeout=5) + + def _post(self, path, origin=None): + req = urllib.request.Request( + f"http://127.0.0.1:{self.port}{path}", method="POST", data=b"", + ) + if origin: + req.add_header("Origin", origin) + return urllib.request.urlopen(req, timeout=10) + + +class TestAgentServerEndpoint(_ServerTestCase): + + def test_status_is_none_before_anything_starts(self): + with self._get("/agent-server/status") as r: + data = json.loads(r.read().decode()) + self.assertEqual(data["state"], "none") + + def test_start_spawns_rooted_at_the_experiment_dir(self): + with patch.object(ui_server, "_resolve_opencode_argv", return_value=_FAKE_ARGV): + with self._post("/agent-server/start", origin="http://localhost:5173") as r: + data = json.loads(r.read().decode()) + self.assertTrue(data["ok"], data) + self.assertEqual(data["workspace"], self.tmp) + + with self._get("/agent-server/status") as r: + status = json.loads(r.read().decode()) + self.assertEqual(status["state"], "running") + + def test_missing_opencode_and_npx_returns_a_clear_error_not_a_500_crash(self): + with patch.object(ui_server, "_resolve_opencode_argv", return_value=None): + with self.assertRaises(urllib.error.HTTPError) as ctx: + self._post("/agent-server/start", origin="http://localhost:5173") + self.assertEqual(ctx.exception.code, 500) + data = json.loads(ctx.exception.read().decode()) + self.assertFalse(data["ok"]) + self.assertIn("opencode", data["error"].lower()) + + def test_falls_back_to_reconstructing_origin_from_host_header(self): + # No Origin header at all (e.g. a same-origin fetch some browsers omit + # it for) -- must not crash, and must still start the server. + with patch.object(ui_server, "_resolve_opencode_argv", return_value=_FAKE_ARGV): + with self._post("/agent-server/start") as r: + data = json.loads(r.read().decode()) + self.assertTrue(data["ok"], data) + + +class TestAgentDocsEndpoint(_ServerTestCase): + """GET /agent-server/docs[?example=] -- AGENTS.md, plus + optionally one matching PyTorch usecase example, for the landing chat's + preset prompts to attach (see agentChat.ts's PRESET_PROMPTS). README.md + was dropped from this endpoint on purpose -- AGENTS.md alone carries the + weightslab integration pattern the presets need.""" + + def test_returns_agents_md_when_present_in_a_repo_checkout(self): + # This test process runs from an actual repo checkout, so it should + # resolve via _read_repo_doc. + with self._get("/agent-server/docs") as r: + data = json.loads(r.read().decode()) + names = {f["name"] for f in data["files"]} + self.assertEqual(names, {"AGENTS.md"}) + for f in data["files"]: + self.assertTrue(f["content"].strip()) + + def test_omits_the_doc_when_it_cannot_be_found_instead_of_erroring(self): + with patch.object(ui_server, "_read_repo_doc", return_value=None): + with self._get("/agent-server/docs") as r: + data = json.loads(r.read().decode()) + self.assertEqual(data["files"], []) + + def test_example_query_param_additionally_attaches_that_usecases_main_py(self): + with self._get("/agent-server/docs?example=wl-classification") as r: + data = json.loads(r.read().decode()) + names = {f["name"] for f in data["files"]} + self.assertEqual(names, {"AGENTS.md", "examples/PyTorch/wl-classification/main.py"}) + + def test_example_query_param_is_repeatable_for_multiple_usecases(self): + with self._get("/agent-server/docs?example=wl-detection&example=wl-segmentation") as r: + data = json.loads(r.read().decode()) + names = {f["name"] for f in data["files"]} + self.assertEqual(names, { + "AGENTS.md", + "examples/PyTorch/wl-detection/main.py", + "examples/PyTorch/wl-segmentation/main.py", + }) + + def test_example_query_param_is_ignored_when_not_a_known_usecase(self): + # Client-supplied -- must be checked against the allowlist, never + # trusted as a path component (e.g. "../../../etc/passwd"). + with self._get("/agent-server/docs?example=../../../etc/passwd") as r: + data = json.loads(r.read().decode()) + names = {f["name"] for f in data["files"]} + self.assertEqual(names, {"AGENTS.md"}) + + def test_no_example_query_param_means_no_example_file(self): + with self._get("/agent-server/docs") as r: + data = json.loads(r.read().decode()) + names = {f["name"] for f in data["files"]} + self.assertNotIn("examples/PyTorch/wl-classification/main.py", names) + + +class TestLoopRegistryUnit(unittest.TestCase): + """_LoopRegistry directly -- no HTTP server, no real OpenCode process. + Mocks the module-level _opencode_json_request/_opencode_send_and_collect/ + _opencode_get_messages functions the registry itself calls, so these + exercise its own eager-session-creation/locking/preamble-once logic in + isolation. A fresh registry per test, not the module-level singleton.""" + + def setUp(self): + self.registry = ui_server._LoopRegistry() + + def tearDown(self): + for job in self.registry._jobs.values(): + if job.timer is not None: + job.timer.cancel() + + def test_start_creates_the_session_eagerly_not_on_first_tick(self): + with patch.object(ui_server, "_opencode_session") as mock_session, \ + patch.object(ui_server, "_opencode_json_request", return_value={"id": "sess-1"}) as mock_req, \ + patch.object(ui_server, "_opencode_send_and_collect", return_value="ok"): + mock_session.ensure.return_value = {"ok": True, "url": "http://fake"} + result = self.registry.start("monitor training", 60.0, "/tmp/ws", "http://localhost:5173") + + self.assertTrue(result["ok"], result) + job = self.registry._jobs[result["id"]] + # Eager: already set by start() itself, not left for _fire's first + # tick (which runs in a background thread started right after). + self.assertEqual(job.session_id, "sess-1") + mock_req.assert_any_call("http://fake", "/session", method="POST", body=unittest.mock.ANY) + + def test_rejected_after_session_creation_deletes_the_orphaned_session(self): + def _ensure_and_fill_concurrently(*_args, **_kwargs): + # Simulates 3 OTHER starts winning the race while this one's + # ensure() call was in flight -- by the time start() re-checks + # under the lock, the cap has already been hit by them. + for i in range(3): + self.registry._jobs[str(i)] = ui_server._LoopJob(str(i), "p", 60.0, "/tmp") + return {"ok": True, "url": "http://fake"} + + with patch.object(ui_server, "_opencode_session") as mock_session, \ + patch.object(ui_server, "_opencode_json_request", return_value={"id": "sess-orphan"}) as mock_req: + mock_session.ensure.side_effect = _ensure_and_fill_concurrently + result = self.registry.start("monitor training", 60.0, "/tmp/ws", "http://localhost:5173") + + self.assertFalse(result["ok"]) + self.assertIn("already running", result["error"]) + mock_req.assert_any_call("http://fake", "/session/sess-orphan", method="DELETE") + + def test_fire_sends_the_preamble_once_then_plain_prompt_on_later_ticks(self): + job = ui_server._LoopJob("1", "check the loss", 60.0, "/tmp") + job.session_id, job.base_url = "sess-1", "http://fake" + self.registry._jobs["1"] = job + + sent_texts = [] + + def _fake_send(_base_url, _session_id, text, _model=None, timeout=600.0): # noqa: ARG001 + sent_texts.append(text) + return "tick result", None + + with patch.object(ui_server, "_opencode_send_and_collect", side_effect=_fake_send): + self.registry._fire("1", "http://fake") + self.assertTrue(job.preamble_sent) + self.assertIn("recurring monitoring agent", sent_texts[0]) + self.assertIn("check the loss", sent_texts[0]) + job.timer.cancel() + + self.registry._fire("1", "http://fake") + self.assertEqual(sent_texts[1], "check the loss") + job.timer.cancel() + + def test_get_messages_success(self): + job = ui_server._LoopJob("1", "p", 60.0, "/tmp") + job.session_id, job.base_url = "sess-1", "http://fake" + self.registry._jobs["1"] = job + canned = [{"info": {"role": "user"}, "parts": [{"type": "text", "text": "hi"}]}] + with patch.object(ui_server, "_opencode_get_messages", return_value=canned): + result = self.registry.get_messages("1") + self.assertTrue(result["ok"], result) + self.assertEqual(result["messages"], canned) + + def test_get_messages_unknown_job(self): + result = self.registry.get_messages("nope") + self.assertFalse(result["ok"]) + + +class TestLoopMessagesEndpoint(_ServerTestCase): + """GET /agent-server/loop//messages -- a loop tab's read-only + transcript, proxied through the module-level _loop_registry singleton to + a job's own OpenCode session (mocked here; no real OpenCode process). + Loopback-gating itself mirrors the existing loop routes (_stop_loop et + al.), which have no dedicated test for the negative case either -- a + real test client is always loopback.""" + + def _seed_job(self): + job = ui_server._LoopJob("1", "check the loss", 60.0, self.tmp) + job.session_id, job.base_url = "sess-1", "http://fake" + ui_server._loop_registry._jobs["1"] = job + return job + + def tearDown(self): + ui_server._loop_registry._jobs.clear() + super().tearDown() + + def test_get_messages_returns_the_sessions_history(self): + self._seed_job() + canned = [{"info": {"role": "assistant"}, "parts": [{"type": "text", "text": "hi"}]}] + with patch.object(ui_server, "_opencode_get_messages", return_value=canned): + with self._get("/agent-server/loop/1/messages") as r: + data = json.loads(r.read().decode()) + self.assertTrue(data["ok"], data) + self.assertEqual(data["messages"], canned) + + def test_get_messages_404s_for_an_unknown_loop(self): + with self.assertRaises(urllib.error.HTTPError) as ctx: + self._get("/agent-server/loop/999/messages") + self.assertEqual(ctx.exception.code, 404) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/ui/test_server_data_query.py b/tests/ui/test_server_data_query.py new file mode 100644 index 00000000..3dacdc74 --- /dev/null +++ b/tests/ui/test_server_data_query.py @@ -0,0 +1,211 @@ +"""Tests for weightslab/ui/server.py's POST /agent-server/data-query -- +lets the landing-page agent chat perform dataset/model actions (discard, +tag, sort, filter, analyze, compute stats, ...) itself, the same way the +now-retired "Backend Agent" tab's query bar always did: by calling +ExperimentService.ApplyDataQuery over the SAME upstream gRPC channel +_proxy_grpc_web already proxies everything else through. + +Spins up a REAL grpc.server() implementing just ApplyDataQuery (not a mock) +alongside a real serve_ui() instance pointed at it -- same "real subprocess/ +real network, not mocked" philosophy as test_server_agent.py's fake-OpenCode +HTTP server -- so this exercises the actual request-building/response- +translation code, not just its shape. +""" + +import json +import tempfile +import threading +import time +import unittest +import urllib.error +import urllib.request +from concurrent import futures + +import grpc + +import weightslab.proto.experiment_service_pb2 as pb2 +import weightslab.proto.experiment_service_pb2_grpc as pb2_grpc +from weightslab.ui import server as ui_server + + +class _FakeExperimentService(pb2_grpc.ExperimentServiceServicer): + """Records every request it receives and returns whatever this test set + as `.next_response` (or raises `.next_error` instead, if set).""" + + def __init__(self): + self.received = [] + self.next_response = pb2.DataQueryResponse(success=True, message="ok") + self.next_error = None + + def ApplyDataQuery(self, request, context): + self.received.append(request) + if self.next_error is not None: + context.abort(self.next_error[0], self.next_error[1]) + return self.next_response + + +class _ServerTestCase(unittest.TestCase): + """Real serve_ui() + a real (fake) ExperimentService gRPC server behind + it, both on ephemeral ports.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp() + + self.servicer = _FakeExperimentService() + self.grpc_server = grpc.server(futures.ThreadPoolExecutor(max_workers=2)) + pb2_grpc.add_ExperimentServiceServicer_to_server(self.servicer, self.grpc_server) + backend_port = self.grpc_server.add_insecure_port("127.0.0.1:0") + self.grpc_server.start() + + self.httpd = ui_server.serve_ui( + ui_host="127.0.0.1", ui_port=0, + backend_host="127.0.0.1", backend_port=backend_port, + open_browser=False, block=False, + experiment_dir=self.tmp, + ) + self.port = self.httpd.server_address[1] + self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) + self.thread.start() + time.sleep(0.1) + + def tearDown(self): + self.httpd.shutdown() + self.thread.join(timeout=5) + self.grpc_server.stop(grace=None) + + def _post_json(self, path, body): + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request( + f"http://127.0.0.1:{self.port}{path}", method="POST", data=data, + headers={"Content-Type": "application/json"}, + ) + return urllib.request.urlopen(req, timeout=10) + + def _get_json(self, path): + with urllib.request.urlopen(f"http://127.0.0.1:{self.port}{path}", timeout=10) as r: + return json.loads(r.read().decode()) + + +class TestDataQueryEndpoint(_ServerTestCase): + def test_builds_a_real_dataqueryrequest_and_translates_the_response(self): + self.servicer.next_response = pb2.DataQueryResponse( + success=True, message="Discarded 3 samples.", + number_of_all_samples=100, number_of_samples_in_the_loop=97, + number_of_discarded_samples=3, unique_tags=["reviewed"], + analysis_result="", + ) + + with self._post_json("/agent-server/data-query", {"query": "discard samples where loss > 5"}) as r: + data = json.loads(r.read().decode()) + + self.assertTrue(data["ok"]) + self.assertEqual(data["message"], "Discarded 3 samples.") + self.assertEqual(data["numberOfAllSamples"], 100) + self.assertEqual(data["numberOfSamplesInTheLoop"], 97) + self.assertEqual(data["numberOfDiscardedSamples"], 3) + self.assertEqual(data["uniqueTags"], ["reviewed"]) + + self.assertEqual(len(self.servicer.received), 1) + sent = self.servicer.received[0] + self.assertEqual(sent.query, "discard samples where loss > 5") + self.assertFalse(sent.accumulate) + self.assertTrue(sent.is_natural_language) + + def test_accumulate_flag_is_forwarded(self): + with self._post_json("/agent-server/data-query", {"query": "sort by loss", "accumulate": True}) as r: + r.read() + self.assertTrue(self.servicer.received[0].accumulate) + + def test_backend_reported_failure_is_not_an_http_error(self): + # The backend understood the request fine and answered -- it just + # couldn't do what was asked (ambiguous, out of scope, ...). That's + # a normal 200 with success=false, not a transport-level failure. + self.servicer.next_response = pb2.DataQueryResponse( + success=False, message="I don't understand which column you mean.", + ) + + with self._post_json("/agent-server/data-query", {"query": "do the thing"}) as r: + data = json.loads(r.read().decode()) + + self.assertFalse(data["ok"]) + self.assertIn("don't understand", data["message"]) + self.assertNotIn("error", data) + + def test_empty_query_is_rejected_without_reaching_the_backend(self): + try: + self._post_json("/agent-server/data-query", {"query": " "}) + self.fail("expected an HTTPError") + except urllib.error.HTTPError as exc: + self.assertEqual(exc.code, 400) + data = json.loads(exc.read().decode()) + self.assertFalse(data["ok"]) + self.assertIn("required", data["error"]) + self.assertEqual(self.servicer.received, []) + + def test_grpc_failure_reports_a_clear_error_not_a_stack_trace(self): + self.servicer.next_error = (grpc.StatusCode.INTERNAL, "dataframe not loaded yet") + + try: + self._post_json("/agent-server/data-query", {"query": "sort by loss"}) + self.fail("expected an HTTPError") + except urllib.error.HTTPError as exc: + self.assertEqual(exc.code, 500) + data = json.loads(exc.read().decode()) + self.assertFalse(data["ok"]) + self.assertIn("dataframe not loaded yet", data["error"]) + + +class TestLatestDataQueryEndpoint(_ServerTestCase): + """The agent's own bash/curl call to /agent-server/data-query never + touches the browser's JS -- agentChat.ts instead polls + GET /agent-server/data-query/latest once per finished turn to find out + a query ran and replay the grid-refresh/subview-banner reaction. See + _LatestDataQuery's docstring in server.py. + + _latest_data_query is a module-level singleton (shared by every + serve_ui() instance in this process), so it's swapped out per-test the + same way test_server_tracked_processes.py does for _tracked_processes -- + otherwise seq/records would leak across tests.""" + + def setUp(self): + super().setUp() + self._orig_latest_data_query = ui_server._latest_data_query + ui_server._latest_data_query = ui_server._LatestDataQuery() + + def tearDown(self): + ui_server._latest_data_query = self._orig_latest_data_query + super().tearDown() + + def test_reports_seq_zero_when_nothing_has_run_yet(self): + data = self._get_json("/agent-server/data-query/latest") + self.assertEqual(data, {"seq": 0}) + + def test_reflects_the_most_recent_data_query_call(self): + self.servicer.next_response = pb2.DataQueryResponse( + success=True, message="Filtered to label 7.", + number_of_all_samples=100, number_of_samples_in_the_loop=12, + ) + with self._post_json("/agent-server/data-query", {"query": "show only label 7"}) as r: + r.read() + + data = self._get_json("/agent-server/data-query/latest") + self.assertEqual(data["seq"], 1) + self.assertEqual(data["query"], "show only label 7") + self.assertTrue(data["ok"]) + self.assertEqual(data["message"], "Filtered to label 7.") + self.assertEqual(data["numberOfAllSamples"], 100) + self.assertEqual(data["numberOfSamplesInTheLoop"], 12) + + def test_seq_increments_on_each_new_call_so_the_frontend_can_dedupe(self): + with self._post_json("/agent-server/data-query", {"query": "first"}) as r: + r.read() + with self._post_json("/agent-server/data-query", {"query": "second"}) as r: + r.read() + + data = self._get_json("/agent-server/data-query/latest") + self.assertEqual(data["seq"], 2) + self.assertEqual(data["query"], "second") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/ui/test_server_loop.py b/tests/ui/test_server_loop.py new file mode 100644 index 00000000..7f602df6 --- /dev/null +++ b/tests/ui/test_server_loop.py @@ -0,0 +1,487 @@ +"""Tests for weightslab/ui/server.py's /loop feature -- recurring +OpenCode-backed monitoring jobs (`_LoopRegistry` + the +/agent-server/loop/{start,list,stop} endpoints). + +_LoopRegistry.start() delegates session/message plumbing to the module-level +_opencode_json_request/_opencode_send_and_collect helpers (already covered at +the wire-protocol level by tests/trainer/services/test_opencode_chat.py's +fake-SSE-server tests for the sibling Python client). Here those two helpers +are mocked so the tests exercise _LoopRegistry's OWN logic -- validation, +session reuse across ticks, error bookkeeping, stop/list, and the HTTP +wiring -- the same split test_server_agent.py uses (direct _OpencodeSession +unit tests, then a separate endpoint-level test class). +""" + +import json +import tempfile +import threading +import time +import unittest +import urllib.request +from unittest.mock import patch + +from weightslab.ui import server as ui_server + + +class TestLoopRegistryUnit(unittest.TestCase): + """Exercises _LoopRegistry directly, with the opencode session assumed + already up (_opencode_session.ensure mocked) and the session-create/ + send-and-collect wire calls mocked -- those are covered elsewhere (see + module docstring).""" + + def setUp(self): + self.registry = ui_server._LoopRegistry() + self._ensure_patch = patch.object( + ui_server._opencode_session, "ensure", + return_value={"ok": True, "url": "http://127.0.0.1:1", "workspace": "/tmp", "reused": False}, + ) + self._ensure_patch.start() + + def tearDown(self): + self.registry.shutdown() + self._ensure_patch.stop() + + def _wait_for_first_tick(self, job_id, timeout=2.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + jobs = {j["id"]: j for j in self.registry.list()} + job = jobs.get(job_id) + if job and (job["lastResult"] is not None or job["lastError"] is not None): + return job + time.sleep(0.01) + self.fail("loop job never completed its first tick") + + def test_model_resolution_prefers_the_caller_then_config_then_provider_defaults(self): + """The three sources, in order -- see _opencode_resolve_model. The + point of the chain is that a loop is configured from the SAME place + the chat is, rather than needing to be told by whichever surface + happened to start it.""" + explicit = {"providerID": "openrouter", "modelID": "anthropic/claude-haiku-4.5"} + + # 1. explicit wins outright -- no lookups at all. + with patch.object(ui_server, "_opencode_json_request") as req: + self.assertEqual(ui_server._opencode_resolve_model("http://fake", explicit), explicit) + req.assert_not_called() + + # 2. opencode.json's own default (what the chat's picker writes back). + def _config_has_model(_base, path, **_kw): + if path == "/config": + return {"model": "openrouter/anthropic/claude-haiku-4.5"} + raise AssertionError(f"should not have reached {path}") + + with patch.object(ui_server, "_opencode_json_request", side_effect=_config_has_model): + self.assertEqual(ui_server._opencode_resolve_model("http://fake", None), explicit) + + # 3. provider defaults, when the config names no model of its own. + def _only_provider_defaults(_base, path, **_kw): + if path == "/config": + return {} + return {"providers": [{"id": "openrouter"}], "default": {"openrouter": "openai/gpt-5"}} + + with patch.object(ui_server, "_opencode_json_request", side_effect=_only_provider_defaults): + self.assertEqual( + ui_server._opencode_resolve_model("http://fake", None), + {"providerID": "openrouter", "modelID": "openai/gpt-5"}, + ) + + # Nothing reachable -- no model, and OpenCode decides per check-in. + with patch.object(ui_server, "_opencode_json_request", side_effect=OSError("down")): + self.assertIsNone(ui_server._opencode_resolve_model("http://fake", None)) + + def test_rejects_an_empty_prompt(self): + result = self.registry.start(" ", 120, "/tmp", None) + self.assertFalse(result["ok"]) + self.assertIn("prompt", result["error"]) + self.assertEqual(self.registry.list(), []) + + def test_rejects_an_interval_below_the_minimum(self): + result = self.registry.start("watch training", 10, "/tmp", None) + self.assertFalse(result["ok"]) + self.assertIn("Minimum", result["error"]) + + def test_surfaces_an_ensure_failure_without_starting_a_job(self): + self._ensure_patch.stop() + try: + with patch.object(ui_server._opencode_session, "ensure", + return_value={"ok": False, "error": "no opencode binary"}): + result = self.registry.start("watch training", 120, "/tmp", None) + finally: + self._ensure_patch.start() + + self.assertFalse(result["ok"]) + self.assertEqual(result["error"], "no opencode binary") + self.assertEqual(self.registry.list(), []) + + def test_first_tick_creates_a_session_seeded_with_the_system_preamble(self): + # Model resolution goes through _opencode_json_request too (it reads + # OpenCode's config) -- stubbed out so create_mock below is only ever + # the session-creation call this test is actually about. + with patch.object(ui_server, "_opencode_json_request", return_value={"id": "ses_abc"}) as create_mock, \ + patch.object(ui_server, "_opencode_resolve_model", return_value=None), \ + patch.object(ui_server, "_opencode_send_and_collect", return_value=("all good", None)) as send_mock: + result = self.registry.start("watch the loss", 120, "/tmp", "http://localhost:5173") + self.assertTrue(result["ok"], result) + job = self._wait_for_first_tick(result["id"]) + + self.assertEqual(job["lastResult"], "all good") + self.assertIsNone(job["lastError"]) + create_mock.assert_called_once() + self.assertEqual(create_mock.call_args.args[1], "/session") + sent_text = send_mock.call_args.args[2] + self.assertIn("watch the loss", sent_text) + self.assertIn("pause / resume", sent_text) # system preamble documents the CLI verbs + # A detached relaunch has no OS-level tie to this workspace, so the + # preamble must tell the model to register it -- with THIS job's own + # origin, not a placeholder -- for Ctrl+C-on-the-workspace cleanup. + self.assertIn("http://localhost:5173/agent-server/track-process", sent_text) + self.assertIn("-PassThru", sent_text) + + def test_second_tick_reuses_the_session_and_sends_the_bare_prompt(self): + with patch.object(ui_server, "_LOOP_MIN_INTERVAL_SECONDS", 0.01), \ + patch.object(ui_server, "_opencode_json_request", return_value={"id": "ses_abc"}) as create_mock, \ + patch.object(ui_server, "_opencode_resolve_model", return_value=None), \ + patch.object(ui_server, "_opencode_send_and_collect", return_value=("ok", None)) as send_mock: + result = self.registry.start("watch the loss", 0.02, "/tmp", None) + self._wait_for_first_tick(result["id"]) + + deadline = time.monotonic() + 2 + while send_mock.call_count < 2 and time.monotonic() < deadline: + time.sleep(0.01) + self.registry.stop(result["id"]) + + self.assertGreaterEqual(send_mock.call_count, 2) + create_mock.assert_called_once() # session created once, reused on tick 2 + second_call_text = send_mock.call_args_list[1].args[2] + self.assertEqual(second_call_text, "watch the loss") # no preamble wrapper on repeat ticks + + def test_records_last_error_and_does_not_set_last_result_on_a_failed_tick(self): + with patch.object(ui_server, "_opencode_json_request", return_value={"id": "ses_abc"}), \ + patch.object(ui_server, "_opencode_send_and_collect", side_effect=RuntimeError("boom")): + result = self.registry.start("watch training", 120, "/tmp", None) + job = self._wait_for_first_tick(result["id"]) + + self.assertEqual(job["lastError"], "boom") + self.assertIsNone(job["lastResult"]) + + def test_records_the_reported_error_when_a_tick_ends_without_raising(self): + """A turn can end via session.error having produced no text at all -- + a provider rejecting the request outright (e.g. a model with no + tool-use endpoints, against a prompt that hands the agent a full + toolset). Nothing raises, so this used to leave lastError None and + lastResult "": the loop's tab showed the check-in prompt with silence + under it, every interval, with nothing anywhere saying why.""" + reported = 'No endpoints found that support tool use.' + with patch.object(ui_server, "_opencode_json_request", return_value={"id": "ses_abc"}), \ + patch.object(ui_server, "_opencode_send_and_collect", return_value=("", reported)): + result = self.registry.start("watch training", 120, "/tmp", None) + job = self._wait_for_first_tick(result["id"]) + + self.assertEqual(job["lastError"], reported) + + def test_explains_an_empty_reply_that_reported_no_error_at_all(self): + with patch.object(ui_server, "_opencode_json_request", return_value={"id": "ses_abc"}), \ + patch.object(ui_server, "_opencode_send_and_collect", return_value=(" ", None)): + result = self.registry.start("watch training", 120, "/tmp", None) + job = self._wait_for_first_tick(result["id"]) + + self.assertIsNotNone(job["lastError"]) + self.assertIn("no reply", job["lastError"]) + + def test_passes_the_requested_model_through_to_every_check_in(self): + model = {"providerID": "openrouter", "modelID": "anthropic/claude-haiku-4.5"} + with patch.object(ui_server, "_opencode_json_request", return_value={"id": "ses_abc"}), \ + patch.object(ui_server, "_opencode_resolve_model", side_effect=lambda _u, m: m), \ + patch.object(ui_server, "_opencode_send_and_collect", return_value=("ok", None)) as send_mock: + result = self.registry.start("watch training", 120, "/tmp", None, model) + job = self._wait_for_first_tick(result["id"]) + + self.assertEqual(send_mock.call_args.args[3], model) + self.assertEqual(job["model"], "openrouter/anthropic/claude-haiku-4.5") + + def test_resolves_a_model_from_opencode_config_when_the_caller_offers_none(self): + """A loop started from a surface with no model picker of its own (or + with none chosen yet) still gets a concrete model: whatever the chat's + picker last wrote into opencode.json. Resolved ONCE at start and + pinned, so a model changed in the chat afterwards doesn't silently + change what an already-running job has been reporting.""" + resolved = {"providerID": "openrouter", "modelID": "anthropic/claude-haiku-4.5"} + with patch.object(ui_server, "_opencode_json_request", return_value={"id": "ses_abc"}), \ + patch.object(ui_server, "_opencode_resolve_model", return_value=resolved) as resolve_mock, \ + patch.object(ui_server, "_opencode_send_and_collect", return_value=("ok", None)) as send_mock: + result = self.registry.start("watch training", 120, "/tmp", None, None) + job = self._wait_for_first_tick(result["id"]) + + resolve_mock.assert_called_once() + self.assertIsNone(resolve_mock.call_args.args[1]) # nothing explicit to prefer + self.assertEqual(send_mock.call_args.args[3], resolved) + self.assertEqual(job["model"], "openrouter/anthropic/claude-haiku-4.5") + + def test_a_job_reports_running_only_while_a_check_in_is_in_flight(self): + """The tab has no other way to tell "the agent is working on this + right now" from "nothing is happening": next_run_at deliberately only + moves once a run FINISHES, so during one it still holds the previous + run's already-elapsed value.""" + started = threading.Event() + release = threading.Event() + + def _slow(*_args, **_kwargs): + started.set() + release.wait(timeout=5) + return ("done", None) + + with patch.object(ui_server, "_opencode_json_request", return_value={"id": "ses_abc"}), \ + patch.object(ui_server, "_opencode_send_and_collect", side_effect=_slow): + result = self.registry.start("watch training", 120, "/tmp", None) + job_id = result["id"] + self.assertTrue(started.wait(timeout=5)) + + in_flight = next(j for j in self.registry.list() if j["id"] == job_id) + self.assertTrue(in_flight["running"]) + # ...and the countdown has not been moved yet, which is exactly + # why `running` has to be reported separately. + self.assertIsNone(in_flight["nextRunAt"]) + + release.set() + job = self._wait_for_first_tick(job_id) + + self.assertFalse(job["running"]) + self.assertIsNotNone(job["nextRunAt"]) + + def test_stop_removes_the_job_and_prevents_a_further_tick(self): + with patch.object(ui_server, "_opencode_json_request", return_value={"id": "ses_abc"}), \ + patch.object(ui_server, "_opencode_send_and_collect", return_value=("ok", None)) as send_mock: + result = self.registry.start("watch training", 120, "/tmp", None) + self._wait_for_first_tick(result["id"]) + + stop_result = self.registry.stop(result["id"]) + self.assertTrue(stop_result["ok"]) + self.assertEqual(self.registry.list(), []) + + calls_at_stop = send_mock.call_count + time.sleep(0.1) + self.assertEqual(send_mock.call_count, calls_at_stop) # no tick after stop + + def test_stopping_an_unknown_job_id_reports_not_found(self): + result = self.registry.stop("does-not-exist") + self.assertFalse(result["ok"]) + + def test_list_reflects_multiple_concurrent_jobs(self): + with patch.object(ui_server, "_opencode_json_request", return_value={"id": "ses_abc"}), \ + patch.object(ui_server, "_opencode_send_and_collect", return_value=("ok", None)): + first = self.registry.start("watch a", 120, "/tmp", None) + second = self.registry.start("watch b", 120, "/tmp", None) + self._wait_for_first_tick(first["id"]) + self._wait_for_first_tick(second["id"]) + + ids = {j["id"] for j in self.registry.list()} + self.assertEqual(ids, {first["id"], second["id"]}) + + def test_shutdown_stops_every_job(self): + with patch.object(ui_server, "_opencode_json_request", return_value={"id": "ses_abc"}), \ + patch.object(ui_server, "_opencode_send_and_collect", return_value=("ok", None)): + result = self.registry.start("watch training", 120, "/tmp", None) + self._wait_for_first_tick(result["id"]) + + self.registry.shutdown() + self.assertEqual(self.registry.list(), []) + + def test_rejects_a_fourth_concurrent_job(self): + with patch.object(ui_server, "_opencode_json_request", return_value={"id": "ses_abc"}), \ + patch.object(ui_server, "_opencode_send_and_collect", return_value=("ok", None)): + for i in range(ui_server._LOOP_MAX_CONCURRENT): + result = self.registry.start(f"watch {i}", 120, "/tmp", None) + self.assertTrue(result["ok"], result) + + fourth = self.registry.start("one too many", 120, "/tmp", None) + + self.assertFalse(fourth["ok"]) + self.assertIn("already running", fourth["error"]) + self.assertEqual(len(self.registry.list()), ui_server._LOOP_MAX_CONCURRENT) + + def test_update_changes_the_prompt_without_touching_the_interval(self): + with patch.object(ui_server, "_opencode_json_request", return_value={"id": "ses_abc"}), \ + patch.object(ui_server, "_opencode_send_and_collect", return_value=("ok", None)): + result = self.registry.start("watch training", 120, "/tmp", None) + self._wait_for_first_tick(result["id"]) + + update_result = self.registry.update(result["id"], prompt="watch training more closely") + + self.assertTrue(update_result["ok"], update_result) + job = {j["id"]: j for j in self.registry.list()}[result["id"]] + self.assertEqual(job["prompt"], "watch training more closely") + self.assertEqual(job["intervalSeconds"], 120) + + def test_update_changes_the_interval_and_reschedules_immediately(self): + with patch.object(ui_server, "_opencode_json_request", return_value={"id": "ses_abc"}), \ + patch.object(ui_server, "_opencode_send_and_collect", return_value=("ok", None)) as send_mock: + result = self.registry.start("watch training", 120, "/tmp", None) + self._wait_for_first_tick(result["id"]) + + with patch.object(ui_server, "_LOOP_MIN_INTERVAL_SECONDS", 0.01): + update_result = self.registry.update(result["id"], interval_seconds=0.02) + self.assertTrue(update_result["ok"], update_result) + self.assertEqual(update_result["intervalSeconds"], 0.02) + + # If the reschedule took effect immediately, a second tick lands + # almost at once rather than after the original 120s interval. + deadline = time.monotonic() + 2 + while send_mock.call_count < 2 and time.monotonic() < deadline: + time.sleep(0.01) + self.registry.stop(result["id"]) + + self.assertGreaterEqual(send_mock.call_count, 2) + + def test_update_on_an_unknown_job_reports_not_found(self): + result = self.registry.update("does-not-exist", prompt="anything") + self.assertFalse(result["ok"]) + self.assertIn("No loop job", result["error"]) + + def test_update_rejects_an_empty_prompt(self): + with patch.object(ui_server, "_opencode_json_request", return_value={"id": "ses_abc"}), \ + patch.object(ui_server, "_opencode_send_and_collect", return_value=("ok", None)): + result = self.registry.start("watch training", 120, "/tmp", None) + self._wait_for_first_tick(result["id"]) + + update_result = self.registry.update(result["id"], prompt=" ") + + self.assertFalse(update_result["ok"]) + self.assertIn("prompt", update_result["error"]) + + def test_update_rejects_an_interval_below_the_minimum(self): + with patch.object(ui_server, "_opencode_json_request", return_value={"id": "ses_abc"}), \ + patch.object(ui_server, "_opencode_send_and_collect", return_value=("ok", None)): + result = self.registry.start("watch training", 120, "/tmp", None) + self._wait_for_first_tick(result["id"]) + + update_result = self.registry.update(result["id"], interval_seconds=10) + + self.assertFalse(update_result["ok"]) + self.assertIn("Minimum", update_result["error"]) + + +class _LoopServerTestCase(unittest.TestCase): + """Spins up a real serve_ui() on 127.0.0.1:, same shape as + test_server_agent.py's _ServerTestCase -- reused rather than imported + since that one is a module-private helper of its own file.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.httpd = ui_server.serve_ui( + ui_host="127.0.0.1", ui_port=0, + backend_host="localhost", backend_port=50051, + open_browser=False, block=False, + experiment_dir=self.tmp, + ) + self.port = self.httpd.server_address[1] + self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) + self.thread.start() + time.sleep(0.1) + + self._orig_registry = ui_server._loop_registry + ui_server._loop_registry = ui_server._LoopRegistry() + + def tearDown(self): + ui_server._loop_registry.shutdown() + ui_server._loop_registry = self._orig_registry + self.httpd.shutdown() + self.thread.join(timeout=5) + + def _get(self, path): + return urllib.request.urlopen(f"http://127.0.0.1:{self.port}{path}", timeout=5) + + def _post_json(self, path, body): + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request( + f"http://127.0.0.1:{self.port}{path}", method="POST", data=data, + headers={"Content-Type": "application/json"}, + ) + return urllib.request.urlopen(req, timeout=10) + + def _post(self, path): + req = urllib.request.Request(f"http://127.0.0.1:{self.port}{path}", method="POST", data=b"") + return urllib.request.urlopen(req, timeout=10) + + +class TestLoopEndpoints(_LoopServerTestCase): + + def test_list_is_empty_before_anything_starts(self): + with self._get("/agent-server/loop/list") as r: + data = json.loads(r.read().decode()) + self.assertEqual(data["loops"], []) + + def test_start_delegates_to_the_registry_and_the_new_job_shows_up_in_list(self): + with patch.object(ui_server._loop_registry, "start", + return_value={"ok": True, "id": "1", "intervalSeconds": 1800.0}) as start_mock: + with self._post_json("/agent-server/loop/start", {"prompt": "watch the loss", "intervalMinutes": 30}) as r: + data = json.loads(r.read().decode()) + self.assertTrue(data["ok"], data) + self.assertEqual(data["id"], "1") + start_mock.assert_called_once() + args = start_mock.call_args.args + self.assertEqual(args[0], "watch the loss") + self.assertEqual(args[1], 30 * 60.0) + self.assertEqual(args[2], self.tmp) # rooted at the experiment dir + + def test_start_with_an_empty_prompt_returns_a_400(self): + import urllib.error + with self.assertRaises(urllib.error.HTTPError) as ctx: + self._post_json("/agent-server/loop/start", {"prompt": "", "intervalMinutes": 30}) + self.assertEqual(ctx.exception.code, 400) + data = json.loads(ctx.exception.read().decode()) + self.assertFalse(data["ok"]) + + def test_stop_delegates_to_the_registry_with_the_id_parsed_out_of_the_path(self): + with patch.object(ui_server._loop_registry, "stop", return_value={"ok": True}) as stop_mock: + with self._post("/agent-server/loop/42/stop") as r: + data = json.loads(r.read().decode()) + self.assertTrue(data["ok"]) + stop_mock.assert_called_once_with("42") + + def test_stopping_an_unknown_job_returns_a_404(self): + import urllib.error + with self.assertRaises(urllib.error.HTTPError) as ctx: + self._post("/agent-server/loop/does-not-exist/stop") + self.assertEqual(ctx.exception.code, 404) + + def test_update_delegates_to_the_registry_with_the_id_parsed_out_of_the_path(self): + with patch.object(ui_server._loop_registry, "update", + return_value={"ok": True, "id": "42", "prompt": "new prompt", "intervalSeconds": 300.0}) as update_mock: + with self._post_json("/agent-server/loop/42/update", {"prompt": "new prompt", "intervalMinutes": 5}) as r: + data = json.loads(r.read().decode()) + self.assertTrue(data["ok"], data) + update_mock.assert_called_once_with("42", prompt="new prompt", interval_seconds=5 * 60.0) + + def test_updating_an_unknown_job_returns_a_400(self): + import urllib.error + with self.assertRaises(urllib.error.HTTPError) as ctx: + self._post_json("/agent-server/loop/does-not-exist/update", {"prompt": "x"}) + self.assertEqual(ctx.exception.code, 400) + data = json.loads(ctx.exception.read().decode()) + self.assertFalse(data["ok"]) + + def test_list_reflects_a_real_start_end_to_end_through_the_registry(self): + with patch.object(ui_server._opencode_session, "ensure", + return_value={"ok": True, "url": "http://127.0.0.1:1", "workspace": self.tmp, "reused": False}), \ + patch.object(ui_server, "_opencode_json_request", return_value={"id": "ses_abc"}), \ + patch.object(ui_server, "_opencode_send_and_collect", return_value=("all quiet", None)): + with self._post_json("/agent-server/loop/start", {"prompt": "watch training", "intervalMinutes": 30}) as r: + started = json.loads(r.read().decode()) + self.assertTrue(started["ok"], started) + + deadline = time.monotonic() + 2 + job = None + while time.monotonic() < deadline: + with self._get("/agent-server/loop/list") as r: + loops = json.loads(r.read().decode())["loops"] + job = next((j for j in loops if j["id"] == started["id"]), None) + if job and job["lastResult"]: + break + time.sleep(0.02) + + self.assertIsNotNone(job) + self.assertEqual(job["lastResult"], "all quiet") + self.assertEqual(job["prompt"], "watch training") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/ui/test_server_opencode_wire.py b/tests/ui/test_server_opencode_wire.py new file mode 100644 index 00000000..060a1db1 --- /dev/null +++ b/tests/ui/test_server_opencode_wire.py @@ -0,0 +1,361 @@ +"""The OpenCode wire layer in ui/server.py, against a real fake server. + +Every other loop/agent test in this directory patches +``_opencode_json_request``/``_opencode_send_and_collect`` out -- correct for +testing ``_LoopRegistry``'s scheduling, but it means the protocol code itself +(SSE framing, stream-before-send ordering, which events count, error vs idle +termination, text assembly) has never actually run under test. This file runs +it, over real HTTP, against ``fake_opencode.FakeOpencode``. + +No network, no credentials, no model calls: the fake binds 127.0.0.1:0 and +every reply is scripted, so these are deterministic and fast. +""" + +import threading +import time +import unittest +from unittest import mock + +from weightslab.ui import server as ui_server + +from tests.ui.fake_opencode import ( + FakeOpencode, + assistant_message, + session_error, + session_idle, + text_part, + tool_part, + user_message, +) + +SES = "ses_1" + + +class _FakeServerTestCase(unittest.TestCase): + def setUp(self): + self.server = FakeOpencode().start() + self.addCleanup(self.server.stop) + + def collect(self, timeout=10.0, session=SES, text="check in", model=None): + return ui_server._opencode_send_and_collect( + self.server.base_url, session, text, model=model, timeout=timeout, + ) + + +class TestPlainJsonRoutes(_FakeServerTestCase): + def test_get_round_trip_parses_json(self): + self.server.responses["GET /config"] = {"model": "openrouter/anthropic/claude-opus-4.6"} + result = ui_server._opencode_json_request(self.server.base_url, "/config") + self.assertEqual(result["model"], "openrouter/anthropic/claude-opus-4.6") + + def test_post_sends_a_json_body_and_content_type(self): + ui_server._opencode_json_request( + self.server.base_url, "/session", method="POST", body={"title": "loop"}, + ) + method, path, body = self.server.requests[-1] + self.assertEqual((method, path), ("POST", "/session")) + self.assertEqual(body, {"title": "loop"}) + + def test_a_non_2xx_status_raises_rather_than_returning_a_falsy_value(self): + # urllib raises HTTPError for 4xx/5xx; callers rely on that (the loop's + # own start() catches it to report "could not reach OpenCode"). + self.server.post_status = 500 + with self.assertRaises(Exception): + ui_server._opencode_json_request( + self.server.base_url, f"/session/{SES}/message", method="POST", body={"parts": []}, + ) + + def test_get_messages_passes_the_list_straight_through(self): + self.server.messages = [{"info": {"id": "m1", "role": "assistant"}, "parts": []}] + result = ui_server._opencode_get_messages(self.server.base_url, SES) + self.assertEqual(result, self.server.messages) + + +class TestSendAndCollectHappyPath(_FakeServerTestCase): + def test_assembles_the_reply_text_and_reports_no_error(self): + self.server.script = [ + assistant_message("m1", SES), + text_part("p1", "m1", SES, "The run looks healthy."), + session_idle(SES), + ] + text, error = self.collect() + self.assertEqual(text, "The run looks healthy.") + self.assertIsNone(error) + + def test_the_stream_is_opened_BEFORE_the_prompt_is_sent(self): + # The fake only releases its scripted events when the POST arrives, so + # collecting any text at all proves the subscription was already live. + # Sending first and subscribing after would lose the whole turn. + self.server.script = [ + assistant_message("m1", SES), + text_part("p1", "m1", SES, "caught it"), + session_idle(SES), + ] + text, _ = self.collect() + self.assertEqual(text, "caught it") + self.assertEqual([r[1] for r in self.server.requests if r[0] == "GET"], ["/event"]) + + def test_a_later_delta_for_the_same_part_replaces_it_instead_of_appending(self): + self.server.script = [ + assistant_message("m1", SES), + text_part("p1", "m1", SES, "Loss is "), + text_part("p1", "m1", SES, "Loss is 0.31 and falling."), + session_idle(SES), + ] + text, error = self.collect() + self.assertEqual(text, "Loss is 0.31 and falling.") + self.assertIsNone(error) + + def test_several_parts_are_joined_in_arrival_order(self): + self.server.script = [ + assistant_message("m1", SES), + text_part("p1", "m1", SES, "First. "), + text_part("p2", "m1", SES, "Second. "), + text_part("p3", "m1", SES, "Third."), + session_idle(SES), + ] + text, _ = self.collect() + self.assertEqual(text, "First. Second. Third.") + + def test_keep_alive_comments_are_skipped_not_parsed(self): + self.server.send_keepalive = True + self.server.script = [ + assistant_message("m1", SES), + text_part("p1", "m1", SES, "fine"), + session_idle(SES), + ] + text, error = self.collect() + self.assertEqual(text, "fine") + self.assertIsNone(error) + + def test_the_selected_model_is_forwarded_on_the_prompt(self): + self.server.script = [session_idle(SES)] + model = {"providerID": "openrouter", "modelID": "anthropic/claude-opus-4.6"} + self.collect(model=model) + self.assertEqual(self.server.prompt_bodies[0]["model"], model) + self.assertEqual(self.server.prompt_bodies[0]["parts"][0]["text"], "check in") + + def test_no_model_key_is_sent_when_none_was_resolved(self): + self.server.script = [session_idle(SES)] + self.collect(model=None) + self.assertNotIn("model", self.server.prompt_bodies[0]) + + def test_a_clean_turn_that_said_nothing_returns_empty_text_and_no_error(self): + # Distinct from a failure: the loop reports "no reply" for this, which + # is only correct because `error` is None here. + self.server.script = [assistant_message("m1", SES), session_idle(SES)] + text, error = self.collect() + self.assertEqual(text, "") + self.assertIsNone(error) + + +class TestSendAndCollectFiltering(_FakeServerTestCase): + """Which events count toward the reply, and which must be ignored.""" + + def test_ignores_text_from_a_different_session(self): + self.server.script = [ + assistant_message("m_other", "ses_stranger"), + text_part("p1", "m_other", "ses_stranger", "not for this loop"), + assistant_message("m1", SES), + text_part("p2", "m1", SES, "mine"), + session_idle(SES), + ] + text, _ = self.collect() + self.assertEqual(text, "mine") + + def test_ignores_the_user_side_of_the_conversation(self): + self.server.script = [ + user_message("m_user", SES), + text_part("p1", "m_user", SES, "the prompt echoed back"), + assistant_message("m1", SES), + text_part("p2", "m1", SES, "the answer"), + session_idle(SES), + ] + text, _ = self.collect() + self.assertEqual(text, "the answer") + + def test_ignores_non_text_parts_such_as_tool_calls(self): + self.server.script = [ + assistant_message("m1", SES), + tool_part("p1", "m1", SES, tool="bash"), + text_part("p2", "m1", SES, "ran it"), + session_idle(SES), + ] + text, _ = self.collect() + self.assertEqual(text, "ran it") + + def test_a_part_arriving_BEFORE_its_message_announcement_is_dropped(self): + # Documents a real asymmetry with the browser client, which queues such + # parts and replays them once the message id is known (agentChat.ts's + # pendingParts). This reader has no queue: the id gate is checked once, + # so an out-of-order part is lost. Harmless in practice -- OpenCode + # announces the message first -- but worth pinning so the difference is + # a decision rather than a surprise. + self.server.script = [ + text_part("p1", "m1", SES, "early"), + assistant_message("m1", SES), + text_part("p2", "m1", SES, "late"), + session_idle(SES), + ] + text, _ = self.collect() + self.assertEqual(text, "late") + + def test_a_malformed_event_payload_does_not_abort_the_turn(self): + self.server.script = [ + {"not": "a valid event"}, # no `type` + assistant_message("m1", SES), + text_part("p1", "m1", SES, "survived"), + session_idle(SES), + ] + text, error = self.collect() + self.assertEqual(text, "survived") + self.assertIsNone(error) + + def test_an_idle_for_a_DIFFERENT_session_does_not_end_this_turn(self): + self.server.script = [ + assistant_message("m1", SES), + session_idle("ses_stranger"), + text_part("p1", "m1", SES, "still streaming after the other idle"), + session_idle(SES), + ] + text, _ = self.collect() + self.assertEqual(text, "still streaming after the other idle") + + +class TestSendAndCollectFailures(_FakeServerTestCase): + def test_a_session_error_with_no_text_is_reported_as_an_error_not_silence(self): + # The exact bug the (text, error) tuple exists for: a provider + # rejecting the request produced an empty string that looked + # identical to "the agent had nothing to say", so the job recorded no + # error and the tab showed silence under the prompt every interval. + self.server.script = [ + session_error(SES, "ProviderAuthError", "OpenRouter rejected the API key"), + ] + text, error = self.collect() + self.assertEqual(text, "") + self.assertEqual(error, "OpenRouter rejected the API key") + + def test_keeps_whatever_text_streamed_in_before_the_error(self): + self.server.script = [ + assistant_message("m1", SES), + text_part("p1", "m1", SES, "Started looking, then "), + session_error(SES, "APIError", "upstream 502"), + ] + text, error = self.collect() + self.assertEqual(text, "Started looking, then ") + self.assertEqual(error, "upstream 502") + + def test_falls_back_to_the_error_name_when_no_message_is_carried(self): + self.server.script = [session_error(SES, "ContextOverflowError")] + _text, error = self.collect() + self.assertEqual(error, "ContextOverflowError") + + def test_an_error_for_a_different_session_is_ignored(self): + self.server.script = [ + session_error("ses_stranger", "ProviderAuthError", "not mine"), + assistant_message("m1", SES), + text_part("p1", "m1", SES, "unaffected"), + session_idle(SES), + ] + text, error = self.collect() + self.assertEqual(text, "unaffected") + self.assertIsNone(error) + + def test_a_rejected_prompt_with_no_text_collected_raises(self): + # The loop's _fire catches this and records it as job.last_error, which + # is what makes a failing check-in visible in its tab. + self.server.post_status = 500 + self.server.script = [] + with self.assertRaises(Exception): + self.collect(timeout=5.0) + + def test_the_stream_closing_early_degrades_to_the_text_collected_so_far(self): + # A server restart mid-turn: EOF on the stream rather than an idle. + self.server.script = [ + assistant_message("m1", SES), + text_part("p1", "m1", SES, "partial answer"), + ] + stop = threading.Timer(0.3, self.server.end_stream) + stop.start() + self.addCleanup(stop.cancel) + text, error = self.collect(timeout=5.0) + self.assertEqual(text, "partial answer") + self.assertIsNone(error) + + +class TestLoopEndToEndOverTheWire(unittest.TestCase): + """_LoopRegistry driving a real (fake) server with NOTHING patched out -- + session creation, the check-in, and the transcript read all go over HTTP.""" + + def setUp(self): + self.server = FakeOpencode().start() + self.addCleanup(self.server.stop) + # The registry normally spawns/reuses an `opencode serve` child; point + # it straight at the fake instead. This is the one thing still stubbed, + # and deliberately: process spawning is covered by test_opencode_process.py. + patcher = mock.patch.object( + ui_server._opencode_session, "ensure", + return_value={"ok": True, "url": self.server.base_url}, + ) + patcher.start() + self.addCleanup(patcher.stop) + self.registry = ui_server._LoopRegistry() + self.addCleanup(self.registry.shutdown) + + def test_a_started_loop_creates_a_session_and_records_its_first_check_in(self): + self.server.responses["POST /session"] = {"id": "ses_loop"} + self.server.script = [ + assistant_message("m1", "ses_loop"), + text_part("p1", "m1", "ses_loop", "Training is progressing; loss 0.42."), + session_idle("ses_loop"), + ] + + result = self.registry.start("watch the loss", 60.0, "/tmp/ws", origin="http://localhost:8080") + self.assertTrue(result.get("ok"), result) + job_id = str(result["id"]) + + job = self.registry._jobs[job_id] + deadline = time.monotonic() + 10.0 + while job.last_result is None and job.last_error is None and time.monotonic() < deadline: + time.sleep(0.05) + + self.assertIsNone(job.last_error) + self.assertIn("loss 0.42", job.last_result or "") + # The prompt that went out carries the monitoring preamble AND the task. + sent = self.server.prompt_bodies[0]["parts"][0]["text"] + self.assertIn("watch the loss", sent) + self.assertIn("weightslab cli", sent) + self.assertIn("NEVER stop/kill a process you did not yourself start", sent) + + def test_a_failing_check_in_is_recorded_as_the_jobs_error(self): + self.server.responses["POST /session"] = {"id": "ses_loop"} + self.server.script = [session_error("ses_loop", "ProviderAuthError", "no credentials")] + + result = self.registry.start("watch it", 60.0, "/tmp/ws", None) + job = self.registry._jobs[str(result["id"])] + deadline = time.monotonic() + 10.0 + while job.last_error is None and time.monotonic() < deadline: + time.sleep(0.05) + + self.assertEqual(job.last_error, "no credentials") + + def test_the_tabs_transcript_is_read_from_the_servers_own_message_list(self): + self.server.responses["POST /session"] = {"id": "ses_loop"} + self.server.script = [session_idle("ses_loop")] + self.server.messages = [ + {"info": {"id": "m1", "role": "assistant"}, "parts": [{"type": "text", "text": "all good"}]}, + ] + result = self.registry.start("watch it", 60.0, "/tmp/ws", None) + job_id = str(result["id"]) + deadline = time.monotonic() + 10.0 + job = self.registry._jobs[job_id] + while job.session_id is None and time.monotonic() < deadline: + time.sleep(0.05) + + messages = self.registry.get_messages(job_id) + self.assertTrue(messages.get("ok"), messages) + self.assertEqual(messages["messages"], self.server.messages) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/ui/test_server_shutdown_signals.py b/tests/ui/test_server_shutdown_signals.py new file mode 100644 index 00000000..348d410e --- /dev/null +++ b/tests/ui/test_server_shutdown_signals.py @@ -0,0 +1,151 @@ +"""Tests for weightslab/ui/server.py's termination-handler coverage. + +Problem: `weightslab start` only ever caught Ctrl+C (SIGINT, which Python +turns into a catchable KeyboardInterrupt by default). Closing the terminal +window or a bare `kill ` delivers a DIFFERENT signal/event +(SIGTERM/SIGHUP on POSIX, CTRL_CLOSE_EVENT on Windows) that Python does NOT +convert into a Python-level exception on its own -- so neither of those +ever ran this server's cleanup (_run_shutdown_cleanup: stopping tracked +detached processes, its own OpenCode/Jupyter children, /loop jobs), +confirmed live against real platform behavior. _install_termination_handlers +closes that gap. +""" + +import os +import signal +import threading +import time +import unittest +from unittest.mock import patch + +from weightslab.ui import server as ui_server + + +class TestRunShutdownCleanup(unittest.TestCase): + def test_calls_all_four_shutdown_methods(self): + with patch.object(ui_server._tracked_processes, "shutdown") as tp, \ + patch.object(ui_server._opencode_session, "shutdown") as oc, \ + patch.object(ui_server._loop_registry, "shutdown") as lr, \ + patch.object(ui_server._jupyter_session, "shutdown") as js: + ui_server._run_shutdown_cleanup() + tp.assert_called_once() + oc.assert_called_once() + lr.assert_called_once() + js.assert_called_once() + + +class TestRaiseKeyboardInterrupt(unittest.TestCase): + def test_raises_keyboardinterrupt(self): + # Exactly how Python's own signal machinery would invoke this: a + # (signum, frame) callback. Called directly, not via a real signal, + # so this passes identically on every platform. + with self.assertRaises(KeyboardInterrupt): + ui_server._raise_keyboard_interrupt(signal.SIGTERM, None) + + +class TestOnWindowsCtrlEvent(unittest.TestCase): + """Pure logic, no ctypes/real Windows API involved -- safe to run on + any platform, unlike _install_windows_console_handler itself below.""" + + def test_terminating_events_run_cleanup_and_report_handled(self): + for ctrl_type in (2, 5, 6): # CLOSE, LOGOFF, SHUTDOWN + with patch.object(ui_server, "_run_shutdown_cleanup") as cleanup_mock: + result = ui_server._on_windows_ctrl_event(ctrl_type) + cleanup_mock.assert_called_once() + self.assertTrue(result) + + def test_ctrl_c_and_ctrl_break_are_left_alone(self): + # Python's own signal module already turns these into SIGINT/ + # SIGBREAK -- this handler must not double-handle them. + for ctrl_type in (0, 1): + with patch.object(ui_server, "_run_shutdown_cleanup") as cleanup_mock: + result = ui_server._on_windows_ctrl_event(ctrl_type) + cleanup_mock.assert_not_called() + self.assertFalse(result) + + def test_unknown_event_is_left_alone(self): + with patch.object(ui_server, "_run_shutdown_cleanup") as cleanup_mock: + result = ui_server._on_windows_ctrl_event(99) + cleanup_mock.assert_not_called() + self.assertFalse(result) + + +@unittest.skipUnless(os.name == "nt", "SetConsoleCtrlHandler only exists on Windows") +class TestInstallWindowsConsoleHandler(unittest.TestCase): + def test_registers_a_real_handler_without_raising(self): + ui_server._install_windows_console_handler() + self.assertIsNotNone(ui_server._console_ctrl_handler_ref) + + +class TestInstallTerminationHandlers(unittest.TestCase): + def setUp(self): + self._orig_sigterm = signal.getsignal(signal.SIGTERM) + self._orig_sighup = signal.getsignal(signal.SIGHUP) if hasattr(signal, "SIGHUP") else None + + def tearDown(self): + signal.signal(signal.SIGTERM, self._orig_sigterm) + if hasattr(signal, "SIGHUP"): + signal.signal(signal.SIGHUP, self._orig_sighup) + + def test_registers_sigterm_to_raise_keyboardinterrupt(self): + ui_server._install_termination_handlers() + self.assertIs(signal.getsignal(signal.SIGTERM), ui_server._raise_keyboard_interrupt) + + @unittest.skipUnless(hasattr(signal, "SIGHUP"), "SIGHUP does not exist on this platform") + def test_registers_sighup_to_raise_keyboardinterrupt(self): + ui_server._install_termination_handlers() + self.assertIs(signal.getsignal(signal.SIGHUP), ui_server._raise_keyboard_interrupt) + + def test_installs_the_windows_console_handler_only_on_windows(self): + with patch.object(ui_server, "_install_windows_console_handler") as install_mock, \ + patch.object(ui_server.os, "name", "nt"): + ui_server._install_termination_handlers() + install_mock.assert_called_once() + + def test_skips_the_windows_console_handler_on_posix(self): + with patch.object(ui_server, "_install_windows_console_handler") as install_mock, \ + patch.object(ui_server.os, "name", "posix"): + ui_server._install_termination_handlers() + install_mock.assert_not_called() + + +@unittest.skipIf( + os.name == "nt", + "os.kill(pid, SIGTERM) maps to TerminateProcess on Windows (a hard kill " + "that bypasses any registered handler), so a self-SIGTERM isn't a safe " + "way to test this there -- the Windows-specific path is covered by " + "TestOnWindowsCtrlEvent/TestInstallWindowsConsoleHandler instead.", +) +class TestServeUiRealSigtermEndToEnd(unittest.TestCase): + """The real thing: an actual SIGTERM delivered to this process while + serve_ui(block=True) is blocking on the MAIN thread (signal handlers + only ever run on the main thread, so this only proves anything when + serve_forever() itself is there too -- exactly how `weightslab start` + really calls it).""" + + def test_sigterm_interrupts_serve_forever_and_runs_cleanup(self): + import tempfile + + tmp = tempfile.mkdtemp() + cleanup_called = threading.Event() + + def _send_sigterm_shortly(): + time.sleep(0.4) + os.kill(os.getpid(), signal.SIGTERM) + + with patch.object(ui_server, "_run_shutdown_cleanup", side_effect=cleanup_called.set): + sender = threading.Thread(target=_send_sigterm_shortly, daemon=True) + sender.start() + ui_server.serve_ui( + ui_host="127.0.0.1", ui_port=0, + backend_host="localhost", backend_port=50051, + open_browser=False, block=True, + experiment_dir=tmp, + ) + sender.join(timeout=5) + + self.assertTrue(cleanup_called.is_set()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/ui/test_server_tracked_processes.py b/tests/ui/test_server_tracked_processes.py new file mode 100644 index 00000000..fcf0fff1 --- /dev/null +++ b/tests/ui/test_server_tracked_processes.py @@ -0,0 +1,235 @@ +"""Tests for weightslab/ui/server.py's POST /agent-server/track-process and +the _TrackedProcesses registry behind it. + +Problem this exists for: the agent is told to launch anything long-running +(training, a relaunched crashed run) DETACHED (Start-Process/setsid) so it +never blocks the chat turn. A detached process has no OS-level parent-child +relationship this server's own process-tree kill (_kill_process_tree) can +walk -- confirmed live: a detached launcher's own immediate shell exits +almost immediately after spawning it, and Windows keeps no record of an +exited process for `taskkill /T` to trace a grandchild through. Registering +the PID directly sidesteps that: this server kills it explicitly, by PID, +with no chain to walk at all. + +Uses a REAL child process (python -c "time.sleep(...)"), not a mock, so the +actual kill call is exercised end to end. +""" + +import json +import subprocess +import sys +import tempfile +import threading +import time +import unittest +import unittest.mock +import urllib.error +import urllib.request + +from weightslab.ui import server as ui_server + + +def _is_alive(pid: int) -> bool: + if ui_server.os.name == "nt": + out = subprocess.run( + ["tasklist", "/FI", f"PID eq {pid}"], + capture_output=True, text=True, + ).stdout + return str(pid) in out + try: + ui_server.os.kill(pid, 0) + return True + except OSError: + return False + + +class TestTrackedProcessesUnit(unittest.TestCase): + def setUp(self): + self.registry = ui_server._TrackedProcesses() + self.proc = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(120)"], + start_new_session=True, + ) + + def tearDown(self): + if self.proc.poll() is None: + self.proc.kill() + self.proc.wait(timeout=5) + + def test_tracked_pid_is_killed_on_shutdown(self): + self.assertTrue(_is_alive(self.proc.pid)) + self.registry.track(self.proc.pid) + + self.registry.shutdown() + + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and _is_alive(self.proc.pid): + time.sleep(0.1) + self.assertFalse(_is_alive(self.proc.pid)) + + def test_untracked_pid_is_left_alone(self): + self.registry.shutdown() # nothing tracked + self.assertTrue(_is_alive(self.proc.pid)) + + def test_shutdown_clears_the_registry_so_a_second_call_is_a_no_op(self): + self.registry.track(self.proc.pid) + self.registry.shutdown() + # A second shutdown() must not error just because the pid is already + # gone (e.g. serve_ui's explicit call racing its own atexit hook). + self.registry.shutdown() + + def test_killing_an_already_dead_pid_does_not_raise(self): + self.proc.kill() + self.proc.wait(timeout=5) + self.registry.track(self.proc.pid) + self.registry.shutdown() # must not raise + + +class _ServerTestCase(unittest.TestCase): + """Real serve_ui() on an ephemeral port -- same shape as + test_server_agent.py's own _ServerTestCase.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.httpd = ui_server.serve_ui( + ui_host="127.0.0.1", ui_port=0, + backend_host="localhost", backend_port=50051, + open_browser=False, block=False, + experiment_dir=self.tmp, + ) + self.port = self.httpd.server_address[1] + self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) + self.thread.start() + time.sleep(0.1) + + self._orig_tracked = ui_server._tracked_processes + ui_server._tracked_processes = ui_server._TrackedProcesses() + + def tearDown(self): + ui_server._tracked_processes = self._orig_tracked + self.httpd.shutdown() + self.thread.join(timeout=5) + + def _post_json(self, path, body): + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request( + f"http://127.0.0.1:{self.port}{path}", method="POST", data=data, + headers={"Content-Type": "application/json"}, + ) + return urllib.request.urlopen(req, timeout=10) + + +class TestTrackProcessEndpoint(_ServerTestCase): + def test_registers_the_pid_end_to_end(self): + proc = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)"], start_new_session=True) + try: + with self._post_json("/agent-server/track-process", {"pid": proc.pid}) as r: + data = json.loads(r.read().decode()) + self.assertTrue(data["ok"]) + + ui_server._tracked_processes.shutdown() + + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and _is_alive(proc.pid): + time.sleep(0.1) + self.assertFalse(_is_alive(proc.pid)) + finally: + if proc.poll() is None: + proc.kill() + proc.wait(timeout=5) + + def test_non_integer_pid_is_rejected(self): + try: + self._post_json("/agent-server/track-process", {"pid": "not-a-number"}) + self.fail("expected an HTTPError") + except urllib.error.HTTPError as exc: + self.assertEqual(exc.code, 400) + data = json.loads(exc.read().decode()) + self.assertFalse(data["ok"]) + self.assertIn("integer", data["error"]) + + def test_missing_pid_is_rejected(self): + try: + self._post_json("/agent-server/track-process", {}) + self.fail("expected an HTTPError") + except urllib.error.HTTPError as exc: + self.assertEqual(exc.code, 400) + + +class TestSelfRegistration(_ServerTestCase): + """The other half of the endpoint above: a training process registering + ITSELF from inside ``wl.serve()``. + + Why this exists: the endpoint alone only helps when someone remembers to + POST to it, and the only thing that ever did was the agent, by hand, + right after a detached launch. That step gets skipped -- the turn is + interrupted between launching and registering, the model forgets on a + relaunch, or the run was started by hand in a terminal no agent was + involved in -- and every miss leaves an orphaned python process holding + the GPU and the gRPC port after the UI is gone. serve() registering + itself makes it unskippable for anything that serves. + """ + + def test_serve_ui_publishes_its_own_origin_for_children_to_find(self): + # The whole chain hangs off this: the OpenCode server is spawned by + # this server, the agent's shell descends from that, and a training + # process it launches inherits the variable from there. + self.assertEqual( + ui_server.os.environ.get("WEIGHTSLAB_UI_ORIGIN"), + f"http://127.0.0.1:{self.port}", + ) + + def test_registers_this_process_against_the_live_server(self): + from weightslab import src as wl_src + + with unittest.mock.patch.dict( + ui_server.os.environ, + {"WEIGHTSLAB_UI_ORIGIN": f"http://127.0.0.1:{self.port}"}, + ): + wl_src._register_pid_with_ui_server() + + # Off the calling thread on purpose (a stale origin must never + # stall a training run), so give the daemon thread a moment. + deadline = time.monotonic() + 5 + own_pid = ui_server.os.getpid() + while time.monotonic() < deadline: + if own_pid in ui_server._tracked_processes._pids: + break + time.sleep(0.05) + + # NOTE: deliberately never calls _tracked_processes.shutdown() here -- + # the pid under test is this very test runner's. _ServerTestCase swaps + # in a throwaway registry and restores the real one in tearDown, so + # nothing else can act on it either. + self.assertIn(own_pid, ui_server._tracked_processes._pids) + + def test_is_a_no_op_when_no_ui_server_owns_this_process(self): + from weightslab import src as wl_src + + env = dict(ui_server.os.environ) + env.pop("WEIGHTSLAB_UI_ORIGIN", None) + with unittest.mock.patch.dict(ui_server.os.environ, env, clear=True): + wl_src._register_pid_with_ui_server() + + time.sleep(0.2) + self.assertEqual(ui_server._tracked_processes._pids, set()) + + def test_an_unreachable_origin_never_raises(self): + """Best-effort is the whole contract: a stale WEIGHTSLAB_UI_ORIGIN + left over from a previous `weightslab start` must cost a training run + nothing at all, not even a raised exception on a background thread.""" + from weightslab import src as wl_src + + with unittest.mock.patch.dict( + ui_server.os.environ, + # Port 1 is reserved and never listening. + {"WEIGHTSLAB_UI_ORIGIN": "http://127.0.0.1:1"}, + ): + wl_src._register_pid_with_ui_server() + + time.sleep(0.3) + self.assertEqual(ui_server._tracked_processes._pids, set()) + + +if __name__ == "__main__": + unittest.main() diff --git a/weightslab/AGENTS.md b/weightslab/AGENTS.md new file mode 100644 index 00000000..283f31c6 --- /dev/null +++ b/weightslab/AGENTS.md @@ -0,0 +1,413 @@ +# WeightsLab — agent context for users & debugging + +Portable context for AI coding agents (and their humans) to **install, run, +integrate, and debug WeightsLab / Weights Studio** without reverse-engineering +the system first. Covers two repos: **weightslab** (Python backend — training +instrumentation, data ledger, gRPC service, shared proto) and **weights_studio** +(browser frontend that inspects/edits a *running* experiment). + +> File/line refs drift — verify against current source. Env var names/defaults +> are stable; authoritative reference is `weightslab/docs/configuration.rst`. + +--- + +## 0. Loading this guide into Claude Code + +- **Repo checkout:** committed as `AGENTS.md`; a gitignored `CLAUDE.md` copy at + the root gets auto-loaded every session. Nothing to do. +- **`pip install weightslab` only** (no checkout): absolute paths are fragile + across venvs/OS. Use a skill that locates the installed copy at runtime — + `~/.claude/skills/weightslab/SKILL.md`: + + ```yaml + --- + name: weightslab + description: Load the WeightsLab debugging & integration guide for weightslab/weights_studio problems (connection, TLS, env vars, training hangs, rendering, wl.* integration). + --- + !`python -c "import weightslab, os; print(open(os.path.join(os.path.dirname(weightslab.__file__), 'AGENTS.md')).read())"` + + Use the guide above to diagnose or implement the user's request. + ``` + + Requires the guide shipped as package data (`weightslab/weightslab/AGENTS.md` — see §7). +- **Quick-and-dirty:** copy this file to `~/.claude/WEIGHTSLAB.md`, `@`-import it + from `~/.claude/CLAUDE.md`. + +--- + +## 1. What it is, how the pieces connect + +A user wraps their PyTorch training script with WeightsLab so a running +experiment becomes inspectable/editable; Weights Studio is the UI. + +``` +Browser → weightslab start :8080 (grpc-web → grpc proxy) → Python gRPC servicer → training loop +``` + +- `weightslab start`: pure-Python HTTP server, serves the bundled SPA and + translates grpc-web↔gRPC. No Docker, no Envoy. Not running ⇒ no UI to load. +- gRPC servicer and training loop share **one process, different threads**, + coordinated by locks in `weightslab/weightslab/components/global_monitoring.py`. +- Proto is the single source of truth: `weightslab/weightslab/proto/experiment_service.proto`. + +--- + +## 2. Install & run + +```bash +pip install weightslab +``` + +```python +import weightslab as wl +# wrap objects so the studio can see/edit them (§3), then: +wl.serve(serving_grpc=True, serving_cli=True) +# ... training loop ... +wl.keep_serving() # keep process alive for the UI +``` + +```bash +weightslab start # http://localhost:8080 by default +``` + +For a new script, pick the closest match in +`weightslab/weightslab/examples/{PyTorch,Lightning,Ultralytics,Usecases}//main.py` +via the decision table in §3.9, then copy its `wl.*` calls — §3 documents that +whole API surface (reactive signals, group signals, the Ultralytics mixin, +etc. aren't in the `.rst` docs; the examples are the primary source). + +TLS/UI deploy details: `weightslab/docs/weights_studio.rst`. TLS is opt-in: +`weightslab se` once, then `weightslab start --certs`. + +--- + +## 3. The integration API (`import weightslab as wl`) + +How to wire a new training script correctly with no docs access — every verb +and kwarg here is real, taken from a shipping example under +`weightslab/weightslab/examples/` and checked against `weightslab/src.py`. + +### 3.1 Lifecycle + +```python +import weightslab as wl + +wl.watch_or_edit(..., flag=...) # register objects (§3.2) +wl.serve(serving_grpc=True, serving_cli=True) # background threads, same process +wl.start_training(timeout=3) # let UI/CLI attach before stepping +# ... training loop, guarded (§3.5) ... +wl.keep_serving() # block so the process/UI survives +``` + +- Register every object with `watch_or_edit` **before** `wl.serve`, using the parameter flag to define which object category it is. +- `timeout=0` skips the pre-start wait entirely. +- Skip `keep_serving()` for a script that should exit after writing a report + (`Usecases/*signals*` examples); include it otherwise. +- Tabular examples (`wl-fraud-detection`, `wl-ads-recommendation`) pass only + `serving_grpc=` to `serve` — no `serving_cli`. + +### 3.2 `wl.watch_or_edit(obj, flag=..., **kwargs)` + +Registers/wraps `obj` in the global ledger (`backend/ledgers.py`, +`GLOBAL_LEDGER`) and returns a live proxy. `flag` matches by substring +(case-insensitive). + +| flag | wraps | key kwargs | +|---|---|---| +| `"hyperparameters"` | plain `dict` | `defaults=parameters`, `poll_interval=1.0`, optional `name=` | +| `"model"` | `nn.Module` | `device=`; `compute_dependencies=False` (skip arch-op dependency graph when not editing architecture); `forced_model_wrapping=True` (Ultralytics only — load current object, not a checkpoint) | +| `"optimizer"` | `torch.optim.Optimizer` | none typically; build from the **watched** model's `.parameters()` | +| `"data"` | `Dataset` → tracked `DataLoader` | `loader_name=`, `batch_size=`, `shuffle=`, `is_training=`, `compute_hash=False`, `collate_fn=`, `preload_labels=`, `preload_metadata=` (both `True` for tabular — §3.4), `enable_h5_persistence=`, `num_workers=`; point-cloud/array data adds `array_autoload_arrays=`, `array_return_proxies=`, `array_use_cache=` | +| `"loss"` | `reduction="none"` criterion | `signal_name=`/`name=` (aliases), `log=True`, `per_sample=True` (one value/sample), `per_instance=True` (one value per `(sample_id, annotation_id)` — multi-box/mask samples); called as `criterion(preds_raw, targets, batch_ids=ids, preds=preds)` | +| `"metric"` | anything with `.compute()`/`.forward()` | same as `"loss"` | + +Registering `flag="loss"` auto-enrolls the signal for the background +loss-shape classifier (§3.6) unless overridden via `@wl.signal_classifier`. + +Objects need a `__name__` — set `obj.__name__ = "..."` manually if missing +(plain callables/custom loss modules). + +Hyperparameter proxy supports both `hp.get("lr")` and `hp["lr"]`, and stays +live (reflects later edits/re-registration). + +**Configuration discipline (REQUIRED).** Before writing any other code, +collect every tunable value for the script — `batch_size`, `learning_rate`, +`num_workers`, step budget, dataset paths, model dims, anything a user might +reasonably want to change — into ONE plain dict (e.g. `CONFIG = {...}`, or a +`parameters` dict loaded from YAML with `.setdefault(...)` calls, as in +`PyTorch/wl-fraud-detection/main.py`). Wrap that dict **first**, via +`hp = wl.watch_or_edit(CONFIG, flag="hyperparameters", defaults=CONFIG)`, +**before** constructing the model, optimizer, or data loaders. Every +downstream construction must then read its value from `hp` +(`hp["batch_size"]`/`hp.get("batch_size", ...)`) — never as a hardcoded +literal passed directly into a constructor, and never from a second, un-wrapped +copy of the same value. A value that isn't sourced from the wrapped dict is +invisible to the UI/agent's hyperparameter tuning (`set_hyperparam`, the HP +panel) — it *looks* configurable but silently can't be changed, because +nothing is reading back from the live proxy. Concretely: `DataLoader(..., +batch_size=4)` with a literal `4` is wrong even if `CONFIG["batch_size"]` +exists elsewhere in the file; it must be `DataLoader(..., batch_size=hp["batch_size"])`. + +**The dict must follow the WeightsLab config shape, not an invented one.** +`set_hyperparam`/`show_config` resolve a handful of semantic names to fixed +dotted paths (`_HP_ALIASES` in `trainer/services/data_service.py`) and try +those paths **in order, using the first one that already exists** — they +never invent a new key. A same-meaning value under a different name (e.g. +`"epochs"` instead of `training_steps_to_do`, `"eval_every_epochs"` instead of +`eval_full_to_train_steps_ratio`) silently falls outside that resolution and +can't be tuned from the UI/agent at all, even though the dict has *a* key for +it. Every generated or rewritten config — regardless of usecase — must use +these exact top-level names and nesting, matching every shipped example +(`PyTorch/wl-classification`, `PyTorch/wl-fraud-detection`, …): + +```python +CONFIG = { + "experiment_name": "...", + "device": "auto", # resolved to cuda/cpu AFTER wrapping, never baked in as a literal + "root_log_dir": None, # or an explicit path; None -> a tempdir is created and logged + "training_steps_to_do": 1_000_000, # WL counts training STEPS (model.get_age()), never "epochs"/an epoch loop + "eval_full_to_train_steps_ratio": 100, # -> agent's "eval ratio" tuning; NOT "eval_every_epochs"/"eval_every_n" + "experiment_dump_to_train_steps_ratio": 100,# -> agent's "dump ratio" tuning; NOT "dump_every_epochs"/"checkpoint_every" + "optimizer": {"lr": 1e-3}, # nested under "optimizer" -> agent's "learning rate" tuning + "data": { + "train_loader": {"batch_size": 64}, # nested under "data." -> agent's "batch size" tuning + "test_loader": {"batch_size": 256}, + }, +} +``` + +Anything with no semantic alias (`num_workers`, `grpc_port`, `start_timeout`, +model width/depth, dataset paths, …) still belongs as a key in this same +dict — never a bare literal in code — just without a required name; pick a +short, descriptive one and stay consistent within the file. + +### 3.3 Per-sample / per-instance / grouped logging + +Watched loss/metric objects call `save_signals` internally on every +forward/compute — call these yourself only for derived values or anything not +from a watched object: + +- `wl.save_signals(batch_ids=ids, signals={...}, preds_raw=, targets=, preds=, log=True)` — + one value per sample id. `log=False` → stored as metadata, not a plotted signal. +- `wl.save_instance_signals(...)` — internal use by `per_instance=True`; rarely called directly. +- `wl.save_group_signals(signals={...}, group_ids=[...], origin="train_loader")` — + one row per group, for pairwise values (e.g. contrastive loss) that can't map + to a single sample. Needs a dataset that emits a `group_id` in its metadata + (`PyTorch/wl-generation`). +- `wl.trajectory_stats(values)` / `wl.classify_loss_shape(values)` — building + blocks behind the loss-shape tag (§3.6); call directly only for a custom classifier. + +### 3.4 `task_type` + +Set `self.task_type = "..."` on **both** model and dataset before +`watch_or_edit`. Confirmed values: + +| `task_type` | renders | set in | +|---|---|---| +| *(unset)* | classification (default) — also clustering, tabular, signal-tagging use cases | most examples | +| `"detection"` | 2D bounding boxes | `PyTorch/wl-detection/utils/{model,data}.py` | +| `"segmentation"` | instance/semantic masks | `PyTorch/wl-segmentation/utils/{model,data}.py` | +| `"detection_pointcloud"` | LiDAR point clouds, 2D or 3D (box column count disambiguates) | `Usecases/wl-{2d,3d}-lidar-detection/utils/{model,data}.py` | + +No `task_type="tabular"` exists — tabular rendering comes from the dataset +exposing feature values as sample **metadata** (`preload_labels=True, +preload_metadata=True`); mirror `PyTorch/wl-fraud-detection`, not the image +classification example. + +A dataset can implement `render_thumbnail_2d(...)` / `project_boxes_2d(...)` +for custom thumbnails — picked up automatically, no registration +(`Usecases/wl-3d-lidar-detection`, `CustomLidarDataset`). + +### 3.5 Guard contexts (required) + +```python +from weightslab import guard_training_context, guard_testing_context + +with guard_training_context: + ... # one training step +with guard_testing_context, torch.no_grad(): + ... # one eval step +``` + +Skip this and pause/resume and train/test stat separation break. Framework +variants: +- **Lightning:** wrap the body of `training_step`/`validation_step` — no manual loop. +- **Ultralytics mixin:** entered/exited manually (`guard.__enter__()`/`__exit__(None,None,None)`) + across `on_train_batch_start`/`_end` callback pairs (§3.9). + +Use `model.get_age()` (steps actually trained, survives checkpoints) for +step-based cadence, not a raw loop counter. + +### 3.6 Reactive signals & loss-shape classification + +```python +@wl.signal(name="sig/entropy", subscribe_to="loss_sample", batched=True) +def entropy(b): ... # b.logits, etc. → per-sample values + +@wl.signal(name="sig/hardness", inputs=["loss_sample", "sig/entropy"], batched=True) +def hardness(loss_vals, entropy_vals): ... +``` + +- `subscribe_to=` fires reactively when that signal saves (push); `inputs=[...]` + pulls named signals as args and can chain off other `@wl.signal` outputs. +- Define before `wl.serve()`/`wl.start_training()` — module scope or inline in `main()`. +- `@wl.signal_classifier(signal=)` overrides the built-in 6-way + loss-shape classifier (`monotonic/plateaued/Flat_high/high_variance/U_Shape/Spiked`) + for that signal, surfaced as categorical `tag:loss_shape` — no manual tagging + needed. Rebind at runtime: `wl.signal_classifier(signal=name)(fn)`. +- No custom classifier needed? Pass `loss_shape_signal=` to + `wl.write_dataframe(...)` (§3.7) to compute the built-in tag at dump time. + +### 3.7 Persisting & inspecting history + +- `wl.write_history()` / `wl.write_dataframe(path=, format="csv", columns=[...], loss_shape_signal=)` — + dump the ledger; `columns=` filters groups (e.g. `["signals","tags"]`). Call + periodically in long loops and once at the end. +- `wl.drain_signals()` — force-flush async signals before reading them back + (dataframe export, or a `GetDataSamples` call right after training). +- `wl.query_signal_history(...)` / `query_sample_history(...)` / `query_instance_history(...)` — + programmatic readback. + +### 3.8 Tagging & filtering samples + +`wl.tag_samples(...)`, `wl.register_categorical_tag(...)`/`set_categorical_tag(...)` +(multi-value, predefined categories — boolean tags are separate), +`wl.discard_samples(...)`, `wl.get_samples_by_tag(...)`, `wl.get_discarded_samples(...)`. +The automatic `tag:loss_shape` tag (§3.6) uses these same primitives. + +### 3.9 Which example to copy + +| Integrating... | Mirror | Notes | +|---|---|---| +| Plain PyTorch loop (classification) | `PyTorch/wl-classification` | Simplest pattern, manual loop in `main()`. | +| Detection (2D boxes) | `PyTorch/wl-detection` | `task_type="detection"`, `per_sample`/`per_instance`, custom `collate_fn`, decoded preds passed for overlays. | +| Segmentation | `PyTorch/wl-segmentation` | `task_type="segmentation"`, masks as list-of-tensors. | +| LiDAR detection (2D/3D) | `Usecases/wl-{2d,3d}-lidar-detection` | `task_type="detection_pointcloud"`; 3D adds `render_thumbnail_2d`. | +| Tabular / feature vectors | `PyTorch/wl-fraud-detection` | No `task_type`; `preload_labels=True, preload_metadata=True`; see §3.10 for a headless verification script. | +| Embedding / clustering | `PyTorch/wl-clustering` (+ `face/model.py`) | `watch_or_edit` calls live inside the model wrapper, not `main.py`; open-ended loop. | +| Paired/contrastive samples, group-level signals | `PyTorch/wl-generation` | `wl.save_group_signals`; dataset emits 2 rows per item via a `uids` metadata key. | +| Reactive signals / custom loss-shape tagging | `Usecases/wl-classification-signals_shape_classification`, `Usecases/ws-signals-mnist` | §3.6; the latter is the minimal variant with no custom classifier. | +| PyTorch Lightning | `Lightning/wl-classification` | Same `watch_or_edit` calls as plain PyTorch; guards wrap `training_step`/`validation_step` bodies; `Trainer(log_every_n_steps=0, enable_checkpointing=False, logger=False)`. | +| Ultralytics YOLO (detect/segment) | `Ultralytics/wl-detection` | Don't call `watch_or_edit` for model/optimizer/data/loss/metric — pass `trainer=WLAwareTrainer` (or `WLAwareSegmentationTrainer`) from `weightslab.integrations.ultralytics` to `YOLO(...).train(...)`. It wires everything via UL callbacks; you only watch the run config as `flag="hyperparameters"`. | + +### 3.10 Verifying an integration headlessly + +Watch model/optimizer/data/loss/metrics, `wl.serve(serving_grpc=True, grpc_port=...)`, +`wl.start_training()`, run real steps inside the guard contexts, +`wl.drain_signals()`, then assert on `wl.write_dataframe(..., format="csv")` +columns or a raw gRPC `GetDataSamples` call's `raw_data.type` (e.g. `"vector"` +for tabular). Plain script, not pytest — `python verify_integration.py` +(`PyTorch/wl-fraud-detection/verify_integration.py`). + +--- + +## 4. Configuration (environment variables) + +Authoritative reference: `weightslab/docs/configuration.rst`. High-signal ones: + +**Backend:** + +| Variable | Default | Why | +|---|---|---| +| `WEIGHTSLAB_LOG_LEVEL` | `INFO` | `DEBUG` for detail (`WATCHDOG` level sits between WARNING/ERROR). | +| `GRPC_BACKEND_HOST`/`PORT` | `0.0.0.0`/`50051` | Backend gRPC bind address. | +| `GRPC_TLS_ENABLED` | `0` | TLS on the gRPC socket; set with `weightslab start --certs`. | +| `GRPC_TLS_REQUIRE_CLIENT_AUTH` | `0` | mTLS; must match `--certs`. | +| `WEIGHTSLAB_CERTS_DIR` | `~/.weightslab-certs` | Cert lookup — single source of truth. | +| `GRPC_AUTH_TOKEN` | unset | Optional token auth on top of mTLS. | +| `GRPC_MAX_MESSAGE_BYTES` | `268435456` | Raise if large tensors/images fail to transfer. | +| `WEIGHTSLAB_DISABLE_WATCHDOGS` | `0` | Set `1` when breakpoint-debugging (§5). | +| `GRPC_WATCHDOG_STUCK_SECONDS` | `60` | Lock/RPC stuck threshold + lock-acquire timeout. | + +**Frontend — runtime `window.*` globals (injected at `weightslab start` time; restart+reload to apply):** + +| Variable | Default | Why | +|---|---|---| +| `WS_SERVER_HOST`/`PORT`/`PROTOCOL` | `localhost`/`8080`/`http` | How the browser reaches the server — #1 connection knob. | +| `WS_HISTOGRAM_MAX_BINS` | `512` | Metadata histogram bar cap. | +| `BB_THUMB_RENDER` | `10` | Max boxes per thumbnail, per overlay (GT/PRED independent). | +| `BB_MODAL_RENDER` | `100` | Max boxes per modal image, per overlay. | +| `ENABLE_PLOTS` | `1` | `0` removes plots board + Signals card. | +| `ENABLE_DATA_EXPLORATION` | `1` | `0` removes data grid + metadata panel. | +| `ENABLE_HYPERPARAMETERS_OPTIMIZATION` | `1` | `0` makes HP inputs read-only, stops HP poll. | +| `ENABLE_AGENT` | `1` | `0` removes agent chat bar. | +| `ENABLE_NOTEBOOK` | `1` | `0` removes the notebook (shared in-process kernel against the live experiment; persisted as `notebook.ipynb`; `>`-prefixed cells ask the agent for code). | + +`VITE_*` vars are build-time (need a frontend rebuild); `WS_*`/`BB_*`/`ENABLE_*` +are runtime (need only restart + reload). `ENABLE_*` default on; `0`/`false`/`no`/`off` disables. + +--- + +## 5. Troubleshooting + +**Sample grid empty / "failed to fetch" / gRPC errors.** Check in order: (1) +backend serving on `0.0.0.0:50051`; (2) `weightslab start` running, browser +reaches `:8080`; (3) TLS mismatch if using `--certs` — run `weightslab se` +first, export `WEIGHTSLAB_CERTS_DIR` (or drop TLS: omit `--certs`, `GRPC_TLS_ENABLED=0`). + +**Env var change not taking effect.** `VITE_*` → rebuild frontend. +`WS_*`/`BB_*`/`ENABLE_*` → restart `weightslab start` + reload tab. + +**Grid flashes empty on auto-refresh.** Refreshes now skip while a +`GetDataSamples` fetch is in flight (`isFetchInProgress()` in +`weights_studio/src/grid_data/gridDataManager.ts`) — confirm your build has this guard. + +**Detection overlays slow/cluttered.** Cap with `BB_THUMB_RENDER` / +`BB_MODAL_RENDER` (GT and PRED capped independently; render-only, no data dropped). + +**Training hangs; `RESOURCE_EXHAUSTED`; server "restarts".** A watchdog flags +locks/RPCs held past `GRPC_WATCHDOG_STUCK_SECONDS` (60s) and restarts the gRPC +server after repeated unhealthy polls. Debugging with breakpoints that +intentionally exceed this? Set `WEIGHTSLAB_DISABLE_WATCHDOGS=1`. +`RESOURCE_EXHAUSTED` = a handler couldn't get the lock in time — find what's holding it. + +**Pause/resume broken, or train/test stats mixed up.** Train/eval step isn't +wrapped in `guard_training_context`/`guard_testing_context` — see §3.5. + +**Large weights/images fail to transfer.** Raise `GRPC_MAX_MESSAGE_BYTES`. + +**Agent bar says unconfigured.** Backed by a local OpenCode server +(`OPENCODE_URL`, default `http://127.0.0.1:4096`), auto-started on first use. +`/init` from the UI (then `/model`, `/reset`). See `docs/agent.rst`, `docs/weights_studio.rst`. + +**Agent says it "cannot run code."** It has bash/read/write/edit tools rooted +at this workspace directory (the frontend sends no `tools` restriction) — a +model claiming otherwise is declining to call a tool it actually has, not +reporting a real limitation. Ask it directly and concretely: "use your bash +tool to run `python @@ -560,6 +794,7 @@ def _dataframe_section_html(stats: dict) -> str: WeightsLab Experiment Report +
@@ -577,6 +812,8 @@ def _dataframe_section_html(stats: dict) -> str: {signals_html}
+ {distributions_section_html} +

Loss-Shape Classification

{loss_shape_html} @@ -589,6 +826,31 @@ def _dataframe_section_html(stats: dict) -> str:
+ + """ @@ -629,6 +891,7 @@ def render_report(context: dict, output_path, narrative: Optional[str] = None) - root_log_dir=html.escape(context.get("root_log_dir", "")), narrative=narrative_html, signals_html=signals_html, + distributions_section_html=_distributions_section_html(context.get("distributions") or []), loss_shape_html=_loss_shape_section_html(context.get("loss_shape_tags") or []), dataframe_html=_dataframe_section_html(context.get("dataframe") or {}), ) @@ -644,6 +907,29 @@ def default_report_path(root_log_dir) -> Path: return Path(root_log_dir) / "reports" / f"experiment_report_{stamp}.html" +def list_reports(root_log_dir) -> list: + """Every ``*.html`` report already written for this experiment, newest + first (by mtime) -- the same directory and ordering the Studio report + button's right-click dropdown uses (``weightslab/ui/server.py``'s + ``_list_experiment_reports``), duplicated here rather than imported since + that lives in the UI server module, not this LLM/agent-agnostic one.""" + reports_dir = Path(root_log_dir) / "reports" + if not reports_dir.is_dir(): + return [] + paths = [p for p in reports_dir.iterdir() if p.is_file() and p.suffix == ".html"] + paths.sort(key=lambda p: p.stat().st_mtime, reverse=True) + return paths + + +def latest_report_path(root_log_dir) -> Optional[Path]: + """The most recently written report for this experiment, or ``None`` if + none exists yet -- used to resolve "update the report"/"add X to the + report" requests to the file they mean, see ``generate_report``'s + ``update_existing``.""" + reports = list_reports(root_log_dir) + return reports[0] if reports else None + + def summarize_context_for_llm(context: dict) -> str: """The JSON summary of ``context`` that gets handed to an LLM for the narrative: everything except the base64 plot images (bytes an LLM can do @@ -658,6 +944,10 @@ def summarize_context_for_llm(context: dict) -> str: {k: v for k, v in entry.items() if k != "plot_b64"} for entry in context.get("signals", []) ], + "distributions": [ + {k: v for k, v in entry.items() if k != "plot_b64"} + for entry in context.get("distributions", []) + ], "loss_shape_tags": context.get("loss_shape_tags", []), "dataframe": context.get("dataframe", {}), }, indent=2, default=str) @@ -670,6 +960,8 @@ def generate_report( signals: Optional[list] = None, output_path=None, narrative_fn: Optional[Callable[[str], str]] = None, + distributions: Optional[list] = None, + update_existing: bool = False, ) -> dict: """Collect → narrate → render, in one call. The single implementation behind every user-facing entry point (the Studio report button and the @@ -684,11 +976,29 @@ def generate_report( configured, request timed out, ...) degrades the same way — a report with no written analysis rather than no report at all. - Returns ``{"path", "n_signals", "narrative", "narrative_error"}``. Failures - to gather data or write the file are NOT swallowed: they raise, because - there is no report to hand back. + ``distributions`` names columns to render as value-distribution + histograms in an extra Distributions section — opt-in, e.g. a follow-up + "add a histogram of train_loss to the report" — see + ``compute_distribution_entries``. + + ``update_existing`` (default ``False``, ignored if ``output_path`` is + given explicitly) — "update the report"/"add X to the report" should + overwrite the most recently written report for this experiment rather + than create a new timestamped one; "generate a report" with no reference + to an existing one should always create a fresh file. Resolved via + ``latest_report_path``; when there is nothing to update yet (a fresh + experiment, or its ``reports/`` directory was cleared), this falls back + to creating a new report exactly as if ``update_existing`` were ``False`` + — there is nothing wrong with "update" on a first-ever report. + + Returns ``{"path", "n_signals", "narrative", "narrative_error", + "updated_existing"}`` — the last key tells the caller whether an existing + file was overwritten (``True``) or a new one was created (``False``), so + it can phrase its reply accordingly. Failures to gather data or write the + file are NOT swallowed: they raise, because there is no report to hand + back. """ - context = collect_report_context(root_log_dir, logger_q, df, signals=signals) + context = collect_report_context(root_log_dir, logger_q, df, signals=signals, distributions=distributions) narrative = None narrative_error = None @@ -699,14 +1009,21 @@ def generate_report( narrative_error = str(exc) logger.warning("experiment report: narrative generation failed: %s", exc) - path = render_report( - context, - output_path if output_path is not None else default_report_path(root_log_dir), - narrative=narrative, - ) + resolved_output_path = output_path + updated_existing = False + if resolved_output_path is None and update_existing: + existing = latest_report_path(root_log_dir) + if existing is not None: + resolved_output_path = existing + updated_existing = True + if resolved_output_path is None: + resolved_output_path = default_report_path(root_log_dir) + + path = render_report(context, resolved_output_path, narrative=narrative) return { "path": path, "n_signals": len(context.get("signals") or []), "narrative": narrative, "narrative_error": narrative_error, + "updated_existing": updated_existing, } diff --git a/weightslab/src.py b/weightslab/src.py index 2c9b3e48..d96205cc 100644 --- a/weightslab/src.py +++ b/weightslab/src.py @@ -1559,6 +1559,57 @@ def start_training(timeout: int = None) -> None: pause_ctrl.resume() # Ensure we're not paused if start_training is called after serve +def _register_pid_with_ui_server() -> None: + """Tell the ``weightslab start`` UI server that this training process + exists, so stopping that workspace (Ctrl+C, closing the terminal) stops + this process too. + + The UI server already kills PIDs registered with it on every termination + path (``weightslab/ui/server.py``'s ``_TrackedProcesses``) -- but until + now the ONLY thing that ever registered one was the agent remembering to + POST it by hand right after launching something detached. That second + step is skipped often enough to matter: the turn gets interrupted between + launch and registration, the model forgets on a relaunch, or the run was + started by hand in a terminal that never involved an agent at all. Each + miss leaves an orphaned python process holding the GPU and the gRPC port + after the UI is long gone. Registering from inside ``serve()`` makes it + unskippable for anything that serves. + + Entirely best-effort, and off the calling thread: a stale/unreachable + origin must never delay or break a training run. Only ever posts to + loopback (the endpoint refuses anything else anyway). + """ + origin = os.environ.get("WEIGHTSLAB_UI_ORIGIN") + if not origin: + return + + def _post() -> None: + try: + import json as _json + import ssl as _ssl + import urllib.request as _urlreq + + payload = _json.dumps({"pid": os.getpid()}).encode("utf-8") + request = _urlreq.Request( + f"{origin.rstrip('/')}/agent-server/track-process", + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + # `weightslab start --certs` serves the UI over HTTPS with its own + # self-signed material, which nothing in this process trusts. The + # payload is one integer going to loopback, so verification buys + # nothing here and would just turn tracking off whenever certs are on. + context = _ssl._create_unverified_context() if origin.startswith("https") else None + with _urlreq.urlopen(request, timeout=3.0, context=context): + pass + logger.debug(f"Registered PID {os.getpid()} with the WeightsLab UI at {origin}.") + except Exception as exc: + logger.debug(f"Could not register this process with the WeightsLab UI at {origin}: {exc}") + + threading.Thread(target=_post, name="wl-track-process", daemon=True).start() + + def serve(serving_cli: bool = True, serving_grpc: bool = True, spawn_cli_client: bool = False, serving_bore: bool = False, bore_port: int = None, allow_unconfigured: bool = True, **kwargs): @@ -1641,6 +1692,10 @@ def serve(serving_cli: bool = True, serving_grpc: bool = True, ) logger.warning(base_msg) + # Before anything that can block: whatever happens next, this process + # should not outlive the workspace that owns it. + _register_pid_with_ui_server() + # Embed a real Jupyter kernel (shares this process's live objects) so the # studio notebook panel — and any external Jupyter client — can attach to # it, unless we're already inside a notebook/Colab kernel ourselves (mode @@ -2790,6 +2845,16 @@ def save_group_signals( except Exception: pass # Never block training on best-effort discard check + # Look up each group's current NB_SEEN so we can increment it (it lives in the + # ledger, not in the `updates` dict being built below, which starts empty every call). + current_nb_seen_by_gid = {} + if step is not None and DATAFRAME_M is not None and hasattr(DATAFRAME_M, 'get_group_column_values'): + try: + current_nb_seen_by_gid = DATAFRAME_M.get_group_column_values( + group_ids, origin, SampleStatsEx.NB_SEEN.value) + except Exception: + pass # Never block training on best-effort NB_SEEN lookup + # Broadcast to all members in ledger (skip tainted groups) all_updates = [] active_group_ids = [] @@ -2804,7 +2869,7 @@ def save_group_signals( if step is not None: updates[SampleStatsEx.LAST_SEEN.value] = step - updates[SampleStatsEx.NB_SEEN.value] = 0 if SampleStatsEx.NB_SEEN.value not in updates else updates[SampleStatsEx.NB_SEEN.value] + 1 + updates[SampleStatsEx.NB_SEEN.value] = current_nb_seen_by_gid.get(gid, 0) + 1 all_updates.append(updates) active_group_ids.append(gid) @@ -5132,6 +5197,7 @@ def ai_report_generation( output_path: str | None = None, root_log_dir: str | None = None, use_agent: bool = True, + distributions: list | None = None, ) -> str: """Generate the experiment health report and return the path written. @@ -5163,6 +5229,11 @@ def ai_report_generation( the same report minus the prose. With ``True`` but no agent available (none configured, or no experiment being served in this process), the report is still written — just without the analysis, never an error. + distributions : list of str, optional + Add a "Distributions" section with a value-distribution histogram for + each named column/signal (e.g. ``["train_loss"]``), computed over the + CURRENT per-sample dataframe rather than the aggregated training + curve. Omitted (default): no Distributions section at all. Raises ------ @@ -5177,10 +5248,12 @@ def ai_report_generation( wl.ai_report_generation() # everything, with analysis wl.ai_report_generation(signals=["train_loss"]) # one signal wl.ai_report_generation(use_agent=False) # no LLM call + wl.ai_report_generation(distributions=["train_loss"]) # + a histogram section """ return _ai_report_generation_result( signals=signals, output_path=output_path, root_log_dir=root_log_dir, use_agent=use_agent, + distributions=distributions, )["path"] @@ -5189,6 +5262,7 @@ def _ai_report_generation_result( output_path: str | None = None, root_log_dir: str | None = None, use_agent: bool = True, + distributions: list | None = None, ) -> dict: """:func:`ai_report_generation`'s implementation, keeping the full ``reporting.generate_report`` result dict (path + signal count + narrative @@ -5250,6 +5324,7 @@ def _ai_report_generation_result( result = reporting.generate_report( root_log_dir, logger_q, df, signals=signals, output_path=output_path, narrative_fn=narrative_fn, + distributions=distributions, ) if result["narrative_error"]: logger.warning( diff --git a/weightslab/trainer/services/agent/agent.py b/weightslab/trainer/services/agent/agent.py index 8866d580..7158d484 100644 --- a/weightslab/trainer/services/agent/agent.py +++ b/weightslab/trainer/services/agent/agent.py @@ -7,22 +7,12 @@ import logging import threading import pandas as pd -from urllib.parse import urlparse, urlunparse from abc import ABC, abstractmethod from typing import Optional, List, Union, Literal, Callable, Dict, Any from dotenv import load_dotenv from pathlib import Path -try: - from langchain_ollama import ChatOllama -except ImportError: - ChatOllama = None - -try: - from langchain_openai import ChatOpenAI -except ImportError: - ChatOpenAI = None from langchain_core.prompts import ChatPromptTemplate from pydantic import BaseModel, Field @@ -30,6 +20,7 @@ from .intent_prompt import INTENT_PROMPT from .notebook_prompt import NOTEBOOK_CODE_PROMPT from .report_prompt import REPORT_ANALYSIS_PROMPT +from .opencode_chat import OpenCodeChat from weightslab.data.sample_stats import SampleStatsEx from weightslab.trainer.trainer_tools import get_layer_representations @@ -397,7 +388,6 @@ def __init__(self, context): self._build_column_index() self._load_config() self._setup_providers() - self._verify_startup_providers() self.history = [] # --- HANDLER REGISTRY --- @@ -722,41 +712,35 @@ def _format_layers_table(self, df: Optional[pd.DataFrame] = None) -> str: return "\n".join(lines) def _load_config(self): - self.preferred_provider = os.environ.get("PREFERRED_PROVIDER", "openrouter") # Default to OpenRouter if API key is provided, otherwise fallback to local Ollama. This can be overridden by config file or env variable. - - # Cloud provider settings with sensible defaults. OpenRouter is the default cloud provider if API key is provided. - # Default to a fast flash-class model: the intent-planning task is - # simple JSON generation, and a 70B model added ~15-30s of latency for - # no accuracy benefit (see plan Phase B.1). Override with OPENROUTER_MODEL - # (or agent_config.yaml) to use a larger model. - self.openrouter_model = os.environ.get("OPENROUTER_MODEL", "~google/gemini-flash-latest") - self.openrouter_base_url = os.environ.get("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1") - self.openrouter_api_key = os.environ.get("OPENROUTER_API_KEY", None) - self.openrouter_request_timeout = float(os.environ.get("OPENROUTER_REQUEST_TIMEOUT", "15.0")) - # Cap the completion length. OpenRouter pre-authorizes - # `max_tokens * completion_price` against the key's remaining budget - # BEFORE generating; with no cap the client requests the model's full - # output window (tens of thousands of tokens), which 402s on a - # credit/weekly-limited key even though the actual intent-planning - # response is only a few hundred tokens. Keep this modest. - self.openrouter_max_tokens = int(os.environ.get("OPENROUTER_MAX_TOKENS", "2048")) - # Bias OpenRouter's upstream provider selection ("throughput"/"latency"/ - # "price"); empty string lets OpenRouter choose freely (see Phase B.2). - self.openrouter_provider_sort = os.environ.get("OPENROUTER_PROVIDER_SORT", "throughput") - # Ask the model for a schema-validated Intent object directly instead of - # free-form JSON + regex repair. More reliable, but only works on models - # whose OpenRouter route supports structured/JSON-schema output (e.g. - # Gemini, GPT-4o). Default OFF so an unsupported model can't break; flip - # on with OPENROUTER_STRUCTURED_OUTPUT=1 or the config key. - self.openrouter_structured_output = os.environ.get( - "OPENROUTER_STRUCTURED_OUTPUT", "" - ).strip().lower() in ("1", "true", "yes", "on") - - # Local fallback if no cloud (OpenRouter) is available or if the user prefers it. Ollama is the default local provider. - self.fallback_to_local = True # Default to allowing fallback to local Ollama if OpenRouter fails - self.ollama_host = "localhost" - self.ollama_port = "11435" - self.ollama_model = "llama3.2:3b" + # OpenCode is the only supported agent backend: a local OpenCode + # server (opencode.ai) backs every LLM call. No API key here -- the + # credential lives in OpenCode's own config, entered once via + # `opencode auth login` or the Weights Studio landing page's login + # modal. OPENCODE_URL is the SAME shared root env var the frontend + # reads (forwarded to window.WS_OPENCODE_URL by weightslab/ui/server.py's + # _UI_ENV_GLOBALS) -- set it once and both sides point at one server. + self.preferred_provider = "opencode" + # Tracked separately from the resolved value itself: an address the + # user (or agent_config.yaml, below) actually chose must never be + # silently overridden by auto-discovery later (see + # OpenCodeChat._ensure_reachable) -- only the bare "nobody said + # anything" default is eligible for that. + self._opencode_url_explicit = "OPENCODE_URL" in os.environ + self.opencode_url = os.environ.get("OPENCODE_URL", "http://127.0.0.1:4096") + # Same tracking, for the model this time: an unset value here is NOT + # "let OpenCode pick a sensible default" -- it's "OpenCode picks + # whatever's configured, arbitrarily" (see + # OpenCodeChat._ensure_model_resolved for the live-confirmed failure + # this caused). Only a value the user (or agent_config.yaml, below) + # actually chose is exempt from that self-healing. + self._opencode_model_explicit = "OPENCODE_MODEL" in os.environ + self.opencode_model = os.environ.get("OPENCODE_MODEL", "") + # The same directory `weightslab start ` roots the browser + # landing-page agent at (WEIGHTSLAB_ROOT_LOG_DIR) -- the shared key + # opencode_process.py's lock file is discovered/published under, so + # this side and that one converge on one server with no extra + # coordination. See _setup_providers/OpenCodeChat._ensure_reachable. + self.opencode_workspace_dir = os.environ.get("WEIGHTSLAB_ROOT_LOG_DIR") or os.getcwd() repo_root = Path(__file__).resolve().parents[4] # weightslab/ root inner_pkg = Path(__file__).resolve().parents[3] @@ -783,23 +767,13 @@ def _load_config(self): if not cfg or "agent" not in cfg: continue a_cfg = cfg["agent"] - # Agents settings - self.preferred_provider = a_cfg.get("provider", self.preferred_provider).lower() - self.fallback_to_local = a_cfg.get("fallback_to_local", self.fallback_to_local) - - # OPENROUTER - self.openrouter_model = a_cfg.get("openrouter_model", self.openrouter_model) - self.openrouter_base_url = a_cfg.get("openrouter_base_url", self.openrouter_base_url) - self.openrouter_api_key = a_cfg.get("openrouter_api_key", self.openrouter_api_key) - self.openrouter_request_timeout = float(a_cfg.get("openrouter_request_timeout", self.openrouter_request_timeout)) - self.openrouter_max_tokens = int(a_cfg.get("openrouter_max_tokens", self.openrouter_max_tokens)) - self.openrouter_provider_sort = a_cfg.get("openrouter_provider_sort", self.openrouter_provider_sort) - self.openrouter_structured_output = bool(a_cfg.get("openrouter_structured_output", self.openrouter_structured_output)) - - # OLLAMA - self.ollama_host = a_cfg.get("ollama_host", self.ollama_host) - self.ollama_port = a_cfg.get("ollama_port", self.ollama_port) - self.ollama_model = a_cfg.get("ollama_model", self.ollama_model) + # OPENCODE + if a_cfg.get("opencode_url"): + self._opencode_url_explicit = True + self.opencode_url = a_cfg.get("opencode_url", self.opencode_url) + if a_cfg.get("opencode_model"): + self._opencode_model_explicit = True + self.opencode_model = a_cfg.get("opencode_model", self.opencode_model) _LOGGER.info(f"Applied agent configuration from {path}") _LOGGER.debug(f"Agent Config: {cfg}") @@ -813,228 +787,77 @@ def _load_config(self): "\n# #######################################" + "\n" + "# #######################################" + "\n" + f"Agent initialized from configuration {path}: " + "\n" + - f"\tFinal Agent Configuration: Preferred Provider={self.preferred_provider}, " + "\n" + - f"\tFallback to Local={self.fallback_to_local}, " + "\n" + - f"\tOpenRouter Model={self.openrouter_model} with:" + "\n" + - f"\t\tAPI Key={f'{self.openrouter_api_key[:4]}****{self.openrouter_api_key[-4:]}' if self.openrouter_api_key else 'None'}" + "\n" + - f"\t\tBase URL={self.openrouter_base_url}, " + "\n" + - f"\tOllama Model={self.ollama_model}" + "\n" + + f"\tOpenCode URL={self.opencode_url}, Model={self.opencode_model or '(server default)'}" + "\n" + "# #######################################" + "\n" + "# #######################################" + "\n" + "" ) - @staticmethod - def _effective_http_port(parsed_url, explicit_port: Optional[str]) -> int: - if explicit_port and explicit_port.isdigit(): - return int(explicit_port) - if parsed_url.port is not None: - return parsed_url.port - return 443 if parsed_url.scheme == "https" else 80 - - @staticmethod - def _normalize_openrouter_base_url(raw_url: str, explicit_port: Optional[str]) -> str: - url = (raw_url or "https://openrouter.ai/api/v1").strip() - parsed = urlparse(url) - if not parsed.scheme: - parsed = urlparse(f"https://{url}") - - host = parsed.hostname or "openrouter.ai" - port = DataManipulationAgent._effective_http_port(parsed, explicit_port) - netloc = host if ((parsed.scheme == "https" and port == 443) or (parsed.scheme == "http" and port == 80)) else f"{host}:{port}" - path = parsed.path if parsed.path else "/api/v1" - - return urlunparse((parsed.scheme, netloc, path, "", "", "")) - def _setup_providers(self): - self.chain_ollama = None - self.chain_openrouter = None + self.chain_opencode = None + self._opencode_chat = None initialized = False - # Determine which providers to initialize - active_providers = {self.preferred_provider} - if self.fallback_to_local: - active_providers.add("ollama") - - # OPEN ROUTER - if "openrouter" in active_providers and self.openrouter_api_key: - _LOGGER.info(f"Setting up OpenRouter with model {self.openrouter_model}") - try: - if ChatOpenAI is None: - _LOGGER.warning("langchain_openai is not installed, skipping OpenRouter provider") - else: - explicit_openrouter_port = os.environ.get("OPENROUTER_PORT", "").strip() - openrouter_base_url = self._normalize_openrouter_base_url(self.openrouter_base_url, explicit_openrouter_port) - parsed = urlparse(openrouter_base_url) - effective_port = self._effective_http_port(parsed, explicit_openrouter_port) - - # Bias OpenRouter's upstream routing so it doesn't pick a - # slow/cold provider for the model — a major source of the - # query tail latency (see plan Phase B.2). Configurable via - # `openrouter_provider_sort` ("throughput"/"latency"/"price"); - # set to "" to let OpenRouter choose freely. - extra_body = None - sort = getattr(self, "openrouter_provider_sort", "throughput") - if sort: - extra_body = {"provider": {"sort": sort}} - - llm = ChatOpenAI( - model=self.openrouter_model, temperature=0, - api_key=self.openrouter_api_key, - base_url=openrouter_base_url, - streaming=False, max_retries=1, request_timeout=self.openrouter_request_timeout, - max_tokens=self.openrouter_max_tokens, - extra_body=extra_body, - ) - self.chain_openrouter = llm - initialized = True - _LOGGER.info( - f"[Agent] OpenRouter enabled: {self.openrouter_model} via {parsed.hostname}:{effective_port} " - f"(provider sort={sort or 'default'})" - ) - except Exception as e: _LOGGER.error(f"OpenRouter error: {e}") - - # LOCAL - if "ollama" in active_providers: - try: - if ChatOllama is None: - _LOGGER.warning("langchain_ollama is not installed, skipping Ollama provider") - else: - _LOGGER.info(f"Setting up Ollama with model {self.ollama_model}") - host = self.ollama_host.split(':')[0] - port = self.ollama_port - llm = ChatOllama(base_url=f"http://{host}:{port}", model=self.ollama_model, temperature=0, timeout=15) - self.chain_ollama = llm - initialized = True - _LOGGER.info(f"[Agent] Ollama enabled: {self.ollama_model}") - except Exception as e: _LOGGER.error(f"Ollama error: {e}") - - return initialized - - def _verify_startup_providers(self) -> None: - """ - A provider configured at construction time (agent_config.yaml / env - vars, e.g. OPENROUTER_API_KEY) never goes through the connectivity - check the `/init` UI flow runs (see `initialize_with_cloud_key`) -- - `_setup_providers()` only builds a client object from whatever key - string it was given, it never confirms the key is actually accepted. - `is_available()` treats "a client object exists" as "ready", so a - bad startup key would otherwise report available=True indefinitely - (CheckAgentHealth says "ready to help you") until the first real - query 401s. Probe once here instead, so that mismatch can't happen. - - Only runs for the OpenRouter chain built in `__init__`: Ollama's - `is_available()` already does a live reachability check every call, - and `initialize_with_cloud_key`/`change_model` already run their own - explicit post-`_setup_providers()` check. - """ - if self.chain_openrouter is None: - return - ok, message = self._check_chat_provider("openrouter") - if not ok: - _LOGGER.warning(f"[Agent] Startup OpenRouter connectivity check failed, disabling it: {message}") - self.chain_openrouter = None - - def _check_chat_provider(self, provider: str) -> "tuple[bool, str]": - """Run a minimal chat request to verify the provider is actually usable.""" - chain = getattr(self, f"chain_{provider}", None) - if chain is None: - return False, f"{provider} client was not initialized." - try: - # Keep the probe's reservation tiny. OpenRouter pre-authorizes - # `max_tokens * price` before generating, so a large (or unset) - # cap makes the health check 402 on a budget-limited key even - # though the model is perfectly usable for real (short) requests. - probe = chain - if provider == "openrouter" and hasattr(chain, "bind"): - try: - probe = chain.bind(max_tokens=16) - except Exception: - probe = chain - response = probe.invoke("Reply with OK.") + # Kept alongside the runnable (not just `self.chain_opencode`) so + # get_context_usage() can read `.last_usage` off it after a call -- + # `as_runnable()` only hands back a RunnableLambda wrapping it. + self._opencode_chat = OpenCodeChat( + self.opencode_url, self.opencode_model, + workspace_dir=self.opencode_workspace_dir, + url_is_explicit=self._opencode_url_explicit, + model_is_explicit=self._opencode_model_explicit, + ) + self.chain_opencode = self._opencode_chat.as_runnable() + initialized = True + _LOGGER.info( + f"[Agent] OpenCode enabled: {self.opencode_url} " + f"(model={self.opencode_model or 'server default'})" + ) except Exception as e: - _LOGGER.warning(f"[{provider}] connectivity check failed: {e}") - return False, f"{provider} connectivity check failed: {e}" + _LOGGER.error(f"OpenCode error: {e}") - text = response.content if hasattr(response, "content") else str(response) - if not str(text).strip(): - return False, f"{provider} connectivity check returned an empty response." - - return True, f"{provider} connectivity check succeeded." - - def is_ollama_available(self) -> bool: - return self.chain_ollama is not None + return initialized def is_available(self) -> bool: - """ - Return True if any LLM provider is actually ready to serve requests. - - OpenRouter is considered ready as soon as its chain is set up. - - Ollama requires an active HTTP connection check because the ChatOllama - constructor succeeds even when the daemon is not running. - """ - if self.chain_openrouter is not None: - return True - if self.chain_ollama is not None: - return self._is_ollama_reachable() - return False - - def _is_ollama_reachable(self) -> bool: - """Ping the Ollama HTTP endpoint to verify the daemon is actually running.""" - try: - import urllib.request as _ur - host = self.ollama_host.split(':')[0] - url = f"http://{host}:{self.ollama_port}/api/version" - with _ur.urlopen(_ur.Request(url), timeout=2) as resp: - return resp.status == 200 - except Exception: - return False + """Return True if the OpenCode provider is ready to serve requests.""" + return self.chain_opencode is not None def initialize_with_cloud_key(self, api_key: str, provider: str, model: Optional[str] = None) -> "tuple[bool, str]": """ - Initialize (or reinitialize) the OpenRouter cloud provider. + Initialize (or reinitialize) the OpenCode provider. Args: - api_key: The API key obtained from the provider's website. - provider: Must be ``"openrouter"``. - model: OpenRouter model identifier chosen by the user. + api_key: Unused -- OpenCode's credential lives in the OpenCode + server's own config (``opencode auth login`` or the Weights + Studio landing page's login modal), never in this agent. + provider: Must be ``"opencode"`` -- the only supported backend. + model: OpenCode's ``"providerID/modelID"`` model identifier. Returns: ``(True, success_message)`` or ``(False, error_message)``. """ - if not api_key or not api_key.strip(): - return False, "API key cannot be empty." - - if provider.lower() != "openrouter": - return False, "Only OpenRouter cloud onboarding is supported." + provider = (provider or "").lower() + if provider != "opencode": + return False, "Only OpenCode is supported as the agent backend." if model is not None and not model.strip(): return False, "Model cannot be empty." - self.openrouter_api_key = api_key.strip() - self.openrouter_base_url = "https://openrouter.ai/api/v1" - self.openrouter_model = model.strip() if model and model.strip() else self.openrouter_model - self.preferred_provider = "openrouter" + self.opencode_model = model.strip() if model and model.strip() else self.opencode_model + self.preferred_provider = "opencode" success = self._setup_providers() + if self.chain_opencode is None or not success: + return False, "Could not reach the OpenCode server. Please verify OPENCODE_URL and that it is running." - if self.chain_openrouter is None or not success: - return False, "Provider client initialization failed. Please verify your API key, base URL, and model." - - chat_ok, chat_message = self._check_chat_provider("openrouter") - if not chat_ok: - self.chain_openrouter = None - return False, chat_message - - return True, "Agent initialized successfully. Ready to help you." + return True, "Agent initialized successfully via OpenCode. Ready to help you." def change_model(self, model: str) -> "tuple[bool, str]": """ - Switch the active OpenRouter model without re-entering the API key. + Switch the active OpenCode model, without re-entering credentials. Args: - model: OpenRouter model identifier (e.g. ``"openai/gpt-4o"``). + model: OpenCode's ``"providerID/modelID"`` model identifier. Returns: ``(True, success_message)`` or ``(False, error_message)``. @@ -1042,25 +865,30 @@ def change_model(self, model: str) -> "tuple[bool, str]": if not model or not model.strip(): return False, "Model cannot be empty." - if not getattr(self, "openrouter_api_key", None): - return False, "No API key configured. Please initialize the agent first (/init)." - - self.openrouter_model = model.strip() + self.opencode_model = model.strip() success = self._setup_providers() - - if self.chain_openrouter is None or not success: - return False, "Provider reinitialization failed. Please verify the model name." - - chat_ok, chat_message = self._check_chat_provider("openrouter") - if not chat_ok: - self.chain_openrouter = None - return False, chat_message - - return True, f"Model switched to {self.openrouter_model}. Ready to help you." + if self.chain_opencode is None or not success: + return False, "Could not reach the OpenCode server. Please verify OPENCODE_URL and that it is running." + return True, f"Model switched to {self.opencode_model}. Ready to help you." + + def _opencode_base_url(self) -> str: + """Same self-heal `OpenCodeChat._ensure_reachable` gives every chat + turn, for the CLI paths below that talk to the OpenCode server + directly instead of through a chat call: without this, `agent + models`/`_fetch_context_window` stayed pointed at the bare + OPENCODE_URL default and failed outright ("connection refused") + whenever nothing had spawned/discovered an OpenCode server yet, + even though a chat turn right after would have self-healed fine.""" + if self._opencode_chat is not None: + self._opencode_chat._ensure_reachable() + return self._opencode_chat.base_url + return self.opencode_url def get_available_models(self) -> "tuple[bool, list[str], str]": """ - Fetch the list of models available via the configured OpenRouter API key. + Fetch every provider/model OpenCode is authenticated for, flattened + into "providerID/modelID" strings (same convention used throughout + the frontend). Returns: ``(True, model_ids, "")`` on success, or ``(False, [], error_message)``. @@ -1068,31 +896,144 @@ def get_available_models(self) -> "tuple[bool, list[str], str]": import urllib.request as _ur import json as _json - api_key = getattr(self, "openrouter_api_key", None) - if not api_key: - return False, [], "No API key configured. Please initialize the agent first (/init)." + try: + url = f"{self._opencode_base_url().rstrip('/')}/config/providers" + with _ur.urlopen(_ur.Request(url), timeout=10) as resp: + data = _json.loads(resp.read().decode()) + models = [] + for provider in data.get("providers", []): + provider_id = provider.get("id") + if not provider_id: + continue + provider_models = provider.get("models", {}) + model_ids = provider_models.keys() if isinstance(provider_models, dict) else \ + (m.get("id") for m in provider_models if isinstance(m, dict)) + for model_id in model_ids: + if model_id: + models.append(f"{provider_id}/{model_id}") + return True, sorted(models), "" + except Exception as exc: + _LOGGER.warning("get_available_models (opencode) error: %s", exc) + return False, [], f"Could not reach the OpenCode server: {exc}" + + def _fetch_context_window(self, model: str) -> int: + """Look up the active model's context-window size (tokens) from + OpenCode's /config/providers -- same endpoint/shape as + get_available_models, but this time reading each model's own `limit` + metadata (models.dev schema: {context, output}) instead of just its + id. Returns 0 (unknown) on any lookup failure or missing field -- + callers must degrade gracefully rather than treat 0 as an error.""" + if not model or "/" not in model: + return 0 + provider_id, model_id = model.split("/", 1) + + import urllib.request as _ur + import json as _json try: - url = "https://openrouter.ai/api/v1/models" - req = _ur.Request(url, headers={"Authorization": f"Bearer {api_key}"}) - with _ur.urlopen(req, timeout=10) as resp: + url = f"{self._opencode_base_url().rstrip('/')}/config/providers" + with _ur.urlopen(_ur.Request(url), timeout=10) as resp: data = _json.loads(resp.read().decode()) - models = sorted(entry["id"] for entry in data.get("data", []) if "id" in entry) - return True, models, "" + for provider in data.get("providers", []): + if provider.get("id") != provider_id: + continue + provider_models = provider.get("models", {}) + info = provider_models.get(model_id) if isinstance(provider_models, dict) else next( + (m for m in provider_models if isinstance(m, dict) and m.get("id") == model_id), None, + ) + context = (info or {}).get("limit", {}).get("context") + return int(context) if isinstance(context, (int, float)) else 0 except Exception as exc: - _LOGGER.warning("get_available_models error: %s", exc) - return False, [], f"Could not fetch models: {exc}" + _LOGGER.warning("_fetch_context_window (opencode) error: %s", exc) + return 0 + + def get_context_usage(self) -> "tuple[bool, dict, str]": + """ + Context-window usage for the active model, for the /context command. + + A fresh OpenCode session is created per agent call (opencode_chat.py), + so there is no persistent session to total across turns -- this + reports the LAST completed call's token usage (self._opencode_chat. + last_usage), which is exactly the size of the context the NEXT call + will resend (the full history is baked into the prompt text every + time, see agent.py's own history= placeholder usage). + + Returns: + ``(True, usage_dict, message)``. ``message`` is empty on a normal + result, or a human-readable note ("no turns yet", etc.) when + ``usage_dict`` is present but incomplete. ``(False, {}, error)`` + only when the agent isn't configured at all. + """ + if self.preferred_provider != "opencode" or self._opencode_chat is None: + return False, {}, "Agent not configured. Type /init to set up the agent." + + window = self._fetch_context_window(self.opencode_model) if self.opencode_model else 0 + usage = self._opencode_chat.last_usage + base = {"model": self.opencode_model, "context_window": window} + if usage is None: + return True, base, "No agent turns yet in this session." + + return True, { + **base, + "input_tokens": usage.get("input", 0), + "output_tokens": usage.get("output", 0), + "reasoning_tokens": usage.get("reasoning", 0), + "cache_read_tokens": usage.get("cache_read", 0), + "cache_write_tokens": usage.get("cache_write", 0), + }, "" def reset_connection(self) -> "tuple[bool, str]": - """Clear the active cloud connection and revert the agent to the uninitialized state.""" - self.chain_ollama = None - self.chain_openrouter = None - self.openrouter_api_key = None - self.openrouter_model = os.environ.get("OPENROUTER_MODEL", "~google/gemini-flash-latest") - self.preferred_provider = "openrouter" + """Clear the active OpenCode connection and revert the agent to the + uninitialized state.""" + self.chain_opencode = None + self._opencode_chat = None + self.opencode_model = os.environ.get("OPENCODE_MODEL", "") + self.preferred_provider = "opencode" return True, "Agent connection reset. Type /init to set up again." + def clear_history(self) -> "tuple[bool, str]": + """Wipe the conversation history (self.history) without touching the + provider connection -- distinct from reset_connection, which drops the + connection but leaves history untouched. Only affects this SDK agent's + own history; OpenCode-backed sessions (the landing page, /loop jobs) + manage their own, separate context and are untouched by this.""" + count = len(self.history) + self.history = [] + return True, f"Cleared {count} history entries." + + def compact_history(self) -> "tuple[bool, str]": + """Summarize self.history via OpenCode, replacing it with a single + entry -- a real compaction (LLM summary), not a harder truncation of + the existing self.history[-5:] read-time cap.""" + if not self.history: + return True, "History is already empty; nothing to compact." + + if not self.chain_opencode: + return False, "Could not compact history -- no provider available." + + transcript = "\n".join(self.history) + summarize_prompt = ChatPromptTemplate.from_messages([ + ("system", "Summarize the following agent conversation log into a short, " + "dense paragraph that preserves the concrete actions taken and " + "any user preferences stated. Output only the summary text, no " + "preamble."), + ("human", "{transcript}"), + ]) + + try: + response = (summarize_prompt | self.chain_opencode).invoke({"transcript": transcript}) + summary = response.content if hasattr(response, "content") else str(response) + summary = summary.strip() + if summary: + before = len(self.history) + self.history = [f"Summary of {before} prior entries: {summary}"] + return True, f"Compacted {before} entries into one summary." + except Exception as e: + _LOGGER.error(f"OpenCode failed to compact history: {e}") + + return False, "Could not compact history -- no provider available." + def _build_column_index(self): """Builds normalized token indexes and lightweight synonyms for column resolution.""" self._cols = list(self.df_schema['columns']) @@ -1702,24 +1643,12 @@ def _query_langchain(self, name: str, chain, instruction: str, system_prompt: st prompt = ChatPromptTemplate.from_messages([("system", escaped_sys), ("human", "{instruction}")]) - # Optionally bind schema-validated structured output (Intent) so the - # model returns a parsed object directly, skipping the free-form JSON - # + regex-repair path in _parse_intent_from_response. Gated because - # not every OpenRouter route supports it (see _load_config). - runnable_chain = chain - if name == "openrouter" and getattr(self, "openrouter_structured_output", False): - try: - runnable_chain = chain.with_structured_output(Intent) - except Exception as e: - _LOGGER.warning(f"[{name}] structured output unavailable, falling back to free-form JSON: {e}") - runnable_chain = chain - # Time the pure LLM round-trip (see plan Phase A). This is the # dominant cost of a query; ~chars/4 is a rough token estimate. prompt_chars = len(escaped_sys) + len(instruction) - model_name = getattr(self, f"{name}_model", None) or getattr(self, "openrouter_model", name) + model_name = getattr(self, f"{name}_model", None) or name _t0 = time.perf_counter() - response = (prompt | runnable_chain).invoke({"instruction": instruction}) + response = (prompt | chain).invoke({"instruction": instruction}) _llm_elapsed = time.perf_counter() - _t0 _LOGGER.info( @@ -1774,7 +1703,7 @@ def _is_auth_error(error) -> bool: ) def _try_query_provider(self, provider: str, instruction: str, system_prompt: str) -> Optional[List[dict]]: - # 1. Dynamically find the chain (chain_openrouter, chain_ollama) + # 1. Dynamically find the chain (chain_opencode) chain = getattr(self, f"chain_{provider}", None) # 2. If it exists, use the standard LangChain method @@ -1852,8 +1781,6 @@ def query(self, instruction: str, abort_event: Optional[threading.Event] = None, ) order = [self.preferred_provider] - if self.fallback_to_local and self.preferred_provider != "ollama": - order.append("ollama") for provider in order: if abort_event and abort_event.is_set(): return [] @@ -1872,19 +1799,19 @@ def query(self, instruction: str, abort_event: Optional[threading.Event] = None, # If we get here, all providers failed error_msg = "Internal Agent Error: Failed to generate a plan." if self._is_auth_error(self._last_query_error): - # The provider rejected our credentials — the agent isn't - # connected. Invalidate the cached client so is_available()/ - # CheckAgentHealth immediately stop reporting "available" for a - # connection that just proved broken, instead of leaving the - # health check permanently stale until the process restarts. - self.chain_openrouter = None + # OpenCode rejected the request -- the agent isn't connected. + # Invalidate the cached client so is_available()/CheckAgentHealth + # immediately stop reporting "available" for a connection that + # just proved broken, instead of leaving the health check + # permanently stale until the process restarts. + self.chain_opencode = None + self._opencode_chat = None error_msg = ( - "Agent not connected: the LLM provider rejected the request " - "(401 Unauthorized). Check your API key and re-initialize the " - "agent with /init." + "Agent not connected: OpenCode rejected the request " + "(401 Unauthorized). Re-initialize the agent with /init." ) - elif not self.is_ollama_available() and not os.environ.get("OPENROUTER_API_KEY"): - error_msg = "No LLM providers configured. Please check your API keys or local Ollama setup. Initialize the agent with /init." + elif not self.is_available(): + error_msg = "No LLM provider configured. Please check your OpenCode server (OPENCODE_URL) and initialize the agent with /init." _LOGGER.info(f"[Agent] Query total wall time: {time.perf_counter() - _query_t0:.2f}s (no provider succeeded)") return [{"function": "out_of_scope", "params": {"reason": error_msg}}] @@ -1957,14 +1884,14 @@ def generate_code(self, prompt: str, context_code: str = ""): """Propose Python for a notebook cell from a natural-language ``prompt``. This does NOT execute anything and does NOT touch the intent pipeline; it - only asks the active LLM provider for runnable code. Returns a - ``(code, explanation)`` tuple. Raises RuntimeError when no provider is - configured so the service can report a clean error. + only asks OpenCode for runnable code. Returns a ``(code, explanation)`` + tuple. Raises RuntimeError when no provider is configured so the + service can report a clean error. """ if not self.is_available(): raise RuntimeError( - "Agent not configured. Initialize a provider with /init (or run a " - "local Ollama server) before using \">\" notebook cells." + "Agent not configured. Initialize OpenCode with /init before " + "using \">\" notebook cells." ) system_prompt = NOTEBOOK_CODE_PROMPT.format( @@ -1978,33 +1905,17 @@ def generate_code(self, prompt: str, context_code: str = ""): [("system", escaped_sys), ("human", "{instruction}")] ) - order = [self.preferred_provider] - if self.fallback_to_local and self.preferred_provider != "ollama": - order.append("ollama") - - last_error = None - for provider in order: - chain = getattr(self, f"chain_{provider}", None) - if chain is None: - continue - try: - response = (chat_prompt | chain).invoke({"instruction": prompt}) - text = getattr(response, "content", None) - if text is None: - text = str(response) - code, explanation = self._extract_code_and_explanation(text) - self.history.append(f"User (notebook): {prompt}") - self.history.append("Action: proposed notebook code") - return code, explanation - except Exception as exc: - last_error = exc - _LOGGER.warning("[notebook code-gen] provider %s failed: %s", provider, exc) - continue - - raise RuntimeError( - f"Code generation failed: {last_error}" if last_error - else "Code generation failed: no provider produced a response." - ) + try: + response = (chat_prompt | self.chain_opencode).invoke({"instruction": prompt}) + text = getattr(response, "content", None) + if text is None: + text = str(response) + code, explanation = self._extract_code_and_explanation(text) + self.history.append(f"User (notebook): {prompt}") + self.history.append("Action: proposed notebook code") + return code, explanation + except Exception as exc: + raise RuntimeError(f"Code generation failed: {exc}") # ------------------------------------------------------------------ # Experiment report narrative @@ -2017,15 +1928,15 @@ def generate_report_narrative(self, stats_summary: str) -> str: sees ``stats_summary`` (signal shapes + dataframe stats), never raw history, so it can comment on the numbers but not invent new ones. - Mirrors ``generate_code``'s provider-fallback shape, but returns plain - text (no code-fence extraction) and does not touch ``self.history`` -- - this is a side-channel report artifact, not a step in the - NL-to-data-operation conversation. + Mirrors ``generate_code``, but returns plain text (no code-fence + extraction) and does not touch ``self.history`` -- this is a + side-channel report artifact, not a step in the NL-to-data-operation + conversation. """ if not self.is_available(): raise RuntimeError( - "Agent not configured. Initialize a provider with /init (or run a " - "local Ollama server) before generating a report." + "Agent not configured. Initialize OpenCode with /init before " + "generating a report." ) system_prompt = REPORT_ANALYSIS_PROMPT.format( @@ -2036,25 +1947,9 @@ def generate_report_narrative(self, stats_summary: str) -> str: [("system", escaped_sys), ("human", "Write the analysis section.")] ) - order = [self.preferred_provider] - if self.fallback_to_local and self.preferred_provider != "ollama": - order.append("ollama") - - last_error = None - for provider in order: - chain = getattr(self, f"chain_{provider}", None) - if chain is None: - continue - try: - response = (chat_prompt | chain).invoke({}) - text = getattr(response, "content", None) - return text.strip() if text else str(response).strip() - except Exception as exc: - last_error = exc - _LOGGER.warning("[report narrative] provider %s failed: %s", provider, exc) - continue - - raise RuntimeError( - f"Report narrative generation failed: {last_error}" if last_error - else "Report narrative generation failed: no provider produced a response." - ) + try: + response = (chat_prompt | self.chain_opencode).invoke({}) + text = getattr(response, "content", None) + return text.strip() if text else str(response).strip() + except Exception as exc: + raise RuntimeError(f"Report narrative generation failed: {exc}") diff --git a/weightslab/trainer/services/agent/intent_prompt.py b/weightslab/trainer/services/agent/intent_prompt.py index b9e61154..8b07ac6a 100644 --- a/weightslab/trainer/services/agent/intent_prompt.py +++ b/weightslab/trainer/services/agent/intent_prompt.py @@ -44,7 +44,8 @@ | **Load weights** | "Load the model weights from step 500", "Roll back weights to step 500", "Load weights at step 500 from hash " | `action` (`action_name="load_weights"`, `action_params={{"step": 500}}`) | | **Tune hyperparameter** | "Set the batch size to 32", "Increase the learning rate by 10%", "Change the dumping model ratio to 15", "Change the evaluation ratio to 20" | `action` (`action_name="set_hyperparam"`; see rule 11) | | **Config question (READ-ONLY)** | "Show me the root log dir", "What is the batch size?", "Display the whole configuration", "Show the config" | `action` (`action_name="show_config"`; `action_params={{"param": ""}}` for one value, omit for the whole config) | -| **Experiment report** | "Generate a report", "How is this experiment going?", "Create a report on training progress", "Summarize the experiment" | `action` (`action_name="generate_experiment_report"`; optional `action_params={{"signals": ["", ...]}}`) | +| **Experiment report (new)** | "Generate a report", "How is this experiment going?", "Create a report on training progress", "Summarize the experiment" | `action` (`action_name="generate_experiment_report"`; optional `action_params={{"signals": ["", ...], "distributions": ["", ...]}}`) | +| **Experiment report (update)** | "Update the report", "Regenerate the report with...", "Add a histogram of X to the report", "Also include Y in it" | `action` (`action_name="generate_experiment_report"`; `action_params={{"update_existing": true, ...}}`) | | **History query** | "...that never had train loss below 0.5", "...whose loss was ever above 5", "min/max/mean loss OVER TRAINING" | `transform`/`keep` using `signal_history(...)` (see rule 10) | --- @@ -100,7 +101,12 @@ - `"load_weights"` — load ONLY model weights, optionally at a specific step: `action_params={{"step": }}` (and optionally `"hash": ""`; defaults to the current experiment). - `"set_hyperparam"` — change a training hyperparameter: `action_params={{"param": "", "op": "", "value": }}` (see rule 11). - `"show_config"` — READ-ONLY: display the experiment configuration. Omit `action_params` to dump the whole config, or pass `action_params={{"param": ""}}` to show a single value (e.g. `"root_log_dir"`, `"batch_size"`). Never modifies anything — use it for any "show/what is/display the config/setting" question. - - `"generate_experiment_report"` — READ-ONLY: build an HTML report (signal trajectory plots + health classification + dataset stats + a written analysis) summarizing how the current experiment is going, saved under the experiment's `reports/` directory. Optionally pass `action_params={{"signals": ["", ...]}}` to report on specific signals instead of the automatically-selected most-important ones. Never modifies anything. + - `"generate_experiment_report"` — READ-ONLY: build an HTML report (signal trajectory plots + health classification + dataset stats + a written analysis) summarizing how the current experiment is going, saved under the experiment's `reports/` directory. Optionally pass `action_params={{"signals": ["", ...]}}` to report on specific signals instead of the automatically-selected most-important ones, and/or `action_params={{"distributions": ["", ...]}}` to ADD a "Distributions" section with a value-distribution histogram for each named column/signal (e.g. "add a histogram of train_loss to the report" -> `distributions=["train_loss"]`). Both may be combined in one call. Never modifies anything. This is the ONLY correct way to satisfy "generate a report"/"how is this experiment going"/"summarize training progress" — never decompose a report request into several separate `analysis`/`data_analysis` steps to compute numbers yourself; the report already computes and plots everything in one deterministic pass. + + **`"update_existing"` (bool, default omitted = `false`)** — decides which FILE the report is written to: + - `false`/omitted → always write a brand-new, separately timestamped report. Use this for a request that doesn't reference an existing report: "Generate a report", "How is this experiment going?", "Create a report on training progress". + - `true` → overwrite the MOST RECENTLY generated report for this experiment instead of creating another one. Use this whenever the user's wording refers to a report that (as far as they're concerned) already exists: "Update the report", "Regenerate the report with...", "Add a histogram of X to the report", "Also include Y in it", "Redo the report but...". If nothing has been generated yet, the backend transparently falls back to creating a fresh one — you never need to check first. + - When you set `update_existing=true` for a request that only adds ONE new thing (e.g. "also add a histogram of val_loss"), check the conversation History for a prior `generate_experiment_report` call in this session and CARRY FORWARD its `signals`/`distributions` alongside the new one (e.g. previous `distributions=["train_loss"]` + this request -> `distributions=["train_loss", "val_loss"]`) — updating overwrites the whole file, so anything not re-listed would otherwise silently disappear from it. If you can't find a prior call to carry forward from, just pass what was asked for this turn. - `action_params`: Optional dict of parameters for the action (e.g. `{{"architecture": true}}`, `{{"hash": "abc123..."}}`, `{{"step": 500}}`, `{{"param": "batch_size", "op": "set", "value": 32}}`). --- @@ -860,7 +866,7 @@ **Ex51: Generate An Experiment Report (READ-ONLY)** User: "How is this experiment going? Generate a report." {{ - "reasoning": "Read-only request for a summary of experiment health. Use generate_experiment_report with no params so it auto-selects the most important signals.", + "reasoning": "Read-only request for a summary of experiment health. Use generate_experiment_report with no params so it auto-selects the most important signals. This one action already builds the whole report (plots, stats, analysis) -- it must NOT be split into separate analysis/data_analysis steps.", "primary_goal": "action", "steps": [ {{ @@ -871,6 +877,52 @@ }} +**Ex52: Add A Histogram Distribution To The Report (READ-ONLY, Update-In-Place)** +User: "Add a histogram distribution of train_loss to the report" +{{ + "reasoning": "The wording 'to THE report' refers to a report that (as far as the user is concerned) already exists, so this overwrites the most recent one (update_existing=true) rather than creating yet another timestamped file, in addition to requesting the Distributions section for this column.", + "primary_goal": "action", + "steps": [ + {{ + "kind": "action", + "action_name": "generate_experiment_report", + "action_params": {{ "update_existing": true, "distributions": ["train_loss"] }} + }} + ] +}} + + +**Ex52b: Second Follow-Up Add Carries The First One Forward (READ-ONLY, Update-In-Place)** +History (last turn): user asked "Add a histogram distribution of train_loss to the report" -> generate_experiment_report with action_params={{"update_existing": true, "distributions": ["train_loss"]}} +User: "Now also add one for val_loss" +{{ + "reasoning": "Another update to the SAME report. Overwriting replaces the whole file, so train_loss's distribution (added last turn, per History) must be re-listed alongside val_loss or it would silently disappear from the report.", + "primary_goal": "action", + "steps": [ + {{ + "kind": "action", + "action_name": "generate_experiment_report", + "action_params": {{ "update_existing": true, "distributions": ["train_loss", "val_loss"] }} + }} + ] +}} + + +**Ex53: Report On Specific Signals Plus A Distribution (READ-ONLY)** +User: "Generate a report on train_loss and val_loss, and include a distribution of val_loss" +{{ + "reasoning": "Combine both optional params on the same generate_experiment_report action: signals restricts the trajectory plots, distributions adds the histogram section.", + "primary_goal": "action", + "steps": [ + {{ + "kind": "action", + "action_name": "generate_experiment_report", + "action_params": {{ "signals": ["train_loss", "val_loss"], "distributions": ["val_loss"] }} + }} + ] +}} + + --- diff --git a/weightslab/trainer/services/agent/opencode_chat.py b/weightslab/trainer/services/agent/opencode_chat.py new file mode 100644 index 00000000..e67581a4 --- /dev/null +++ b/weightslab/trainer/services/agent/opencode_chat.py @@ -0,0 +1,373 @@ +"""LangChain-`Runnable`-compatible wrapper around a local OpenCode server. + +OpenCode (https://opencode.ai) is a session-based, tool-using coding-agent +server -- not a plain chat-completions endpoint like OpenRouter/Ollama. This +module lets `DataManipulationAgent` (agent.py) use one as a third provider +without changing any of its three existing call sites (`query()`, +`generate_code()`, `generate_report_narrative()`), all of which do +`(prompt | chain).invoke(...)` and read `.content` off the result. + +Mirrors the wire protocol already implemented in +weights_studio/src/landing/agent/opencodeClient.ts: create a session, send a +message, and collect the assistant's text from the global SSE event stream +ending on `session.idle` -- a bare POST .../message response is not a +reliable completion/content signal on its own (weights_studio's TS client +treats it the same way, with its own idle-driven finish as the source of +truth). The stream must be opened BEFORE the message is sent, or events +emitted in between are lost (same "stream-first" rule the TS client follows). + +A fresh session is created per call rather than reused across calls: +`self.history` on `DataManipulationAgent` already carries cross-call context +via `INTENT_PROMPT`'s own `history=` placeholder, so OpenCode's own +multi-turn session memory isn't needed here, and a fresh session per call +avoids an ever-growing OpenCode-side history for a long-running experiment. + +Every mutating tool (write/edit/patch/bash) is explicitly disabled on the +outgoing message: this call wants a text/JSON reply for the SDK agent to +parse and act on itself, not file writes as a side effect. That is the +opposite default from the Weights Studio landing-page chat, which +deliberately runs OpenCode's full toolset. + +`_ensure_reachable` (called at the top of every `_call`) is the other half +of that convergence: if `base_url` wasn't explicitly chosen and isn't +currently answering, it resolves or spawns one via +`weightslab/opencode_process.py`'s cross-process lock file, so this agent +and the browser landing-page chat end up on the SAME OpenCode server +regardless of which one happens to start first -- two separate sessions on +it (this class still creates a fresh one per call, as above), not one +shared session, since the two sides send incompatible message shapes +(structured-JSON/no-tools here, free-form/full-tools there) that would +otherwise bleed into each other's context. +""" + +from __future__ import annotations + +import json +import logging +import threading +import time +import urllib.error +import urllib.request +from typing import Optional + +_LOGGER = logging.getLogger(__name__) + +# Mutating tools, disabled on every message this wrapper sends -- see module +# docstring. Named explicitly (not an allowlist) so a newly-added read-only +# tool on the OpenCode side keeps working without a change here. +_MUTATING_TOOLS = ("write", "edit", "patch", "bash") + +# Last-resort model for _ensure_model_resolved, below: a free-tier model so a +# totally fresh OpenCode install (no provider credentials configured at all, +# so /config has no model and /config/providers has no defaults either) +# still gets a usable text-reasoning model, instead of leaving `self.model` +# unset -- which is exactly the "OpenCode picks WHATEVER model happens to be +# configured, arbitrarily" failure this method exists to avoid in the first +# place. +_DEFAULT_MODEL = "opencode/deepseek-v4-flash-free" + + +class OpenCodeError(RuntimeError): + """Raised when a call to the OpenCode server fails outright (not just an + empty/partial reply -- those degrade to whatever text was collected).""" + + +class OpenCodeChat: + """One instance per configured `(base_url, model)` pair; safe to reuse + across calls (`_call` is the only state-touching method, and it is + self-contained per invocation).""" + + def __init__(self, base_url: str, model: Optional[str] = None, timeout: float = 60.0, + workspace_dir: Optional[str] = None, url_is_explicit: bool = True, + model_is_explicit: bool = True): + self.base_url = (base_url or "http://127.0.0.1:4096").rstrip("/") + self.model = model + self.timeout = timeout + # Token usage from the LAST completed `_call`, or None before any call + # has finished -- {"input", "output", "reasoning", "cache_read", + # "cache_write"}. Populated by `_collect_reply`; read by + # `DataManipulationAgent.get_context_usage()` for the /context command. + # There is no persistent OpenCode session to total across calls (see + # module docstring), so this is deliberately per-call, not cumulative. + self.last_usage: Optional[dict] = None + # See _ensure_reachable: workspace_dir is where opencode_process.py's + # cross-process lock file for this experiment lives, and + # url_is_explicit says whether base_url came from something the user + # (or agent_config.yaml) actually chose -- if so, a dead address + # stays dead rather than being silently swapped for an auto-spawned + # one on a different port. + self.workspace_dir = workspace_dir + self.url_is_explicit = url_is_explicit + # See _ensure_model_resolved: model_is_explicit says whether `model` + # came from something the user (or agent_config.yaml) actually + # chose -- if not, an empty model here isn't "let OpenCode pick", + # it's "OpenCode already picks arbitrarily when none is given" + # (confirmed live: an image-generation preview model, useless for + # this class's structured-JSON-reply use case). + self.model_is_explicit = model_is_explicit + + # -- wire helpers --------------------------------------------------- # + + def _model_ref(self) -> Optional[dict]: + """OpenCode identifies a model as {providerID, modelID}; our config + carries it as one "providerID/modelID" string (matching the exact + convention used throughout the frontend -- see opencodeClient.ts's + formatModelValue/parseModelValue). Split on the FIRST slash only: + model IDs themselves often contain slashes (e.g. OpenRouter's + "anthropic/claude-opus-4.6"), so a naive split would truncate it.""" + if not self.model or "/" not in self.model: + return None + provider_id, model_id = self.model.split("/", 1) + return {"providerID": provider_id, "modelID": model_id} + + def _request(self, path: str, method: str = "GET", body: Optional[dict] = None, headers: Optional[dict] = None): + data = json.dumps(body).encode("utf-8") if body is not None else None + all_headers = {"Content-Type": "application/json"} if body is not None else {} + all_headers.update(headers or {}) + req = urllib.request.Request(f"{self.base_url}{path}", data=data, headers=all_headers, method=method) + return urllib.request.urlopen(req, timeout=self.timeout) + + def _create_session(self) -> str: + try: + with self._request("/session", method="POST", body={"title": "weightslab-sdk-agent"}) as resp: + data = json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, ValueError) as exc: + raise OpenCodeError(f"Could not create an OpenCode session at {self.base_url}: {exc}") from exc + session_id = data.get("id") + if not session_id: + raise OpenCodeError(f"OpenCode did not return a session id: {data!r}") + return session_id + + def _send_message(self, session_id: str, text: str) -> None: + body = { + "parts": [{"type": "text", "text": text}], + "tools": {name: False for name in _MUTATING_TOOLS}, + } + model_ref = self._model_ref() + if model_ref: + body["model"] = model_ref + try: + self._request(f"/session/{session_id}/message", method="POST", body=body).read() + except urllib.error.URLError as exc: + raise OpenCodeError(f"OpenCode rejected the prompt: {exc}") from exc + + @staticmethod + def _handle_event( + payload: str, + session_id: str, + assistant_message_ids: set, + text_parts: dict, + usage: Optional[dict] = None, + ) -> Optional[str]: + """Returns None while the turn is still in progress, or a string + ("idle"/"error") once this session's turn has finished. Field names + are read defensively -- OpenCode's event shapes are not part of its + published docs, so a minor server change should degrade to "no text + collected" rather than raising inside the stream loop.""" + try: + event = json.loads(payload) + except (ValueError, TypeError): + return None + event_type = event.get("type") + props = event.get("properties") or {} + + if event_type == "message.updated": + msg = props.get("info") or props.get("message") or props + msg_session = str(msg.get("sessionID") or "") + if msg_session and msg_session != session_id: + return None + if str(msg.get("role") or "") == "assistant" and msg.get("id"): + assistant_message_ids.add(str(msg["id"])) + # AssistantMessage.tokens (GET /doc): {input, output, + # reasoning, cache: {read, write}} -- may be absent on early + # deltas of the same message id, so this overwrites `usage` + # in place rather than accumulating, same "latest wins" + # convention weights_studio's TS client uses for its own + # per-message token map. + tokens = msg.get("tokens") + if usage is not None and isinstance(tokens, dict): + cache = tokens.get("cache") or {} + usage["input"] = tokens.get("input") or 0 + usage["output"] = tokens.get("output") or 0 + usage["reasoning"] = tokens.get("reasoning") or 0 + usage["cache_read"] = cache.get("read") or 0 + usage["cache_write"] = cache.get("write") or 0 + return None + + if event_type == "message.part.updated": + part = props.get("part") or props + part_session = str(part.get("sessionID") or "") + if part_session and part_session != session_id: + return None + message_id = str(part.get("messageID") or "") + if message_id not in assistant_message_ids: + return None + if part.get("type") == "text" and isinstance(part.get("text"), str): + part_id = str(part.get("id") or message_id) + text_parts[part_id] = part["text"] + return None + + if event_type == "session.idle" and str(props.get("sessionID") or "") == session_id: + return "idle" + + if event_type == "session.error" and str(props.get("sessionID") or "") == session_id: + return "error" + + return None + + def _collect_reply(self, session_id: str, text: str) -> str: + """Open the SSE stream, THEN send the message on a background thread, + THEN read the stream until this session goes idle/errors. Ordering + matters: opening the stream first means it starts buffering before the + message is sent, so nothing emitted in the gap between "create + session" and "start reading" is lost (see module docstring).""" + text_parts: dict = {} + assistant_message_ids: set = set() + usage: dict = {} + + try: + stream = self._request("/event", headers={"Accept": "text/event-stream"}) + except urllib.error.URLError as exc: + raise OpenCodeError(f"Could not open the OpenCode event stream at {self.base_url}: {exc}") from exc + + send_errors: list = [] + + def _send() -> None: + try: + self._send_message(session_id, text) + except Exception as exc: # noqa: BLE001 - surfaced via send_errors + send_errors.append(exc) + + sender = threading.Thread(target=_send, daemon=True) + + try: + sender.start() + data_lines: list = [] + deadline = time.monotonic() + self.timeout + for raw_line in stream: + if time.monotonic() > deadline: + _LOGGER.warning("OpenCode event stream timed out waiting for session %s", session_id) + break + line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n") + if line == "": + if data_lines: + payload = "\n".join(data_lines) + data_lines = [] + outcome = self._handle_event(payload, session_id, assistant_message_ids, text_parts, usage) + if outcome is not None: + break + continue + if line.startswith(":"): + continue + if line.startswith("data:"): + data_lines.append(line[5:].lstrip(" ")) + except (urllib.error.URLError, TimeoutError, OSError) as exc: + # A dropped stream degrades to whatever text was collected so far, + # matching the browser client's "stream dropped -> don't leave the + # turn hanging" behavior, rather than losing a partial reply. + _LOGGER.warning("OpenCode event stream dropped for session %s: %s", session_id, exc) + finally: + stream.close() + + sender.join(timeout=1.0) + if send_errors: + raise send_errors[0] + + # Set even on a dropped/errored stream (whatever was captured before + # that point) -- a partial usage read is still more useful than none, + # matching how a partial text reply is still returned above. + self.last_usage = usage or None + return "".join(text_parts[key] for key in text_parts) + + # -- public surface --------------------------------------------------- # + + def _ensure_reachable(self) -> None: + """Self-heal `base_url` before the first network call of a turn, so + this side and the browser landing-page chat converge on ONE OpenCode + server (see weightslab/opencode_process.py) regardless of which one + actually starts first -- without this, an agent constructed before + anything else has ever needed a server would stay pointed at a dead + default address for its entire life. + + Deliberately lazy (called here, not from __init__): constructing + this class must stay fast/side-effect-free even when nothing is + listening yet, since it is built unconditionally on every + DataService startup whether or not the user ever opens the agent + chat (see docs/agent.rst on why connectivity is never eagerly + checked). A dead explicit URL (url_is_explicit=True) is left alone + -- that address was deliberately chosen, not a placeholder to + auto-replace. + """ + if self.url_is_explicit: + return + from weightslab.opencode_process import opencode_healthy, resolve_or_spawn_opencode + if opencode_healthy(self.base_url): + return + result = resolve_or_spawn_opencode(self.workspace_dir or ".") + if result.get("ok") and result.get("url"): + self.base_url = result["url"].rstrip("/") + + def _ensure_model_resolved(self) -> None: + """Self-heal `self.model` before the first network call of a turn, + same reasoning as _ensure_reachable but for the model instead of the + address: leaving it unset does NOT mean "OpenCode picks a sensible + default" -- it means OpenCode picks WHATEVER model happens to be + configured, arbitrarily (confirmed live: an image-generation + preview model was picked this way, and produced replies useless for + this class's structured-JSON intent-parsing, since it isn't a + text-reasoning model at all). + + Resolution order: + 1. `GET /config`'s own `model` field -- the one the model picker + writes back to opencode.json on every pick (opencodeClient.ts's + setDefaultModel), so it's "whatever the user last actually + chose", and survives across the browser, the CLI, and other + machines. The ONLY thing that counts as an actual choice here. + 2. `_DEFAULT_MODEL` -- a free-tier, always-available text/tool + model, used whenever step 1 comes back empty. + + `/config/providers`'s own `default` mapping used to be tried in + between (what OpenCode itself would otherwise fall back to for + whichever provider happens to be configured) -- dropped after this + was confirmed live to still resolve to an arbitrary, sometimes + non-text-reasoning model (an image-generation preview, the same + failure mode this method exists to avoid) whenever ANY provider had + credentials configured, even with no real model chosen -- silently + pre-empting _DEFAULT_MODEL every time. "At UI init, with nothing + explicitly chosen, land on the known-good free model" now means + exactly that, with no provider-reported default able to override it. + + Resolved once and cached on self.model. An explicit model + (model_is_explicit=True) is left alone -- deliberately chosen, not + a placeholder to override. + """ + if self.model_is_explicit or self.model: + return + try: + with self._request("/config") as resp: + config = json.loads(resp.read().decode("utf-8")) + model_id = (config or {}).get("model") + if isinstance(model_id, str) and "/" in model_id: + self.model = model_id + return + except Exception: # noqa: BLE001 - fall through to the hardcoded default + pass + self.model = _DEFAULT_MODEL + + def _call(self, prompt_value): + from langchain_core.messages import AIMessage + + self._ensure_reachable() + self._ensure_model_resolved() + text = prompt_value.to_string() if hasattr(prompt_value, "to_string") else str(prompt_value) + session_id = self._create_session() + reply = self._collect_reply(session_id, text) + return AIMessage(content=reply) + + def as_runnable(self): + """A `Runnable` usable exactly like `ChatOpenAI`/`ChatOllama` in + `(prompt | chain).invoke(...)` — the one integration point + `_query_langchain`/`generate_code`/`generate_report_narrative` need.""" + from langchain_core.runnables import RunnableLambda + + return RunnableLambda(self._call) diff --git a/weightslab/trainer/services/agent/report_prompt.py b/weightslab/trainer/services/agent/report_prompt.py index 588f99e2..d764ae2a 100644 --- a/weightslab/trainer/services/agent/report_prompt.py +++ b/weightslab/trainer/services/agent/report_prompt.py @@ -19,9 +19,9 @@ You are given already-computed statistics below and must not recompute, \ extrapolate, or second-guess them -- write an analysis grounded ONLY in what's \ -listed. The statistics have three parts, each bounded in size on purpose (so this \ -prompt stays small even for a dataset with millions of samples -- neither of us \ -ever sees a full per-sample dump): +listed. The statistics have up to four parts, each bounded in size on purpose (so \ +this prompt stays small even for a dataset with millions of samples -- neither of \ +us ever sees a full per-sample dump): - `signals`: per-signal AGGREGATE trajectory (the mean training curve) with its own shape label, plus (under `outliers`) a SMALL handful of specific sample_ids that @@ -33,6 +33,12 @@ any concerning label. Absent/empty means it hasn't been computed for this experiment -- say so briefly rather than assuming every sample is fine. - `dataframe`: sample counts, discard rate, splits, other tags. +- `distributions`: OPTIONAL -- only present when the user explicitly asked for a + histogram of a specific column (e.g. "add a histogram of train_loss"). Each entry + has `name`, `n`, `mean`, `std`, `min`, `max` for that column's CURRENT per-sample + values -- a snapshot spread, not a trajectory. Usually absent/empty; when present + you may mention it briefly (e.g. a wide spread, or min/max worth flagging), but + never invent one that isn't listed. Rules: - 3 to 6 sentences of plain prose. No JSON, no markdown headers/bullets, no code. diff --git a/weightslab/trainer/services/agent_service.py b/weightslab/trainer/services/agent_service.py index e39998b0..3403b370 100644 --- a/weightslab/trainer/services/agent_service.py +++ b/weightslab/trainer/services/agent_service.py @@ -77,10 +77,15 @@ def CheckAgentHealth(self, request, context): @safe_grpc(lambda msg: pb2.InitializeAgentResponse(success=False, message=msg)) def InitializeAgent(self, request, context): """ - Initialize the OpenRouter cloud provider with a user-supplied API key. + Initialize the OpenCode agent backend. - Supported provider (AgentProviderType enum): - PROVIDER_OPENROUTER (0) — openrouter.ai + Supported providers (AgentProviderType enum): + PROVIDER_OPENCODE (1) — a local OpenCode server; api_key is + ignored, the credential lives in OpenCode's own config. + PROVIDER_OPENROUTER (0) is a legacy enum value kept for wire + compatibility with older frontends; requesting it is rejected + below rather than removed from the .proto, so an old client + sending it gets a clean error instead of a decode failure. Returns: InitializeAgentResponse { success: bool, message: str } @@ -93,10 +98,10 @@ def InitializeAgent(self, request, context): ) provider_name = AGENT_PROVIDER_MAP.get(request.provider) - if provider_name != "openrouter": + if provider_name != "opencode": return pb2.InitializeAgentResponse( success=False, - message="Only OpenRouter cloud onboarding is supported.", + message="Only OpenCode is supported as the agent backend.", ) logger.debug( @@ -112,7 +117,7 @@ def InitializeAgent(self, request, context): @safe_grpc(lambda msg: pb2.ChangeAgentModelResponse(success=False, message=msg)) def ChangeAgentModel(self, request, context): """ - Switch the active OpenRouter model without re-entering the API key. + Switch the active OpenCode model without re-entering credentials. Returns: ChangeAgentModelResponse { success: bool, message: str } @@ -131,7 +136,7 @@ def ChangeAgentModel(self, request, context): @safe_grpc(lambda msg: pb2.GetAgentModelsResponse(success=False, models=[], message=msg)) def GetAgentModels(self, request, context): """ - Return the list of models available via the stored OpenRouter API key. + Return every provider/model the OpenCode server is authenticated for. Returns: GetAgentModelsResponse { success: bool, models: [str], message: str } @@ -160,3 +165,59 @@ def ResetAgent(self, request, context): logger.debug("ResetAgent") success, message = agent.reset_connection() return pb2.ResetAgentResponse(success=success, message=message) + + @safe_grpc(lambda msg: pb2.ClearAgentHistoryResponse(success=False, message=msg)) + def ClearAgentHistory(self, request, context): + """Wipe the agent's conversation history without touching the provider + connection -- distinct from ResetAgent.""" + agent = self._agent + if agent is None: + return pb2.ClearAgentHistoryResponse( + success=False, + message="Agent backend is not running.", + ) + + logger.debug("ClearAgentHistory") + success, message = agent.clear_history() + return pb2.ClearAgentHistoryResponse(success=success, message=message) + + @safe_grpc(lambda msg: pb2.CompactAgentHistoryResponse(success=False, message=msg)) + def CompactAgentHistory(self, request, context): + """Summarize the agent's conversation history via the active provider, + replacing it with the summary.""" + agent = self._agent + if agent is None: + return pb2.CompactAgentHistoryResponse( + success=False, + message="Agent backend is not running.", + ) + + logger.debug("CompactAgentHistory") + success, message = agent.compact_history() + return pb2.CompactAgentHistoryResponse(success=success, message=message) + + @safe_grpc(lambda msg: pb2.GetAgentContextUsageResponse(success=False, message=msg)) + def GetAgentContextUsage(self, request, context): + """Context-window usage breakdown for the active model (backs the + /context command) -- see DataManipulationAgent.get_context_usage.""" + agent = self._agent + if agent is None: + return pb2.GetAgentContextUsageResponse( + success=False, + message="Agent backend is not running.", + ) + + logger.debug("GetAgentContextUsage") + ok, usage, message = agent.get_context_usage() + return pb2.GetAgentContextUsageResponse( + success=ok, + message=message, + model=usage.get("model") or "", + context_window=usage.get("context_window") or 0, + input_tokens=usage.get("input_tokens") or 0, + output_tokens=usage.get("output_tokens") or 0, + reasoning_tokens=usage.get("reasoning_tokens") or 0, + cache_read_tokens=usage.get("cache_read_tokens") or 0, + cache_write_tokens=usage.get("cache_write_tokens") or 0, + ) + return pb2.CompactAgentHistoryResponse(success=success, message=message) diff --git a/weightslab/trainer/services/data_service.py b/weightslab/trainer/services/data_service.py index 4d94955e..39bce79a 100755 --- a/weightslab/trainer/services/data_service.py +++ b/weightslab/trainer/services/data_service.py @@ -904,7 +904,7 @@ def _log_audit( def _is_agent_available(self) -> bool: """ - Check if the agent (Ollama) is available for natural language queries. + Check if the agent (OpenCode) is available for natural language queries. Returns: bool: True if agent is available, False otherwise @@ -2814,7 +2814,8 @@ def _agent_show_config(self, param=None) -> str: text = text[:max_len] + "\n... (truncated; ask for a specific key, e.g. 'show the root log dir')" return f"Configuration ({hp_name}):\n{text}" - def _agent_generate_experiment_report(self, signals=None) -> str: + def _agent_generate_experiment_report(self, signals=None, distributions=None, + update_existing=False) -> str: """Agent action (READ-ONLY): build an HTML experiment report -- signal trajectory plots + health classification + dataset stats + a written analysis -- saved under the experiment's ``reports/`` directory. @@ -2827,6 +2828,17 @@ def _agent_generate_experiment_report(self, signals=None) -> str: the collected numbers, never raw history, see report_prompt.py. A failed/unavailable LLM degrades to a report with no narrative rather than no report at all. + + ``distributions`` (optional column/signal names) adds a Distributions + section of value-distribution histograms -- e.g. "generate a report + with a histogram of train_loss" -> ``distributions=["train_loss"]``. + + ``update_existing`` (default ``False``) means "update the report" / + "add X to the report" -- overwrite the most recently generated report + for this experiment instead of writing a new timestamped one. A plain + "generate a report" (no reference to an existing one) should leave + this ``False`` and always get a fresh file; see intent_prompt.py's + `generate_experiment_report` guidance for when the LLM should set it. """ from weightslab.backend import ledgers from weightslab import reporting @@ -2852,12 +2864,14 @@ def _agent_generate_experiment_report(self, signals=None) -> str: result = reporting.generate_report( root_log_dir, logger_q, df, signals=signals, narrative_fn=self._agent.generate_report_narrative, + distributions=distributions, update_existing=bool(update_existing), ) except Exception as e: return f"Action: failed to generate report: {e}" suffix = "" if result["narrative"] else " (no written analysis -- agent LLM unavailable)" - return (f"Action: generated experiment report ({result['n_signals']} signal(s)) " + verb = "updated" if result["updated_existing"] else "generated" + return (f"Action: {verb} experiment report ({result['n_signals']} signal(s)) " f"at {result['path']}{suffix}") @staticmethod @@ -2974,7 +2988,10 @@ def _apply_agent_operation(self, df, func: str, params: dict) -> str: # classification + dataset stats + a written analysis). elif action_name in ("generate_experiment_report", "experiment_report", "create_report", "generate_report", "report"): - return self._agent_generate_experiment_report(signals=params.get("signals")) + return self._agent_generate_experiment_report( + signals=params.get("signals"), distributions=params.get("distributions"), + update_existing=bool(params.get("update_existing")), + ) return f"Action triggered: {action_name} (Not implemented)" diff --git a/weightslab/trainer/services/utils/tools.py b/weightslab/trainer/services/utils/tools.py index 11148009..f46f907b 100644 --- a/weightslab/trainer/services/utils/tools.py +++ b/weightslab/trainer/services/utils/tools.py @@ -22,9 +22,14 @@ # Maps the AgentProviderType proto enum integer values to the internal # provider names understood by DataManipulationAgent.initialize_with_cloud_key. -# Cloud onboarding is currently limited to OpenRouter. +# OpenCode (1) is the only agent backend weightslab actually supports; +# PROVIDER_OPENROUTER (0) is kept here (rather than removed) purely for wire +# compatibility with older frontends, so a client that still sends it gets a +# clean "not supported" rejection from AgentService.InitializeAgent instead of +# an unrecognized-enum-value decode failure. AGENT_PROVIDER_MAP: dict[int, str] = { 0: "openrouter", + 1: "opencode", } diff --git a/weightslab/ui/server.py b/weightslab/ui/server.py index 8a7fcd4f..11d01f60 100644 --- a/weightslab/ui/server.py +++ b/weightslab/ui/server.py @@ -36,14 +36,18 @@ import subprocess import sys import threading +import time import webbrowser from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path from typing import Iterable, Optional, Tuple from urllib.parse import parse_qs, quote, unquote, urlsplit import grpc +from weightslab import opencode_process + # --------------------------------------------------------------------------- # # Constants # --------------------------------------------------------------------------- # @@ -338,10 +342,1164 @@ def _kill_process_tree(process: "subprocess.Popen") -> None: pass +def _kill_pid_tree(pid: int) -> None: + """Same tree-kill as _kill_process_tree, but for a PID this server never + held a subprocess.Popen handle for -- see _TrackedProcesses, below.""" + if os.name == "nt": + try: + subprocess.run( + ["taskkill", "/T", "/F", "/PID", str(pid)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + except Exception: # pragma: no cover - best-effort cleanup + pass + else: + try: + os.killpg(os.getpgid(pid), signal.SIGTERM) + except Exception: + try: + os.kill(pid, signal.SIGTERM) + except Exception: # pragma: no cover - already gone + pass + + +class _TrackedProcesses: + """PIDs the agent (landing-page chat or a /loop job) told us about right + after launching something DETACHED (training, a relaunched crashed run, + ...), via ``POST /agent-server/track-process``. + + A detached process (``Start-Process ... -WindowStyle Hidden``, or POSIX + ``setsid``) is invisible to _kill_process_tree's own ancestor-based + tree-kill: that walk only finds descendants whose PID/PPID chain is + traceable through STILL-LIVE intermediate processes at kill time, and a + detached launcher's immediate shell typically exits almost immediately + after spawning it, breaking that chain permanently (Windows keeps no + record of an exited process, so a later `taskkill /T` from any ancestor + higher up can never discover a child of a parent that's already gone). + Tracking the PID directly here sidesteps the whole problem -- this + server kills it explicitly, by PID, needing no intermediate chain at + all. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + self._pids: "set[int]" = set() + + def track(self, pid: int) -> None: + with self._lock: + self._pids.add(pid) + + def shutdown(self) -> None: + with self._lock: + pids = list(self._pids) + self._pids.clear() + for pid in pids: + _kill_pid_tree(pid) + + +_tracked_processes = _TrackedProcesses() +atexit.register(_tracked_processes.shutdown) + + +class _LatestDataQuery: + """The most recent query the landing-page agent ran itself via + POST /agent-server/data-query, so the browser can find out about it + without the agent's own bash/curl call ever touching the page's JS. + + A query typed directly into the UI updates the grid and the "now + viewing a subset -- reset?" banner as part of the SAME client-side call + that sent it (main.ts's handleQuerySubmit). The agent's bash tool has + no such hook into the page -- its curl call is a plain HTTP round trip + from a shell process, invisible to any open tab. agentChat.ts instead + polls GET /agent-server/data-query/latest once per turn (on + session.idle, not continuously) and compares `seq` against the last one + it already reacted to; a new one replays main.ts's own grid/banner + logic for this query's response. `query` is kept alongside the + response fields because the banner is rendered with the query's own + text, which the browser has no other way to learn for a bridge- + triggered call. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + self._seq = 0 + self._query: Optional[str] = None + self._payload: dict = {} + + def record(self, query: str, payload: dict) -> None: + with self._lock: + self._seq += 1 + self._query = query + self._payload = dict(payload) + + def get(self) -> dict: + with self._lock: + if self._seq == 0: + return {"seq": 0} + return {"seq": self._seq, "query": self._query, **self._payload} + + +_latest_data_query = _LatestDataQuery() + + _jupyter_session = _JupyterSession() atexit.register(_jupyter_session.shutdown) +# --------------------------------------------------------------------------- # +# OpenCode agent server (backs the landing-page agent chat) +# --------------------------------------------------------------------------- # + +# Generous: a cold `npx` run downloads the package before the server binds. +_OPENCODE_START_TIMEOUT = 45.0 + + +def _free_port() -> int: + """Reserve an unused loopback port by binding and releasing it. + + We pick the port ourselves rather than parsing it out of the child's stdout: + that gives us a URL to health-poll immediately, and avoids depending on the + exact wording of a startup log line we do not control. + """ + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _resolve_opencode_argv() -> Optional[list]: + """Locate a way to run OpenCode, preferring an already-installed binary. + + Falls back to ``npx --yes``, which fetches the package into the npx cache on + first use. That is deliberately *not* ``npm install -g``: a global install may + need elevated permissions and mutates the user's toolchain behind their back, + while the npx path needs neither and is equally automatic. + """ + exe = shutil.which("opencode") + if exe: + return [exe] + npx = shutil.which("npx") + if npx: + return [npx, "--yes", "opencode-ai@latest"] + return None + + +def _opencode_healthy(base_url: str, timeout: float = 1.5) -> bool: + import urllib.error + import urllib.request + try: + with urllib.request.urlopen(base_url + "/global/health", timeout=timeout) as resp: + return 200 <= int(resp.status) < 300 + except Exception: + return False + + +def _cors_origin_variants(origin: Optional[str]) -> list: + """Expand one origin into the set a browser might actually send. + + ``localhost`` and ``127.0.0.1`` are *different* origins to the CORS check, and + getting that wrong produces the single most confusing failure in this whole + feature: every request is blocked, and from the page it is indistinguishable + from the server being down. So allow both spellings of whichever we were given. + """ + origins: list = [] + + def add(value: str) -> None: + if value and value not in origins: + origins.append(value) + + if origin: + add(origin) + if "localhost" in origin: + add(origin.replace("localhost", "127.0.0.1")) + elif "127.0.0.1" in origin: + add(origin.replace("127.0.0.1", "localhost")) + return origins + + +class _OpencodeSession: + """Tracks the single OpenCode server process this UI server has launched. + + The browser cannot start a process, so the landing-page agent asks us to do it + (``POST /agent-server/start``). The child is rooted at the experiment + directory, which becomes the agent's workspace, and is torn down with this + UI server so it never outlives the session that spawned it. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + self._process: Optional[subprocess.Popen] = None + self._port: Optional[int] = None + self._workspace: Optional[str] = None + self._error: Optional[str] = None + self._log: list = [] + # Set when OPENCODE_URL points at an already-running server we adopted + # instead of spawning our own -- see ensure(). Mutually exclusive with + # self._process; only one of the two is ever active at a time. + self._external_url: Optional[str] = None + + def _running_locked(self) -> bool: + if self._external_url is not None: + return True + return self._process is not None and self._process.poll() is None + + def _url_locked(self) -> Optional[str]: + if self._external_url is not None: + return self._external_url + return f"http://127.0.0.1:{self._port}" if self._port else None + + def _drain_output(self, process: "subprocess.Popen[str]") -> None: + # Must be drained for the life of the process or the pipe buffer fills and + # blocks the child. We keep only a short tail, purely so a failed start can + # report why instead of a bare timeout. + try: + for line in process.stdout: # type: ignore[union-attr] + self._log.append(line.rstrip()) + del self._log[:-15] + except Exception: # pragma: no cover - best-effort log draining + pass + + def ensure(self, workspace_dir: str, origin: Optional[str]) -> dict: + """Start the agent server if it is not already running, and wait until it + answers a health check. Idempotent: a second call while alive is a no-op. + + If OPENCODE_URL is set and healthy, adopt it directly instead of + spawning a child -- this is what makes the shared root config in + agent.py's _load_config actually converge: point both the SDK agent + and this UI server at one already-running server via one env var, + rather than each independently spawning its own. + + Failing that, check opencode_process.py's lock file for this same + workspace directory: the backend SDK agent (agent.py's + DataManipulationAgent, via OpenCodeChat._ensure_reachable) writes + one there the first time IT needs a server and none exists yet, so + a `weightslab start ` that comes along afterward -- with no + OPENCODE_URL set by anyone -- still adopts that same server instead + of spawning a second one for the identical experiment directory. + """ + _ensure_workspace_agent_files(workspace_dir) + with self._lock: + if self._running_locked(): + return {"ok": True, "url": self._url_locked(), "reused": True, + "workspace": self._workspace} + + external_url = os.environ.get("OPENCODE_URL", "").strip() + if external_url and _opencode_healthy(external_url): + with self._lock: + self._external_url = external_url + self._workspace = workspace_dir + self._error = None + return {"ok": True, "url": external_url, "reused": False, "workspace": workspace_dir} + + lock = opencode_process.read_lock(workspace_dir) + if lock and lock.get("url") and _opencode_healthy(lock["url"]): + with self._lock: + self._external_url = lock["url"] + self._workspace = workspace_dir + self._error = None + return {"ok": True, "url": lock["url"], "reused": False, + "workspace": workspace_dir, "adopted": "lockfile"} + + with self._lock: + argv = _resolve_opencode_argv() + if argv is None: + self._error = ( + "Could not find `opencode` or `npx`. Install Node.js 20+ " + "(which provides npx), or `npm i -g opencode-ai`." + ) + return {"ok": False, "error": self._error} + + port = _free_port() + cmd = argv + ["serve", "--hostname", "127.0.0.1", "--port", str(port)] + for value in _cors_origin_variants(origin): + cmd += ["--cors", value] + + self._log = [] + self._error = None + try: + process = subprocess.Popen( + cmd, + cwd=workspace_dir, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + # Process-group leader so shutdown() can kill the whole tree -- + # npx spawns the real binary as a child, so killing only this + # PID would orphan the server. + start_new_session=True, + ) + except Exception as exc: # pragma: no cover - defensive + self._error = str(exc) + return {"ok": False, "error": self._error} + + self._process = process + self._port = port + self._workspace = workspace_dir + threading.Thread(target=self._drain_output, args=(process,), daemon=True).start() + + # Health-poll outside the lock so status() stays responsive while a cold + # npx download runs. + base_url = f"http://127.0.0.1:{port}" + deadline = time.monotonic() + _OPENCODE_START_TIMEOUT + while time.monotonic() < deadline: + if process.poll() is not None: + break + if _opencode_healthy(base_url): + # So a backend SDK agent that starts AFTER this UI server + # (order (b): `weightslab start` first) finds this same + # server via the lock file instead of spawning its own -- + # symmetric with the read above, which covers order (a). + opencode_process.write_lock(workspace_dir, base_url, process.pid) + return {"ok": True, "url": base_url, "reused": False, + "workspace": workspace_dir} + time.sleep(0.4) + + tail = " / ".join(self._log[-4:]) or "no output" + self._error = ( + f"The agent server did not come up within {int(_OPENCODE_START_TIMEOUT)}s. " + f"Last output: {tail}" + ) + _kill_process_tree(process) + with self._lock: + self._port = None + return {"ok": False, "error": self._error} + + def status(self) -> dict: + with self._lock: + process = self._process + url = self._url_locked() + workspace = self._workspace + error = self._error + external = self._external_url is not None + if external: + return {"state": "running", "url": url, "workspace": workspace} + if process is None: + return {"state": "none", "error": error} + if process.poll() is None: + return {"state": "running", "url": url, "workspace": workspace} + return {"state": "killed", "workspace": workspace, "error": error} + + def shutdown(self) -> None: + with self._lock: + process = self._process + # An externally-provided OPENCODE_URL server is not ours to kill -- + # just drop the reference so a later ensure() re-evaluates it fresh. + self._external_url = None + if process is None or process.poll() is not None: + return + _kill_process_tree(process) + + +_opencode_session = _OpencodeSession() +atexit.register(_opencode_session.shutdown) + + +# --------------------------------------------------------------------------- # +# /agent-server/docs -- integration docs for preset prompts +# --------------------------------------------------------------------------- # + +# AGENTS.md lives inside the package itself (weightslab/weightslab/AGENTS.md, +# so parents[1] from here), which is what actually ships in a `pip install` +# (see pyproject.toml's package-data) -- parents[2], the repo root, is kept +# as a fallback only for anything not (yet) moved into the package. Missing +# entirely is not an error; the preset prompt still works without it, just +# without that extra grounding. +def _repo_doc_path(filename: str) -> Optional[Path]: + here = Path(__file__).resolve() + for candidate in (here.parents[1] / filename, here.parents[2] / filename): + if candidate.is_file(): + return candidate + return None + + +def _read_repo_doc(filename: str) -> Optional[str]: + path = _repo_doc_path(filename) + if path is None: + return None + try: + return path.read_text(encoding="utf-8") + except OSError: + return None + + +# Seeding AGENTS.md + opencode.json into the workspace lives in +# opencode_process, not here: the backend SDK agent reaches that module +# directly without ever going through this server, and both spawn paths have +# to leave a workspace in the same state. See its WORKSPACE_SEED_FILES. +_ensure_workspace_agent_files = opencode_process.ensure_workspace_agent_files + + +# The MNIST preset's counterpart to _read_repo_doc: a complete, working +# reference implementation is more actionable grounding than an excerpt alone +# (confirmed live: the excerpt in AGENTS.md wasn't enough on its own to stop a +# model from re-deriving the API from the installed package instead of using +# it directly). Scoped to just the one usecase the requesting preset actually +# matches -- attaching all of them to every request would trade the "a few +# minutes to generate" goal away for thoroughness nobody asked for here. +_KNOWN_EXAMPLE_USECASES = { + "wl-ads-recommendation", "wl-classification", "wl-clustering", + "wl-detection", "wl-fraud-detection", "wl-generation", "wl-segmentation", +} + + +def _read_example_main(usecase: str) -> Optional[str]: + """Best-effort main.py for one of weightslab's own PyTorch usecase + examples. `usecase` is client-supplied (a query param) -- checked against + a fixed allowlist first, never trusted as a path component directly.""" + if usecase not in _KNOWN_EXAMPLE_USECASES: + return None + here = Path(__file__).resolve() + candidate = here.parents[1] / "examples" / "PyTorch" / usecase / "main.py" + if candidate.is_file(): + try: + return candidate.read_text(encoding="utf-8") + except OSError: + return None + return None + + +# --------------------------------------------------------------------------- # +# /loop -- recurring OpenCode-backed monitoring jobs +# --------------------------------------------------------------------------- # + +# Minimum interval a loop can be scheduled at -- guards against a typo'd +# "/loop 30s ..." hammering the model every few seconds. +_LOOP_MIN_INTERVAL_SECONDS = 30.0 + +# Caps a single check-in's wall-clock time (_opencode_send_and_collect's own +# default is 600s, generous for the SDK/landing chat's own interactive use). +# 150s was tried first and was wrong: it was sized for "what's the last loss +# value" wandering off into `--help`/directory-listing guessing (a real +# problem, fixed by the preamble's own efficiency guidance instead, see +# _LOOP_SYSTEM_PREAMBLE), but a loop's task can just as legitimately be +# "look at the training trends and decide what to do -- discard samples, +# freeze layers, edit the model" -- multi-step agentic work on a slow/free +# model that genuinely needs minutes, not seconds. 150s cut that off mid- +# investigation every single tick (confirmed live: job.last_error == "timed +# out" on back-to-back ticks of exactly this kind of prompt). This is a +# backstop against a truly runaway session, not a budget for ordinary work. +_LOOP_CHECKIN_TIMEOUT_SECONDS = 450.0 + +# Loops run against the live training process with a broad toolset (bash, +# file edits, pause/discard/restart) -- an unbounded number of them is an +# unbounded number of concurrent interventions. Shared across both chat +# surfaces since _loop_registry is a single process-wide instance. +_LOOP_MAX_CONCURRENT = 3 + +# Every mutating capability this job needs already exists as a verb typed +# INSIDE `weightslab cli`'s interactive REPL (weightslab.cli:main -> backend/ +# cli.py's cli_client_main, a localhost TCP command server to the live +# training process) -- there is NO separate top-level `weightslab status`/ +# `weightslab pause` etc; those are argparse subcommands for `se`/`start`/ +# `tunnel`/`cli` only. Confirmed the hard way: an earlier preamble phrased +# these as if they were their own shell commands, and a model followed that +# literally -- `weightslab status` (a nonexistent subcommand, silently a +# no-op/usage error) followed by several minutes of guessing (`--help`, +# directory listings, log greps) before it independently discovered piping +# into `cli` was the real mechanism. Since bash tool calls are one-shot (no +# persistent stdin), a command reaches that REPL by piping it in and letting +# EOF close the session, e.g. `echo "status" | weightslab cli`. +# +# The loop's OpenCode session gets the SAME full toolset the landing page +# chat runs with (bash/read/write/edit/patch, no restriction), so it can pipe +# into `cli` directly. +_LOOP_SYSTEM_PREAMBLE = ( + "You are a recurring monitoring agent for a live WeightsLab run. " + "Workspace: the experiment directory (bash/read/write/edit/patch rooted there).\n\n" + "`weightslab cli` is the ONLY way to inspect/control the run -- it's an " + "interactive session, not separate shell commands. Pipe ONE line in per " + "command (bash is one-shot; EOF ends the session), e.g.:\n" + " echo \"status\" | weightslab cli\n" + "Lines you can pipe in:\n" + " status -- component NAMES + model age only, NO metric/hyperparam values\n" + " agent query \"\" -- plain-English read OR edit (e.g. \"what is the last train loss\", " + "\"discard samples where loss > 5\") -- the right tool for any metric/signal question, status never has that\n" + " agent query \"Generate an experiment report.\" -- the SAME command also builds a complete, " + "styled HTML report (signal plots, health classification, dataset stats, written analysis) in " + "ONE call when the task asks for a report/summary of how the run is going -- never assemble a " + "report yourself from several separate 'agent query' answers plus your own write tool, that " + "skips the plots/styling the backend already produces. On a LATER tick, \"agent query \\\"Update " + "the report with a histogram of val_loss.\\\"\" overwrites that SAME report file instead of " + "creating another one each check-in -- use that phrasing (\"update\"/\"add X to it\") once a " + "report already exists for this run, plain \"generate\" only for the first one\n" + " pause / resume -- freeze/resume weight updates\n" + " discard -- discard one sample by id\n\n" + "Be fast: for a simple question, pipe one command in and answer from its " + "reply. Only go further (logs, editing code, restarting a crashed run via " + "ps/pgrep + relaunch) when the task actually needs it.\n\n" + "Launch anything long-running (training, a relaunched crashed run, a " + "server) DETACHED so the command returns immediately -- e.g. (PowerShell) " + "`$p = Start-Process python -ArgumentList \"-u\",\"train.py\" " + "-WindowStyle Hidden -PassThru`, never a bare `python train.py` in the " + "foreground. A foreground launch blocks THIS tool call -- and therefore " + "this whole check-in, and the next one after it -- until the entire run " + "finishes, since this session processes one turn at a time. This applies " + "just as much once you've decided the run genuinely needs to happen (e.g. " + "relaunching a crashed one) as it does anywhere else -- that is exactly " + "the case this rule is for, not an exception to it. If a bash call you " + "already made hasn't finished, the fix is to switch that launch to " + "Start-Process, never to raise its timeout and wait longer -- a bigger " + "timeout still blocks the same way, only for longer, and leaves you with " + "no PID to act on if you're later asked to stop it.\n\n" + "A DETACHED process like that is NOT automatically stopped when this " + "workspace is (Ctrl+C on `weightslab start`, or the process otherwise " + "exiting) -- it has no OS-level relationship to this workspace's own " + "process tree once launched this way. A script that calls `wl.serve()` " + "registers itself and needs nothing from you; for anything else, register " + "its PID right after launching it, so it is stopped too: " + "`Invoke-RestMethod -Method Post -Uri " + "'{origin}/agent-server/track-process' -ContentType 'application/json' " + "-Body (@{{pid=$p.Id}} | ConvertTo-Json)`. Do this for anything you " + "relaunch, not just the first run -- and keep that PID in mind for the " + "rest of this job's life: it's what makes a later stop/kill instant " + "(`Stop-Process -Id -Force`) instead of having to go rediscover " + "which process is the right one.\n\n" + "NEVER stop/kill a process you did not yourself start in this session (or " + "the one crashed run you were explicitly asked to relaunch) without " + "asking the user first and naming the exact PID/command. Finding an " + "unfamiliar, duplicate-looking, or seemingly-stale process via ps/" + "tasklist/Get-CimInstance is not authorization to end it -- describe what " + "you found and wait for a go-ahead instead of cleaning it up yourself.\n\n" + "An exec/bash call blocks THIS check-in until it returns, with no " + "per-call timeout -- a command that can hang (a broad WMI/" + "Get-CimInstance scan, Stop-Process on something with stuck threads, " + "anything that can wait on a lock or a confirmation prompt) stalls the " + "whole check-in indefinitely, not just that one command. Prefer the " + "narrowest command that answers the question (a specific known PID over " + "a broad process scan); if something doesn't return in a few seconds, " + "don't just retry the same broad query -- report what you know and ask " + "rather than keep guessing.\n\n" + "Your reply renders Markdown and LaTeX ($...$/$$...$$ or \\(...\\)/\\[...\\]) " + "-- use real formulas for anything math-shaped instead of describing them in prose.\n\n" + # weights_studio/src/agent/loopChatPane.ts looks for this EXACT trailing + # "Task:\n" and shows only what follows it (the actual prompt) -- the + # preamble above is real instruction content the model needs, but not + # something the user asked to read every time a loop tab opens. If this + # tail ever changes, update the matching string there too. + "Task:\n{prompt}" +) + + +def _opencode_json_request(base_url: str, path: str, method: str = "GET", body: Optional[dict] = None, timeout: float = 30.0): + import urllib.request + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Content-Type": "application/json"} if body is not None else {} + req = urllib.request.Request(f"{base_url.rstrip('/')}{path}", data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def _parse_model_value(value) -> Optional[dict]: + """"provider/model-id" -> {"providerID", "modelID"}. + + Split on the FIRST slash only -- model ids routinely contain their own + ("openrouter/anthropic/claude-haiku-4.5" is provider `openrouter`, model + `anthropic/claude-haiku-4.5"). Mirrors weights_studio's opencodeClient.ts + parseModelValue, which reads the same strings back out of this config. + """ + if not isinstance(value, str) or "/" not in value: + return None + provider, _, model = value.partition("/") + if not provider or not model: + return None + return {"providerID": provider, "modelID": model} + + +def _opencode_resolve_model(base_url: str, explicit: Optional[dict] = None) -> Optional[dict]: + """Which model a loop's check-ins should run on, most specific first. + + A loop can't inherit a model implicitly: its check-ins run in their own + session, from this process, with no chat attached. But it also shouldn't + have to be told one by whichever surface happened to type /loop -- the + answer already exists in the same OpenCode the chat is using: + + 1. `explicit` -- the model the chat that started this loop is on right + now. The most current signal there is, when a caller can offer it. + 2. `GET /config`'s own `model` -- the default the model picker writes + back to opencode.json on every pick (opencodeClient.ts's + setDefaultModel), so it IS "whatever the user last chose", and it + survives the browser, the CLI and other machines. + 3. The provider defaults from `/config/providers` -- what OpenCode + itself would have fallen back to. Resolved here rather than left + implicit so the job can record and display what it picked. + + None if even that is unavailable, in which case the check-in goes out + without a model and OpenCode decides, exactly as before. + """ + if isinstance(explicit, dict) and explicit.get("providerID") and explicit.get("modelID"): + return {"providerID": str(explicit["providerID"]), "modelID": str(explicit["modelID"])} + + try: + config = _opencode_json_request(base_url, "/config", timeout=10.0) + parsed = _parse_model_value((config or {}).get("model")) + if parsed: + return parsed + except Exception: # noqa: BLE001 - fall through to the next source + pass + + try: + providers = _opencode_json_request(base_url, "/config/providers", timeout=10.0) + defaults = (providers or {}).get("default") or {} + for provider in (providers or {}).get("providers") or []: + provider_id = provider.get("id") + if provider_id and defaults.get(provider_id): + return {"providerID": str(provider_id), "modelID": str(defaults[provider_id])} + except Exception: # noqa: BLE001 - no model, OpenCode picks + pass + + return None + + +def _opencode_error_text(props: dict) -> str: + """Flatten a `session.error` event's payload into one line. + + Shape is not pinned down by GET /doc beyond "an error object", and the + interesting part is nested at a different depth depending on where the + failure came from (provider rejection vs. internal) -- so this digs for a + message rather than assuming one path, and falls back to the raw JSON so + an unrecognised shape still reaches the user instead of being swallowed. + """ + error = props.get("error") + if isinstance(error, str): + return error + if isinstance(error, dict): + data = error.get("data") + if isinstance(data, dict): + for key in ("message", "error", "detail"): + value = data.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + for key in ("message", "name"): + value = error.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + try: + return json.dumps(error)[:400] + except (TypeError, ValueError): + pass + return "The agent server reported an error for this check-in." + + +def _opencode_send_and_collect( + base_url: str, + session_id: str, + text: str, + model: Optional[dict] = None, + timeout: float = 600.0, +) -> tuple: + """Returns `(text, error)` -- `error` is None on a clean turn. + + Both halves matter: a turn can end via `session.error` having produced no + text at all (a provider rejecting the request outright, e.g. a model with + no tool-use endpoints against a toolset-carrying prompt), and that used to + come back as an empty string indistinguishable from "the agent had nothing + to say". Nothing raised, so the job recorded no error either, and the tab + showed the check-in prompt with silence under it, every interval, forever. + """ + """Open the SSE event stream, THEN send the message, THEN read the stream + until this session goes idle -- same stream-first ordering and event + parsing as weights_studio/src/landing/agent/opencodeClient.ts and + weightslab/trainer/services/agent/opencode_chat.py's _collect_reply. + Duplicated in miniature here (rather than importing opencode_chat.py) + because this module is deliberately stdlib-only -- see its own docstring -- + and this loop wants the opposite tool policy (full toolset, no + restriction) from that module's SDK-agent use case anyway. + + A loop check-in can legitimately run long (the agent may read logs, edit + files, run training-control commands) -- default timeout is 10 minutes, + generous relative to the loop's own interval (minimum 1 minute), and a + slow check-in simply delays that job's next tick rather than blocking + anything else (each job's timer callback runs independently).""" + import urllib.error + import urllib.request + + stream_req = urllib.request.Request(f"{base_url.rstrip('/')}/event", headers={"Accept": "text/event-stream"}) + stream = urllib.request.urlopen(stream_req, timeout=timeout) + + send_errors: list = [] + + def _send() -> None: + try: + # `model` mirrors what the chat surfaces send ({providerID, + # modelID}); omitted, OpenCode falls back to its own configured + # default, which is not necessarily one that supports tool use -- + # and this prompt hands the agent a full toolset, so a default + # like an image-generation model fails the turn outright. + body = {"parts": [{"type": "text", "text": text}]} + if model: + body["model"] = model + _opencode_json_request( + base_url, f"/session/{session_id}/message", method="POST", + body=body, timeout=timeout, + ) + except Exception as exc: # noqa: BLE001 - surfaced via send_errors + send_errors.append(exc) + + sender = threading.Thread(target=_send, daemon=True) + text_parts: dict = {} + assistant_message_ids: set = set() + error: Optional[str] = None + + hit_deadline = False + try: + sender.start() + data_lines: list = [] + deadline = time.monotonic() + timeout + for raw_line in stream: + if time.monotonic() > deadline: + hit_deadline = True + break + line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n") + if line == "": + if data_lines: + payload = "\n".join(data_lines) + data_lines = [] + try: + event = json.loads(payload) + except (ValueError, TypeError): + continue + event_type = event.get("type") + props = event.get("properties") or {} + if event_type == "message.updated": + msg = props.get("info") or props.get("message") or props + msg_session = str(msg.get("sessionID") or "") + if (not msg_session or msg_session == session_id) and \ + str(msg.get("role") or "") == "assistant" and msg.get("id"): + assistant_message_ids.add(str(msg["id"])) + elif event_type == "message.part.updated": + part = props.get("part") or props + part_session = str(part.get("sessionID") or "") + message_id = str(part.get("messageID") or "") + if (not part_session or part_session == session_id) and \ + message_id in assistant_message_ids and part.get("type") == "text" \ + and isinstance(part.get("text"), str): + text_parts[str(part.get("id") or message_id)] = part["text"] + elif event_type == "session.error" and \ + str(props.get("sessionID") or "") == session_id: + error = _opencode_error_text(props) + break + elif event_type == "session.idle" and \ + str(props.get("sessionID") or "") == session_id: + break + continue + if line.startswith(":"): + continue + if line.startswith("data:"): + data_lines.append(line[5:].lstrip(" ")) + except (urllib.error.URLError, TimeoutError, OSError): + pass # degrade to whatever text was collected so far + finally: + stream.close() + + sender.join(timeout=1.0) + if send_errors and not text_parts: + exc = send_errors[0] + # The message-send POST itself (not the SSE read loop, which has its + # own `hit_deadline` check above) is what actually blocks for the + # whole turn -- OpenCode's POST /session/{id}/message doesn't return + # until the model is done, so THIS is where a check-in that runs long + # actually times out at the socket level. Left as a raw exception, + # `str(exc)` for a socket timeout is just "timed out" -- accurate but + # unhelpful next to the deadline-check path's own clear message, so + # this is reworded to match it rather than leaking the bare Python + # exception text into job.last_error. + if isinstance(exc, (TimeoutError, OSError)): + raise TimeoutError(f"Check-in did not finish within {int(timeout)}s and was cut off.") from exc + raise exc + + # Distinct from "no reply" below (_fire's own fallback, for a turn that + # ended cleanly with nothing to show) -- this one DID something, it just + # ran out of time doing it (e.g. wandering through several slow bash + # calls instead of answering directly). Whatever text had streamed in by + # then is kept and returned alongside this, rather than thrown away. + if hit_deadline and error is None: + error = f"Check-in did not finish within {int(timeout)}s and was cut off." + + return "".join(text_parts[key] for key in text_parts), error + + +def _opencode_get_messages(base_url: str, session_id: str) -> list: + """A loop's chat tab reads its own scrollback from here -- same shape + weights_studio's opencodeClient.ts's getSessionMessages() already + consumes (each item `{info, parts}`), a trivial GET via the JSON helper + above rather than a new primitive: both scheduled ticks and manual + messages land as ordinary turns in this same session, so its own message + list already is the merged transcript, nothing to reconcile here.""" + return _opencode_json_request(base_url, f"/session/{session_id}/message", method="GET") + + +class _LoopJob: + def __init__(self, job_id: str, prompt: str, interval_seconds: float, workspace: str, + model: Optional[dict] = None, origin: Optional[str] = None) -> None: + self.id = job_id + self.prompt = prompt + self.interval_seconds = interval_seconds + self.workspace = workspace + # This server's own address, as the browser saw it when /loop start + # was called -- interpolated into _LOOP_SYSTEM_PREAMBLE so the loop's + # model can call /agent-server/track-process itself, same reasoning + # as agentChat.ts's use of location.origin for the landing chat. + self.origin = origin + # {providerID, modelID} chosen in the chat that started this loop, or + # None to let OpenCode pick its default. Fixed for the job's life -- + # a check-in is meant to be the same measurement every interval. + self.model = model + # True only while a check-in is actually in flight. The tab polls + # this: without it there is no difference on screen between "the + # agent is working on this right now" and "nothing is happening", + # which is most of a loop's life given the 1-minute minimum interval. + self.running = False + self.last_run_started_at: Optional[float] = None + self.session_id: Optional[str] = None + self.base_url: Optional[str] = None + self.next_run_at: Optional[float] = None + self.last_result: Optional[str] = None + self.last_error: Optional[str] = None + self.timer: Optional[threading.Timer] = None + self.stopped = False + # True once the monitoring preamble (role + available CLI verbs) has + # been sent as this session's first message -- after that, both the + # scheduled tick and manual messages send plain text. Separate from + # session_id being set: the session itself is now created eagerly in + # start(), before any message (scheduled or manual) has gone out. + self.preamble_sent = False + + +class _LoopRegistry: + """Recurring OpenCode-backed monitoring jobs, e.g. `/loop 30m `. + + Lives here (this UI server process) rather than in the browser tab or the + training backend: the browser can't run a persistent timer that survives + a page reload, and the training backend doesn't need to be touched at all + since every intervention the loop needs is already reachable through the + local `weightslab` CLI over bash (see _LOOP_SYSTEM_PREAMBLE). A job + survives a page reload/tab close (tied to this process, not the tab) but + not a full `weightslab start` restart -- no persistence beyond that, + matching every other piece of state in this server (Jupyter/OpenCode + sessions). + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + self._jobs: dict = {} + self._next_id = 1 + + def start(self, prompt: str, interval_seconds: float, workspace_dir: str, origin: Optional[str], + model: Optional[dict] = None) -> dict: + prompt = (prompt or "").strip() + if not prompt: + return {"ok": False, "error": "A monitoring prompt is required."} + if interval_seconds < _LOOP_MIN_INTERVAL_SECONDS: + return {"ok": False, "error": f"Minimum loop interval is {int(_LOOP_MIN_INTERVAL_SECONDS)}s."} + + with self._lock: + if len(self._jobs) >= _LOOP_MAX_CONCURRENT: + return { + "ok": False, + "error": f"{_LOOP_MAX_CONCURRENT} loops already running -- " + f"stop one first (\"/loop stop \").", + } + + ensured = _opencode_session.ensure(workspace_dir, origin) + if not ensured.get("ok"): + return {"ok": False, "error": ensured.get("error") or "Could not start the agent server."} + base_url = ensured["url"] + + # Resolved once, here, and pinned for the job's life: a monitoring + # loop is meant to be the same measurement every interval, so a model + # changed in the chat later must not silently change what this job + # has been reporting. Recorded on the job (and surfaced by list()) + # so a run of failing check-ins can be traced to the model behind it. + model = _opencode_resolve_model(base_url, model) + + # Created eagerly now -- not lazily on the first tick, as before -- + # so a loop's chat tab has a session to show/send into immediately, + # before its first check-in has even run. Done here, between the two + # admission checks rather than inside either, same reasoning as + # ensure() just above: a network round-trip has no business running + # while holding the lock that also serializes list()/stop()/update(). + try: + created = _opencode_json_request( + base_url, "/session", method="POST", + body={"title": f"weightslab-loop-{int(time.time())}"}, + ) + session_id = created["id"] + except Exception as exc: + return {"ok": False, "error": f"Could not start a chat session for this loop: {exc}"} + + with self._lock: + if len(self._jobs) >= _LOOP_MAX_CONCURRENT: + # Lost the race between the two checks -- the session just + # created above will never be used. Best-effort delete rather + # than leak it (this registry already tolerates a leaked + # session on stop(), below; unlike that one, this is a + # certain, immediate leak, so it's worth the extra call). + try: + _opencode_json_request(base_url, f"/session/{session_id}", method="DELETE") + except Exception: + pass + return { + "ok": False, + "error": f"{_LOOP_MAX_CONCURRENT} loops already running -- " + f"stop one first (\"/loop stop \").", + } + job_id = str(self._next_id) + self._next_id += 1 + job = _LoopJob(job_id, prompt, interval_seconds, workspace_dir, model, origin=origin) + job.base_url = base_url + job.session_id = session_id + self._jobs[job_id] = job + + threading.Thread(target=self._fire, args=(job_id, base_url), daemon=True).start() + return {"ok": True, "id": job_id, "intervalSeconds": interval_seconds} + + def get_messages(self, job_id: str) -> dict: + with self._lock: + job = self._jobs.get(job_id) + if job is None: + return {"ok": False, "error": f"No loop job {job_id}."} + try: + messages = _opencode_get_messages(job.base_url, job.session_id) + except Exception as exc: + return {"ok": False, "error": str(exc)} + return {"ok": True, "messages": messages} + + def _fire(self, job_id: str, base_url: str) -> None: + with self._lock: + job = self._jobs.get(job_id) + if job is None or job.stopped: + return # stopped before this tick ran + + with self._lock: + job.running = True + job.last_run_started_at = time.time() + + try: + # The monitoring preamble (role + available CLI verbs) goes out + # as this session's first message, exactly once; every check-in + # after that is just the plain prompt. + send_preamble = not job.preamble_sent + text = ( + _LOOP_SYSTEM_PREAMBLE.format(prompt=job.prompt, origin=job.origin or "http://127.0.0.1:8080") + if send_preamble else job.prompt + ) + result, error = _opencode_send_and_collect( + base_url, job.session_id, text, job.model, timeout=_LOOP_CHECKIN_TIMEOUT_SECONDS, + ) + with self._lock: + if job_id in self._jobs: + job.last_result = result + # A turn that ends with neither text nor a reported error + # is still a failed check-in from the user's side -- the + # tab would show the prompt and nothing under it. Say so + # rather than leaving the silence unexplained. + job.last_error = error or ( + None if result.strip() + else "The check-in produced no reply. If this repeats, the model " + "selected for this loop may not support tool use." + ) + if send_preamble: + job.preamble_sent = True + except Exception as exc: + with self._lock: + if job_id in self._jobs: + job.last_error = str(exc) + + with self._lock: + job = self._jobs.get(job_id) + if job is None or job.stopped: + return # stopped while this tick was running + job.running = False + # Measured from when the answer LANDED, not from when the tick + # fired -- a check-in that takes two minutes on a five-minute + # loop leaves five clear minutes before the next one, instead of + # the interval quietly eating the agent's own working time. + job.next_run_at = time.time() + job.interval_seconds + timer = threading.Timer(job.interval_seconds, self._fire, args=(job_id, base_url)) + timer.daemon = True + job.timer = timer + timer.start() + + def stop(self, job_id: str) -> dict: + with self._lock: + job = self._jobs.pop(job_id, None) + if job is not None: + job.stopped = True + if job is None: + return {"ok": False, "error": f"No loop job {job_id}."} + if job.timer is not None: + job.timer.cancel() + return {"ok": True} + + def update(self, job_id: str, prompt: Optional[str] = None, interval_seconds: Optional[float] = None) -> dict: + """Change a running job's prompt and/or interval in place. An interval + change reschedules from now rather than waiting out the old timer, so + the change is felt immediately instead of on the tick after next.""" + if prompt is not None and not prompt.strip(): + return {"ok": False, "error": "The monitoring prompt cannot be empty."} + if interval_seconds is not None and interval_seconds < _LOOP_MIN_INTERVAL_SECONDS: + return {"ok": False, "error": f"Minimum loop interval is {int(_LOOP_MIN_INTERVAL_SECONDS)}s."} + + with self._lock: + job = self._jobs.get(job_id) + if job is None: + return {"ok": False, "error": f"No loop job {job_id}."} + + if prompt is not None: + job.prompt = prompt.strip() + + reschedule = interval_seconds is not None and interval_seconds != job.interval_seconds + if interval_seconds is not None: + job.interval_seconds = interval_seconds + + if reschedule and job.timer is not None: + job.timer.cancel() + job.next_run_at = time.time() + job.interval_seconds + timer = threading.Timer(job.interval_seconds, self._fire, args=(job_id, job.base_url)) + timer.daemon = True + job.timer = timer + timer.start() + + return { + "ok": True, + "id": job.id, + "prompt": job.prompt, + "intervalSeconds": job.interval_seconds, + "nextRunAt": job.next_run_at, + } + + def list(self) -> list: + with self._lock: + jobs = list(self._jobs.values()) + return [ + { + "id": j.id, + "prompt": j.prompt, + "intervalSeconds": j.interval_seconds, + "nextRunAt": j.next_run_at, + "lastResult": j.last_result, + "lastError": j.last_error, + # What the tab needs to tell "working on it" apart from + # "waiting for the next interval" -- the countdown alone + # can't, since next_run_at only moves once a run finishes. + "running": j.running, + "lastRunStartedAt": j.last_run_started_at, + # "provider/model-id", or None if none could be resolved and + # OpenCode is choosing per check-in. Shown in the loop's tab: + # when check-ins fail for a model-shaped reason (no tool-use + # endpoints being the common one), the model this job is + # actually pinned to is the first thing worth seeing. + "model": f"{j.model['providerID']}/{j.model['modelID']}" if j.model else None, + } + for j in jobs + ] + + def shutdown(self) -> None: + with self._lock: + jobs = list(self._jobs.values()) + self._jobs.clear() + for job in jobs: + job.stopped = True + if job.timer is not None: + job.timer.cancel() + + +_loop_registry = _LoopRegistry() +atexit.register(_loop_registry.shutdown) + + +def _run_shutdown_cleanup() -> None: + """Every termination path this server can take (Ctrl+C, closing the + terminal, a bare `kill`) converges here. Each of these four is + independent and best-effort, so order doesn't matter and one raising + doesn't stop the others -- see each class's own shutdown().""" + _tracked_processes.shutdown() + _opencode_session.shutdown() + _loop_registry.shutdown() + _jupyter_session.shutdown() + + +def _raise_keyboard_interrupt(signum, frame) -> None: + raise KeyboardInterrupt() + + +# CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT, CTRL_SHUTDOWN_EVENT -- the ones that +# mean "this process is going away", as opposed to CTRL_C_EVENT (0) / +# CTRL_BREAK_EVENT (1), which Python already turns into SIGINT/SIGBREAK on +# its own and which this handler explicitly leaves alone. +_WINDOWS_TERMINATING_CTRL_EVENTS = (2, 5, 6) + +# Holds the ctypes callback so it isn't garbage-collected out from under +# Windows once registered -- SetConsoleCtrlHandler only keeps a raw function +# pointer, not a reference that would keep the wrapping Python object alive. +_console_ctrl_handler_ref = None + + +def _on_windows_ctrl_event(ctrl_type: int) -> bool: + """The actual console-control-event logic, kept separate from the + ctypes registration below so it's unit-testable without a real Windows + console or a real SetConsoleCtrlHandler call.""" + if ctrl_type in _WINDOWS_TERMINATING_CTRL_EVENTS: + _run_shutdown_cleanup() + return True + return False + + +def _install_windows_console_handler() -> None: + """Closing the console window, logging off, or a system shutdown sends + CTRL_CLOSE_EVENT/CTRL_LOGOFF_EVENT/CTRL_SHUTDOWN_EVENT -- none of which + the `signal` module can catch (that only covers CTRL_C_EVENT/ + CTRL_BREAK_EVENT, as SIGINT/SIGBREAK). SetConsoleCtrlHandler is the only + way to intercept the others, so this calls it directly via ctypes rather + than adding a pywin32 dependency for one function. Runs on an + OS-spawned thread, not the main one -- Windows also kills the process + shortly after delivering one of these regardless of what the handler + does, so _run_shutdown_cleanup (not this registration) is what has to + stay fast. + """ + import ctypes + from ctypes import wintypes + + global _console_ctrl_handler_ref + handler_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.DWORD) + _console_ctrl_handler_ref = handler_type(_on_windows_ctrl_event) + try: + ctypes.windll.kernel32.SetConsoleCtrlHandler(_console_ctrl_handler_ref, True) + except Exception: # pragma: no cover - best-effort; Ctrl+C still works + pass + + +def _install_termination_handlers() -> None: + """Makes closing the terminal or a bare `kill` run the SAME cleanup as + Ctrl+C, instead of leaving a DETACHED launch this server was tracking + (see _TrackedProcesses), plus its own OpenCode/Jupyter children, + orphaned -- confirmed live: neither SIGTERM/SIGHUP (POSIX) nor + CTRL_CLOSE_EVENT (Windows) becomes a catchable Python exception the way + SIGINT does by default, so without this, only Ctrl+C itself was ever + covered. + + POSIX: SIGTERM/SIGHUP re-raised as KeyboardInterrupt, so they run + through the EXACT same `except KeyboardInterrupt` below rather than a + second, duplicate cleanup path. + + Windows: see _install_windows_console_handler. + """ + try: + signal.signal(signal.SIGTERM, _raise_keyboard_interrupt) + if hasattr(signal, "SIGHUP"): # not defined on Windows + signal.signal(signal.SIGHUP, _raise_keyboard_interrupt) + except ValueError: + # Only the main thread of the main interpreter may install signal + # handlers -- best-effort if serve_ui(block=True) is ever called + # from somewhere else (e.g. embedded in another app's own thread). + # Ctrl+C (SIGINT) is still caught either way; only the closed- + # terminal/bare-kill cases this function exists for are unaffected. + pass + + if os.name == "nt": + _install_windows_console_handler() + + # --------------------------------------------------------------------------- # # Request handler # --------------------------------------------------------------------------- # @@ -391,6 +1549,22 @@ def do_GET(self): # noqa: N802 if path == "/local-notebook/status": self._send_json(HTTPStatus.OK, _jupyter_session.status()) return + if path == "/agent-server/status": + self._send_json(HTTPStatus.OK, _opencode_session.status()) + return + if path == "/agent-server/loop/list": + self._send_json(HTTPStatus.OK, {"loops": _loop_registry.list()}) + return + if path.startswith("/agent-server/loop/") and path.endswith("/messages"): + loop_id = path[len("/agent-server/loop/"):-len("/messages")] + self._get_loop_messages(loop_id) + return + if path == "/agent-server/docs": + self._get_agent_docs() + return + if path == "/agent-server/data-query/latest": + self._get_latest_data_query() + return if path == "/local-notebook/list": self._list_local_notebooks() return @@ -409,6 +1583,20 @@ def do_POST(self): # noqa: N802 path = self.path.split("?", 1)[0] if path == "/local-notebook": self._start_local_notebook() + elif path == "/agent-server/start": + self._start_agent_server() + elif path == "/agent-server/loop/start": + self._start_loop() + elif path.startswith("/agent-server/loop/") and path.endswith("/stop"): + loop_id = path[len("/agent-server/loop/"):-len("/stop")] + self._stop_loop(loop_id) + elif path.startswith("/agent-server/loop/") and path.endswith("/update"): + loop_id = path[len("/agent-server/loop/"):-len("/update")] + self._update_loop(loop_id) + elif path == "/agent-server/data-query": + self._data_query() + elif path == "/agent-server/track-process": + self._track_process() elif path.startswith(self.api_prefix + "/") or path == self.api_prefix: self._proxy_grpc_web(path) else: @@ -524,6 +1712,248 @@ def _list_local_notebooks(self): }) self._send_json(HTTPStatus.OK, {"notebooks": entries}) + def _start_agent_server(self): + """Start (or reuse) the OpenCode server backing the landing-page agent. + + Same shape and same reasoning as ``_start_local_notebook`` below: spawning + a process is an OS-level action, so the browser asks us to do it. Rooted at + the experiment directory, which becomes the agent's workspace. Loopback-only, + like every other local-machine action in this server -- this one starts a + process with filesystem access, so it must never be reachable off-host. + """ + if self.client_address[0] not in _LOOPBACK_ADDRESSES: + self._send_json(HTTPStatus.FORBIDDEN, + {"ok": False, "error": "Only reachable from localhost."}) + return + + workspace = self.experiment_dir or os.getcwd() + os.makedirs(workspace, exist_ok=True) + + # Prefer the browser's own Origin header: it is the exact string the CORS + # check will compare against. Fall back to reconstructing it from Host. + origin = self.headers.get("Origin") + if not origin: + host = self.headers.get("Host") or "localhost" + scheme = "https" if isinstance(getattr(self, "connection", None), ssl.SSLSocket) else "http" + origin = f"{scheme}://{host}" + + result = _opencode_session.ensure(workspace, origin) + if result.get("ok"): + # Told outright rather than left for the model to discover by + # trial and error -- see opencode_process.shell_platform_note() + # and _default_shell()'s docstring for why guessing was the bug. + result.update(opencode_process.shell_platform_note()) + status = HTTPStatus.OK if result.get("ok") else HTTPStatus.INTERNAL_SERVER_ERROR + self._send_json(status, result) + + def _start_loop(self): + """Start a recurring OpenCode-backed monitoring job (the `/loop` + command in the connected-experiment agent bar). Loopback-only, same + reasoning as _start_agent_server -- this also starts/reuses that same + process.""" + if self.client_address[0] not in _LOOPBACK_ADDRESSES: + self._send_json(HTTPStatus.FORBIDDEN, + {"ok": False, "error": "Only reachable from localhost."}) + return + + body = self._read_json_body() + prompt = str(body.get("prompt") or "") + try: + interval_minutes = float(body.get("intervalMinutes") or 0) + except (TypeError, ValueError): + interval_minutes = 0 + + workspace = self.experiment_dir or os.getcwd() + os.makedirs(workspace, exist_ok=True) + + origin = self.headers.get("Origin") + if not origin: + host = self.headers.get("Host") or "localhost" + scheme = "https" if isinstance(getattr(self, "connection", None), ssl.SSLSocket) else "http" + origin = f"{scheme}://{host}" + + # Whatever model the chat that typed /loop is itself on, when it can + # say. Entirely optional -- the registry resolves the rest from + # OpenCode's own config either way, see _opencode_resolve_model. + model = body.get("model") + if not (isinstance(model, dict) and model.get("providerID") and model.get("modelID")): + model = None + + result = _loop_registry.start(prompt, interval_minutes * 60.0, workspace, origin, model) + status = HTTPStatus.OK if result.get("ok") else HTTPStatus.BAD_REQUEST + self._send_json(status, result) + + def _stop_loop(self, loop_id: str): + if self.client_address[0] not in _LOOPBACK_ADDRESSES: + self._send_json(HTTPStatus.FORBIDDEN, + {"ok": False, "error": "Only reachable from localhost."}) + return + result = _loop_registry.stop(loop_id) + status = HTTPStatus.OK if result.get("ok") else HTTPStatus.NOT_FOUND + self._send_json(status, result) + + def _update_loop(self, loop_id: str): + """Change a running loop's prompt and/or interval (the panel's Edit + action) without stopping and restarting the job.""" + if self.client_address[0] not in _LOOPBACK_ADDRESSES: + self._send_json(HTTPStatus.FORBIDDEN, + {"ok": False, "error": "Only reachable from localhost."}) + return + + body = self._read_json_body() + prompt = body.get("prompt") + interval_seconds = None + if body.get("intervalMinutes") is not None: + try: + interval_seconds = float(body["intervalMinutes"]) * 60.0 + except (TypeError, ValueError): + self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "intervalMinutes must be a number."}) + return + + result = _loop_registry.update(loop_id, prompt=prompt, interval_seconds=interval_seconds) + status = HTTPStatus.OK if result.get("ok") else HTTPStatus.BAD_REQUEST + self._send_json(status, result) + + def _get_loop_messages(self, loop_id: str): + """Backs a loop tab's read-only transcript: its scrollback is just + this job's own OpenCode session history, written to solely by the + scheduled check-in (_fire) -- nothing to merge here, only fetching.""" + if self.client_address[0] not in _LOOPBACK_ADDRESSES: + self._send_json(HTTPStatus.FORBIDDEN, + {"ok": False, "error": "Only reachable from localhost."}) + return + result = _loop_registry.get_messages(loop_id) + status = HTTPStatus.OK if result.get("ok") else HTTPStatus.NOT_FOUND + self._send_json(status, result) + + def _data_query(self): + """Lets the landing-page agent chat perform dataset/model actions -- + discard, tag, sort, filter, analyze, compute stats, and the rest of + DataManipulationAgent's `action.*`/handler surface -- itself, via its + bash tool, instead of needing a second tab for it (the merged Agent + Window's Backend Agent capability -- see agentChat.ts's standing + instruction that points the model at this endpoint). + + Deliberately NOT a new code path: this calls the SAME + ExperimentService.ApplyDataQuery RPC the (now-retired) gRPC query bar + always used, over the SAME upstream channel `_proxy_grpc_web` already + proxies everything else through -- so every safety invariant that + pipeline already enforces (WL never deletes rows, only flags them; + protected columns can't be silently overwritten; etc., see + data_service.py/agent.py) applies here unchanged. The difference is + only in HOW the request gets built: a real DataQueryRequest message, + called as the genuinely unary RPC it is, rather than grpc-web's + forward-raw-bytes-as-unary-stream trick (see _proxy_grpc_web) -- that + shortcut only works when the caller already HAS a serialized + protobuf body to forward, and this one starts from a plain JSON + {query, accumulate} instead. + """ + if self.client_address[0] not in _LOOPBACK_ADDRESSES: + self._send_json(HTTPStatus.FORBIDDEN, + {"ok": False, "error": "Only reachable from localhost."}) + return + + body = self._read_json_body() + query = str(body.get("query") or "").strip() + if not query: + self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "A query is required."}) + return + accumulate = bool(body.get("accumulate", False)) + + from weightslab.proto import experiment_service_pb2 as pb2 + + request = pb2.DataQueryRequest(query=query, accumulate=accumulate, is_natural_language=True) + call = self.channel.unary_unary( + "/ExperimentService/ApplyDataQuery", + request_serializer=pb2.DataQueryRequest.SerializeToString, + response_deserializer=pb2.DataQueryResponse.FromString, + ) + try: + response = call(request, metadata=self._collect_metadata(), timeout=self.rpc_timeout) + except grpc.RpcError as err: + message = err.details() or str(err) + self._send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"ok": False, "error": message}) + return + + payload = { + "ok": bool(response.success), + "message": response.message, + "numberOfAllSamples": response.number_of_all_samples, + "numberOfSamplesInTheLoop": response.number_of_samples_in_the_loop, + "numberOfDiscardedSamples": response.number_of_discarded_samples, + "uniqueTags": list(response.unique_tags), + "analysisResult": response.analysis_result, + "agentIntentType": int(response.agent_intent_type), + } + # The agent's own bash tool called this endpoint directly -- nothing + # in the browser's own JS observed the request or its response the + # way it would for a query typed into the UI, so the grid/subview + # banner never updates on its own without this: see + # _latest_data_query's docstring for how the frontend picks it up. + _latest_data_query.record(query, payload) + self._send_json(HTTPStatus.OK, payload) + + def _get_latest_data_query(self): + """Polled once by agentChat.ts whenever a Frontend Agent turn ends + (session.idle) -- NOT continuously -- so main.ts can replay the + SAME grid-refresh/subview-banner reaction a query typed directly + into the (now-retired) backend query bar always triggered + client-side, for a query the agent just ran itself instead (see + _data_query/_latest_data_query). {"seq": 0} if nothing has run yet + this process; the frontend only reacts when seq is NEWER than the + last one it already handled.""" + if self.client_address[0] not in _LOOPBACK_ADDRESSES: + self._send_json(HTTPStatus.FORBIDDEN, + {"ok": False, "error": "Only reachable from localhost."}) + return + self._send_json(HTTPStatus.OK, _latest_data_query.get()) + + def _track_process(self): + """Registers a PID the agent (landing-page chat or a /loop job) just + launched DETACHED, so Ctrl+C on this workspace stops it too -- see + _TrackedProcesses' own docstring for why a detached process needs + this instead of being reachable through the normal process-tree + kill every OTHER child of this server already gets.""" + if self.client_address[0] not in _LOOPBACK_ADDRESSES: + self._send_json(HTTPStatus.FORBIDDEN, + {"ok": False, "error": "Only reachable from localhost."}) + return + + body = self._read_json_body() + try: + pid = int(body.get("pid")) + except (TypeError, ValueError): + self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "pid must be an integer."}) + return + + _tracked_processes.track(pid) + self._send_json(HTTPStatus.OK, {"ok": True}) + + def _get_agent_docs(self): + """AGENTS.md content for the landing chat's preset prompts to attach, + so the agent has a grounded, accurate weightslab integration pattern + instead of guessing. Best-effort and never errors -- if a file + genuinely is not present (e.g. a `pip install` without a repo + checkout), it is just omitted from the response. + + ?example= (repeatable) additionally attaches each named + PyTorch usecase's complete main.py (see _KNOWN_EXAMPLE_USECASES) -- + whichever ones the requesting preset asks for. Unknown/unlisted + usecases are silently skipped rather than erroring, same as a missing + AGENTS.md.""" + files = [] + agents_md = _read_repo_doc("AGENTS.md") + if agents_md: + files.append({"name": "AGENTS.md", "content": agents_md}) + + query = parse_qs(urlsplit(self.path).query) + for usecase in query.get("example") or []: + example_main = _read_example_main(usecase) + if example_main: + files.append({"name": f"examples/PyTorch/{usecase}/main.py", "content": example_main}) + + self._send_json(HTTPStatus.OK, {"files": files}) + def _start_local_notebook(self): # Copying a file and spawning a process are OS-level actions the # browser can't do itself; this is the one piece of local-only @@ -779,6 +2209,11 @@ def _js_str(value: Optional[str]) -> str: ("ENABLE_HYPERPARAMETERS_OPTIMIZATION", "WS_ENABLE_HYPERPARAMETERS_OPTIMIZATION", "VITE_ENABLE_HYPERPARAMETERS_OPTIMIZATION")), ("WS_ENABLE_AGENT", ("ENABLE_AGENT", "WS_ENABLE_AGENT", "VITE_ENABLE_AGENT")), + # Shared with the SDK agent's own OPENCODE_URL config (agent.py's + # _load_config) -- setting OPENCODE_URL once configures both sides, and + # is also what _OpencodeSession.ensure() prefers over spawning its own + # server (see below). + ("WS_OPENCODE_URL", ("OPENCODE_URL", "WS_OPENCODE_URL", "VITE_OPENCODE_URL")), ] @@ -930,6 +2365,16 @@ def serve_ui( display_host = "localhost" if ui_host in ("0.0.0.0", "::", "") else ui_host url = f"{scheme}://{display_host}:{ui_port}" + # Where /agent-server/track-process lives, for anything launched from an + # environment descending from this process -- most importantly the + # OpenCode server (spawned by this server, so it inherits this) and + # therefore the agent's own shell, and therefore any training process the + # agent starts from it. weightslab.src.serve() reads this and registers + # ITSELF, so a run is tracked even when nobody POSTs its PID by hand; see + # _register_pid_with_ui_server there for why that mattered. Loopback + # rather than display_host because the endpoint only answers loopback. + os.environ["WEIGHTSLAB_UI_ORIGIN"] = f"{scheme}://127.0.0.1:{ui_port}" + sys.stdout.write( "\n" " WeightsLab UI is running:\n" @@ -947,6 +2392,10 @@ def serve_ui( thread.start() return httpd + # Only for the blocking (real CLI) path -- see _install_termination_handlers' + # own docstring for why plain Ctrl+C alone used to be the only covered case. + _install_termination_handlers() + try: httpd.serve_forever() except KeyboardInterrupt: @@ -954,7 +2403,7 @@ def serve_ui( finally: httpd.shutdown() httpd.server_close() - _jupyter_session.shutdown() + _run_shutdown_cleanup() return httpd