From 5959f93f5ff67880845292ad07e9a83dca840d2f Mon Sep 17 00:00:00 2001 From: GuillaumePELLUET Date: Wed, 5 Aug 2026 14:53:45 +0200 Subject: [PATCH 01/10] Remove OpenRouter dependencies with local llm to opencode --- AGENTS.md | 9 +- README.md | 14 + agent_config.yaml | 34 +- docs/agent.rst | 195 +++++- docs/configuration.rst | 126 ++-- docs/usage/parameters.rst | 31 +- docs/user_commands.rst | 4 +- docs/weights_studio.rst | 42 +- pyproject.toml | 8 +- tests/backend/test_cli_additional_unit.py | 18 +- tests/gRPC/test_grpc_user_actions.py | 2 +- .../test_agent_live_prompt_evaluation.py | 83 +-- .../test_agent_model_and_safety_unit.py | 81 +-- .../services/test_agent_opencode_provider.py | 291 ++++++++ .../services/test_agent_prompt_unit.py | 95 +-- .../services/test_agent_service_unit.py | 68 +- tests/trainer/services/test_opencode_chat.py | 250 +++++++ tests/ui/test_server_agent.py | 238 +++++++ tests/ui/test_server_loop.py | 356 ++++++++++ weightslab/backend/cli.py | 56 +- weightslab/proto/experiment_service.proto | 23 + weightslab/proto/experiment_service_pb2.py | 386 +++++----- .../proto/experiment_service_pb2_grpc.py | 433 +++++++----- weightslab/trainer/services/agent/agent.py | 525 ++++---------- .../trainer/services/agent/opencode_chat.py | 229 ++++++ weightslab/trainer/services/agent_service.py | 49 +- weightslab/trainer/services/data_service.py | 2 +- weightslab/trainer/services/utils/tools.py | 7 +- weightslab/ui/server.py | 662 ++++++++++++++++++ 29 files changed, 3126 insertions(+), 1191 deletions(-) create mode 100644 tests/trainer/services/test_agent_opencode_provider.py create mode 100644 tests/trainer/services/test_opencode_chat.py create mode 100644 tests/ui/test_server_agent.py create mode 100644 tests/ui/test_server_loop.py create mode 100644 weightslab/trainer/services/agent/opencode_chat.py diff --git a/AGENTS.md b/AGENTS.md index b7b63b3e..9629fab8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -222,10 +222,11 @@ gates and separates phases. **Large weights/images fail to transfer.** Raise `GRPC_MAX_MESSAGE_BYTES`. -**The agent bar says it's unconfigured.** The LLM agent needs a provider: a -local **Ollama** server (`provider: ollama`, available immediately) or **cloud -OpenRouter** initialized from the UI via `/init` (then `/model` to switch, -`/reset` to clear). See `weightslab/docs/weights_studio.rst`. +**The agent bar says it's unconfigured.** The LLM agent is backed entirely by +a local **OpenCode** server (`OPENCODE_URL`, default `http://127.0.0.1:4096`) +— WeightsLab starts one for you on first use. Initialize from the UI via +`/init` (then `/model` to switch, `/reset` to clear). See +`weightslab/docs/agent.rst` and `weightslab/docs/weights_studio.rst`. --- 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..ad7d5c38 100644 --- a/agent_config.yaml +++ b/agent_config.yaml @@ -4,25 +4,19 @@ # 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 lets the OpenCode server use its own + # configured default model. + opencode_model: "" diff --git a/docs/agent.rst b/docs/agent.rst index 9f00ed89..9d8b3b44 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,42 @@ 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. -- **OpenRouter** — cloud-hosted models (recommended; interactive onboarding in - the UI). -- **Ollama** — local inference, available immediately at backend startup when - configured in ``agent_config.yaml``. +Both agent surfaces (see above) converge on the **same** OpenCode server via +one shared environment variable: -You can initialize it three ways. +.. code-block:: bash + + 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 use OpenCode's own configured default. + +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 +341,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 +369,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 +389,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 +714,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 cd006a70..94bd97bc 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -683,10 +683,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 @@ -695,19 +700,23 @@ 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 uses OpenCode's own + configured default. 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 @@ -744,7 +753,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 ^^^^^^^^^^^^^^^^^^^ @@ -756,73 +765,33 @@ 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 + uses OpenCode's own configured default. -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 = use OpenCode's own configured default -.. 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 ^^^^^^^^^^^^^^^^^^^^^^^ @@ -830,11 +799,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. @@ -842,13 +810,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/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 8eebbdbe..9ef44d1f 100644 --- a/docs/user_commands.rst +++ b/docs/user_commands.rst @@ -487,9 +487,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 170624f9..3a741a79 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", 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/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..d1fd4bff --- /dev/null +++ b/tests/trainer/services/test_agent_opencode_provider.py @@ -0,0 +1,291 @@ +"""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 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") + + +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) + 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() + agent.opencode_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() + 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) + + +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..36b8f5aa 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,56 @@ 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) + 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..52840b2c --- /dev/null +++ b/tests/trainer/services/test_opencode_chat.py @@ -0,0 +1,250 @@ +"""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 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) + + emit({"type": "message.updated", "properties": {"info": {"id": "msg_1", "role": "assistant", "sessionID": session_id}}}) + 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, **kwargs): + super().__init__(*args, **kwargs) + self.session_id = "ses_test1" + self.reply_deltas = reply_deltas + self.emit_error = emit_error + self.recorded_messages = [] + self.recorded_session_titles = [] + + +class _ServerTestCase(unittest.TestCase): + def _start_server(self, reply_deltas, emit_error=False): + self.httpd = _FakeOpenCodeServer( + ("127.0.0.1", 0), _FakeOpenCodeHandler, reply_deltas=reply_deltas, emit_error=emit_error, + ) + 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") + + +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") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/ui/test_server_agent.py b/tests/ui/test_server_agent.py new file mode 100644 index 00000000..1d40e35a --- /dev/null +++ b/tests/ui/test_server_agent.py @@ -0,0 +1,238 @@ +"""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 + + +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) + + +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..0ca30391 --- /dev/null +++ b/tests/ui/test_server_loop.py @@ -0,0 +1,356 @@ +"""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_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): + with patch.object(ui_server, "_opencode_json_request", return_value={"id": "ses_abc"}) as create_mock, \ + patch.object(ui_server, "_opencode_send_and_collect", return_value="all good") 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("weightslab pause", sent_text) # system preamble documents the CLI verbs + + 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_send_and_collect", return_value="ok") 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_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") 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"): + 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"): + 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"): + 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"): + 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") 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"): + 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"): + 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"): + 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/weightslab/backend/cli.py b/weightslab/backend/cli.py index 6e64fa3c..c994a48c 100644 --- a/weightslab/backend/cli.py +++ b/weightslab/backend/cli.py @@ -210,9 +210,9 @@ def _handle_command(cmd: str) -> Any: }, 'agent_examples': { 'check agent': 'agent status', - 'initialize openrouter': 'agent init --api-key sk-or-... --model openai/gpt-4o-mini --timeout 20', + 'initialize opencode': 'agent init --model openrouter/anthropic/claude-opus-4.6', 'list available models': 'agent models', - 'switch model': 'agent model ~google/gemini-flash-latest', + 'switch model': 'agent model openrouter/openai/gpt-5', 'query the agent': 'agent query discard all samples with loss > 5 and tag them as hard_examples', 'query shortcut': 'ask tag train samples with loss > 1.2 as goldset', } @@ -695,12 +695,12 @@ def _handle_command(cmd: str) -> Any: return { 'ok': True, 'available': available, - 'message': 'Agent available.' if available else 'Agent not configured. Use: agent init --api-key KEY [--model MODEL] [--timeout SEC]', + 'message': 'Agent available.' if available else 'Agent not configured. Use: agent init [--model MODEL]', 'commands': { 'agent status': 'Check whether the agent is available', - 'agent init': 'Initialize OpenRouter with API key, model, and optional timeout', - 'agent model': 'Switch the active OpenRouter model', - 'agent models': 'List available OpenRouter models', + 'agent init': 'Initialize the OpenCode agent backend, optionally with a model', + 'agent model': 'Switch the active OpenCode model', + 'agent models': 'List available OpenCode models', 'agent reset': 'Clear the active agent connection', 'agent query': 'Execute a natural-language query through the agent', }, @@ -720,8 +720,8 @@ def _handle_command(cmd: str) -> Any: 'ok': True, 'available': available, 'preferred_provider': getattr(agent, 'preferred_provider', None), - 'openrouter_model': getattr(agent, 'openrouter_model', None), - 'openrouter_timeout': getattr(agent, 'openrouter_request_timeout', None), + 'opencode_url': getattr(agent, 'opencode_url', None), + 'opencode_model': getattr(agent, 'opencode_model', None), 'message': 'Agent available. Ready to help you.' if available else 'Agent not configured. Use agent init.', } @@ -729,51 +729,23 @@ def _handle_command(cmd: str) -> Any: if agent is None: return {'ok': False, 'error': 'agent_unavailable', 'message': 'Agent backend is not attached to the CLI.'} - api_key = None - provider = 'openrouter' model = None - timeout = None i = 0 while i < len(agent_parts): token = agent_parts[i] - if token == '--api-key' and i + 1 < len(agent_parts): - api_key = agent_parts[i + 1] - i += 2 - elif token == '--provider' and i + 1 < len(agent_parts): - provider = agent_parts[i + 1] - i += 2 - elif token == '--model' and i + 1 < len(agent_parts): + if token == '--model' and i + 1 < len(agent_parts): model = agent_parts[i + 1] i += 2 - elif token == '--timeout' and i + 1 < len(agent_parts): - timeout = agent_parts[i + 1] - i += 2 else: - return {'ok': False, 'error': 'usage: agent init --api-key KEY [--provider openrouter] [--model MODEL] [--timeout SEC]'} - - api_key = api_key or os.environ.get('OPENROUTER_API_KEY') - if not api_key: - return {'ok': False, 'error': 'usage: agent init --api-key KEY [--provider openrouter] [--model MODEL] [--timeout SEC]'} - - if timeout is not None: - try: - timeout_value = float(timeout) - except ValueError: - return {'ok': False, 'error': 'Invalid --timeout value'} - os.environ['OPENROUTER_REQUEST_TIMEOUT'] = str(timeout_value) - try: - agent.openrouter_request_timeout = timeout_value - except Exception: - pass + return {'ok': False, 'error': 'usage: agent init [--model MODEL]'} - success, message = agent.initialize_with_cloud_key(api_key, provider, model) + success, message = agent.initialize_with_cloud_key("", "opencode", model) return { 'ok': success, 'message': message, - 'provider': provider, - 'model': getattr(agent, 'openrouter_model', model), - 'timeout': getattr(agent, 'openrouter_request_timeout', None), + 'provider': 'opencode', + 'model': getattr(agent, 'opencode_model', model), } if subverb in ('model', 'set-model'): @@ -783,7 +755,7 @@ def _handle_command(cmd: str) -> Any: return {'ok': False, 'error': 'usage: agent model '} model = ' '.join(agent_parts).strip() success, message = agent.change_model(model) - return {'ok': success, 'message': message, 'model': getattr(agent, 'openrouter_model', model)} + return {'ok': success, 'message': message, 'model': getattr(agent, 'opencode_model', model)} if subverb in ('models', 'list-models'): if agent is None: diff --git a/weightslab/proto/experiment_service.proto b/weightslab/proto/experiment_service.proto index e926ab55..6c686348 100644 --- a/weightslab/proto/experiment_service.proto +++ b/weightslab/proto/experiment_service.proto @@ -40,6 +40,13 @@ service ExperimentService { rpc ChangeAgentModel (ChangeAgentModelRequest) returns (ChangeAgentModelResponse); rpc GetAgentModels (GetAgentModelsRequest) returns (GetAgentModelsResponse); rpc ResetAgent (Empty) returns (ResetAgentResponse); + // Wipe the agent's conversation history (self.history) without touching the + // provider connection -- distinct from ResetAgent, which drops the connection. + rpc ClearAgentHistory (Empty) returns (ClearAgentHistoryResponse); + // Summarize the agent's conversation history via the active model, replacing + // it with the summary. Distinct from OpenCode's own session compaction (which + // this does not touch) -- this is the SDK agent's own self.history. + rpc CompactAgentHistory (Empty) returns (CompactAgentHistoryResponse); // Notebook (shared in-process Python kernel for the studio UI). Cell output is // server-streamed in chunks (stdout / stderr / result / image), mirroring @@ -576,6 +583,12 @@ message AgentHealthResponse { // --- Agent Onboarding --- enum AgentProviderType { PROVIDER_OPENROUTER = 0; + // A local OpenCode server (opencode.ai) backs the LLM calls instead of a + // direct OpenRouter connection. No api_key is used for this provider -- the + // credential lives in OpenCode's own config, entered once via `opencode auth + // login` or the Weights Studio landing page's login modal. `model` carries + // OpenCode's "providerID/modelID" string (e.g. "openrouter/anthropic/claude-opus-4.6"). + PROVIDER_OPENCODE = 1; } message InitializeAgentRequest { @@ -611,6 +624,16 @@ message ResetAgentResponse { string message = 2; } +message ClearAgentHistoryResponse { + bool success = 1; + string message = 2; +} + +message CompactAgentHistoryResponse { + bool success = 1; + string message = 2; +} + // --- Checkpoint Restore --- message RestoreCheckpointRequest { string experiment_hash = 1; // Hash code of checkpoint to restore diff --git a/weightslab/proto/experiment_service_pb2.py b/weightslab/proto/experiment_service_pb2.py index d299a943..9adf459e 100644 --- a/weightslab/proto/experiment_service_pb2.py +++ b/weightslab/proto/experiment_service_pb2.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # NO CHECKED-IN PROTOBUF GENCODE -# source: weightslab/proto/experiment_service.proto -# Protobuf Python Version: 6.31.1 +# source: experiment_service.proto +# Protobuf Python Version: 5.28.1 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool @@ -11,11 +11,11 @@ from google.protobuf.internal import builder as _builder _runtime_version.ValidateProtobufRuntimeVersion( _runtime_version.Domain.PUBLIC, - 6, - 31, + 5, + 28, 1, '', - 'weightslab/proto/experiment_service.proto' + 'experiment_service.proto' ) # @@protoc_insertion_point(imports) @@ -24,11 +24,11 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n)weightslab/proto/experiment_service.proto\"\x89\x01\n\x1aGetLatestLoggerDataRequest\x12\x1c\n\x14request_full_history\x18\x01 \x01(\x08\x12\x12\n\nmax_points\x18\x02 \x01(\x05\x12\x17\n\x0f\x62reak_by_slices\x18\x03 \x01(\x08\x12\x0c\n\x04tags\x18\x04 \x03(\t\x12\x12\n\ngraph_name\x18\x05 \x01(\t\"\x81\x02\n\x0fLoggerDataPoint\x12\x13\n\x0bmetric_name\x18\x01 \x01(\t\x12\x11\n\tmodel_age\x18\x02 \x01(\x05\x12\x14\n\x0cmetric_value\x18\x03 \x01(\x02\x12\x17\n\x0f\x65xperiment_hash\x18\x04 \x01(\t\x12\x11\n\ttimestamp\x18\x05 \x01(\x03\x12\x11\n\tsample_id\x18\x06 \x01(\t\x12\x1c\n\x14is_evaluation_marker\x18\x07 \x01(\x08\x12\x12\n\nsplit_name\x18\x08 \x01(\t\x12\x17\n\x0f\x65valuation_tags\x18\t \x03(\t\x12\x12\n\npoint_note\x18\n \x01(\t\x12\x12\n\naudit_mode\x18\x0b \x01(\x08\"[\n\x1bGetLatestLoggerDataResponse\x12 \n\x06points\x18\x01 \x03(\x0b\x32\x10.LoggerDataPoint\x12\x1a\n\x12weightslab_version\x18\x02 \x01(\t\"\x07\n\x05\x45mpty\"/\n\x08NeuronId\x12\x10\n\x08layer_id\x18\x01 \x01(\x05\x12\x11\n\tneuron_id\x18\x02 \x01(\x05\"\x91\x02\n\x0fWeightOperation\x12*\n\x07op_type\x18\x01 \x01(\x0e\x32\x14.WeightOperationTypeH\x00\x88\x01\x01\x12\x15\n\x08layer_id\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x1d\n\nneuron_ids\x18\x03 \x03(\x0b\x32\t.NeuronId\x12\x16\n\x0eneurons_to_add\x18\t \x01(\x05\x12 \n\x18zerofy_from_incoming_ids\x18\x0b \x03(\x05\x12\x1c\n\x14zerofy_to_neuron_ids\x18\x0c \x03(\x05\x12+\n\x11zerofy_predicates\x18\r \x03(\x0e\x32\x10.ZerofyPredicateB\n\n\x08_op_typeB\x0b\n\t_layer_id\"_\n\x17WeightsOperationRequest\x12/\n\x10weight_operation\x18\x01 \x01(\x0b\x32\x10.WeightOperationH\x00\x88\x01\x01\x42\x13\n\x11_weight_operation\"<\n\x18WeightsOperationResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"\xc1\x05\n\x0fHyperParameters\x12\x1c\n\x0f\x65xperiment_name\x18\x01 \x01(\tH\x00\x88\x01\x01\x12!\n\x14training_steps_to_do\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x1a\n\rlearning_rate\x18\x03 \x01(\x02H\x02\x88\x01\x01\x12\x17\n\nbatch_size\x18\x04 \x01(\x05H\x03\x88\x01\x01\x12 \n\x13\x66ull_eval_frequency\x18\x05 \x01(\x05H\x04\x88\x01\x01\x12 \n\x13\x63heckpont_frequency\x18\x06 \x01(\x05H\x05\x88\x01\x01\x12\x18\n\x0bis_training\x18\x07 \x01(\x08H\x06\x88\x01\x01\x12\x15\n\x08nb_steps\x18\x08 \x01(\x05H\x07\x88\x01\x01\x12\x19\n\x0c\x61uditor_mode\x18\t \x01(\x08H\x08\x88\x01\x01\x12\x1d\n\x10train_batch_size\x18\n \x01(\x05H\t\x88\x01\x01\x12\x1b\n\x0eval_batch_size\x18\x0b \x01(\x05H\n\x88\x01\x01\x12\x1c\n\x0ftest_batch_size\x18\x0c \x01(\x05H\x0b\x88\x01\x01\x12\x1c\n\x0f\x65valuation_mode\x18\r \x01(\x08H\x0c\x88\x01\x01\x12\x1e\n\x11\x65valuation_config\x18\x0e \x01(\tH\r\x88\x01\x01\x42\x12\n\x10_experiment_nameB\x17\n\x15_training_steps_to_doB\x10\n\x0e_learning_rateB\r\n\x0b_batch_sizeB\x16\n\x14_full_eval_frequencyB\x16\n\x14_checkpont_frequencyB\x0e\n\x0c_is_trainingB\x0b\n\t_nb_stepsB\x0f\n\r_auditor_modeB\x13\n\x11_train_batch_sizeB\x11\n\x0f_val_batch_sizeB\x12\n\x10_test_batch_sizeB\x12\n\x10_evaluation_modeB\x14\n\x12_evaluation_config\",\n\rMetricsStatus\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x02\"~\n\rAnnotatStatus\x12\x0c\n\x04name\x18\x01 \x01(\t\x12.\n\x08metadata\x18\x02 \x03(\x0b\x32\x1c.AnnotatStatus.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x02:\x02\x38\x01\"\x90\x02\n\x10TrainingStatusEx\x12\x16\n\ttimestamp\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x1c\n\x0f\x65xperiment_name\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x16\n\tmodel_age\x18\x03 \x01(\x05H\x02\x88\x01\x01\x12+\n\x0emetrics_status\x18\x04 \x01(\x0b\x32\x0e.MetricsStatusH\x03\x88\x01\x01\x12+\n\x0e\x61nnotat_status\x18\x05 \x01(\x0b\x32\x0e.AnnotatStatusH\x04\x88\x01\x01\x42\x0c\n\n_timestampB\x12\n\x10_experiment_nameB\x0c\n\n_model_ageB\x11\n\x0f_metrics_statusB\x11\n\x0f_annotat_status\"]\n\x15HyperParameterCommand\x12/\n\x10hyper_parameters\x18\x01 \x01(\x0b\x32\x10.HyperParametersH\x00\x88\x01\x01\x42\x13\n\x11_hyper_parameters\">\n\x14\x44\x65nySamplesOperation\x12\x12\n\nsample_ids\x18\x01 \x03(\t\x12\x12\n\naccumulate\x18\x02 \x01(\x08\"0\n\x17LoadCheckpointOperation\x12\x15\n\rcheckpoint_id\x18\x01 \x01(\x05\"b\n\x11PlotNoteOperation\x12\x13\n\x0bmetric_name\x18\x01 \x01(\t\x12\x17\n\x0f\x65xperiment_hash\x18\x02 \x01(\t\x12\x11\n\tmodel_age\x18\x03 \x01(\x05\x12\x0c\n\x04note\x18\x04 \x01(\t\"L\n\x17SaveCheckpointOperation\x12\x19\n\x11save_architecture\x18\x01 \x01(\x08\x12\x16\n\x0esave_optimizer\x18\x02 \x01(\x08\"\x1a\n\x18RestartInstanceOperation\"\x8d\x08\n\x0eTrainerCommand\x12\x1c\n\x14get_hyper_parameters\x18\x04 \x01(\x08\x12\x1e\n\x16get_interactive_layers\x18\x05 \x01(\x08\x12\x1d\n\x10get_data_records\x18\x06 \x01(\tH\x00\x88\x01\x01\x12%\n\x18get_single_layer_info_id\x18\x08 \x01(\x05H\x01\x88\x01\x01\x12;\n\x16hyper_parameter_change\x18\x01 \x01(\x0b\x32\x16.HyperParameterCommandH\x02\x88\x01\x01\x12:\n\x16\x64\x65ny_samples_operation\x18\x07 \x01(\x0b\x32\x15.DenySamplesOperationH\x03\x88\x01\x01\x12?\n\x1b\x64\x65ny_eval_samples_operation\x18\n \x01(\x0b\x32\x15.DenySamplesOperationH\x04\x88\x01\x01\x12@\n\x19load_checkpoint_operation\x18\t \x01(\x0b\x32\x18.LoadCheckpointOperationH\x05\x88\x01\x01\x12\x42\n\x1eremove_from_denylist_operation\x18\x0b \x01(\x0b\x32\x15.DenySamplesOperationH\x06\x88\x01\x01\x12G\n#remove_eval_from_denylist_operation\x18\x0c \x01(\x0b\x32\x15.DenySamplesOperationH\x07\x88\x01\x01\x12\x34\n\x13plot_note_operation\x18\r \x01(\x0b\x32\x12.PlotNoteOperationH\x08\x88\x01\x01\x12@\n\x19save_checkpoint_operation\x18\x0e \x01(\x0b\x32\x18.SaveCheckpointOperationH\t\x88\x01\x01\x12\x39\n\x11restart_operation\x18\x0f \x01(\x0b\x32\x19.RestartInstanceOperationH\n\x88\x01\x01\x42\x13\n\x11_get_data_recordsB\x1b\n\x19_get_single_layer_info_idB\x19\n\x17_hyper_parameter_changeB\x19\n\x17_deny_samples_operationB\x1e\n\x1c_deny_eval_samples_operationB\x1c\n\x1a_load_checkpoint_operationB!\n\x1f_remove_from_denylist_operationB&\n$_remove_eval_from_denylist_operationB\x16\n\x14_plot_note_operationB\x1c\n\x1a_save_checkpoint_operationB\x14\n\x12_restart_operation\"\x9d\x01\n\x12HyperParameterDesc\x12\r\n\x05label\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0c\n\x04type\x18\x03 \x01(\t\x12\x1c\n\x0fnumerical_value\x18\x04 \x01(\x02H\x00\x88\x01\x01\x12\x19\n\x0cstring_value\x18\x05 \x01(\tH\x01\x88\x01\x01\x42\x12\n\x10_numerical_valueB\x0f\n\r_string_value\"\xf2\x02\n\x10NeuronStatistics\x12!\n\tneuron_id\x18\x01 \x01(\x0b\x32\t.NeuronIdH\x00\x88\x01\x01\x12\x17\n\nneuron_age\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x1f\n\x12train_trigger_rate\x18\x03 \x01(\x02H\x02\x88\x01\x01\x12\x1e\n\x11\x65val_trigger_rate\x18\x04 \x01(\x02H\x03\x88\x01\x01\x12\x1a\n\rlearning_rate\x18\x07 \x01(\x02H\x04\x88\x01\x01\x12\x36\n\x0bincoming_lr\x18\x08 \x03(\x0b\x32!.NeuronStatistics.IncomingLrEntry\x1a\x31\n\x0fIncomingLrEntry\x12\x0b\n\x03key\x18\x01 \x01(\x05\x12\r\n\x05value\x18\x02 \x01(\x02:\x02\x38\x01\x42\x0c\n\n_neuron_idB\r\n\x0b_neuron_ageB\x15\n\x13_train_trigger_rateB\x14\n\x12_eval_trigger_rateB\x10\n\x0e_learning_rate\"\xf0\x02\n\x13LayerRepresentation\x12\x15\n\x08layer_id\x18\x01 \x01(\x05H\x00\x88\x01\x01\x12\x17\n\nlayer_name\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x17\n\nlayer_type\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x1a\n\rneurons_count\x18\x04 \x01(\x05H\x03\x88\x01\x01\x12#\n\x16incoming_neurons_count\x18\x05 \x01(\x05H\x04\x88\x01\x01\x12\x18\n\x0bkernel_size\x18\x06 \x01(\x05H\x05\x88\x01\x01\x12\x13\n\x06stride\x18\x07 \x01(\x05H\x06\x88\x01\x01\x12-\n\x12neurons_statistics\x18\n \x03(\x0b\x32\x11.NeuronStatisticsB\x0b\n\t_layer_idB\r\n\x0b_layer_nameB\r\n\x0b_layer_typeB\x10\n\x0e_neurons_countB\x19\n\x17_incoming_neurons_countB\x0e\n\x0c_kernel_sizeB\t\n\x07_stride\"H\n\x11\x41\x63tivationRequest\x12\x10\n\x08layer_id\x18\x01 \x01(\x05\x12\x11\n\tsample_id\x18\x02 \x01(\t\x12\x0e\n\x06origin\x18\x03 \x01(\t\"H\n\rActivationMap\x12\x11\n\tneuron_id\x18\x01 \x01(\x05\x12\x0e\n\x06values\x18\x02 \x03(\x02\x12\t\n\x01H\x18\x03 \x01(\x05\x12\t\n\x01W\x18\x04 \x01(\x05\"d\n\x12\x41\x63tivationResponse\x12\x12\n\nlayer_type\x18\x01 \x01(\t\x12\x15\n\rneurons_count\x18\x02 \x01(\x05\x12#\n\x0b\x61\x63tivations\x18\x03 \x03(\x0b\x32\x0e.ActivationMap\"\x93\x01\n\tTaskField\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x15\n\x0b\x66loat_value\x18\x02 \x01(\x02H\x00\x12\x13\n\tint_value\x18\x03 \x01(\x05H\x00\x12\x16\n\x0cstring_value\x18\x04 \x01(\tH\x00\x12\x15\n\x0b\x62ytes_value\x18\x05 \x01(\x0cH\x00\x12\x14\n\nbool_value\x18\x06 \x01(\x08H\x00\x42\x07\n\x05value\"\x87\x03\n\x0eRecordMetadata\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\x14\n\x0csample_label\x18\x02 \x03(\x05\x12\x19\n\x11sample_prediction\x18\x03 \x03(\x05\x12=\n\x10sample_last_loss\x18\x04 \x03(\x0b\x32#.RecordMetadata.SampleLastLossEntry\x12\x19\n\x11sample_encounters\x18\x05 \x01(\x05\x12\x18\n\x10sample_discarded\x18\x06 \x01(\x08\x12 \n\x0c\x65xtra_fields\x18\x07 \x03(\x0b\x32\n.TaskField\x12\x16\n\x0eprediction_raw\x18\t \x01(\x0c\x12\x11\n\ttask_type\x18\n \x01(\t\x12\x19\n\x11sample_label_text\x18\x0b \x03(\t\x12\x1e\n\x16sample_prediction_text\x18\x0c \x03(\t\x1a\x35\n\x13SampleLastLossEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x02:\x02\x38\x01\"\x93\x01\n\x10SampleStatistics\x12\x13\n\x06origin\x18\x06 \x01(\tH\x00\x88\x01\x01\x12\x19\n\x0csample_count\x18\x07 \x01(\x05H\x01\x88\x01\x01\x12\x11\n\ttask_type\x18\t \x01(\t\x12 \n\x07records\x18\x08 \x03(\x0b\x32\x0f.RecordMetadataB\t\n\x07_originB\x0f\n\r_sample_count\"\xe6\x01\n\x0f\x43ommandResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x33\n\x16hyper_parameters_descs\x18\x03 \x03(\x0b\x32\x13.HyperParameterDesc\x12\x33\n\x15layer_representations\x18\x04 \x03(\x0b\x32\x14.LayerRepresentation\x12\x31\n\x11sample_statistics\x18\x05 \x01(\x0b\x32\x11.SampleStatisticsH\x00\x88\x01\x01\x42\x14\n\x12_sample_statistics\"U\n\rSampleRequest\x12\x16\n\tsample_id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06origin\x18\x02 \x01(\tH\x01\x88\x01\x01\x42\x0c\n\n_sample_idB\t\n\x07_origin\"\xad\x02\n\x15SampleRequestResponse\x12\x16\n\tsample_id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06origin\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x12\n\x05label\x18\x03 \x01(\x05H\x02\x88\x01\x01\x12\x11\n\x04\x64\x61ta\x18\x04 \x01(\x0cH\x03\x88\x01\x01\x12\x1a\n\rerror_message\x18\x05 \x01(\tH\x04\x88\x01\x01\x12\x15\n\x08raw_data\x18\x06 \x01(\x0cH\x05\x88\x01\x01\x12\x11\n\x04mask\x18\x07 \x01(\x0cH\x06\x88\x01\x01\x12\x17\n\nprediction\x18\x08 \x01(\x0cH\x07\x88\x01\x01\x42\x0c\n\n_sample_idB\t\n\x07_originB\x08\n\x06_labelB\x07\n\x05_dataB\x10\n\x0e_error_messageB\x0b\n\t_raw_dataB\x07\n\x05_maskB\r\n\x0b_prediction\"\x92\x01\n\x12\x42\x61tchSampleRequest\x12\x12\n\nsample_ids\x18\x01 \x03(\t\x12\x0e\n\x06origin\x18\x02 \x01(\t\x12\x19\n\x0cresize_width\x18\x03 \x01(\x05H\x00\x88\x01\x01\x12\x1a\n\rresize_height\x18\x04 \x01(\x05H\x01\x88\x01\x01\x42\x0f\n\r_resize_widthB\x10\n\x0e_resize_height\">\n\x13\x42\x61tchSampleResponse\x12\'\n\x07samples\x18\x01 \x03(\x0b\x32\x16.SampleRequestResponse\".\n\x0eWeightsRequest\x12\x1c\n\tneuron_id\x18\x01 \x01(\x0b\x32\t.NeuronId\"\x9d\x02\n\x0fWeightsResponse\x12\x1c\n\tneuron_id\x18\x01 \x01(\x0b\x32\t.NeuronId\x12\x17\n\nlayer_name\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x17\n\nlayer_type\x18\x03 \x01(\tH\x01\x88\x01\x01\x12\x10\n\x08incoming\x18\x04 \x01(\x05\x12\x10\n\x08outgoing\x18\x05 \x01(\x05\x12\x18\n\x0bkernel_size\x18\x06 \x01(\x05H\x02\x88\x01\x01\x12\x0f\n\x07weights\x18\x07 \x03(\x02\x12\x0f\n\x07success\x18\x0b \x01(\x08\x12\x1a\n\rerror_message\x18\x0c \x01(\tH\x03\x88\x01\x01\x42\r\n\x0b_layer_nameB\r\n\x0b_layer_typeB\x0e\n\x0c_kernel_sizeB\x10\n\x0e_error_message\"R\n\x10\x44\x61taQueryRequest\x12\r\n\x05query\x18\x01 \x01(\t\x12\x12\n\naccumulate\x18\x02 \x01(\x08\x12\x1b\n\x13is_natural_language\x18\x03 \x01(\x08\"5\n\x11\x43\x61tegoricalTagDef\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\ncategories\x18\x02 \x03(\t\"\xa9\x02\n\x11\x44\x61taQueryResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x1d\n\x15number_of_all_samples\x18\x03 \x01(\x05\x12%\n\x1dnumber_of_samples_in_the_loop\x18\x04 \x01(\x05\x12#\n\x1bnumber_of_discarded_samples\x18\x05 \x01(\x05\x12\x13\n\x0bunique_tags\x18\x06 \x03(\t\x12+\n\x11\x61gent_intent_type\x18\x07 \x01(\x0e\x32\x10.AgentIntentType\x12\x17\n\x0f\x61nalysis_result\x18\x08 \x01(\t\x12,\n\x10\x63\x61tegorical_tags\x18\t \x03(\x0b\x32\x12.CategoricalTagDef\"\xc2\x01\n\x12\x44\x61taSamplesRequest\x12\x13\n\x0bstart_index\x18\x01 \x01(\x05\x12\x13\n\x0brecords_cnt\x18\x02 \x01(\x05\x12 \n\x18include_transformed_data\x18\x03 \x01(\x08\x12\x18\n\x10include_raw_data\x18\x04 \x01(\x08\x12\x19\n\x11stats_to_retrieve\x18\x05 \x03(\t\x12\x14\n\x0cresize_width\x18\x06 \x01(\x05\x12\x15\n\rresize_height\x18\x07 \x01(\x05\"m\n\x08\x44\x61taStat\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\r\n\x05shape\x18\x03 \x03(\x05\x12\r\n\x05value\x18\x04 \x03(\x02\x12\x14\n\x0cvalue_string\x18\x05 \x01(\t\x12\x11\n\tthumbnail\x18\x06 \x01(\x0c\">\n\nDataRecord\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\x1d\n\ndata_stats\x18\x02 \x03(\x0b\x32\t.DataStat\"Z\n\x13\x44\x61taSamplesResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12!\n\x0c\x64\x61ta_records\x18\x03 \x03(\x0b\x32\x0b.DataRecord\"C\n\x0fHistogramSubBar\x12\x0e\n\x06origin\x18\x01 \x01(\t\x12\x11\n\tdiscarded\x18\x02 \x01(\x08\x12\r\n\x05\x63ount\x18\x03 \x01(\x03\"h\n\x0cHistogramBin\x12\x0b\n\x03min\x18\x01 \x01(\x01\x12\x0b\n\x03max\x18\x02 \x01(\x01\x12\x0b\n\x03\x61vg\x18\x03 \x01(\x01\x12\r\n\x05\x63ount\x18\x04 \x01(\x03\x12\"\n\x08sub_bars\x18\x05 \x03(\x0b\x32\x10.HistogramSubBar\"[\n\x17\x43\x61tegoricalHistogramBar\x12\r\n\x05label\x18\x01 \x01(\t\x12\r\n\x05\x63ount\x18\x02 \x01(\x03\x12\"\n\x08sub_bars\x18\x03 \x03(\x0b\x32\x10.HistogramSubBar\"4\n\x10HistogramRequest\x12\x0e\n\x06\x63olumn\x18\x01 \x01(\t\x12\x10\n\x08max_bins\x18\x02 \x01(\x05\"\xb2\x01\n\x11HistogramResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x12\n\ntotal_rows\x18\x03 \x01(\x03\x12\x1b\n\x04\x62ins\x18\x04 \x03(\x0b\x32\r.HistogramBin\x12\x16\n\x0eis_categorical\x18\x05 \x01(\x08\x12\x32\n\x10\x63\x61tegorical_bars\x18\x06 \x03(\x0b\x32\x18.CategoricalHistogramBar\"W\n\x12GetMetaDataRequest\x12\x13\n\x0bstart_index\x18\x01 \x01(\x05\x12\x13\n\x0brecords_cnt\x18\x02 \x01(\x05\x12\x17\n\x0fmodal_sample_id\x18\x03 \x01(\t\"\x99\x01\n\x13GetMetaDataResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x1a\n\x12\x61ll_metadata_names\x18\x03 \x03(\t\x12!\n\x0cgrid_records\x18\x04 \x03(\x0b\x32\x0b.DataRecord\x12!\n\x0cmodal_record\x18\x05 \x01(\x0b\x32\x0b.DataRecord\"Y\n\x1aGetSignalTrajectoryRequest\x12\x13\n\x0bsignal_name\x18\x01 \x01(\t\x12\x12\n\nsample_ids\x18\x02 \x03(\t\x12\x12\n\nmax_points\x18\x03 \x01(\x05\"4\n\x10SignalTrajectory\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x03(\x02\"}\n\x1bGetSignalTrajectoryResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x13\n\x0bsignal_name\x18\x03 \x01(\t\x12\'\n\x0ctrajectories\x18\x04 \x03(\x0b\x32\x11.SignalTrajectory\"J\n\x11PointCloudRequest\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\x0e\n\x06origin\x18\x02 \x01(\t\x12\x12\n\nmax_points\x18\x03 \x01(\x05\"\xbf\x01\n\x0fPointCloudChunk\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x12\n\nnum_points\x18\x03 \x01(\x05\x12\x14\n\x0cnum_features\x18\x04 \x01(\x05\x12\x10\n\x08pc_range\x18\x05 \x03(\x02\x12\x0c\n\x04\x64\x61ta\x18\x06 \x01(\x0c\x12\x13\n\x0b\x63hunk_index\x18\x07 \x01(\x05\x12\x14\n\x0ctotal_chunks\x18\x08 \x01(\x05\x12\x15\n\rfeature_names\x18\t \x03(\t\"\xdc\x01\n\x10\x44\x61taEditsRequest\x12\x11\n\tstat_name\x18\x01 \x01(\t\x12\x13\n\x0b\x66loat_value\x18\x02 \x01(\x02\x12\x14\n\x0cstring_value\x18\x03 \x01(\t\x12\x12\n\nbool_value\x18\x04 \x01(\x08\x12\x1d\n\x04type\x18\x05 \x01(\x0e\x32\x0f.SampleEditType\x12\x13\n\x0bsamples_ids\x18\x06 \x03(\t\x12\x16\n\x0esample_origins\x18\x07 \x03(\t\x12\x16\n\x0eis_categorical\x18\x08 \x01(\x08\x12\x12\n\ncategories\x18\t \x03(\t\"5\n\x11\x44\x61taEditsResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\":\n\x12\x44\x61taSplitsResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x13\n\x0bsplit_names\x18\x02 \x03(\t\"9\n\x13\x41gentHealthResponse\x12\x11\n\tavailable\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"^\n\x16InitializeAgentRequest\x12\x0f\n\x07\x61pi_key\x18\x01 \x01(\t\x12$\n\x08provider\x18\x02 \x01(\x0e\x32\x12.AgentProviderType\x12\r\n\x05model\x18\x03 \x01(\t\";\n\x17InitializeAgentResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"(\n\x17\x43hangeAgentModelRequest\x12\r\n\x05model\x18\x01 \x01(\t\"<\n\x18\x43hangeAgentModelResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"\x17\n\x15GetAgentModelsRequest\"J\n\x16GetAgentModelsResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0e\n\x06models\x18\x02 \x03(\t\x12\x0f\n\x07message\x18\x03 \x01(\t\"6\n\x12ResetAgentResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"3\n\x18RestoreCheckpointRequest\x12\x17\n\x0f\x65xperiment_hash\x18\x01 \x01(\t\"=\n\x19RestoreCheckpointResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"R\n\x18TriggerEvaluationRequest\x12\x12\n\nsplit_name\x18\x01 \x01(\t\x12\x0c\n\x04tags\x18\x02 \x03(\t\x12\x14\n\x0cuse_full_set\x18\x03 \x01(\x08\"=\n\x19TriggerEvaluationResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"\x1c\n\x1aGetEvaluationStatusRequest\"\x81\x01\n\x1bGetEvaluationStatusResponse\x12\x0e\n\x06status\x18\x01 \x01(\t\x12\x0f\n\x07\x63urrent\x18\x02 \x01(\x05\x12\r\n\x05total\x18\x03 \x01(\x05\x12\x0f\n\x07message\x18\x04 \x01(\t\x12\r\n\x05\x65rror\x18\x05 \x01(\t\x12\x12\n\nsplit_name\x18\x06 \x01(\t\")\n\x17\x43\x61ncelEvaluationRequest\x12\x0e\n\x06reason\x18\x01 \x01(\t\"<\n\x18\x43\x61ncelEvaluationResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"7\n\x16RunNotebookCellRequest\x12\x0c\n\x04\x63ode\x18\x01 \x01(\t\x12\x0f\n\x07\x63\x65ll_id\x18\x02 \x01(\t\"2\n\x10NotebookCellDone\x12\x12\n\nexec_count\x18\x01 \x01(\x05\x12\n\n\x02ok\x18\x02 \x01(\x08\"\x1e\n\x1cInterruptNotebookCellRequest\":\n\x1dInterruptNotebookCellResponse\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"\xbd\x01\n\x11NotebookCellChunk\x12\x0f\n\x07\x63\x65ll_id\x18\x01 \x01(\t\x12\x10\n\x06stdout\x18\x02 \x01(\tH\x00\x12\x10\n\x06stderr\x18\x03 \x01(\tH\x00\x12\x15\n\x0bresult_text\x18\x04 \x01(\tH\x00\x12\x13\n\timage_png\x18\x05 \x01(\x0cH\x00\x12\x19\n\x0f\x65rror_traceback\x18\x06 \x01(\tH\x00\x12!\n\x04\x64one\x18\x07 \x01(\x0b\x32\x11.NotebookCellDoneH\x00\x42\t\n\x07payload\"S\n\x10NotebookResponse\x12\x12\n\nipynb_json\x18\x01 \x01(\t\x12\x0f\n\x07\x65xisted\x18\x02 \x01(\x08\x12\x0c\n\x04path\x18\x03 \x01(\t\x12\x0c\n\x04name\x18\x04 \x01(\t\"7\n\x13SaveNotebookRequest\x12\x12\n\nipynb_json\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\"M\n\x14SaveNotebookResponse\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x0c\n\x04path\x18\x02 \x01(\t\x12\r\n\x05\x65rror\x18\x03 \x01(\t\x12\x0c\n\x04name\x18\x04 \x01(\t\"C\n\x1bGenerateNotebookCodeRequest\x12\x0e\n\x06prompt\x18\x01 \x01(\t\x12\x14\n\x0c\x63ontext_code\x18\x02 \x01(\t\"\\\n\x1cGenerateNotebookCodeResponse\x12\x0c\n\x04\x63ode\x18\x01 \x01(\t\x12\x13\n\x0b\x65xplanation\x18\x02 \x01(\t\x12\n\n\x02ok\x18\x03 \x01(\x08\x12\r\n\x05\x65rror\x18\x04 \x01(\t*d\n\x13WeightOperationType\x12\n\n\x06ZEROFY\x10\x00\x12\x10\n\x0cREINITIALIZE\x10\x01\x12\n\n\x06\x46REEZE\x10\x02\x12\x12\n\x0eREMOVE_NEURONS\x10\t\x12\x0f\n\x0b\x41\x44\x44_NEURONS\x10\n*o\n\x0fZerofyPredicate\x12\x19\n\x15ZEROFY_PREDICATE_NONE\x10\x00\x12 \n\x1cZEROFY_PREDICATE_WITH_FROZEN\x10\x01\x12\x1f\n\x1bZEROFY_PREDICATE_WITH_OLDER\x10\x02*M\n\x0f\x41gentIntentType\x12\x12\n\x0eINTENT_UNKNOWN\x10\x00\x12\x11\n\rINTENT_FILTER\x10\x01\x12\x13\n\x0fINTENT_ANALYSIS\x10\x02*I\n\x0eSampleEditType\x12\x11\n\rEDIT_OVERRIDE\x10\x00\x12\x13\n\x0f\x45\x44IT_ACCUMULATE\x10\x01\x12\x0f\n\x0b\x45\x44IT_REMOVE\x10\x02*,\n\x11\x41gentProviderType\x12\x17\n\x13PROVIDER_OPENROUTER\x10\x00\x32\x9d\x0e\n\x11\x45xperimentService\x12P\n\x13GetLatestLoggerData\x12\x1b.GetLatestLoggerDataRequest\x1a\x1c.GetLatestLoggerDataResponse\x12\x36\n\x11\x45xperimentCommand\x12\x0f.TrainerCommand\x1a\x10.CommandResponse\x12H\n\x11ManipulateWeights\x12\x18.WeightsOperationRequest\x1a\x19.WeightsOperationResponse\x12/\n\nGetWeights\x12\x0f.WeightsRequest\x1a\x10.WeightsResponse\x12\x39\n\x0eGetActivations\x12\x12.ActivationRequest\x1a\x13.ActivationResponse\x12\x37\n\nGetSamples\x12\x13.BatchSampleRequest\x1a\x14.BatchSampleResponse\x12\x37\n\x0e\x41pplyDataQuery\x12\x11.DataQueryRequest\x1a\x12.DataQueryResponse\x12;\n\x0eGetDataSamples\x12\x13.DataSamplesRequest\x1a\x14.DataSamplesResponse\x12\x35\n\x0cGetHistogram\x12\x11.HistogramRequest\x1a\x12.HistogramResponse\x12\x38\n\x0bGetMetaData\x12\x13.GetMetaDataRequest\x1a\x14.GetMetaDataResponse\x12P\n\x13GetSignalTrajectory\x12\x1b.GetSignalTrajectoryRequest\x1a\x1c.GetSignalTrajectoryResponse\x12\x37\n\rGetPointCloud\x12\x12.PointCloudRequest\x1a\x10.PointCloudChunk0\x01\x12\x37\n\x0e\x45\x64itDataSample\x12\x11.DataEditsRequest\x1a\x12.DataEditsResponse\x12,\n\rGetDataSplits\x12\x06.Empty\x1a\x13.DataSplitsResponse\x12\x30\n\x10\x43heckAgentHealth\x12\x06.Empty\x1a\x14.AgentHealthResponse\x12\x44\n\x0fInitializeAgent\x12\x17.InitializeAgentRequest\x1a\x18.InitializeAgentResponse\x12G\n\x10\x43hangeAgentModel\x12\x18.ChangeAgentModelRequest\x1a\x19.ChangeAgentModelResponse\x12\x41\n\x0eGetAgentModels\x12\x16.GetAgentModelsRequest\x1a\x17.GetAgentModelsResponse\x12)\n\nResetAgent\x12\x06.Empty\x1a\x13.ResetAgentResponse\x12@\n\x0fRunNotebookCell\x12\x17.RunNotebookCellRequest\x1a\x12.NotebookCellChunk0\x01\x12V\n\x15InterruptNotebookCell\x12\x1d.InterruptNotebookCellRequest\x1a\x1e.InterruptNotebookCellResponse\x12(\n\x0bGetNotebook\x12\x06.Empty\x1a\x11.NotebookResponse\x12;\n\x0cSaveNotebook\x12\x14.SaveNotebookRequest\x1a\x15.SaveNotebookResponse\x12S\n\x14GenerateNotebookCode\x12\x1c.GenerateNotebookCodeRequest\x1a\x1d.GenerateNotebookCodeResponse\x12J\n\x11RestoreCheckpoint\x12\x19.RestoreCheckpointRequest\x1a\x1a.RestoreCheckpointResponse\x12J\n\x11TriggerEvaluation\x12\x19.TriggerEvaluationRequest\x1a\x1a.TriggerEvaluationResponse\x12P\n\x13GetEvaluationStatus\x12\x1b.GetEvaluationStatusRequest\x1a\x1c.GetEvaluationStatusResponse\x12G\n\x10\x43\x61ncelEvaluation\x12\x18.CancelEvaluationRequest\x1a\x19.CancelEvaluationResponseb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x18\x65xperiment_service.proto\"\x89\x01\n\x1aGetLatestLoggerDataRequest\x12\x1c\n\x14request_full_history\x18\x01 \x01(\x08\x12\x12\n\nmax_points\x18\x02 \x01(\x05\x12\x17\n\x0f\x62reak_by_slices\x18\x03 \x01(\x08\x12\x0c\n\x04tags\x18\x04 \x03(\t\x12\x12\n\ngraph_name\x18\x05 \x01(\t\"\x81\x02\n\x0fLoggerDataPoint\x12\x13\n\x0bmetric_name\x18\x01 \x01(\t\x12\x11\n\tmodel_age\x18\x02 \x01(\x05\x12\x14\n\x0cmetric_value\x18\x03 \x01(\x02\x12\x17\n\x0f\x65xperiment_hash\x18\x04 \x01(\t\x12\x11\n\ttimestamp\x18\x05 \x01(\x03\x12\x11\n\tsample_id\x18\x06 \x01(\t\x12\x1c\n\x14is_evaluation_marker\x18\x07 \x01(\x08\x12\x12\n\nsplit_name\x18\x08 \x01(\t\x12\x17\n\x0f\x65valuation_tags\x18\t \x03(\t\x12\x12\n\npoint_note\x18\n \x01(\t\x12\x12\n\naudit_mode\x18\x0b \x01(\x08\"[\n\x1bGetLatestLoggerDataResponse\x12 \n\x06points\x18\x01 \x03(\x0b\x32\x10.LoggerDataPoint\x12\x1a\n\x12weightslab_version\x18\x02 \x01(\t\"\x07\n\x05\x45mpty\"/\n\x08NeuronId\x12\x10\n\x08layer_id\x18\x01 \x01(\x05\x12\x11\n\tneuron_id\x18\x02 \x01(\x05\"\x91\x02\n\x0fWeightOperation\x12*\n\x07op_type\x18\x01 \x01(\x0e\x32\x14.WeightOperationTypeH\x00\x88\x01\x01\x12\x15\n\x08layer_id\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x1d\n\nneuron_ids\x18\x03 \x03(\x0b\x32\t.NeuronId\x12\x16\n\x0eneurons_to_add\x18\t \x01(\x05\x12 \n\x18zerofy_from_incoming_ids\x18\x0b \x03(\x05\x12\x1c\n\x14zerofy_to_neuron_ids\x18\x0c \x03(\x05\x12+\n\x11zerofy_predicates\x18\r \x03(\x0e\x32\x10.ZerofyPredicateB\n\n\x08_op_typeB\x0b\n\t_layer_id\"_\n\x17WeightsOperationRequest\x12/\n\x10weight_operation\x18\x01 \x01(\x0b\x32\x10.WeightOperationH\x00\x88\x01\x01\x42\x13\n\x11_weight_operation\"<\n\x18WeightsOperationResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"\xc1\x05\n\x0fHyperParameters\x12\x1c\n\x0f\x65xperiment_name\x18\x01 \x01(\tH\x00\x88\x01\x01\x12!\n\x14training_steps_to_do\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x1a\n\rlearning_rate\x18\x03 \x01(\x02H\x02\x88\x01\x01\x12\x17\n\nbatch_size\x18\x04 \x01(\x05H\x03\x88\x01\x01\x12 \n\x13\x66ull_eval_frequency\x18\x05 \x01(\x05H\x04\x88\x01\x01\x12 \n\x13\x63heckpont_frequency\x18\x06 \x01(\x05H\x05\x88\x01\x01\x12\x18\n\x0bis_training\x18\x07 \x01(\x08H\x06\x88\x01\x01\x12\x15\n\x08nb_steps\x18\x08 \x01(\x05H\x07\x88\x01\x01\x12\x19\n\x0c\x61uditor_mode\x18\t \x01(\x08H\x08\x88\x01\x01\x12\x1d\n\x10train_batch_size\x18\n \x01(\x05H\t\x88\x01\x01\x12\x1b\n\x0eval_batch_size\x18\x0b \x01(\x05H\n\x88\x01\x01\x12\x1c\n\x0ftest_batch_size\x18\x0c \x01(\x05H\x0b\x88\x01\x01\x12\x1c\n\x0f\x65valuation_mode\x18\r \x01(\x08H\x0c\x88\x01\x01\x12\x1e\n\x11\x65valuation_config\x18\x0e \x01(\tH\r\x88\x01\x01\x42\x12\n\x10_experiment_nameB\x17\n\x15_training_steps_to_doB\x10\n\x0e_learning_rateB\r\n\x0b_batch_sizeB\x16\n\x14_full_eval_frequencyB\x16\n\x14_checkpont_frequencyB\x0e\n\x0c_is_trainingB\x0b\n\t_nb_stepsB\x0f\n\r_auditor_modeB\x13\n\x11_train_batch_sizeB\x11\n\x0f_val_batch_sizeB\x12\n\x10_test_batch_sizeB\x12\n\x10_evaluation_modeB\x14\n\x12_evaluation_config\",\n\rMetricsStatus\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x02\"~\n\rAnnotatStatus\x12\x0c\n\x04name\x18\x01 \x01(\t\x12.\n\x08metadata\x18\x02 \x03(\x0b\x32\x1c.AnnotatStatus.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x02:\x02\x38\x01\"\x90\x02\n\x10TrainingStatusEx\x12\x16\n\ttimestamp\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x1c\n\x0f\x65xperiment_name\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x16\n\tmodel_age\x18\x03 \x01(\x05H\x02\x88\x01\x01\x12+\n\x0emetrics_status\x18\x04 \x01(\x0b\x32\x0e.MetricsStatusH\x03\x88\x01\x01\x12+\n\x0e\x61nnotat_status\x18\x05 \x01(\x0b\x32\x0e.AnnotatStatusH\x04\x88\x01\x01\x42\x0c\n\n_timestampB\x12\n\x10_experiment_nameB\x0c\n\n_model_ageB\x11\n\x0f_metrics_statusB\x11\n\x0f_annotat_status\"]\n\x15HyperParameterCommand\x12/\n\x10hyper_parameters\x18\x01 \x01(\x0b\x32\x10.HyperParametersH\x00\x88\x01\x01\x42\x13\n\x11_hyper_parameters\">\n\x14\x44\x65nySamplesOperation\x12\x12\n\nsample_ids\x18\x01 \x03(\t\x12\x12\n\naccumulate\x18\x02 \x01(\x08\"0\n\x17LoadCheckpointOperation\x12\x15\n\rcheckpoint_id\x18\x01 \x01(\x05\"b\n\x11PlotNoteOperation\x12\x13\n\x0bmetric_name\x18\x01 \x01(\t\x12\x17\n\x0f\x65xperiment_hash\x18\x02 \x01(\t\x12\x11\n\tmodel_age\x18\x03 \x01(\x05\x12\x0c\n\x04note\x18\x04 \x01(\t\"L\n\x17SaveCheckpointOperation\x12\x19\n\x11save_architecture\x18\x01 \x01(\x08\x12\x16\n\x0esave_optimizer\x18\x02 \x01(\x08\"\x1a\n\x18RestartInstanceOperation\"\x8d\x08\n\x0eTrainerCommand\x12\x1c\n\x14get_hyper_parameters\x18\x04 \x01(\x08\x12\x1e\n\x16get_interactive_layers\x18\x05 \x01(\x08\x12\x1d\n\x10get_data_records\x18\x06 \x01(\tH\x00\x88\x01\x01\x12%\n\x18get_single_layer_info_id\x18\x08 \x01(\x05H\x01\x88\x01\x01\x12;\n\x16hyper_parameter_change\x18\x01 \x01(\x0b\x32\x16.HyperParameterCommandH\x02\x88\x01\x01\x12:\n\x16\x64\x65ny_samples_operation\x18\x07 \x01(\x0b\x32\x15.DenySamplesOperationH\x03\x88\x01\x01\x12?\n\x1b\x64\x65ny_eval_samples_operation\x18\n \x01(\x0b\x32\x15.DenySamplesOperationH\x04\x88\x01\x01\x12@\n\x19load_checkpoint_operation\x18\t \x01(\x0b\x32\x18.LoadCheckpointOperationH\x05\x88\x01\x01\x12\x42\n\x1eremove_from_denylist_operation\x18\x0b \x01(\x0b\x32\x15.DenySamplesOperationH\x06\x88\x01\x01\x12G\n#remove_eval_from_denylist_operation\x18\x0c \x01(\x0b\x32\x15.DenySamplesOperationH\x07\x88\x01\x01\x12\x34\n\x13plot_note_operation\x18\r \x01(\x0b\x32\x12.PlotNoteOperationH\x08\x88\x01\x01\x12@\n\x19save_checkpoint_operation\x18\x0e \x01(\x0b\x32\x18.SaveCheckpointOperationH\t\x88\x01\x01\x12\x39\n\x11restart_operation\x18\x0f \x01(\x0b\x32\x19.RestartInstanceOperationH\n\x88\x01\x01\x42\x13\n\x11_get_data_recordsB\x1b\n\x19_get_single_layer_info_idB\x19\n\x17_hyper_parameter_changeB\x19\n\x17_deny_samples_operationB\x1e\n\x1c_deny_eval_samples_operationB\x1c\n\x1a_load_checkpoint_operationB!\n\x1f_remove_from_denylist_operationB&\n$_remove_eval_from_denylist_operationB\x16\n\x14_plot_note_operationB\x1c\n\x1a_save_checkpoint_operationB\x14\n\x12_restart_operation\"\x9d\x01\n\x12HyperParameterDesc\x12\r\n\x05label\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0c\n\x04type\x18\x03 \x01(\t\x12\x1c\n\x0fnumerical_value\x18\x04 \x01(\x02H\x00\x88\x01\x01\x12\x19\n\x0cstring_value\x18\x05 \x01(\tH\x01\x88\x01\x01\x42\x12\n\x10_numerical_valueB\x0f\n\r_string_value\"\xf2\x02\n\x10NeuronStatistics\x12!\n\tneuron_id\x18\x01 \x01(\x0b\x32\t.NeuronIdH\x00\x88\x01\x01\x12\x17\n\nneuron_age\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x1f\n\x12train_trigger_rate\x18\x03 \x01(\x02H\x02\x88\x01\x01\x12\x1e\n\x11\x65val_trigger_rate\x18\x04 \x01(\x02H\x03\x88\x01\x01\x12\x1a\n\rlearning_rate\x18\x07 \x01(\x02H\x04\x88\x01\x01\x12\x36\n\x0bincoming_lr\x18\x08 \x03(\x0b\x32!.NeuronStatistics.IncomingLrEntry\x1a\x31\n\x0fIncomingLrEntry\x12\x0b\n\x03key\x18\x01 \x01(\x05\x12\r\n\x05value\x18\x02 \x01(\x02:\x02\x38\x01\x42\x0c\n\n_neuron_idB\r\n\x0b_neuron_ageB\x15\n\x13_train_trigger_rateB\x14\n\x12_eval_trigger_rateB\x10\n\x0e_learning_rate\"\xf0\x02\n\x13LayerRepresentation\x12\x15\n\x08layer_id\x18\x01 \x01(\x05H\x00\x88\x01\x01\x12\x17\n\nlayer_name\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x17\n\nlayer_type\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x1a\n\rneurons_count\x18\x04 \x01(\x05H\x03\x88\x01\x01\x12#\n\x16incoming_neurons_count\x18\x05 \x01(\x05H\x04\x88\x01\x01\x12\x18\n\x0bkernel_size\x18\x06 \x01(\x05H\x05\x88\x01\x01\x12\x13\n\x06stride\x18\x07 \x01(\x05H\x06\x88\x01\x01\x12-\n\x12neurons_statistics\x18\n \x03(\x0b\x32\x11.NeuronStatisticsB\x0b\n\t_layer_idB\r\n\x0b_layer_nameB\r\n\x0b_layer_typeB\x10\n\x0e_neurons_countB\x19\n\x17_incoming_neurons_countB\x0e\n\x0c_kernel_sizeB\t\n\x07_stride\"H\n\x11\x41\x63tivationRequest\x12\x10\n\x08layer_id\x18\x01 \x01(\x05\x12\x11\n\tsample_id\x18\x02 \x01(\t\x12\x0e\n\x06origin\x18\x03 \x01(\t\"H\n\rActivationMap\x12\x11\n\tneuron_id\x18\x01 \x01(\x05\x12\x0e\n\x06values\x18\x02 \x03(\x02\x12\t\n\x01H\x18\x03 \x01(\x05\x12\t\n\x01W\x18\x04 \x01(\x05\"d\n\x12\x41\x63tivationResponse\x12\x12\n\nlayer_type\x18\x01 \x01(\t\x12\x15\n\rneurons_count\x18\x02 \x01(\x05\x12#\n\x0b\x61\x63tivations\x18\x03 \x03(\x0b\x32\x0e.ActivationMap\"\x93\x01\n\tTaskField\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x15\n\x0b\x66loat_value\x18\x02 \x01(\x02H\x00\x12\x13\n\tint_value\x18\x03 \x01(\x05H\x00\x12\x16\n\x0cstring_value\x18\x04 \x01(\tH\x00\x12\x15\n\x0b\x62ytes_value\x18\x05 \x01(\x0cH\x00\x12\x14\n\nbool_value\x18\x06 \x01(\x08H\x00\x42\x07\n\x05value\"\x87\x03\n\x0eRecordMetadata\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\x14\n\x0csample_label\x18\x02 \x03(\x05\x12\x19\n\x11sample_prediction\x18\x03 \x03(\x05\x12=\n\x10sample_last_loss\x18\x04 \x03(\x0b\x32#.RecordMetadata.SampleLastLossEntry\x12\x19\n\x11sample_encounters\x18\x05 \x01(\x05\x12\x18\n\x10sample_discarded\x18\x06 \x01(\x08\x12 \n\x0c\x65xtra_fields\x18\x07 \x03(\x0b\x32\n.TaskField\x12\x16\n\x0eprediction_raw\x18\t \x01(\x0c\x12\x11\n\ttask_type\x18\n \x01(\t\x12\x19\n\x11sample_label_text\x18\x0b \x03(\t\x12\x1e\n\x16sample_prediction_text\x18\x0c \x03(\t\x1a\x35\n\x13SampleLastLossEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x02:\x02\x38\x01\"\x93\x01\n\x10SampleStatistics\x12\x13\n\x06origin\x18\x06 \x01(\tH\x00\x88\x01\x01\x12\x19\n\x0csample_count\x18\x07 \x01(\x05H\x01\x88\x01\x01\x12\x11\n\ttask_type\x18\t \x01(\t\x12 \n\x07records\x18\x08 \x03(\x0b\x32\x0f.RecordMetadataB\t\n\x07_originB\x0f\n\r_sample_count\"\xe6\x01\n\x0f\x43ommandResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x33\n\x16hyper_parameters_descs\x18\x03 \x03(\x0b\x32\x13.HyperParameterDesc\x12\x33\n\x15layer_representations\x18\x04 \x03(\x0b\x32\x14.LayerRepresentation\x12\x31\n\x11sample_statistics\x18\x05 \x01(\x0b\x32\x11.SampleStatisticsH\x00\x88\x01\x01\x42\x14\n\x12_sample_statistics\"U\n\rSampleRequest\x12\x16\n\tsample_id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06origin\x18\x02 \x01(\tH\x01\x88\x01\x01\x42\x0c\n\n_sample_idB\t\n\x07_origin\"\xad\x02\n\x15SampleRequestResponse\x12\x16\n\tsample_id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06origin\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x12\n\x05label\x18\x03 \x01(\x05H\x02\x88\x01\x01\x12\x11\n\x04\x64\x61ta\x18\x04 \x01(\x0cH\x03\x88\x01\x01\x12\x1a\n\rerror_message\x18\x05 \x01(\tH\x04\x88\x01\x01\x12\x15\n\x08raw_data\x18\x06 \x01(\x0cH\x05\x88\x01\x01\x12\x11\n\x04mask\x18\x07 \x01(\x0cH\x06\x88\x01\x01\x12\x17\n\nprediction\x18\x08 \x01(\x0cH\x07\x88\x01\x01\x42\x0c\n\n_sample_idB\t\n\x07_originB\x08\n\x06_labelB\x07\n\x05_dataB\x10\n\x0e_error_messageB\x0b\n\t_raw_dataB\x07\n\x05_maskB\r\n\x0b_prediction\"\x92\x01\n\x12\x42\x61tchSampleRequest\x12\x12\n\nsample_ids\x18\x01 \x03(\t\x12\x0e\n\x06origin\x18\x02 \x01(\t\x12\x19\n\x0cresize_width\x18\x03 \x01(\x05H\x00\x88\x01\x01\x12\x1a\n\rresize_height\x18\x04 \x01(\x05H\x01\x88\x01\x01\x42\x0f\n\r_resize_widthB\x10\n\x0e_resize_height\">\n\x13\x42\x61tchSampleResponse\x12\'\n\x07samples\x18\x01 \x03(\x0b\x32\x16.SampleRequestResponse\".\n\x0eWeightsRequest\x12\x1c\n\tneuron_id\x18\x01 \x01(\x0b\x32\t.NeuronId\"\x9d\x02\n\x0fWeightsResponse\x12\x1c\n\tneuron_id\x18\x01 \x01(\x0b\x32\t.NeuronId\x12\x17\n\nlayer_name\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x17\n\nlayer_type\x18\x03 \x01(\tH\x01\x88\x01\x01\x12\x10\n\x08incoming\x18\x04 \x01(\x05\x12\x10\n\x08outgoing\x18\x05 \x01(\x05\x12\x18\n\x0bkernel_size\x18\x06 \x01(\x05H\x02\x88\x01\x01\x12\x0f\n\x07weights\x18\x07 \x03(\x02\x12\x0f\n\x07success\x18\x0b \x01(\x08\x12\x1a\n\rerror_message\x18\x0c \x01(\tH\x03\x88\x01\x01\x42\r\n\x0b_layer_nameB\r\n\x0b_layer_typeB\x0e\n\x0c_kernel_sizeB\x10\n\x0e_error_message\"R\n\x10\x44\x61taQueryRequest\x12\r\n\x05query\x18\x01 \x01(\t\x12\x12\n\naccumulate\x18\x02 \x01(\x08\x12\x1b\n\x13is_natural_language\x18\x03 \x01(\x08\"5\n\x11\x43\x61tegoricalTagDef\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\ncategories\x18\x02 \x03(\t\"\xa9\x02\n\x11\x44\x61taQueryResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x1d\n\x15number_of_all_samples\x18\x03 \x01(\x05\x12%\n\x1dnumber_of_samples_in_the_loop\x18\x04 \x01(\x05\x12#\n\x1bnumber_of_discarded_samples\x18\x05 \x01(\x05\x12\x13\n\x0bunique_tags\x18\x06 \x03(\t\x12+\n\x11\x61gent_intent_type\x18\x07 \x01(\x0e\x32\x10.AgentIntentType\x12\x17\n\x0f\x61nalysis_result\x18\x08 \x01(\t\x12,\n\x10\x63\x61tegorical_tags\x18\t \x03(\x0b\x32\x12.CategoricalTagDef\"\xc2\x01\n\x12\x44\x61taSamplesRequest\x12\x13\n\x0bstart_index\x18\x01 \x01(\x05\x12\x13\n\x0brecords_cnt\x18\x02 \x01(\x05\x12 \n\x18include_transformed_data\x18\x03 \x01(\x08\x12\x18\n\x10include_raw_data\x18\x04 \x01(\x08\x12\x19\n\x11stats_to_retrieve\x18\x05 \x03(\t\x12\x14\n\x0cresize_width\x18\x06 \x01(\x05\x12\x15\n\rresize_height\x18\x07 \x01(\x05\"m\n\x08\x44\x61taStat\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\r\n\x05shape\x18\x03 \x03(\x05\x12\r\n\x05value\x18\x04 \x03(\x02\x12\x14\n\x0cvalue_string\x18\x05 \x01(\t\x12\x11\n\tthumbnail\x18\x06 \x01(\x0c\">\n\nDataRecord\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\x1d\n\ndata_stats\x18\x02 \x03(\x0b\x32\t.DataStat\"Z\n\x13\x44\x61taSamplesResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12!\n\x0c\x64\x61ta_records\x18\x03 \x03(\x0b\x32\x0b.DataRecord\"C\n\x0fHistogramSubBar\x12\x0e\n\x06origin\x18\x01 \x01(\t\x12\x11\n\tdiscarded\x18\x02 \x01(\x08\x12\r\n\x05\x63ount\x18\x03 \x01(\x03\"h\n\x0cHistogramBin\x12\x0b\n\x03min\x18\x01 \x01(\x01\x12\x0b\n\x03max\x18\x02 \x01(\x01\x12\x0b\n\x03\x61vg\x18\x03 \x01(\x01\x12\r\n\x05\x63ount\x18\x04 \x01(\x03\x12\"\n\x08sub_bars\x18\x05 \x03(\x0b\x32\x10.HistogramSubBar\"[\n\x17\x43\x61tegoricalHistogramBar\x12\r\n\x05label\x18\x01 \x01(\t\x12\r\n\x05\x63ount\x18\x02 \x01(\x03\x12\"\n\x08sub_bars\x18\x03 \x03(\x0b\x32\x10.HistogramSubBar\"4\n\x10HistogramRequest\x12\x0e\n\x06\x63olumn\x18\x01 \x01(\t\x12\x10\n\x08max_bins\x18\x02 \x01(\x05\"\xb2\x01\n\x11HistogramResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x12\n\ntotal_rows\x18\x03 \x01(\x03\x12\x1b\n\x04\x62ins\x18\x04 \x03(\x0b\x32\r.HistogramBin\x12\x16\n\x0eis_categorical\x18\x05 \x01(\x08\x12\x32\n\x10\x63\x61tegorical_bars\x18\x06 \x03(\x0b\x32\x18.CategoricalHistogramBar\"W\n\x12GetMetaDataRequest\x12\x13\n\x0bstart_index\x18\x01 \x01(\x05\x12\x13\n\x0brecords_cnt\x18\x02 \x01(\x05\x12\x17\n\x0fmodal_sample_id\x18\x03 \x01(\t\"\x99\x01\n\x13GetMetaDataResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x1a\n\x12\x61ll_metadata_names\x18\x03 \x03(\t\x12!\n\x0cgrid_records\x18\x04 \x03(\x0b\x32\x0b.DataRecord\x12!\n\x0cmodal_record\x18\x05 \x01(\x0b\x32\x0b.DataRecord\"Y\n\x1aGetSignalTrajectoryRequest\x12\x13\n\x0bsignal_name\x18\x01 \x01(\t\x12\x12\n\nsample_ids\x18\x02 \x03(\t\x12\x12\n\nmax_points\x18\x03 \x01(\x05\"4\n\x10SignalTrajectory\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x03(\x02\"}\n\x1bGetSignalTrajectoryResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x13\n\x0bsignal_name\x18\x03 \x01(\t\x12\'\n\x0ctrajectories\x18\x04 \x03(\x0b\x32\x11.SignalTrajectory\"J\n\x11PointCloudRequest\x12\x11\n\tsample_id\x18\x01 \x01(\t\x12\x0e\n\x06origin\x18\x02 \x01(\t\x12\x12\n\nmax_points\x18\x03 \x01(\x05\"\xbf\x01\n\x0fPointCloudChunk\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x12\n\nnum_points\x18\x03 \x01(\x05\x12\x14\n\x0cnum_features\x18\x04 \x01(\x05\x12\x10\n\x08pc_range\x18\x05 \x03(\x02\x12\x0c\n\x04\x64\x61ta\x18\x06 \x01(\x0c\x12\x13\n\x0b\x63hunk_index\x18\x07 \x01(\x05\x12\x14\n\x0ctotal_chunks\x18\x08 \x01(\x05\x12\x15\n\rfeature_names\x18\t \x03(\t\"\xdc\x01\n\x10\x44\x61taEditsRequest\x12\x11\n\tstat_name\x18\x01 \x01(\t\x12\x13\n\x0b\x66loat_value\x18\x02 \x01(\x02\x12\x14\n\x0cstring_value\x18\x03 \x01(\t\x12\x12\n\nbool_value\x18\x04 \x01(\x08\x12\x1d\n\x04type\x18\x05 \x01(\x0e\x32\x0f.SampleEditType\x12\x13\n\x0bsamples_ids\x18\x06 \x03(\t\x12\x16\n\x0esample_origins\x18\x07 \x03(\t\x12\x16\n\x0eis_categorical\x18\x08 \x01(\x08\x12\x12\n\ncategories\x18\t \x03(\t\"5\n\x11\x44\x61taEditsResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\":\n\x12\x44\x61taSplitsResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x13\n\x0bsplit_names\x18\x02 \x03(\t\"9\n\x13\x41gentHealthResponse\x12\x11\n\tavailable\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"^\n\x16InitializeAgentRequest\x12\x0f\n\x07\x61pi_key\x18\x01 \x01(\t\x12$\n\x08provider\x18\x02 \x01(\x0e\x32\x12.AgentProviderType\x12\r\n\x05model\x18\x03 \x01(\t\";\n\x17InitializeAgentResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"(\n\x17\x43hangeAgentModelRequest\x12\r\n\x05model\x18\x01 \x01(\t\"<\n\x18\x43hangeAgentModelResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"\x17\n\x15GetAgentModelsRequest\"J\n\x16GetAgentModelsResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0e\n\x06models\x18\x02 \x03(\t\x12\x0f\n\x07message\x18\x03 \x01(\t\"6\n\x12ResetAgentResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"=\n\x19\x43learAgentHistoryResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"?\n\x1b\x43ompactAgentHistoryResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"3\n\x18RestoreCheckpointRequest\x12\x17\n\x0f\x65xperiment_hash\x18\x01 \x01(\t\"=\n\x19RestoreCheckpointResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"R\n\x18TriggerEvaluationRequest\x12\x12\n\nsplit_name\x18\x01 \x01(\t\x12\x0c\n\x04tags\x18\x02 \x03(\t\x12\x14\n\x0cuse_full_set\x18\x03 \x01(\x08\"=\n\x19TriggerEvaluationResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"\x1c\n\x1aGetEvaluationStatusRequest\"\x81\x01\n\x1bGetEvaluationStatusResponse\x12\x0e\n\x06status\x18\x01 \x01(\t\x12\x0f\n\x07\x63urrent\x18\x02 \x01(\x05\x12\r\n\x05total\x18\x03 \x01(\x05\x12\x0f\n\x07message\x18\x04 \x01(\t\x12\r\n\x05\x65rror\x18\x05 \x01(\t\x12\x12\n\nsplit_name\x18\x06 \x01(\t\")\n\x17\x43\x61ncelEvaluationRequest\x12\x0e\n\x06reason\x18\x01 \x01(\t\"<\n\x18\x43\x61ncelEvaluationResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"7\n\x16RunNotebookCellRequest\x12\x0c\n\x04\x63ode\x18\x01 \x01(\t\x12\x0f\n\x07\x63\x65ll_id\x18\x02 \x01(\t\"2\n\x10NotebookCellDone\x12\x12\n\nexec_count\x18\x01 \x01(\x05\x12\n\n\x02ok\x18\x02 \x01(\x08\"\x1e\n\x1cInterruptNotebookCellRequest\":\n\x1dInterruptNotebookCellResponse\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"\xbd\x01\n\x11NotebookCellChunk\x12\x0f\n\x07\x63\x65ll_id\x18\x01 \x01(\t\x12\x10\n\x06stdout\x18\x02 \x01(\tH\x00\x12\x10\n\x06stderr\x18\x03 \x01(\tH\x00\x12\x15\n\x0bresult_text\x18\x04 \x01(\tH\x00\x12\x13\n\timage_png\x18\x05 \x01(\x0cH\x00\x12\x19\n\x0f\x65rror_traceback\x18\x06 \x01(\tH\x00\x12!\n\x04\x64one\x18\x07 \x01(\x0b\x32\x11.NotebookCellDoneH\x00\x42\t\n\x07payload\"S\n\x10NotebookResponse\x12\x12\n\nipynb_json\x18\x01 \x01(\t\x12\x0f\n\x07\x65xisted\x18\x02 \x01(\x08\x12\x0c\n\x04path\x18\x03 \x01(\t\x12\x0c\n\x04name\x18\x04 \x01(\t\"7\n\x13SaveNotebookRequest\x12\x12\n\nipynb_json\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\"M\n\x14SaveNotebookResponse\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x0c\n\x04path\x18\x02 \x01(\t\x12\r\n\x05\x65rror\x18\x03 \x01(\t\x12\x0c\n\x04name\x18\x04 \x01(\t\"C\n\x1bGenerateNotebookCodeRequest\x12\x0e\n\x06prompt\x18\x01 \x01(\t\x12\x14\n\x0c\x63ontext_code\x18\x02 \x01(\t\"\\\n\x1cGenerateNotebookCodeResponse\x12\x0c\n\x04\x63ode\x18\x01 \x01(\t\x12\x13\n\x0b\x65xplanation\x18\x02 \x01(\t\x12\n\n\x02ok\x18\x03 \x01(\x08\x12\r\n\x05\x65rror\x18\x04 \x01(\t*d\n\x13WeightOperationType\x12\n\n\x06ZEROFY\x10\x00\x12\x10\n\x0cREINITIALIZE\x10\x01\x12\n\n\x06\x46REEZE\x10\x02\x12\x12\n\x0eREMOVE_NEURONS\x10\t\x12\x0f\n\x0b\x41\x44\x44_NEURONS\x10\n*o\n\x0fZerofyPredicate\x12\x19\n\x15ZEROFY_PREDICATE_NONE\x10\x00\x12 \n\x1cZEROFY_PREDICATE_WITH_FROZEN\x10\x01\x12\x1f\n\x1bZEROFY_PREDICATE_WITH_OLDER\x10\x02*M\n\x0f\x41gentIntentType\x12\x12\n\x0eINTENT_UNKNOWN\x10\x00\x12\x11\n\rINTENT_FILTER\x10\x01\x12\x13\n\x0fINTENT_ANALYSIS\x10\x02*I\n\x0eSampleEditType\x12\x11\n\rEDIT_OVERRIDE\x10\x00\x12\x13\n\x0f\x45\x44IT_ACCUMULATE\x10\x01\x12\x0f\n\x0b\x45\x44IT_REMOVE\x10\x02*C\n\x11\x41gentProviderType\x12\x17\n\x13PROVIDER_OPENROUTER\x10\x00\x12\x15\n\x11PROVIDER_OPENCODE\x10\x01\x32\x93\x0f\n\x11\x45xperimentService\x12P\n\x13GetLatestLoggerData\x12\x1b.GetLatestLoggerDataRequest\x1a\x1c.GetLatestLoggerDataResponse\x12\x36\n\x11\x45xperimentCommand\x12\x0f.TrainerCommand\x1a\x10.CommandResponse\x12H\n\x11ManipulateWeights\x12\x18.WeightsOperationRequest\x1a\x19.WeightsOperationResponse\x12/\n\nGetWeights\x12\x0f.WeightsRequest\x1a\x10.WeightsResponse\x12\x39\n\x0eGetActivations\x12\x12.ActivationRequest\x1a\x13.ActivationResponse\x12\x37\n\nGetSamples\x12\x13.BatchSampleRequest\x1a\x14.BatchSampleResponse\x12\x37\n\x0e\x41pplyDataQuery\x12\x11.DataQueryRequest\x1a\x12.DataQueryResponse\x12;\n\x0eGetDataSamples\x12\x13.DataSamplesRequest\x1a\x14.DataSamplesResponse\x12\x35\n\x0cGetHistogram\x12\x11.HistogramRequest\x1a\x12.HistogramResponse\x12\x38\n\x0bGetMetaData\x12\x13.GetMetaDataRequest\x1a\x14.GetMetaDataResponse\x12P\n\x13GetSignalTrajectory\x12\x1b.GetSignalTrajectoryRequest\x1a\x1c.GetSignalTrajectoryResponse\x12\x37\n\rGetPointCloud\x12\x12.PointCloudRequest\x1a\x10.PointCloudChunk0\x01\x12\x37\n\x0e\x45\x64itDataSample\x12\x11.DataEditsRequest\x1a\x12.DataEditsResponse\x12,\n\rGetDataSplits\x12\x06.Empty\x1a\x13.DataSplitsResponse\x12\x30\n\x10\x43heckAgentHealth\x12\x06.Empty\x1a\x14.AgentHealthResponse\x12\x44\n\x0fInitializeAgent\x12\x17.InitializeAgentRequest\x1a\x18.InitializeAgentResponse\x12G\n\x10\x43hangeAgentModel\x12\x18.ChangeAgentModelRequest\x1a\x19.ChangeAgentModelResponse\x12\x41\n\x0eGetAgentModels\x12\x16.GetAgentModelsRequest\x1a\x17.GetAgentModelsResponse\x12)\n\nResetAgent\x12\x06.Empty\x1a\x13.ResetAgentResponse\x12\x37\n\x11\x43learAgentHistory\x12\x06.Empty\x1a\x1a.ClearAgentHistoryResponse\x12;\n\x13\x43ompactAgentHistory\x12\x06.Empty\x1a\x1c.CompactAgentHistoryResponse\x12@\n\x0fRunNotebookCell\x12\x17.RunNotebookCellRequest\x1a\x12.NotebookCellChunk0\x01\x12V\n\x15InterruptNotebookCell\x12\x1d.InterruptNotebookCellRequest\x1a\x1e.InterruptNotebookCellResponse\x12(\n\x0bGetNotebook\x12\x06.Empty\x1a\x11.NotebookResponse\x12;\n\x0cSaveNotebook\x12\x14.SaveNotebookRequest\x1a\x15.SaveNotebookResponse\x12S\n\x14GenerateNotebookCode\x12\x1c.GenerateNotebookCodeRequest\x1a\x1d.GenerateNotebookCodeResponse\x12J\n\x11RestoreCheckpoint\x12\x19.RestoreCheckpointRequest\x1a\x1a.RestoreCheckpointResponse\x12J\n\x11TriggerEvaluation\x12\x19.TriggerEvaluationRequest\x1a\x1a.TriggerEvaluationResponse\x12P\n\x13GetEvaluationStatus\x12\x1b.GetEvaluationStatusRequest\x1a\x1c.GetEvaluationStatusResponse\x12G\n\x10\x43\x61ncelEvaluation\x12\x18.CancelEvaluationRequest\x1a\x19.CancelEvaluationResponseb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) -_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'weightslab.proto.experiment_service_pb2', _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'experiment_service_pb2', _globals) if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None _globals['_ANNOTATSTATUS_METADATAENTRY']._loaded_options = None @@ -37,188 +37,192 @@ _globals['_NEURONSTATISTICS_INCOMINGLRENTRY']._serialized_options = b'8\001' _globals['_RECORDMETADATA_SAMPLELASTLOSSENTRY']._loaded_options = None _globals['_RECORDMETADATA_SAMPLELASTLOSSENTRY']._serialized_options = b'8\001' - _globals['_WEIGHTOPERATIONTYPE']._serialized_start=10979 - _globals['_WEIGHTOPERATIONTYPE']._serialized_end=11079 - _globals['_ZEROFYPREDICATE']._serialized_start=11081 - _globals['_ZEROFYPREDICATE']._serialized_end=11192 - _globals['_AGENTINTENTTYPE']._serialized_start=11194 - _globals['_AGENTINTENTTYPE']._serialized_end=11271 - _globals['_SAMPLEEDITTYPE']._serialized_start=11273 - _globals['_SAMPLEEDITTYPE']._serialized_end=11346 - _globals['_AGENTPROVIDERTYPE']._serialized_start=11348 - _globals['_AGENTPROVIDERTYPE']._serialized_end=11392 - _globals['_GETLATESTLOGGERDATAREQUEST']._serialized_start=46 - _globals['_GETLATESTLOGGERDATAREQUEST']._serialized_end=183 - _globals['_LOGGERDATAPOINT']._serialized_start=186 - _globals['_LOGGERDATAPOINT']._serialized_end=443 - _globals['_GETLATESTLOGGERDATARESPONSE']._serialized_start=445 - _globals['_GETLATESTLOGGERDATARESPONSE']._serialized_end=536 - _globals['_EMPTY']._serialized_start=538 - _globals['_EMPTY']._serialized_end=545 - _globals['_NEURONID']._serialized_start=547 - _globals['_NEURONID']._serialized_end=594 - _globals['_WEIGHTOPERATION']._serialized_start=597 - _globals['_WEIGHTOPERATION']._serialized_end=870 - _globals['_WEIGHTSOPERATIONREQUEST']._serialized_start=872 - _globals['_WEIGHTSOPERATIONREQUEST']._serialized_end=967 - _globals['_WEIGHTSOPERATIONRESPONSE']._serialized_start=969 - _globals['_WEIGHTSOPERATIONRESPONSE']._serialized_end=1029 - _globals['_HYPERPARAMETERS']._serialized_start=1032 - _globals['_HYPERPARAMETERS']._serialized_end=1737 - _globals['_METRICSSTATUS']._serialized_start=1739 - _globals['_METRICSSTATUS']._serialized_end=1783 - _globals['_ANNOTATSTATUS']._serialized_start=1785 - _globals['_ANNOTATSTATUS']._serialized_end=1911 - _globals['_ANNOTATSTATUS_METADATAENTRY']._serialized_start=1864 - _globals['_ANNOTATSTATUS_METADATAENTRY']._serialized_end=1911 - _globals['_TRAININGSTATUSEX']._serialized_start=1914 - _globals['_TRAININGSTATUSEX']._serialized_end=2186 - _globals['_HYPERPARAMETERCOMMAND']._serialized_start=2188 - _globals['_HYPERPARAMETERCOMMAND']._serialized_end=2281 - _globals['_DENYSAMPLESOPERATION']._serialized_start=2283 - _globals['_DENYSAMPLESOPERATION']._serialized_end=2345 - _globals['_LOADCHECKPOINTOPERATION']._serialized_start=2347 - _globals['_LOADCHECKPOINTOPERATION']._serialized_end=2395 - _globals['_PLOTNOTEOPERATION']._serialized_start=2397 - _globals['_PLOTNOTEOPERATION']._serialized_end=2495 - _globals['_SAVECHECKPOINTOPERATION']._serialized_start=2497 - _globals['_SAVECHECKPOINTOPERATION']._serialized_end=2573 - _globals['_RESTARTINSTANCEOPERATION']._serialized_start=2575 - _globals['_RESTARTINSTANCEOPERATION']._serialized_end=2601 - _globals['_TRAINERCOMMAND']._serialized_start=2604 - _globals['_TRAINERCOMMAND']._serialized_end=3641 - _globals['_HYPERPARAMETERDESC']._serialized_start=3644 - _globals['_HYPERPARAMETERDESC']._serialized_end=3801 - _globals['_NEURONSTATISTICS']._serialized_start=3804 - _globals['_NEURONSTATISTICS']._serialized_end=4174 - _globals['_NEURONSTATISTICS_INCOMINGLRENTRY']._serialized_start=4033 - _globals['_NEURONSTATISTICS_INCOMINGLRENTRY']._serialized_end=4082 - _globals['_LAYERREPRESENTATION']._serialized_start=4177 - _globals['_LAYERREPRESENTATION']._serialized_end=4545 - _globals['_ACTIVATIONREQUEST']._serialized_start=4547 - _globals['_ACTIVATIONREQUEST']._serialized_end=4619 - _globals['_ACTIVATIONMAP']._serialized_start=4621 - _globals['_ACTIVATIONMAP']._serialized_end=4693 - _globals['_ACTIVATIONRESPONSE']._serialized_start=4695 - _globals['_ACTIVATIONRESPONSE']._serialized_end=4795 - _globals['_TASKFIELD']._serialized_start=4798 - _globals['_TASKFIELD']._serialized_end=4945 - _globals['_RECORDMETADATA']._serialized_start=4948 - _globals['_RECORDMETADATA']._serialized_end=5339 - _globals['_RECORDMETADATA_SAMPLELASTLOSSENTRY']._serialized_start=5286 - _globals['_RECORDMETADATA_SAMPLELASTLOSSENTRY']._serialized_end=5339 - _globals['_SAMPLESTATISTICS']._serialized_start=5342 - _globals['_SAMPLESTATISTICS']._serialized_end=5489 - _globals['_COMMANDRESPONSE']._serialized_start=5492 - _globals['_COMMANDRESPONSE']._serialized_end=5722 - _globals['_SAMPLEREQUEST']._serialized_start=5724 - _globals['_SAMPLEREQUEST']._serialized_end=5809 - _globals['_SAMPLEREQUESTRESPONSE']._serialized_start=5812 - _globals['_SAMPLEREQUESTRESPONSE']._serialized_end=6113 - _globals['_BATCHSAMPLEREQUEST']._serialized_start=6116 - _globals['_BATCHSAMPLEREQUEST']._serialized_end=6262 - _globals['_BATCHSAMPLERESPONSE']._serialized_start=6264 - _globals['_BATCHSAMPLERESPONSE']._serialized_end=6326 - _globals['_WEIGHTSREQUEST']._serialized_start=6328 - _globals['_WEIGHTSREQUEST']._serialized_end=6374 - _globals['_WEIGHTSRESPONSE']._serialized_start=6377 - _globals['_WEIGHTSRESPONSE']._serialized_end=6662 - _globals['_DATAQUERYREQUEST']._serialized_start=6664 - _globals['_DATAQUERYREQUEST']._serialized_end=6746 - _globals['_CATEGORICALTAGDEF']._serialized_start=6748 - _globals['_CATEGORICALTAGDEF']._serialized_end=6801 - _globals['_DATAQUERYRESPONSE']._serialized_start=6804 - _globals['_DATAQUERYRESPONSE']._serialized_end=7101 - _globals['_DATASAMPLESREQUEST']._serialized_start=7104 - _globals['_DATASAMPLESREQUEST']._serialized_end=7298 - _globals['_DATASTAT']._serialized_start=7300 - _globals['_DATASTAT']._serialized_end=7409 - _globals['_DATARECORD']._serialized_start=7411 - _globals['_DATARECORD']._serialized_end=7473 - _globals['_DATASAMPLESRESPONSE']._serialized_start=7475 - _globals['_DATASAMPLESRESPONSE']._serialized_end=7565 - _globals['_HISTOGRAMSUBBAR']._serialized_start=7567 - _globals['_HISTOGRAMSUBBAR']._serialized_end=7634 - _globals['_HISTOGRAMBIN']._serialized_start=7636 - _globals['_HISTOGRAMBIN']._serialized_end=7740 - _globals['_CATEGORICALHISTOGRAMBAR']._serialized_start=7742 - _globals['_CATEGORICALHISTOGRAMBAR']._serialized_end=7833 - _globals['_HISTOGRAMREQUEST']._serialized_start=7835 - _globals['_HISTOGRAMREQUEST']._serialized_end=7887 - _globals['_HISTOGRAMRESPONSE']._serialized_start=7890 - _globals['_HISTOGRAMRESPONSE']._serialized_end=8068 - _globals['_GETMETADATAREQUEST']._serialized_start=8070 - _globals['_GETMETADATAREQUEST']._serialized_end=8157 - _globals['_GETMETADATARESPONSE']._serialized_start=8160 - _globals['_GETMETADATARESPONSE']._serialized_end=8313 - _globals['_GETSIGNALTRAJECTORYREQUEST']._serialized_start=8315 - _globals['_GETSIGNALTRAJECTORYREQUEST']._serialized_end=8404 - _globals['_SIGNALTRAJECTORY']._serialized_start=8406 - _globals['_SIGNALTRAJECTORY']._serialized_end=8458 - _globals['_GETSIGNALTRAJECTORYRESPONSE']._serialized_start=8460 - _globals['_GETSIGNALTRAJECTORYRESPONSE']._serialized_end=8585 - _globals['_POINTCLOUDREQUEST']._serialized_start=8587 - _globals['_POINTCLOUDREQUEST']._serialized_end=8661 - _globals['_POINTCLOUDCHUNK']._serialized_start=8664 - _globals['_POINTCLOUDCHUNK']._serialized_end=8855 - _globals['_DATAEDITSREQUEST']._serialized_start=8858 - _globals['_DATAEDITSREQUEST']._serialized_end=9078 - _globals['_DATAEDITSRESPONSE']._serialized_start=9080 - _globals['_DATAEDITSRESPONSE']._serialized_end=9133 - _globals['_DATASPLITSRESPONSE']._serialized_start=9135 - _globals['_DATASPLITSRESPONSE']._serialized_end=9193 - _globals['_AGENTHEALTHRESPONSE']._serialized_start=9195 - _globals['_AGENTHEALTHRESPONSE']._serialized_end=9252 - _globals['_INITIALIZEAGENTREQUEST']._serialized_start=9254 - _globals['_INITIALIZEAGENTREQUEST']._serialized_end=9348 - _globals['_INITIALIZEAGENTRESPONSE']._serialized_start=9350 - _globals['_INITIALIZEAGENTRESPONSE']._serialized_end=9409 - _globals['_CHANGEAGENTMODELREQUEST']._serialized_start=9411 - _globals['_CHANGEAGENTMODELREQUEST']._serialized_end=9451 - _globals['_CHANGEAGENTMODELRESPONSE']._serialized_start=9453 - _globals['_CHANGEAGENTMODELRESPONSE']._serialized_end=9513 - _globals['_GETAGENTMODELSREQUEST']._serialized_start=9515 - _globals['_GETAGENTMODELSREQUEST']._serialized_end=9538 - _globals['_GETAGENTMODELSRESPONSE']._serialized_start=9540 - _globals['_GETAGENTMODELSRESPONSE']._serialized_end=9614 - _globals['_RESETAGENTRESPONSE']._serialized_start=9616 - _globals['_RESETAGENTRESPONSE']._serialized_end=9670 - _globals['_RESTORECHECKPOINTREQUEST']._serialized_start=9672 - _globals['_RESTORECHECKPOINTREQUEST']._serialized_end=9723 - _globals['_RESTORECHECKPOINTRESPONSE']._serialized_start=9725 - _globals['_RESTORECHECKPOINTRESPONSE']._serialized_end=9786 - _globals['_TRIGGEREVALUATIONREQUEST']._serialized_start=9788 - _globals['_TRIGGEREVALUATIONREQUEST']._serialized_end=9870 - _globals['_TRIGGEREVALUATIONRESPONSE']._serialized_start=9872 - _globals['_TRIGGEREVALUATIONRESPONSE']._serialized_end=9933 - _globals['_GETEVALUATIONSTATUSREQUEST']._serialized_start=9935 - _globals['_GETEVALUATIONSTATUSREQUEST']._serialized_end=9963 - _globals['_GETEVALUATIONSTATUSRESPONSE']._serialized_start=9966 - _globals['_GETEVALUATIONSTATUSRESPONSE']._serialized_end=10095 - _globals['_CANCELEVALUATIONREQUEST']._serialized_start=10097 - _globals['_CANCELEVALUATIONREQUEST']._serialized_end=10138 - _globals['_CANCELEVALUATIONRESPONSE']._serialized_start=10140 - _globals['_CANCELEVALUATIONRESPONSE']._serialized_end=10200 - _globals['_RUNNOTEBOOKCELLREQUEST']._serialized_start=10202 - _globals['_RUNNOTEBOOKCELLREQUEST']._serialized_end=10257 - _globals['_NOTEBOOKCELLDONE']._serialized_start=10259 - _globals['_NOTEBOOKCELLDONE']._serialized_end=10309 - _globals['_INTERRUPTNOTEBOOKCELLREQUEST']._serialized_start=10311 - _globals['_INTERRUPTNOTEBOOKCELLREQUEST']._serialized_end=10341 - _globals['_INTERRUPTNOTEBOOKCELLRESPONSE']._serialized_start=10343 - _globals['_INTERRUPTNOTEBOOKCELLRESPONSE']._serialized_end=10401 - _globals['_NOTEBOOKCELLCHUNK']._serialized_start=10404 - _globals['_NOTEBOOKCELLCHUNK']._serialized_end=10593 - _globals['_NOTEBOOKRESPONSE']._serialized_start=10595 - _globals['_NOTEBOOKRESPONSE']._serialized_end=10678 - _globals['_SAVENOTEBOOKREQUEST']._serialized_start=10680 - _globals['_SAVENOTEBOOKREQUEST']._serialized_end=10735 - _globals['_SAVENOTEBOOKRESPONSE']._serialized_start=10737 - _globals['_SAVENOTEBOOKRESPONSE']._serialized_end=10814 - _globals['_GENERATENOTEBOOKCODEREQUEST']._serialized_start=10816 - _globals['_GENERATENOTEBOOKCODEREQUEST']._serialized_end=10883 - _globals['_GENERATENOTEBOOKCODERESPONSE']._serialized_start=10885 - _globals['_GENERATENOTEBOOKCODERESPONSE']._serialized_end=10977 - _globals['_EXPERIMENTSERVICE']._serialized_start=11395 - _globals['_EXPERIMENTSERVICE']._serialized_end=13216 + _globals['_WEIGHTOPERATIONTYPE']._serialized_start=11090 + _globals['_WEIGHTOPERATIONTYPE']._serialized_end=11190 + _globals['_ZEROFYPREDICATE']._serialized_start=11192 + _globals['_ZEROFYPREDICATE']._serialized_end=11303 + _globals['_AGENTINTENTTYPE']._serialized_start=11305 + _globals['_AGENTINTENTTYPE']._serialized_end=11382 + _globals['_SAMPLEEDITTYPE']._serialized_start=11384 + _globals['_SAMPLEEDITTYPE']._serialized_end=11457 + _globals['_AGENTPROVIDERTYPE']._serialized_start=11459 + _globals['_AGENTPROVIDERTYPE']._serialized_end=11526 + _globals['_GETLATESTLOGGERDATAREQUEST']._serialized_start=29 + _globals['_GETLATESTLOGGERDATAREQUEST']._serialized_end=166 + _globals['_LOGGERDATAPOINT']._serialized_start=169 + _globals['_LOGGERDATAPOINT']._serialized_end=426 + _globals['_GETLATESTLOGGERDATARESPONSE']._serialized_start=428 + _globals['_GETLATESTLOGGERDATARESPONSE']._serialized_end=519 + _globals['_EMPTY']._serialized_start=521 + _globals['_EMPTY']._serialized_end=528 + _globals['_NEURONID']._serialized_start=530 + _globals['_NEURONID']._serialized_end=577 + _globals['_WEIGHTOPERATION']._serialized_start=580 + _globals['_WEIGHTOPERATION']._serialized_end=853 + _globals['_WEIGHTSOPERATIONREQUEST']._serialized_start=855 + _globals['_WEIGHTSOPERATIONREQUEST']._serialized_end=950 + _globals['_WEIGHTSOPERATIONRESPONSE']._serialized_start=952 + _globals['_WEIGHTSOPERATIONRESPONSE']._serialized_end=1012 + _globals['_HYPERPARAMETERS']._serialized_start=1015 + _globals['_HYPERPARAMETERS']._serialized_end=1720 + _globals['_METRICSSTATUS']._serialized_start=1722 + _globals['_METRICSSTATUS']._serialized_end=1766 + _globals['_ANNOTATSTATUS']._serialized_start=1768 + _globals['_ANNOTATSTATUS']._serialized_end=1894 + _globals['_ANNOTATSTATUS_METADATAENTRY']._serialized_start=1847 + _globals['_ANNOTATSTATUS_METADATAENTRY']._serialized_end=1894 + _globals['_TRAININGSTATUSEX']._serialized_start=1897 + _globals['_TRAININGSTATUSEX']._serialized_end=2169 + _globals['_HYPERPARAMETERCOMMAND']._serialized_start=2171 + _globals['_HYPERPARAMETERCOMMAND']._serialized_end=2264 + _globals['_DENYSAMPLESOPERATION']._serialized_start=2266 + _globals['_DENYSAMPLESOPERATION']._serialized_end=2328 + _globals['_LOADCHECKPOINTOPERATION']._serialized_start=2330 + _globals['_LOADCHECKPOINTOPERATION']._serialized_end=2378 + _globals['_PLOTNOTEOPERATION']._serialized_start=2380 + _globals['_PLOTNOTEOPERATION']._serialized_end=2478 + _globals['_SAVECHECKPOINTOPERATION']._serialized_start=2480 + _globals['_SAVECHECKPOINTOPERATION']._serialized_end=2556 + _globals['_RESTARTINSTANCEOPERATION']._serialized_start=2558 + _globals['_RESTARTINSTANCEOPERATION']._serialized_end=2584 + _globals['_TRAINERCOMMAND']._serialized_start=2587 + _globals['_TRAINERCOMMAND']._serialized_end=3624 + _globals['_HYPERPARAMETERDESC']._serialized_start=3627 + _globals['_HYPERPARAMETERDESC']._serialized_end=3784 + _globals['_NEURONSTATISTICS']._serialized_start=3787 + _globals['_NEURONSTATISTICS']._serialized_end=4157 + _globals['_NEURONSTATISTICS_INCOMINGLRENTRY']._serialized_start=4016 + _globals['_NEURONSTATISTICS_INCOMINGLRENTRY']._serialized_end=4065 + _globals['_LAYERREPRESENTATION']._serialized_start=4160 + _globals['_LAYERREPRESENTATION']._serialized_end=4528 + _globals['_ACTIVATIONREQUEST']._serialized_start=4530 + _globals['_ACTIVATIONREQUEST']._serialized_end=4602 + _globals['_ACTIVATIONMAP']._serialized_start=4604 + _globals['_ACTIVATIONMAP']._serialized_end=4676 + _globals['_ACTIVATIONRESPONSE']._serialized_start=4678 + _globals['_ACTIVATIONRESPONSE']._serialized_end=4778 + _globals['_TASKFIELD']._serialized_start=4781 + _globals['_TASKFIELD']._serialized_end=4928 + _globals['_RECORDMETADATA']._serialized_start=4931 + _globals['_RECORDMETADATA']._serialized_end=5322 + _globals['_RECORDMETADATA_SAMPLELASTLOSSENTRY']._serialized_start=5269 + _globals['_RECORDMETADATA_SAMPLELASTLOSSENTRY']._serialized_end=5322 + _globals['_SAMPLESTATISTICS']._serialized_start=5325 + _globals['_SAMPLESTATISTICS']._serialized_end=5472 + _globals['_COMMANDRESPONSE']._serialized_start=5475 + _globals['_COMMANDRESPONSE']._serialized_end=5705 + _globals['_SAMPLEREQUEST']._serialized_start=5707 + _globals['_SAMPLEREQUEST']._serialized_end=5792 + _globals['_SAMPLEREQUESTRESPONSE']._serialized_start=5795 + _globals['_SAMPLEREQUESTRESPONSE']._serialized_end=6096 + _globals['_BATCHSAMPLEREQUEST']._serialized_start=6099 + _globals['_BATCHSAMPLEREQUEST']._serialized_end=6245 + _globals['_BATCHSAMPLERESPONSE']._serialized_start=6247 + _globals['_BATCHSAMPLERESPONSE']._serialized_end=6309 + _globals['_WEIGHTSREQUEST']._serialized_start=6311 + _globals['_WEIGHTSREQUEST']._serialized_end=6357 + _globals['_WEIGHTSRESPONSE']._serialized_start=6360 + _globals['_WEIGHTSRESPONSE']._serialized_end=6645 + _globals['_DATAQUERYREQUEST']._serialized_start=6647 + _globals['_DATAQUERYREQUEST']._serialized_end=6729 + _globals['_CATEGORICALTAGDEF']._serialized_start=6731 + _globals['_CATEGORICALTAGDEF']._serialized_end=6784 + _globals['_DATAQUERYRESPONSE']._serialized_start=6787 + _globals['_DATAQUERYRESPONSE']._serialized_end=7084 + _globals['_DATASAMPLESREQUEST']._serialized_start=7087 + _globals['_DATASAMPLESREQUEST']._serialized_end=7281 + _globals['_DATASTAT']._serialized_start=7283 + _globals['_DATASTAT']._serialized_end=7392 + _globals['_DATARECORD']._serialized_start=7394 + _globals['_DATARECORD']._serialized_end=7456 + _globals['_DATASAMPLESRESPONSE']._serialized_start=7458 + _globals['_DATASAMPLESRESPONSE']._serialized_end=7548 + _globals['_HISTOGRAMSUBBAR']._serialized_start=7550 + _globals['_HISTOGRAMSUBBAR']._serialized_end=7617 + _globals['_HISTOGRAMBIN']._serialized_start=7619 + _globals['_HISTOGRAMBIN']._serialized_end=7723 + _globals['_CATEGORICALHISTOGRAMBAR']._serialized_start=7725 + _globals['_CATEGORICALHISTOGRAMBAR']._serialized_end=7816 + _globals['_HISTOGRAMREQUEST']._serialized_start=7818 + _globals['_HISTOGRAMREQUEST']._serialized_end=7870 + _globals['_HISTOGRAMRESPONSE']._serialized_start=7873 + _globals['_HISTOGRAMRESPONSE']._serialized_end=8051 + _globals['_GETMETADATAREQUEST']._serialized_start=8053 + _globals['_GETMETADATAREQUEST']._serialized_end=8140 + _globals['_GETMETADATARESPONSE']._serialized_start=8143 + _globals['_GETMETADATARESPONSE']._serialized_end=8296 + _globals['_GETSIGNALTRAJECTORYREQUEST']._serialized_start=8298 + _globals['_GETSIGNALTRAJECTORYREQUEST']._serialized_end=8387 + _globals['_SIGNALTRAJECTORY']._serialized_start=8389 + _globals['_SIGNALTRAJECTORY']._serialized_end=8441 + _globals['_GETSIGNALTRAJECTORYRESPONSE']._serialized_start=8443 + _globals['_GETSIGNALTRAJECTORYRESPONSE']._serialized_end=8568 + _globals['_POINTCLOUDREQUEST']._serialized_start=8570 + _globals['_POINTCLOUDREQUEST']._serialized_end=8644 + _globals['_POINTCLOUDCHUNK']._serialized_start=8647 + _globals['_POINTCLOUDCHUNK']._serialized_end=8838 + _globals['_DATAEDITSREQUEST']._serialized_start=8841 + _globals['_DATAEDITSREQUEST']._serialized_end=9061 + _globals['_DATAEDITSRESPONSE']._serialized_start=9063 + _globals['_DATAEDITSRESPONSE']._serialized_end=9116 + _globals['_DATASPLITSRESPONSE']._serialized_start=9118 + _globals['_DATASPLITSRESPONSE']._serialized_end=9176 + _globals['_AGENTHEALTHRESPONSE']._serialized_start=9178 + _globals['_AGENTHEALTHRESPONSE']._serialized_end=9235 + _globals['_INITIALIZEAGENTREQUEST']._serialized_start=9237 + _globals['_INITIALIZEAGENTREQUEST']._serialized_end=9331 + _globals['_INITIALIZEAGENTRESPONSE']._serialized_start=9333 + _globals['_INITIALIZEAGENTRESPONSE']._serialized_end=9392 + _globals['_CHANGEAGENTMODELREQUEST']._serialized_start=9394 + _globals['_CHANGEAGENTMODELREQUEST']._serialized_end=9434 + _globals['_CHANGEAGENTMODELRESPONSE']._serialized_start=9436 + _globals['_CHANGEAGENTMODELRESPONSE']._serialized_end=9496 + _globals['_GETAGENTMODELSREQUEST']._serialized_start=9498 + _globals['_GETAGENTMODELSREQUEST']._serialized_end=9521 + _globals['_GETAGENTMODELSRESPONSE']._serialized_start=9523 + _globals['_GETAGENTMODELSRESPONSE']._serialized_end=9597 + _globals['_RESETAGENTRESPONSE']._serialized_start=9599 + _globals['_RESETAGENTRESPONSE']._serialized_end=9653 + _globals['_CLEARAGENTHISTORYRESPONSE']._serialized_start=9655 + _globals['_CLEARAGENTHISTORYRESPONSE']._serialized_end=9716 + _globals['_COMPACTAGENTHISTORYRESPONSE']._serialized_start=9718 + _globals['_COMPACTAGENTHISTORYRESPONSE']._serialized_end=9781 + _globals['_RESTORECHECKPOINTREQUEST']._serialized_start=9783 + _globals['_RESTORECHECKPOINTREQUEST']._serialized_end=9834 + _globals['_RESTORECHECKPOINTRESPONSE']._serialized_start=9836 + _globals['_RESTORECHECKPOINTRESPONSE']._serialized_end=9897 + _globals['_TRIGGEREVALUATIONREQUEST']._serialized_start=9899 + _globals['_TRIGGEREVALUATIONREQUEST']._serialized_end=9981 + _globals['_TRIGGEREVALUATIONRESPONSE']._serialized_start=9983 + _globals['_TRIGGEREVALUATIONRESPONSE']._serialized_end=10044 + _globals['_GETEVALUATIONSTATUSREQUEST']._serialized_start=10046 + _globals['_GETEVALUATIONSTATUSREQUEST']._serialized_end=10074 + _globals['_GETEVALUATIONSTATUSRESPONSE']._serialized_start=10077 + _globals['_GETEVALUATIONSTATUSRESPONSE']._serialized_end=10206 + _globals['_CANCELEVALUATIONREQUEST']._serialized_start=10208 + _globals['_CANCELEVALUATIONREQUEST']._serialized_end=10249 + _globals['_CANCELEVALUATIONRESPONSE']._serialized_start=10251 + _globals['_CANCELEVALUATIONRESPONSE']._serialized_end=10311 + _globals['_RUNNOTEBOOKCELLREQUEST']._serialized_start=10313 + _globals['_RUNNOTEBOOKCELLREQUEST']._serialized_end=10368 + _globals['_NOTEBOOKCELLDONE']._serialized_start=10370 + _globals['_NOTEBOOKCELLDONE']._serialized_end=10420 + _globals['_INTERRUPTNOTEBOOKCELLREQUEST']._serialized_start=10422 + _globals['_INTERRUPTNOTEBOOKCELLREQUEST']._serialized_end=10452 + _globals['_INTERRUPTNOTEBOOKCELLRESPONSE']._serialized_start=10454 + _globals['_INTERRUPTNOTEBOOKCELLRESPONSE']._serialized_end=10512 + _globals['_NOTEBOOKCELLCHUNK']._serialized_start=10515 + _globals['_NOTEBOOKCELLCHUNK']._serialized_end=10704 + _globals['_NOTEBOOKRESPONSE']._serialized_start=10706 + _globals['_NOTEBOOKRESPONSE']._serialized_end=10789 + _globals['_SAVENOTEBOOKREQUEST']._serialized_start=10791 + _globals['_SAVENOTEBOOKREQUEST']._serialized_end=10846 + _globals['_SAVENOTEBOOKRESPONSE']._serialized_start=10848 + _globals['_SAVENOTEBOOKRESPONSE']._serialized_end=10925 + _globals['_GENERATENOTEBOOKCODEREQUEST']._serialized_start=10927 + _globals['_GENERATENOTEBOOKCODEREQUEST']._serialized_end=10994 + _globals['_GENERATENOTEBOOKCODERESPONSE']._serialized_start=10996 + _globals['_GENERATENOTEBOOKCODERESPONSE']._serialized_end=11088 + _globals['_EXPERIMENTSERVICE']._serialized_start=11529 + _globals['_EXPERIMENTSERVICE']._serialized_end=13468 # @@protoc_insertion_point(module_scope) diff --git a/weightslab/proto/experiment_service_pb2_grpc.py b/weightslab/proto/experiment_service_pb2_grpc.py index 2f9127b2..55830bca 100644 --- a/weightslab/proto/experiment_service_pb2_grpc.py +++ b/weightslab/proto/experiment_service_pb2_grpc.py @@ -3,9 +3,9 @@ import grpc import warnings -from weightslab.proto import experiment_service_pb2 as weightslab_dot_proto_dot_experiment__service__pb2 +from weightslab.proto import experiment_service_pb2 as experiment__service__pb2 -GRPC_GENERATED_VERSION = '1.76.0' +GRPC_GENERATED_VERSION = '1.68.1' GRPC_VERSION = grpc.__version__ _version_not_supported = False @@ -18,7 +18,7 @@ if _version_not_supported: raise RuntimeError( f'The grpc package installed is at version {GRPC_VERSION},' - + ' but the generated code in weightslab/proto/experiment_service_pb2_grpc.py depends on' + + f' but the generated code in experiment_service_pb2_grpc.py depends on' + f' grpcio>={GRPC_GENERATED_VERSION}.' + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' @@ -36,143 +36,153 @@ def __init__(self, channel): """ self.GetLatestLoggerData = channel.unary_unary( '/ExperimentService/GetLatestLoggerData', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.GetLatestLoggerDataRequest.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.GetLatestLoggerDataResponse.FromString, + request_serializer=experiment__service__pb2.GetLatestLoggerDataRequest.SerializeToString, + response_deserializer=experiment__service__pb2.GetLatestLoggerDataResponse.FromString, _registered_method=True) self.ExperimentCommand = channel.unary_unary( '/ExperimentService/ExperimentCommand', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.TrainerCommand.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.CommandResponse.FromString, + request_serializer=experiment__service__pb2.TrainerCommand.SerializeToString, + response_deserializer=experiment__service__pb2.CommandResponse.FromString, _registered_method=True) self.ManipulateWeights = channel.unary_unary( '/ExperimentService/ManipulateWeights', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.WeightsOperationRequest.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.WeightsOperationResponse.FromString, + request_serializer=experiment__service__pb2.WeightsOperationRequest.SerializeToString, + response_deserializer=experiment__service__pb2.WeightsOperationResponse.FromString, _registered_method=True) self.GetWeights = channel.unary_unary( '/ExperimentService/GetWeights', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.WeightsRequest.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.WeightsResponse.FromString, + request_serializer=experiment__service__pb2.WeightsRequest.SerializeToString, + response_deserializer=experiment__service__pb2.WeightsResponse.FromString, _registered_method=True) self.GetActivations = channel.unary_unary( '/ExperimentService/GetActivations', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.ActivationRequest.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.ActivationResponse.FromString, + request_serializer=experiment__service__pb2.ActivationRequest.SerializeToString, + response_deserializer=experiment__service__pb2.ActivationResponse.FromString, _registered_method=True) self.GetSamples = channel.unary_unary( '/ExperimentService/GetSamples', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.BatchSampleRequest.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.BatchSampleResponse.FromString, + request_serializer=experiment__service__pb2.BatchSampleRequest.SerializeToString, + response_deserializer=experiment__service__pb2.BatchSampleResponse.FromString, _registered_method=True) self.ApplyDataQuery = channel.unary_unary( '/ExperimentService/ApplyDataQuery', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.DataQueryRequest.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.DataQueryResponse.FromString, + request_serializer=experiment__service__pb2.DataQueryRequest.SerializeToString, + response_deserializer=experiment__service__pb2.DataQueryResponse.FromString, _registered_method=True) self.GetDataSamples = channel.unary_unary( '/ExperimentService/GetDataSamples', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.DataSamplesRequest.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.DataSamplesResponse.FromString, + request_serializer=experiment__service__pb2.DataSamplesRequest.SerializeToString, + response_deserializer=experiment__service__pb2.DataSamplesResponse.FromString, _registered_method=True) self.GetHistogram = channel.unary_unary( '/ExperimentService/GetHistogram', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.HistogramRequest.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.HistogramResponse.FromString, + request_serializer=experiment__service__pb2.HistogramRequest.SerializeToString, + response_deserializer=experiment__service__pb2.HistogramResponse.FromString, _registered_method=True) self.GetMetaData = channel.unary_unary( '/ExperimentService/GetMetaData', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.GetMetaDataRequest.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.GetMetaDataResponse.FromString, + request_serializer=experiment__service__pb2.GetMetaDataRequest.SerializeToString, + response_deserializer=experiment__service__pb2.GetMetaDataResponse.FromString, _registered_method=True) self.GetSignalTrajectory = channel.unary_unary( '/ExperimentService/GetSignalTrajectory', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.GetSignalTrajectoryRequest.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.GetSignalTrajectoryResponse.FromString, + request_serializer=experiment__service__pb2.GetSignalTrajectoryRequest.SerializeToString, + response_deserializer=experiment__service__pb2.GetSignalTrajectoryResponse.FromString, _registered_method=True) self.GetPointCloud = channel.unary_stream( '/ExperimentService/GetPointCloud', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.PointCloudRequest.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.PointCloudChunk.FromString, + request_serializer=experiment__service__pb2.PointCloudRequest.SerializeToString, + response_deserializer=experiment__service__pb2.PointCloudChunk.FromString, _registered_method=True) self.EditDataSample = channel.unary_unary( '/ExperimentService/EditDataSample', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.DataEditsRequest.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.DataEditsResponse.FromString, + request_serializer=experiment__service__pb2.DataEditsRequest.SerializeToString, + response_deserializer=experiment__service__pb2.DataEditsResponse.FromString, _registered_method=True) self.GetDataSplits = channel.unary_unary( '/ExperimentService/GetDataSplits', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.Empty.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.DataSplitsResponse.FromString, + request_serializer=experiment__service__pb2.Empty.SerializeToString, + response_deserializer=experiment__service__pb2.DataSplitsResponse.FromString, _registered_method=True) self.CheckAgentHealth = channel.unary_unary( '/ExperimentService/CheckAgentHealth', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.Empty.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.AgentHealthResponse.FromString, + request_serializer=experiment__service__pb2.Empty.SerializeToString, + response_deserializer=experiment__service__pb2.AgentHealthResponse.FromString, _registered_method=True) self.InitializeAgent = channel.unary_unary( '/ExperimentService/InitializeAgent', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.InitializeAgentRequest.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.InitializeAgentResponse.FromString, + request_serializer=experiment__service__pb2.InitializeAgentRequest.SerializeToString, + response_deserializer=experiment__service__pb2.InitializeAgentResponse.FromString, _registered_method=True) self.ChangeAgentModel = channel.unary_unary( '/ExperimentService/ChangeAgentModel', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.ChangeAgentModelRequest.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.ChangeAgentModelResponse.FromString, + request_serializer=experiment__service__pb2.ChangeAgentModelRequest.SerializeToString, + response_deserializer=experiment__service__pb2.ChangeAgentModelResponse.FromString, _registered_method=True) self.GetAgentModels = channel.unary_unary( '/ExperimentService/GetAgentModels', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.GetAgentModelsRequest.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.GetAgentModelsResponse.FromString, + request_serializer=experiment__service__pb2.GetAgentModelsRequest.SerializeToString, + response_deserializer=experiment__service__pb2.GetAgentModelsResponse.FromString, _registered_method=True) self.ResetAgent = channel.unary_unary( '/ExperimentService/ResetAgent', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.Empty.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.ResetAgentResponse.FromString, + request_serializer=experiment__service__pb2.Empty.SerializeToString, + response_deserializer=experiment__service__pb2.ResetAgentResponse.FromString, + _registered_method=True) + self.ClearAgentHistory = channel.unary_unary( + '/ExperimentService/ClearAgentHistory', + request_serializer=experiment__service__pb2.Empty.SerializeToString, + response_deserializer=experiment__service__pb2.ClearAgentHistoryResponse.FromString, + _registered_method=True) + self.CompactAgentHistory = channel.unary_unary( + '/ExperimentService/CompactAgentHistory', + request_serializer=experiment__service__pb2.Empty.SerializeToString, + response_deserializer=experiment__service__pb2.CompactAgentHistoryResponse.FromString, _registered_method=True) self.RunNotebookCell = channel.unary_stream( '/ExperimentService/RunNotebookCell', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.RunNotebookCellRequest.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.NotebookCellChunk.FromString, + request_serializer=experiment__service__pb2.RunNotebookCellRequest.SerializeToString, + response_deserializer=experiment__service__pb2.NotebookCellChunk.FromString, _registered_method=True) self.InterruptNotebookCell = channel.unary_unary( '/ExperimentService/InterruptNotebookCell', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.InterruptNotebookCellRequest.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.InterruptNotebookCellResponse.FromString, + request_serializer=experiment__service__pb2.InterruptNotebookCellRequest.SerializeToString, + response_deserializer=experiment__service__pb2.InterruptNotebookCellResponse.FromString, _registered_method=True) self.GetNotebook = channel.unary_unary( '/ExperimentService/GetNotebook', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.Empty.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.NotebookResponse.FromString, + request_serializer=experiment__service__pb2.Empty.SerializeToString, + response_deserializer=experiment__service__pb2.NotebookResponse.FromString, _registered_method=True) self.SaveNotebook = channel.unary_unary( '/ExperimentService/SaveNotebook', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.SaveNotebookRequest.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.SaveNotebookResponse.FromString, + request_serializer=experiment__service__pb2.SaveNotebookRequest.SerializeToString, + response_deserializer=experiment__service__pb2.SaveNotebookResponse.FromString, _registered_method=True) self.GenerateNotebookCode = channel.unary_unary( '/ExperimentService/GenerateNotebookCode', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.GenerateNotebookCodeRequest.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.GenerateNotebookCodeResponse.FromString, + request_serializer=experiment__service__pb2.GenerateNotebookCodeRequest.SerializeToString, + response_deserializer=experiment__service__pb2.GenerateNotebookCodeResponse.FromString, _registered_method=True) self.RestoreCheckpoint = channel.unary_unary( '/ExperimentService/RestoreCheckpoint', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.RestoreCheckpointRequest.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.RestoreCheckpointResponse.FromString, + request_serializer=experiment__service__pb2.RestoreCheckpointRequest.SerializeToString, + response_deserializer=experiment__service__pb2.RestoreCheckpointResponse.FromString, _registered_method=True) self.TriggerEvaluation = channel.unary_unary( '/ExperimentService/TriggerEvaluation', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.TriggerEvaluationRequest.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.TriggerEvaluationResponse.FromString, + request_serializer=experiment__service__pb2.TriggerEvaluationRequest.SerializeToString, + response_deserializer=experiment__service__pb2.TriggerEvaluationResponse.FromString, _registered_method=True) self.GetEvaluationStatus = channel.unary_unary( '/ExperimentService/GetEvaluationStatus', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.GetEvaluationStatusRequest.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.GetEvaluationStatusResponse.FromString, + request_serializer=experiment__service__pb2.GetEvaluationStatusRequest.SerializeToString, + response_deserializer=experiment__service__pb2.GetEvaluationStatusResponse.FromString, _registered_method=True) self.CancelEvaluation = channel.unary_unary( '/ExperimentService/CancelEvaluation', - request_serializer=weightslab_dot_proto_dot_experiment__service__pb2.CancelEvaluationRequest.SerializeToString, - response_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.CancelEvaluationResponse.FromString, + request_serializer=experiment__service__pb2.CancelEvaluationRequest.SerializeToString, + response_deserializer=experiment__service__pb2.CancelEvaluationResponse.FromString, _registered_method=True) @@ -306,6 +316,23 @@ def ResetAgent(self, request, context): context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') + def ClearAgentHistory(self, request, context): + """Wipe the agent's conversation history (self.history) without touching the + provider connection -- distinct from ResetAgent, which drops the connection. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def CompactAgentHistory(self, request, context): + """Summarize the agent's conversation history via the active model, replacing + it with the summary. Distinct from OpenCode's own session compaction (which + this does not touch) -- this is the SDK agent's own self.history. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + def RunNotebookCell(self, request, context): """Notebook (shared in-process Python kernel for the studio UI). Cell output is server-streamed in chunks (stdout / stderr / result / image), mirroring @@ -375,143 +402,153 @@ def add_ExperimentServiceServicer_to_server(servicer, server): rpc_method_handlers = { 'GetLatestLoggerData': grpc.unary_unary_rpc_method_handler( servicer.GetLatestLoggerData, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.GetLatestLoggerDataRequest.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.GetLatestLoggerDataResponse.SerializeToString, + request_deserializer=experiment__service__pb2.GetLatestLoggerDataRequest.FromString, + response_serializer=experiment__service__pb2.GetLatestLoggerDataResponse.SerializeToString, ), 'ExperimentCommand': grpc.unary_unary_rpc_method_handler( servicer.ExperimentCommand, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.TrainerCommand.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.CommandResponse.SerializeToString, + request_deserializer=experiment__service__pb2.TrainerCommand.FromString, + response_serializer=experiment__service__pb2.CommandResponse.SerializeToString, ), 'ManipulateWeights': grpc.unary_unary_rpc_method_handler( servicer.ManipulateWeights, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.WeightsOperationRequest.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.WeightsOperationResponse.SerializeToString, + request_deserializer=experiment__service__pb2.WeightsOperationRequest.FromString, + response_serializer=experiment__service__pb2.WeightsOperationResponse.SerializeToString, ), 'GetWeights': grpc.unary_unary_rpc_method_handler( servicer.GetWeights, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.WeightsRequest.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.WeightsResponse.SerializeToString, + request_deserializer=experiment__service__pb2.WeightsRequest.FromString, + response_serializer=experiment__service__pb2.WeightsResponse.SerializeToString, ), 'GetActivations': grpc.unary_unary_rpc_method_handler( servicer.GetActivations, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.ActivationRequest.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.ActivationResponse.SerializeToString, + request_deserializer=experiment__service__pb2.ActivationRequest.FromString, + response_serializer=experiment__service__pb2.ActivationResponse.SerializeToString, ), 'GetSamples': grpc.unary_unary_rpc_method_handler( servicer.GetSamples, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.BatchSampleRequest.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.BatchSampleResponse.SerializeToString, + request_deserializer=experiment__service__pb2.BatchSampleRequest.FromString, + response_serializer=experiment__service__pb2.BatchSampleResponse.SerializeToString, ), 'ApplyDataQuery': grpc.unary_unary_rpc_method_handler( servicer.ApplyDataQuery, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.DataQueryRequest.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.DataQueryResponse.SerializeToString, + request_deserializer=experiment__service__pb2.DataQueryRequest.FromString, + response_serializer=experiment__service__pb2.DataQueryResponse.SerializeToString, ), 'GetDataSamples': grpc.unary_unary_rpc_method_handler( servicer.GetDataSamples, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.DataSamplesRequest.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.DataSamplesResponse.SerializeToString, + request_deserializer=experiment__service__pb2.DataSamplesRequest.FromString, + response_serializer=experiment__service__pb2.DataSamplesResponse.SerializeToString, ), 'GetHistogram': grpc.unary_unary_rpc_method_handler( servicer.GetHistogram, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.HistogramRequest.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.HistogramResponse.SerializeToString, + request_deserializer=experiment__service__pb2.HistogramRequest.FromString, + response_serializer=experiment__service__pb2.HistogramResponse.SerializeToString, ), 'GetMetaData': grpc.unary_unary_rpc_method_handler( servicer.GetMetaData, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.GetMetaDataRequest.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.GetMetaDataResponse.SerializeToString, + request_deserializer=experiment__service__pb2.GetMetaDataRequest.FromString, + response_serializer=experiment__service__pb2.GetMetaDataResponse.SerializeToString, ), 'GetSignalTrajectory': grpc.unary_unary_rpc_method_handler( servicer.GetSignalTrajectory, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.GetSignalTrajectoryRequest.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.GetSignalTrajectoryResponse.SerializeToString, + request_deserializer=experiment__service__pb2.GetSignalTrajectoryRequest.FromString, + response_serializer=experiment__service__pb2.GetSignalTrajectoryResponse.SerializeToString, ), 'GetPointCloud': grpc.unary_stream_rpc_method_handler( servicer.GetPointCloud, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.PointCloudRequest.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.PointCloudChunk.SerializeToString, + request_deserializer=experiment__service__pb2.PointCloudRequest.FromString, + response_serializer=experiment__service__pb2.PointCloudChunk.SerializeToString, ), 'EditDataSample': grpc.unary_unary_rpc_method_handler( servicer.EditDataSample, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.DataEditsRequest.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.DataEditsResponse.SerializeToString, + request_deserializer=experiment__service__pb2.DataEditsRequest.FromString, + response_serializer=experiment__service__pb2.DataEditsResponse.SerializeToString, ), 'GetDataSplits': grpc.unary_unary_rpc_method_handler( servicer.GetDataSplits, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.Empty.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.DataSplitsResponse.SerializeToString, + request_deserializer=experiment__service__pb2.Empty.FromString, + response_serializer=experiment__service__pb2.DataSplitsResponse.SerializeToString, ), 'CheckAgentHealth': grpc.unary_unary_rpc_method_handler( servicer.CheckAgentHealth, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.Empty.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.AgentHealthResponse.SerializeToString, + request_deserializer=experiment__service__pb2.Empty.FromString, + response_serializer=experiment__service__pb2.AgentHealthResponse.SerializeToString, ), 'InitializeAgent': grpc.unary_unary_rpc_method_handler( servicer.InitializeAgent, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.InitializeAgentRequest.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.InitializeAgentResponse.SerializeToString, + request_deserializer=experiment__service__pb2.InitializeAgentRequest.FromString, + response_serializer=experiment__service__pb2.InitializeAgentResponse.SerializeToString, ), 'ChangeAgentModel': grpc.unary_unary_rpc_method_handler( servicer.ChangeAgentModel, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.ChangeAgentModelRequest.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.ChangeAgentModelResponse.SerializeToString, + request_deserializer=experiment__service__pb2.ChangeAgentModelRequest.FromString, + response_serializer=experiment__service__pb2.ChangeAgentModelResponse.SerializeToString, ), 'GetAgentModels': grpc.unary_unary_rpc_method_handler( servicer.GetAgentModels, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.GetAgentModelsRequest.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.GetAgentModelsResponse.SerializeToString, + request_deserializer=experiment__service__pb2.GetAgentModelsRequest.FromString, + response_serializer=experiment__service__pb2.GetAgentModelsResponse.SerializeToString, ), 'ResetAgent': grpc.unary_unary_rpc_method_handler( servicer.ResetAgent, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.Empty.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.ResetAgentResponse.SerializeToString, + request_deserializer=experiment__service__pb2.Empty.FromString, + response_serializer=experiment__service__pb2.ResetAgentResponse.SerializeToString, + ), + 'ClearAgentHistory': grpc.unary_unary_rpc_method_handler( + servicer.ClearAgentHistory, + request_deserializer=experiment__service__pb2.Empty.FromString, + response_serializer=experiment__service__pb2.ClearAgentHistoryResponse.SerializeToString, + ), + 'CompactAgentHistory': grpc.unary_unary_rpc_method_handler( + servicer.CompactAgentHistory, + request_deserializer=experiment__service__pb2.Empty.FromString, + response_serializer=experiment__service__pb2.CompactAgentHistoryResponse.SerializeToString, ), 'RunNotebookCell': grpc.unary_stream_rpc_method_handler( servicer.RunNotebookCell, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.RunNotebookCellRequest.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.NotebookCellChunk.SerializeToString, + request_deserializer=experiment__service__pb2.RunNotebookCellRequest.FromString, + response_serializer=experiment__service__pb2.NotebookCellChunk.SerializeToString, ), 'InterruptNotebookCell': grpc.unary_unary_rpc_method_handler( servicer.InterruptNotebookCell, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.InterruptNotebookCellRequest.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.InterruptNotebookCellResponse.SerializeToString, + request_deserializer=experiment__service__pb2.InterruptNotebookCellRequest.FromString, + response_serializer=experiment__service__pb2.InterruptNotebookCellResponse.SerializeToString, ), 'GetNotebook': grpc.unary_unary_rpc_method_handler( servicer.GetNotebook, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.Empty.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.NotebookResponse.SerializeToString, + request_deserializer=experiment__service__pb2.Empty.FromString, + response_serializer=experiment__service__pb2.NotebookResponse.SerializeToString, ), 'SaveNotebook': grpc.unary_unary_rpc_method_handler( servicer.SaveNotebook, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.SaveNotebookRequest.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.SaveNotebookResponse.SerializeToString, + request_deserializer=experiment__service__pb2.SaveNotebookRequest.FromString, + response_serializer=experiment__service__pb2.SaveNotebookResponse.SerializeToString, ), 'GenerateNotebookCode': grpc.unary_unary_rpc_method_handler( servicer.GenerateNotebookCode, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.GenerateNotebookCodeRequest.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.GenerateNotebookCodeResponse.SerializeToString, + request_deserializer=experiment__service__pb2.GenerateNotebookCodeRequest.FromString, + response_serializer=experiment__service__pb2.GenerateNotebookCodeResponse.SerializeToString, ), 'RestoreCheckpoint': grpc.unary_unary_rpc_method_handler( servicer.RestoreCheckpoint, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.RestoreCheckpointRequest.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.RestoreCheckpointResponse.SerializeToString, + request_deserializer=experiment__service__pb2.RestoreCheckpointRequest.FromString, + response_serializer=experiment__service__pb2.RestoreCheckpointResponse.SerializeToString, ), 'TriggerEvaluation': grpc.unary_unary_rpc_method_handler( servicer.TriggerEvaluation, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.TriggerEvaluationRequest.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.TriggerEvaluationResponse.SerializeToString, + request_deserializer=experiment__service__pb2.TriggerEvaluationRequest.FromString, + response_serializer=experiment__service__pb2.TriggerEvaluationResponse.SerializeToString, ), 'GetEvaluationStatus': grpc.unary_unary_rpc_method_handler( servicer.GetEvaluationStatus, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.GetEvaluationStatusRequest.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.GetEvaluationStatusResponse.SerializeToString, + request_deserializer=experiment__service__pb2.GetEvaluationStatusRequest.FromString, + response_serializer=experiment__service__pb2.GetEvaluationStatusResponse.SerializeToString, ), 'CancelEvaluation': grpc.unary_unary_rpc_method_handler( servicer.CancelEvaluation, - request_deserializer=weightslab_dot_proto_dot_experiment__service__pb2.CancelEvaluationRequest.FromString, - response_serializer=weightslab_dot_proto_dot_experiment__service__pb2.CancelEvaluationResponse.SerializeToString, + request_deserializer=experiment__service__pb2.CancelEvaluationRequest.FromString, + response_serializer=experiment__service__pb2.CancelEvaluationResponse.SerializeToString, ), } generic_handler = grpc.method_handlers_generic_handler( @@ -539,8 +576,8 @@ def GetLatestLoggerData(request, request, target, '/ExperimentService/GetLatestLoggerData', - weightslab_dot_proto_dot_experiment__service__pb2.GetLatestLoggerDataRequest.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.GetLatestLoggerDataResponse.FromString, + experiment__service__pb2.GetLatestLoggerDataRequest.SerializeToString, + experiment__service__pb2.GetLatestLoggerDataResponse.FromString, options, channel_credentials, insecure, @@ -566,8 +603,8 @@ def ExperimentCommand(request, request, target, '/ExperimentService/ExperimentCommand', - weightslab_dot_proto_dot_experiment__service__pb2.TrainerCommand.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.CommandResponse.FromString, + experiment__service__pb2.TrainerCommand.SerializeToString, + experiment__service__pb2.CommandResponse.FromString, options, channel_credentials, insecure, @@ -593,8 +630,8 @@ def ManipulateWeights(request, request, target, '/ExperimentService/ManipulateWeights', - weightslab_dot_proto_dot_experiment__service__pb2.WeightsOperationRequest.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.WeightsOperationResponse.FromString, + experiment__service__pb2.WeightsOperationRequest.SerializeToString, + experiment__service__pb2.WeightsOperationResponse.FromString, options, channel_credentials, insecure, @@ -620,8 +657,8 @@ def GetWeights(request, request, target, '/ExperimentService/GetWeights', - weightslab_dot_proto_dot_experiment__service__pb2.WeightsRequest.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.WeightsResponse.FromString, + experiment__service__pb2.WeightsRequest.SerializeToString, + experiment__service__pb2.WeightsResponse.FromString, options, channel_credentials, insecure, @@ -647,8 +684,8 @@ def GetActivations(request, request, target, '/ExperimentService/GetActivations', - weightslab_dot_proto_dot_experiment__service__pb2.ActivationRequest.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.ActivationResponse.FromString, + experiment__service__pb2.ActivationRequest.SerializeToString, + experiment__service__pb2.ActivationResponse.FromString, options, channel_credentials, insecure, @@ -674,8 +711,8 @@ def GetSamples(request, request, target, '/ExperimentService/GetSamples', - weightslab_dot_proto_dot_experiment__service__pb2.BatchSampleRequest.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.BatchSampleResponse.FromString, + experiment__service__pb2.BatchSampleRequest.SerializeToString, + experiment__service__pb2.BatchSampleResponse.FromString, options, channel_credentials, insecure, @@ -701,8 +738,8 @@ def ApplyDataQuery(request, request, target, '/ExperimentService/ApplyDataQuery', - weightslab_dot_proto_dot_experiment__service__pb2.DataQueryRequest.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.DataQueryResponse.FromString, + experiment__service__pb2.DataQueryRequest.SerializeToString, + experiment__service__pb2.DataQueryResponse.FromString, options, channel_credentials, insecure, @@ -728,8 +765,8 @@ def GetDataSamples(request, request, target, '/ExperimentService/GetDataSamples', - weightslab_dot_proto_dot_experiment__service__pb2.DataSamplesRequest.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.DataSamplesResponse.FromString, + experiment__service__pb2.DataSamplesRequest.SerializeToString, + experiment__service__pb2.DataSamplesResponse.FromString, options, channel_credentials, insecure, @@ -755,8 +792,8 @@ def GetHistogram(request, request, target, '/ExperimentService/GetHistogram', - weightslab_dot_proto_dot_experiment__service__pb2.HistogramRequest.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.HistogramResponse.FromString, + experiment__service__pb2.HistogramRequest.SerializeToString, + experiment__service__pb2.HistogramResponse.FromString, options, channel_credentials, insecure, @@ -782,8 +819,8 @@ def GetMetaData(request, request, target, '/ExperimentService/GetMetaData', - weightslab_dot_proto_dot_experiment__service__pb2.GetMetaDataRequest.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.GetMetaDataResponse.FromString, + experiment__service__pb2.GetMetaDataRequest.SerializeToString, + experiment__service__pb2.GetMetaDataResponse.FromString, options, channel_credentials, insecure, @@ -809,8 +846,8 @@ def GetSignalTrajectory(request, request, target, '/ExperimentService/GetSignalTrajectory', - weightslab_dot_proto_dot_experiment__service__pb2.GetSignalTrajectoryRequest.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.GetSignalTrajectoryResponse.FromString, + experiment__service__pb2.GetSignalTrajectoryRequest.SerializeToString, + experiment__service__pb2.GetSignalTrajectoryResponse.FromString, options, channel_credentials, insecure, @@ -836,8 +873,8 @@ def GetPointCloud(request, request, target, '/ExperimentService/GetPointCloud', - weightslab_dot_proto_dot_experiment__service__pb2.PointCloudRequest.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.PointCloudChunk.FromString, + experiment__service__pb2.PointCloudRequest.SerializeToString, + experiment__service__pb2.PointCloudChunk.FromString, options, channel_credentials, insecure, @@ -863,8 +900,8 @@ def EditDataSample(request, request, target, '/ExperimentService/EditDataSample', - weightslab_dot_proto_dot_experiment__service__pb2.DataEditsRequest.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.DataEditsResponse.FromString, + experiment__service__pb2.DataEditsRequest.SerializeToString, + experiment__service__pb2.DataEditsResponse.FromString, options, channel_credentials, insecure, @@ -890,8 +927,8 @@ def GetDataSplits(request, request, target, '/ExperimentService/GetDataSplits', - weightslab_dot_proto_dot_experiment__service__pb2.Empty.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.DataSplitsResponse.FromString, + experiment__service__pb2.Empty.SerializeToString, + experiment__service__pb2.DataSplitsResponse.FromString, options, channel_credentials, insecure, @@ -917,8 +954,8 @@ def CheckAgentHealth(request, request, target, '/ExperimentService/CheckAgentHealth', - weightslab_dot_proto_dot_experiment__service__pb2.Empty.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.AgentHealthResponse.FromString, + experiment__service__pb2.Empty.SerializeToString, + experiment__service__pb2.AgentHealthResponse.FromString, options, channel_credentials, insecure, @@ -944,8 +981,8 @@ def InitializeAgent(request, request, target, '/ExperimentService/InitializeAgent', - weightslab_dot_proto_dot_experiment__service__pb2.InitializeAgentRequest.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.InitializeAgentResponse.FromString, + experiment__service__pb2.InitializeAgentRequest.SerializeToString, + experiment__service__pb2.InitializeAgentResponse.FromString, options, channel_credentials, insecure, @@ -971,8 +1008,8 @@ def ChangeAgentModel(request, request, target, '/ExperimentService/ChangeAgentModel', - weightslab_dot_proto_dot_experiment__service__pb2.ChangeAgentModelRequest.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.ChangeAgentModelResponse.FromString, + experiment__service__pb2.ChangeAgentModelRequest.SerializeToString, + experiment__service__pb2.ChangeAgentModelResponse.FromString, options, channel_credentials, insecure, @@ -998,8 +1035,8 @@ def GetAgentModels(request, request, target, '/ExperimentService/GetAgentModels', - weightslab_dot_proto_dot_experiment__service__pb2.GetAgentModelsRequest.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.GetAgentModelsResponse.FromString, + experiment__service__pb2.GetAgentModelsRequest.SerializeToString, + experiment__service__pb2.GetAgentModelsResponse.FromString, options, channel_credentials, insecure, @@ -1025,8 +1062,62 @@ def ResetAgent(request, request, target, '/ExperimentService/ResetAgent', - weightslab_dot_proto_dot_experiment__service__pb2.Empty.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.ResetAgentResponse.FromString, + experiment__service__pb2.Empty.SerializeToString, + experiment__service__pb2.ResetAgentResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def ClearAgentHistory(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/ExperimentService/ClearAgentHistory', + experiment__service__pb2.Empty.SerializeToString, + experiment__service__pb2.ClearAgentHistoryResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def CompactAgentHistory(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/ExperimentService/CompactAgentHistory', + experiment__service__pb2.Empty.SerializeToString, + experiment__service__pb2.CompactAgentHistoryResponse.FromString, options, channel_credentials, insecure, @@ -1052,8 +1143,8 @@ def RunNotebookCell(request, request, target, '/ExperimentService/RunNotebookCell', - weightslab_dot_proto_dot_experiment__service__pb2.RunNotebookCellRequest.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.NotebookCellChunk.FromString, + experiment__service__pb2.RunNotebookCellRequest.SerializeToString, + experiment__service__pb2.NotebookCellChunk.FromString, options, channel_credentials, insecure, @@ -1079,8 +1170,8 @@ def InterruptNotebookCell(request, request, target, '/ExperimentService/InterruptNotebookCell', - weightslab_dot_proto_dot_experiment__service__pb2.InterruptNotebookCellRequest.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.InterruptNotebookCellResponse.FromString, + experiment__service__pb2.InterruptNotebookCellRequest.SerializeToString, + experiment__service__pb2.InterruptNotebookCellResponse.FromString, options, channel_credentials, insecure, @@ -1106,8 +1197,8 @@ def GetNotebook(request, request, target, '/ExperimentService/GetNotebook', - weightslab_dot_proto_dot_experiment__service__pb2.Empty.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.NotebookResponse.FromString, + experiment__service__pb2.Empty.SerializeToString, + experiment__service__pb2.NotebookResponse.FromString, options, channel_credentials, insecure, @@ -1133,8 +1224,8 @@ def SaveNotebook(request, request, target, '/ExperimentService/SaveNotebook', - weightslab_dot_proto_dot_experiment__service__pb2.SaveNotebookRequest.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.SaveNotebookResponse.FromString, + experiment__service__pb2.SaveNotebookRequest.SerializeToString, + experiment__service__pb2.SaveNotebookResponse.FromString, options, channel_credentials, insecure, @@ -1160,8 +1251,8 @@ def GenerateNotebookCode(request, request, target, '/ExperimentService/GenerateNotebookCode', - weightslab_dot_proto_dot_experiment__service__pb2.GenerateNotebookCodeRequest.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.GenerateNotebookCodeResponse.FromString, + experiment__service__pb2.GenerateNotebookCodeRequest.SerializeToString, + experiment__service__pb2.GenerateNotebookCodeResponse.FromString, options, channel_credentials, insecure, @@ -1187,8 +1278,8 @@ def RestoreCheckpoint(request, request, target, '/ExperimentService/RestoreCheckpoint', - weightslab_dot_proto_dot_experiment__service__pb2.RestoreCheckpointRequest.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.RestoreCheckpointResponse.FromString, + experiment__service__pb2.RestoreCheckpointRequest.SerializeToString, + experiment__service__pb2.RestoreCheckpointResponse.FromString, options, channel_credentials, insecure, @@ -1214,8 +1305,8 @@ def TriggerEvaluation(request, request, target, '/ExperimentService/TriggerEvaluation', - weightslab_dot_proto_dot_experiment__service__pb2.TriggerEvaluationRequest.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.TriggerEvaluationResponse.FromString, + experiment__service__pb2.TriggerEvaluationRequest.SerializeToString, + experiment__service__pb2.TriggerEvaluationResponse.FromString, options, channel_credentials, insecure, @@ -1241,8 +1332,8 @@ def GetEvaluationStatus(request, request, target, '/ExperimentService/GetEvaluationStatus', - weightslab_dot_proto_dot_experiment__service__pb2.GetEvaluationStatusRequest.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.GetEvaluationStatusResponse.FromString, + experiment__service__pb2.GetEvaluationStatusRequest.SerializeToString, + experiment__service__pb2.GetEvaluationStatusResponse.FromString, options, channel_credentials, insecure, @@ -1268,8 +1359,8 @@ def CancelEvaluation(request, request, target, '/ExperimentService/CancelEvaluation', - weightslab_dot_proto_dot_experiment__service__pb2.CancelEvaluationRequest.SerializeToString, - weightslab_dot_proto_dot_experiment__service__pb2.CancelEvaluationResponse.FromString, + experiment__service__pb2.CancelEvaluationRequest.SerializeToString, + experiment__service__pb2.CancelEvaluationResponse.FromString, options, channel_credentials, insecure, diff --git a/weightslab/trainer/services/agent/agent.py b/weightslab/trainer/services/agent/agent.py index 8866d580..3dac1912 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,16 @@ 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" + self.opencode_url = os.environ.get("OPENCODE_URL", "http://127.0.0.1:4096") + self.opencode_model = os.environ.get("OPENCODE_MODEL", "") repo_root = Path(__file__).resolve().parents[4] # weightslab/ root inner_pkg = Path(__file__).resolve().parents[3] @@ -783,23 +748,9 @@ 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 + self.opencode_url = a_cfg.get("opencode_url", self.opencode_url) + 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 +764,67 @@ 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 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.") + self.chain_opencode = OpenCodeChat(self.opencode_url, self.opencode_model).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}" - - 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." + _LOGGER.error(f"OpenCode error: {e}") - 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 +832,17 @@ 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 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 +850,77 @@ 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 = "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_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, "" + 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 error: %s", exc) - return False, [], f"Could not fetch models: {exc}" + _LOGGER.warning("get_available_models (opencode) error: %s", exc) + return False, [], f"Could not reach the OpenCode server: {exc}" 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_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 +1530,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 +1590,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 +1668,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 +1686,18 @@ 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 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 +1770,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 +1791,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 +1814,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 +1833,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/opencode_chat.py b/weightslab/trainer/services/agent/opencode_chat.py new file mode 100644 index 00000000..80904a29 --- /dev/null +++ b/weightslab/trainer/services/agent/opencode_chat.py @@ -0,0 +1,229 @@ +"""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. +""" + +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") + + +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): + self.base_url = (base_url or "http://127.0.0.1:4096").rstrip("/") + self.model = model + self.timeout = timeout + + # -- 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) -> 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"])) + 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() + + 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) + 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] + + return "".join(text_parts[key] for key in text_parts) + + # -- public surface --------------------------------------------------- # + + def _call(self, prompt_value): + from langchain_core.messages import AIMessage + + 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_service.py b/weightslab/trainer/services/agent_service.py index e39998b0..6d9ab191 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,33 @@ 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) diff --git a/weightslab/trainer/services/data_service.py b/weightslab/trainer/services/data_service.py index 64192cfd..f233e3ac 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 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..4a5d4bc4 100644 --- a/weightslab/ui/server.py +++ b/weightslab/ui/server.py @@ -36,6 +36,7 @@ import subprocess import sys import threading +import time import webbrowser from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -342,6 +343,556 @@ def _kill_process_tree(process: "subprocess.Popen") -> None: 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.""" + 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} + + 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): + 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) + + +# --------------------------------------------------------------------------- # +# /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 = 60.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 local CLI verb +# (weightslab.cli:main, backed by weightslab/backend/cli.py's localhost TCP +# command server to the live training process) -- no new bridge/tool is built +# for this. 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 +# invoke these directly. +_LOOP_SYSTEM_PREAMBLE = ( + "You are a recurring monitoring agent for a live WeightsLab training run, " + "checking in periodically. Your workspace is the experiment directory; you " + "have bash, read, write, edit, and patch tools rooted there.\n\n" + "A local `weightslab` CLI is already available via bash for controlling " + "the live training run, not just editing files:\n" + " - `weightslab pause` / `weightslab resume` -- freeze/resume weight " + "updates (a safe pause; skips the optimizer step, does not kill the process)\n" + " - `weightslab discard ` -- discard one sample by id\n" + " - `weightslab agent query \"\"` -- ask the data-" + "manipulation agent to do something in plain English, e.g. " + "`weightslab agent query \"discard samples where loss > 5\"` or " + "`weightslab agent query \"tag mislabeled samples with tag:review\"` -- " + "this goes through the normal LLM-driven intent pipeline, not just exact ids\n" + " - `weightslab status` -- a snapshot of hyperparameters/model/training state\n\n" + "You may also read and edit training code directly (e.g. fix a broken " + "signal function) and, if training has crashed or stalled, attempt to " + "restart it via bash -- this is best-effort: look for the process " + "(`ps`/`pgrep`), stop it if still running, and re-launch the training " + "command if you can determine what it was from shell history, a run " + "script, or logs in this directory. There is no dedicated restart " + "command, so use your judgement and explain what you did.\n\n" + "Your monitoring task for this check-in:\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 _opencode_send_and_collect(base_url: str, session_id: str, text: str, timeout: float = 600.0) -> str: + """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: + _opencode_json_request( + base_url, f"/session/{session_id}/message", method="POST", + body={"parts": [{"type": "text", "text": text}]}, 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() + + try: + sender.start() + data_lines: list = [] + deadline = time.monotonic() + timeout + for raw_line in stream: + if time.monotonic() > deadline: + 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 in ("session.idle", "session.error") 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: + raise send_errors[0] + + return "".join(text_parts[key] for key in text_parts) + + +class _LoopJob: + def __init__(self, job_id: str, prompt: str, interval_seconds: float, workspace: str) -> None: + self.id = job_id + self.prompt = prompt + self.interval_seconds = interval_seconds + self.workspace = workspace + 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 + + +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]) -> 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"] + + 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 \").", + } + job_id = str(self._next_id) + self._next_id += 1 + job = _LoopJob(job_id, prompt, interval_seconds, workspace_dir) + job.base_url = base_url + 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 _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 + + try: + if job.session_id is None: + created = _opencode_json_request( + base_url, "/session", method="POST", + body={"title": f"weightslab-loop-{job_id}"}, + ) + job.session_id = created["id"] + text = _LOOP_SYSTEM_PREAMBLE.format(prompt=job.prompt) + else: + text = job.prompt + result = _opencode_send_and_collect(base_url, job.session_id, text) + with self._lock: + if job_id in self._jobs: + job.last_result = result + job.last_error = None + 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.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, + } + 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) + + # --------------------------------------------------------------------------- # # Request handler # --------------------------------------------------------------------------- # @@ -391,6 +942,12 @@ 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 == "/local-notebook/list": self._list_local_notebooks() return @@ -409,6 +966,16 @@ 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.startswith(self.api_prefix + "/") or path == self.api_prefix: self._proxy_grpc_web(path) else: @@ -524,6 +1091,96 @@ 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) + 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}" + + result = _loop_registry.start(prompt, interval_minutes * 60.0, workspace, origin) + 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 _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 +1436,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")), ] From 4406830e04051afd474e9c3c81c4dfb617b493a4 Mon Sep 17 00:00:00 2001 From: GuillaumePELLUET Date: Fri, 7 Aug 2026 17:10:50 +0200 Subject: [PATCH 02/10] Give each /loop job its own OpenCode session created up front (not lazily on the first tick) and guarded by a lock, and add message/messages endpoints so a loop's monitoring chat can be read from and written to directly -- backing weights_studio's new per-loop chat tabs, where a running check-in and a manual message must never race on the same session. Co-Authored-By: Claude Sonnet 5 --- AGENTS.md | 280 -------------- pyproject.toml | 5 + .../services/test_agent_opencode_provider.py | 89 +++++ .../services/test_agent_service_unit.py | 33 ++ tests/trainer/services/test_opencode_chat.py | 58 ++- tests/ui/test_server_agent.py | 294 ++++++++++++++ weightslab/AGENTS.md | 362 ++++++++++++++++++ weightslab/proto/experiment_service.proto | 19 + weightslab/proto/experiment_service_pb2.py | 100 ++--- .../proto/experiment_service_pb2_grpc.py | 49 +++ weightslab/trainer/services/agent/agent.py | 75 +++- .../trainer/services/agent/opencode_chat.py | 36 +- weightslab/trainer/services/agent_service.py | 26 ++ weightslab/ui/server.py | 263 ++++++++++++- 14 files changed, 1339 insertions(+), 350 deletions(-) delete mode 100644 AGENTS.md create mode 100644 weightslab/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 9629fab8..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,280 +0,0 @@ -# WeightsLab — agent context for users & debugging - -This file is a **portable context for AI coding agents** (Claude Code, etc.) and -the humans driving them. Its job is to let you — or an agent helping you — -**install, configure, run, and debug WeightsLab and Weights Studio** without -having to reverse-engineer the system first. - -It deliberately covers only the two shipped repositories: - -- **weightslab** — the Python backend / core (training instrumentation, data - ledger, gRPC service, the shared proto). -- **weights_studio** — the browser frontend (the studio UI that inspects and - edits a *running* experiment). - -> File/line references drift as the code evolves — treat them as starting points -> and verify against the current source before relying on them. Environment -> variable names and defaults are the most stable thing here; when in doubt the -> authoritative reference is `weightslab/docs/configuration.rst`. - ---- - -## 0. How to load this guide into Claude Code - -So an agent actually *has* this context when you ask it for help: - -- **Working inside a checkout of the repo** (`git clone`): this guide is - committed as `AGENTS.md`; the repo keeps a gitignored `CLAUDE.md` copy of it at - the root so Claude Code auto-loads it every session. Nothing to do. (Claude - Code also loads `~/.claude/CLAUDE.md` global memory and any parent-dir - `CLAUDE.md`.) -- **You only ran `pip install weightslab`** (no checkout — the package lives in - `site-packages`): absolute `@import` paths are fragile because the path - changes per venv/OS. The robust pattern is a small **skill** that locates the - installed file at runtime. Create `~/.claude/skills/weightslab/SKILL.md`: - - ```yaml - --- - name: weightslab - description: Load the WeightsLab debugging & configuration guide when helping with weightslab or weights_studio problems (connection, TLS, env vars, training hangs, rendering). - --- - !`python -c "import weightslab, os; print(open(os.path.join(os.path.dirname(weightslab.__file__), 'AGENTS.md')).read())"` - - Use the guide above to diagnose the user's weightslab / weights_studio issue. - ``` - - Then run `/weightslab` (or let Claude auto-invoke it). This requires the guide - to be **shipped as package data** inside the installed package (see §7); the - copy at the repo root is for contributors working in a checkout. -- **Quick-and-dirty:** copy this file to `~/.claude/WEIGHTSLAB.md` and add - `@~/.claude/WEIGHTSLAB.md` to your `~/.claude/CLAUDE.md`. - ---- - -## 1. What it is and how the pieces connect - -A user wraps their own PyTorch training script with WeightsLab so a running -experiment becomes inspectable/editable; Weights Studio is the UI for that. - -**Wire path (the thing that breaks most often):** - -``` -Browser → weightslab start :8080 (grpc-web → grpc proxy) → Python gRPC servicer → training loop -``` - -- `weightslab start` is a pure-Python HTTP server that serves the bundled SPA - and translates grpc-web (browser) to raw gRPC (backend). No Docker, no Envoy. - If `weightslab start` is not running, the browser has no UI to load. -- The gRPC servicer and the training loop run in the **same process, different - threads**, coordinated by locks in - `weightslab/weightslab/components/global_monitoring.py`. -- One proto is the single source of truth: - `weightslab/weightslab/proto/experiment_service.proto`. - ---- - -## 2. Install & run (the happy path) - -```bash -pip install weightslab -``` - -In your training script: - -```python -import weightslab as wl -# wrap your objects so the studio can see/edit them (see §3), then: -wl.serve(serving_grpc=True, serving_cli=True) # background threads, same process -# ... your training loop ... -wl.keep_serving() # keep the process alive for the UI -``` - -Then start the UI in another terminal and open it in a browser: - -```bash -weightslab start # serves at http://localhost:8080 by default -``` - -Working starting points live in -`weightslab/weightslab/examples/{PyTorch,Lightning,Usecases}//` -(each is a `main.py` + `config.yaml`) — find the closest example and mirror it. - -UI deployment details (port, TLS, certs) are documented in -`weightslab/docs/weights_studio.rst`. TLS is opt-in: run `weightslab se` once, -then `weightslab start --certs`. - ---- - -## 3. The integration API (`import weightslab as wl`) - -How a user's script plugs in. Wrap each training object with -`wl.watch_or_edit(obj, flag=...)`; the returned tracked proxy is registered in -the global ledger (`weightslab/weightslab/backend/ledgers.py`, -`GLOBAL_LEDGER` — the hub everything reads/mutates through). - -- `flag="hyperparameters"` (dict), `flag="model"` (nn.Module, `device=…`), - `flag="optimizer"`, `flag="data"` (Dataset → tracked DataLoader: `loader_name`, - `batch_size`, `is_training`, `collate_fn`, …), `flag="loss"` (a - `reduction="none"` criterion, called with `(preds_raw, targets, batch_ids=ids, - preds=preds)`), `flag="metric"`. - -Conventions that matter for correctness: - -- Wrap the train step in `with guard_training_context:` and eval in - `with guard_testing_context:` (from - `weightslab.components.global_monitoring`). This is how pause/resume and - train/test separation work — **skip it and pause/resume or stats will misbehave.** -- Use `model.get_age()` (steps actually trained; survives checkpoint reloads), - not the raw loop counter. -- `task_type` on the dataset/model selects rendering: `classification`, - `segmentation`, `detection`, `detection_pointcloud`. -- **Hyperparameter handle access:** the registered hyperparameters proxy - supports both `hp.get("lr")` and `hp["lr"]` (subscript == `.get`), and stays - live — reads reflect in-place updates and re-registration. - ---- - -## 4. Configuration (environment variables) - -WeightsLab and Weights Studio are configured almost entirely through env vars. -**Authoritative reference: `weightslab/docs/configuration.rst`.** The high-signal -ones when debugging: - -**Backend (Python):** - -| Variable | Default | Why you touch it | -|---|---|---| -| `WEIGHTSLAB_LOG_LEVEL` | `INFO` | Set `DEBUG` to see what's happening. (`WATCHDOG` level sits between WARNING/ERROR.) | -| `GRPC_BACKEND_HOST` / `GRPC_BACKEND_PORT` | `0.0.0.0` / `50051` | Backend gRPC bind address. | -| `GRPC_TLS_ENABLED` | `0` | TLS on the gRPC socket. Set `1` with `weightslab start --certs`. | -| `GRPC_TLS_REQUIRE_CLIENT_AUTH` | `0` | mTLS. Must match what `weightslab start --certs` presents. | -| `WEIGHTSLAB_CERTS_DIR` | `~/.weightslab-certs` | Where cert files are looked up (single source of truth). | -| `GRPC_AUTH_TOKEN` | *(unset)* | Optional metadata-token auth on top of mTLS. | -| `GRPC_MAX_MESSAGE_BYTES` | `268435456` (256 MB) | Raise it if large tensors/image batches fail. | -| `WEIGHTSLAB_DISABLE_WATCHDOGS` | `0` | Set `1` when debugging with breakpoints (see §5). | -| `GRPC_WATCHDOG_STUCK_SECONDS` | `60` | Lock/RPC stuck threshold + lock-acquire timeout. | - -**Frontend (Weights Studio) — runtime-injected `window.*` globals:** - -| Variable | Default | Why you touch it | -|---|---|---| -| `WS_SERVER_HOST` / `WS_SERVER_PORT` / `WS_SERVER_PROTOCOL` | `localhost` / `8080` / `http` | How the browser reaches the `weightslab start` server. The #1 connection-issue knob. | -| `WS_HISTOGRAM_MAX_BINS` | `512` | Cap on metadata histogram bars. | -| `BB_THUMB_RENDER` | `10` | Max bounding boxes drawn per **thumbnail**, per overlay (GT and PRED capped independently). | -| `BB_MODAL_RENDER` | `100` | Max bounding boxes drawn per **modal** image, per overlay. A `?` button in the modal shows the active limit. | -| `ENABLE_PLOTS` | `1` | `0`/`false` removes the plots board + Signals card and stops plot auto-refresh. | -| `ENABLE_DATA_EXPLORATION` | `1` | `0`/`false` removes the data grid + metadata/details panel and stops the data/metadata auto-refresh. | -| `ENABLE_HYPERPARAMETERS_OPTIMIZATION` | `1` | `0`/`false` removes the Hyperparameters section, makes HP inputs read-only, and stops the HP poll. | -| `ENABLE_AGENT` | `1` | `0`/`false` removes the agent chat bar + history panel and stops the agent health poll. | -| `ENABLE_NOTEBOOK` | `1` | `0`/`false` removes the notebook button (left of the logo) + notebook window. The notebook runs Python in a shared in-process kernel against the live experiment (`df`, model, checkpoints), persisted as `notebook.ipynb` under `root_log_dir`; `>`-prefixed cells ask the agent to propose code. | - -> **VITE_ vs WS_/BB_/ENABLE_:** `VITE_*` variables are baked at **build time** -> (changing them needs a frontend rebuild). `WS_*` / `BB_*` / `ENABLE_*` are -> injected into `config.js` at `weightslab start` time and read as `window.*` -> globals — changing them needs only a restart + browser reload. Each `ENABLE_*` -> defaults to on; set it to `0`/`false`/`no`/`off` to disable. Full reference: -> `weightslab/docs/configuration.rst` (“Feature toggles”). - ---- - -## 5. Troubleshooting — symptom → cause → fix - -This is the core of the guide. Each entry is a real failure mode (several are -distilled from issues hit in development). - -**UI loads but the sample grid is empty / "failed to fetch" / gRPC errors.** -The wire path (§1) is broken somewhere. Check in order: (1) backend actually -serving on `0.0.0.0:50051`; (2) `weightslab start` is running and the browser -can reach it on `:8080`; (3) **TLS mismatch** if using `--certs` — run -`weightslab se` first and export `WEIGHTSLAB_CERTS_DIR`. For local debugging -drop TLS entirely (omit `--certs`; `GRPC_TLS_ENABLED=0`). - -**Changed an env var, restarted, but the UI still uses the old value.** -- `VITE_*` is build-time → you must **rebuild** the frontend, not just restart. -- `WS_*` / `BB_*` / `ENABLE_*` are injected at `weightslab start` time → you - must **restart `weightslab start`** then reload the tab. - -**Sample grid flashes empty cells when auto-refresh fires.** -An auto-refresh (timer or manual) that lands while a `GetDataSamples` grid fetch -is still in flight used to clear the cache mid-render. The fix in -`weights_studio/src/grid_data/gridDataManager.ts` is `isFetchInProgress()`: -refreshes are skipped while a grid fetch is ongoing. If you see this, confirm -you're on a build that has that guard. - -**Detection overlays are slow or unreadably cluttered.** -Dense detection samples can carry hundreds of boxes. Cap rendering with -`BB_THUMB_RENDER` (thumbnails) and `BB_MODAL_RENDER` (modal); each is applied -separately to GT and to predictions. Render-only — no sample data is dropped. - -**Training appears hung; RPCs return `RESOURCE_EXHAUSTED`; server "restarts".** -A watchdog monitors the global rlock and in-flight RPCs. If a lock/RPC is held -longer than `GRPC_WATCHDOG_STUCK_SECONDS` (60s) it's flagged; locks get -interrupted, and after `GRPC_WATCHDOG_RESTART_THRESHOLD` unhealthy polls the -gRPC server restarts. When **debugging with breakpoints** that intentionally -pause longer than that, set `WEIGHTSLAB_DISABLE_WATCHDOGS=1`. If RPCs fail with -`RESOURCE_EXHAUSTED`, a handler couldn't acquire the lock within the window — -something else is holding it; check for a long/blocking train or eval step. - -**Pause/resume doesn't work, or train vs test stats are mixed up.** -The train step isn't wrapped in `guard_training_context` (or eval in -`guard_testing_context`). See §3 — these context managers are how the system -gates and separates phases. - -**Large weights/images fail to transfer.** Raise `GRPC_MAX_MESSAGE_BYTES`. - -**The agent bar says it's unconfigured.** The LLM agent is backed entirely by -a local **OpenCode** server (`OPENCODE_URL`, default `http://127.0.0.1:4096`) -— WeightsLab starts one for you on first use. Initialize from the UI via -`/init` (then `/model` to switch, `/reset` to clear). See -`weightslab/docs/agent.rst` and `weightslab/docs/weights_studio.rst`. - ---- - -## 6. Where things live (for deeper digging) - -**Backend (`weightslab/weightslab/`):** -- `src.py` — the public verbs (`watch_or_edit`, `serve`, `keep_serving`, - `tag_samples`, `query_*`, decorators) re-exported from `__init__.py`. -- `trainer/services/` — `experiment_service.py` (gRPC servicer) delegating to - `{model,data,agent}_service.py`; `data_image_utils.py` (preview/mask encoding). -- `components/` — `global_monitoring.py` (locks, `guard_*` contexts, pause), - `checkpoint_manager.py`, `evaluation_controller.py`. -- `data/` — `dataframe_manager.py`, `data_samples_with_ops.py`, `sample_stats.py`, - H5 storage (`h5_dataframe_store.py`, `h5_array_store.py`, `array_proxy.py`). -- `backend/` — `ledgers.py` (`GLOBAL_LEDGER`), `logger.py`, `audit_logger.py`, `cli.py`. -- `security/` (`CertAuthManager`), `proto/`, `examples/`, `docs/`. - -**Frontend (`weights_studio/src/`):** -- `main.ts` — bootstrap; builds the grpc-web transport from `WS_SERVER_*`. -- `experiment_service.client.ts` / `experiment_service.ts` — generated client - (regenerate with `npm run generate-proto:data`; do not hand-edit). -- `grid_data/` — grid + modal rendering (`GridCell.ts`, `DataImageService.ts`, - `gridDataManager.ts`, `BboxRenderer.ts`, `SegmentationRenderer.ts`, - `PointCloudViewer.ts`). -- `ui/` — `server.py` (pure-Python HTTP + gRPC-Web proxy), `static/` (bundled SPA), - `utils/` (cert-generation scripts, sync-frontend helper). - -**Docs:** `weightslab/docs/` (Sphinx) — `configuration.rst` (all env vars), -`weights_studio.rst` (studio deploy + agent), `quickstart.rst`, `grpc/`. - ---- - -## 7. For contributors (working in a checkout) - -- **The two repos must sit side by side** (`…/weightslab`, `…/weights_studio`); - proto codegen scripts reach across by relative path. -- **Editing the proto is cross-repo** — do all of: edit - `experiment_service.proto`; regenerate Python stubs from the repo root; run - `npm run generate-proto:data` in weights_studio. Editing one side only leaves - the build broken. -- **Tests:** backend `python -m pytest weightslab/tests/...`; frontend unit - `npm run test` (vitest); E2E/user-simulation Playwright lives in - **weights_studio** (`test:realtime:*`, `test:e2e:*`), not here. -- **CI on a custom branch:** pushes to non-`main`/`dev` branches only run CI when - the commit message contains `[force ci]` (both repos). -- **TLS/auth in the bundled UI** is decided by cert presence under - `WEIGHTSLAB_CERTS_DIR` (single source of truth) — don't hardcode secure/insecure. -- **To make this guide available to pip users**, ship it as package data inside - the installed package (e.g. as `weightslab/weightslab/AGENTS.md`) so the §0 - skill can locate it; keep the root `AGENTS.md` (mirrored as the gitignored - `CLAUDE.md`) as the contributor-facing source. diff --git a/pyproject.toml b/pyproject.toml index 3a741a79..c4f99f08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -154,6 +154,11 @@ 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 server.py's + # _ensure_workspace_agents_md) -- lives inside the package, not just at the + # repo root, so a `pip install weightslab` ships it too. + "AGENTS.md", ] [tool.setuptools.exclude-package-data] diff --git a/tests/trainer/services/test_agent_opencode_provider.py b/tests/trainer/services/test_agent_opencode_provider.py index d1fd4bff..e7c8f403 100644 --- a/tests/trainer/services/test_agent_opencode_provider.py +++ b/tests/trainer/services/test_agent_opencode_provider.py @@ -11,6 +11,7 @@ """ import importlib +import json import sys import types import unittest @@ -206,6 +207,94 @@ def test_reports_failure_when_opencode_server_unreachable(self): self.assertIn("OpenCode", message) +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(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(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() diff --git a/tests/trainer/services/test_agent_service_unit.py b/tests/trainer/services/test_agent_service_unit.py index 36b8f5aa..c3d394ed 100644 --- a/tests/trainer/services/test_agent_service_unit.py +++ b/tests/trainer/services/test_agent_service_unit.py @@ -147,6 +147,39 @@ def test_clear_and_compact_history_fail_cleanly_when_agent_backend_missing(self) 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 index 52840b2c..e16ccb04 100644 --- a/tests/trainer/services/test_opencode_chat.py +++ b/tests/trainer/services/test_opencode_chat.py @@ -86,7 +86,10 @@ def emit(event: dict) -> None: while not self.server.recorded_messages and time.monotonic() < deadline: time.sleep(0.01) - emit({"type": "message.updated", "properties": {"info": {"id": "msg_1", "role": "assistant", "sessionID": session_id}}}) + 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, @@ -103,19 +106,21 @@ def emit(event: dict) -> None: class _FakeOpenCodeServer(ThreadingHTTPServer): daemon_threads = True - def __init__(self, *args, reply_deltas, emit_error=False, **kwargs): + 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): + 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, + ("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) @@ -194,6 +199,29 @@ def test_raises_opencode_error_when_the_server_is_unreachable(self): 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): @@ -245,6 +273,28 @@ def test_session_idle_signals_completion(self): ) 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) + if __name__ == "__main__": unittest.main() diff --git a/tests/ui/test_server_agent.py b/tests/ui/test_server_agent.py index 1d40e35a..6fe89ad4 100644 --- a/tests/ui/test_server_agent.py +++ b/tests/ui/test_server_agent.py @@ -129,6 +129,67 @@ def test_shutdown_stops_a_running_process(self): 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_agents_md_exists(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, "_repo_doc_path", 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) + self.assertFalse(os.path.isfile(os.path.join(self.tmp, "AGENTS.md"))) + + +class TestEnsureWorkspaceAgentsMd(unittest.TestCase): + """_ensure_workspace_agents_md directly -- no OpenCode process involved.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp() + + def test_copies_from_the_installed_package_location(self): + ui_server._ensure_workspace_agents_md(self.tmp) + target = os.path.join(self.tmp, "AGENTS.md") + self.assertTrue(os.path.isfile(target)) + with open(target, encoding="utf-8") as fh: + content = fh.read() + self.assertEqual(content, ui_server._read_repo_doc("AGENTS.md")) + + def test_is_a_no_op_when_the_workspace_already_has_one(self): + target = os.path.join(self.tmp, "AGENTS.md") + with open(target, "w", encoding="utf-8") as fh: + fh.write("mine") + ui_server._ensure_workspace_agents_md(self.tmp) + with open(target, encoding="utf-8") as fh: + self.assertEqual(fh.read(), "mine") + + def test_does_nothing_when_no_source_is_found(self): + with patch.object(ui_server, "_repo_doc_path", return_value=None): + ui_server._ensure_workspace_agents_md(self.tmp) + self.assertFalse(os.path.isfile(os.path.join(self.tmp, "AGENTS.md"))) + class TestCorsOriginVariants(unittest.TestCase): """The localhost <-> 127.0.0.1 expansion is the #1 way this feature goes @@ -197,6 +258,14 @@ def _post(self, path, origin=None): req.add_header("Origin", origin) return urllib.request.urlopen(req, timeout=10) + def _post_json(self, path, body): + req = urllib.request.Request( + f"http://127.0.0.1:{self.port}{path}", method="POST", + data=json.dumps(body).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + return urllib.request.urlopen(req, timeout=10) + class TestAgentServerEndpoint(_ServerTestCase): @@ -234,5 +303,230 @@ def test_falls_back_to_reconstructing_origin_from_host_header(self): 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, timeout=600.0): # noqa: ARG001 + sent_texts.append(text) + return "tick result" + + 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_fire_skips_the_tick_when_a_manual_message_is_in_flight(self): + job = ui_server._LoopJob("1", "check the loss", 60.0, "/tmp") + job.session_id, job.base_url = "sess-1", "http://fake" + job.preamble_sent = True # isolate the busy-skip path from preamble logic + self.registry._jobs["1"] = job + + job.lock.acquire() # simulate a manual send from the loop's chat tab + try: + with patch.object(ui_server, "_opencode_send_and_collect") as mock_send: + self.registry._fire("1", "http://fake") + mock_send.assert_not_called() + finally: + job.lock.release() + + self.assertIn("already in progress", job.last_error) + self.assertTrue(job.preamble_sent) # untouched -- the send never ran + self.assertIsNotNone(job.next_run_at) # rescheduled despite the skip + job.timer.cancel() + + def test_send_message_rejects_empty_text(self): + job = ui_server._LoopJob("1", "p", 60.0, "/tmp") + self.registry._jobs["1"] = job + result = self.registry.send_message("1", " ") + self.assertFalse(result["ok"]) + + def test_send_message_unknown_job(self): + result = self.registry.send_message("nope", "hi") + self.assertFalse(result["ok"]) + self.assertIn("No loop job", result["error"]) + + def test_send_message_success_updates_last_result(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 + with patch.object(ui_server, "_opencode_send_and_collect", return_value="the reply"): + result = self.registry.send_message("1", "hello") + self.assertTrue(result["ok"], result) + self.assertEqual(result["result"], "the reply") + self.assertEqual(job.last_result, "the reply") + + 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 TestLoopMessageEndpoints(_ServerTestCase): + """GET/POST /agent-server/loop//messages|message -- a loop tab's + scrollback + manual-send, 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) + + def test_post_message_sends_and_returns_the_result(self): + self._seed_job() + with patch.object(ui_server, "_opencode_send_and_collect", return_value="the reply"): + with self._post_json("/agent-server/loop/1/message", {"text": "hello"}) as r: + data = json.loads(r.read().decode()) + self.assertTrue(data["ok"], data) + self.assertEqual(data["result"], "the reply") + + def test_post_message_rejects_empty_text(self): + self._seed_job() + with self.assertRaises(urllib.error.HTTPError) as ctx: + self._post_json("/agent-server/loop/1/message", {"text": ""}) + self.assertEqual(ctx.exception.code, 400) + + if __name__ == "__main__": unittest.main() diff --git a/weightslab/AGENTS.md b/weightslab/AGENTS.md new file mode 100644 index 00000000..57ed52cf --- /dev/null +++ b/weightslab/AGENTS.md @@ -0,0 +1,362 @@ +# 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). + +### 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