From b99e1522e1afa866bcc1663ec562123185d36455 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:50:35 +0200 Subject: [PATCH 1/3] feat(agents): parallel chat windows, a per-user cap on sandbox runs Owner decision of 2026-10-21: the admin agent chat's browser windows work in parallel, and only sandbox work queues. - Every exclusion in the agents service is per session now. `_active` checks the session's registry entry (`Runtime.active`) and, unless the route passes `paste=False` (the language chip), the session's paste in flight; `Runtime.pasting` and `Runtime.converting` hold session ids, not user ids. `_data_route_free` folds into `_active`. A turn, a paste, a theme toggle and the data routes still exclude each other within one session, and the user's other sessions never block them. - `RunQueue.submit` refuses a user's entry past `AGENT_USER_MAX_RUNS` (default 3, waiting and running together, across sessions) with the new `QueueUserLimit`, checked before the queue's length, so a user at the cap learns that from their own runs. `TurnSlot.take` fails the turn with `run_limit` (stream `error{code:"run_limit"}`), `run_data_code` answers the same code, and the `chat_turn` telemetry's `ErrorCode` gains `run_limit`. The queue's capacity and order are unchanged. - BFF: `run_limit` joins the pass-through allowlist of stream error codes. - Page: `run_limit` has its own text ("you already have 3 plots or data runs in progress in other windows; ..."), and the `run_active` texts now name this window. Decisions: - Why the cross-session paste exclusion existed: a paste holds the view it read before its judges ran and writes the next dataset from it, so a version stored meanwhile would be lost. That hazard is per session (the stores are keyed by session id, no ADK state is user-scoped), and the guards are keyed by session id, so they hold whoever sends the request, also two tabs sharing one id. The page opens one session per window and keeps its id in memory only, so its tabs never share one. - Conversions: one per session; each waits for the render slot at most `AGENT_CONVERT_SLOT_WAIT_S`, and the per-user conversion counter still bounds them. - Budgets under parallel turns (comment in `post_message`): message, paste, conversion and pipeline-run counts are checked and booked with no suspending await between, so they never overshoot. The daily token budget is checked before every model call and booked after, so parallel turns overshoot it by the calls in flight. Daily data runs are checked before the queue wait and counted after it, so they can overshoot by at most `AGENT_USER_MAX_RUNS` - 1. - `drop_stale`, `finish` and the idle sweep work per registry entry and release each entry's own ticket, so several entries per user need no change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M --- agents/README.md | 3 +- agents/anyplot/run_queue.py | 34 +++- agents/anyplot/settings.py | 7 + agents/anyplot/telemetry.py | 5 +- agents/anyplot/theme_render.py | 5 +- agents/anyplot/tools/session.py | 10 +- agents/main.py | 145 ++++++++++------- agents/stream.py | 2 +- api/routers/agent.py | 2 +- app/src/hooks/useAgentSession.test.ts | 18 +++ app/src/hooks/useAgentSession.ts | 5 +- app/src/lib/agent.test.ts | 8 + app/src/lib/agent.ts | 2 + .../sections/agent-chat/ChatThread.test.tsx | 26 ++++ .../sections/agent-chat/DataPanel.test.tsx | 4 +- app/src/sections/agent-chat/messages.ts | 8 +- changelog.d/agent-parallel-windows.md | 15 ++ docs/concepts/agent-network.md | 19 +-- docs/reference/api.md | 21 ++- .../unit/agents/runtime/test_convert_route.py | 39 ++++- .../agents/runtime/test_data_code_tool.py | 17 ++ tests/unit/agents/runtime/test_run_queue.py | 53 +++++++ .../agents/runtime/test_run_queue_flow.py | 147 ++++++++++++++++-- .../unit/agents/runtime/test_service_flow.py | 23 ++- tests/unit/agents/runtime/test_turn_limits.py | 43 +++-- tests/unit/agents/test_settings.py | 1 + tests/unit/api/test_agent_router.py | 8 + 27 files changed, 547 insertions(+), 123 deletions(-) create mode 100644 changelog.d/agent-parallel-windows.md diff --git a/agents/README.md b/agents/README.md index d7701e29ba..dd0150aaa0 100644 --- a/agents/README.md +++ b/agents/README.md @@ -15,7 +15,7 @@ The model is **Claude Haiku 5.5 on Vertex AI** (`claude-haiku-5-5`) by default. | `main.py` | The `anyplot-agents` FastAPI service: the `/v1` routes the BFF (`api/routers/agent.py`) calls, the caller check, the run registry behind `409 run_active`, in-memory session and artifact services, and the idle sweeper | | `stream.py` | The `anyplot/1` stream translator: ADK events in, sanitised `ready`, `status` (including `queued`), `message`, `plot`, `refusal`, `error` and `done` events out | | `Dockerfile`, `cloudbuild.yaml` | The `anyplot-agents` image (the `agents` extra, the source tree with its prompts and fonts, uid 1000) and its deploy: build, a `candidate` revision without traffic, the IAM and `/v1/status` smoke, promote. The config's header lists the owner's bootstrap steps | -| `anyplot/run_queue.py` | The run queue in front of the sandbox runs (since 2026-10-14 a chat turn starts at once, and only its `plot_pipeline` or `run_data_code` call waits, mid-turn): one run in flight, one start a minute, a 600-second maximum wait, a `premium` lane that nothing sets yet | +| `anyplot/run_queue.py` | The run queue in front of the sandbox runs (since 2026-10-14 a chat turn starts at once, and only its `plot_pipeline` or `run_data_code` call waits, mid-turn): one run in flight, one start a minute, a 600-second maximum wait, at most three runs per user across sessions (since 2026-10-21 a user's browser windows work in parallel, and only their sandbox runs share the queue), a `premium` lane that nothing sets yet | | `anyplot/theme_render.py` | The theme toggle: renders another theme of a finished version from its stored run form, behind waiting pipeline renders, with no model call | | `anyplot/agent.py` | The root agent `anyplot` with its session block (`session_context`, appended to the system instruction by `add_session_context`, never to the user's turn), the `ALL_AGENTS` registry and `app` (the ADK `App` with its plugins), which `adk web` loads | | `anyplot/models.py` | The only place that builds a model or a model client: `make_model`, `make_content_config` and `make_judge_client`, for Claude on Vertex AI and for Gemini | @@ -85,6 +85,7 @@ Three rules hold for everything here: | `AGENT_RUN_CONCURRENCY` | `1` | Sandbox runs in flight (a turn's `plot_pipeline` or `run_data_code` call, holding its entry until the turn ends); the run queue holds the rest. A chat turn without one never enters the queue | | `AGENT_RUNS_PER_MINUTE` | `1` | Sandbox runs that may start within any 60 seconds (a sliding window), one per turn | | `AGENT_QUEUE_MAX_WAIT_S` | `600` | Longest wait in the run queue, after which the turn ends with `error{code:"capacity"}`; the queue holds rate x wait / 60 entries (10), and a sandbox run beyond that ends its turn the same way. Through the BFF the whole turn, this wait included, ends by `AGENT_TURN_MAX_S` (1,190 s, see `docs/reference/api.md`), which the root's first call, the full wait and the 540 s run fit into | +| `AGENT_USER_MAX_RUNS` | `3` | Sandbox runs one user may have queued or running at the same time, across all their sessions (browser windows); the turn whose run would be one more ends with `error{code:"run_limit"}`, before the queue's length is checked. Everything else runs per session at once: a session answers `409 run_active` only to a second thing of its own | | `AGENT_USER_MESSAGES_PER_MINUTE` | `10` | Chat turns (typed messages and actions) per user within any 60 seconds; one more gets the fixed `rate_limited` refusal before the scope judge, so it costs no model call | | `AGENT_USER_MESSAGES_PER_DAY` | `200` | Chat turns per user and UTC day; one more gets the fixed `daily_limit` refusal before the scope judge | | `AGENT_SESSION_REFUSALS` | `3` | Scope-judge refusals of type `out_of_scope` or `attack` (never `needs_data`; `other_library` only with `AGENT_OTHER_LIBRARY_COUNTS`) after which a session is closed: that refusal and every later turn answer `session_closed`, a paste `403 session_closed`, and the user reloads the page for a new session | diff --git a/agents/anyplot/run_queue.py b/agents/anyplot/run_queue.py index 1a36d09079..daa37b9246 100644 --- a/agents/anyplot/run_queue.py +++ b/agents/anyplot/run_queue.py @@ -27,6 +27,16 @@ `capacity`. Admission therefore promises a place, not a start. A run holds its slot until its turn ends, the root's closing reply included. +**Per user.** A user may hold at most `AGENT_USER_MAX_RUNS` entries (default 3), +waiting and running together, across all their sessions (owner decision of +2026-10-21: each browser window is a session of its own, and a user's windows work in +parallel, so the cap bounds what one user puts into the shared queue). `submit` refuses +one more with `QueueUserLimit`, which the route ends as `error{code:"run_limit"}`, before +it looks at the queue's length: a user at the cap learns that from their own runs, not +from `capacity`. The cap is checked in `submit` because the queue already holds every +entry with its user; it changes neither the queue's capacity nor its order, and an +entry the cap refuses never took a place, a start or a position. + **Lanes.** An entry carries a lane: `premium` entries go before every `normal` entry, first come first served within a lane. Nothing sets `premium` yet; it is the lane for users who later pay for their own tokens. It bypasses the normal lane only: the @@ -74,6 +84,10 @@ class QueueFull(Exception): """The entry would wait longer than the maximum; nothing was queued.""" +class QueueUserLimit(Exception): + """The user already holds the most entries a user may hold, waiting or running; nothing was queued.""" + + class QueueTimeout(Exception): """The entry waited the maximum and left the queue without running.""" @@ -127,13 +141,18 @@ def __init__( max_wait_s: float, clock: Callable[[], float] = time.monotonic, window_s: float = RATE_WINDOW_S, + per_user: int | None = None, ) -> None: if concurrency < 1 or per_minute < 1 or max_wait_s <= 0 or window_s <= 0: raise ValueError("concurrency, rate, maximum wait and window must be positive") + if per_user is not None and per_user < 1: + raise ValueError("the per-user cap must be positive") self.concurrency = concurrency self.per_minute = per_minute self.max_wait_s = max_wait_s self.window_s = window_s + self.per_user = per_user + """Entries one user may hold, waiting and running (`AGENT_USER_MAX_RUNS`); None sets no cap.""" self._clock = clock self._waiting: list[Ticket] = [] self._running: list[Ticket] = [] @@ -147,6 +166,7 @@ def from_settings(cls, settings: AgentSettings, clock: Callable[[], float] = tim per_minute=settings.runs_per_minute, max_wait_s=settings.queue_max_wait_s, clock=clock, + per_user=settings.user_max_runs, ) def now(self) -> float: @@ -166,10 +186,20 @@ def waiting_count(self) -> int: def in_flight(self) -> int: return len(self._running) + def user_entries(self, user: str) -> int: + """The user's entries in the queue, waiting and running, in every session.""" + return sum(1 for entry in (*self._waiting, *self._running) if entry.user == user) + def submit(self, user: str, session_id: str, lane: Lane = "normal") -> Ticket: - """Queue a run, or start it at once; raises `QueueFull` when it would wait past the maximum.""" + """Queue a run, or start it at once. + + Raises `QueueUserLimit` when the user already holds `per_user` entries (checked first), and `QueueFull` + when the run would wait past the maximum. + """ now = self._clock() - self._pump(now) + self._pump(now) # an entry that expired just now no longer counts against its user + if self.per_user is not None and self.user_entries(user) >= self.per_user: + raise QueueUserLimit(f"the user already holds {self.per_user} entries") ticket = Ticket(user=user, session_id=session_id, lane=lane, seq=next(self._seq), enqueued_at=now) position = 1 + sum(1 for other in self._waiting if other.order < ticket.order) if position > self.capacity and not (position == 1 and self._can_start(now)): diff --git a/agents/anyplot/settings.py b/agents/anyplot/settings.py index 446874a76e..f8a2cc8b5d 100644 --- a/agents/anyplot/settings.py +++ b/agents/anyplot/settings.py @@ -39,6 +39,7 @@ | `AGENT_RUN_CONCURRENCY` | `1` | Sandbox runs (a `plot_pipeline` or `run_data_code` call, held until its turn ends) in flight per instance; the run queue holds the rest. A chat turn without one never enters the queue | | `AGENT_RUNS_PER_MINUTE` | `1` | Sandbox runs that may start within any 60 seconds (a sliding window); one start per turn | | `AGENT_QUEUE_MAX_WAIT_S` | `600` | Longest wait in the run queue; it also sizes the queue (`AGENT_RUNS_PER_MINUTE` x this / 60 entries) | +| `AGENT_USER_MAX_RUNS` | `3` | Sandbox runs one user may have queued or running at the same time, across all their sessions (browser windows); one more ends its turn with `error{code:"run_limit"}`. Everything else in a session (chat turns, pastes, data steps, the theme toggle, file conversions) runs at once, whatever the user's other sessions do | | `AGENT_USER_MESSAGES_PER_MINUTE` | `10` | Chat turns (typed messages and actions) per user within any 60 seconds; more get the `rate_limited` refusal before the scope judge runs | | `AGENT_USER_MESSAGES_PER_DAY` | `200` | Chat turns per user and UTC day; more get the `daily_limit` refusal before the scope judge runs | | `AGENT_SESSION_REFUSALS` | `3` | Scope-judge refusals (`out_of_scope` or `attack`, not `needs_data`, and `other_library` only with `AGENT_OTHER_LIBRARY_COUNTS`) after which the session is closed: the last of them and every later turn, and every paste, get the `session_closed` refusal | @@ -329,6 +330,12 @@ class AgentSettings(BaseSettings): entries and refuses more with `capacity`. That length assumes runs shorter than the 60 s rate window: longer runs delay later starts, so admission does not guarantee a start.""" + user_max_runs: PositiveInt = 3 + """Sandbox runs one user may have in the run queue at the same time, waiting and running together, across all + their sessions (`AGENT_USER_MAX_RUNS`). Owner decision of 2026-10-21: a user's browser windows work in parallel, + and only their sandbox runs share the queue; one more than this ends its turn with `error{code:"run_limit"}` + instead of `capacity`, so the page can say that the user's own runs fill it.""" + user_messages_per_minute: PositiveInt = 10 """Chat turns per user within any 60 seconds (`AGENT_USER_MESSAGES_PER_MINUTE`), typed messages and actions alike, counted in the usage book. Checked before the scope judge, so a flood costs no model call: since chat turns diff --git a/agents/anyplot/telemetry.py b/agents/anyplot/telemetry.py index da43b7b436..52bd3af6dc 100644 --- a/agents/anyplot/telemetry.py +++ b/agents/anyplot/telemetry.py @@ -253,8 +253,9 @@ def _library(value: str) -> str: "daily_limit", "other", ] -ErrorCode = Literal["guard_unavailable", "capacity", "deadline", "internal", "cancelled", "other"] -"""`cancelled`: the client closed the stream before the turn ended (the page's cancel, a reload, a lost connection).""" +ErrorCode = Literal["guard_unavailable", "capacity", "run_limit", "deadline", "internal", "cancelled", "other"] +"""`cancelled`: the client closed the stream before the turn ended (the page's cancel, a reload, a lost connection); +`run_limit`: the turn's sandbox run would have been one more than `AGENT_USER_MAX_RUNS` across the user's sessions.""" LanguageSource = Literal["default", "pasted", "detected", "chosen"] Step = Literal["root", "adapter", "reviewer", "judge", "other"] diff --git a/agents/anyplot/theme_render.py b/agents/anyplot/theme_render.py index bb5586d6d8..7297812a0b 100644 --- a/agents/anyplot/theme_render.py +++ b/agents/anyplot/theme_render.py @@ -21,8 +21,9 @@ before it raises `RenderBusy` (`503 capacity`), so wait plus render stay inside the request deadline, like a run. * **One at a time.** The route registers the toggle in the run registry, so a - session with a toggle in flight refuses `/messages` and a second toggle, and a user - has at most one run or toggle in flight (`409 run_active`). + session with a toggle in flight refuses `/messages` and a second toggle, and a session + has at most one run or toggle in flight (`409 run_active`); the user's other sessions + are not blocked. * **Records.** A theme the version already holds is answered from its record without a render. A `render` failure is recorded too: the same code and data fail the same way, so it is rendered again only while it failed fewer than `MAX_THEME_TRIES` diff --git a/agents/anyplot/tools/session.py b/agents/anyplot/tools/session.py index b9260ba04d..1344c66784 100644 --- a/agents/anyplot/tools/session.py +++ b/agents/anyplot/tools/session.py @@ -565,12 +565,18 @@ async def run_data_code(code: str, source_language: str, tool_context: ToolConte return _error_lines("invalid_code", lines) services = get_services() user = ledger.user_id or "anonymous" + # Checked before the queue wait and counted after it, so a run that never starts is never counted; the user's + # runs waiting in their other sessions at the same time (at most `AGENT_USER_MAX_RUNS` - 1) can each pass this + # check and overshoot the day's count by as many, a small overshoot accepted with the parallel windows (2026-10-21). if not services.usage.data_runs_ok(user, settings): attribution("data_code", ledger, outcome="daily_limit", language=language) return _error("daily_limit") if ledger.run_slot is not None and not await ledger.run_slot(): # the run queue's slot and the turn's start - attribution("data_code", ledger, outcome="capacity", language=language) - return _error("capacity") + # The route failed the turn with `run_limit` (the user's runs in other sessions fill their share) or + # `capacity` (a full queue, an expired wait), or the wait was cancelled; the turn is aborted either way. + code = "run_limit" if ledger.error == "run_limit" else "capacity" + attribution("data_code", ledger, outcome=code, language=language) + return _error(code) services.usage.add_data_run(user) job = DataJob(job_id=f"d{secrets.token_hex(8)}", source=prepared, timeout_s=settings.data_run_timeout_s) try: diff --git a/agents/main.py b/agents/main.py index 6719584ef4..a27b7b1530 100644 --- a/agents/main.py +++ b/agents/main.py @@ -12,8 +12,8 @@ | `GET /v1/eligibility?spec=&library=` | | `{eligible, status, reasons}` | | | `POST /v1/sessions` | `{user, spec_id, library, locale, snapshot}` | `{session_id, eligibility}` | `422 not_eligible` | | `POST /v1/sessions/{sid}/library` | `{library, snapshot}` | `{session_id, eligibility}` | `422 not_eligible`, `409 run_active` | -| `POST /v1/sessions/{sid}/dataset` | `{text, confirmed?, source?, table?}` (`source`: the name of the file the page read the text from, at most 1,024 characters; screened on its own, never a note; `table`: 1 to 10, which of several tables in the text to read, from the previous answer's `tables`) | `{preview, profile, bindings, warnings, context_lines, roles, suggestion, version, language, file_name?, tables?, table?}` (`file_name`: the dropped file's name as the session keeps it, `(name withheld)` when the screen refused it; `tables`: one label per table, its header names and its row count, when the text held two or more tables each under its own header after a blank line, and `table` the one read, see `data/parse.py` `_one_table`;`suggestion`: the wide-table offer of `data/shape.py` with `siblings`, the columns a role that takes labels should not take (`bindings.label_siblings`), or null; `context_lines`: the notes pasted above or below the table that passed the pre-filter and the scope judge, which the root gets with the next `summarize_data` turn; `language`: the session language as `{language, source}`, which a paste before any typed message or choice sets from the notes' or the column names' language, source `pasted`), stored as version 1 (the undo history of an earlier paste is dropped); or, for free text, `{transcribed, text, preview, profile, warnings, note}`, not stored (`confirmed: true` skips that fallback and keeps every row) | `413 too_long`, `422 unparseable` (both with `cause`, the parser's sentence naming what the data fails on, at most 300 characters), `422 formula` (text of at most 2,000 characters that asks for a formula, which the chat's `generate_data` computes; never transcribed), `403 data_refused`, `403 session_closed` (the scope judge closed the session), `409 run_active` (a turn of the user in any session, or another paste of the user in flight), `429 rate_limited` / `429 daily_limit` (`AGENT_USER_PASTES_PER_MINUTE` / `_PER_DAY`, or the daily token budgets for `daily_limit`), `429 locked_out` (the day's `attack` strikes), `503 guard_unavailable` | -| `POST /v1/sessions/{sid}/dataset/convert` | `{data_base64, sheet?}` (a dropped Excel, OpenDocument, Parquet, Feather, Arrow or NumPy file, at most 5 MiB; `sheet` a sheet or `.npz` array the previous answer named) | `{text, format, kind, rows, columns, sheet, sheets, sheet_count, warnings, dropped}`: the file read in the renderer's sandbox (`harness.py --convert`) into the text the paste box takes (CSV, or JSON records for a table with list columns, `format`), every value with the precision it came with, the sheet read and at most 10 visible ones with cells (`sheet_count` all of them), `warnings` counts (`formulas_without_value`, `hidden_sheets`, `dropped_columns`, `list_columns`, `utc_timestamps`, `renamed_columns`), `dropped` the names of the columns left out; nothing stored, no model call | `413 too_long`, `422 invalid`, `422 file_` (`wire.CONVERT_REASONS`, with `measured` and `limit` for a broken cap) and `file_timeout`, `file_too_complex` (also a reader that died without its `status.json`), `file_too_large`, `file_unreadable`, `403 session_closed`, `409 run_active` (another conversion of the user in flight), `429 rate_limited` / `429 daily_limit` (`AGENT_USER_CONVERSIONS_PER_MINUTE` / `_PER_DAY`, or the daily token budgets), `429 locked_out`, `503 capacity` (no render slot within `AGENT_CONVERT_SLOT_WAIT_S`), `503 convert_unavailable` (no renderer, or one without `/convert`) | +| `POST /v1/sessions/{sid}/dataset` | `{text, confirmed?, source?, table?}` (`source`: the name of the file the page read the text from, at most 1,024 characters; screened on its own, never a note; `table`: 1 to 10, which of several tables in the text to read, from the previous answer's `tables`) | `{preview, profile, bindings, warnings, context_lines, roles, suggestion, version, language, file_name?, tables?, table?}` (`file_name`: the dropped file's name as the session keeps it, `(name withheld)` when the screen refused it; `tables`: one label per table, its header names and its row count, when the text held two or more tables each under its own header after a blank line, and `table` the one read, see `data/parse.py` `_one_table`;`suggestion`: the wide-table offer of `data/shape.py` with `siblings`, the columns a role that takes labels should not take (`bindings.label_siblings`), or null; `context_lines`: the notes pasted above or below the table that passed the pre-filter and the scope judge, which the root gets with the next `summarize_data` turn; `language`: the session language as `{language, source}`, which a paste before any typed message or choice sets from the notes' or the column names' language, source `pasted`), stored as version 1 (the undo history of an earlier paste is dropped); or, for free text, `{transcribed, text, preview, profile, warnings, note}`, not stored (`confirmed: true` skips that fallback and keeps every row) | `413 too_long`, `422 unparseable` (both with `cause`, the parser's sentence naming what the data fails on, at most 300 characters), `422 formula` (text of at most 2,000 characters that asks for a formula, which the chat's `generate_data` computes; never transcribed), `403 data_refused`, `403 session_closed` (the scope judge closed the session), `409 run_active` (a turn, a theme toggle or another paste in flight in this session), `429 rate_limited` / `429 daily_limit` (`AGENT_USER_PASTES_PER_MINUTE` / `_PER_DAY`, or the daily token budgets for `daily_limit`), `429 locked_out` (the day's `attack` strikes), `503 guard_unavailable` | +| `POST /v1/sessions/{sid}/dataset/convert` | `{data_base64, sheet?}` (a dropped Excel, OpenDocument, Parquet, Feather, Arrow or NumPy file, at most 5 MiB; `sheet` a sheet or `.npz` array the previous answer named) | `{text, format, kind, rows, columns, sheet, sheets, sheet_count, warnings, dropped}`: the file read in the renderer's sandbox (`harness.py --convert`) into the text the paste box takes (CSV, or JSON records for a table with list columns, `format`), every value with the precision it came with, the sheet read and at most 10 visible ones with cells (`sheet_count` all of them), `warnings` counts (`formulas_without_value`, `hidden_sheets`, `dropped_columns`, `list_columns`, `utc_timestamps`, `renamed_columns`), `dropped` the names of the columns left out; nothing stored, no model call | `413 too_long`, `422 invalid`, `422 file_` (`wire.CONVERT_REASONS`, with `measured` and `limit` for a broken cap) and `file_timeout`, `file_too_complex` (also a reader that died without its `status.json`), `file_too_large`, `file_unreadable`, `403 session_closed`, `409 run_active` (another conversion in flight in this session), `429 rate_limited` / `429 daily_limit` (`AGENT_USER_CONVERSIONS_PER_MINUTE` / `_PER_DAY`, or the daily token budgets), `429 locked_out`, `503 capacity` (no render slot within `AGENT_CONVERT_SLOT_WAIT_S`), `503 convert_unavailable` (no renderer, or one without `/convert`) | | `PUT /v1/sessions/{sid}/bindings` | `[{role, column}]` | `{bindings, complete, missing_roles, warnings}`: `warnings` names a role that needs values of 0 or more bound to a column with a negative value, or left open for that reason (`data/signs.py`); never a refusal | `409 run_active`, `422 invalid` (with `errors`, at most 20 lines) | | `PUT /v1/sessions/{sid}/language` | `{language}` (a short name or code, `^[A-Za-z][A-Za-z -]{0,39}$`) | `{language, source, suggestion?}`: the session language in its stored form (`English` is `en`), source `chosen`, which detection never overrides; `suggestion`, when the session has data, the `.to_long()` offer (or null) with its new column names in that language | `409 run_active`, `422` (the pattern) | | `POST /v1/sessions/{sid}/dataset/confirm` | | `{confirmed, rows, columns}`: step 1 of two, the user confirmed the dataset with its bindings; the pipeline runs only then | `409 run_active`, `422 no_dataset` | @@ -43,8 +43,17 @@ sends `status{step:"queued", position, waiting}` while it waits, mid-turn, then the run's own events. The request deadline is paused while the turn waits and resumes with the time it had left when the slot comes, so queued time never counts. A full queue, or a -wait of `AGENT_QUEUE_MAX_WAIT_S`, ends the turn with `error{code:"capacity"}`. A user -with a turn in flight gets `409 run_active` on a second turn in any session. +wait of `AGENT_QUEUE_MAX_WAIT_S`, ends the turn with `error{code:"capacity"}`. + +Every exclusion is per session (owner decision of 2026-10-21): the page opens one session per +browser window, and a user's windows work in parallel. A session runs one thing at a time, so +a second turn while its turn runs is `409 run_active`, and a paste in flight blocks the +session's turns, its theme toggle and its data routes; the user's other sessions never block +it. What the sessions of one user share is the run queue: a user may have at most +`AGENT_USER_MAX_RUNS` sandbox runs queued or running at the same time, across sessions, and the +turn whose sandbox run would be one more ends with `error{code:"run_limit"}` (`TurnSlot`), +before the queue's length is looked at. The per-user rate limits and daily budgets stay per +user. Before the scope judge runs, and before any model call, the route answers with a fixed refusal right after `ready`: `locked_out` for a user past the day's `attack` strikes @@ -57,7 +66,8 @@ The theme toggle (`/versions/{version}/render`) renders the other theme of a finished version outside the queue, because it costs no tokens, but behind waiting pipeline renders in the render slot (`render/serial.py`); while it renders it holds the -session's registry entry, so a user has one run or toggle in flight at a time. +session's registry entry, so a session has one run or toggle in flight at a time (the +user's other sessions are not blocked). `adk web` runs the agents without this service, so its runs bypass the queue; its renders still go through the one render slot. @@ -198,6 +208,7 @@ QueueFull, QueuePosition, QueueTimeout, + QueueUserLimit, QueueWithdrawn, RunQueue, Ticket, @@ -430,8 +441,10 @@ class TurnSlot: `status{step:"queued"}`), and resumes the deadline with the time it had left when the entry starts. A later call in the same turn answers at once: one start per turn. A full queue or an expired wait fails the turn with `capacity` and aborts the run, so the stream ends - with `error{code:"capacity"}` and the root spends no further call; a cancelled wait - ends the run like any cancel. The entry is released with the turn (`Runtime.finish`). + with `error{code:"capacity"}` and the root spends no further call; a user who already + holds `AGENT_USER_MAX_RUNS` entries in other sessions fails it the same way with + `run_limit` (`QueueUserLimit`); a cancelled wait ends the run like any cancel. The entry + is released with the turn (`Runtime.finish`). """ runtime: "Runtime" @@ -449,10 +462,14 @@ async def take(self) -> bool: queue = self.runtime.run_queue() try: ticket = queue.submit(self.run.user, self.session_id) + except QueueUserLimit: # the user's runs in their other sessions fill their share: not `capacity` + attribution("queue", self.ledger, verdict="run_limit", waited_s=0.0) + self._fail("run_limit") + return False except QueueFull: logger.info("run queue full (ref %s): %d waiting", self.ledger.request_id, queue.waiting_count) attribution("queue", self.ledger, verdict="full", waited_s=0.0) - self._fail() + self._fail("capacity") return False self.run.ticket = ticket outcome = "started" @@ -470,15 +487,15 @@ async def take(self) -> bool: outcome = "withdrawn" attribution("queue", self.ledger, verdict=outcome, waited_s=round(self.runtime.now() - ticket.enqueued_at, 1)) if outcome == "expired": - self._fail() + self._fail("capacity") if outcome != "started": return False if waited: self.deadline.arm(self.runtime.now()) return True - def _fail(self) -> None: - self.ledger.fail("capacity") + def _fail(self, code: Literal["capacity", "run_limit"]) -> None: + self.ledger.fail(code) self.run.abort.set() @@ -488,17 +505,23 @@ class Runtime: `active` is the run registry: one entry per session with a queued or running `/messages` turn or a theme toggle in flight, which is what `409 run_active` - checks. `pasting` holds the users with a dataset paste in flight, so a user has one - paste at a time across sessions (`409 run_active` for a second one). The queue is built - from the settings on first use; its clock is the registry's clock too. + checks. `pasting` holds the sessions with a dataset paste in flight, so a session has + one paste at a time (`409 run_active` for a second one). `active`, `pasting` and `converting` + are keyed by session, never by user (owner decision of 2026-10-21): a user's other sessions never + block a session, and only the run queue counts a user's runs across sessions + (`AGENT_USER_MAX_RUNS`). A user may therefore hold several entries here at once, one per + session; `drop_stale`, `finish` and the idle sweep work per entry and release each + entry's own ticket. The queue is built from the settings on first use; its clock is the + registry's clock too. """ session_service: InMemorySessionService = field(default_factory=InMemorySessionService) artifact_service: InMemoryArtifactService = field(default_factory=InMemoryArtifactService) active: dict[str, ActiveRun] = field(default_factory=dict) pasting: set[str] = field(default_factory=set) + """The sessions with a dataset paste in flight.""" converting: set[str] = field(default_factory=set) - """The users with a file conversion in flight: one per user at a time (`409 run_active` for a second).""" + """The sessions with a file conversion in flight: one per session at a time (`409 run_active` for a second).""" eligibility_cache: dict[tuple[str, str], Eligibility] = field(default_factory=dict) runner: Runner | None = None queue: RunQueue | None = None @@ -853,23 +876,23 @@ def _fixture_seed( return delta -def _active(runtime: Runtime, sid: str, user: str | None = None) -> None: - """409 while the session has a queued or running run, or, with `user`, while that user has one in any session - or a dataset paste in flight (`Runtime.pasting`), so a turn, a theme toggle and a paste exclude each other.""" +def _active(runtime: Runtime, sid: str, *, paste: bool = True) -> None: + """409 while the session has a queued or running turn or a theme toggle in flight (`Runtime.active`), or, with + `paste`, a dataset paste in flight (`Runtime.pasting`). + + Per session only (owner decision of 2026-10-21): a turn, a paste, a theme toggle and the data routes (library, + bindings, confirm, undo, to_long) exclude each other within the session, and the user's other sessions (other + browser windows) never block them; their sandbox runs meet only in the run queue (`TurnSlot`). The exclusion + guards the session's own data: a paste holds the view it read before its judges ran and writes the next dataset + from it, so a version stored meanwhile in the same session (a data route, a turn's `prepare_data`) would be + dropped silently. Every guard is keyed by the session id, so it holds whoever sends the request, also two tabs + that share one session id; the page opens a session per window and keeps its id in memory only, so its tabs + never share one. Sessions never share data: the stores are keyed by session id, and no state is user-scoped. + The language chip passes `paste=False`: the paste reads the session again before it writes the language, so a + choice that lands while the judges run is kept. + """ runtime.drop_stale(get_settings()) - if sid in runtime.active: - raise AgentsError(409, "run_active") - if user is not None and (user in runtime.pasting or any(run.user == user for run in runtime.active.values())): - raise AgentsError(409, "run_active") - - -def _data_route_free(runtime: Runtime, sid: str, user: str) -> None: - """`_active` for the session's data routes (library, bindings, confirm, undo, to_long): 409 while the session - has a run, or while the user's paste is in flight in any session. A paste holds the view it read before its - judges ran and writes the next dataset from it, so a version another tab stored meanwhile would be dropped - silently. A run of the user in another session does not block them, as before.""" - _active(runtime, sid) - if user in runtime.pasting: + if sid in runtime.active or (paste and sid in runtime.pasting): raise AgentsError(409, "run_active") @@ -877,7 +900,7 @@ def _data_route_free(runtime: Runtime, sid: str, user: str) -> None: async def switch_library(sid: SessionId, body: SwitchLibraryBody, user: User, runtime: RuntimeDep) -> JSONResponse: settings = get_settings() session = await runtime.session(user, sid) - _data_route_free(runtime, sid, user) + _active(runtime, sid) view = read_session(session.state) if view is None or body.snapshot.spec_id != view.spec_id: raise AgentsError(422, "invalid") @@ -907,7 +930,7 @@ async def upload_dataset( # A closed session takes no paste either: each one spends judge calls, and the chat it would feed is closed. if scope_refusals(session.events) >= settings.session_refusals: raise AgentsError(403, SESSION_CLOSED) - _active(runtime, sid, user) # also one paste per user at a time, across sessions + _active(runtime, sid) # one paste per session at a time; the user's other sessions may paste meanwhile view = read_session(session.state) if view is None: raise AgentsError(404, "session_expired") @@ -918,17 +941,22 @@ async def upload_dataset( try: if size > MAX_INPUT_BYTES: raise AgentsError(413, "too_long") + # The limits stay per user while the user's sessions paste in parallel (2026-10-21). The paste count is + # checked here and booked in `_ingest_paste` (`add_paste`) before its first suspending `await`, so no paste + # of another session slips in between and the counts never overshoot. The daily token budget is checked here, + # before the judges, and booked after each judge call: pastes in several sessions at once can overshoot it + # by the judge calls they have in flight, a few thousand tokens each. _paste_budget(services, user, settings) limit = services.usage.paste_limit(user, settings) if limit is not None: attribution("paste_limit", ledger, verdict=limit) raise AgentsError(429, limit) telemetry.clear_converted(sid) # past the limits: this paste is the converted file's one - runtime.pasting.add(user) # no await since the check above, so no second paste slipped in between + runtime.pasting.add(sid) # no await since the check above, so no second paste slipped in between try: return await _ingest_paste(services, runtime, body, user, sid, ledger, view, facts) finally: - runtime.pasting.discard(user) + runtime.pasting.discard(sid) except AgentsError as exc: facts.refused(exc.code) raise @@ -959,8 +987,12 @@ async def convert_dataset_file( own counter (`AGENT_USER_CONVERSIONS_PER_MINUTE` / `_PER_DAY`, counted when the file goes to the sandbox), checked after the paste route's daily budget and strikes, so a user who may not paste spends no sandbox time; the paste that follows counts as one paste (owner - decision of 2026-10-15). One conversion per user at a time (`Runtime.converting`), across - sessions; it waits for the render slot behind every pipeline render. The attribution line + decision of 2026-10-15). One conversion per session at a time (`Runtime.converting`); the + user's other sessions may convert meanwhile (owner decision of 2026-10-21), and each one waits + for the render slot behind every pipeline render, at most `AGENT_CONVERT_SLOT_WAIT_S`, so + parallel conversions queue in the slot and the per-user counter still bounds them. The + counter is checked and booked with no suspending `await` between, so parallel sessions never + overshoot it. The attribution line carries the kind, the outcome, the byte count and the time, never the file, its text or its sheet names. """ @@ -969,7 +1001,7 @@ async def convert_dataset_file( session = await runtime.session(user, sid) if scope_refusals(session.events) >= settings.session_refusals: raise AgentsError(403, SESSION_CLOSED) - if user in runtime.converting: + if sid in runtime.converting: raise AgentsError(409, "run_active") try: data = base64.b64decode(body.data_base64, validate=True) @@ -1008,7 +1040,7 @@ async def _convert( if limit is not None: attribution("convert_limit", ledger, verdict=limit) raise AgentsError(429, limit) - runtime.converting.add(user) # no await since the check above, so no second conversion slipped in between + runtime.converting.add(sid) # no await since the check above, so no second conversion slipped in between try: services.usage.add_conversion(user) job = ConvertJob( @@ -1023,7 +1055,7 @@ async def _convert( attribution("data_convert", ledger, outcome="unavailable", bytes=len(data)) raise AgentsError(503, "convert_unavailable") from None finally: - runtime.converting.discard(user) + runtime.converting.discard(sid) kind = output.meta.kind if output.meta is not None else None if output.exit_code == 0 and output.text is not None and output.meta is not None: meta = output.meta @@ -1458,7 +1490,7 @@ async def put_bindings( ) -> JSONResponse: services = get_services() session = await runtime.session(user, sid) - _data_route_free(runtime, sid, user) + _active(runtime, sid) view = read_session(session.state) if view is None: raise AgentsError(404, "session_expired") @@ -1501,11 +1533,12 @@ async def put_language(sid: SessionId, body: LanguageBody, user: User, runtime: same change. The next reply and the next plot version use it; earlier versions keep their text. The pattern runs before `normalise` (a leading space is a `422`), and the answer carries the stored form, which the page shows: `English` comes back as `en`, - `Swedish` as typed. `409 run_active` while a turn or a theme render is in flight, so - a choice never races detection inside a run. + `Swedish` as typed. `409 run_active` while a turn or a theme render is in flight in the + session, so a choice never races detection inside a run; a paste in flight does not block + it, because the paste reads the session again before it writes the language. """ session = await runtime.session(user, sid) - _active(runtime, sid) + _active(runtime, sid, paste=False) if read_session(session.state) is None: raise AgentsError(404, "session_expired") value = normalise(body.language) @@ -1531,7 +1564,7 @@ async def confirm_dataset(sid: SessionId, user: User, runtime: RuntimeDep) -> JS """ services = get_services() session = await runtime.session(user, sid) - _data_route_free(runtime, sid, user) + _active(runtime, sid) view = read_session(session.state) if view is None: raise AgentsError(404, "session_expired") @@ -1556,7 +1589,7 @@ async def undo_dataset(sid: SessionId, user: User, runtime: RuntimeDep) -> JSONR """ services = get_services() session = await runtime.session(user, sid) - _data_route_free(runtime, sid, user) + _active(runtime, sid) view = read_session(session.state) if view is None: raise AgentsError(404, "session_expired") @@ -1589,7 +1622,7 @@ async def dataset_to_long(sid: SessionId, user: User, runtime: RuntimeDep) -> JS """ services = get_services() session = await runtime.session(user, sid) - _data_route_free(runtime, sid, user) + _active(runtime, sid) view = read_session(session.state) if view is None: raise AgentsError(404, "session_expired") @@ -1791,7 +1824,7 @@ async def post_message( if body.text is not None and len(body.text) > MAX_MESSAGE_CHARS: raise AgentsError(413, "too_long") session = await runtime.session(user, sid) - _active(runtime, sid, user) + _active(runtime, sid) # one turn per session; the user's other sessions run their turns in parallel settings = get_settings() services = get_services() view = read_session(session.state) @@ -1811,6 +1844,13 @@ async def post_message( # The fixed refusals sent before the scope judge, so none of these turns costs a model call. # A closed session answers `session_closed` first: its third `attack` refusal also reaches the strike lockout, # which would otherwise mask the closure behind `budget` here (the lockout still covers the user's other sessions). + # The limits stay per user while the user's sessions run turns in parallel (2026-10-21), and may overlap: + # - messages: checked and counted below with no suspending `await` between, so they never overshoot; + # - the daily token budget: checked here and before every model call (`plugins/budget.py`), booked after each + # call, so parallel turns overshoot it by at most the model calls they have in flight; + # - daily pipeline runs: checked and counted in one step (`BudgetPlugin.before_tool_callback`), no overshoot; + # - daily data runs: checked before the queue wait and counted after it (`tools/session.run_data_code`), so the + # user's runs waiting in other sessions, at most `AGENT_USER_MAX_RUNS` - 1, can overshoot it by as many. refused = scope_refusals(session.events) if refused >= settings.session_refusals: return _refuse_now(translator, ledger, facts, SESSION_CLOSED, "session_closed", refusals=refused) @@ -1940,12 +1980,13 @@ async def render_version_theme( Version 0 is the latest. Synchronous (a render takes seconds): it renders in the render slot's `toggle` lane, behind waiting pipeline renders, but not through the run queue. It is registered in the run registry while it renders, so it and a turn - refuse each other with `409 run_active` in both directions, and a user has one run - or toggle in flight at a time. `503 capacity` when no render slot came free in time - or the render store is full. + of the same session refuse each other with `409 run_active` in both directions, and a + session has one run or toggle in flight at a time; it holds only this session's entry, + so the user's other sessions go on meanwhile (owner decision of 2026-10-21). `503 + capacity` when no render slot came free in time or the render store is full. """ await runtime.session(user, sid) - _active(runtime, sid, user) + _active(runtime, sid) services = get_services() stored = services.versions.get(sid, version or None) if stored is None: diff --git a/agents/stream.py b/agents/stream.py index a8143bfcac..472083b809 100644 --- a/agents/stream.py +++ b/agents/stream.py @@ -11,7 +11,7 @@ | `message` | `text` | a final, non-partial text response authored by the root (`anyplot`) | | `plot` | `status`, `reason`, `attempts`, `artifacts`, `changes`, `residual_defects`, `version`, `rounds` | the pipeline's `PlotResult` output event; `version`, the stored version's number, only on `ok` and `needs_attention`; `rounds`, how each attempt ended (`{attempt, outcome, findings, step, shipped, defects}`: the step it ended in, whether its render is the one shipped, and the rubric ids it passed on to the repair) | | `refusal` | `code`, `text` | the request ledger's refusal, in place of the message: `out_of_scope`, `needs_data` (data that would have to be fetched), `other_library` (this plot in another library or language: a fixed pointer to the catalogue, not counted toward closing by default), `unsupported_content`, `locked_out` (past the day's `attack` strikes) and `session_closed` (the session's third counted refusal) from the scope guard, `budget` from the budgets; the refusal one before the closing one ends with the fixed `close_warning` sentence; right after `ready`, before the scope judge: `locked_out`, `budget` for a user over the daily budget, `rate_limited` or `daily_limit` past the user's message limits, `session_closed` in a closed session | -| `error` | `code`, `ref` | `guard_unavailable`, `capacity` (also when a sandbox run found the queue full or waited its maximum; the turn ends there, without a `plot`), `deadline` or `internal` | +| `error` | `code`, `ref` | `guard_unavailable`, `capacity` (also when a sandbox run found the queue full or waited its maximum; the turn ends there, without a `plot`), `run_limit` (the user already has `AGENT_USER_MAX_RUNS` sandbox runs queued or running in other sessions; the turn ends the same way), `deadline` or `internal` | | `done` | `llm_calls`, `tokens`, and after a run `bindings`, `data_confirmed`, `language` (`{language, source}`, the session language and how it was set), `suggestion` (the `.to_long()` offer in the session language, or null, when the session has data) and, when the turn changed the data (`prepare_data`, `generate_data`), `dataset` | the end of every run, always last; the route adds the session's sync fields (`_session_sync` in `agents/main.py`) | Function calls and responses, tool outputs, thoughts, partial chunks, adapter and diff --git a/api/routers/agent.py b/api/routers/agent.py index cca040c9b4..48c415f87e 100644 --- a/api/routers/agent.py +++ b/api/routers/agent.py @@ -158,7 +158,7 @@ # `suggestion` the `.to_long()` offer in that language (or null) on a session with data, so its card follows. "done": frozenset({"llm_calls", "tokens", "bindings", "data_confirmed", "dataset", "language", "suggestion"}), } -_ERROR_CODES = frozenset({"capacity", "deadline", "guard_unavailable", "upstream", "internal"}) +_ERROR_CODES = frozenset({"capacity", "run_limit", "deadline", "guard_unavailable", "upstream", "internal"}) _MAX_BINDING_ERRORS = 20 _MAX_BINDING_ERROR_CHARS = 300 _MAX_CAUSE_CHARS = 300 # agents/main.py MAX_CAUSE_CHARS; copied, because the API image does not ship the agents package diff --git a/app/src/hooks/useAgentSession.test.ts b/app/src/hooks/useAgentSession.test.ts index 3aea9e04bd..e55cc1470f 100644 --- a/app/src/hooks/useAgentSession.test.ts +++ b/app/src/hooks/useAgentSession.test.ts @@ -1491,6 +1491,24 @@ describe('useAgentSession: turns', () => { }); }); + it("records the user's run limit across windows as its own error and ends the run", async () => { + const routes = baseRoutes(); + routes['POST /sessions/S1/messages'] = () => + sse([ + ['ready', {}], + ['error', { code: 'run_limit', ref: 'rl' }], + ['done', {}], + ]); + const { result } = await openSession(routes); + await act(() => result.current.createPlot()); + expect(lastItem(result.current.state.items)).toMatchObject({ + kind: 'error', + code: 'run_limit', + ref: 'rl', + }); + expect(result.current.state.run).toBeNull(); + }); + it("Stop cancels, keeps the stream until the server's done and still shows a late plot", async () => { const routes = baseRoutes(); const stream = liveSse([ diff --git a/app/src/hooks/useAgentSession.ts b/app/src/hooks/useAgentSession.ts index 3e564fb7b4..153329a7f8 100644 --- a/app/src/hooks/useAgentSession.ts +++ b/app/src/hooks/useAgentSession.ts @@ -78,8 +78,9 @@ * (`formulaKind`), and the assistant's `run_data_code` runs it in the chat; * the version it stores carries the Python that ran (`code`). * - * One thing at a time: the agents service runs one turn or theme render per - * user and answers anything else with `409 run_active`, a parse, an undo or a + * One thing at a time: the agents service runs one turn, theme render or paste + * per session (each window opens its own, and the user's other windows never + * block it) and answers anything else with `409 run_active`, a parse, an undo or a * binding change rewrites the data a turn reads, and a confirmation must cover * exactly the data it was given. So the hook starts none of these while * another is in flight, and Stop keeps the stream open until the server's diff --git a/app/src/lib/agent.test.ts b/app/src/lib/agent.test.ts index 687f806f4c..7a6c047bf7 100644 --- a/app/src/lib/agent.test.ts +++ b/app/src/lib/agent.test.ts @@ -126,6 +126,14 @@ describe('parseAgentEvent', () => { }); }); + it("keeps the user's run limit as its own code", () => { + expect(parseAgentEvent(raw('error', { code: 'run_limit', ref: 'abc' }))).toEqual({ + type: 'error', + code: 'run_limit', + ref: 'abc', + }); + }); + it('drops unknown types, non-object data, malformed JSON and bad statuses', () => { expect(parseAgentEvent(raw('debug', { a: 1 }))).toBeNull(); expect(parseAgentEvent(raw('message', '[1,2]'))).toBeNull(); diff --git a/app/src/lib/agent.ts b/app/src/lib/agent.ts index 4f5044a802..f645b48afd 100644 --- a/app/src/lib/agent.ts +++ b/app/src/lib/agent.ts @@ -1076,6 +1076,8 @@ export type PipelineStep = (typeof PIPELINE_STEPS)[number]; export const STREAM_ERROR_CODES = [ 'capacity', + // The user already has `AGENT_USER_MAX_RUNS` (3) sandbox runs queued or running in other windows. + 'run_limit', 'deadline', 'guard_unavailable', 'upstream', diff --git a/app/src/sections/agent-chat/ChatThread.test.tsx b/app/src/sections/agent-chat/ChatThread.test.tsx index 8eaa38a731..5554728178 100644 --- a/app/src/sections/agent-chat/ChatThread.test.tsx +++ b/app/src/sections/agent-chat/ChatThread.test.tsx @@ -99,6 +99,32 @@ describe('ChatThread', () => { expect(screen.getAllByTestId('confirm-lost')).toHaveLength(1); expect(screen.getByText('# stopped')).toBeInTheDocument(); }); + + it("says in plain words that the user's other windows hold their three runs", () => { + renderThread({ + ...initialAgentState('matplotlib'), + phase: 'ready', + sessionId: 'S1', + items: [{ id: 1, kind: 'error', code: 'run_limit', ref: 'r-lim' }], + }); + const alert = screen.getByRole('alert'); + expect(alert).toHaveTextContent('error · run_limit · ref r-lim'); + expect(alert).toHaveTextContent( + 'you already have 3 plots or data runs in progress in other windows; wait until one finishes, then try again' + ); + }); + + it('names this window, not the others, for a run_active turn', () => { + renderThread({ + ...initialAgentState('matplotlib'), + phase: 'ready', + sessionId: 'S1', + items: [{ id: 1, kind: 'error', code: 'run_active', ref: null }], + }); + expect(screen.getByRole('alert')).toHaveTextContent( + 'a run, a theme render or a paste is still in progress in this window' + ); + }); }); describe('ChatThread in-progress line', () => { diff --git a/app/src/sections/agent-chat/DataPanel.test.tsx b/app/src/sections/agent-chat/DataPanel.test.tsx index 3740b8bcdf..d3bcc84515 100644 --- a/app/src/sections/agent-chat/DataPanel.test.tsx +++ b/app/src/sections/agent-chat/DataPanel.test.tsx @@ -648,10 +648,10 @@ describe('DataPanel data changes', () => { rerender(changedState(2, { undoBusy: true })); expect(screen.getByRole('button', { name: '.undo() …' })).toBeDisabled(); - // A run in another tab: the undo can be tried again. + // A run the server still holds for this window (a stopped run's last step): the undo can be tried again. rerender(changedState(2, { undoError: { code: 'run_active', ref: 'r409' } })); expect(screen.getByRole('alert')).toHaveTextContent( - 'a run or another paste is in progress; wait for it to finish · ref r409' + 'a run or another paste is still in progress in this window; wait for it to finish · ref r409' ); expect(screen.getByRole('button', { name: '.undo()' })).toBeEnabled(); diff --git a/app/src/sections/agent-chat/messages.ts b/app/src/sections/agent-chat/messages.ts index 80a1b69ae5..08e5be43c6 100644 --- a/app/src/sections/agent-chat/messages.ts +++ b/app/src/sections/agent-chat/messages.ts @@ -9,12 +9,16 @@ import type { ConfirmLoss } from 'src/hooks/useAgentSession'; /** Errors of a turn (stream `error` events and failed requests). */ export const ERROR_TEXT: Record = { capacity: 'the run queue is full or the wait ran out; try again in a few minutes', + // The server's `AGENT_USER_MAX_RUNS` (3): plots and data runs queued or running across the user's windows. + run_limit: + 'you already have 3 plots or data runs in progress in other windows; wait until one finishes, then try again', deadline: 'the run took too long and was stopped', guard_unavailable: 'the scope check is unavailable right now; try again', upstream: 'the agents service did not answer or cut the stream', internal: 'something went wrong on the server', + // Per window since 2026-10-21: the user's other windows never cause it. run_active: - 'a run or a theme render is still in progress, in this tab or another one (a stopped run ends after its current step); try again in a moment', + 'a run, a theme render or a paste is still in progress in this window (a stopped run ends after its current step); try again in a moment', too_long: 'the message is longer than 2,000 characters', not_eligible: 'this plot cannot be adapted in that library', not_found: 'that plot or library is not in the catalogue', @@ -50,7 +54,7 @@ export const DATA_FAILURE: Record = { locked_out: 'paused until tomorrow after several refused requests today', session_closed: 'this chat is closed; reload the page to start a new session', capacity: 'the server is busy; try again in a minute', - run_active: 'a run or another paste is in progress; wait for it to finish', + run_active: 'a run or another paste is still in progress in this window; wait for it to finish', no_dataset: 'parse the data first', // The service keeps the last five versions, so the history can end above version 1. nothing_to_undo: 'there is no earlier version to go back to', diff --git a/changelog.d/agent-parallel-windows.md b/changelog.d/agent-parallel-windows.md new file mode 100644 index 0000000000..781f9beb73 --- /dev/null +++ b/changelog.d/agent-parallel-windows.md @@ -0,0 +1,15 @@ +### Changed + +- **The agent chat's windows work in parallel; only their sandbox runs queue.** + A paste, a chat turn, a data step (bindings, `.confirm()`, `.undo()`, + `.to_long()`, the language, a library switch), the theme toggle and a file + conversion in one browser window no longer answer `409 run_active` because + another window of the same user renders a plot: every exclusion in the + agents service is now per session (`Runtime.active`, `Runtime.pasting`, + `Runtime.converting`), and within one session it stays one thing at a time. + Plot runs and data snippets still share the run queue, at most three per user + queued or running across windows (`AGENT_USER_MAX_RUNS`); a fourth ends its + turn with the new stream error `run_limit`, which the page words as "you + already have 3 plots or data runs in progress in other windows", instead of + `capacity`. The per-user rate limits and daily budgets are unchanged (owner + decision of 2026-10-21). diff --git a/docs/concepts/agent-network.md b/docs/concepts/agent-network.md index 32aaad9280..4fa683fb4e 100644 --- a/docs/concepts/agent-network.md +++ b/docs/concepts/agent-network.md @@ -175,10 +175,11 @@ The owner decided the queue, the rate and the serial renders on 2026-10-09, afte | Run starts | fewer than `AGENT_RUNS_PER_MINUTE` (1) in the last 60 s, a sliding window of start times; one start per turn that runs a sandbox | the run queue | | Wait in the queue | at most `AGENT_QUEUE_MAX_WAIT_S` (600 s), mid-turn; a run that waited that long ends the turn with `error{code:"capacity"}`, unless its turn comes at that very moment. Through the BFF, a whole turn ends by `AGENT_TURN_MAX_S` (1,190 s since 2026-10-13, 890 s before), below anyplot-api's 1,200 s request timeout, so the root's first call, the full wait and the 540 s run fit | the run queue; `AGENT_TURN_MAX_S` in the BFF | | Queue length | rate x maximum wait / 60, so 10 entries at the defaults; a sandbox run past that ends its turn with `error{code:"capacity"}` and the run is aborted before the pipeline's first model call. The formula assumes runs within the 60 s rate window, so admission promises a place, not a start | the run queue | -| Runs per user | one turn in flight (answering, queued or running), or one theme toggle, in any session; anything more gets `409 run_active` | the run registry | +| Runs per session | one turn in flight (answering, queued or running), one theme toggle or one paste; anything more in the same session gets `409 run_active`. Since 2026-10-21 (owner decision) the exclusion is per session only: each browser window opens a session of its own, and a user's other sessions never block it | the run registry (`Runtime.active`, `Runtime.pasting`) | +| Sandbox runs per user | at most `AGENT_USER_MAX_RUNS` (3) queued or running at the same time across the user's sessions, since 2026-10-21; the turn whose run would be one more ends with `error{code:"run_limit"}` before the queue's length is checked, and the run is aborted before the pipeline's first model call | the run queue (`RunQueue.submit`, `QueueUserLimit`) | | Messages per user | at most `AGENT_USER_MESSAGES_PER_MINUTE` (10) chat turns in any 60 s and `AGENT_USER_MESSAGES_PER_DAY` (200) per UTC day, typed messages and actions alike, counted in the usage book; one more gets the fixed `rate_limited` or `daily_limit` refusal right after `ready`, before the scope judge, so a flood costs no model call. A refused turn is not counted | the `/messages` route (`UsageBook.message_limit`) | -| Pastes per user | at most `AGENT_USER_PASTES_PER_MINUTE` (10) in any 60 s and `AGENT_USER_PASTES_PER_DAY` (100) per UTC day (owner decision of 2026-10-14), across sessions, checked before any parser or model; only a paste that reaches the pre-filter or a judge counts (a formula, code or unparseable text is refused for free). One more is `429 rate_limited` or `429 daily_limit`. One paste per user at a time, and none while a turn of the user runs in any session (`409 run_active`); a closed session takes none (`403 session_closed`). Since 2026-10-14: a paste spends dataset-judge calls, a notes-judge call and maybe a transcriber call, and had only the daily token check | the `/dataset` route (`UsageBook.paste_limit`, `Runtime.pasting`) | -| File conversions per user | at most `AGENT_USER_CONVERSIONS_PER_MINUTE` (5) in any 60 s and `AGENT_USER_CONVERSIONS_PER_DAY` (30) per UTC day (owner decision of 2026-10-15), across sessions, counted when the file goes to the sandbox and checked after the daily token budget and the strikes; the paste that follows counts as one paste. One conversion per user at a time (`409 run_active`), at most 5 MB, 10 s in the sandbox, behind every pipeline render in the render slot (`503 capacity` after 20 s) | the `/dataset/convert` route (`UsageBook.convert_limit`, `Runtime.converting`) | +| Pastes per user | at most `AGENT_USER_PASTES_PER_MINUTE` (10) in any 60 s and `AGENT_USER_PASTES_PER_DAY` (100) per UTC day (owner decision of 2026-10-14), across sessions, checked before any parser or model; only a paste that reaches the pre-filter or a judge counts (a formula, code or unparseable text is refused for free). One more is `429 rate_limited` or `429 daily_limit`. One paste per session at a time, and none while a turn or a theme toggle of the session runs (`409 run_active`); since 2026-10-21 the user's other sessions paste in parallel, and the counts stay per user; a closed session takes none (`403 session_closed`). Since 2026-10-14: a paste spends dataset-judge calls, a notes-judge call and maybe a transcriber call, and had only the daily token check | the `/dataset` route (`UsageBook.paste_limit`, `Runtime.pasting`) | +| File conversions per user | at most `AGENT_USER_CONVERSIONS_PER_MINUTE` (5) in any 60 s and `AGENT_USER_CONVERSIONS_PER_DAY` (30) per UTC day (owner decision of 2026-10-15), across sessions, counted when the file goes to the sandbox and checked after the daily token budget and the strikes; the paste that follows counts as one paste. One conversion per session at a time (`409 run_active`; per user before 2026-10-21), at most 5 MB, 10 s in the sandbox, behind every pipeline render in the render slot (`503 capacity` after 20 s) | the `/dataset/convert` route (`UsageBook.convert_limit`, `Runtime.converting`) | | Scope refusals per session | the third counted refusal of a session (`AGENT_SESSION_REFUSALS`: `out_of_scope` or `attack`; `needs_data` never counts, `other_library` only with `AGENT_OTHER_LIBRARY_COUNTS`) closes it: that refusal and every later turn of the session answer `session_closed` ("reload the page to start a new session"), the later ones before the judge. The counted refusal before the closing one carries the fixed `close_warning` sentence (`AGENT_SESSION_CLOSE_WARNING`), after a live test of 2026-10-14 closed a session unwarned. Counted from the session's withheld messages, so the count ends with the session; a closed session's data is not carried into the next one (follow-up). The per-user strikes (`AGENT_ATTACK_STRIKES`, 3 `attack` verdicts per day across sessions and the dataset judge) end in the `locked_out` refusal for the day (the `budget` text before 2026-10-14, which read as a spent usage limit); the strikes live in the instance's memory and reset at midnight UTC or on a restart (a redeploy or a scale-to-zero) | the ScopeGuard, the `/messages` and `/dataset` routes | | Daily budget | a user already over the daily token budget gets the `budget` refusal at once, before the judge | the `/messages` route | | Concurrent renders per instance | 1 (`AGENT_RENDER_CONCURRENCY`), one theme per slot, for every backend and every route; a waiting pipeline render gets a freed slot before a waiting theme toggle | `SerialRenderer` (`render/serial.py`), applied in `Services.backend` | @@ -193,7 +194,7 @@ The stress cases (`agents/evals/fixtures/cases/stress-*`) each name the bound th A typical "Create plot" costs 4 LLM calls (root twice, adapter, reviewer); a repair adds one, and the review of a render repaired after a rejection one more; each free-text turn adds one judge call; the theme toggle costs none. -The run queue (`agents/anyplot/run_queue.py`) is an in-process FIFO in front of the sandbox runs, the memory, rate and cost limiter of the instance. Since 2026-10-14 a turn takes its entry mid-turn: the `/messages` route runs the ADK invocation in a task of its own and gives the request ledger `run_slot` (`TurnSlot.take` in `agents/main.py`), which `plot_pipeline` and `run_data_code` await before they render or run code; the queue positions it reports go through the stream's outbox while the tool is blocked. It has two lanes: a `premium` entry goes before every `normal` one (first come, first served within a lane), but the concurrency and rate limits bind it too. Nothing sets `premium` yet; it is the lane for users who later pay for their own tokens. While a run waits, the stream sends `status{step:"queued", position, waiting}` at once, on every change, and every 15 s unchanged, so idle proxies keep the stream open; `position` 1 runs next and `waiting` counts every queued entry, this one included. The BFF restarts its turn budget on each of them and on the first event after the wait, as before, because it never assumed the wait comes first. A client that disconnects while it waits leaves the queue, and `POST .../cancel` takes a waiting run out at once and ends the turn. A full queue or an expired wait fails the turn with `capacity` and aborts it, so the root spends no closing call and the page sees the same `error{code:"capacity"}` as before. The run registry, which answers `409 run_active`, covers every turn in flight and theme toggles, and its stale sweep frees an entry whose stream vanished after the maximum wait (while its sandbox run waits) or the deadline, plus a margin of 30 s. A turn counts as use of the session's dataset, so the idle sweep does not take it while the run waits. `GET /v1/status` reports `waiting` and `in_flight`. Runs that `adk web` starts bypass the queue, because they never pass through `/v1`; their renders still go through the one render slot. +The run queue (`agents/anyplot/run_queue.py`) is an in-process FIFO in front of the sandbox runs, the memory, rate and cost limiter of the instance. Since 2026-10-14 a turn takes its entry mid-turn: the `/messages` route runs the ADK invocation in a task of its own and gives the request ledger `run_slot` (`TurnSlot.take` in `agents/main.py`), which `plot_pipeline` and `run_data_code` await before they render or run code; the queue positions it reports go through the stream's outbox while the tool is blocked. It has two lanes: a `premium` entry goes before every `normal` one (first come, first served within a lane), but the concurrency and rate limits bind it too. Nothing sets `premium` yet; it is the lane for users who later pay for their own tokens. While a run waits, the stream sends `status{step:"queued", position, waiting}` at once, on every change, and every 15 s unchanged, so idle proxies keep the stream open; `position` 1 runs next and `waiting` counts every queued entry, this one included. The BFF restarts its turn budget on each of them and on the first event after the wait, as before, because it never assumed the wait comes first. A client that disconnects while it waits leaves the queue, and `POST .../cancel` takes a waiting run out at once and ends the turn. A full queue or an expired wait fails the turn with `capacity` and aborts it, so the root spends no closing call and the page sees the same `error{code:"capacity"}` as before. Since 2026-10-21 (owner decision) a user's browser windows work in parallel, each in a session of its own, and only their sandbox runs share the queue: `submit` refuses a user's entry past `AGENT_USER_MAX_RUNS` (3, waiting and running together across sessions) with `QueueUserLimit`, before it looks at the queue's length, and the turn fails the same way with `run_limit`, so the page can say that the user's own runs in other windows fill it. The run registry, which answers `409 run_active`, covers every turn in flight and theme toggles per session, never per user, and its stale sweep frees an entry whose stream vanished after the maximum wait (while its sandbox run waits) or the deadline, plus a margin of 30 s. A turn counts as use of the session's dataset, so the idle sweep does not take it while the run waits. `GET /v1/status` reports `waiting` and `in_flight`. Runs that `adk web` starts bypass the queue, because they never pass through `/v1`; their renders still go through the one render slot. The queue length is the owner's formula and assumes that a run ends within the 60 s rate window. A run that takes longer holds the only slot past the window, so every later start slips by the difference: with one run in flight, ten accepted entries and 90 s runs, the last four wait the full maximum and end with `capacity`. Admitting by an estimated start time (the position times the larger of 60 s per start and the recent run time) would keep that promise; it is an open decision. @@ -219,7 +220,7 @@ Built since 2026-10-11 (formulas): owner decision of 2026-10-11, a function such Built since 2026-10-14 (data code): owner decision of 2026-10-14, a user may paste or type a snippet that generates the data (Python, JavaScript, R, a formula from a textbook, anything), and it must work offline. The root translates any non-Python input into Python and calls `run_data_code(code, source_language)`; `generate_data` stays until the evals show the new tool covers it. The server checks and runs the code in these steps (`agents/anyplot/tools/session.py`): 1. `code/data_code.py` strips a top-level `if __name__ == "__main__":` guard (its body dedented), and the validator's DATA profile (`validate_data_code`) checks the result: every SECURITY rule except savefig; the import surface COMMON plus the standard `random` and the scipy computing submodules (no plotting library, no sklearn, no statsmodels, whose `datasets` reach files and the network); every private attribute banned on any receiver; a top-level assignment to `df`; and a `literal-data` rule that counts numbers and text values (list, tuple and set elements and dict values, never dict keys) in every outermost literal container, a repetition by an int literal counted as its copies, at most 20 of either per container and 40 in the file, so the model cannot write rows from memory. A file, URL or network finding carries the fixed message "paste the file's contents in the data panel instead". The findings reach the root fenced as ``; the root fixes the code and calls once more at most (ToolSafety allows two calls per invocation). -2. The rate and the budget: a per-user daily count (`AGENT_DAILY_DATA_RUNS`, 30), checked and booked before the run, and the run queue's slot with the turn's start in the rate window (`RequestLedger.run_slot`, the same helper the pipeline awaits), taken mid-turn while the stream reports the queue position; a full queue or an expired wait ends the turn with `error{code:"capacity"}`. +2. The rate and the budget: a per-user daily count (`AGENT_DAILY_DATA_RUNS`, 30), checked and booked before the run, and the run queue's slot with the turn's start in the rate window (`RequestLedger.run_slot`, the same helper the pipeline awaits), taken mid-turn while the stream reports the queue position; a full queue or an expired wait ends the turn with `error{code:"capacity"}`, and a run past the user's `AGENT_USER_MAX_RUNS` across sessions with `error{code:"run_limit"}`. 3. The run: `services.backend.run_data` in one slot of the render semaphore's run lane, through the renderer's `POST /run-data` (see [Render](#render)) with a time limit of `AGENT_DATA_RUN_TIMEOUT_S` (20 s, at most 30). The harness runs the snippet in data mode under an audit hook and writes `df` as `out.csv`, and its outcome as `status.json` (its exit code and, for a broken row, column or size cap, the measured value and the cap). The host takes the exit code from `status.json`, because Cloud Run's sandbox launcher reports every non-zero exit of the sandboxed process as its own death by SIGPIPE (exit code -13, with no `[end]` line in `sandbox.log`; seen on every failed render and data run from 2026-10-11 to 2026-10-14), which turned a too-large table into "an error the sandbox does not name" in the live test of 2026-10-14. The tool trusts only what it parses itself: the exit code, which maps to a fixed line, the cap numbers, which make the line actionable (`df's CSV is 4,493 KB, over the 2,048 KB cap: keep every 3rd row (df = df.iloc[::3]) ... and round the floats to the decimal places that still keep the values distinct`), and `out.csv` within the parser's caps. The harness never rounds or thins a table itself: no fixed rule fits every table (a fixed number of significant digits collapses large values that differ only in their decimals, such as 1,000,000.1 and 1,000,000.9), so the root chooses the decimal places for the data it computed (`df.round(3)` for values between -50 and 50), and the tool description and the root prompt name the 2 MB cap of computed data (raised from the 200 KB paste cap on 2026-10-14, owner decision, so the live test's 10,000-step Lorenz run, 637,727 bytes, and 10,001 rows of six full-precision columns, 1,138,766 bytes, fit as they are) and that rounding up front. Nothing the snippet printed reaches the model: an exception is named by its class and the line of `snippet.py` it was raised on (`gates.error_summary`), never by its message, which a snippet could fill with an injection. 4. The table: `parse_dataset(find_table=False)`, at least one number or date column, then the same pre-filter and dataset judge as a paste (`dataset_guard.check_dataset`, one function the dataset route calls too, so strikes, token booking and attribution stay in one place; a refusal is the tool's `data_refused`), then the store: the next version with the default bindings (`push_version(rebind=True)`), or version 1 without data. The version keeps the Python that ran (`StoredDataset.code`), which the dataset answers and `done.dataset` carry as `code` and the data panel shows collapsed under `# code that made this data`. The change line is `computed from your code: rows, columns`. @@ -252,9 +253,9 @@ Callable only with Cloud Run IAM; the BFF mirrors it under `/debug/agent`. | `POST /v1/sessions/{sid}/dataset/convert` | `{data_base64, sheet?}` → `{text, format, kind, rows, columns, sheet, sheets, sheet_count, warnings}`: a dropped binary data file converted in the renderer's sandbox (`POST /convert`, `harness.py --convert`) into the text the paste box takes; nothing stored, no model call | `413 too_long`, `422 invalid`, `422 file_` with `measured` and `limit` for a broken cap, `403 session_closed`, `409 run_active`, `429 rate_limited` / `daily_limit` (`AGENT_USER_CONVERSIONS_PER_MINUTE` / `_PER_DAY`) / `locked_out`, `503 capacity`, `503 convert_unavailable` | | `POST /v1/sessions/{sid}/dataset/confirm` | → `{confirmed, rows, columns}`: step 1 of two, the user confirmed the dataset with its bindings | `409 run_active`, `422 no_dataset` | | `POST /v1/sessions/{sid}/dataset/undo` | → `{preview, profile, bindings, warnings, context_lines, roles, version}`: the version before the root's last `prepare_data` or `generate_data` is the current dataset again, bindings carried over, unconfirmed | `409 run_active`, `422 nothing_to_undo` | -| `POST /v1/sessions/{sid}/messages` | `{text}` (at most 2,000 chars) or `{action}` (`create_plot`, `summarize_data`) → SSE `anyplot/1`, started at once (only a `plot_pipeline` or `run_data_code` call waits in the run queue, mid-turn); before the scope judge, `budget`, `session_closed`, `rate_limited` and `daily_limit` are fixed refusals right after `ready`; `done` carries `bindings`, `data_confirmed`, `language` (`{language, source}`) and, after a turn whose `prepare_data`, `generate_data` or `run_data_code` changed the data, `dataset: {preview, profile, bindings, warnings, context_lines, roles, changes, version}` plus `code` for a version `run_data_code` computed (`version` 1 when `generate_data` or `run_data_code` made the session's first data) | `413 too_long`, `409 run_active`; a full queue is the stream's `error{code:"capacity"}` since 2026-10-14 (`503 capacity` before the stream until then) | +| `POST /v1/sessions/{sid}/messages` | `{text}` (at most 2,000 chars) or `{action}` (`create_plot`, `summarize_data`) → SSE `anyplot/1`, started at once (only a `plot_pipeline` or `run_data_code` call waits in the run queue, mid-turn); before the scope judge, `budget`, `session_closed`, `rate_limited` and `daily_limit` are fixed refusals right after `ready`; `done` carries `bindings`, `data_confirmed`, `language` (`{language, source}`) and, after a turn whose `prepare_data`, `generate_data` or `run_data_code` changed the data, `dataset: {preview, profile, bindings, warnings, context_lines, roles, changes, version}` plus `code` for a version `run_data_code` computed (`version` 1 when `generate_data` or `run_data_code` made the session's first data) | `413 too_long`, `409 run_active` (a turn, a toggle or a paste in flight in this session); a full queue is the stream's `error{code:"capacity"}` since 2026-10-14 (`503 capacity` before the stream until then), a sandbox run past the user's `AGENT_USER_MAX_RUNS` the stream's `error{code:"run_limit"}` since 2026-10-21 | | `POST /v1/sessions/{sid}/cancel` | Sets `abort_signal` and cancels the task; a waiting sandbox run leaves the queue | | -| `POST /v1/sessions/{sid}/versions/{version}/render` | `{theme}` → `{status: ok\|needs_attention\|failed, reason?, artifacts}`: the theme toggle renders `theme` of a finished version (0 is the latest) from its stored run form, synchronously, with no model call; `reason` is `canvas_padded`, `render` or `error` | `404 not_found`, `409 run_active` (the session or the user has a turn or a toggle in flight), `503 capacity` (no render slot in time, or the render store is full) | +| `POST /v1/sessions/{sid}/versions/{version}/render` | `{theme}` → `{status: ok\|needs_attention\|failed, reason?, artifacts}`: the theme toggle renders `theme` of a finished version (0 is the latest) from its stored run form, synchronously, with no model call; `reason` is `canvas_padded`, `render` or `error` | `404 not_found`, `409 run_active` (the session has a turn, a toggle or a paste in flight; per session since 2026-10-21), `503 capacity` (no render slot in time, or the render store is full) | | `GET /v1/sessions/{sid}/artifacts/{name}?v=` | Allowlist `plot-light.png`, `plot-dark.png`, `plot.py`, `data.csv`; a PNG exists only for a rendered theme and carries the footer strip (a 3200x1800 render is served as 3200x1864) unless `AGENT_WATERMARK=false` | `404` | | `GET /v1/sessions/{sid}/bundle?version=&include_data=` | The server-assembled feedback case bundle; its images carry the footer strip like the artifact route; with `AGENT_KEEP_REFUSALS` it also lists the session's refused messages as `refusals` `[{verdict, lang, text, at}]` | `404` | | `DELETE /v1/sessions/{sid}` | Purges the session, the dataset store and the render store | | @@ -374,7 +375,7 @@ Plugin order on `App`, where the first non-None result wins and a plugin never r | Injection through pasted data | The raw dataset never reaches a prompt or a tool argument; only bounded, sanitised, fenced samples do: a deterministic pre-filter over every header and cell for text addressed to an AI (a link of any scheme, `javascript:` included, is data and passes), then the dataset judge, which judges injection only and never personal data, on every header and the root's and the adapter's 5 sample rows and top values (up to 12 per column) with cells in full (values whose 40-character display copies coincide are all judged), in parts of about 4,000 characters, at parse time; the profile's at most 5 sample rows (cells at most 40 characters) for the root and the adapter; canonical headers; bindings validated on the server; adapter output bound to a schema and linted. The one model that reads raw pasted text is the transcriber (free text of at most 4,000 characters and 200 lines that the parser cannot read as a table): the pre-filter runs on the raw text first, the model answers only under the `TranscribedTable` schema (names, rows, a 200-character note), its table is parsed like any paste and shown to the user for confirmation, and the confirmation goes through the pre-filter and the judge like every other upload | | Injection through catalogue code, comments or image labels | Own fencing; validated adapter output; a tool-less reviewer with a fixed-id schema that can trigger at most one bounded repair | | Code escape | The two-profile AST validator, with every submodule of the packages it admits whole reviewed or banned; loader substitution; no executors anywhere; the harness's audit hooks in plot and data mode (no `exec` of plot code, no process, socket, native library or write beyond the outputs); the sandbox (no egress, environment or metadata server, read-only root filesystem) in a renderer service of its own with a zero-role service account; resource limits; a host timeout plus a confirmed kill and `sandbox delete`; a run watchdog (bytes, files and `MemAvailable`) on an in-memory, size-limited run volume that the service requires; per-job directory wipe; the phase-0 probe suite and the sibling-directory spike | -| Resource abuse (denial of wallet) | Ledger budgets including the judge; the run queue in front of the sandbox runs (one run in flight, one start a minute, at most 10 waiting); the per-user message limits (10 chat turns a minute, 200 a day), checked before the judge; a session closed after three scope refusals; one turn per user and one `plot_pipeline` per invocation; `max-instances=1`; a CSRF header and Origin check on the cookie-authenticated BFF; a spend cap on `aiplatform.googleapis.com` only (never on `run.googleapis.com`, which would pause `anyplot-api`); the `AGENT_ENABLED` kill switch | +| Resource abuse (denial of wallet) | Ledger budgets including the judge; the run queue in front of the sandbox runs (one run in flight, one start a minute, at most 10 waiting); the per-user message limits (10 chat turns a minute, 200 a day), checked before the judge; a session closed after three scope refusals; one turn per session, at most three sandbox runs per user across sessions (`AGENT_USER_MAX_RUNS`), and one `plot_pipeline` per invocation; `max-instances=1`; a CSRF header and Origin check on the cookie-authenticated BFF; a spend cap on `aiplatform.googleapis.com` only (never on `run.googleapis.com`, which would pause `anyplot-api`); the `AGENT_ENABLED` kill switch | | Data leakage | A server-derived HMAC user id; ownership checks on every route; no content in logs or spans (`ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS=false`, `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` unset); in-memory stores tied to the session lifetime plus an idle sweeper and a DELETE route, so anyplot stores the personal data a user plots nowhere unless they submit quick feedback, whose case bundle always holds the conversation, the code, the images and the dataset profile with its column names, sample rows and top values, any of which can show that data, and the full data file only when its data box is ticked; the translator drops arguments, tool outputs, thoughts and `error_details`; the `eu` endpoint; PNG only in the UI, no model HTML, no auto-loaded URLs | | The data sandbox used for something other than the plot's data (`run_data_code`) | The scope judge (a snippet whose job is to generate the plot's data is in scope; a general-purpose program is out of scope; code that touches files, the network or the system is an attack); the DATA validator profile (no plotting stack, sklearn or statsmodels, no private attribute on any receiver, a top-level `df`); a per-user daily count, the run queue's slot and a start in its rate window, and at most two calls per turn; the snippet runs in the same sandbox as a plot (no egress, read-only root, rlimits, watchdog) under the harness's audit hook | | Native code or raw memory from a data snippet (`ctypes` through a NumPy array, `from_address`, `as_strided`) | The validator's escape names on any receiver; the harness's audit hook refuses every `ctypes.*` event after numpy and pandas are imported, and a refusal the snippet caught still fails the run | @@ -436,7 +437,7 @@ Built on 2026-10-10: the harness, the two fixture generators and the 146 fixture - **Environment on anyplot-agents**, as `agents/cloudbuild.yaml` sets it: `ENVIRONMENT=production`, `GOOGLE_CLOUD_PROJECT=anyplot`, `GOOGLE_GENAI_USE_ENTERPRISE=TRUE`, `GOOGLE_CLOUD_LOCATION=eu`, `AGENT_LOCATION=eu` (never europe-west4), `AGENT_PROVIDER`, `AGENT_MODEL` and `AGENT_JUDGE_MODEL` from the substitutions `_PROVIDER`, `_MODEL` and `_JUDGE_MODEL`, whose defaults are the settings defaults (`anthropic-vertex`, `claude-haiku-5-5`, `claude-haiku-5-5`; the Gemini arm: `gemini`, `gemini-3.8-flash`, `gemini-3.5-flash-lite`), so a deploy switches the arm without a code change, `AGENT_LIBRARIES=matplotlib,seaborn` (`_LIBRARIES`), `AGENT_RENDERER=remote`, `AGENT_RENDER_URL=` (`_RENDER_URL`, which the deploy requires, because `--update-env-vars` would otherwise replace a working URL with nothing), `AGENT_ALLOWED_CALLERS=,` (`_API_CALLER`, by default the Compute Engine default service account, and `_SERVICE_ACCOUNT`, so a build cannot drop either), `AGENT_SERVICE_URLS=[,<_AUDIENCES>]`, and `ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS=false`. The budgets, limits and deadlines (the `AGENT_*` budgets, `AGENT_MAX_LLM_CALLS`, the run queue, the render and request bounds) are not set, so the deployed code's settings defaults apply (`agents/anyplot/settings.py`, see [Bounds](#bounds)) and a change to them is a reviewed code change; `ADK_MAX_LLM_CALLS` is not set either, because every run of the service passes its own `RunConfig`. On anyplot-renderer: `ENVIRONMENT=production`, `RENDERER_ALLOWED_CALLERS=,[,]`, `RENDERER_AUDIENCES=[,]`, never a tag URL, which Cloud Run does not accept as an audience; the limits keep their defaults (`agents/renderer/settings.py`). On anyplot-api: `AGENT_ENABLED` (true since 2026-10-11 through the deploy-api trigger's `_AGENT_ENABLED`; false ships the routes dark, answering 404), `AGENT_SERVICE_URL` (the agents service's URL, `_AGENT_SERVICE_URL`), and `AGENT_USER_ID_KEY` from Secret Manager (bound since 2026-10-11; the BFF answers 404 while any of the three is unset, so a deploy never breaks). On the app: `VITE_ENABLE_AGENT_CHAT`, a build-time flag that tree-shakes the chunk. - **Sessions and artifacts.** Phase 1 uses `InMemorySessionService`, `InMemoryArtifactService` and in-memory dataset and render stores; they are consistent because `max-instances=1`, and scale-to-zero shows "session expired". Phase 2 moves to `DatabaseSessionService` in a separate database `anyplot_agents` on `anyplot-db` with its own user and an hourly purge of sessions older than 24 h, because `alembic/env.py` has no `include_object` filter and ADK's `create_all` tables would read as drift; artifacts go to `GcsArtifactService` on a private EU bucket with a 1-day lifecycle, never `anyplot-images`. - **BFF** in `api/routers/agent.py`: `APIRouter(prefix="/debug/agent", dependencies=[Depends(require_admin)])`. A small refactor `require_admin_identity()` returns `AdminIdentity(email|None, via)` and `require_admin` wraps it unchanged. `user_id = "adm_" + HMAC(key, email or "token")[:16]`. POST requests need `Content-Type: application/json`, `X-Anyplot-Client: agent-chat/1` and an allowed Origin. ID tokens come from `google.oauth2.id_token.fetch_id_token` (skipped for localhost). The messages route is an async generator declared with `response_class=EventSourceResponse` (which is what gives FastAPI's automatic 15 s pings; Cloudflare returns 524 after 125 s), reads upstream with httpx `aiter_lines()`, assembles complete events, re-validates each event type against the `anyplot/1` allowlist, and yields `ServerSentEvent(event=..., raw_data=...)`; on upstream failure it emits `error{code:"upstream"}`. Routes mirror `/v1` plus `GET eligibility`, `POST sessions/{sid}/feedback`, and the triage routes `GET /debug/agent/cases`, `GET /debug/agent/cases/{id}` and `PATCH /debug/agent/cases/{id}`. The deploy smoke test expects 401 on `/debug/agent/status`. -- **SSE protocol `anyplot/1`**, translated from ADK events and never forwarded raw: `ready{v, run_id}`, sent when the turn starts, at once; `status{step:"queued", position, waiting}` mid-turn while its `plot_pipeline` or `run_data_code` call waits in the run queue, at once, on every change and every 15 s unchanged (`position` 1 runs next, `waiting` counts every queued entry, this one included); `status{step, attempt}` for the pipeline's steps; `message{text}` (root final text only), `plot{PlotResult}`, `refusal{code, text}`, `error{code, ref}` with codes `capacity` (also for a run that waited the queue's maximum), `deadline`, `guard_unavailable`, `upstream` and `internal`, and `done{llm_calls, tokens, bindings?, data_confirmed?, dataset?, language?}` (the last four are optional: after a run `bindings` and `data_confirmed` carry the session's bindings and whether the data is confirmed, so the page syncs its dropdowns and its step after a turn in which the root's `set_bindings` changed them, `dataset` carries the new data version after a turn in which the root's `prepare_data` or `generate_data` changed the data, and `language` carries the session language as `{language, source}`, `source` one of `default`, `pasted`, `detected` and `chosen`, so the page syncs its chip (the dataset route's answer carries the same field after a paste); a turn refused before the queue, such as the budget refusal, and a session gone meanwhile omit them). The translator tolerates the ADK 2.x `node_info` and `output` fields. A user already over the daily budget gets `ready`, `refusal{code:"budget"}` and `done` without waiting in the queue. Because queued time does not count toward the agents service's deadline, the BFF restarts its own turn budget (`AGENT_REQUEST_TIMEOUT_S`) on every queued status, with the queue's 15 s heartbeat on top because the run may start that long before its first event, and on the first event after the wait. Nothing moves a turn past `AGENT_TURN_MAX_S` (1,190 s), which stays below anyplot-api's `--timeout=1200`: a turn still queued when its run could no longer finish inside that cap ends with `error{code:"capacity"}`, and closing the upstream stream takes it out of the queue before it spends a token. +- **SSE protocol `anyplot/1`**, translated from ADK events and never forwarded raw: `ready{v, run_id}`, sent when the turn starts, at once; `status{step:"queued", position, waiting}` mid-turn while its `plot_pipeline` or `run_data_code` call waits in the run queue, at once, on every change and every 15 s unchanged (`position` 1 runs next, `waiting` counts every queued entry, this one included); `status{step, attempt}` for the pipeline's steps; `message{text}` (root final text only), `plot{PlotResult}`, `refusal{code, text}`, `error{code, ref}` with codes `capacity` (also for a run that waited the queue's maximum), `run_limit` (since 2026-10-21, a sandbox run past the user's `AGENT_USER_MAX_RUNS` across sessions), `deadline`, `guard_unavailable`, `upstream` and `internal`, and `done{llm_calls, tokens, bindings?, data_confirmed?, dataset?, language?}` (the last four are optional: after a run `bindings` and `data_confirmed` carry the session's bindings and whether the data is confirmed, so the page syncs its dropdowns and its step after a turn in which the root's `set_bindings` changed them, `dataset` carries the new data version after a turn in which the root's `prepare_data` or `generate_data` changed the data, and `language` carries the session language as `{language, source}`, `source` one of `default`, `pasted`, `detected` and `chosen`, so the page syncs its chip (the dataset route's answer carries the same field after a paste); a turn refused before the queue, such as the budget refusal, and a session gone meanwhile omit them). The translator tolerates the ADK 2.x `node_info` and `output` fields. A user already over the daily budget gets `ready`, `refusal{code:"budget"}` and `done` without waiting in the queue. Because queued time does not count toward the agents service's deadline, the BFF restarts its own turn budget (`AGENT_REQUEST_TIMEOUT_S`) on every queued status, with the queue's 15 s heartbeat on top because the run may start that long before its first event, and on the first event after the wait. Nothing moves a turn past `AGENT_TURN_MAX_S` (1,190 s), which stays below anyplot-api's `--timeout=1200`: a turn still queued when its run could no longer finish inside that cap ends with `error{code:"capacity"}`, and closing the upstream stream takes it out of the queue before it spends a token. - **Frontend.** A lazy `app/src/pages/AgentChatPage.tsx` at `debug/agent`, laid out as one chat column (since 2026-10-11, "chat first"). The data card (`app/src/sections/agent-chat/DataPanel.tsx`) is the first turn of the thread: a textarea with a 1 MB counter (200 KB until 2026-10-14), preview table, binding dropdowns, `.confirm()` at its foot, and the `# changed` block with `.undo()` after the assistant changed the data or computed it from a formula, without `.undo()` when the formula made the session's first data; a formula pasted into the textarea gets the server's `422 formula`, and the card offers `.send()`, which sends the text to the chat as a turn. The assistant's summary of a stored parse is the next turn, and each data change updates the card in place. After `.confirm()` the card collapses into a header pinned under the site's sticky masthead (`✓ data fits · N rows · M columns · version V`), whose `.show()` opens the full card again without leaving step 2; any binding change, undo, data request or new paste drops the confirmation on the server, and the page returns to step 1 with the card open. The header then stays, as `! confirm again · N rows · M columns · version V` with the phrase in bold on a warning-tinted surface, and its `.show()` leads back to the card from anywhere in the thread, for example after a data request sent from below the plot. The thread gets a warning notice that names what dropped the confirmation (`bindings changed`, `the data changed`, `not confirmed on the server`), and the card's step line repeats it next to `.confirm()`; the first binding setup after a paste, where nothing was confirmed yet, gets none. The step line follows the data the card shows: a data request or undo after the loss names the changed data, and a new paste, which nobody has confirmed yet, names no loss. A table read from free text that waits for `.use()` or `.discard()` holds the page in step 1, even when a turn reports the server's confirmation of the stored data. While the header shows, the page's `scroll-padding-top` keeps keyboard focus and scrolled-to content below it. Below the thread sit a progress grid (since 2026-10-12, the owner's variant A: one row per round, `first try`, `second try`, ... (`repair 1` until live round 9 of 2026-10-16, which read as no second round; a running repair round says what it repairs in words on the right, `repairing text size, layout`, from the rubric ids, `Round.defects`, that the round before passed on), the same four steps in the same columns so each step lines up under the round above, and how the round ended on the right, such as `✓ ok`, `✗ review: 3 findings`, `✗ 2 measured`, `✗ code check failed` or, for the shipped round of a `needs_attention` result, `~ 1 finding open` (the round marked `shipped`, which is not always the last: a repair that fails its check leaves the earlier render shipped); the step a round stopped at shows a red ✗ and the steps after it a hollow dot; the facts come from `PlotResult.rounds` and the `outcome` and `findings` that the `repairing` status carries for the round before it; the result card keeps the same grid under `# rounds` and names the shipped round the same way (`second try`), or, when an earlier round's render won, which one (`first try shipped of 2`); on a phone the round's label and outcome take a line above its steps), a Stop button (`AbortController` plus the cancel route) and the composer. While a turn without pipeline steps runs (the summary, a question, a data request), the thread shows an in-progress line in the assistant's slot (`summarizing your data …`, `working on your message …`) until the first reply or pipeline step; the progress grid shows only for a plot run, a queue position or pipeline steps, so the two never say the same thing. While anything is in flight, the bindings block says that the bindings and `.confirm()` wait, and a running parse says so under `.parse()`. Once a dataset exists, the composer always shows `.create_plot()`: before the confirmation as the quiet button next to `.send()`, whose click calls nothing and shows a notice under the composer that leads to `.confirm()` (`# confirm the data first · …`, with `.show()`, which scrolls to the button and focuses it; for a table read from text that still waits, it leads to `.use()`). The notice reads the data card at every render, so a table read from text that arrives or is discarded after the click changes its text and the target of its `.show()`. In step 2 it is the filled action: with an empty box it starts the first plot as it is, with text the text goes as the user's turn, so the assistant makes the first plot with that change. Once the library has a plot, the composer refines with `.send()` (a question or a change on the latest version) and keeps a quiet `.start_over()` beside it (since 2026-10-12, `.create_plot()` before: the owner could not tell the two apart), described as "new from the catalogue plot, without your changes": a fresh plot of the same data from the catalogue code. A wide table (since 2026-10-12, `agents/anyplot/data/shape.py`): when the spec wants one required label role and exactly one required numeric role, no numeric series family, and the table holds 2 to 20 numeric columns and no text or boolean column (date columns are kept as keys), the dataset answers carry `suggestion`, the `pivot_longer` into two columns named in the session language (`Weekday`, `Month` or `Year` when every melted header is a weekday, month or year of one language, else `Category`, and `Value`; German `Wochentag`, `Monat`, `Jahr`, `Kategorie`, `Wert`; `shape.LONG_NAMES` for en, de, fr, es, it, pt, nl and pl, English otherwise; English for every session until live round 9 of 2026-10-16, where `Weekday` and `Value` became a German heatmap's axis and colorbar titles; the language route and every turn's `done` return the recomputed offer, so the card always names what `.to_long()` applies; a name that an input column already has gives way to the role's name, then to a numbered one, so names never decide the offer; `.to_long()` binds the two new columns to the roles they were made for, `LongSuggestion.bindings`, so `Category` never takes a spec's `category` role by its name), since live test round 8 of 2026-10-16, where the role names `y` and `value` became the plot's title "Wert nach Wochentag und Stunde"; the data card shows it as `# wide table` with `.to_long()`, which `POST /v1/sessions/{sid}/dataset/to_long` applies without a model call as the next version with the new columns bound to their roles, the user's bindings on the kept columns carried over and the defaults for the rest (undo as usual), and the root's profile tool carries it as `to_long` so the summary names it in one sentence. Since live test round 7 of 2026-10-16 a wide matrix gets it too: a spec whose required roles are two label roles of a grid or grouped chart (`shape.MATRIX_ROLES`: a heatmap's `x` and `y`, a grouped box's `category` and `subcategory`, a stacked bar's `component`; never a flow's `source` and `target`, a hierarchy's `name` and `parent` or a party's `color`) and one numeric role, and a table of one row-label column with a distinct label in every row (the one text column, else the one date column, else a first column of whole numbers that is a year or every hour of a range) plus at least three numeric columns (`Stunde;Mo;...;So`) is offered the `pivot_longer` of the numeric columns into the second label role, the row labels kept as the key for the first. Years and parts of a date are keys in both rules, never melted (an unnamed 1-to-12 column counts as a month only right after a year column that repeats), and so is a first column that numbers the rows (`id`, `Nr`, `Stunde` 0 to 3), which keeps the category of `id,a,b,c` on `id`. The numeric columns the offer melts (`bindings.label_siblings`) are never bound by default to a role that takes labels, also when `carry_bindings` refills the open roles after an undo: the role stays open, so the bindings are incomplete until `.to_long()` (the default had bound the heatmap's `y` to `Mo` and `value` to `Di`, and a confirmed run drew a meaningless matrix after 170 s). Bound so by hand, the binding is valid with a warning, shown under the dropdown (the dataset answer's `suggestion.siblings`) and in the session block, where the root offers `.to_long()` before any plot; blocking `.confirm()` on it is an open owner decision. The chat header carries a language chip next to the library pills (since 2026-10-12): `lang: en`, `lang: de · from data` after a paste set it from the notes around the table or the column names (since 2026-10-14; one `#` notice), `lang: de · detected` after the first typed message set it (the thread then shows one `#` line that names the chip), or `lang: de · chosen`. A click opens a popover with the ten common languages and a field for any other language in Latin letters; a choice calls the language route, and a `done` with `language` updates the chip, never moving it from `chosen` back down to `detected` or `default`. Keyboard focus follows the steps: after the user's own `.confirm()` it moves to `.create_plot()` before the first plot and to the header's `.show()` after it, a turn that reports the confirmation again leaves it where it is, and a key press on a composer button that a run replaces with Stop moves it into the message box, so a second press never stops the run. The **result card** (`app/src/sections/agent-chat/ResultCard.tsx`) reuses the plot page's overlay actions: the rendered plot shown inline as a blob URL, in a box that takes the loaded PNG's own aspect because the footer strip makes it taller than 16:9, with a light and dark switch that renders the other theme on demand through the theme toggle route, **Copy image** (Clipboard API `navigator.clipboard.write([new ClipboardItem({"image/png": blob})])`, falling back to download where unsupported), **Download PNG** for every rendered theme and **Open full size**; the adapted code in `CodeHighlighter` with **Copy code** (`useCopyCode`, one click), **Download plot.py** and **Download data.csv** (the pair runs unchanged); the change list and residual notes; and the quick-feedback control. The card has no composer of its own (since 2026-10-12): a change request or a question about the plot goes through the thread's one composer below it, and the assistant decides whether it is a change or an answer. Earlier versions stay reachable in the thread, each with its own image and code. The same card is reused unchanged when the feature goes public. `app/src/lib/sse.ts` parses the stream over `fetchWithAuth(...).body.getReader()`. The `.adapt()` button is the fourth overlay button in `app/src/sections/spec-detail/SpecDetailView.tsx`, wired through `onUseWithMyData` from `SpecPage.tsx`, and renders only when `CONFIG.features.agentChat && (CONFIG.isDev || adminHint) && eligible`, where `adminHint` is a localStorage flag (`anyplot.adminHint`, `app/src/utils/adminAuth.ts`) that `DebugPage` sets after `/debug/status` succeeds and clears on a refusal, and `eligible` comes from the eligibility route (`app/src/hooks/useAgentEligibility.ts`). It does a full navigation so Cloudflare Access can intercept, and public pages never probe `/api/debug/*`. As built on 2026-10-10: the dataset response lists the spec's roles (`{name, kinds, required, variadic, description}`), so every role gets a dropdown, a variadic family one per member (`y1`, `y2`, ...) plus an empty one for the next, kept contiguous on the client; a refused binding set answers with the check's lines, which the panel lists; the `plot` event carries the stored `version`, which addresses the artifact and theme toggle routes (see [Agent chat](../reference/api.md#agent-chat-admin-only)); the page starts nothing while a turn, a theme render, a parse or a binding change is in flight, since the server would refuse it with `409 run_active`; Stop calls the cancel route and keeps the stream open until the server's `done` (at most 30 s), so a result stored just before the stop still arrives; the session is also deleted on `pagehide` with `keepalive`; a request without a readable answer (in production usually an expired Cloudflare Access session) reloads the page once, then offers a reload link; the queue position shows as a small counter and as a `queued · 2 of 3` line above the progress grid; the button adds `source=plot_page` to the URL, which the page reads for `agent_open` and then drops. `app/scripts/agent-bff-mock.mjs` serves the documented BFF routes and a scripted stream locally (for `scatter-basic` and the series family of `line-multi`), so the page can be driven in a browser without the API, the agents service or a model. - **Analytics** (enum properties only, documented in [Plausible](../reference/plausible.md#agent-chat-admin-only)): the pageview `/debug/agent`, `agent_open{library,source,spec}`, `agent_data_parsed{status,size_bucket}`, `agent_data_confirmed{size_bucket}`, `agent_data_prepared{changes}`, `agent_data_undo{version}`, `agent_plot_rendered{library,status,repaired,spec}`, `agent_guardrail_block{reason}`, `agent_session_saved{include_data}`, `agent_result_feedback{reaction,include_data}`, and `copy_code{page:'agent_chat',method:'agent'}`, all built. The session language fires no event: the chat is admin-only, and the chip's numbers would be tiny. diff --git a/docs/reference/api.md b/docs/reference/api.md index e42c9b6ff8..e0acb2bd58 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -486,14 +486,14 @@ The routes mirror the agents service's `/v1` API. All paths below start with | `GET /eligibility?spec=&library=` | Spec id and library id | The agents service's answer, passed through | | `POST /sessions` | `{spec_id, library, locale}` | `{session_id, eligibility}`; `404 not_found` when the spec has no implementation for the library | | `POST /sessions/{sid}/library` | `{spec_id, library}` | Switches the library; the dataset and bindings stay | -| `POST /sessions/{sid}/dataset` | `{text, confirmed?, source?}`, at most 1 MB (1,048,576 bytes) of UTF-8 (200 KB before 2026-10-14); `source` is the name of the file the page read `text` from (1 to 1,024 characters), which the agents service sanitises, screens on its own and answers as `file_name` (`(name withheld)` when its screen refused it), never as a note and never in the text | `{preview, profile, bindings, warnings, context_lines, roles, suggestion, version, language}` for a table, stored as version 1 of the data (an earlier paste's undo history is dropped; `suggestion` is the wide-table offer or `null`, see [Data changes](#data-changes); `language` is the session language as `{language, source}` after the paste, see [Session language](#session-language)): CSV, TSV, semicolon or pipe text; JSON as an array of records, an object of column lists, an object of values or an array of `[category, value]` pairs (the last two become the columns `category`, the keys kept as text, and `value`; a repeated key keeps its last value with a warning); JSON Lines, one JSON object per line, read as JSON records (source format `json_lines`; up to 10 lines above the first object, such as a title, become notes; a `source` named `.jsonl` or `.ndjson` is read as JSON Lines first, so a file of one record is one row; a line that is no object is `422 unparseable` with its line number); a JSON-stat 2.0 dataset, as statistics offices' APIs answer it (or a JSON-stat 1.x bundle of one dataset), flattened into a long table with one column per dimension of more than one category, named by its label and holding the category labels as text, and a typed `value` column, the one-category dimensions and the dataset's label, source and notes coming back in `context_lines` (source format `json_stat`; an inconsistent dataset is `422 unparseable`); the same shapes as a Python literal, also behind an assignment such as `cat_day = {...}`, with single quotes, trailing commas and tuples (text that starts with a bare `(` and is no literal is read as delimited text); a column whose every cell carries the same unit (`65 %`, `15,6 h`, `€ 5`, also with a no-break space) is read as numbers and named ` ()` with a warning, unless the header already names the unit. One column of values (integers, numbers, dates or booleans, such as a header and 800 commute times for a histogram) is a table, stored as any paste; without a header line its first line is kept as the first value, the column is named `value`, and a warning says so (a list of 20,001 values without a header is `422 unparseable`, never cut). A column of digit codes such as postal codes with leading zeros is a table too, kept as text, and so is a column of categories under a header where at least one category repeats; when that header has the categories' form (no `_`, brackets, `:`, `/`, `%`, `#` or digit, and no more words than a category), the warning `read '
' as the column name` says which line became the name, since `Tier` above `Hund` and `Apfel` above `Birne` look alike. For text that is no table (prose, `label value` lines without a separator, a list of names typed one per line) of at most 4,000 characters and 200 non-blank lines, `{transcribed: true, text, preview, profile, warnings, note}`, the table the judge model read, with its CSV in `text` and nothing stored: the page shows it for confirmation, and confirming posts that CSV through this route again with `confirmed: true`, which skips the free-text fallback (a one-column table would otherwise be read again) and keeps every row (no line is left out as a note or a total), never the parse, the pre-filter, the judge or the store. A pasted table may carry other lines: a title, metadata or notes above it, a source line or notes below it, and up to three summary rows at the end (`Total`, `Summe`…) are left out, each kind with one entry in `warnings`. The lines left out above and below the table (not the summary rows) come back in `context_lines`, one text line each and at most 2,000 characters in all: the notes the assistant reads with the data. They pass the same pre-filter as the cells and then the scope judge; when either refuses them, or the judge does not answer, `context_lines` is empty and `warnings` starts with the reason, while the table is stored all the same and no strike is counted. `413 too_long` above the limit, `422 unparseable` when neither the parser nor the model finds a table (with a `cause` that names what the data fails on: the lines with too many or too few fields, the line and column of a JSON error, the cap a table breaks, free text past the transcriber's character or line limit, which is never stored as it parsed, or the model running out of output room on the text, or out of time on a text of at least half those limits, with its line and character count), `422 formula` when the text asks for a formula such as `sin(x) von -pi bis pi` or `y = x**2` (no model reads it; ask for the formula in the chat, where the assistant computes the table, see [Data changes](#data-changes)), `422 code` when the text is a program that generates data, such as Python, JavaScript or R (checked before any parse; send it to the chat, where the assistant runs it; only text within the chat's 2,000 characters can move there), `403 data_refused` when a header, a cell or the raw text addresses an AI (personal data such as names, addresses, e-mail addresses, phone numbers and web addresses is allowed), `403 session_closed` in a session the scope check closed, `409 run_active` while a turn of yours runs in any session or another paste of yours is in flight, `429 rate_limited` past 10 pastes in a minute and `429 daily_limit` past 100 in a UTC day or past the daily token budget (only a paste that reaches the checks counts; one refused as a formula, code or unparseable text does not), `429 locked_out` for the rest of the UTC day after three messages or pastes classed as attacks, `503 guard_unavailable` when the judge model does not answer (an error, a quota, an answer that does not parse, or no answer in time on a short text, a stalled endpoint) | +| `POST /sessions/{sid}/dataset` | `{text, confirmed?, source?}`, at most 1 MB (1,048,576 bytes) of UTF-8 (200 KB before 2026-10-14); `source` is the name of the file the page read `text` from (1 to 1,024 characters), which the agents service sanitises, screens on its own and answers as `file_name` (`(name withheld)` when its screen refused it), never as a note and never in the text | `{preview, profile, bindings, warnings, context_lines, roles, suggestion, version, language}` for a table, stored as version 1 of the data (an earlier paste's undo history is dropped; `suggestion` is the wide-table offer or `null`, see [Data changes](#data-changes); `language` is the session language as `{language, source}` after the paste, see [Session language](#session-language)): CSV, TSV, semicolon or pipe text; JSON as an array of records, an object of column lists, an object of values or an array of `[category, value]` pairs (the last two become the columns `category`, the keys kept as text, and `value`; a repeated key keeps its last value with a warning); JSON Lines, one JSON object per line, read as JSON records (source format `json_lines`; up to 10 lines above the first object, such as a title, become notes; a `source` named `.jsonl` or `.ndjson` is read as JSON Lines first, so a file of one record is one row; a line that is no object is `422 unparseable` with its line number); a JSON-stat 2.0 dataset, as statistics offices' APIs answer it (or a JSON-stat 1.x bundle of one dataset), flattened into a long table with one column per dimension of more than one category, named by its label and holding the category labels as text, and a typed `value` column, the one-category dimensions and the dataset's label, source and notes coming back in `context_lines` (source format `json_stat`; an inconsistent dataset is `422 unparseable`); the same shapes as a Python literal, also behind an assignment such as `cat_day = {...}`, with single quotes, trailing commas and tuples (text that starts with a bare `(` and is no literal is read as delimited text); a column whose every cell carries the same unit (`65 %`, `15,6 h`, `€ 5`, also with a no-break space) is read as numbers and named ` ()` with a warning, unless the header already names the unit. One column of values (integers, numbers, dates or booleans, such as a header and 800 commute times for a histogram) is a table, stored as any paste; without a header line its first line is kept as the first value, the column is named `value`, and a warning says so (a list of 20,001 values without a header is `422 unparseable`, never cut). A column of digit codes such as postal codes with leading zeros is a table too, kept as text, and so is a column of categories under a header where at least one category repeats; when that header has the categories' form (no `_`, brackets, `:`, `/`, `%`, `#` or digit, and no more words than a category), the warning `read '
' as the column name` says which line became the name, since `Tier` above `Hund` and `Apfel` above `Birne` look alike. For text that is no table (prose, `label value` lines without a separator, a list of names typed one per line) of at most 4,000 characters and 200 non-blank lines, `{transcribed: true, text, preview, profile, warnings, note}`, the table the judge model read, with its CSV in `text` and nothing stored: the page shows it for confirmation, and confirming posts that CSV through this route again with `confirmed: true`, which skips the free-text fallback (a one-column table would otherwise be read again) and keeps every row (no line is left out as a note or a total), never the parse, the pre-filter, the judge or the store. A pasted table may carry other lines: a title, metadata or notes above it, a source line or notes below it, and up to three summary rows at the end (`Total`, `Summe`…) are left out, each kind with one entry in `warnings`. The lines left out above and below the table (not the summary rows) come back in `context_lines`, one text line each and at most 2,000 characters in all: the notes the assistant reads with the data. They pass the same pre-filter as the cells and then the scope judge; when either refuses them, or the judge does not answer, `context_lines` is empty and `warnings` starts with the reason, while the table is stored all the same and no strike is counted. `413 too_long` above the limit, `422 unparseable` when neither the parser nor the model finds a table (with a `cause` that names what the data fails on: the lines with too many or too few fields, the line and column of a JSON error, the cap a table breaks, free text past the transcriber's character or line limit, which is never stored as it parsed, or the model running out of output room on the text, or out of time on a text of at least half those limits, with its line and character count), `422 formula` when the text asks for a formula such as `sin(x) von -pi bis pi` or `y = x**2` (no model reads it; ask for the formula in the chat, where the assistant computes the table, see [Data changes](#data-changes)), `422 code` when the text is a program that generates data, such as Python, JavaScript or R (checked before any parse; send it to the chat, where the assistant runs it; only text within the chat's 2,000 characters can move there), `403 data_refused` when a header, a cell or the raw text addresses an AI (personal data such as names, addresses, e-mail addresses, phone numbers and web addresses is allowed), `403 session_closed` in a session the scope check closed, `409 run_active` while a turn, a theme render or another paste is in flight in this session (your other sessions never block it), `429 rate_limited` past 10 pastes in a minute and `429 daily_limit` past 100 in a UTC day or past the daily token budget (only a paste that reaches the checks counts; one refused as a formula, code or unparseable text does not), `429 locked_out` for the rest of the UTC day after three messages or pastes classed as attacks, `503 guard_unavailable` when the judge model does not answer (an error, a quota, an answer that does not parse, or no answer in time on a short text, a stalled endpoint) | | `PUT /sessions/{sid}/bindings` | `[{role, column}]`, at most 50 | The agents service's `{bindings, complete, missing_roles, warnings}`; `422 no_dataset` before a parse, `422 invalid` with the check's `errors` lines for a binding the spec roles or the columns refuse, `409 run_active` while a turn or a render is in flight. A change of the bindings drops the data confirmation below | | `PUT /sessions/{sid}/language` | `{language}`: a short language name or code such as `de`, `Deutsch` or `Swedish`, 1 to 40 characters that match `^[A-Za-z][A-Za-z -]{0,39}$` | The agents service's `{language, source}`, the stored form (`English` comes back as `en`, `Swedish` as typed) with the source now `chosen`, plus, when the session has data, `suggestion`: the `.to_long()` offer (or null) with its new column names in the new language. The replies and the plot text of the next version follow it, and the language a typed message implies never replaces it (see [Session language](#session-language)). Versions made before keep their text. `422` for a value outside the pattern, `409 run_active` while a turn or a render is in flight | -| `POST /sessions/{sid}/dataset/convert` | `{data_base64, sheet?}`: a dropped binary data file as base64, at most 5 MB (5,242,880 bytes) raw; `sheet` a sheet (or `.npz` array) name from a previous answer's `sheets` | `{text, format, kind, rows, columns, sheet, sheets, sheet_count, warnings, dropped}`: the file read on the server, in the renderer's sandbox, into the text the paste box takes, and nothing stored. Values keep the precision they came with: every fraction digit of a timestamp and its offset in its own zone (UTC, counted as `utc_timestamps`, only where the zone cannot be read), decimals and 64-bit integers exactly, float32 in its shortest text; `dropped` names the columns left out (durations, intervals, binary, map, union, nested; at most 20). `kind` is the format found from the first bytes (`xlsx`, `xlsm`, `xlsb`, `xls`, `ods`, `parquet`, `feather`, `arrow`, `npy`, `npz`); `text` is CSV, or JSON records when a Parquet or Arrow table has list columns (`format` `json`), which the dataset route expands into one row per value; a spreadsheet comes as its raw cell grid, so a title above the header stays a note. `sheet` is the sheet read (the first visible one with a cell unless one was asked for), `sheets` at most ten visible ones with cells (an empty sheet is not offered) and `sheet_count` all of them; `warnings` counts `formulas_without_value` (empty cells, xlsx and xlsm only), `hidden_sheets`, `dropped_columns`, `list_columns`, `utc_timestamps` and `renamed_columns` (a repeated column name given a `_2` suffix). Sheet names come whole, so a name from `sheets` can be sent back as is; a name over 255 characters, with a control or bidi character, or with a path separator is left out of the list. Send `text` to `POST /sessions/{sid}/dataset` with the file name and sheet as `source`. `413 too_long` above 5 MB, `422 invalid` for a body that is not this shape or no base64, `422 file_unsupported`, `file_encrypted` (remove the password), `file_pickle`, `file_hdf5` (never read), `file_corrupt` (also a zip member that inflates to another size than it declares, or a Parquet footer whose row groups do not add up), `file_archive_limits` (a zip declaring more than 100 MB, 250 times its size or 2,000 entries), `file_too_many_rows` (20,000, or 20,200 raw rows of a sheet, measured from the span of its cells before the sheet is loaded), `file_too_many_columns` (50), `file_formulas_only` (a sheet whose cells are all formulas without saved values: open it in Excel and save it once), `file_too_large` (more than 1 MB of text), `file_empty`, `file_sheet_not_found`, `file_array_shape` (no 2-D or structured array), `file_object_array` (a pickled NumPy array), `file_timeout` (10 seconds), `file_too_complex` (the sandbox's memory or disk limit, or a reader that died without an answer) and `file_unreadable`, each with `measured` and `limit` where a cap was broken; `403 session_closed`, `409 run_active` (another conversion of yours in flight), `429 rate_limited` past 5 conversions in a minute and `429 daily_limit` past 30 in a UTC day or past the daily token budget, `429 locked_out`, `503 capacity` (no render slot within 20 seconds) and `503 convert_unavailable` (the renderer cannot convert files) | +| `POST /sessions/{sid}/dataset/convert` | `{data_base64, sheet?}`: a dropped binary data file as base64, at most 5 MB (5,242,880 bytes) raw; `sheet` a sheet (or `.npz` array) name from a previous answer's `sheets` | `{text, format, kind, rows, columns, sheet, sheets, sheet_count, warnings, dropped}`: the file read on the server, in the renderer's sandbox, into the text the paste box takes, and nothing stored. Values keep the precision they came with: every fraction digit of a timestamp and its offset in its own zone (UTC, counted as `utc_timestamps`, only where the zone cannot be read), decimals and 64-bit integers exactly, float32 in its shortest text; `dropped` names the columns left out (durations, intervals, binary, map, union, nested; at most 20). `kind` is the format found from the first bytes (`xlsx`, `xlsm`, `xlsb`, `xls`, `ods`, `parquet`, `feather`, `arrow`, `npy`, `npz`); `text` is CSV, or JSON records when a Parquet or Arrow table has list columns (`format` `json`), which the dataset route expands into one row per value; a spreadsheet comes as its raw cell grid, so a title above the header stays a note. `sheet` is the sheet read (the first visible one with a cell unless one was asked for), `sheets` at most ten visible ones with cells (an empty sheet is not offered) and `sheet_count` all of them; `warnings` counts `formulas_without_value` (empty cells, xlsx and xlsm only), `hidden_sheets`, `dropped_columns`, `list_columns`, `utc_timestamps` and `renamed_columns` (a repeated column name given a `_2` suffix). Sheet names come whole, so a name from `sheets` can be sent back as is; a name over 255 characters, with a control or bidi character, or with a path separator is left out of the list. Send `text` to `POST /sessions/{sid}/dataset` with the file name and sheet as `source`. `413 too_long` above 5 MB, `422 invalid` for a body that is not this shape or no base64, `422 file_unsupported`, `file_encrypted` (remove the password), `file_pickle`, `file_hdf5` (never read), `file_corrupt` (also a zip member that inflates to another size than it declares, or a Parquet footer whose row groups do not add up), `file_archive_limits` (a zip declaring more than 100 MB, 250 times its size or 2,000 entries), `file_too_many_rows` (20,000, or 20,200 raw rows of a sheet, measured from the span of its cells before the sheet is loaded), `file_too_many_columns` (50), `file_formulas_only` (a sheet whose cells are all formulas without saved values: open it in Excel and save it once), `file_too_large` (more than 1 MB of text), `file_empty`, `file_sheet_not_found`, `file_array_shape` (no 2-D or structured array), `file_object_array` (a pickled NumPy array), `file_timeout` (10 seconds), `file_too_complex` (the sandbox's memory or disk limit, or a reader that died without an answer) and `file_unreadable`, each with `measured` and `limit` where a cap was broken; `403 session_closed`, `409 run_active` (another conversion in flight in this session), `429 rate_limited` past 5 conversions in a minute and `429 daily_limit` past 30 in a UTC day or past the daily token budget, `429 locked_out`, `503 capacity` (no render slot within 20 seconds) and `503 convert_unavailable` (the renderer cannot convert files) | | `POST /sessions/{sid}/dataset/confirm` | none | Step 1 of two: you confirm that the stored dataset and its bindings fit; `{confirmed: true, rows, columns}`. The plot pipeline runs only on confirmed data (`create_plot` before it answers `plot{status: "not_ready", reason: "data_unconfirmed"}`); a new paste, a transcription's `.use()`, a binding change, a change of the data by the assistant or an undo drops the confirmation, a library switch keeps it. `422 no_dataset` before a parse, `409 run_active` while a turn or a render is in flight | | `POST /sessions/{sid}/dataset/undo` | none | Undo the assistant's last change to the data (see [Data changes](#data-changes)): the version before it is the current dataset again, `{preview, profile, bindings, warnings, context_lines, roles, suggestion, version}` like the dataset route (the wide-table offer comes back with a wide version), unconfirmed. The service keeps the last five versions. `422 nothing_to_undo` when the paste was never changed or the first data came from a formula, `409 run_active` while a turn or a render is in flight | | `POST /sessions/{sid}/dataset/to_long` | none | Make a wide table long for the plot's category and value roles (the page's `.to_long()`, see [Data changes](#data-changes)): the service recomputes its `suggestion` and applies that `pivot_longer` without a model call, as the next version with the default bindings for the new columns, answered like the undo route plus `changes`, unconfirmed. `422 no_suggestion` when the table is not wide for this plot, `409 run_active` while a turn or a render is in flight | -| `POST /sessions/{sid}/messages` | `{text}` (at most 2,000 characters), `{"action": "create_plot"}` or `{"action": "summarize_data"}` | An SSE stream in protocol `anyplot/1`; `413 too_long` above the limit, `409 run_active` while you have a turn or a theme render in flight in any session; a full run queue ends the turn's stream with `error {"code": "capacity"}` (see [SSE protocol](#sse-protocol-anyplot1)). `summarize_data` asks the assistant for the step-1 summary of the data (rows, columns with types and units, missing values, percentages, the proposed bindings, one question when something is ambiguous) without the scope judge, like `create_plot`; when the stored dataset kept notes pasted with the table (`context_lines`, judged when they were pasted), the turn carries them to the assistant fenced as ``, so it can act on a request in them, such as "make it a line chart" | +| `POST /sessions/{sid}/messages` | `{text}` (at most 2,000 characters), `{"action": "create_plot"}` or `{"action": "summarize_data"}` | An SSE stream in protocol `anyplot/1`; `413 too_long` above the limit, `409 run_active` while this session has a turn, a theme render or a paste in flight (your other sessions, such as a second browser window, never block it); a full run queue ends the turn's stream with `error {"code": "capacity"}`, and a plot run or data snippet while you already have three such runs queued or running in other sessions with `error {"code": "run_limit"}` (see [SSE protocol](#sse-protocol-anyplot1)). `summarize_data` asks the assistant for the step-1 summary of the data (rows, columns with types and units, missing values, percentages, the proposed bindings, one question when something is ambiguous) without the scope judge, like `create_plot`; when the stored dataset kept notes pasted with the table (`context_lines`, judged when they were pasted), the turn carries them to the assistant fenced as ``, so it can act on a request in them, such as "make it a line chart" | | `POST /sessions/{sid}/cancel` | None | `204`; a turn that still waits leaves the run queue | | `POST /sessions/{sid}/outcome` | `{action, version?, reaction?}`: `action` is `copied_code`, `copied_image`, `downloaded_png`, `downloaded_code`, `downloaded_data`, `saved_session` or `feedback`; `version` a JSON integer from 0 to 999 (0 for the session); `reaction` only with `feedback`, one of the four reactions; nothing else | `204`: the page's content-free outcome beacon, forwarded to the agents service, which writes the `outcome` telemetry line ([Agent usage telemetry](agent-telemetry.md)); `429 rate_limited` / `daily_limit` past 30 a minute or 2,000 a day per user. The page ignores every error | | `POST /sessions/{sid}/versions/{version}/render` | `{"theme": "light"}` or `{"theme": "dark"}`; `version` is 0 to 999, where 0 is the latest version | `{status, reason?, artifacts}` once the render is done (see [Theme toggle](#theme-toggle)) | @@ -696,8 +696,9 @@ so asking again renders it once more and then answers from the record. An `error` is not recorded, so you can try again. The route answers `409 run_active` while the session has a queued or running -turn or another theme render, or while you have one in another session (a -turn in the session also gets `409 run_active` while the theme renders), +turn, another theme render or a paste in flight (a turn in the session also +gets `409 run_active` while the theme renders; your other sessions are not +blocked), `404 not_found` for an unknown version or one whose render was swept, and `503 capacity` when no render slot came free within 120 seconds or the agents service's render store is full. @@ -903,7 +904,7 @@ the variable is unset and `unavailable` when the query fails; both come with zer | `message` | `{text}`: the assistant's reply as plain text, at most 1,200 characters; a longer reply arrives cut to 1,200 characters when the turn ran the plot pipeline, and as the `out_of_scope` refusal otherwise | | `plot` | `{status, reason, attempts, artifacts, changes, residual_defects, version, rounds}`; `version` only on `ok` and `needs_attention`; `rounds` lists each attempt as `{attempt, outcome, findings, step, shipped, defects}` (`step` is the pipeline step the round ended in, `shipped` marks the round whose render the result shipped, `defects` are the rubric ids the round passed on to the repair), where `outcome` is `ok` (the review passed), `review` (the review named `findings`), `measured` (measured findings went to the repair), `render_failed`, `check_failed`, `answer_unusable`, `unreviewed` or `error` | | `refusal` | `{code, text}`; `code` is `out_of_scope`, `needs_data` (the request needs data the service would have to fetch, also code that reads its data from a URL or a file), `other_library` (this plot in another library or language: the fixed answer points to the plot's catalogue page and, by default, does not count toward closing the session; with `AGENT_OTHER_LIBRARY_COUNTS=true` it does), `unsupported_content`, `budget`, `locked_out` (paused until the next UTC day after three messages or pastes classed as attacks), `rate_limited` (more than 10 messages in a minute), `daily_limit` (more than 200 messages in a UTC day) or `session_closed` (the session's third `out_of_scope` or `attack` refusal, and every turn after it: reload the page to start a new session; the refusal before it ends with a fixed warning sentence), and `text` is the fixed reply for it in the language the scope judge detects in your message, or in the session language for a refusal sent before the judge runs (`budget`, `locked_out`, `unsupported_content`, `rate_limited`, `daily_limit`, and `session_closed` after the closing turn), which is English until your pasted data, your first message or your choice sets another (see [Session language](#session-language)): English, German, French, Spanish, Italian, Portuguese, Dutch, Polish, Chinese, Vietnamese or Russian, and English for any other language | -| `error` | `{code, ref}`; `code` is `capacity` (the run queue was full when the turn's plot run or data snippet asked for a place, the run waited the queue's maximum of 600 seconds, or the BFF's turn cap ran out while it waited), `deadline`, `guard_unavailable`, `upstream`, or `internal`; `ref` is the request id | +| `error` | `{code, ref}`; `code` is `capacity` (the run queue was full when the turn's plot run or data snippet asked for a place, the run waited the queue's maximum of 600 seconds, or the BFF's turn cap ran out while it waited), `run_limit` (you already have three plot runs or data snippets queued or running in other sessions, `AGENT_USER_MAX_RUNS`), `deadline`, `guard_unavailable`, `upstream`, or `internal`; `ref` is the request id | | `done` | `{llm_calls, tokens, bindings?, data_confirmed?, dataset?, language?, suggestion?}`: after a run, the session's bindings and whether the data is confirmed, so the page syncs its dropdowns and its step after a turn in which the root's `set_bindings` changed them; `dataset` only after a turn in which the assistant changed the data or computed it from a formula (see [Data changes](#data-changes)): `{preview, profile, bindings, warnings, context_lines, roles, changes, version}`, the dataset route's shape (`roles` at most 50; `context_lines`, the pasted notes, carried over to every version made from the paste) plus the turn's change lines, for the page's preview table; `version` is 1 when a formula or the user's code made the session's first data; `code`, only on a version computed from the user's code, holds the Python that ran; `language` after every run: `{language, source}`, the session language and how it was set (`default`, `pasted`, `detected` or `chosen`), so the page syncs its chip; `suggestion` after every run on a session with data: the `.to_long()` offer, or null, with its new column names in the session language, so the data panel's card names what `.to_long()` applies | The BFF re-frames the upstream stream instead of forwarding it. It assembles @@ -930,6 +931,14 @@ streams `ready`, the `queued` statuses if it has to wait, the pipeline steps, `plot`, `message`, `done`. A turn whose run finds the queue full ends with `error {"code": "capacity"}` and `done`. +Each browser window opens a session of its own, and your sessions work in +parallel: a turn, a paste or a data change in one window never waits for +another window, and only their sandbox runs share the queue. You can have at +most three such runs queued or running at the same time across your sessions +(`AGENT_USER_MAX_RUNS`); a turn whose run would be the fourth ends with +`error {"code": "run_limit"}` and `done`, before the queue's length is checked. +Within one session it stays one thing at a time (`409 run_active`). + Before the scope check, and without any model call, the stream sends `ready`, a `refusal` and `done` at once when you are paused for the day after three messages or pastes the checks classed as attacks (`locked_out`; the count lives diff --git a/tests/unit/agents/runtime/test_convert_route.py b/tests/unit/agents/runtime/test_convert_route.py index 155df0c203..0f4890b20f 100644 --- a/tests/unit/agents/runtime/test_convert_route.py +++ b/tests/unit/agents/runtime/test_convert_route.py @@ -188,17 +188,48 @@ async def test_conversions_have_their_own_window_and_leave_the_pastes_alone( clock.advance(MESSAGE_WINDOW_S) assert (await convert(client, sid)).status_code == 200 - async def test_one_conversion_per_user_at_a_time( + async def test_one_conversion_per_session_at_a_time( self, client: httpx.AsyncClient, backend: FakeBackend, runtime: Runtime ) -> None: + """Per session since 2026-10-21 (owner decision): the user's other window converts its own file meanwhile.""" sid = await open_session(client, with_data=False) - runtime.converting.add(USER) + other = await open_session(client, with_data=False) + runtime.converting.add(sid) # a conversion in flight in this session response = await convert(client, sid) + meanwhile = await convert(client, other) assert (response.status_code, response.json()) == (409, {"detail": "run_active"}) - runtime.converting.discard(USER) - assert (await convert(client, sid)).status_code == 200 and USER not in runtime.converting + assert meanwhile.status_code == 200 and runtime.converting == {sid} + runtime.converting.discard(sid) + assert (await convert(client, sid)).status_code == 200 and runtime.converting == set() + + async def test_two_windows_convert_at_the_same_time( + self, client: httpx.AsyncClient, backend: FakeBackend, services: Services, runtime: Runtime + ) -> None: + """Both conversions are in flight together, one per session, and wait for the render slot in turn.""" + first = await open_session(client, with_data=False) + second = await open_session(client, with_data=False) + seen: list[set[str]] = [] + meta = ConvertMeta(kind="xlsx", rows=1, columns=2, sheet="Sheet1", sheets=["Sheet1"], sheet_count=1) + + def script(job: ConvertJob) -> ConvertOutput: + seen.append(set(runtime.converting)) + return ConvertOutput(exit_code=0, text="x,y\n1,2\n", meta=meta, wall_s=0.1) + + backend.convert_script = script + slots = services.backend.slots + await slots.acquire("run") # a pipeline render holds the slot, so both conversions wait for it + answers = asyncio.gather(convert(client, first), convert(client, second)) + for _ in range(200): + if runtime.converting == {first, second}: + break + await asyncio.sleep(0.01) + assert runtime.converting == {first, second} and slots.waiting == 2 + slots.release() + + assert [response.status_code for response in await answers] == [200, 200] + assert seen[0] == {first, second} and runtime.converting == set() async def test_a_busy_slot_is_capacity( self, client: httpx.AsyncClient, services: Services, monkeypatch: pytest.MonkeyPatch diff --git a/tests/unit/agents/runtime/test_data_code_tool.py b/tests/unit/agents/runtime/test_data_code_tool.py index 772b05721d..01f9ddbcfe 100644 --- a/tests/unit/agents/runtime/test_data_code_tool.py +++ b/tests/unit/agents/runtime/test_data_code_tool.py @@ -313,6 +313,23 @@ async def no_slot() -> bool: assert await run_data_code(SNIPPET, "python", context) == {"status": "error", "code": "capacity"} assert backend.data_jobs == [] and services.usage.user_data_runs("anonymous") == 0 + async def test_the_users_run_limit_has_its_own_code( + self, client: httpx.AsyncClient, services: Services, backend: FakeBackend + ) -> None: + """The route failed the turn with `run_limit` (the user's runs in other windows fill their share).""" + sid = await open_session(client, with_data=False) + context = await context_for(client, sid, "inv-slot-3") + ledger = ledger_for("inv-slot-3") + + async def limited() -> bool: + ledger.fail("run_limit") + return False + + ledger.run_slot = limited + + assert await run_data_code(SNIPPET, "python", context) == {"status": "error", "code": "run_limit"} + assert backend.data_jobs == [] and services.usage.user_data_runs("anonymous") == 0 + async def test_the_run_slot_is_taken_before_the_run( self, client: httpx.AsyncClient, services: Services, backend: FakeBackend ) -> None: diff --git a/tests/unit/agents/runtime/test_run_queue.py b/tests/unit/agents/runtime/test_run_queue.py index 0d465be3fd..6c587edaf6 100644 --- a/tests/unit/agents/runtime/test_run_queue.py +++ b/tests/unit/agents/runtime/test_run_queue.py @@ -13,6 +13,7 @@ QueueFull, QueuePosition, QueueTimeout, + QueueUserLimit, QueueWithdrawn, RunQueue, Ticket, @@ -159,6 +160,56 @@ def test_a_premium_entry_counts_only_the_entries_ahead_of_it(self, clock: Clock) assert queue.position(premium) == QueuePosition(position=1, waiting=3) +class TestPerUser: + """`AGENT_USER_MAX_RUNS`: a user's entries, waiting and running, across their sessions (2026-10-21).""" + + def test_the_default_cap_is_three(self) -> None: + assert RunQueue.from_settings(AgentSettings()).per_user == 3 + + def test_the_fourth_entry_of_a_user_is_refused_and_another_user_still_queues(self, clock: Clock) -> None: + queue = RunQueue(concurrency=1, per_minute=1_000, max_wait_s=600, clock=clock, per_user=3) + mine = [queue.submit("u", f"s-{window}") for window in range(3)] # one running, two waiting + + with pytest.raises(QueueUserLimit): + queue.submit("u", "s-4") + other = queue.submit("v", "s-v") + + assert [ticket.state for ticket in mine] == ["running", "waiting", "waiting"] + assert other.waiting and queue.position(other) == QueuePosition(position=3, waiting=3) + assert queue.user_entries("u") == 3 and queue.user_entries("v") == 1 + + def test_the_cap_is_checked_before_the_queue_length(self, clock: Clock) -> None: + """A user at their cap learns that from their own runs, even when the queue is full too.""" + queue = RunQueue(concurrency=1, per_minute=1, max_wait_s=60, clock=clock, per_user=2) # capacity 1 + queue.submit("u", "s-1") + queue.submit("u", "s-2") + + with pytest.raises(QueueUserLimit): + queue.submit("u", "s-3") + with pytest.raises(QueueFull): + queue.submit("v", "s-v") + + def test_a_released_withdrawn_or_expired_entry_frees_its_place(self, clock: Clock) -> None: + queue = RunQueue(concurrency=1, per_minute=1_000, max_wait_s=600, clock=clock, per_user=3) + first, second, third = (queue.submit("u", f"s-{window}") for window in range(3)) + + queue.release(first) # the second starts in its place + assert queue.submit("u", "s-3").waiting # the run that ended no longer counts + queue.withdraw(third) + assert queue.submit("u", "s-4").waiting # nor does a cancelled wait + clock.advance(600) + queue.pump() # every waiting entry expired; the running one still holds its place + assert queue.user_entries("u") == 1 and second.running + assert queue.submit("u", "s-5").waiting and queue.user_entries("u") == 2 + + def test_without_a_cap_a_user_may_hold_any_number(self, clock: Clock) -> None: + queue = make(clock) + for window in range(5): + queue.submit("u", f"s-{window}") + + assert queue.per_user is None and queue.user_entries("u") == 5 + + class TestMaxWait: def test_an_entry_expires_at_the_maximum_wait(self, clock: Clock) -> None: queue = make(clock, max_wait_s=600) @@ -332,3 +383,5 @@ def test_withdraw_leaves_a_running_entry_alone(self, clock: Clock) -> None: def test_invalid_limits_are_refused(self, clock: Clock) -> None: with pytest.raises(ValueError): RunQueue(concurrency=0, per_minute=1, max_wait_s=600, clock=clock) + with pytest.raises(ValueError): + RunQueue(concurrency=1, per_minute=1, max_wait_s=600, clock=clock, per_user=0) diff --git a/tests/unit/agents/runtime/test_run_queue_flow.py b/tests/unit/agents/runtime/test_run_queue_flow.py index 047ca43a60..fe14002248 100644 --- a/tests/unit/agents/runtime/test_run_queue_flow.py +++ b/tests/unit/agents/runtime/test_run_queue_flow.py @@ -1,15 +1,18 @@ """The run queue through `/v1`: two users, a full queue, the maximum wait, cancel and disconnect. Only sandbox work queues (owner decision of 2026-10-14): a turn starts at once, and its -`plot_pipeline` or `run_data_code` call waits for the slot mid-turn. The runtime's queue -runs at the production defaults (one run in flight, one start a minute, a 600-second -maximum wait) on a fake clock, so the tests advance time instead of sleeping. A gated +`plot_pipeline` or `run_data_code` call waits for the slot mid-turn. A user's windows +(sessions) work in parallel, and only their sandbox runs share the queue, at most three per +user (owner decision of 2026-10-21). The runtime's queue runs at the production defaults +(one run in flight, one start a minute, a 600-second maximum wait, three entries per user) +on a fake clock, so the tests advance time instead of sleeping. A gated fake renderer holds a run or a theme toggle in flight until the test opens the gate. The theme toggle's place next to the queue (the registry, the render slot) and the checks before a turn starts (budget, dataset) are here too. """ import asyncio +import json from collections.abc import AsyncIterator, Callable, Iterator from dataclasses import dataclass, field from typing import Any @@ -17,6 +20,8 @@ import httpx import pytest +from agents.anyplot import telemetry +from agents.anyplot.plugins.ledger import ATTRIBUTION_LOGGER from agents.anyplot.render.backends.fake import FakeBackend from agents.anyplot.render.contract import RenderJob, RenderResult from agents.anyplot.run_queue import RunQueue @@ -31,6 +36,8 @@ OTHER = "adm_fedcba9876543210" +LOGGERS = (ATTRIBUTION_LOGGER, telemetry.TELEMETRY_LOGGER) +"""The content-free attribution and telemetry lines, both JSON.""" @dataclass @@ -58,7 +65,7 @@ def clock() -> Clock: @pytest.fixture def runtime(clock: Clock) -> Iterator[Runtime]: - fresh = Runtime(queue=RunQueue(concurrency=1, per_minute=1, max_wait_s=600, clock=clock)) + fresh = Runtime(queue=RunQueue(concurrency=1, per_minute=1, max_wait_s=600, clock=clock, per_user=3)) app.dependency_overrides[get_runtime] = lambda: fresh yield fresh app.dependency_overrides.clear() @@ -255,26 +262,23 @@ async def test_a_run_without_a_queue_place_keeps_the_kept_note_and_its_own( assert state[PENDING_REQUEST] == "Title: A\nlog y" -async def test_a_user_with_a_queued_run_gets_409_and_cancel_leaves_the_queue( +async def test_a_session_with_a_queued_run_gets_409_and_cancel_leaves_the_queue( client: httpx.AsyncClient, runtime: Runtime, swap_models ) -> None: swap_models("gemini", two_runs()) first = await open_session(client) - second = await open_session(client) queue = runtime.run_queue() queue.submit("adm_running", "s-running") # another user's run holds the only slot queued = asyncio.create_task(create_plot(client, first)) await until(lambda: queue.waiting_count == 1, "the run to queue") + # Within one session it stays one thing at a time: a second turn, a toggle and a paste all wait. same_session = await client.post(f"/v1/sessions/{first}/messages", headers=HEADERS, json={"text": "hi"}) - other_session = await client.post( - f"/v1/sessions/{second}/messages", headers=HEADERS, json={"action": "create_plot"} - ) toggle = await client.post(f"/v1/sessions/{first}/versions/1/render", headers=HEADERS, json={"theme": "dark"}) - assert (same_session.status_code, same_session.json()) == (409, {"detail": "run_active"}) - assert (other_session.status_code, other_session.json()) == (409, {"detail": "run_active"}) - assert (toggle.status_code, toggle.json()) == (409, {"detail": "run_active"}) + paste = await client.post(f"/v1/sessions/{first}/dataset", headers=HEADERS, json={"text": "x,y\n1,2\n"}) + for refused in (same_session, toggle, paste): + assert (refused.status_code, refused.json()) == (409, {"detail": "run_active"}) cancel = await client.post(f"/v1/sessions/{first}/cancel", headers=HEADERS) events = await asyncio.wait_for(queued, 5) @@ -285,6 +289,106 @@ async def test_a_user_with_a_queued_run_gets_409_and_cancel_leaves_the_queue( assert queue.waiting_count == 0 and first not in runtime.active +async def test_a_users_other_window_pastes_and_chats_while_the_first_renders( + client: httpx.AsyncClient, runtime: Runtime, backend: GatedBackend, swap_models +) -> None: + """Two windows of one user (owner decision of 2026-10-21): while window A's plot renders, a paste and a chat turn in + window B are accepted at once and take no queue entry; only a second thing in window A itself waits.""" + swap_models( + "gemini", + { + "root": [{"call": "plot_pipeline", "args": {}}, {"text": "It has 2 rows."}, {"text": ROOT_REPLY}], + "adapter": [{"json": SCATTER_PLAN}], + "reviewer": [{"json": VERDICT_OK}], + }, + ) + window_a = await open_session(client) + window_b = await open_session(client, with_data=False) + queue = runtime.run_queue() + plot = asyncio.create_task(create_plot(client, window_a)) + await asyncio.wait_for(backend.entered.wait(), 5) + + pasted = await client.post(f"/v1/sessions/{window_b}/dataset", headers=HEADERS, json={"text": "x,y\n1,2\n3,4\n"}) + turn = await client.post( + f"/v1/sessions/{window_b}/messages", headers=HEADERS, json={"text": "How many rows has my data?"} + ) + again = await client.post(f"/v1/sessions/{window_a}/messages", headers=HEADERS, json={"text": "hi"}) + + assert pasted.status_code == 200, pasted.text + events = parse_sse(turn.text) + assert turn.status_code == 200 and [name for name, _ in events] == ["ready", "message", "done"] + assert events[1][1] == {"text": "It has 2 rows."} + assert (again.status_code, again.json()) == (409, {"detail": "run_active"}) # window A: one thing at a time + assert (queue.waiting_count, queue.in_flight) == (0, 1) # window B's turn ran no sandbox, so it took no entry + backend.gate.set() + assert next(data for name, data in await asyncio.wait_for(plot, 10) if name == "plot")["status"] == "ok" + assert runtime.active == {} and runtime.pasting == set() + + +async def test_a_users_second_window_queues_its_own_plot( + client: httpx.AsyncClient, runtime: Runtime, swap_models +) -> None: + """Until 2026-10-21 the second window's plot was `409 run_active`; now it waits in the queue like any run.""" + swap_models("gemini", interleaved_runs()) + window_a = await open_session(client) + window_b = await open_session(client) + queue = runtime.run_queue() + queue.submit("adm_running", "s-running") # another user's run holds the only slot + + first = asyncio.create_task(create_plot(client, window_a)) + await until(lambda: queue.waiting_count == 1, "window A's run to queue") + second = asyncio.create_task(create_plot(client, window_b)) + await until(lambda: queue.waiting_count == 2, "window B's run to queue") + + assert queue.user_entries(USER) == 2 and set(runtime.active) == {window_a, window_b} + for sid in (window_a, window_b): + assert (await client.post(f"/v1/sessions/{sid}/cancel", headers=HEADERS)).status_code == 204 + for events in await asyncio.wait_for(asyncio.gather(first, second), 5): + names = [name for name, _ in events] + assert names[0] == "ready" and names[-1] == "done" and "error" not in names + assert all(data["step"] == "queued" for data in statuses(events)) + assert queue.waiting_count == 0 and runtime.active == {} + + +async def test_a_users_fourth_sandbox_run_ends_with_run_limit_and_another_user_still_queues( + client: httpx.AsyncClient, runtime: Runtime, swap_models, caplog: pytest.LogCaptureFixture +) -> None: + """`AGENT_USER_MAX_RUNS` (3): the user's runs in three other windows fill their share, so a fourth window's plot + ends at once with `run_limit`, never `capacity`, and spends no pipeline call; another user is unaffected.""" + caplog.set_level("INFO") + fake = swap_models("gemini", {"root": [{"call": "plot_pipeline", "args": {}}] * 3}) + window = await open_session(client) + stranger = await open_session(client, user=OTHER) + queue = runtime.run_queue() + others = [queue.submit(USER, f"s-window-{index}") for index in range(3)] # one running, two waiting + + events = await create_plot(client, window) + + assert [name for name, _ in events] == ["ready", "error", "done"] + assert events[1][1] == {"code": "run_limit", "ref": "req-1"} + assert root_calls(fake) == 1 and len(fake.requests) == 1 # aborted before the pipeline's first model call + assert window not in runtime.active and queue.user_entries(USER) == 3 and queue.waiting_count == 2 + logged = [json.loads(record.getMessage()) for record in caplog.records if record.name in LOGGERS] + assert {"hook": "queue", "verdict": "run_limit"}.items() <= next( + line for line in logged if line.get("hook") == "queue" + ).items() + assert next(line for line in logged if line.get("event") == "chat_turn")["error"] == "run_limit" + + # Another user's plot is not refused: it waits behind the user's two waiting runs. + waiting = asyncio.create_task(create_plot(client, stranger, headers_for(OTHER))) + await until(lambda: queue.waiting_count == 3, "the other user's run to queue") + await client.post(f"/v1/sessions/{stranger}/cancel", headers=headers_for(OTHER)) + assert [name for name, _ in await asyncio.wait_for(waiting, 5)] == ["ready", "status", "done"] + + # One of the user's runs ends: the fourth window's next plot queues instead of being refused. + queue.release(others[2]) + retry = asyncio.create_task(create_plot(client, window)) + await until(lambda: queue.waiting_count == 2, "the retry to queue") + assert queue.user_entries(USER) == 3 + await client.post(f"/v1/sessions/{window}/cancel", headers=HEADERS) + assert [name for name, _ in await asyncio.wait_for(retry, 5)] == ["ready", "status", "done"] + + async def test_a_run_that_waited_the_maximum_ends_with_capacity( client: httpx.AsyncClient, runtime: Runtime, clock: Clock, swap_models ) -> None: @@ -368,11 +472,18 @@ def watch(delay: float, callback: Callable[..., object], *args: Any, **kwargs: A assert "error" not in [name for name, _ in events] -async def test_a_toggle_in_flight_and_a_turn_refuse_each_other( +async def test_a_toggle_in_flight_and_a_turn_of_its_session_refuse_each_other( client: httpx.AsyncClient, runtime: Runtime, backend: GatedBackend, swap_models ) -> None: backend.gate.set() - swap_models("gemini", two_runs()) + swap_models( + "gemini", + { + "root": [{"call": "plot_pipeline", "args": {}}, {"text": ROOT_REPLY}, {"text": "It has 24 rows."}], + "adapter": [{"json": SCATTER_PLAN}], + "reviewer": [{"json": VERDICT_OK}], + }, + ) sid = await open_session(client) other = await open_session(client) await create_plot(client, sid) @@ -382,10 +493,14 @@ async def test_a_toggle_in_flight_and_a_turn_refuse_each_other( await asyncio.wait_for(backend.entered.wait(), 5) same_session = await client.post(f"/v1/sessions/{sid}/messages", headers=HEADERS, json={"text": "hi"}) - other_session = await client.post(f"/v1/sessions/{other}/messages", headers=HEADERS, json={"action": "create_plot"}) second_toggle = await render_theme(client, sid, "dark") - for refused in (same_session, other_session, second_toggle): + for refused in (same_session, second_toggle): assert (refused.status_code, refused.json()) == (409, {"detail": "run_active"}) + # The toggle holds only its own session's entry: the user's other window chats meanwhile (2026-10-21). + other_session = await client.post( + f"/v1/sessions/{other}/messages", headers=HEADERS, json={"text": "How many rows has my data?"} + ) + assert [name for name, _ in parse_sse(other_session.text)] == ["ready", "message", "done"] backend.gate.set() response = await asyncio.wait_for(toggle, 5) diff --git a/tests/unit/agents/runtime/test_service_flow.py b/tests/unit/agents/runtime/test_service_flow.py index 913637a7f7..1d9305c45d 100644 --- a/tests/unit/agents/runtime/test_service_flow.py +++ b/tests/unit/agents/runtime/test_service_flow.py @@ -1774,9 +1774,10 @@ async def test_run_active_is_a_409(client: httpx.AsyncClient, runtime: Runtime, assert cancel.status_code == 204 and runtime.active[sid].abort.is_set() -async def test_one_active_run_per_user_across_sessions( +async def test_a_run_in_one_session_never_blocks_the_users_other_session( client: httpx.AsyncClient, runtime: Runtime, swap_models ) -> None: + """Owner decision of 2026-10-21: each window is a session, and a user's sessions work in parallel.""" import asyncio from agents.main import ActiveRun @@ -1784,10 +1785,26 @@ async def test_one_active_run_per_user_across_sessions( swap_models("gemini", default_script()) first = await open_session(client) second = await open_session(client) - runtime.active[first] = ActiveRun(abort=asyncio.Event(), user=USER) + runtime.active[first] = ActiveRun(abort=asyncio.Event(), user=USER) # the first window's turn runs - response = await client.post(f"/v1/sessions/{second}/messages", headers=HEADERS, json={"action": "create_plot"}) + data = (CASES / "scatter-basic-matplotlib" / "data.csv").read_text() + pasted = await client.post(f"/v1/sessions/{second}/dataset", headers=HEADERS, json={"text": data}) + bindings = await client.put( + f"/v1/sessions/{second}/bindings", + headers=HEADERS, + json=[{"role": "x", "column": "Study Hours"}, {"role": "y", "column": "Exam Score"}], + ) + confirmed = await client.post(f"/v1/sessions/{second}/dataset/confirm", headers=HEADERS) + for response in (pasted, bindings, confirmed): + assert response.status_code == 200, response.text + events = await create_plot(client, second) + language = await client.put(f"/v1/sessions/{second}/language", headers=HEADERS, json={"language": "de"}) + assert next(data for name, data in events if name == "plot")["status"] == "ok" + assert language.status_code == 200, language.text + assert list(runtime.active) == [first] # the second window's turn ended; the first one's still runs + # The first window itself still runs one thing at a time. + response = await client.post(f"/v1/sessions/{first}/messages", headers=HEADERS, json={"text": "hi"}) assert (response.status_code, response.json()) == (409, {"detail": "run_active"}) diff --git a/tests/unit/agents/runtime/test_turn_limits.py b/tests/unit/agents/runtime/test_turn_limits.py index 01ca8b9d21..0e6c6dabe7 100644 --- a/tests/unit/agents/runtime/test_turn_limits.py +++ b/tests/unit/agents/runtime/test_turn_limits.py @@ -411,9 +411,10 @@ async def test_past_the_day_a_paste_gets_the_daily_limit( assert (refused.status_code, refused.json()) == (429, {"detail": "daily_limit"}) - async def test_one_paste_per_user_at_a_time_and_none_during_a_turn_in_another_session( + async def test_one_paste_per_session_at_a_time_and_none_during_the_sessions_turn( self, client: httpx.AsyncClient, runtime: Runtime ) -> None: + """Per session since 2026-10-21 (owner decision): the user's other windows paste and chat in parallel.""" sid = await open_session(client, with_data=False) other = await open_session(client, with_data=False) stranger = await open_session(client, with_data=False, user=OTHER) @@ -421,17 +422,20 @@ async def test_one_paste_per_user_at_a_time_and_none_during_a_turn_in_another_se def paste(session: str, headers: dict[str, str] = HEADERS) -> Any: return client.post(f"/v1/sessions/{session}/dataset", headers=headers, json={"text": "x,y\n1,2\n"}) - runtime.pasting.add(USER) # a paste of the user in flight, in any session - assert ((await paste(sid)).status_code, (await paste(other)).status_code) == (409, 409) - assert (await paste(stranger, headers_for(OTHER))).status_code == 200 # per user - # The inverse direction: no turn starts while the user's paste may replace the data it reads. - turn = await client.post(f"/v1/sessions/{other}/messages", headers=HEADERS, json={"text": "Which columns?"}) - assert (turn.status_code, turn.json()) == (409, {"detail": "run_active"}) - runtime.pasting.discard(USER) - - runtime.active[other] = ActiveRun(abort=asyncio.Event(), user=USER, started=runtime.now()) + runtime.pasting.add(sid) # a paste in flight in this session assert (await paste(sid)).json() == {"detail": "run_active"} - del runtime.active[other] + # The inverse direction: no turn of the session starts while its paste may replace the data it reads. + turn = await client.post(f"/v1/sessions/{sid}/messages", headers=HEADERS, json={"text": "Which columns?"}) + assert (turn.status_code, turn.json()) == (409, {"detail": "run_active"}) + # The user's other window and another user paste meanwhile. + assert (await paste(other)).status_code == 200 + assert (await paste(stranger, headers_for(OTHER))).status_code == 200 + runtime.pasting.discard(sid) + + runtime.active[sid] = ActiveRun(abort=asyncio.Event(), user=USER, started=runtime.now()) + assert (await paste(sid)).json() == {"detail": "run_active"} # the session's own turn blocks its paste + assert (await paste(other)).status_code == 200 # a turn in another window never does + del runtime.active[sid] assert (await paste(sid)).status_code == 200 assert runtime.pasting == set() # released after the paste, also on success @@ -456,11 +460,18 @@ def paste(text: str) -> Any: judge.script = [] assert (await paste("a,b\n1,2\n")).status_code == 200 # nothing left behind blocks the next paste - async def test_the_data_routes_wait_for_the_users_paste(self, client: httpx.AsyncClient, runtime: Runtime) -> None: - """Two tabs: a paste waiting on its judges holds the view it read; a version another tab stored meanwhile - (`.to_long()`, an undo, new bindings) would be dropped when the paste writes the next dataset from it.""" + async def test_the_data_routes_wait_for_the_sessions_paste( + self, client: httpx.AsyncClient, runtime: Runtime + ) -> None: + """A paste waiting on its judges holds the view it read; a version the session stored meanwhile (`.to_long()`, + an undo, new bindings) would be dropped when the paste writes the next dataset from it. Keyed by the session, + so it holds whoever sends the request; a paste in the user's other window never blocks them.""" sid = await open_session(client) - runtime.pasting.add(USER) + other = await open_session(client) + runtime.pasting.add(other) + assert (await client.post(f"/v1/sessions/{sid}/dataset/confirm", headers=HEADERS)).status_code == 200 + runtime.pasting.discard(other) + runtime.pasting.add(sid) calls = { "library": client.post( @@ -482,5 +493,5 @@ async def test_the_data_routes_wait_for_the_users_paste(self, client: httpx.Asyn chosen = await client.put(f"/v1/sessions/{sid}/language", headers=HEADERS, json={"language": "de"}) assert chosen.status_code == 200 - runtime.pasting.discard(USER) + runtime.pasting.discard(sid) assert (await client.post(f"/v1/sessions/{sid}/dataset/confirm", headers=HEADERS)).status_code == 200 diff --git a/tests/unit/agents/test_settings.py b/tests/unit/agents/test_settings.py index 2263581fc0..f23a5d78fa 100644 --- a/tests/unit/agents/test_settings.py +++ b/tests/unit/agents/test_settings.py @@ -36,6 +36,7 @@ def test_defaults_are_the_pinned_production_values(self) -> None: assert settings.run_concurrency == 1 assert settings.runs_per_minute == 1 assert settings.queue_max_wait_s == 600 + assert settings.user_max_runs == 3 # sandbox runs per user across sessions (owner decision of 2026-10-21) assert settings.service_urls == [] assert settings.dev_fixture is None assert settings.libraries == ["matplotlib", "seaborn"] diff --git a/tests/unit/api/test_agent_router.py b/tests/unit/api/test_agent_router.py index ed3ea765fb..b8487a93a6 100644 --- a/tests/unit/api/test_agent_router.py +++ b/tests/unit/api/test_agent_router.py @@ -928,6 +928,14 @@ def test_unknown_error_code_becomes_internal(self, client, upstream) -> None: events = _sse_events(self._post(client).text) assert events[0] == ("error", {"code": "internal", "ref": events[0][1]["ref"]}) + def test_the_users_run_limit_passes_through(self, client, upstream) -> None: + """The per-user cap on sandbox runs across sessions has its own code, which the page words.""" + upstream.handler = lambda request: httpx.Response( + 200, content=b'event: error\ndata: {"code": "run_limit"}\n\nevent: done\ndata: {}\n\n' + ) + events = _sse_events(self._post(client).text) + assert events[0] == ("error", {"code": "run_limit", "ref": events[0][1]["ref"]}) + def test_plot_keeps_its_version(self, client, upstream) -> None: """The stored version's number reaches the browser, so it never counts plot events.""" plot = {"status": "ok", "attempts": 1, "artifacts": ["plot-light.png"], "version": 2, "plan": "secret"} From 6932b13ad3da49b9f514ef17e2ea40fa1a3f3e0e Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:52:57 +0200 Subject: [PATCH 2/3] chore: reference PR #12220 in the changelog fragment Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M --- changelog.d/agent-parallel-windows.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/agent-parallel-windows.md b/changelog.d/agent-parallel-windows.md index 781f9beb73..f8e6e8e785 100644 --- a/changelog.d/agent-parallel-windows.md +++ b/changelog.d/agent-parallel-windows.md @@ -12,4 +12,4 @@ turn with the new stream error `run_limit`, which the page words as "you already have 3 plots or data runs in progress in other windows", instead of `capacity`. The per-user rate limits and daily budgets are unchanged (owner - decision of 2026-10-21). + decision of 2026-10-21). (#12220) From d7718da12ec8099411550862a855cf04c5747a38 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:05:47 +0200 Subject: [PATCH 3/3] fix(agents): sweep stale runs before the per-user cap is checked Gemini 4 Argon review of #12220: a run of the user that goes stale during another turn's root call would still count toward AGENT_USER_MAX_RUNS until the next request swept it. TurnSlot.take now sweeps right before it submits. A flow test pins that a stale run in another window never causes run_limit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M --- agents/main.py | 3 +++ .../agents/runtime/test_run_queue_flow.py | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/agents/main.py b/agents/main.py index a27b7b1530..7c186733dc 100644 --- a/agents/main.py +++ b/agents/main.py @@ -459,6 +459,9 @@ async def take(self) -> bool: return self.run.ticket.running if self.run.abort.is_set(): return False + # A run of the user whose stream vanished mid-turn would still count toward their cap. The request's + # start already swept (`_active`); this catches a run that went stale during this turn's root call. + self.runtime.drop_stale(get_settings()) queue = self.runtime.run_queue() try: ticket = queue.submit(self.run.user, self.session_id) diff --git a/tests/unit/agents/runtime/test_run_queue_flow.py b/tests/unit/agents/runtime/test_run_queue_flow.py index fe14002248..724cf83ce1 100644 --- a/tests/unit/agents/runtime/test_run_queue_flow.py +++ b/tests/unit/agents/runtime/test_run_queue_flow.py @@ -389,6 +389,33 @@ async def test_a_users_fourth_sandbox_run_ends_with_run_limit_and_another_user_s assert [name for name, _ in await asyncio.wait_for(retry, 5)] == ["ready", "status", "done"] +async def test_a_stale_run_in_another_window_does_not_count_toward_the_cap( + client: httpx.AsyncClient, runtime: Runtime, clock: Clock, swap_models +) -> None: + """A run in another window whose stream vanished is dropped before the cap counts it, so it never causes + `run_limit`. This covers the sweep at the request's start; `TurnSlot.take` sweeps again for a run that goes + stale during the turn's root call, a window of seconds this test cannot place a clock jump in.""" + from agents.main import STALE_RUN_MARGIN_S, ActiveRun + + swap_models("gemini", {"root": [{"call": "plot_pipeline", "args": {}}]}) + window = await open_session(client) + queue = runtime.run_queue() + abandoned = queue.submit(USER, "s-abandoned") # running, its stream gone + runtime.active["s-abandoned"] = ActiveRun(abort=asyncio.Event(), user=USER, started=runtime.now(), ticket=abandoned) + clock.advance(get_settings().request_deadline_s + STALE_RUN_MARGIN_S + 1) + queue.submit(USER, "s-window-1") + queue.submit(USER, "s-window-2") + assert queue.user_entries(USER) == 3 + + plot = asyncio.create_task(create_plot(client, window)) + await until( + lambda: window in runtime.active and runtime.active[window].ticket is not None, "the plot to take an entry" + ) + assert "s-abandoned" not in runtime.active and queue.user_entries(USER) == 3 + await client.post(f"/v1/sessions/{window}/cancel", headers=HEADERS) + assert [name for name, _ in await asyncio.wait_for(plot, 5)] == ["ready", "status", "done"] + + async def test_a_run_that_waited_the_maximum_ends_with_capacity( client: httpx.AsyncClient, runtime: Runtime, clock: Clock, swap_models ) -> None: