diff --git a/agents/README.md b/agents/README.md index 1655d8f68b8..8ee1d44d009 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, 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/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): five runs in flight, one start every 10 seconds, a 600-second maximum wait, at most two runs per public user and five per admin 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 | @@ -83,12 +83,13 @@ Three rules hold for everything here: | `AGENT_RENDER_IMAGE` | `anyplot-renderer:dev` | Image the `local` renderer runs, built from `renderer/Dockerfile` | | `AGENT_RENDER_CONCURRENCY` | `1` | Theme renders at the same time, for every backend (`render/serial.py`); serial, because one 4 GiB instance holds one sandbox safely (spikes S and S2) | | `AGENT_WATERMARK` | `true` | Whether the served PNGs (the artifact route and the feedback bundle) carry the footer strip; `false` serves the raw renders. The gates, the reviewer and `plot.py` always see the raw render | -| `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_RUN_CONCURRENCY` | `5` | Sandbox runs in flight in the whole instance, every lane together (a turn's `plot_pipeline` or `run_data_code` call, holding its entry until the turn ends; 1 before 2026-11-01); the run queue holds the rest. A chat turn without one never enters the queue | +| `AGENT_RUNS_PER_MINUTE` | `6` | Sandbox runs that may start within any 60 seconds (a sliding window), spaced at least 60 / this seconds apart: one start every 10 seconds, in every lane (1 a minute before 2026-11-01); 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 (60), 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` | `2` | Sandbox runs one public user may have queued or running at the same time, across all their sessions (browser windows; 3 before 2026-11-01); 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_ADMIN_USER_MAX_RUNS` | `5` | The same for an admin (a request with `X-Anyplot-Tier: admin` from an allowed caller, the BFF for its admins), whose runs share the concurrency and the rate with everyone's; `GET /v1/status` reports it to an admin as `user_max_runs`, and `.plot_all()` starts that many plots at once | +| `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. An admin is exempt | +| `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. An admin is exempt | | `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 | | `AGENT_OTHER_LIBRARY_COUNTS` | `false` | Whether the `other_library` answer (this plot in another library or language) counts toward `AGENT_SESSION_REFUSALS` | | `AGENT_SESSION_CLOSE_WARNING` | `true` | The counted refusal one before the closing one ends with the fixed `close_warning` sentence | @@ -114,13 +115,13 @@ Three rules hold for everything here: | `AGENT_ROOT_EFFORT`, `AGENT_ADAPTER_EFFORT`, `AGENT_REVIEWER_EFFORT` | `medium`, `high`, `high` | Claude's effort per agent kind (`low`, `medium`, `high`), set by the 2026-10-13 measurements (see [The plot pipeline since 2026-10-13](#the-plot-pipeline-since-2026-10-13)); on the claude-cli transport also how much the CLI thinks | | `AGENT_MAX_LLM_CALLS` | `16` | LLM calls per request | | `AGENT_REQUEST_TOKEN_BUDGET` | `800000` | Cost-weighted tokens per request (see [Token budgets and output caps](#token-budgets-and-output-caps)) | -| `AGENT_DAILY_TOKEN_BUDGET` | `8000000` | Cost-weighted tokens per user and day | -| `AGENT_DAILY_PIPELINE_RUNS` | `40` | Pipeline runs per user and day | -| `AGENT_GLOBAL_DAILY_TOKEN_BUDGET` | `24000000` | Cost-weighted tokens per day for the whole service | +| `AGENT_DAILY_TOKEN_BUDGET` | `10000000` | Cost-weighted tokens per user and day, about $1 (8,000,000 before 2026-11-01); an admin is exempt | +| `AGENT_DAILY_PIPELINE_RUNS` | `50` | Pipeline runs per user and day (40 before 2026-11-01); an admin is exempt | +| `AGENT_GLOBAL_DAILY_TOKEN_BUDGET` | `100000000` | Cost-weighted tokens per day for the whole service, about $10, admins included (24,000,000 before 2026-11-01) | | `AGENT_RENDER_TIMEOUT_S` | `60` | Seconds per theme render | | `AGENT_DATA_CODE` | `true` | Whether the root's `run_data_code` runs; `false` makes it answer `unavailable`. The tool also answers `unavailable` while the renderer has no `POST /run-data` (`404`) | | `AGENT_DATA_RUN_TIMEOUT_S` | `20` | Seconds one data snippet may run, at most 30 | -| `AGENT_DAILY_DATA_RUNS` | `30` | `run_data_code` runs per user and day, counted before each run; a data run also takes the run queue's slot and its turn's start in the rate window, like a plot | +| `AGENT_DAILY_DATA_RUNS` | `30` | `run_data_code` runs per user and day, counted before each run; a data run also takes the run queue's slot and its turn's start in the rate window, like a plot. An admin is exempt from the count | | `AGENT_REQUEST_DEADLINE_S` | `540` | Hard request deadline (180 before 2026-10-13: with a self-review per round, the soft deadline of 140 s stopped the repair round in 11 of 20 runs; the owner's rule is that no plot ships unfinished for lack of time) | | `AGENT_SOFT_DEADLINE_S` | `480` | Soft deadline inside the pipeline | | `AGENT_JUDGE_TIMEOUT_S` | `4` | Budget of one judge verdict, its one retry and the half-second wait before it included | @@ -221,9 +222,9 @@ Claude runs with adaptive thinking on the root (`AGENT_ROOT_THINKING_BUDGET`, 1, uv run uvicorn agents.main:app --port 8001 ``` -The service needs the header `X-Anyplot-User` on every `/v1` route; outside `ENVIRONMENT=development` it also requires the IAM-forwarded ID token (`AGENT_SERVICE_URLS`, `AGENT_ALLOWED_CALLERS`). To drive it from the plot page, run the API in front of it as described under [Drive the chat page](#drive-the-chat-page). +The service needs the header `X-Anyplot-User` on every `/v1` route; outside `ENVIRONMENT=development` it also requires the IAM-forwarded ID token (`AGENT_SERVICE_URLS`, `AGENT_ALLOWED_CALLERS`). `X-Anyplot-Tier: admin` serves a request as an admin: the run queue's admin lane (five runs per admin instead of two, the same concurrency and start rate as everyone) and no per-user day caps or message minute; without it a request is public. The BFF sends it for every admin. To drive it from the plot page, run the API in front of it as described under [Drive the chat page](#drive-the-chat-page). -At the defaults only one sandbox run may start a minute, so a second "Create plot" within a minute waits in the run queue: the turn starts at once, and when its pipeline is about to run, the stream shows `status` events with `step: "queued"` until the minute is over. A turn that runs no sandbox (a question, a refusal, the summary after a paste, a data request in step 1) never waits. To iterate faster on your own machine, export `AGENT_RUNS_PER_MINUTE=60`; more than 10 chat turns a minute get the `rate_limited` refusal (`AGENT_USER_MESSAGES_PER_MINUTE`). The theme toggle (`POST /v1/sessions/{sid}/versions/{version}/render {"theme": "dark"}`) never waits in the queue. +At the defaults a sandbox run starts at most every 10 seconds, so a second "Create plot" within 10 seconds waits in the run queue: the turn starts at once, and when its pipeline is about to run, the stream shows `status` events with `step: "queued"` until the gap has passed. A turn that runs no sandbox (a question, a refusal, the summary after a paste, a data request in step 1) never waits. To iterate faster on your own machine, export `AGENT_RUNS_PER_MINUTE=60`, and send `X-Anyplot-Tier: admin` for the admin's cap of five runs; more than 10 chat turns a minute get the `rate_limited` refusal (`AGENT_USER_MESSAGES_PER_MINUTE`) in the public tier. The theme toggle (`POST /v1/sessions/{sid}/versions/{version}/render {"theme": "dark"}`) never waits in the queue. ### Drive the chat page @@ -383,7 +384,7 @@ The container has no Application Default Credentials, so a "Create plot" fails a `cloudbuild.yaml` builds the image, deploys a `candidate` revision without traffic, smokes it, and promotes exactly that revision: -- The flags follow the design: one instance (sessions live in memory), 1 vCPU and 2 GiB (2 vCPU and 4 GiB before 2026-10-29), `--concurrency=20` and `--timeout=1200` (900 before 2026-10-13) for the run queue's open streams, `--no-allow-unauthenticated`, the service account `anyplot-agents@`, no `--sandbox-launcher`, and `--update-env-vars` only. +- The flags follow the design: one instance (sessions live in memory), 1 vCPU and 2 GiB (2 vCPU and 4 GiB before 2026-10-29), `--concurrency=80` (20 before 2026-11-01: the queue's 60 waiting streams and 5 runs, plus the other routes) and `--timeout=1200` (900 before 2026-10-13) for the run queue's open streams, `--no-allow-unauthenticated`, the service account `anyplot-agents@`, no `--sandbox-launcher`, and `--update-env-vars` only. - `_PROVIDER`, `_MODEL`, `_JUDGE_MODEL` and `_LIBRARIES` default to the settings defaults, so a deploy switches the model arm without a code change. The budgets and limits are not set, so the deployed code's settings defaults apply. - `_RENDER_URL` is the `status.url` of the real `anyplot-renderer`, and the deploy refuses to run without it, because `--update-env-vars` would otherwise replace a working renderer URL with nothing. `AGENT_ALLOWED_CALLERS` is `_API_CALLER`, anyplot-api's runtime identity (the Compute Engine default service account unless a build sets another one), plus `anyplot-agents@` itself for the smoke. The deploy adds the service's own URL to `AGENT_SERVICE_URLS`. - The smoke expects Cloud Run IAM to refuse an anonymous `GET /v1/status` with `403`. With `_SMOKE_AUTH=true`, the default, it also calls `/v1/status` with an ID token minted for `anyplot-agents@` and checks that the answer names the configured provider, model, location and libraries. diff --git a/agents/anyplot/models.py b/agents/anyplot/models.py index 6e4d008300b..751e61101c3 100644 --- a/agents/anyplot/models.py +++ b/agents/anyplot/models.py @@ -163,6 +163,12 @@ (about 21,333 tokens) unless the client sets a timeout of its own. The adapter's 49,152 take about 143 s at the measured rate; 300 s leaves twice that, and the pipeline's deadlines bound a run long before the SDK's retries could add up.""" +CLAUDE_MAX_RETRIES = 4 +"""The SDK retries of the root's, the adapter's and the reviewer's Claude calls (`VertexClaude`), twice the SDK's +default of 2 since 2026-11-01: the admin lane runs five pipelines at once, and a 429 on a shared Vertex AI quota +passes within seconds (the SDK backs off 0.5, 1, 2 and 4 s with jitter, or as long as the answer's `retry-after` +asks). A call still refused after them ends the turn as busy (`model_busy`). The judge keeps 0: its own budget of +`AGENT_JUDGE_TIMEOUT_S` is the only clock it may spend (`_within_budget`).""" GEMINI_ADAPTER_FULL_MAX_OUTPUT_TOKENS = 12_288 """The Gemini adapter's cap when a full file is allowed, thinking included. @@ -337,6 +343,7 @@ def _anthropic_client(self) -> Any: project_id=self.vertex_project, region=self.vertex_region, timeout=Timeout(CLAUDE_REQUEST_TIMEOUT_S, connect=10.0), + max_retries=CLAUDE_MAX_RETRIES, ) def _build_anthropic_kwargs( @@ -440,7 +447,11 @@ def cli_client(settings: AgentSettings | None = None, *, fallback: bool = True) key = (settings.project if fallback else "", settings.location, settings.cli_concurrency, settings.cli_call_log) client = _CLI_CLIENTS.get(key) if client is None: - vertex = make_anthropic_client(settings.project, settings.location, max_retries=2) if fallback else None + vertex = ( + make_anthropic_client(settings.project, settings.location, max_retries=CLAUDE_MAX_RETRIES) + if fallback + else None + ) log = Path(settings.cli_call_log) if settings.cli_call_log else None client = ClaudeCliClient(fallback=vertex, concurrency=settings.cli_concurrency, call_log=log) _CLI_CLIENTS[key] = client @@ -904,6 +915,41 @@ def make_anthropic_client(project: str, location: str, *, max_retries: int = 0) return AsyncAnthropicVertex(project_id=project, region=location, max_retries=max_retries) +BUSY_STATUSES = frozenset({429, 529}) +"""The HTTP statuses of a model call that mean the service is busy, not broken: a quota (429) and Anthropic's +overload (529, the SDK's `OverloadedError`). Both pass within seconds to minutes.""" + + +def error_chain(exc: BaseException) -> list[BaseException]: + """`exc` and the errors behind it, outermost first: through ADK's `DynamicNodeFailError` (its `error`) and explicit + causes (`raise ... from`). Never an implicit `__context__`: an unrelated failure raised while handling a quota + error is that failure, not the quota.""" + chain: list[BaseException] = [] + current: BaseException | None = exc + while current is not None and all(current is not seen for seen in chain): + chain.append(current) + wrapped = getattr(current, "error", None) + current = wrapped if isinstance(wrapped, BaseException) else current.__cause__ + return chain + + +def model_busy(exc: BaseException) -> bool: + """Whether a model call failed because the model service is busy, after its retries: a quota (Anthropic's + `RateLimitError`, Gemini's `RESOURCE_EXHAUSTED`) or an overload (`OverloadedError`), anywhere in `error_chain`. + + Read from the type names and the status codes only, never from a message. The `/messages` stream ends such a turn + with `capacity` and the pipeline its run with `busy`: both read as "busy, try again shortly", not as a fault. + """ + for link in error_chain(exc): + name = type(link).__name__ + if "RateLimit" in name or "ResourceExhausted" in name or "Overloaded" in name: + return True + statuses = (getattr(link, "code", None), getattr(link, "status_code", None)) + if any(isinstance(status, int) and status in BUSY_STATUSES for status in statuses): + return True + return False + + def parse_judge_answer(text: str) -> JudgeVerdict: """Parse a judge answer given as JSON text (used by fakes and the eval harness).""" try: diff --git a/agents/anyplot/pipeline.py b/agents/anyplot/pipeline.py index 6bc6c3aea8e..2f431723b8c 100644 --- a/agents/anyplot/pipeline.py +++ b/agents/anyplot/pipeline.py @@ -63,8 +63,10 @@ needs `ADAPTER_P95_S` plus the runtime's `render_p95_s` left, so the first attempt has the rest of the soft deadline) and for every render timeout; the request deadline is `abort_signal` on the run. A render waits in the `run` lane of the render slot -(`render/serial.py`), ahead of every waiting theme toggle, so it waits for at most -the render in progress; its clamped timeout starts when it runs. The exported +(`render/serial.py`), ahead of every waiting theme toggle, so it waits only for the renders +of the other runs in flight (up to five since 2026-11-01, their starts at least 10 s apart); +its clamped timeout starts when it runs. The gates run in a worker thread, off the +event loop the other runs in flight share. The exported code (`plot.py`, `plot.R`, `plot.jl`) names the rendered theme in its run line. Every path that is not cancelled yields exactly one `Event(output=PlotResult)` as a @@ -102,18 +104,21 @@ `pipeline_check` (edit-apply failure count and kinds, outcome `drift_restored` with the restored drift kinds, how many edits a tolerant matching tier applied, blocking validator rule ids, the labels of banned imports (`validate.module_label`: a word of a closed vocabulary, or `?`), ADAPTATION rule -ids), `pipeline_render` (render and wall time, the gate ids that +ids), `pipeline_render` (render, render-slot wait and wall time, the gate ids that failed or reported, and the class a failed probe names, `grounding.probe_error`), `pipeline_review` (the verdict, its defect ids and the call's finish reason) and `pipeline_result` (status, reason, attempts, the `Stage` that ended each attempt and the run, the shipped and the last reviewed attempt, the number of reviewer calls, whether the shipped render was padded or carries ADAPTATION or probe findings, the roles of its drawn texts -and whether its library reports them, and the exception class behind reason `error`). The eval harness (`agents/evals/matrix.py`) reads them per +and whether its library reports them, and the exception class behind reason `error` +or `busy`, the latter a model quota or overload still refused after the SDK's retries, +`models.model_busy`; the innermost class, never ADK's wrapper). The eval harness (`agents/evals/matrix.py`) reads them per case; they never carry code, data, column names or model text. `PlotResult.reason` keeps its public vocabulary; the stage is only in the attribution line. """ +import asyncio import csv import functools import io @@ -153,6 +158,7 @@ reviewed_misses, ) from .language import DEFAULT_LANGUAGE, same_language +from .models import error_chain, model_busy from .plugins.ledger import RequestLedger, attribution, budget_allows, ledger_for from .policy import DATA_PREAMBLE, adapter_instruction, fence, prompt_hash from .render.contract import RendererUnavailable, RenderJob, RuntimeAdapter, Theme @@ -450,7 +456,7 @@ class Run: rendered: bool = False unreviewed_why: str | None = None error_type: str | None = None - """The exception class that ended the run with reason `error` (content-free, for the attribution log).""" + """The exception class that ended the run with reason `error` or `busy` (content-free, for the attribution log).""" stage: Stage | None = None """What stopped the run: the last attempt's stage, or a limit before the next attempt.""" stages: list[Stage] = field(default_factory=list) @@ -1185,7 +1191,7 @@ def finish(run: Run) -> PlotResult: shipped = shipped_candidate(run) if shipped is None: reason: FailureReason - if run.reason in ("budget", "deadline", "error"): + if run.reason in ("budget", "deadline", "busy", "error"): reason = run.reason else: reason = "render" if run.rendered else "validation" @@ -1474,6 +1480,7 @@ async def run_pipeline(ctx: Context, node_input: PipelineArgs) -> AsyncGenerator reason=not_ready or "no_dataset", plot_language=telemetry.plot_language(language), readiness=telemetry.readiness(view.readiness) if view is not None else None, + lane=ledger.tier, ) yield _result_event(PlotResult.not_ready(not_ready or "no_dataset", request_kept=kept)) return @@ -1510,9 +1517,13 @@ async def run_pipeline(ctx: Context, node_input: PipelineArgs) -> AsyncGenerator yield event except Exception as exc: # every failure ends in a PlotResult; the type is logged, never the message logger.warning("pipeline failed: %s", type(exc).__name__) - run.reason = "error" - run.error_type = type(exc).__name__ - run.unreviewed_why = run.unreviewed_why or "an internal error stopped the run" + busy = model_busy(exc) # a quota or overload after the SDK's retries: try again shortly, not a fault + run.reason = "busy" if busy else "error" + # The innermost class, not ADK's `DynamicNodeFailError` around a sub-agent's failure (Flash review, 2026-11-01). + run.error_type = type(error_chain(exc)[-1]).__name__ + run.unreviewed_why = run.unreviewed_why or ( + "the model service was busy" if busy else "an internal error stopped the run" + ) if len(run.stages) < run.attempts: _end_attempt(run, "error") run.stage = "error" @@ -1550,7 +1561,8 @@ def _attribute_result(ledger: RequestLedger, run: Run, result: PlotResult) -> No harness counts a shipped run without texts where they are expected (`agents/evals/report.py`, `untexted`). `error` is the exception class behind reason `error` (for example `RendererUnavailable`, which the harness treats as an - outage rather than a model failure), never its message. `stage` names what stopped + outage rather than a model failure) or `busy` (a `RateLimitError` or an `OverloadedError`), + the innermost class of `models.error_chain`, never its message. `stage` names what stopped the run and `stages` what ended each attempt (`Stage`); `shipped_attempt` and `reviewed_attempt` name the attempt whose render shipped and the one the reviewer was last called on, and `reviews` counts the reviewer calls. @@ -1576,7 +1588,7 @@ def _attribute_result(ledger: RequestLedger, run: Run, result: PlotResult) -> No texts=sorted(shipped.texts) if shipped and shipped.texts is not None else None, texts_expected=runtime_for(run.view.library).text_probe, residual=len(result.residual_defects), - error=run.error_type if result.reason == "error" else None, + error=run.error_type if result.reason in ("error", "busy") else None, ) @@ -1643,6 +1655,13 @@ def _telemetry_run( review_ms=telemetry.ms(run.stage_s.get("review", 0.0)), total_ms=telemetry.ms(seconds), steps=telemetry.step_tokens(ledger.steps, steps=("adapter", "reviewer")), + # Whether anyone waits long or comes close to a limit (owner, 2026-11-01): buckets and bounded numbers of + # the queue and the usage book, never the user they were counted for. + lane=ledger.tier, + queue_wait_ms=telemetry.ms(ledger.queue_wait_s) if ledger.queue_wait_s is not None else None, + queue_position=ledger.queue_position, + user_runs_today=telemetry.runs_today_bucket(ledger.user_runs_today), + user_tokens_today=telemetry.tokens_today_bucket(ledger.user_tokens_share), ) @@ -1901,7 +1920,10 @@ async def _attempts( rendered = await services.backend.render(job) render_s = time.monotonic() - started run.timed("render", render_s) - report = evaluate( + # The gates decode and scan the PNGs, CPU work of up to a second: in a worker thread, so the other runs in + # flight (five in the admin lane) keep streaming meanwhile, as the watermark does (`main._served_png`). + report = await asyncio.to_thread( + evaluate, rendered, themes=job.themes, library=view.library, @@ -1923,6 +1945,7 @@ async def _attempts( attempt=attempt, theme=run.theme, render_s=round(render_s, 3), + slot_wait_s=round(rendered.slot_wait_s, 3), wall_s={theme: round(output.wall_s, 3) for theme, output in rendered.outputs.items()}, passed=report.passed_host_gates, canvas_ok=report.canvas_ok, diff --git a/agents/anyplot/plugins/budget.py b/agents/anyplot/plugins/budget.py index 8e202d8bc90..ef64248fa1b 100644 --- a/agents/anyplot/plugins/budget.py +++ b/agents/anyplot/plugins/budget.py @@ -163,11 +163,17 @@ async def before_tool_callback( if ToolSafetyPlugin.check(tool.name, tool_args, tool_context.agent_name) is not None: return None # ToolSafety refuses an invalid call; it is not a run either usage = get_services().usage + settings = get_settings() user = ledger.user_id or tool_context.session.user_id - if not usage.runs_ok(user, get_settings()): + if not usage.runs_ok(user, settings, ledger.tier): attribution("budget_pipeline", ledger, verdict="refused") return {"status": "error", "code": "budget"} + # What the run's telemetry buckets (`telemetry.runs_today_bucket`, `tokens_today_bucket`): how far into + # the day's limits the user was when this run was booked, measured against the public budget for every + # tier so the numbers stay comparable. The user id never leaves the usage book. + ledger.user_tokens_share = usage.user_tokens(user) / settings.daily_token_budget usage.add_pipeline_run(user) + ledger.user_runs_today = usage.user_runs(user) return None except Exception as exc: logger.warning("pipeline budget failed: %s", type(exc).__name__) diff --git a/agents/anyplot/plugins/ledger.py b/agents/anyplot/plugins/ledger.py index 583ddc33bbc..4bf5b6a7cf8 100644 --- a/agents/anyplot/plugins/ledger.py +++ b/agents/anyplot/plugins/ledger.py @@ -20,6 +20,17 @@ which the `/messages` route checks before the scope judge (`message_limit`). Only a new UTC day, a redeploy or a scale-to-zero resets them. +Every check takes the caller's `Tier`, which the `/v1` route reads from the header the BFF +sets (`agents/main.py` `caller_tier`). An `admin` is exempt from the per-user day caps (the +token budget, the pipeline runs, the data runs, the chat turns) and from the chat turns' +minute (owner decision of 2026-11-01: a fan-out over every library starts a turn and a run +per library); the global day budget and the `attack` strikes bind every tier, the one as +the service's runaway guard, the other as a safety rule rather than a usage cap. Pastes, +file conversions and outcome beacons keep their limits for every tier: they bound memory +and sandbox time, not model spend. The fan-out's copies bind no admin (`copy_limit`): three +windows of `.plot_all()` copy 45 datasets within a minute, and the store's byte cap still +refuses a copy it has no room for. + The request, user-day and global-day budgets count cost-weighted tokens: each kind weighted by its price relative to an uncached input token (`BUDGET_WEIGHTS`), so a cache read counts a tenth and an output token five times. `RequestLedger.tokens` and @@ -54,6 +65,9 @@ logger = logging.getLogger(ATTRIBUTION_LOGGER) RequestKind = Literal["action", "text"] +Tier = Literal["admin", "public"] +"""Who the caller is to the limits: `admin` (the BFF asserts it for an admin identity) or `public` (everyone else, +and every request without the assertion).""" MAX_ADHOC_LEDGERS = 64 @@ -104,6 +118,9 @@ class RequestLedger: user_id: str = "" session_id: str = "" kind: RequestKind = "text" + tier: Tier = "public" + """The caller's tier, set by the `/messages` route from the BFF's assertion: the run queue's lane and the day + caps' exemptions follow it. `public` without a route (`adk web`).""" invocation_id: str | None = None llm_calls: int = 0 tokens: int = 0 @@ -162,6 +179,16 @@ class RequestLedger: """The operation names of every `prepare_data` call that changed the data (closed: `telemetry.PREP_OPS`).""" reply_question: bool = False """The root's last reply sent to the page ends with a question mark (`stream.Translator`); the text is never kept.""" + queue_position: int | None = None + queue_wait_s: float | None = None + """Where the turn's run-queue entry stood at submit (0: it started at once) and how long it waited, set by the + route once the entry starts (`TurnSlot.take`); None for a turn that never ran sandbox work. The usage telemetry's + `queue_position` and `queue_wait_ms` (`pipeline_run`).""" + user_runs_today: int | None = None + user_tokens_share: float | None = None + """This run's ordinal among the user's pipeline runs of the UTC day, and the share of the public per-user daily + token budget (`AGENT_DAILY_TOKEN_BUDGET`) the user had spent when it was booked, admins measured against it too, + set by the Budget plugin (`before_tool_callback`). Only their buckets reach the telemetry, never the user.""" site_theme: Theme | None = None """The theme the page showed the site in when the user sent the turn (`/messages?theme=`), which a plot run takes when neither the user nor the version it changes asked for one (`pipeline.run_theme`); None without a page.""" @@ -257,7 +284,7 @@ class _Day: review of 2026-11-01: 600 let one user put about 3 GiB a day into the 256 MiB store). A copy parses nothing and calls no model, so it is no paste, but each one takes the dataset's size in the store again: the cap bounds what one user can put there. A per-user byte budget in the store is the follow-up before the chat goes public. Constants, not -settings, like the outcome beacons'.""" +settings, like the outcome beacons'. An admin is exempt from both (owner decision of 2026-11-01, `copy_limit`).""" class UsageBook: @@ -308,10 +335,13 @@ def add_strike(self, user_id: str, content: str) -> int: def user_strikes(self, user_id: str) -> int: return len(self._current().user_strikes.get(user_id, ())) - def daily_ok(self, user_id: str, settings: AgentSettings, *, reserve: int = 0) -> bool: - """Whether the user and the service still have daily token budget, `reserve` tokens beyond the spend, and strikes left.""" + def daily_ok(self, user_id: str, settings: AgentSettings, *, reserve: int = 0, tier: Tier = "public") -> bool: + """Whether the user and the service still have daily token budget, `reserve` tokens beyond the spend, and strikes left. + + An `admin` has no daily token budget of their own: only the service's and the strikes bind them. + """ return ( - self.user_tokens(user_id) + reserve < settings.daily_token_budget + (tier == "admin" or self.user_tokens(user_id) + reserve < settings.daily_token_budget) and self.global_tokens() + reserve < settings.global_daily_token_budget and self.user_strikes(user_id) < settings.attack_strikes ) @@ -321,8 +351,9 @@ def locked_out(self, user_id: str, settings: AgentSettings) -> bool: `daily_ok` folds into its answer, told apart from a spent token budget.""" return self.user_strikes(user_id) >= settings.attack_strikes - def runs_ok(self, user_id: str, settings: AgentSettings) -> bool: - return self.user_runs(user_id) < settings.daily_pipeline_runs + def runs_ok(self, user_id: str, settings: AgentSettings, tier: Tier = "public") -> bool: + """Whether the user may start another pipeline run today (`AGENT_DAILY_PIPELINE_RUNS`); always for an admin.""" + return tier == "admin" or self.user_runs(user_id) < settings.daily_pipeline_runs def add_data_run(self, user_id: str) -> None: """Count one `run_data_code` run against the user's day, before it runs.""" @@ -332,16 +363,20 @@ def add_data_run(self, user_id: str) -> None: def user_data_runs(self, user_id: str) -> int: return self._current().user_data_runs.get(user_id, 0) - def data_runs_ok(self, user_id: str, settings: AgentSettings) -> bool: - return self.user_data_runs(user_id) < settings.daily_data_runs + def data_runs_ok(self, user_id: str, settings: AgentSettings, tier: Tier = "public") -> bool: + """Whether the user may run another data snippet today (`AGENT_DAILY_DATA_RUNS`); always for an admin.""" + return tier == "admin" or self.user_data_runs(user_id) < settings.daily_data_runs def user_messages(self, user_id: str) -> int: """The user's chat turns today (typed messages and actions alike) that passed `message_limit`.""" return self._current().user_messages.get(user_id, 0) - def message_limit(self, user_id: str, settings: AgentSettings) -> MessageLimit | None: + def message_limit(self, user_id: str, settings: AgentSettings, tier: Tier = "public") -> MessageLimit | None: """Why the user may not send another chat turn now, or None; checked before the scope judge, so a flood costs - no model call. A refused turn is not counted: the window frees itself one minute after the last counted one.""" + no model call. A refused turn is not counted: the window frees itself one minute after the last counted one. + Never a limit for an admin, whose turns are still counted.""" + if tier == "admin": + return None return self._limit( "message", user_id, @@ -404,9 +439,13 @@ def add_outcome(self, user_id: str) -> None: day.user_outcomes[user_id] = day.user_outcomes.get(user_id, 0) + 1 self._stamp("outcome", user_id) - def copy_limit(self, user_id: str) -> MessageLimit | None: + def copy_limit(self, user_id: str, tier: Tier = "public") -> MessageLimit | None: """Why the user may not copy another dataset into a fan-out session now, or None (`COPIES_PER_MINUTE`, - `_PER_DAY`).""" + `_PER_DAY`). Never a limit for an admin (owner decision of 2026-11-01), whose copies are still counted: three + windows of `.plot_all()` copy 45 datasets within a minute, and the store's own byte cap still refuses a copy + it has no room for (`503 capacity`).""" + if tier == "admin": + return None count = self._current().user_copies.get(user_id, 0) return self._limit("copy", user_id, count, COPIES_PER_MINUTE, COPIES_PER_DAY) @@ -463,10 +502,10 @@ def budget_allows( `reserve` is what the caller still needs after the check, in cost-weighted tokens: the pipeline keeps room for a review and the root's closing reply (`pipeline.REVIEW_RESERVE_TOKENS`), so a review never leaves the reply to a shipped - plot to the `budget` refusal. + plot to the `budget` refusal. The user-day budget follows the ledger's tier (`UsageBook.daily_ok`). """ return request_ok(ledger, settings, next_calls=next_calls, reserve=reserve) and usage.daily_ok( - ledger.user_id or "anonymous", settings, reserve=reserve + ledger.user_id or "anonymous", settings, reserve=reserve, tier=ledger.tier ) diff --git a/agents/anyplot/plugins/scope_guard.py b/agents/anyplot/plugins/scope_guard.py index 2bb078c6576..385f8873ba4 100644 --- a/agents/anyplot/plugins/scope_guard.py +++ b/agents/anyplot/plugins/scope_guard.py @@ -288,7 +288,7 @@ async def _judge( services = get_services() settings = get_settings() user = ledger.user_id or invocation_context.session.user_id - if not services.usage.daily_ok(user, settings): + if not services.usage.daily_ok(user, settings, tier=ledger.tier): reason = LOCKED_OUT if services.usage.locked_out(user, settings) else "budget" ledger.refuse(reason, refusal(reason, ledger.lang)) attribution("scope_guard", ledger, verdict=reason) diff --git a/agents/anyplot/render/contract.py b/agents/anyplot/render/contract.py index f02e0dcb08d..07b58b4b0fa 100644 --- a/agents/anyplot/render/contract.py +++ b/agents/anyplot/render/contract.py @@ -156,6 +156,9 @@ class RenderResult: job_id: str outputs: dict[Theme, ThemeOutput] = field(default_factory=dict) + slot_wait_s: float = 0.0 + """Seconds the job waited for the instance's render slot (`render/serial.py`), every theme together: what the + runs in flight cost each other's renders.""" class DataRunUnsupported(RendererUnavailable): diff --git a/agents/anyplot/render/serial.py b/agents/anyplot/render/serial.py index 6272ec86ef4..55999b40763 100644 --- a/agents/anyplot/render/serial.py +++ b/agents/anyplot/render/serial.py @@ -10,8 +10,10 @@ two sandboxes at once. * **Runs first.** A slot that comes free goes to a waiting pipeline render (`run` lane) before a waiting theme toggle (`toggle` lane), first come first served - within a lane. A run therefore waits for at most the render in progress, never - for a line of toggles that arrived before it. + within a lane. A run therefore never waits for a line of toggles that arrived + before it, only for the renders of the other runs in flight: since 2026-11-01 up + to five at once (`run_queue.py`), whichever run-queue lane they came from, their + starts at least 10 s apart. `RenderResult.slot_wait_s` reports the wait. * **Bounded waits.** A caller may pass `wait_s`: when no slot came free in time, `render` raises `RenderBusy` and nothing ran. The theme toggle uses it, so a synchronous route never hangs behind a busy instance. @@ -25,6 +27,7 @@ """ import asyncio +import time from collections import deque from dataclasses import replace from typing import Literal @@ -99,10 +102,12 @@ async def render(self, job: RenderJob, *, lane: RenderLane = "run", wait_s: floa result = RenderResult(job_id=job.job_id) for theme in job.themes: part = job if len(job.themes) == 1 else replace(job, themes=(theme,)) + asked = time.monotonic() try: await self.slots.acquire(lane, wait_s) except TimeoutError: raise RenderBusy(f"no render slot came free within {wait_s} s") from None + result.slot_wait_s += time.monotonic() - asked try: rendered = await self.backend.render(part) finally: diff --git a/agents/anyplot/run_queue.py b/agents/anyplot/run_queue.py index daa37b9246f..e46d79e7d2c 100644 --- a/agents/anyplot/run_queue.py +++ b/agents/anyplot/run_queue.py @@ -7,41 +7,61 @@ answer, a refusal, `prepare_data` or `generate_data` never touches the queue. An entry runs when both limits allow it, in the order of its lane and then its arrival: -* **Concurrency.** At most `AGENT_RUN_CONCURRENCY` runs are in flight (default 1), - because spikes S and S2 showed one 4 GiB instance serves one sandbox at a time safely. -* **Rate.** A run may start only while fewer than `AGENT_RUNS_PER_MINUTE` runs started - in the last 60 seconds (default 1), a sliding window of start times. +* **Concurrency.** At most `AGENT_RUN_CONCURRENCY` runs are in flight in the whole + instance, every lane together (default 5 since 2026-11-01, 1 before). A run spends most + of its time in model calls, which wait on the network; its renders go to the renderer + service through the instance's one render slot (`render/serial.py`). +* **Rate.** A run may start only while fewer than `AGENT_RUNS_PER_MINUTE` runs started in + the last 60 seconds (a sliding window of start times), and since 2026-11-01 at least + `60 / AGENT_RUNS_PER_MINUTE` seconds after the previous start (`start_gap_s`): at the + default of 6, one start every 10 seconds (owner decision of 2026-11-01, "1 start every + 10 s"). The window alone would let a fan-out start five runs at once, whose renders then + meet in the one render slot (24 s of waiting at the 95th percentile in the measurement + of 2026-11-01); the gap spreads them. The queue holds at most `capacity = AGENT_RUNS_PER_MINUTE * AGENT_QUEUE_MAX_WAIT_S / 60` -waiting entries (10 at the defaults): a new entry that would stand at a position past +waiting entries (60 at the defaults): a new entry that would stand at a position past it would wait longer than the maximum, so `submit` refuses it with `QueueFull`, which the route answers as `503 capacity`. An entry that can start at once never waits and is accepted even when `capacity` is 0. An entry that waited `AGENT_QUEUE_MAX_WAIT_S` leaves with `QueueTimeout`, which the stream ends as `error{code:"capacity"}`; an -entry whose turn comes at that very moment starts instead. +entry whose turn comes at that very moment starts instead. Every waiting entry holds an +open stream, so the instance's Cloud Run concurrency (`agents/cloudbuild.yaml`) covers +the capacity, the runs in flight and the other routes. The capacity is the owner's formula (2026-10-09) and assumes that runs end within the -60-second rate window. A run that takes longer holds the only slot past the window, -so every later start slips by the difference: at one run in flight, ten accepted +time their starts take: at one run in flight and one start a minute, ten accepted entries and 90-second runs, the last four wait the full maximum and leave with `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 -concurrency and rate limits bind it too, and its position counts only the entries -ahead of it, so it can push normal entries past the maximum wait. +**Lanes** (owner decision of 2026-11-01). An entry carries a lane. Every lane shares the +concurrency, the rate and the capacity above; the lanes differ in their per-user cap and +their order: + +* `normal`, every user who is not an admin, today and after the public launch: + `AGENT_USER_MAX_RUNS` entries per user (default 2 since 2026-11-01, 3 before). +* `admin`, the admin's runs, the fan-out over every library (`.plot_all()`) above all: + `AGENT_ADMIN_USER_MAX_RUNS` entries per admin (default 5), in the order of arrival + with the `normal` entries. The admin's other exemptions (no per-user day caps, no + message minute) live in the usage book (`plugins/ledger.py`). +* `premium` goes before every `normal` and `admin` entry, under the `normal` lane's + per-user cap. Nothing sets it yet; it is the lane for users who later pay for their own + tokens. The concurrency and rate limits bind it too, and its position counts only the + entries ahead of it, so it can push other entries past the maximum wait. + +The route picks the lane from the tier the BFF asserts for the caller (`X-Anyplot-Tier`, +`agents/main.py` `caller_tier`), never from anything the browser sets. + +**Per user.** A user may hold at most their lane's per-user cap of entries, 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. **Waiting.** `wait(ticket)` is an async generator that yields the entry's `QueuePosition` at once, on every change of its position or of the queue length, and @@ -52,10 +72,10 @@ until `release`: the route releases it when the run ends, and the run registry's stale sweep releases one whose stream vanished. -The clock is injectable, and time-based changes (the rate window opening, an entry -expiring) are evaluated on every `pump`, so tests advance a fake clock and call -`pump()` instead of sleeping. One asyncio loop drives everything; nothing is locked. -No ADK import. +The clock is injectable, and time-based changes (the rate window opening, the start gap +passing, an entry expiring) are evaluated on every `pump`, so tests advance a fake clock +and call `pump()` instead of sleeping. One asyncio loop drives everything; nothing is +locked. No ADK import. """ import asyncio @@ -70,9 +90,9 @@ from .settings import AgentSettings -Lane = Literal["premium", "normal"] -LANE_PRIORITY: dict[str, int] = {"premium": 0, "normal": 1} -"""Lower runs first; within a lane, the earlier arrival runs first.""" +Lane = Literal["admin", "premium", "normal"] +LANE_PRIORITY: dict[str, int] = {"premium": 0, "normal": 1, "admin": 1} +"""Lower runs first; within the same priority, the earlier arrival runs first.""" TicketState = Literal["waiting", "running", "done", "withdrawn", "expired"] RATE_WINDOW_S = 60.0 HEARTBEAT_S = 15.0 @@ -106,7 +126,11 @@ class QueuePosition: @dataclass(eq=False) class Ticket: - """One queue entry: a run that waits, runs, or has left the queue.""" + """One queue entry: a run that waits, runs, or has left the queue. + + `submitted_position` is where the entry stood when it was submitted, 0 when it started at once: the usage + telemetry's `queue_position` (`pipeline_run`), never a user id or anything that names one. + """ user: str session_id: str @@ -115,6 +139,7 @@ class Ticket: enqueued_at: float state: TicketState = "waiting" started_at: float | None = None + submitted_position: int = 0 changed: asyncio.Event = field(default_factory=asyncio.Event, repr=False) @property @@ -129,9 +154,15 @@ def waiting(self) -> bool: def running(self) -> bool: return self.state == "running" + @property + def waited_s(self) -> float | None: + """Seconds from submit to start, 0 for an entry that started at once; None before it started.""" + return None if self.started_at is None else self.started_at - self.enqueued_at + class RunQueue: - """A two-lane FIFO of runs under a concurrency limit and a sliding-window rate limit.""" + """A FIFO of runs under a concurrency limit, a sliding-window rate limit, a minimum gap between starts, and a + per-user cap per lane.""" def __init__( self, @@ -142,21 +173,32 @@ def __init__( clock: Callable[[], float] = time.monotonic, window_s: float = RATE_WINDOW_S, per_user: int | None = None, + admin_per_user: int | None = None, + start_gap_s: float = 0.0, ) -> 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: + if any(cap is not None and cap < 1 for cap in (per_user, admin_per_user)): raise ValueError("the per-user cap must be positive") + if start_gap_s < 0: + raise ValueError("the start gap must not be negative") 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.""" + """Entries one user may hold in the `normal` and `premium` lanes, waiting and running (`AGENT_USER_MAX_RUNS`); + None sets no cap.""" + self.admin_per_user = admin_per_user + """Entries one admin may hold in the `admin` lane (`AGENT_ADMIN_USER_MAX_RUNS`); None sets no cap.""" + self.start_gap_s = start_gap_s + """The least seconds between two starts, every lane together (`60 / AGENT_RUNS_PER_MINUTE` from the + settings); 0 leaves only the window.""" self._clock = clock self._waiting: list[Ticket] = [] self._running: list[Ticket] = [] self._starts: deque[float] = deque() + self._last_start = -math.inf self._seq = itertools.count(1) @classmethod @@ -167,12 +209,18 @@ def from_settings(cls, settings: AgentSettings, clock: Callable[[], float] = tim max_wait_s=settings.queue_max_wait_s, clock=clock, per_user=settings.user_max_runs, + admin_per_user=settings.admin_user_max_runs, + start_gap_s=RATE_WINDOW_S / settings.runs_per_minute, ) def now(self) -> float: """The queue's clock, which the run registry shares so its stale check measures the same time.""" return self._clock() + def user_cap(self, lane: Lane) -> int | None: + """The entries one user may hold when they submit in `lane`; None sets no cap.""" + return self.admin_per_user if lane == "admin" else self.per_user + @property def capacity(self) -> int: """Waiting entries the maximum wait allows: the rate times the maximum wait, per window.""" @@ -193,13 +241,14 @@ def user_entries(self, user: str) -> int: def submit(self, user: str, session_id: str, lane: Lane = "normal") -> Ticket: """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. + Raises `QueueUserLimit` when the user already holds their lane's cap of entries (checked first), and + `QueueFull` when the run would wait past the maximum. """ now = self._clock() 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") + cap = self.user_cap(lane) + if cap is not None and self.user_entries(user) >= cap: + raise QueueUserLimit(f"the user already holds {cap} 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)): @@ -208,6 +257,8 @@ def submit(self, user: str, session_id: str, lane: Lane = "normal") -> Ticket: self._waiting.sort(key=lambda entry: entry.order) self._wake(self._waiting) self._pump(now) + if ticket.waiting: + ticket.submitted_position = position return ticket def position(self, ticket: Ticket) -> QueuePosition | None: @@ -242,13 +293,17 @@ def release(self, ticket: Ticket) -> None: self.pump() def next_change_at(self, now: float) -> float: - """The next time a waiting entry's fate can change without any call: an expiry or the window opening.""" + """The next time a waiting entry's fate can change without any call: an expiry, the window opening or the + start gap passing.""" if not self._waiting: return math.inf times = [entry.enqueued_at + self.max_wait_s for entry in self._waiting] self._trim(now) - if len(self._running) < self.concurrency and len(self._starts) >= self.per_minute: - times.append(self._starts[0] + self.window_s) + if len(self._running) < self.concurrency: + if len(self._starts) >= self.per_minute: + times.append(self._starts[0] + self.window_s) + if now - self._last_start < self.start_gap_s: + times.append(self._last_start + self.start_gap_s) return min(times) async def wait(self, ticket: Ticket, *, heartbeat_s: float = HEARTBEAT_S) -> AsyncGenerator[QueuePosition, None]: @@ -292,7 +347,11 @@ def _trim(self, now: float) -> None: def _can_start(self, now: float) -> bool: self._trim(now) - return len(self._running) < self.concurrency and len(self._starts) < self.per_minute + return ( + len(self._running) < self.concurrency + and len(self._starts) < self.per_minute + and now - self._last_start >= self.start_gap_s + ) def _pump(self, now: float) -> None: # An entry past its maximum wait never starts, however late the pump that finds @@ -305,6 +364,7 @@ def _pump(self, now: float) -> None: entry.state, entry.started_at = "running", now self._running.append(entry) self._starts.append(now) + self._last_start = now started.append(entry) expired += self._expire(lambda waited: waited >= self.max_wait_s, now) if expired or started: diff --git a/agents/anyplot/schemas.py b/agents/anyplot/schemas.py index b425a85a6e1..76127f41a90 100644 --- a/agents/anyplot/schemas.py +++ b/agents/anyplot/schemas.py @@ -140,7 +140,10 @@ def _visible_text(text: str) -> str: """A render theme; `render/contract.py` re-exports it with the `THEMES` order.""" PlotStatus = Literal["ok", "needs_attention", "failed", "not_ready"] -FailureReason = Literal["validation", "render", "deadline", "budget", "error"] +FailureReason = Literal["validation", "render", "deadline", "budget", "busy", "error"] +"""`busy`: a model call was still refused for its quota (429) or an overload (529) after its retries +(`models.model_busy`), so the run stopped; the user can try again shortly. `error`: any other failure the run could +not recover from.""" NotReadyReason = Literal["no_dataset", "incomplete_bindings", "data_unconfirmed"] """`data_unconfirmed`: the dataset and its bindings are there, but the user has not confirmed them in the data panel yet (step 1 of two, owner rule of 2026-10-11).""" diff --git a/agents/anyplot/settings.py b/agents/anyplot/settings.py index bd7825c3d7b..34ce2eb972e 100644 --- a/agents/anyplot/settings.py +++ b/agents/anyplot/settings.py @@ -36,12 +36,13 @@ | `AGENT_RENDER_IMAGE` | `anyplot-renderer:dev` | Image the `local` renderer runs with Docker (build it from `agents/renderer/Dockerfile`) | | `AGENT_RENDER_CONCURRENCY` | `1` | Renders (one theme each) that may run at the same time; serial, because one 4 GiB instance holds one sandbox safely (spikes S and S2) | | `AGENT_WATERMARK` | `true` | Append the footer strip ("made with any.plot()", "anyplot.ai/") to the PNGs the artifact route and the feedback bundle serve; `false` serves the raw renders. Gates, reviewer and `plot.py` always see the raw render | -| `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_RUN_CONCURRENCY` | `5` | Sandbox runs (a `plot_pipeline` or `run_data_code` call, held until its turn ends) in flight in the whole instance, every lane together (1 before 2026-11-01); the run queue holds the rest. A chat turn without one never enters the queue | +| `AGENT_RUNS_PER_MINUTE` | `6` | Sandbox runs that may start within any 60 seconds (a sliding window), spaced at least 60 / this seconds apart: one start every 10 seconds at the default, in every lane (1 a minute before 2026-11-01); 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, 60 at the defaults) | +| `AGENT_USER_MAX_RUNS` | `2` | Sandbox runs one public user may have queued or running at the same time, across all their sessions (browser windows; 3 before 2026-11-01); 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_ADMIN_USER_MAX_RUNS` | `5` | The same for an admin (`X-Anyplot-Tier: admin` from an allowed caller), whose runs share the concurrency and the rate with everyone's; `GET /v1/status` reports it to an admin as `user_max_runs`, which the fan-out keeps to | +| `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. An admin is exempt | +| `AGENT_USER_MESSAGES_PER_DAY` | `200` | Chat turns per user and UTC day; more get the `daily_limit` refusal before the scope judge runs. An admin is exempt | | `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 | | `AGENT_OTHER_LIBRARY_COUNTS` | `false` | Whether an `other_library` refusal (this plot in another library or language) counts toward `AGENT_SESSION_REFUSALS` | | `AGENT_SESSION_CLOSE_WARNING` | `true` | The counted refusal one before the closing one carries the fixed `close_warning` suffix | @@ -58,13 +59,13 @@ | `AGENT_ROOT_EFFORT`, `AGENT_ADAPTER_EFFORT`, `AGENT_REVIEWER_EFFORT` | `medium`, `high`, `high` | Claude's effort per agent kind (`low`, `medium`, `high`), set by the 2026-10-13 measurements; on the claude-cli transport also how much the CLI thinks | | `AGENT_MAX_LLM_CALLS` | `16` | LLM calls per request (the `RunConfig` cap) | | `AGENT_REQUEST_TOKEN_BUDGET` | `800000` | Cost-weighted tokens per request, in input-token equivalents: an uncached input token counts 1, a cache read 0.1, a cache write 1.25, an output token 5 (`plugins/ledger.BUDGET_WEIGHTS`, the list-price ratios) | -| `AGENT_DAILY_TOKEN_BUDGET` | `8000000` | Cost-weighted tokens per user and day, weighted the same way; about 30 median runs of the live flow of 2026-10-13 | -| `AGENT_DAILY_PIPELINE_RUNS` | `40` | Pipeline runs per user and day | -| `AGENT_GLOBAL_DAILY_TOKEN_BUDGET` | `24000000` | Cost-weighted tokens per day across all users, three users at their daily budget; reaching it pauses the service | +| `AGENT_DAILY_TOKEN_BUDGET` | `10000000` | Cost-weighted tokens per user and day, weighted the same way: about $1 at Claude Haiku 5.5's $0.10 per million input-token equivalents (8,000,000 before 2026-11-01). An admin is exempt | +| `AGENT_DAILY_PIPELINE_RUNS` | `50` | Pipeline runs per user and day (40 before 2026-11-01). An admin is exempt | +| `AGENT_GLOBAL_DAILY_TOKEN_BUDGET` | `100000000` | Cost-weighted tokens per day across all users, admins included: about $10, the runaway guard of the whole service (24,000,000 before 2026-11-01); reaching it pauses the service | | `AGENT_RENDER_TIMEOUT_S` | `60` | Seconds per render, host-enforced | | `AGENT_DATA_CODE` | `true` | Whether the root has `run_data_code`: a data-generating snippet (Python, JavaScript, R, a formula) translated to Python, validated and run in the renderer's sandbox | | `AGENT_DATA_RUN_TIMEOUT_S` | `20` | Seconds one data snippet may run, at most 30 | -| `AGENT_DAILY_DATA_RUNS` | `30` | `run_data_code` runs per user and day | +| `AGENT_DAILY_DATA_RUNS` | `30` | `run_data_code` runs per user and day. An admin is exempt | | `AGENT_NARROWED_BINDINGS` | `keep` | The root's `set_bindings` that only leaves out confirmed number series (`y2`, `y3`, ...): `keep` keeps the user's confirmation, `reset` asks for `.confirm()` again | | `AGENT_REQUEST_DEADLINE_S` | `540` | Hard request deadline, enforced through `abort_signal` | | `AGENT_SOFT_DEADLINE_S` | `480` | Soft deadline inside the pipeline; below the hard one | @@ -320,33 +321,46 @@ class AgentSettings(BaseSettings): render store and the exported `plot.py` always work with the raw render. Off, both routes serve the raw PNGs; the eval harness turns it off so its renders compare with its baselines.""" - run_concurrency: PositiveInt = 1 - """Sandbox runs in flight per instance (`AGENT_RUN_CONCURRENCY`); the run queue holds the rest. Only a turn whose - root calls `plot_pipeline` or `run_data_code` takes a run, from that call to the end of the turn.""" + run_concurrency: PositiveInt = 5 + """Sandbox runs in flight in the whole instance, every lane together (`AGENT_RUN_CONCURRENCY`); the run queue holds + the rest. Only a turn whose root calls `plot_pipeline` or `run_data_code` takes a run, from that call to the end of + the turn. 5 since 2026-11-01 (owner decision; 1 before): a 15-library fan-out took about 20 minutes through one + slot and one start a minute, while each run renders for about 5 s of its 60 to 240 s and spends the rest in model + calls that wait on the network. Renders stay serial in the one render slot (`AGENT_RENDER_CONCURRENCY`).""" - runs_per_minute: PositiveInt = 1 - """Sandbox runs that may start within any 60 seconds (`AGENT_RUNS_PER_MINUTE`), a sliding window; a chat turn - without a sandbox run never counts.""" + runs_per_minute: PositiveInt = 6 + """Sandbox runs that may start within any 60 seconds (`AGENT_RUNS_PER_MINUTE`), a sliding window, and spaced at + least `60 / runs_per_minute` seconds apart (`RunQueue.start_gap_s`), in every lane; a chat turn without a sandbox + run never counts. 6 since 2026-11-01 (owner decision, "1 start every 10 s"; 1 before): the gap keeps the five + runs of a fan-out from reaching the render slot at the same moment.""" queue_max_wait_s: PositiveInt = 600 """Longest wait in the run queue in seconds (`AGENT_QUEUE_MAX_WAIT_S`). Queued time does not count toward the request deadline; the queue holds `runs_per_minute * queue_max_wait_s / 60` - 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.""" + entries (60 at the defaults) and refuses more with `capacity`. That length assumes runs end + within the time their starts take: 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_max_runs: PositiveInt = 2 + """Sandbox runs one public user may have in the run queue at the same time, waiting and running together, across + all their sessions (`AGENT_USER_MAX_RUNS`; 2 since 2026-11-01, 3 before). 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.""" + + admin_user_max_runs: PositiveInt = 5 + """The same for an admin (`AGENT_ADMIN_USER_MAX_RUNS`, owner decision of 2026-11-01): the admin's runs share the + concurrency and the rate with everyone's; `GET /v1/status` reports this cap to an admin as `user_max_runs`, and + the fan-out starts at most that many plots at once.""" 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 - no longer wait in the run queue (2026-10-14), this is what bounds them.""" + no longer wait in the run queue (2026-10-14), this is what bounds them. An admin is exempt (2026-11-01): a fan-out + starts a turn per library.""" user_messages_per_day: PositiveInt = 200 - """Chat turns per user and UTC day (`AGENT_USER_MESSAGES_PER_DAY`), checked like the minute's.""" + """Chat turns per user and UTC day (`AGENT_USER_MESSAGES_PER_DAY`), checked like the minute's; an admin is + exempt.""" session_refusals: PositiveInt = 3 """Scope-judge refusals of type `out_of_scope` or `attack` after which a session is closed @@ -463,19 +477,23 @@ class AgentSettings(BaseSettings): A review starts only while `pipeline.REVIEW_RESERVE_TOKENS` is left for it and the root's closing reply.""" - daily_token_budget: PositiveInt = 8_000_000 - """Cost-weighted tokens per user and day (`AGENT_DAILY_TOKEN_BUDGET`), weighted like the request - budget; raised on 2026-10-13 (2,000,000 before) to about 30 median runs of the live flow. The old - flow's numbers: about 54 median runs with a warm cache, where the 40 runs of `daily_pipeline_runs` - bind first, and about 26 for a user whose every request starts cold (77,700 each), where - this budget binds.""" + daily_token_budget: PositiveInt = 10_000_000 + """Cost-weighted tokens per user and day (`AGENT_DAILY_TOKEN_BUDGET`), weighted like the request budget: about $1 + at Claude Haiku 5.5's $0.10 per million input-token equivalents (`agents/evals/pricing.py`). 10,000,000 since + 2026-11-01 (owner decision; 8,000,000 from 2026-10-13, 2,000,000 before). The old flow's numbers: about 54 median + runs with a warm cache and about 26 for a user whose every request starts cold (77,700 each). An admin is exempt + (owner decision of 2026-11-01): one fan-out over the 15 libraries booked 3,047,083 in the measurement of that day, + so the global budget is an admin's only daily cap on model spend.""" - daily_pipeline_runs: PositiveInt = 40 - """Pipeline runs per user and day (`AGENT_DAILY_PIPELINE_RUNS`).""" + daily_pipeline_runs: PositiveInt = 50 + """Pipeline runs per user and day (`AGENT_DAILY_PIPELINE_RUNS`; 50 since 2026-11-01, 40 before); an admin is + exempt.""" - global_daily_token_budget: PositiveInt = 24_000_000 - """Cost-weighted tokens per day across all users (`AGENT_GLOBAL_DAILY_TOKEN_BUDGET`), three users - at their daily budget; reaching it pauses the service.""" + global_daily_token_budget: PositiveInt = 100_000_000 + """Cost-weighted tokens per day across all users (`AGENT_GLOBAL_DAILY_TOKEN_BUDGET`), admins included: about $10, + the runaway guard of the whole service, which binds every tier; reaching it pauses the service. 100,000,000 since + 2026-11-01 (owner decision; 24,000,000, three users at their daily budget, before): ten public users at their + daily budget, or about thirty admin fan-outs. The Vertex AI spend cap stays the money limit.""" render_timeout_s: PositiveInt = 60 """Seconds per render, host-enforced (`AGENT_RENDER_TIMEOUT_S`).""" @@ -491,7 +509,7 @@ class AgentSettings(BaseSettings): renderer's 30 s (`wire.MAX_DATA_TIMEOUT_S`).""" daily_data_runs: PositiveInt = 30 - """`run_data_code` runs per user and day (`AGENT_DAILY_DATA_RUNS`), counted before each run.""" + """`run_data_code` runs per user and day (`AGENT_DAILY_DATA_RUNS`), counted before each run; an admin is exempt.""" narrowed_bindings: Literal["keep", "reset"] = "keep" """What the root's `set_bindings` does to the user's confirmation when the new bindings only leave out members diff --git a/agents/anyplot/telemetry.py b/agents/anyplot/telemetry.py index a1ddcf2c061..de3e7ef6f3d 100644 --- a/agents/anyplot/telemetry.py +++ b/agents/anyplot/telemetry.py @@ -266,10 +266,19 @@ def _library(value: str) -> str: Step = Literal["root", "adapter", "reviewer", "judge", "other"] PlotStatus = Literal["ok", "needs_attention", "failed", "not_ready"] -FailureReason = Literal["validation", "render", "deadline", "budget", "error"] +FailureReason = Literal["validation", "render", "deadline", "budget", "busy", "error"] NotReadyReason = Literal["no_dataset", "incomplete_bindings", "data_unconfirmed"] """`schemas.NotReadyReason`: why a `not_ready` run did not start (pinned equal by a test).""" ScoreBucket = Literal["lt50", "50_69", "70_79", "80_89", "90_plus"] +Lane = Literal["admin", "public"] +"""The tier a run was served in (`X-Anyplot-Tier`): never a user, only whether the admin's limits applied.""" +RunsTodayBucket = Literal["1", "2_5", "6_10", "11_20", "21_30", "31_40", "41_plus"] +"""This run's ordinal among one user's pipeline runs of the UTC day, bucketed (`runs_today_bucket`).""" +TokensTodayBucket = Literal["lt25", "25_50", "50_75", "75_90", "90_100", "over"] +"""The share in percent of the public per-user daily token budget spent when the run was booked +(`tokens_today_bucket`); `over` is past it, which only an admin, who is exempt, can reach.""" +QueuePositionValue = Annotated[int, Field(ge=0, le=1_000)] +"""Where a run's queue entry stood at submit: 0 started at once, 1 ran next. The queue holds at most 60.""" SandboxFailure = Literal["syntax", "name", "key", "value", "type", "timeout", "memory", "other"] PipelineStage = Literal[ "budget", @@ -529,6 +538,19 @@ class PipelineRun(_Event): review_ms: Ms = 0 total_ms: Ms = 0 steps: list[StepTokens] = _list() + lane: Lane | None = None + """`admin` or `public`, the tier the run was served in (2026-11-01). None on a line from before that day.""" + queue_wait_ms: Ms | None = None + """How long the turn's run-queue entry waited before this run could start, 0 when it started at once; None for a + `not_ready` run, which never enters the queue.""" + queue_position: QueuePositionValue | None = None + """Where the entry stood at submit, 0 when it started at once; None like `queue_wait_ms`.""" + user_runs_today: RunsTodayBucket | None = None + """The bucket of this run's ordinal among the same user's runs of the UTC day, from the in-memory usage book; a + redeploy or a scale-to-zero starts the count again. None for a `not_ready` run.""" + user_tokens_today: TokensTodayBucket | None = None + """The bucket of the share of the public per-user daily token budget the user had spent when the run was booked, + an admin's measured against the same budget. None for a `not_ready` run.""" class Outcome(_Event): @@ -678,6 +700,26 @@ def missing_bucket(missing: int, cells: int) -> MissingBucket: return "gt30" +def runs_today_bucket(ordinal: int | None) -> RunsTodayBucket | None: + """`RunsTodayBucket` of a run's ordinal among the user's runs of the day (1 is the first).""" + if ordinal is None or ordinal < 1: + return None + for bound, bucket in ((1, "1"), (5, "2_5"), (10, "6_10"), (20, "11_20"), (30, "21_30"), (40, "31_40")): + if ordinal <= bound: + return cast(RunsTodayBucket, bucket) + return "41_plus" + + +def tokens_today_bucket(share: float | None) -> TokensTodayBucket | None: + """`TokensTodayBucket` of the share (0.0 to 1.0 and past it) of the daily token budget spent.""" + if share is None or share < 0: + return None + for bound, bucket in ((0.25, "lt25"), (0.50, "25_50"), (0.75, "50_75"), (0.90, "75_90")): + if share < bound: + return cast(TokensTodayBucket, bucket) + return "90_100" if share <= 1.0 else "over" + + def score_bucket(score: int | None) -> ScoreBucket | None: if score is None: return None diff --git a/agents/anyplot/theme_render.py b/agents/anyplot/theme_render.py index 1da96bc4363..c15e29ce015 100644 --- a/agents/anyplot/theme_render.py +++ b/agents/anyplot/theme_render.py @@ -34,6 +34,7 @@ No ADK import. """ +import asyncio import logging import secrets from dataclasses import dataclass @@ -107,7 +108,8 @@ async def render_theme( except Exception as exc: # no Docker, the sandbox stub: the class is logged, never the message logger.warning("theme render could not run: %s", type(exc).__name__) return ThemeResult("failed", "error", version.artifacts()) - report = evaluate( + report = await asyncio.to_thread( # CPU work on the PNG: off the loop, like the pipeline's gates + evaluate, result, themes=job.themes, library=version.library, diff --git a/agents/anyplot/tools/session.py b/agents/anyplot/tools/session.py index 27b7b404320..54cb5f22667 100644 --- a/agents/anyplot/tools/session.py +++ b/agents/anyplot/tools/session.py @@ -592,7 +592,7 @@ async def run_data_code(code: str, source_language: str, tool_context: ToolConte # 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): + if not services.usage.data_runs_ok(user, settings, ledger.tier): 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 diff --git a/agents/cloudbuild.yaml b/agents/cloudbuild.yaml index 02602007c75..0b8277a3feb 100644 --- a/agents/cloudbuild.yaml +++ b/agents/cloudbuild.yaml @@ -170,12 +170,15 @@ steps: # worst case is about 1.1 GiB: the dataset and render stores' 256 MiB caps, the # process, a 1 MB paste's parse and the PNG gates, and an out-of-memory kill would # lose every session in memory, hence 2 GiB); ONE instance, because sessions, datasets, - # renders and the run queue live in memory; concurrency 20 and a 1200 s timeout + # renders and the run queue live in memory; concurrency 80 and a 1200 s timeout # because of the run queue, not the CPU: every queued run holds an open stream, - # so the instance must take the queue's 10 waiting streams, the run in flight - # and the other routes, and one turn can last the queue's 600 s maximum wait plus + # so the instance must take the queue's 60 waiting streams and its 5 runs in + # flight (since 2026-11-01: 6 starts a minute times the 600 s wait; 10 and 1 + # before, at concurrency 20) and about 15 more for the other routes, and one turn + # can last the queue's 600 s maximum wait plus # the 540 s request deadline (anyplot-api's own --timeout is 1200 for the same - # turn). No secrets, no Cloud SQL in phase 1. + # turn). An idle stream waiting in the queue costs a heartbeat every 15 s, no CPU + # to speak of. No secrets, no Cloud SQL in phase 1. # # `--update-env-vars`, never `--set-env-vars`, for the reason api/cloudbuild.yaml # gives: an out-of-band variable must survive a deploy. @@ -223,7 +226,7 @@ steps: --memory=2Gi \ --min-instances=${_MIN_INSTANCES} \ --max-instances=1 \ - --concurrency=20 \ + --concurrency=80 \ --timeout=1200 \ "--update-env-vars=^|^ENVIRONMENT=production|GOOGLE_CLOUD_PROJECT=$PROJECT_ID|GOOGLE_GENAI_USE_ENTERPRISE=TRUE|GOOGLE_CLOUD_LOCATION=eu|AGENT_LOCATION=eu|AGENT_PROVIDER=${_PROVIDER}|AGENT_MODEL=${_MODEL}|AGENT_JUDGE_MODEL=${_JUDGE_MODEL}|AGENT_LIBRARIES=${_LIBRARIES}|AGENT_RENDERER=remote|AGENT_RENDER_URL=${_RENDER_URL}|AGENT_ALLOWED_CALLERS=${_API_CALLER},${_SERVICE_ACCOUNT}|AGENT_SERVICE_URLS=$$AUDIENCES|ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS=false" \ "$${TRAFFIC[@]}" \ diff --git a/agents/main.py b/agents/main.py index 10e8bf4bd93..d36226c13a1 100644 --- a/agents/main.py +++ b/agents/main.py @@ -8,7 +8,7 @@ | Route | Body | Result | Errors | |---|---|---|---| -| `GET /v1/status` | | `{libraries, model, location, version, provider, waiting, in_flight, user_max_runs}` (`user_max_runs`: `AGENT_USER_MAX_RUNS`) | | +| `GET /v1/status` | | `{libraries, model, location, version, provider, waiting, in_flight, user_max_runs, tier}` (`waiting` and `in_flight`: every lane of the run queue; `user_max_runs`: the caller's own cap, `AGENT_USER_MAX_RUNS` or, for an admin, `AGENT_ADMIN_USER_MAX_RUNS`; `tier`: `admin` or `public`, as the request was served) | | | `GET /v1/eligibility?spec=&library=` | | `{eligible, status, reasons}` | | | `POST /v1/sessions` | `{user, spec_id, library, locale, snapshot, source?}` (`source`: `fanout` for a session the admin's "plot in all libraries" opens; its telemetry lines carry that source) | `{session_id, eligibility}` | `422 not_eligible` | | `POST /v1/sessions/{sid}/library` | `{library, snapshot}` | `{session_id, eligibility}` | `422 not_eligible`, `409 run_active` | @@ -19,7 +19,7 @@ | `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` | | `POST /v1/sessions/{sid}/dataset/undo` | | `{preview, profile, bindings, warnings, context_lines, roles, suggestion, version}`: the dataset version before the root's last `prepare_data` or `generate_data` is the current one again, its bindings carried over (`dataset_versions.py`); unconfirmed, like any version the user has not confirmed | `409 run_active`, `422 nothing_to_undo` | | `POST /v1/sessions/{sid}/dataset/to_long` | | The undo route's shape plus `changes`: the page's `.to_long()` applies the wide-table suggestion, recomputed on the server, as the next dataset version with the new columns bound to the roles they were made for; no model call | `409 run_active`, `422 no_suggestion` | -| `POST /v1/sessions/{sid}/dataset/copy` | `{from_session}` | The paste route's dataset shape (version 1) plus `confirmed: true`, `complete`, `missing_roles` and `copied_from` (the source's version number): the admin fan-out's copy of the confirmed current data version of `from_session`, a session of the same user and spec, into this fan-out session, byte for byte, with its bindings, their confirmation and the session language; nothing parsed or judged, no model call, no paste counted | `404 session_expired` (either session, also another user's), `409 run_active` (either session), `409 has_dataset` (the target has data), `422 no_dataset`, `422 unconfirmed`, `422 invalid` (the same session, a target that is no fan-out session, another spec, refused bindings), `429 rate_limited` / `429 daily_limit` (`COPIES_PER_MINUTE` / `_PER_DAY`), `503 capacity` | +| `POST /v1/sessions/{sid}/dataset/copy` | `{from_session}` | The paste route's dataset shape (version 1) plus `confirmed: true`, `complete`, `missing_roles` and `copied_from` (the source's version number): the admin fan-out's copy of the confirmed current data version of `from_session`, a session of the same user and spec, into this fan-out session, byte for byte, with its bindings, their confirmation and the session language; nothing parsed or judged, no model call, no paste counted | `404 session_expired` (either session, also another user's), `409 run_active` (either session), `409 has_dataset` (the target has data), `422 no_dataset`, `422 unconfirmed`, `422 invalid` (the same session, a target that is no fan-out session, another spec, refused bindings), `429 rate_limited` / `429 daily_limit` (`COPIES_PER_MINUTE` / `_PER_DAY`; never for an admin), `503 capacity` | | `POST /v1/sessions/{sid}/messages?theme=` | `{text}` or `{action}` (`create_plot`, `summarize_data`); `theme` (`light`, `dark`, optional) is the site theme the page shows, which a plot run of the turn renders unless the user asked for a theme (`pipeline.run_theme`) | SSE `anyplot/1`; the `done` event carries `dataset` (with `roles` and `changes`) after a turn whose `prepare_data` or `generate_data` changed the data, version 1 when `generate_data` made the session's first data, and `language` (`{language, source}`) after every run, and `suggestion` (the `.to_long()` offer in the session language, or null) after every run on a session with data | `413 too_long`, `409 run_active`, `422` for a `theme` other than `light` or `dark` | | `POST /v1/sessions/{sid}/cancel` | | `204` | | | `POST /v1/sessions/{sid}/outcome` | `{action, version?, reaction?}` (closed sets: `telemetry.OutcomeAction`, `telemetry.Reaction`, the latter with `feedback` only) | `204`: the content-free `outcome` telemetry line (`anyplot/telemetry.py`) | `422`, `429 rate_limited` / `429 daily_limit` (`OUTCOMES_PER_MINUTE` / `_PER_DAY`) | @@ -36,6 +36,18 @@ `X-Serverless-Authorization` whenever that header is present, because Cloud Run then checks only that one and passes `Authorization` through unverified. +`X-Anyplot-Tier: admin` puts the request in the admin tier (owner decision of 2026-11-01): +its sandbox runs go into the run queue's admin lane, which shares the instance's +concurrency and start rate with everyone's and allows five runs per admin +(`AGENT_ADMIN_USER_MAX_RUNS`), and it is exempt from the per-user day caps and the chat +turns' minute (`plugins/ledger.py`); the global day budget binds it like everyone. The BFF sets the +header from the identity its admin gate verified, and only an allowed caller gets past the +caller check, so nobody else can claim the tier. A missing or other value is the public +tier, never an error: a BFF deployed before the header keeps today's limits for everyone. +A header, not the `adm_` prefix of the user id: the id stays opaque, the tier stays one +explicit assertion in one place of the BFF (`agent_context`), and a later tier (`premium`) +needs no new id format. + A `/messages` turn starts at once: the stream sends `ready` and the run begins, so a plain answer, a refusal, `prepare_data` or `generate_data` never waits (owner decision of 2026-10-14). Only sandbox work goes through the run queue (`anyplot/run_queue.py`): @@ -51,7 +63,8 @@ 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 +`AGENT_USER_MAX_RUNS` sandbox runs (an admin `AGENT_ADMIN_USER_MAX_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. @@ -62,7 +75,9 @@ `session_closed` in a session the scope judge closed (`AGENT_SESSION_REFUSALS` refusals of type `out_of_scope` or `attack`), and `rate_limited` or `daily_limit` past the user's message limits (`AGENT_USER_MESSAGES_PER_MINUTE`, -`AGENT_USER_MESSAGES_PER_DAY`, counted in the usage book; actions count too). +`AGENT_USER_MESSAGES_PER_DAY`, counted in the usage book; actions count too). An admin is +exempt from the user's daily token budget and both message limits; the strikes, the global +budget and a closed session bind every tier. 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 @@ -171,7 +186,7 @@ pasted_language, refusal_lang, ) -from agents.anyplot.models import JudgeFailure, JudgeUnavailable +from agents.anyplot.models import JudgeFailure, JudgeUnavailable, model_busy from agents.anyplot.opening import ( INSTRUCTION_LIKE, MAX_SOURCE_RAW_CHARS, @@ -192,6 +207,7 @@ ATTRIBUTION_LOGGER, CURRENT_LEDGER, RequestLedger, + Tier, attribution, budget_allows, judge_budget_tokens, @@ -205,6 +221,7 @@ from agents.anyplot.render.watermark import add_footer from agents.anyplot.run_queue import ( HEARTBEAT_S, + Lane, QueueFull, QueuePosition, QueueTimeout, @@ -465,6 +482,10 @@ def _hit(self) -> None: StreamNote = tuple[Literal["queued"], QueuePosition] | tuple[Literal["event"], Event] | tuple[Literal["end"], None] """What the stream's outbox carries: a queue position of a waiting sandbox run, an ADK event, or the run's end.""" +TIER_LANES: dict[Tier, Lane] = {"admin": "admin", "public": "normal"} +"""The run queue's lane of each tier (`run_queue.py`): an admin's runs share the concurrency and the rate with +everyone's and have a per-user cap of their own.""" + @dataclass class TurnSlot: @@ -475,12 +496,14 @@ class TurnSlot: window and the run slot), pauses the deadline while the entry waits, puts every position into the stream's outbox as it changes (the stream sends it as `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 + A later call in the same turn answers at once: one start per turn. The entry goes into + the lane of the turn's tier (`TIER_LANES`), and its position at submit and its wait go into + the ledger for the run's usage telemetry (`RequestLedger.queue_position`, `queue_wait_s`). 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 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`). + holds their lane's cap of entries (`AGENT_USER_MAX_RUNS`, `AGENT_ADMIN_USER_MAX_RUNS`) 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" @@ -499,15 +522,16 @@ async def take(self) -> bool: # 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() + lane = TIER_LANES[self.ledger.tier] try: - ticket = queue.submit(self.run.user, self.session_id) + ticket = queue.submit(self.run.user, self.session_id, lane) 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) + attribution("queue", self.ledger, verdict="run_limit", waited_s=0.0, lane=lane) 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) + attribution("queue", self.ledger, verdict="full", waited_s=0.0, lane=lane) self._fail("capacity") return False self.run.ticket = ticket @@ -524,7 +548,10 @@ async def take(self) -> bool: outcome = "expired" except QueueWithdrawn: # cancelled or purged while it waited: nothing to report outcome = "withdrawn" - attribution("queue", self.ledger, verdict=outcome, waited_s=round(self.runtime.now() - ticket.enqueued_at, 1)) + waited_s = round(self.runtime.now() - ticket.enqueued_at, 1) + attribution("queue", self.ledger, verdict=outcome, waited_s=waited_s, lane=lane) + if ticket.waited_s is not None: # it started: what the run's telemetry line reports, never who waited + self.ledger.queue_position, self.ledger.queue_wait_s = ticket.submitted_position, ticket.waited_s if outcome == "expired": self._fail("capacity") if outcome != "started": @@ -729,6 +756,17 @@ def require_user(x_anyplot_user: Annotated[str | None, Header()] = None) -> str: return x_anyplot_user +def caller_tier(x_anyplot_tier: Annotated[str | None, Header()] = None) -> Tier: + """The caller's tier as the BFF asserts it: `admin` only for exactly `X-Anyplot-Tier: admin`, else `public`. + + Trusted because only an allowed caller reaches a `/v1` route (`check_caller`); the browser never talks to this + service, and the BFF sets the header from the identity its admin gate verified, never from a request field. + A missing or unknown value is `public`, never an error, so a BFF deployed before the header keeps every user + in the public lane. + """ + return "admin" if x_anyplot_tier == "admin" else "public" + + def request_id(x_request_id: Annotated[str | None, Header()] = None) -> str: if x_request_id and re.fullmatch(r"^[A-Za-z0-9-]{1,64}$", x_request_id): return x_request_id @@ -736,6 +774,7 @@ def request_id(x_request_id: Annotated[str | None, Header()] = None) -> str: User = Annotated[str, Depends(require_user)] +CallerTier = Annotated[Tier, Depends(caller_tier)] RequestId = Annotated[str, Depends(request_id)] SessionId = Annotated[str, Path(pattern=SESSION_PATTERN)] RuntimeDep = Annotated[Runtime, Depends(get_runtime)] @@ -837,10 +876,12 @@ def _stamp(ledger: RequestLedger, view: SessionView | None, session: Session) -> @app.get("/v1/status", dependencies=v1_dependencies) -async def status(runtime: RuntimeDep) -> dict[str, Any]: - """The service's configuration, plus the run queue: `waiting` entries and runs `in_flight`. +async def status(runtime: RuntimeDep, tier: CallerTier) -> dict[str, Any]: + """The service's configuration, plus the run queue: `waiting` entries and runs `in_flight`, in every lane. - `user_max_runs` is `AGENT_USER_MAX_RUNS`, which the admin chat's fan-out over every library keeps to. + `user_max_runs` is the caller's own cap in their tier's lane (`AGENT_USER_MAX_RUNS`, or + `AGENT_ADMIN_USER_MAX_RUNS` for an admin), which the admin chat's fan-out over every library keeps to; `tier` is + the tier the request was served as. """ settings = get_settings() queue = runtime.run_queue() @@ -853,7 +894,8 @@ async def status(runtime: RuntimeDep) -> dict[str, Any]: "version": _version(), "waiting": queue.waiting_count, "in_flight": queue.in_flight, - "user_max_runs": settings.user_max_runs, + "user_max_runs": queue.user_cap(TIER_LANES[tier]), + "tier": tier, } @@ -978,7 +1020,7 @@ async def switch_library(sid: SessionId, body: SwitchLibraryBody, user: User, ru @app.post("/v1/sessions/{sid}/dataset", dependencies=v1_dependencies) async def upload_dataset( - sid: SessionId, body: DatasetBody, user: User, runtime: RuntimeDep, rid: RequestId + sid: SessionId, body: DatasetBody, user: User, tier: CallerTier, runtime: RuntimeDep, rid: RequestId ) -> JSONResponse: services = get_services() settings = get_settings() @@ -990,7 +1032,9 @@ async def upload_dataset( view = read_session(session.state) if view is None: raise AgentsError(404, "session_expired") - ledger = _stamp(RequestLedger(request_id=rid, user_id=user, session_id=sid, kind="action"), view, session) + ledger = _stamp( + RequestLedger(request_id=rid, user_id=user, session_id=sid, kind="action", tier=tier), view, session + ) size = len(body.text.encode("utf-8", errors="surrogatepass")) facts = telemetry.InputFacts.for_paste(sid, size=size, source=body.source is not None, confirmed=body.confirmed) clock = telemetry.Clock() @@ -1002,7 +1046,7 @@ async def upload_dataset( # 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) + _paste_budget(services, user, settings, tier) limit = services.usage.paste_limit(user, settings) if limit is not None: attribution("paste_limit", ledger, verdict=limit) @@ -1033,7 +1077,7 @@ async def upload_dataset( @app.post("/v1/sessions/{sid}/dataset/convert", dependencies=v1_dependencies) async def convert_dataset_file( - sid: SessionId, body: ConvertBody, user: User, runtime: RuntimeDep, rid: RequestId + sid: SessionId, body: ConvertBody, user: User, tier: CallerTier, runtime: RuntimeDep, rid: RequestId ) -> JSONResponse: """A dropped binary data file read in the renderer's sandbox into the text the paste box takes. @@ -1066,7 +1110,9 @@ async def convert_dataset_file( if not data: raise AgentsError(422, "invalid") ledger = _stamp( - RequestLedger(request_id=rid, user_id=user, session_id=sid, kind="action"), read_session(session.state), session + RequestLedger(request_id=rid, user_id=user, session_id=sid, kind="action", tier=tier), + read_session(session.state), + session, ) clock = telemetry.Clock() try: @@ -1091,7 +1137,7 @@ async def _convert( `data_input` line of a refusal raised here, this function the one of a file the converter refused.""" if len(data) > MAX_CONVERT_INPUT_BYTES: raise AgentsError(413, "too_long") - _paste_budget(services, user, settings) + _paste_budget(services, user, settings, ledger.tier) limit = services.usage.convert_limit(user, settings) if limit is not None: attribution("convert_limit", ledger, verdict=limit) @@ -1182,9 +1228,10 @@ async def _convert( return JSONResponse(status_code=422, content=content, headers=_NO_STORE) -def _paste_budget(services: Services, user: str, settings: AgentSettings) -> None: - """`429 locked_out` past the day's `attack` strikes, `429 daily_limit` past the daily token budgets.""" - if services.usage.daily_ok(user, settings): +def _paste_budget(services: Services, user: str, settings: AgentSettings, tier: Tier) -> None: + """`429 locked_out` past the day's `attack` strikes, `429 daily_limit` past the daily token budgets (the global + one only, for an admin).""" + if services.usage.daily_ok(user, settings, tier=tier): return raise AgentsError(429, LOCKED_OUT if services.usage.locked_out(user, settings) else "daily_limit") @@ -1737,7 +1784,7 @@ async def dataset_to_long(sid: SessionId, user: User, runtime: RuntimeDep) -> JS @app.post("/v1/sessions/{sid}/dataset/copy", dependencies=v1_dependencies) async def copy_dataset( - sid: SessionId, body: CopyDatasetBody, user: User, runtime: RuntimeDep, rid: RequestId + sid: SessionId, body: CopyDatasetBody, user: User, tier: CallerTier, runtime: RuntimeDep, rid: RequestId ) -> JSONResponse: """The admin fan-out's copy: the confirmed current data version of `from_session` becomes this session's data. @@ -1753,7 +1800,8 @@ async def copy_dataset( The copy is the stored version as it is (`DatasetStore.copy`): the same canonical CSV, dtypes, column order, notes, file name, source and code, nothing parsed or judged again, so it costs no model call and counts as no - paste; its own counter (`COPIES_PER_MINUTE`, `_PER_DAY`) bounds what one user puts in the store. The bindings, + paste; its own counter (`COPIES_PER_MINUTE`, `_PER_DAY`) bounds what one user puts in the store, except for an + admin, whose fan-outs in several windows copy more at once and whom only the store's byte cap bounds. The bindings, their confirmation, the session language and the plot language come along. The copy is the target's version 1 with no undo history, and `DATASET_COPIED_FROM` records the source's version number. The answer is the paste route's dataset shape plus `confirmed`, `complete`, `missing_roles` and `copied_from`. The `dataset_copy` attribution @@ -1784,8 +1832,10 @@ async def copy_dataset( check = check_bindings(origin.bindings, roles, stored.profile) if check.errors: raise AgentsError(422, "invalid") - ledger = _stamp(RequestLedger(request_id=rid, user_id=user, session_id=sid, kind="action"), view, session) - limit = services.usage.copy_limit(user) + ledger = _stamp( + RequestLedger(request_id=rid, user_id=user, session_id=sid, kind="action", tier=tier), view, session + ) + limit = services.usage.copy_limit(user, tier) # never a limit for an admin; the store's byte cap still binds if limit is not None: attribution("dataset_copy", ledger, outcome=limit) raise AgentsError(429, limit) @@ -2013,17 +2063,22 @@ async def _produce( def _error_code(exc: BaseException) -> str: - name = type(exc).__name__ - if "RateLimit" in name or "ResourceExhausted" in name or getattr(exc, "code", None) == 429: + if model_busy(exc): # a model quota or overload after the SDK's retries: busy, like a full queue return "capacity" - if name == "LlmCallsLimitExceededError": + if type(exc).__name__ == "LlmCallsLimitExceededError": return "capacity" return "internal" @app.post("/v1/sessions/{sid}/messages", dependencies=v1_dependencies) async def post_message( - sid: SessionId, body: MessageBody, user: User, runtime: RuntimeDep, rid: RequestId, theme: Theme | None = None + sid: SessionId, + body: MessageBody, + user: User, + tier: CallerTier, + runtime: RuntimeDep, + rid: RequestId, + theme: Theme | None = None, ) -> StreamingResponse: """One chat turn as SSE. `theme` is the site theme the page shows, the theme a plot run of the turn renders unless the user asked for one (`pipeline.run_theme`). A query parameter, not a body field: the strict bodies of @@ -2041,6 +2096,7 @@ async def post_message( user_id=user, session_id=sid, kind="action" if body.action else "text", + tier=tier, # The refusals sent before the judge (the budget, unsupported content) follow the # session language, never the browser's locale; after the judge, the message's. lang=refusal_lang(view.language) if view else DEFAULT_LANGUAGE, @@ -2064,6 +2120,7 @@ async def post_message( # - 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. + # An admin is exempt from all but the strikes and the global budget (`plugins/ledger.py`, the ledger's tier). refused = scope_refusals(session.events) if refused >= settings.session_refusals: return _refuse_now(translator, ledger, facts, SESSION_CLOSED, "session_closed", refusals=refused) @@ -2073,7 +2130,7 @@ async def post_message( if not budget_allows(ledger, services.usage, settings): # The refusal the root would send, sent now: a user over the daily budget spends nothing. return _refuse_now(translator, ledger, facts, "budget", "budget_halt", agent="route") - limit = services.usage.message_limit(user, settings) + limit = services.usage.message_limit(user, settings, tier) if limit is not None: return _refuse_now(translator, ledger, facts, limit, "message_limit") services.usage.add_message(user) diff --git a/agents/telemetry/views/02_event_views.sql b/agents/telemetry/views/02_event_views.sql index 7873189665a..d8364b6f1a4 100644 --- a/agents/telemetry/views/02_event_views.sql +++ b/agents/telemetry/views/02_event_views.sql @@ -5,7 +5,8 @@ -- unknown, not "none" or false. Only a field whose absence has one meaning gets a default here (`drift_restored`: -- no restore). The fields of 2026-10-30 (`plot_language`, `stages`, `round_scores`, `version`, `shipped_score`, -- `readiness`, `loader_notes`, `edit_failures`, `validator_rules`, `adaptation_rules`, the pipeline configuration and --- prompt hashes, and `thoughts` in `steps`) are NULL before that day. +-- prompt hashes, and `thoughts` in `steps`) are NULL before that day, and so are the fields of 2026-11-01 (`lane`, +-- `queue_wait_ms`, `queue_position`, `user_runs_today`, `user_tokens_today` in `pipeline_runs`). CREATE OR REPLACE VIEW `anyplot.agent_telemetry.data_inputs` AS SELECT @@ -112,6 +113,14 @@ SELECT LAX_INT64(payload.review_ms) AS review_ms, LAX_INT64(payload.total_ms) AS total_ms, payload.steps AS steps, + -- Whether anyone waits long or comes close to a limit, without saying who (2026-11-01): the tier, the run + -- queue's wait and position at submit (0: it started at once), and buckets of the user's runs and spent token + -- budget of the day. NULL for a `not_ready` run, which never enters the queue, and before that day. + JSON_VALUE(payload.lane) AS lane, + LAX_INT64(payload.queue_wait_ms) AS queue_wait_ms, + LAX_INT64(payload.queue_position) AS queue_position, + JSON_VALUE(payload.user_runs_today) AS user_runs_today, + JSON_VALUE(payload.user_tokens_today) AS user_tokens_today, JSON_VALUE(payload.status) IN ('ok', 'needs_attention') AS shipped FROM `anyplot.agent_telemetry.events` WHERE event = 'pipeline_run'; diff --git a/api/routers/agent.py b/api/routers/agent.py index befc5362d60..16335d284d2 100644 --- a/api/routers/agent.py +++ b/api/routers/agent.py @@ -2,7 +2,9 @@ The browser never talks to the agents service. This router sits behind the same gate as every other `/debug` route and in front of the private -anyplot-agents Cloud Run service: it derives the opaque user id, accepts only +anyplot-agents Cloud Run service: it derives the opaque user id, asserts the +caller's tier (`X-Anyplot-Tier: admin`, which gives the admin the agents service's +admin lane), accepts only allowlisted request fields, presents a Cloud Run ID token upstream, and re-frames the chat stream so only the documented `anyplot/1` events reach the browser. Design: `docs/concepts/agent-network.md` ("Serving and @@ -446,13 +448,24 @@ def derive_user_id(key: bytes, identity: AdminIdentity) -> str: return "adm_" + digest[:16] +AgentTier = Literal["admin", "public"] +"""The caller's tier the agents service keys its run-queue lane and its day caps on (`X-Anyplot-Tier`).""" + + @dataclass(frozen=True) class AgentContext: - """Per-request upstream context: where to call, as whom, under which id.""" + """Per-request upstream context: where to call, as whom, under which id, in which tier. + + `tier` is asserted here and only here, from the identity the admin gate verified: never + from a request field (the client's `source: fanout` only labels telemetry). Every route of + this router passes the admin gate, so every caller is `admin`; a public path, once there is + one, builds its context with `public`. + """ base_url: str user_id: str request_id: str + tier: AgentTier = "public" def agent_context( @@ -460,12 +473,15 @@ def agent_context( identity: AdminIdentity = Depends(require_admin_identity), config: AgentConfig = Depends(require_agent_enabled), ) -> AgentContext: - """Mint the request id (returned as `X-Request-Id`) and derive the user id.""" + """Mint the request id (returned as `X-Request-Id`), derive the user id, and assert the admin tier.""" request_id = str(uuid.uuid4()) response.headers["X-Request-Id"] = request_id response.headers["Cache-Control"] = _NO_STORE return AgentContext( - base_url=config.service_url, user_id=derive_user_id(config.user_id_key, identity), request_id=request_id + base_url=config.service_url, + user_id=derive_user_id(config.user_id_key, identity), + request_id=request_id, + tier="admin", ) @@ -719,7 +735,7 @@ def _fetch_id_token(audience: str) -> str: async def _upstream_headers(ctx: AgentContext) -> dict[str, str]: - headers = {"X-Anyplot-User": ctx.user_id, "X-Request-Id": ctx.request_id} + headers = {"X-Anyplot-User": ctx.user_id, "X-Anyplot-Tier": ctx.tier, "X-Request-Id": ctx.request_id} if not _is_local(ctx.base_url): token = await run_in_threadpool(_fetch_id_token, ctx.base_url) headers["Authorization"] = f"Bearer {token}" @@ -1114,7 +1130,8 @@ async def agent_status(ctx: Ctx, client: Client) -> dict[str, Any]: """The agents service's status plus `enabled`. Upstream fields: `libraries`, `model`, `location`, `version`, `provider`, the - run queue's `waiting` entries and `in_flight` runs, and `user_max_runs`. + run queue's `waiting` entries and `in_flight` runs, `user_max_runs` (the caller's own + cap in their tier's lane) and `tier`. """ upstream = await _call_upstream(client, ctx, "GET", "/status") return {**(upstream if isinstance(upstream, dict) else {}), "enabled": True} diff --git a/app/src/hooks/useAgentSession.ts b/app/src/hooks/useAgentSession.ts index 37e30fd3943..b027a90e8dd 100644 --- a/app/src/hooks/useAgentSession.ts +++ b/app/src/hooks/useAgentSession.ts @@ -1146,6 +1146,7 @@ export function useAgentSession({ siteThemeRef.current = siteTheme; const abortRef = useRef(null); const stoppedRef = useRef(false); + const turnErrorRef = useRef(null); const stopTimer = useRef | null>(null); // Set synchronously, before the state catches up, so a double click starts nothing twice. const themeRef = useRef(false); @@ -1754,11 +1755,15 @@ export function useAgentSession({ [safeDispatch, addItem, onPlot, trackEvent] ); + /** One turn; the code of the error that ended it (`run_limit`, `capacity`, a failed request) lands in + * `turnErrorRef`, null when it ended without one or never started (`plotTurn`). */ const runTurn = useCallback( async (body: TurnBody) => { const current = stateRef.current; const sid = current.sessionId; + turnErrorRef.current = null; if (!sid || current.run || busy()) return; + let errorCode: string | null = null; const controller = new AbortController(); abortRef.current = controller; stoppedRef.current = false; @@ -1782,6 +1787,7 @@ export function useAgentSession({ const event = parseAgentEvent(raw); if (!event) continue; onEvent(event); + if (event.type === 'error') errorCode = event.code; if (event.type === 'done') { finished = true; break; @@ -1790,17 +1796,20 @@ export function useAgentSession({ if (!finished && !stoppedRef.current) { // The BFF always ends a turn with `done`; a stream without it was cut. addItem({ kind: 'error', code: 'upstream', ref: null }); + errorCode = 'upstream'; } } catch (err) { if (!stoppedRef.current && !isAbort(err)) { const failure = handleFailure(err); addItem({ kind: 'error', code: failure.code, ref: failure.ref }); + errorCode = failure.code; } } finally { if (stopTimer.current) clearTimeout(stopTimer.current); stopTimer.current = null; if (stoppedRef.current) addItem({ kind: 'notice', text: 'stopped' }); if (abortRef.current === controller) abortRef.current = null; + turnErrorRef.current = errorCode; safeDispatch({ type: 'run_end' }); } }, @@ -1809,6 +1818,13 @@ export function useAgentSession({ const createPlot = useCallback(() => runTurn({ action: 'create_plot' }), [runTurn]); + /** `createPlot`, resolving to the code of the error that ended the turn, or null. The fan-out tells a turn the + * user's cap refused (`run_limit`) from a turn that ran (`FanOut`). */ + const plotTurn = useCallback(async (): Promise => { + await runTurn({ action: 'create_plot' }); + return turnErrorRef.current; + }, [runTurn]); + const sendMessage = useCallback( (text: string) => { const trimmed = text.trim(); @@ -2164,6 +2180,7 @@ export function useAgentSession({ confirmData, undoData, createPlot, + plotTurn, sendMessage, stop, switchLibrary, diff --git a/app/src/lib/agent.ts b/app/src/lib/agent.ts index 39a34e4d78c..65563d77e9f 100644 --- a/app/src/lib/agent.ts +++ b/app/src/lib/agent.ts @@ -109,8 +109,13 @@ export interface AgentStatus { provider?: string; waiting?: number; in_flight?: number; - /** `AGENT_USER_MAX_RUNS`: one user's sandbox runs queued or running at once; absent from an older server. */ + /** + * The caller's own cap of sandbox runs queued or running at once: `AGENT_USER_MAX_RUNS`, or + * `AGENT_ADMIN_USER_MAX_RUNS` for an admin; absent from an older server. + */ user_max_runs?: number; + /** The tier the agents service served the request as (`X-Anyplot-Tier`); absent from an older server. */ + tier?: 'admin' | 'public'; enabled: boolean; } @@ -1203,7 +1208,7 @@ 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. + // The user already has their cap of sandbox runs (`user_max_runs`) queued or running in other windows. 'run_limit', 'deadline', 'guard_unavailable', diff --git a/app/src/pages/AgentChatPage.tsx b/app/src/pages/AgentChatPage.tsx index daf4a46c758..ed10cc5e714 100644 --- a/app/src/pages/AgentChatPage.tsx +++ b/app/src/pages/AgentChatPage.tsx @@ -305,7 +305,8 @@ function AgentChat({ const [draft, setDraft] = useState(''); const [title, setTitle] = useState(null); const [libraries, setLibraries] = useState([...DEFAULT_AGENT_LIBRARIES]); - // `.plot_all()`: the server's run limit, and the session whose confirmed data the open fan-out copies. + // `.plot_all()`: the caller's own run cap (`user_max_runs`, 5 for an admin), and the session whose + // confirmed data the open fan-out copies. const [maxRuns, setMaxRuns] = useState(DEFAULT_USER_MAX_RUNS); // Both answers below are in (or failed): before that, the libraries and the limit are fallbacks. const [listsSettled, setListsSettled] = useState(false); diff --git a/app/src/sections/agent-chat/ChatThread.test.tsx b/app/src/sections/agent-chat/ChatThread.test.tsx index 55547281786..c31c24dc677 100644 --- a/app/src/sections/agent-chat/ChatThread.test.tsx +++ b/app/src/sections/agent-chat/ChatThread.test.tsx @@ -100,7 +100,7 @@ describe('ChatThread', () => { expect(screen.getByText('# stopped')).toBeInTheDocument(); }); - it("says in plain words that the user's other windows hold their three runs", () => { + it("says in plain words that the user's other windows hold their runs, without a number of its own", () => { renderThread({ ...initialAgentState('matplotlib'), phase: 'ready', @@ -109,11 +109,38 @@ describe('ChatThread', () => { }); const alert = screen.getByRole('alert'); expect(alert).toHaveTextContent('error · run_limit · ref r-lim'); + // The cap is the caller's own (3, or 5 for an admin), so the sentence names none. expect(alert).toHaveTextContent( - 'you already have 3 plots or data runs in progress in other windows; wait until one finishes, then try again' + 'your plots and data runs in other windows already fill your share; wait until one finishes, then try again' ); }); + it('reads a run the model service refused for its quota as busy, not as a fault', () => { + renderThread({ + ...initialAgentState('matplotlib'), + phase: 'ready', + sessionId: 'S1', + items: [ + { + id: 1, + kind: 'plot_failed', + result: { + status: 'failed', + reason: 'busy', + attempts: 1, + artifacts: [], + changes: [], + residual_defects: [], + version: null, + }, + }, + ], + }); + expect( + screen.getByText(/the model service is busy; try again in a minute/) + ).toBeInTheDocument(); + }); + it('names this window, not the others, for a run_active turn', () => { renderThread({ ...initialAgentState('matplotlib'), diff --git a/app/src/sections/agent-chat/FanOut.test.tsx b/app/src/sections/agent-chat/FanOut.test.tsx index 9138a9ad42e..2726b50496d 100644 --- a/app/src/sections/agent-chat/FanOut.test.tsx +++ b/app/src/sections/agent-chat/FanOut.test.tsx @@ -84,13 +84,26 @@ const PAGE_SESSION = 'S-page'; let calls: Call[]; let turns: Map>; +/** A turn the user's cap refused: the server's `run_limit`, then `done`. */ +const runLimitTurn = () => + new Response( + wire('error', { code: 'run_limit', ref: 'r-lim' }) + wire('done', { llm_calls: 1 }), + { status: 200, headers: { 'Content-Type': 'text/event-stream' } } + ); + /** * The BFF: a session per library (`S-`), `ineligible` libraries refused at the open. * `copy` answers the copy route: the server's copy, a server without it (`404 not_found`), or a refusal. + * The first plot turn of each library in `limitedOnce` gets `run_limit`, as when other windows hold the places. */ -function mockBff(ineligible: string[] = [], copy: () => Response = () => json(200, COPIED)) { +function mockBff( + ineligible: string[] = [], + copy: () => Response = () => json(200, COPIED), + limitedOnce: string[] = [] +) { calls = []; turns = new Map(); + const limited = new Set(); vi.stubGlobal( 'fetch', vi.fn(async (input: string, init: RequestInit = {}) => { @@ -117,6 +130,10 @@ function mockBff(ineligible: string[] = [], copy: () => Response = () => json(20 if (path.endsWith('/dataset/confirm')) return json(200, { confirmed: true, rows: 1, columns: 2 }); if (path.endsWith('/messages')) { + if (limitedOnce.includes(sid.replace(/^S-/, '')) && !limited.has(sid)) { + limited.add(sid); + return runLimitTurn(); + } const turn = liveTurn(); turns.set(sid, turn); return turn.response; @@ -221,6 +238,29 @@ describe('FanOut', () => { expect(within(card('altair')).getByTestId('fanout-status')).toHaveTextContent('adapting…'); }); + it('sends a library the cap refused back to waiting and tries it again at the next freed place', async () => { + mockBff([], () => json(200, COPIED), ['seaborn']); + renderFanOut(['matplotlib', 'seaborn', 'altair'], 2); + + // seaborn's turn hit the cap (the admin's other windows hold the places): no failure, it waits, + // and the panel starts nothing more while only matplotlib holds a place the server gave it. + await waitFor(() => expect(status('seaborn')).toHaveTextContent('waiting · run_limit ×1')); + expect(plotTurns().map(call => call.path)).toEqual([ + '/sessions/S-matplotlib/messages', + '/sessions/S-seaborn/messages', + ]); + expect(status('altair')).toHaveTextContent('waiting for a free place…'); + expect(screen.getByText(/0 of 3 done/)).toBeInTheDocument(); + + // matplotlib's plot frees a place: seaborn, first in the page's order, tries again and runs. + turns.get('S-matplotlib')!.finish(); + await waitFor(() => expect(plotTurns()).toHaveLength(3)); + expect(plotTurns()[2].path).toBe('/sessions/S-seaborn/messages'); + await waitFor(() => expect(status('seaborn')).toHaveTextContent('adapting…')); + turns.get('S-seaborn')!.finish(); + await waitFor(() => expect(status('seaborn')).toHaveTextContent('v1 · ok')); + }); + it("plots every library in the page's site theme", async () => { mockBff(); renderFanOut(['matplotlib', 'seaborn'], 2, { site: 'dark' }); diff --git a/app/src/sections/agent-chat/FanOut.tsx b/app/src/sections/agent-chat/FanOut.tsx index 50b4ee7596c..ea53836dfeb 100644 --- a/app/src/sections/agent-chat/FanOut.tsx +++ b/app/src/sections/agent-chat/FanOut.tsx @@ -8,10 +8,15 @@ * version into it with its bindings and their confirmation (`copyData`, without * the summary turn of a paste), whatever the data's origin: pasted, opened, * computed or changed by the assistant. It then starts `create_plot` in at most - * `maxRuns` sessions at a time (`grantSlots`): the server's - * `AGENT_USER_MAX_RUNS`, which refuses one run more with `run_limit`. Each run - * goes through the agents service's run queue like any other, and each session - * is deleted when the panel closes or the page goes, like the page's own. + * `maxRuns` sessions at a time (`grantSlots`): the caller's own cap, which + * `/status` reports as `user_max_runs` (`AGENT_ADMIN_USER_MAX_RUNS`, 5, for the + * admin since 2026-11-01) and which refuses one run more with `run_limit`. That + * cap counts the admin's runs in every window, so a `run_limit` sends the library + * back to waiting and holds the panel back until one of its runs ends or 15 s + * pass (`nextFanOut`); three panels started at once thus share the five places + * and finish every library. Each run goes through the agents service's run queue, + * and each session is deleted when the panel closes or the page goes, like the + * page's own. * * A server without the copy (the page deployed before the API or the agents * service) answers `404 not_found`: a library then posts the page's upload again @@ -30,8 +35,13 @@ import { useAgentSession, } from 'src/hooks/useAgentSession'; import { + awaitsRetry, + type FanOutEvent, type FanOutSlot, - grantSlots, + initialFanOut, + nextFanOut, + RUN_LIMIT_RETRY_MS, + type RunLimitInfo, runStatus, type Tone, } from 'src/sections/agent-chat/fanoutRules'; @@ -61,8 +71,10 @@ interface FanOutRunProps { upload?: DatasetUpload; bindings: BindingMap; slot: FanOutSlot; + limit: RunLimitInfo | null; onReady: (library: string) => void; onDone: (library: string) => void; + onLimited: (library: string) => void; } function FanOutRun({ @@ -74,10 +86,12 @@ function FanOutRun({ upload, bindings, slot, + limit, onReady, onDone, + onLimited, }: FanOutRunProps) { - const { state, copyData, replayData, createPlot, stop } = useAgentSession({ + const { state, copyData, replayData, plotTurn, stop } = useAgentSession({ specId, library, locale, @@ -86,7 +100,7 @@ function FanOutRun({ }); const [dataFailure, setDataFailure] = useState(null); const stored = useRef(false); - const started = useRef(false); + const inFlight = useRef(false); // The session opened: the server copies the page session's confirmed data into it. A session // that could not open (not eligible, an error) gives its place up at once. @@ -125,14 +139,25 @@ function FanOutRun({ onDone, ]); - // Given a place: one `create_plot`, whose end frees the place, whatever it brought. + // Given a place: one `create_plot`, whose end frees the place. A `run_limit` (the admin's runs in other + // windows hold every place) sends the library back to waiting for another place; anything else ends it. useEffect(() => { - if (slot !== 'running' || started.current) return; - started.current = true; - void createPlot().finally(() => onDone(library)); - }, [slot, createPlot, library, onDone]); + if (slot !== 'running' || inFlight.current) return; + inFlight.current = true; + void plotTurn() + .then(code => { + inFlight.current = false; + if (code === 'run_limit') onLimited(library); + else onDone(library); + }) + .catch(() => { + // The turn handles its own errors; this only keeps an unexpected rejection from holding the place. + inFlight.current = false; + onDone(library); + }); + }, [slot, plotTurn, library, onDone, onLimited]); - const status = runStatus(state, slot, dataFailure); + const status = runStatus(state, slot, dataFailure, limit); const versions = Object.values(state.versions).filter(version => version.library === library); const latest = versions[versions.length - 1]; const image = latest?.images[latest.theme]; @@ -217,7 +242,7 @@ export interface FanOutProps { */ upload?: DatasetUpload; bindings: BindingMap; - /** How many plots run at once (`AGENT_USER_MAX_RUNS`). */ + /** How many plots run at once: the caller's own cap (`user_max_runs` of `/status`). */ maxRuns: number; onClose: () => void; /** The panel, which takes the focus when a key press on `.plot_all()` opened it. */ @@ -236,20 +261,25 @@ export function FanOut({ maxRuns, onClose, }: FanOutProps) { - const [slots, setSlots] = useState>(() => - Object.fromEntries(libraries.map(library => [library, 'data' as FanOutSlot])) - ); - const move = useCallback( - (library: string, slot: 'waiting' | 'done') => - setSlots(current => - current[library] === 'done' - ? current - : grantSlots({ ...current, [library]: slot }, libraries, maxRuns) - ), + const [fanOut, setFanOut] = useState(() => initialFanOut(libraries)); + const send = useCallback( + (event: FanOutEvent) => setFanOut(current => nextFanOut(current, event, libraries, maxRuns)), [libraries, maxRuns] ); - const onReady = useCallback((library: string) => move(library, 'waiting'), [move]); - const onDone = useCallback((library: string) => move(library, 'done'), [move]); + const onReady = useCallback((library: string) => send({ type: 'ready', library }), [send]); + const onDone = useCallback((library: string) => send({ type: 'ended', library }), [send]); + const onLimited = useCallback( + (library: string) => send({ type: 'run_limit', library, now: Date.now() }), + [send] + ); + // Held back by `run_limit`: try one library more every 15 s, until the server takes it again. + const retrying = awaitsRetry(fanOut); + useEffect(() => { + if (!retrying) return; + const timer = setInterval(() => send({ type: 'retry' }), RUN_LIMIT_RETRY_MS); + return () => clearInterval(timer); + }, [retrying, send]); + const { slots } = fanOut; const done = libraries.filter(library => slots[library] === 'done').length; return ( @@ -308,8 +338,17 @@ export function FanOut({ upload={upload} bindings={bindings} slot={slots[library]} + limit={ + fanOut.limited[library] + ? { + count: fanOut.limited[library].count, + gaveUp: fanOut.gaveUp.includes(library), + } + : null + } onReady={onReady} onDone={onDone} + onLimited={onLimited} /> ))} diff --git a/app/src/sections/agent-chat/fanoutRules.test.ts b/app/src/sections/agent-chat/fanoutRules.test.ts index e321fd95f7f..acd7145d124 100644 --- a/app/src/sections/agent-chat/fanoutRules.test.ts +++ b/app/src/sections/agent-chat/fanoutRules.test.ts @@ -2,7 +2,17 @@ import { describe, expect, it } from 'vitest'; import { type AgentSessionState, initialAgentState } from 'src/hooks/useAgentSession'; import type { PlotResult } from 'src/lib/agent'; -import { type FanOutSlot, grantSlots, runStatus } from 'src/sections/agent-chat/fanoutRules'; +import { + awaitsRetry, + type FanOutSlot, + type FanOutState, + grantSlots, + initialFanOut, + nextFanOut, + RUN_LIMIT_GIVE_UP_MS, + RUN_LIMIT_RETRY_MS, + runStatus, +} from 'src/sections/agent-chat/fanoutRules'; const ORDER = ['matplotlib', 'seaborn', 'plotnine', 'altair']; @@ -72,6 +82,8 @@ describe('runStatus', () => { ); expect(failed.text).toBe('error · capacity'); expect(failed.detail).toMatch(/queue/); + // A root call the model service refused after its retries ends as `capacity` too: the text says so. + expect(failed.detail).toMatch(/model service is overloaded/); expect(runStatus(ready(), 'data', { code: 'rate_limited', ref: null }).text).toBe( 'data · rate_limited' ); @@ -129,6 +141,15 @@ describe('runStatus', () => { runStatus(ready({ items: [{ id: 1, kind: 'plot_failed', result: failed }] }), 'done', null) .text ).toBe('failed · render_error'); + // A model quota that outlasted the retries reads as busy, with the chat's sentence. + const busy = result({ status: 'failed', reason: 'busy' }); + expect( + runStatus(ready({ items: [{ id: 1, kind: 'plot_failed', result: busy }] }), 'done', null) + ).toEqual({ + text: 'failed · busy', + tone: 'error', + detail: 'the model service is busy; try again in a minute', + }); expect( runStatus( ready({ items: [{ id: 1, kind: 'error', code: 'run_limit', ref: null }] }), @@ -148,4 +169,165 @@ describe('runStatus', () => { ).toBe('stopped'); expect(runStatus(ready(), 'done', null)).toEqual({ text: 'no plot', tone: 'error' }); }); + + it('says a library the cap refused waits, and says when it gave up', () => { + const limited = ready({ items: [{ id: 1, kind: 'error', code: 'run_limit', ref: null }] }); + + expect(runStatus(limited, 'waiting', null, { count: 2, gaveUp: false })).toMatchObject({ + text: 'waiting · run_limit ×2', + tone: 'muted', + }); + expect(runStatus(limited, 'done', null, { count: 40, gaveUp: true })).toMatchObject({ + text: 'gave up · run_limit', + tone: 'error', + detail: expect.stringContaining('30 minutes'), + }); + }); +}); + +/** A panel of `libraries` whose data is stored: every library waits, `max` of them start. */ +function storedPanel(libraries: readonly string[], max: number): FanOutState { + return libraries.reduce( + (state, library) => nextFanOut(state, { type: 'ready', library }, libraries, max), + initialFanOut(libraries) + ); +} + +describe('nextFanOut', () => { + it('sends a library the cap refused back to waiting and starts nothing past the runs the server took', () => { + let panel = storedPanel(ORDER, 2); + expect(panel.slots).toMatchObject({ matplotlib: 'running', seaborn: 'running' }); + + panel = nextFanOut(panel, { type: 'run_limit', library: 'seaborn', now: 0 }, ORDER, 2); + + expect(panel.slots).toEqual({ + matplotlib: 'running', + seaborn: 'waiting', + plotnine: 'waiting', + altair: 'waiting', + }); + expect(panel.cap).toBe(1); + expect(panel.limited.seaborn).toEqual({ since: 0, count: 1 }); + expect(awaitsRetry(panel)).toBe(true); + }); + + it('gives a freed place to the first waiting library, and a retry one library more', () => { + let panel = storedPanel(ORDER, 2); + panel = nextFanOut(panel, { type: 'run_limit', library: 'seaborn', now: 0 }, ORDER, 2); + + panel = nextFanOut(panel, { type: 'ended', library: 'matplotlib' }, ORDER, 2); + expect(panel.slots).toMatchObject({ seaborn: 'running', plotnine: 'waiting' }); + + panel = nextFanOut(panel, { type: 'retry' }, ORDER, 2); + expect(panel.slots).toMatchObject({ seaborn: 'running', plotnine: 'running' }); + expect(panel.cap).toBeNull(); // back at the server's cap + }); + + it('gives up on a library only after half an hour of run_limit', () => { + let panel = storedPanel(ORDER, 1); + panel = nextFanOut(panel, { type: 'run_limit', library: 'matplotlib', now: 0 }, ORDER, 1); + panel = nextFanOut(panel, { type: 'retry' }, ORDER, 1); + panel = nextFanOut( + panel, + { type: 'run_limit', library: 'matplotlib', now: RUN_LIMIT_GIVE_UP_MS - 1 }, + ORDER, + 1 + ); + expect(panel.slots.matplotlib).toBe('waiting'); + + panel = nextFanOut(panel, { type: 'retry' }, ORDER, 1); + panel = nextFanOut( + panel, + { type: 'run_limit', library: 'matplotlib', now: RUN_LIMIT_GIVE_UP_MS }, + ORDER, + 1 + ); + expect(panel.slots.matplotlib).toBe('done'); + expect(panel.gaveUp).toEqual(['matplotlib']); + expect(panel.limited.matplotlib.count).toBe(3); + }); + + it('ignores the events of a library that is done', () => { + let panel = storedPanel(ORDER, 2); + panel = nextFanOut(panel, { type: 'ended', library: 'matplotlib' }, ORDER, 2); + + expect(nextFanOut(panel, { type: 'run_limit', library: 'matplotlib', now: 0 }, ORDER, 2)).toBe( + panel + ); + }); +}); + +describe('three panels sharing one cap', () => { + /** + * The owner's question of 2026-11-01: an admin starts `.plot_all()` in three windows at once. The server + * takes at most `serverCap` of the admin's runs in all; a turn it refuses ends after `refuseS` with + * `run_limit`, a turn it takes ends after `runS`. Every panel retries every 15 s while held back. One second + * per step until all libraries ended. + */ + function simulate(panels: number, libraries: number, serverCap: number, runS = 90, refuseS = 3) { + const order = Array.from({ length: libraries }, (_, index) => `lib${index}`); + const states = Array.from({ length: panels }, () => storedPanel(order, serverCap)); + const turns = new Map< + string, + { panel: number; library: string; endsAt: number; taken: boolean } + >(); + let inFlight = 0; + let refused = 0; + let lastRetry = 0; + let now = 0; + const startTurns = () => { + states.forEach((state, panel) => { + for (const library of order) { + const key = `${panel}/${library}`; + if (state.slots[library] !== 'running' || turns.has(key)) continue; + const taken = inFlight < serverCap; + if (taken) inFlight += 1; + turns.set(key, { panel, library, endsAt: now + (taken ? runS : refuseS), taken }); + } + }); + }; + startTurns(); + while (states.some(state => Object.values(state.slots).some(slot => slot !== 'done'))) { + now += 1; + for (const [key, turn] of [...turns]) { + if (turn.endsAt > now) continue; + turns.delete(key); + if (turn.taken) inFlight -= 1; + else refused += 1; + const event = turn.taken + ? ({ type: 'ended', library: turn.library } as const) + : ({ type: 'run_limit', library: turn.library, now: now * 1000 } as const); + states[turn.panel] = nextFanOut(states[turn.panel], event, order, serverCap); + } + if ((now - lastRetry) * 1000 >= RUN_LIMIT_RETRY_MS) { + lastRetry = now; + states.forEach((state, panel) => { + if (awaitsRetry(state)) + states[panel] = nextFanOut(state, { type: 'retry' }, order, serverCap); + }); + } + startTurns(); + if (now > 3 * 3600) throw new Error('the panels never finished'); + } + return { minutes: now / 60, refused, gaveUp: states.flatMap(state => state.gaveUp) }; + } + + it('finishes all 45 plots of three 15-library panels without one lost to run_limit', () => { + const { minutes, refused, gaveUp } = simulate(3, 15, 5); + + expect(gaveUp).toEqual([]); + // 45 runs of 90 s through five places take 13.5 minutes at best; the retries keep it close. + expect(minutes).toBeGreaterThanOrEqual(13.5); + expect(minutes).toBeLessThan(18); + // The panels back off instead of burning their libraries: at most a few refused turns a minute. + expect(refused / minutes).toBeLessThan(15); + }); + + it('runs one panel at the full cap without a single refusal', () => { + const { refused, gaveUp, minutes } = simulate(1, 15, 5); + + expect(gaveUp).toEqual([]); + expect(refused).toBe(0); + expect(minutes).toBeCloseTo(4.5, 0); + }); }); diff --git a/app/src/sections/agent-chat/fanoutRules.ts b/app/src/sections/agent-chat/fanoutRules.ts index 7d7bc9fbd7b..412c39afcef 100644 --- a/app/src/sections/agent-chat/fanoutRules.ts +++ b/app/src/sections/agent-chat/fanoutRules.ts @@ -1,17 +1,94 @@ /** * The admin fan-out's rules (`FanOut.tsx`): which library may start its plot, + * how the panel backs off when the server's per-user cap answers `run_limit`, * and the one status line each library's card shows. */ import type { AgentSessionState, Failure } from 'src/hooks/useAgentSession'; -import { ERROR_TEXT } from 'src/sections/agent-chat/messages'; +import { ERROR_TEXT, PLOT_FAILED_TEXT } from 'src/sections/agent-chat/messages'; -/** `AGENT_USER_MAX_RUNS`'s default, for a server whose `/status` does not name it. */ +/** + * The public cap (`AGENT_USER_MAX_RUNS`), for a server whose `/status` does not name the caller's own: an + * older server has no admin lane, so the public cap is what it holds an admin to. + */ export const DEFAULT_USER_MAX_RUNS = 3; /** Where a library is: storing its data, waiting for a free place, running its plot, or finished. */ export type FanOutSlot = 'data' | 'waiting' | 'running' | 'done'; +/** How long a panel held back by `run_limit` waits before it tries one library again. */ +export const RUN_LIMIT_RETRY_MS = 15_000; +/** How long a library may keep getting `run_limit` before it gives up and its card says so. */ +export const RUN_LIMIT_GIVE_UP_MS = 30 * 60_000; + +/** + * One panel's places. The server's cap (`user_max_runs`) counts the admin's runs in every window, while + * `grantSlots` counts only this panel's: with three panels open, the server takes five runs in all and + * answers the rest with `run_limit` (owner question of 2026-11-01). A `run_limit` is therefore no failure: + * the library goes back to waiting and `cap` shrinks to the runs the server took from this panel, so the + * panel starts nothing more until one of its own runs ends (a freed place) or `RUN_LIMIT_RETRY_MS` passes + * (`retry`, one library more). A library gives up only after `RUN_LIMIT_GIVE_UP_MS` of `run_limit`. + */ +export interface FanOutState { + slots: Record; + /** How many runs the panel starts at once after a `run_limit`; null is the server's `maxRuns`. */ + cap: number | null; + /** Per library: when its first `run_limit` came (ms) and how many it got. */ + limited: Record; + /** The libraries that gave up after `RUN_LIMIT_GIVE_UP_MS` of `run_limit`. */ + gaveUp: string[]; +} + +export type FanOutEvent = + | { type: 'ready'; library: string } + | { type: 'ended'; library: string } + | { type: 'run_limit'; library: string; now: number } + | { type: 'retry' }; + +export function initialFanOut(libraries: readonly string[]): FanOutState { + return { + slots: Object.fromEntries(libraries.map(library => [library, 'data' as FanOutSlot])), + cap: null, + limited: {}, + gaveUp: [], + }; +} + +/** The panel after `event`, its free places granted in the page's order (`grantSlots`). */ +export function nextFanOut( + state: FanOutState, + event: FanOutEvent, + order: readonly string[], + maxRuns: number +): FanOutState { + if (event.type !== 'retry' && state.slots[event.library] === 'done') return state; + const running = order.filter(library => state.slots[library] === 'running').length; + let { slots, cap, limited, gaveUp } = state; + if (event.type === 'ready') { + slots = { ...slots, [event.library]: 'waiting' }; + } else if (event.type === 'ended') { + slots = { ...slots, [event.library]: 'done' }; + } else if (event.type === 'run_limit') { + const before = limited[event.library]; + const entry = { since: before?.since ?? event.now, count: (before?.count ?? 0) + 1 }; + limited = { ...limited, [event.library]: entry }; + const giveUp = event.now - entry.since >= RUN_LIMIT_GIVE_UP_MS; + slots = { ...slots, [event.library]: giveUp ? 'done' : 'waiting' }; + if (giveUp) gaveUp = [...gaveUp, event.library]; + // The server took the runs still running here and no more: start nothing past them for now. + cap = Math.min(maxRuns, running - 1); + } else if (cap !== null) { + cap = Math.min(maxRuns, running + 1); // one library more, the server may have room again + } + if (cap !== null && cap >= maxRuns) cap = null; + return { slots: grantSlots(slots, order, cap ?? maxRuns), cap, limited, gaveUp }; +} + +/** Whether the panel waits for a retry: it is held back by `run_limit` and a library waits. */ +export function awaitsRetry(state: FanOutState): boolean { + return state.cap !== null && Object.values(state.slots).includes('waiting'); +} + /** Start the waiting libraries, in the page's order, while fewer than `max` run. */ export function grantSlots( slots: Record, @@ -44,11 +121,18 @@ export interface RunStatus { detail?: string; } +/** A library's `run_limit` answers so far (`FanOutState.limited`), and whether it gave up. */ +export interface RunLimitInfo { + count: number; + gaveUp: boolean; +} + /** One line for a library's card, from its session and its place in the fan-out. */ export function runStatus( state: AgentSessionState, slot: FanOutSlot, - dataFailure: Failure | null + dataFailure: Failure | null, + limit: RunLimitInfo | null = null ): RunStatus { if (state.phase === 'opening') return { text: 'opening a session…', tone: 'muted' }; if (state.phase === 'ineligible') return { text: 'not eligible', tone: 'error' }; @@ -83,9 +167,28 @@ export function runStatus( tone: status === 'ok' ? 'ok' : 'warning', }; } + // Your runs in other windows held every place: no failure, the library waits and tries again. + if (limit?.gaveUp) { + return { + text: 'gave up · run_limit', + tone: 'error', + detail: `your runs in other windows held every place for ${RUN_LIMIT_GIVE_UP_MS / 60_000} minutes; close the panel and .plot_all() again`, + }; + } + if (limit && slot === 'waiting') { + return { + text: `waiting · run_limit ×${limit.count}`, + tone: 'muted', + detail: 'your runs in other windows hold every place; this one starts when a place frees', + }; + } for (const item of [...state.items].reverse()) { if (item.kind === 'plot_failed') { - return { text: `failed · ${item.result.reason ?? item.result.status}`, tone: 'error' }; + return { + text: `failed · ${item.result.reason ?? item.result.status}`, + tone: 'error', + detail: PLOT_FAILED_TEXT[item.result.reason ?? ''], + }; } if (item.kind === 'error') { return { text: `error · ${item.code}`, tone: 'error', detail: ERROR_TEXT[item.code] }; diff --git a/app/src/sections/agent-chat/messages.ts b/app/src/sections/agent-chat/messages.ts index 3e088e68ab7..05d53153a2f 100644 --- a/app/src/sections/agent-chat/messages.ts +++ b/app/src/sections/agent-chat/messages.ts @@ -8,10 +8,14 @@ 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. + // The agents service's `capacity`: a full run queue, a wait that ran out, or a model call the model service still + // refused for its quota or an overload after the retries (`model_busy`, the root's call before any plot run). + capacity: + 'the server is busy: the run queue is full, the wait ran out, or the model service is overloaded; try again in a few minutes', + // The user's own cap (`user_max_runs` of `/status`: 3, or 5 for an admin): 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', + 'your plots and data runs in other windows already fill your share; 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', @@ -41,6 +45,8 @@ export const PLOT_FAILED_TEXT: Record = { render: 'the plot did not render', deadline: 'the run ran out of time before a render passed', budget: 'the usage limit is reached', + // The model service refused a call for its quota or an overload, also after the retries. + busy: 'the model service is busy; try again in a minute', error: 'something went wrong while adapting', no_dataset: 'paste and parse your data first', incomplete_bindings: 'pick a column for every required role first', diff --git a/changelog.d/agent-admin-lane.md b/changelog.d/agent-admin-lane.md new file mode 100644 index 00000000000..183503a2b23 --- /dev/null +++ b/changelog.d/agent-admin-lane.md @@ -0,0 +1,32 @@ +### Added + +- **An admin lane in the agents service's run queue.** The admin's runs, + `.plot_all()` above all, may hold five places per admin (public users two), + with no per-user day caps or message minute (owner decisions of + 2026-11-01). The BFF asserts the tier with `X-Anyplot-Tier: admin` from the + identity its admin gate verified; a missing or other value is the public + tier, so the two services deploy in either order. `GET /status` reports the + caller's own cap, and the fan-out panel follows it (#12507). +- **Anonymous limit telemetry on `pipeline_run`.** `lane`, `queue_wait_ms`, + `queue_position`, `user_runs_today` and `user_tokens_today` show whether + anyone waits long or comes close to a limit, as closed sets and bounded + numbers with no user id and no hash; the views SQL reads them, and applying + it in BigQuery waits for the owner after the merge (#12507). + +### Changed + +- **The run queue runs five plots at once and starts one every 10 seconds.** + `AGENT_RUN_CONCURRENCY` 5 for the whole instance and `AGENT_RUNS_PER_MINUTE` + 6 with the starts spaced 10 s apart (1 and 1 before), so the queue holds 60 + and a 15-library fan-out no longer takes about 20 minutes. A public user's + day is 50 runs and 10,000,000 weighted tokens (about $1), and the global + day budget is 100,000,000 (about $10), admins included. Claude calls retry + four times instead of two; a 429 or 529 that outlasts them ends the run as + `busy`. The render gates run in a worker thread (#12507). +- **The fan-out panel waits out `run_limit` instead of failing.** With + `.plot_all()` open in several windows the admin's cap counts them all: a + library the cap refused goes back to waiting, and the panel tries again + when one of its runs ends or after 15 s, giving up only after 30 minutes. + The fan-out's data copies no longer count against an admin's copy limits + (30 a minute, 150 a day), so three windows can copy their 45 datasets at + once (#12507). diff --git a/docs/concepts/agent-network.md b/docs/concepts/agent-network.md index ef4b14ec3d1..42185009a71 100644 --- a/docs/concepts/agent-network.md +++ b/docs/concepts/agent-network.md @@ -41,13 +41,14 @@ This document describes the agent network that lets a visitor paste their own da ``` Browser ──fetchWithAuth──► anyplot-api /debug/agent/* (require_admin → AdminIdentity, CSRF header, AGENT_ENABLED kill switch) - │ ID token (roles/run.invoker) + X-Anyplot-User + X-Request-Id + │ ID token (roles/run.invoker) + X-Anyplot-User + X-Anyplot-Tier + X-Request-Id ▼ anyplot-agents (Cloud Run gen2, --no-allow-unauthenticated, own service account) FastAPI /v1/* → message limits, closed session → Runner (at once, no queue) → Runner(app=App(plugins=[ScopeGuard, Budget, ToolSafety, ContextFilter(6)])) root "anyplot" ── session tools ── plot_pipeline (Workflow NodeTool, input_schema=PipelineArgs) - run_data_code, plot_pipeline → run queue (1 in flight, 1 start a minute, 600 s maximum wait) + run_data_code, plot_pipeline → run queue (5 in flight, 1 start every 10 s, + 600 s maximum wait; 2 per user, 5 per admin) @node run_pipeline: adapter → apply + validate → render, one theme → gates → reviewer (≤1) → repair (≤1) → Event(output=PlotResult) POST /v1/.../versions/{v}/render → render the other theme → gates (no model call, no queue) @@ -73,7 +74,7 @@ The root's "never" list, enforced by the stream translator and the evals: never Rules for every agent: - Models come only from `make_model()` in `agents/anyplot/models.py`, which builds the model of the configured provider. On `anthropic-vertex` (the default) it returns `VertexClaude`, ADK's `Claude` with an `AsyncAnthropicVertex` client built from the settings, adaptive thinking on every kind (`AGENT_ROOT_THINKING_BUDGET`, 1,024, `AGENT_ADAPTER_THINKING_BUDGET`, 8,192, and `AGENT_REVIEWER_THINKING_BUDGET`, 4,096, each at most 8,192 and added to that agent's output cap; 0 turns it off, and the eval harness overrides the root's and the adapter's with `--thinking-budget`; a replayed thinking block whose signature no longer matches the conversation, which happens when a tool changes the session block between the root's calls, is dropped by the API through the `thinking-binding-controls-2026-08-01` beta instead of failing the call with a 400), and Claude's structured outputs for the adapter's and the reviewer's response schemas, because ADK 2.11 sends no response schema to Claude: the request's `output_config.format` carries the schema made strict by the SDK's `transform_schema`, so constrained decoding enforces types, enums and required fields, and the Pydantic schema with its repairs checks the lengths and counts the grammar cannot. On `gemini` it returns `Gemini(model=..., client_kwargs={"enterprise": True, "project": ..., "location": settings.location}, retry_options=HttpRetryOptions(attempts=3, initial_delay=1))`. The bare `Gemini()` default is the retiring 2.5 Flash and is never used. -- No `FallbackModel` in phase 1. A fallback would need a second model id or the `global` endpoint, which breaks the pins; persistent 429s map to `error{code:"capacity"}`. A named fallback on `eu` is an explicit owner decision; switching `AGENT_PROVIDER` is a configuration change, not a fallback. +- No `FallbackModel` in phase 1. A fallback would need a second model id or the `global` endpoint, which breaks the pins. The Claude client retries a call 4 times (`CLAUDE_MAX_RETRIES`, the SDK's 2 before 2026-11-01; the judge never retries inside the SDK); a 429 (quota) or 529 (overload) that outlasts the retries ends a turn with `error{code:"capacity"}` and a pipeline run with `failed` reason `busy` (`models.model_busy`), both read as "busy, try again shortly". A named fallback on `eu` is an explicit owner decision; switching `AGENT_PROVIDER` is a configuration change, not a fallback. - Low-cardinality `labels` in every agent's generation config. On Gemini: explicit `safety_settings` at BLOCK_MEDIUM_AND_ABOVE, and no temperature, top_p or thinking budget on Gemini 3. - `agents/anyplot/agent.py` keeps an explicit registry `ALL_AGENTS = [root_agent, *ADAPTERS.values(), reviewer]`, because the adapters and the reviewer are started with `ctx.run_node` and a tree walk from the root would miss them. A unit test iterates the registry and `App.plugins` and asserts: no `code_executor`, `google_search`, `url_context`, `AgentTool`, `LoggingPlugin` or chat-mode sub-agents; no agent has a non-empty string `instruction`; every model string and client location comes from settings. - Settings defaults in `AgentSettings` (`agents/anyplot/settings.py`, read from `AGENT_*` variables): `provider="anthropic-vertex"`, `model="claude-haiku-5-5"`, `judge_model="claude-haiku-5-5"`, `location="eu"`, `root_thinking_budget=1024`, `adapter_thinking_budget=8192` (2,048 until 2026-10-14), `reviewer_thinking_budget=4096`, `root_effort="medium"`, `adapter_effort="high"`, `reviewer_effort="high"` (the 2026-10-11 measurement: with both, every one of the 122 cases passed the gates and 51.6 % ended `ok`, against 91.0 % and 28.7 % with thinking off, for a median of $0.0063 instead of $0.0038 per plot and a p50 of 31.5 s instead of 13.6 s; Gemini ignores the budgets and refuses a non-zero budget set explicitly, while an explicit 0 is accepted). The Gemini arm sets `AGENT_PROVIDER=gemini`, `AGENT_MODEL=gemini-3.8-flash` and `AGENT_JUDGE_MODEL=gemini-3.5-flash-lite`. The settings refuse `-latest` aliases, model ids that do not match the provider, Claude models outside `CLAUDE_MODELS` (newer Claude models refuse disabled thinking with a 400), any location other than `eu`, `us` or `global`, and `renderer="local"` outside development. @@ -133,7 +134,7 @@ async def run_pipeline(ctx: Context, node_input: PipelineArgs): yield Event(output=s.finish(ctx, best, best_defects, best_reviewed, review_defects, reason)) # every path ``` -`finish` returns `ok` only when `best` exists, the reviewer saw exactly that render (`best_reviewed`) and passed it, and the canvas was not padded; `needs_attention` when `best` exists but either the last review rejected it (its lines become residual defects), or the reviewer never saw it after giving defects (a repair whose second review the budget or the deadline skipped, or whose answer could not be read; the first review's lines become residual defects), or the canvas had to be padded, or advisory probe gates still report defects; `failed` when no render passed R1 and R2, with `reason` in `validation`, `render`, `deadline`, `budget` or `error`. The translator maps an abort without a `PlotResult` to `error{code:"deadline"}`. +`finish` returns `ok` only when `best` exists, the reviewer saw exactly that render (`best_reviewed`) and passed it, and the canvas was not padded; `needs_attention` when `best` exists but either the last review rejected it (its lines become residual defects), or the reviewer never saw it after giving defects (a repair whose second review the budget or the deadline skipped, or whose answer could not be read; the first review's lines become residual defects), or the canvas had to be padded, or advisory probe gates still report defects; `failed` when no render passed R1 and R2, with `reason` in `validation`, `render`, `deadline`, `budget`, `busy` (since 2026-11-01: a model call still refused for its quota or an overload after the client's retries) or `error`. The translator maps an abort without a `PlotResult` to `error{code:"deadline"}`. Since 2026-10-13 the defaults change the loop in four places (the measurements are in `agents/README.md`, "The plot pipeline since 2026-10-13"): @@ -172,18 +173,19 @@ The owner decided the queue, the rate and the serial renders on 2026-10-09, afte | Render time | 60 s per render, host-enforced and clamped to the remaining soft deadline | the pipeline's clamp and the renderer's kill path (`timeout_s` per theme) | | Request deadline | 540 s since 2026-10-13 (180 s before) through `abort_signal` plus task cancellation on disconnect, counted from the start of the turn; paused while a sandbox run waits in the queue and resumed with the time it had left when the run leaves the queue, so a turn's active time stays within 540 s and the whole turn within 540 s plus the queue's 600 s maximum wait, below the BFF's 1,190 s turn cap | the `/messages` route (`RunDeadline`) | | What queues | since 2026-10-14 only sandbox work: a turn's first `plot_pipeline` call that passed its readiness check, or its first `run_data_code` call, takes the queue entry and holds it until the turn ends; a second sandbox run in the same turn neither waits nor books a second start. A chat turn without one (an answer, a refusal, `prepare_data`, `generate_data`, a pipeline call that answers `not_ready`) never enters the queue | the `/messages` route (`TurnSlot`) | -| Runs in flight per instance | 1 (`AGENT_RUN_CONCURRENCY`) sandbox run | the run queue | -| 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 | +| Runs in flight per instance | 5 (`AGENT_RUN_CONCURRENCY`) sandbox runs, every lane together, since 2026-11-01 (owner decision; 1 before) | the run queue | +| Run starts | fewer than `AGENT_RUNS_PER_MINUTE` (6) in the last 60 s, a sliding window of start times, and at least 60 / that many seconds after the previous start: one start every 10 s, in every lane, since 2026-11-01 (1 a minute before). The gap spreads a fan-out's runs, whose renders would otherwise reach the one render slot together; one start per turn that runs a sandbox | the run queue (`RunQueue.start_gap_s`) | | 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 | +| Queue length | rate x maximum wait / 60, so 60 entries at the defaults (10 before 2026-11-01); every waiting entry holds an open stream, which the instance's Cloud Run concurrency of 80 covers with the 5 runs in flight and the other routes. 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 end within the time their starts take, so admission promises a place, not a start | the run queue | | 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`) | +| Sandbox runs per user | at most `AGENT_USER_MAX_RUNS` (2 since 2026-11-01, 3 before) queued or running at the same time across the user's sessions, since 2026-10-21, and `AGENT_ADMIN_USER_MAX_RUNS` (5) for an admin since 2026-11-01; 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. An admin is exempt since 2026-11-01 | 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 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` | +| Daily budget | a user already over the daily token budget (`AGENT_DAILY_TOKEN_BUDGET`, 10,000,000 weighted tokens, about $1, since 2026-11-01; 8,000,000 before), or any user once the service is over its global one (`AGENT_GLOBAL_DAILY_TOKEN_BUDGET`, 100,000,000, about $10, since 2026-11-01; 24,000,000 before), gets the `budget` refusal at once, before the judge; a public user also has 50 pipeline runs a day (`AGENT_DAILY_PIPELINE_RUNS`, 40 before). An admin has no daily token budget, pipeline-run count, data-run count or message limit of their own since 2026-11-01 (owner decision: one fan-out booked 3,047,083 weighted tokens in that day's measurement), so the global budget is the admin's only daily cap on model spend; the strikes bind every tier, and pastes and conversions keep their per-user limits, while the fan-out's copies bind no admin (only the store's 256 MiB cap) | the `/messages` route, the Budget plugin (`UsageBook.daily_ok`) | +| Admin tier | `X-Anyplot-Tier: admin`, which the BFF sets from the identity its admin gate verified and only an allowed caller can send (the caller check answers first); anything else is the public tier, so a BFF without the header keeps today's limits. A header, not the `adm_` prefix of the user id, so the id stays opaque and the tier is one explicit assertion | `caller_tier` in `agents/main.py`, `agent_context` in the BFF | +| 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, first come first served whichever run-queue lane its run is in, and the `pipeline_render` attribution line reports the wait (`slot_wait_s`). It stays 1 with five runs in flight (owner decision of 2026-11-01): a second renderer instance would break the cancel path and the `503 busy` handling | `SerialRenderer` (`render/serial.py`), applied in `Services.backend` | | Theme toggle | one render; it waits for a render slot at most `AGENT_REQUEST_DEADLINE_S` - `AGENT_RENDER_TIMEOUT_S` (120 s), then answers `503 capacity`; a theme that failed the host gates twice is answered from its record | `theme_render.py` | | Dataset size | at most 1,048,576 bytes of paste (204,800 until 2026-10-14; computed data 2,097,152), 20,000 rows, 50 columns and 200 characters per cell; one past any of them is refused before any model call (`413 too_long` for the bytes, `422 unparseable` for the rest). The canonical `data.csv` can grow to about 3.8 times the input (`7e15` becomes `7000000000000000.0`); the parser refuses a parse whose canonical CSV passes `MAX_CANONICAL_BYTES` (4 MiB, the renderer's `data_csv` limit, 2 MiB before), so the 1 MB `stress-csv-expansion` case (3,954,696 bytes canonical) still fits. Measured on a 914,795-byte paste of 19,999 rows: the parse 0.5 s, the pre-filter over every cell 0.22 s, one dataset-judge part (the judge reads headers and a sample, never every row). The dataset store accounts each version at its canonical CSV (up to 4 MiB) and profile, and a session keeps its current version and five undo versions, about 25 MiB at worst, so its cap rose from 64 MiB to 256 MiB (about ten such sessions per instance, inside the service's 4 GiB) | the `/dataset` route and `parse.py` (stress cases `stress-cap-*`, `stress-over-*`, `stress-csv-expansion`) | | Dataset profile in the adapter request | at most `MAX_ADAPTER_PROFILE_BYTES` (16 KiB of UTF-8, measured like the root's `get_dataset_profile` limit on the profile inside its `` fence, so neither CJK or emoji cells nor tag text that the fence escapes can stretch it): the sample rows go first, then the top values past the five most frequent, then the rest of the top values, then the ranges and statistics, and every column stays. 47 text columns of long values make the full profile 26,911 characters (29,491 bytes; the model-facing JSON carries no null field) | `briefs.adapter_profile` in `render_adapt_request` (stress case `stress-cap-profile`) | @@ -195,7 +197,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. 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 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. Since 2026-11-01 (owner decision) it runs five runs at once for the whole instance (`AGENT_RUN_CONCURRENCY`, 1 before), starts at most one every 10 seconds (`AGENT_RUNS_PER_MINUTE` 6, with the starts spaced 60 / 6 seconds apart; one a minute before) and holds 60 waiting entries: a 15-library fan-out had taken about 20 minutes through one slot and one start a minute, while each run renders for about 5 s of its 60 to 240 s. It has three lanes, which share that concurrency, rate and length and differ in their per-user cap and their order. `normal` is every user who is not an admin, today and after the public launch: two runs per user (`AGENT_USER_MAX_RUNS`, 3 before). `admin` holds the admin's runs, the fan-out above all: five per admin (`AGENT_ADMIN_USER_MAX_RUNS`), first come, first served with the `normal` entries; the admin's other exemptions live in the usage book. A `premium` entry goes before every other one, under the `normal` cap; nothing sets `premium` yet, it is the lane for users who later pay for their own tokens. The `/messages` route picks the lane from the tier the BFF asserts (`X-Anyplot-Tier`), never from the client's `source: fanout`. The runs' model calls wait on the network, but every render takes the one render slot (`AGENT_RENDER_CONCURRENCY` stays 1), first come first served, and the `pipeline_render` line reports that wait as `slot_wait_s`; the 10-second gap between starts spreads a fan-out's renders. The gates run in a worker thread, so one run's PNG checks never stall the others' streams. 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 their lane's cap (`AGENT_USER_MAX_RUNS`, 2, or `AGENT_ADMIN_USER_MAX_RUNS`, 5, 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. `GET /v1/status` reports the caller's own cap as `user_max_runs`. 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. @@ -227,11 +229,11 @@ Built since 2026-10-14 (data code): owner decision of 2026-10-14, a user may pas The dataset route classifies a paste before it parses it (`data/classify.py`): code answers `422 code` (at any length, because code is never a table; `np.linspace(0, 10, 100)` would otherwise parse as a one-row table), a formula within the chat's cap `422 formula`; the page offers `.send()` for either when the text fits a chat message, and asks to shorten longer code. The scope judge calls a snippet or formula whose job is to generate the plot's data `in_scope` in any language, code with another purpose (factoring a number, "run this script") `out_of_scope`, and code that touches files, the network or the system `attack`. `AGENT_DATA_CODE=false` switches the tool off (it answers `unavailable`); a renderer deployed before `POST /run-data` answers `404`, which the tool also answers as `unavailable`. -Built since 2026-11-01 (example data, owner request of 2026-11-01): "Beispieldaten" or "generiere Zufallszahlen mit Kategorien" in the data field ended in "no table found", because the text holds no table and the transcriber found no rows in it. Such a request now goes to the chat, where the root makes the data up with code. Detection has two halves and no new model call: the dataset route's classifier (`classify.looks_like_data_request`, between the code and the formula check) reads one line of at most 30 words for a fixed phrase in German, English, French, Spanish, Italian, Portuguese, Dutch or Polish ("Beispieldaten", "Zufallszahlen", "random numbers", "make up some data", "données d'exemple") with fewer than three values of its own (`generate.stated_values`, the formula guard's count with its range phrases left out, so "3 Kategorien und 20 Zeilen" stays a request and "Beispieldaten: Hund 7, Katze 3, Vogel 4" stays a table in words); and the transcriber, which already reads every other text without a table, answers `asks_for_data` for data described in words or in another language ("monthly sales for 4 regions"), honoured for text the chat takes when that answer holds no table or the text no values of its own (`classify.request_for_chat`, so "Beispieldaten mit 3 Kategorien, 4 Regionen und 12 Monaten", which the rules leave to the transcriber for its three numbers, still goes), and whatever table came with that answer is dropped. Either answers `422 data_request` (the rules' answer counts as no paste; the transcriber's does, since it reached a model), and the page sends the text to the chat as the user's message at once, with no offer to click (owner: simple), and empties the box. The scope judge's rubric calls a request for made-up example data `in_scope`, and only figures of the real world (a named company, place, index or dataset, "the real figures") `needs_data`. The root reads the spec's roles (`get_spec_brief`) and writes Python for `run_data_code` with `source_language` `example`: one column per role in the roles' order and kinds, named in the session language, realistic labels and ranges for the plot's subject, the counts and ranges the user names (otherwise a small table: 4 to 8 categories, 2 to 4 groups, 12 to 36 dates, 50 to 200 points for a scatter), and `np.random.default_rng(42)`, so the same request gives the same table. The model writes no row: the validator's `literal-data` rule holds for this code as for any other, and the sandbox computes the table. The version's change line says `generated example data: rows, columns` instead of `computed from your code`, and its source line for the adapter and the reviewer starts `generated example data, made up by Python code` (`dataset_context.code_source`); the rest is a `run_data_code` version (the data panel's code block, `.undo()` when there was data before, the confirmation). The run counts toward `AGENT_DAILY_DATA_RUNS` and takes the run queue's slot like any data run, so at the default of one start a minute the first `.create_plot()` can wait up to a minute behind it. Generation by code rather than a server-side generator from the roles alone, because a spec's data needs structure that no list of role kinds states (a value for every pair of category and group, a trend over dates, a relation between x and y, high above low), which the root reads from the spec's description, and the code it ran is shown under the data. Telemetry: `data_input.format` and `outcome` `data_request` (`cause` `transcribed_data_request` from the transcriber), `chat_turn.kind` `example`. The live check of 2026-11-01 (Claude Haiku 5.5 on `eu`, the deployed renderer) took five requests to a confirmed table and a shipped plot, all `ok`: "Beispieldaten" on `bar-grouped` in matplotlib (16 rows of quarter, region and sales), "generiere Zufallszahlen mit Kategorien" on `scatter-basic` in plotly (200 rows in four categories), 36 months of temperatures on `line-timeseries` in ggplot2 (1,096 daily rows), "monthly sales for 4 regions over 6 months", which only the transcriber recognised, on `bar-grouped` in seaborn (24 rows), and 24 weekly values asked for in the chat on `line-timeseries` in Chart.js; $0.15 in all, about $0.03 a request with its plot. +Built since 2026-10-28 (the admin fan-out, owner request of 2026-10-28): `.plot_all()` plots the confirmed table in every library of the page's header at once, for the admin's tests. Since 2026-10-30 (owner request) it is chosen in the header: an `all` pill after the library pills, a toggle, turns the composer's create action (`.create_plot()`, or `.start_over()` once the library has a plot) into `.plot_all()`; before that it was a row of its own under the composer. While `all` is pressed the page's own session stays the data session, and any library pill turns `all` off again, the page's own library without switching. In step 2 the message box waits, since the fan-out takes no typed text, and the line above it says to turn `all` off to chat; in step 1, with or without data, the chat about the data goes on as without `all`. Before the confirmation a click on `.plot_all()` shows the same notice as `.create_plot()`. While the panel is open, the library pills, `all` and `.plot_all()` are disabled, and its `.close()` returns the focus to `.plot_all()`. The page opens a session per library (`FanOut` in `app/src/sections/agent-chat/`), and starts `create_plot` in at most `user_max_runs` sessions at a time, the caller's own cap that `GET /v1/status` reports (a pool in the page). Until 2026-11-01 that was the public `AGENT_USER_MAX_RUNS` (3) behind one run slot and one start a minute, and a 15-library fan-out took about 20 minutes; since then (owner decision) the fan-out stays admin-only, also after the public launch, and runs in the run queue's admin lane: five plots at once (`AGENT_ADMIN_USER_MAX_RUNS`), one start every 10 seconds like every run, no per-user day caps or message minute, and the global day budget, raised to 100,000,000, as the only daily cap on model spend. The cap counts the admin's runs in every window, while each panel counts only its own, so since the same day a `run_limit` answer is no failure in the panel (`fanoutRules.ts`, `nextFanOut`): the library goes back to waiting, the panel starts nothing more until one of its own runs ends or 15 s pass, then tries one library again, and a library gives up only after 30 minutes of `run_limit`, which its card says. Three panels of 15 libraries started at once thus share the five places and finish all 45 plots. Every session is deleted when the panel closes or the page goes. -Built since 2026-10-28 (the admin fan-out, owner request of 2026-10-28): `.plot_all()` plots the confirmed table in every library of the page's header at once, for the admin's tests. Since 2026-10-30 (owner request) it is chosen in the header: an `all` pill after the library pills, a toggle, turns the composer's create action (`.create_plot()`, or `.start_over()` once the library has a plot) into `.plot_all()`; before that it was a row of its own under the composer. While `all` is pressed the page's own session stays the data session, and any library pill turns `all` off again, the page's own library without switching. In step 2 the message box waits, since the fan-out takes no typed text, and the line above it says to turn `all` off to chat; in step 1, with or without data, the chat about the data goes on as without `all`. Before the confirmation a click on `.plot_all()` shows the same notice as `.create_plot()`. While the panel is open, the library pills, `all` and `.plot_all()` are disabled, and its `.close()` returns the focus to `.plot_all()`. The page opens a session per library (`FanOut` in `app/src/sections/agent-chat/`), and starts `create_plot` in at most `AGENT_USER_MAX_RUNS` sessions at a time, which `GET /v1/status` reports as `user_max_runs` (owner decision: a pool in the page, no server limit raised). Every run goes through the run queue like any other, and every session is deleted when the panel closes or the page goes. +Built since 2026-11-01 (example data, owner request of 2026-11-01): "Beispieldaten" or "generiere Zufallszahlen mit Kategorien" in the data field ended in "no table found", because the text holds no table and the transcriber found no rows in it. Such a request now goes to the chat, where the root makes the data up with code. Detection has two halves and no new model call: the dataset route's classifier (`classify.looks_like_data_request`, between the code and the formula check) reads one line of at most 30 words for a fixed phrase in German, English, French, Spanish, Italian, Portuguese, Dutch or Polish ("Beispieldaten", "Zufallszahlen", "random numbers", "make up some data", "données d'exemple") with fewer than three values of its own (`generate.stated_values`, the formula guard's count with its range phrases left out, so "3 Kategorien und 20 Zeilen" stays a request and "Beispieldaten: Hund 7, Katze 3, Vogel 4" stays a table in words); and the transcriber, which already reads every other text without a table, answers `asks_for_data` for data described in words or in another language ("monthly sales for 4 regions"), honoured for text the chat takes when that answer holds no table or the text no values of its own (`classify.request_for_chat`, so "Beispieldaten mit 3 Kategorien, 4 Regionen und 12 Monaten", which the rules leave to the transcriber for its three numbers, still goes), and whatever table came with that answer is dropped. Either answers `422 data_request` (the rules' answer counts as no paste; the transcriber's does, since it reached a model), and the page sends the text to the chat as the user's message at once, with no offer to click (owner: simple), and empties the box. The scope judge's rubric calls a request for made-up example data `in_scope`, and only figures of the real world (a named company, place, index or dataset, "the real figures") `needs_data`. The root reads the spec's roles (`get_spec_brief`) and writes Python for `run_data_code` with `source_language` `example`: one column per role in the roles' order and kinds, named in the session language, realistic labels and ranges for the plot's subject, the counts and ranges the user names (otherwise a small table: 4 to 8 categories, 2 to 4 groups, 12 to 36 dates, 50 to 200 points for a scatter), and `np.random.default_rng(42)`, so the same request gives the same table. The model writes no row: the validator's `literal-data` rule holds for this code as for any other, and the sandbox computes the table. The version's change line says `generated example data: rows, columns` instead of `computed from your code`, and its source line for the adapter and the reviewer starts `generated example data, made up by Python code` (`dataset_context.code_source`); the rest is a `run_data_code` version (the data panel's code block, `.undo()` when there was data before, the confirmation). The run counts toward `AGENT_DAILY_DATA_RUNS` and takes the run queue's slot like any data run, so at the default of one start every 10 seconds the first `.create_plot()` can wait up to 10 seconds behind it. Generation by code rather than a server-side generator from the roles alone, because a spec's data needs structure that no list of role kinds states (a value for every pair of category and group, a trend over dates, a relation between x and y, high above low), which the root reads from the spec's description, and the code it ran is shown under the data. Telemetry: `data_input.format` and `outcome` `data_request` (`cause` `transcribed_data_request` from the transcriber), `chat_turn.kind` `example`. The live check of 2026-11-01 (Claude Haiku 5.5 on `eu`, the deployed renderer) took five requests to a confirmed table and a shipped plot, all `ok`: "Beispieldaten" on `bar-grouped` in matplotlib (16 rows of quarter, region and sales), "generiere Zufallszahlen mit Kategorien" on `scatter-basic` in plotly (200 rows in four categories), 36 months of temperatures on `line-timeseries` in ggplot2 (1,096 daily rows), "monthly sales for 4 regions over 6 months", which only the transcriber recognised, on `bar-grouped` in seaborn (24 rows), and 24 weekly values asked for in the chat on `line-timeseries` in Chart.js; $0.15 in all, about $0.03 a request with its plot. -Since 2026-11-01 (owner decision) the server copies the page session's confirmed current data version into each fan-out session (`POST /v1/sessions/{sid}/dataset/copy` with `{from_session}`, `copyData` in the page), so `.plot_all()` plots exactly what the user confirmed, whatever its origin: a paste, a file, a table the assistant changed (`prepare_data`, `.to_long()`) or computed (`generate_data`, `run_data_code`). The copy is the stored version as it is (`DatasetStore.copy`): the same canonical CSV, dtypes, column order, notes, file name, `source` and `code`, nothing parsed or judged again, so a library costs no paste and no model call, and one chat turn, its `create_plot`, against `AGENT_USER_MESSAGES_PER_MINUTE`. The bindings, their confirmation and the session language come along; the copy is the target's version 1 with no undo history, and the session state's `anyplot.dataset_copied_from` (the bundle's `session.dataset_copied_from`) records the source's version number. Both sessions are looked up under the caller's user id, so another user's session is `404 session_expired` and no data crosses users. The target must be a fan-out session (`source: fanout`) of the same spec without data (`409 has_dataset`), and the source must hold data (`422 no_dataset`) confirmed with its current bindings (`422 unconfirmed`); neither may have a turn, a render or a paste in flight (`409 run_active`). While the panel is open the page also locks the data card (`DataPanel`'s `locked`, with a line that says to close the panel first), so no paste, binding change, confirmation or undo can land between the copies of the libraries, which a new paste's version number (1 again) would not reveal to the server. Each copy takes the dataset's size in the store again, so a counter of its own bounds it (`COPIES_PER_MINUTE` 30, `COPIES_PER_DAY` 150 per user in `agents/anyplot/plugins/ledger.py`, constants like the outcome beacon's; a per-user byte budget in the store is the follow-up before public use, see [Risks](#risks-and-mitigations)). The `dataset_copy` attribution line carries the outcome, the row and column counts, the version number and the source session's salted hash, never data, and a copy writes no `data_input` telemetry line. Until 2026-11-01 the page posted the paste its own session stored again (`DatasetState.upload`, `replayData`) and kept `.plot_all()` disabled for changed or computed data; against a server without the copy route (`404 not_found`, the page deployed first) it still does that where the data came from a paste or a file, and otherwise each library stops with `copy_unavailable`. Since the same day the pill is in the URL as `&all=1`, which a reload presses again once the libraries are known, and a library switch writes the library's `language` to the URL along with `library`. +Since 2026-11-01 (owner decision) the server copies the page session's confirmed current data version into each fan-out session (`POST /v1/sessions/{sid}/dataset/copy` with `{from_session}`, `copyData` in the page), so `.plot_all()` plots exactly what the user confirmed, whatever its origin: a paste, a file, a table the assistant changed (`prepare_data`, `.to_long()`) or computed (`generate_data`, `run_data_code`). The copy is the stored version as it is (`DatasetStore.copy`): the same canonical CSV, dtypes, column order, notes, file name, `source` and `code`, nothing parsed or judged again, so a library costs no paste and no model call, and one chat turn, its `create_plot`, which an admin's message limits no longer count against since 2026-11-01. The bindings, their confirmation and the session language come along; the copy is the target's version 1 with no undo history, and the session state's `anyplot.dataset_copied_from` (the bundle's `session.dataset_copied_from`) records the source's version number. Both sessions are looked up under the caller's user id, so another user's session is `404 session_expired` and no data crosses users. The target must be a fan-out session (`source: fanout`) of the same spec without data (`409 has_dataset`), and the source must hold data (`422 no_dataset`) confirmed with its current bindings (`422 unconfirmed`); neither may have a turn, a render or a paste in flight (`409 run_active`). While the panel is open the page also locks the data card (`DataPanel`'s `locked`, with a line that says to close the panel first), so no paste, binding change, confirmation or undo can land between the copies of the libraries, which a new paste's version number (1 again) would not reveal to the server. Each copy takes the dataset's size in the store again, so a counter of its own bounds it (`COPIES_PER_MINUTE` 30, `COPIES_PER_DAY` 150 per user in `agents/anyplot/plugins/ledger.py`, constants like the outcome beacon's; since the same day an admin is exempt from both, because three windows of `.plot_all()` copy 45 datasets within a minute, and the store's byte cap still refuses a copy it has no room for; a per-user byte budget in the store is the follow-up before public use, see [Risks](#risks-and-mitigations)). The `dataset_copy` attribution line carries the outcome, the row and column counts, the version number and the source session's salted hash, never data, and a copy writes no `data_input` telemetry line. Until 2026-11-01 the page posted the paste its own session stored again (`DatasetState.upload`, `replayData`) and kept `.plot_all()` disabled for changed or computed data; against a server without the copy route (`404 not_found`, the page deployed first) it still does that where the data came from a paste or a file, and otherwise each library stops with `copy_unavailable`. Since the same day the pill is in the URL as `&all=1`, which a reload presses again once the libraries are known, and a library switch writes the library's `language` to the URL along with `library`. ### Session language @@ -253,7 +255,7 @@ Callable only with Cloud Run IAM; the BFF mirrors it under `/debug/agent`. | Route | Body and result | Errors | |---|---|---| -| `GET /v1/status` | `{libraries, model, location, version, provider, waiting, in_flight, user_max_runs}`: `waiting` entries in the run queue, runs `in_flight`, and `AGENT_USER_MAX_RUNS` | | +| `GET /v1/status` | `{libraries, model, location, version, provider, waiting, in_flight, user_max_runs, tier}`: `waiting` entries and runs `in_flight` in every lane of the run queue, the caller's own cap (`AGENT_USER_MAX_RUNS`, or `AGENT_ADMIN_USER_MAX_RUNS` for an admin), and the tier the request was served as | | | `POST /v1/sessions` | `{user, spec_id, library, locale, snapshot: CatalogueSnapshot{spec_id, title, description, data_roles[], notes[], code, library_version}, source?}` (at most 64 KB; `source`: `fanout` only, for the admin fan-out's sessions, whose usage telemetry it files apart from `live`) → `{session_id, eligibility}` | `422 not_eligible` | | `POST /v1/sessions/{sid}/library` | `{library, snapshot}`; keeps dataset and bindings | `422`, `409 run_active` | | `POST /v1/sessions/{sid}/dataset` | `{text, confirmed?, source?}` → `{preview, profile, bindings, warnings, context_lines, roles, version, language}`; `source` is the name of a dropped file (at most 1,024 characters), screened on its own and answered as `file_name`, never a note; `context_lines` are the judged notes pasted around the table; `language` is the session language as `{language, source}` after the paste, which sets it (`pasted`) when nothing typed or chosen has (see [Session language](#session-language)); `roles` lists the spec's data roles as `{name, kinds, required, variadic, description, non_negative}`; a paste is `version` 1 and drops an earlier paste's undo history | `413 too_long`, `422 unparseable`, `422 formula` (text that asks for a formula, which the root's `generate_data` computes in the chat; never transcribed), `403 data_refused`, `422 code` (a program that generates data, which the root's `run_data_code` runs in the chat; checked before any parse) | @@ -441,7 +443,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 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 | +| Resource abuse (denial of wallet) | Ledger budgets including the judge, and the global day budget (100,000,000 weighted tokens, about $10) for every tier, admins included; the run queue in front of the sandbox runs (five runs in flight, one start every 10 s, at most 60 waiting; the admin's cap of five runs only for a tier the BFF asserts behind the caller check); 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 two sandbox runs per public 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 | @@ -500,7 +502,7 @@ Built on 2026-10-10: the harness, the two fixture generators and the 146 fixture ## Serving and infrastructure -- **`anyplot-agents`**, a new Cloud Run service built from `agents/Dockerfile` and deployed by `agents/cloudbuild.yaml` (both built on 2026-10-10; the service has run at `https://anyplot-agents-r3tvmejsmq-ez.a.run.app` since 2026-10-11, deployed by the `deploy-agents` trigger on every push to `main` that touches it) with the renderer's candidate-then-smoke-then-promote steps and `--update-*` flags only. Like the renderer's, its deploy step creates the service on the first build without `--no-traffic`, because gcloud rejects `--no-traffic` on a new service, and only when `services describe` reports the service as not found; the API side stayed dark through `AGENT_ENABLED=false` until the switch-on of 2026-10-11. Flags: `gcloud run deploy anyplot-agents --region=europe-west4 --execution-environment=gen2 --no-allow-unauthenticated --service-account=anyplot-agents@anyplot.iam.gserviceaccount.com --cpu=1 --memory=2Gi --min-instances=${_MIN_INSTANCES} --max-instances=1 --concurrency=20 --timeout=1200 --update-env-vars=^|^... --no-traffic --tag=candidate`, where `_MIN_INSTANCES` is 1 since 2026-11-02 (0 before): a warm instance keeps the in-memory sessions and day counters through idle periods and spares the first message a cold start, and the account's unused Compute Flexible commitment absorbs its idle cost (about 0.44 CHF a day at list price) until the commitment's first part ends on 2027-01-15. No secrets and no Cloud SQL in phase 1. The smoke step checks that Cloud Run IAM refuses an anonymous `GET /v1/status` with exactly `403` (a `401` would come from the container's own caller check, past IAM) and, with `_SMOKE_AUTH=true`, calls `/v1/status` with an ID token impersonated for `anyplot-agents@` (the renderer's token recipe) and checks that it names the provider, model, location and libraries the build configured; the bootstrap build runs with `_SMOKE_AUTH=false`, because the service has no URL and no invoker binding before it exists. The image installs `--extra agents` without plotting extras, copies the source tree with the prompts, the fonts and the catalogue prompt sources at their repository paths, runs as uid 1000, and fails its build when a prompt source or a font is missing: the build imports `agents.main` and reads the two judge rubrics, which the service otherwise reads on first use. The root `.dockerignore` keeps `agents/evals` and ADK's local `.adk/` store, both of which can hold users' data, out of the image. `.github/workflows/ci-image.yml` builds it before merge and checks `/v1/status`, the production default's `401` for an anonymous caller, the files in the image and the uid. The concurrency and the timeout follow the run queue, not the memory: the queue, not Cloud Run, limits the runs in memory, but every queued run holds an open stream, so the instance must accept the queue's 10 waiting streams, the run in flight and the other routes (`--concurrency=20`), and one turn can last the 600 s maximum wait plus the 540 s deadline (`--timeout=1200` since 2026-10-13, 900 before). The FastAPI app in `agents/main.py` sets `docs_url=None`. The service runs no code of its own users: it has no `--sandbox-launcher`, and every render goes to `anyplot-renderer` through the `remote` backend. Its 2 vCPU and 4 GiB were sized when it still hosted the sandboxes; since 2026-10-29 it runs on 1 vCPU and 2 GiB (owner decision): over the 14 days before, its hourly p99 peaked at 0.46 of a core and 370 MiB, and the worst case of about 1.1 GiB (the dataset and render stores' 256 MiB caps, the process, a 1 MB paste's parse and the PNG gates) keeps headroom, because an out-of-memory kill would lose every session in memory. The renderer keeps 2 vCPU and 4 GiB: its renders peaked at 1.2 cores (the sandbox's gVisor and the host share the CPU with the render), a Makie render needs up to 1.5 GiB, and a single render is no faster with more CPUs (a Makie heatmap took 8.9 s locally at 1, 2 and 4 CPUs alike: Julia compiles on one thread). The renderer scales to zero and bills only while it serves a request; the agents service keeps one instance warm (see its `_MIN_INSTANCES` above). +- **`anyplot-agents`**, a new Cloud Run service built from `agents/Dockerfile` and deployed by `agents/cloudbuild.yaml` (both built on 2026-10-10; the service has run at `https://anyplot-agents-r3tvmejsmq-ez.a.run.app` since 2026-10-11, deployed by the `deploy-agents` trigger on every push to `main` that touches it) with the renderer's candidate-then-smoke-then-promote steps and `--update-*` flags only. Like the renderer's, its deploy step creates the service on the first build without `--no-traffic`, because gcloud rejects `--no-traffic` on a new service, and only when `services describe` reports the service as not found; the API side stayed dark through `AGENT_ENABLED=false` until the switch-on of 2026-10-11. Flags: `gcloud run deploy anyplot-agents --region=europe-west4 --execution-environment=gen2 --no-allow-unauthenticated --service-account=anyplot-agents@anyplot.iam.gserviceaccount.com --cpu=1 --memory=2Gi --min-instances=${_MIN_INSTANCES} --max-instances=1 --concurrency=80 --timeout=1200 --update-env-vars=^|^... --no-traffic --tag=candidate`, where `_MIN_INSTANCES` is 1 since 2026-11-02 (0 before): a warm instance keeps the in-memory sessions and day counters through idle periods and spares the first message a cold start, and the account's unused Compute Flexible commitment absorbs its idle cost (about 0.44 CHF a day at list price) until the commitment's first part ends on 2027-01-15. No secrets and no Cloud SQL in phase 1. The smoke step checks that Cloud Run IAM refuses an anonymous `GET /v1/status` with exactly `403` (a `401` would come from the container's own caller check, past IAM) and, with `_SMOKE_AUTH=true`, calls `/v1/status` with an ID token impersonated for `anyplot-agents@` (the renderer's token recipe) and checks that it names the provider, model, location and libraries the build configured; the bootstrap build runs with `_SMOKE_AUTH=false`, because the service has no URL and no invoker binding before it exists. The image installs `--extra agents` without plotting extras, copies the source tree with the prompts, the fonts and the catalogue prompt sources at their repository paths, runs as uid 1000, and fails its build when a prompt source or a font is missing: the build imports `agents.main` and reads the two judge rubrics, which the service otherwise reads on first use. The root `.dockerignore` keeps `agents/evals` and ADK's local `.adk/` store, both of which can hold users' data, out of the image. `.github/workflows/ci-image.yml` builds it before merge and checks `/v1/status`, the production default's `401` for an anonymous caller, the files in the image and the uid. The concurrency and the timeout follow the run queue, not the memory: the queue, not Cloud Run, limits the runs in memory, but every queued run holds an open stream, so the instance must accept the queue's 60 waiting streams, its 5 runs in flight and the other routes (`--concurrency=80` since 2026-11-01; 20 before, for 10 waiting streams and 1 run), and one turn can last the 600 s maximum wait plus the 540 s deadline (`--timeout=1200` since 2026-10-13, 900 before). The FastAPI app in `agents/main.py` sets `docs_url=None`. The service runs no code of its own users: it has no `--sandbox-launcher`, and every render goes to `anyplot-renderer` through the `remote` backend. Its 2 vCPU and 4 GiB were sized when it still hosted the sandboxes; since 2026-10-29 it runs on 1 vCPU and 2 GiB (owner decision): over the 14 days before, its hourly p99 peaked at 0.46 of a core and 370 MiB, and the worst case of about 1.1 GiB (the dataset and render stores' 256 MiB caps, the process, a 1 MB paste's parse and the PNG gates) keeps headroom, because an out-of-memory kill would lose every session in memory. The renderer keeps 2 vCPU and 4 GiB: its renders peaked at 1.2 cores (the sandbox's gVisor and the host share the CPU with the render), a Makie render needs up to 1.5 GiB, and a single render is no faster with more CPUs (a Makie heatmap took 8.9 s locally at 1, 2 and 4 CPUs alike: Julia compiles on one thread). The renderer scales to zero and bills only while it serves a request; the agents service keeps one instance warm (see its `_MIN_INSTANCES` above). - **`anyplot-renderer`**, a phase-1 Cloud Run service that runs the code (`agents/renderer/`), deployed by `agents/renderer/cloudbuild.yaml` with the same candidate, smoke and promote steps. Since 2026-10-29 its image builds on the toolchain image `anyplot-toolchain` (`agents/renderer/toolchain.Dockerfile`: `python:3.14-slim` pinned by digest, R 4.6.1 with the pipeline's package set, and the exec filter's preload library for the exec-mode rows; step 2a of `agentic/specs/261024-agent-all-libraries.md`). The deploy hashes the toolchain's inputs (`agents/renderer/toolchain.py`), builds and pushes `anyplot-toolchain:` to the private Artifact Registry repository `anyplot` only when that tag is missing, and builds the renderer from it by digest; the CI job pulls the same tag through Workload Identity Federation (`roles/artifactregistry.reader` on the repository) or builds it. Its deploy step creates the service on the first build, because gcloud refuses `--no-traffic` for a service that does not exist yet; it does so only when `services describe` reports the service as not found, and stops on any other describe error, so a permission or API error never sends all traffic to an unsmoked revision. Flags: `gcloud beta run deploy anyplot-renderer --region=europe-west4 --execution-environment=gen2 --sandbox-launcher --no-allow-unauthenticated --service-account=anyplot-renderer@anyplot.iam.gserviceaccount.com --cpu=2 --memory=4Gi --min-instances=${_MIN_INSTANCES} --max-instances=1 --concurrency=4 --timeout=300 --add-volume=name=runs,type=in-memory,size-limit=512Mi --add-volume-mount=volume=runs,mount-path=/tmp/runs --no-traffic --tag=candidate --update-env-vars=^|^ENVIRONMENT=production|RENDERER_ALLOWED_CALLERS=...|RENDERER_AUDIENCES=...`, where `_MIN_INSTANCES` is 0 unless a build sets 1 to keep an instance warm. One instance with one render slot, because renders are serial; `--concurrency=4` so a second caller gets the slot's `503 busy` after 30 s instead of a Cloud Run 429, and a cancel reaches the instance while a render holds the slot; the run directories on the in-memory volume (see [Render](#render)). No public domain, no secrets, no Cloud SQL and no role: the service account exists only to be an identity. The process runs as root, because `sandbox do` creates the gVisor sandboxes; the model-written code runs inside them. The smoke step checks that IAM refuses an anonymous caller (403) and, with `_SMOKE_RENDER=true`, checks that `/status` reports the launcher and the volume and renders a matplotlib plot through the candidate as the service's own account, which proves the launcher, the volume and the harness together; a Makie, a Chart.js and a plotly render and a data run follow, and each one gates the promotion too. These render probes live in `agents/renderer/deploy_smoke.sh`, which the step calls with the token in the environment, because Cloud Build refuses the whole build when one step argument is longer than 10,000 characters: the inline step reached 11,347 with the plotly probe, and PB-2a's renderer did not deploy (build `dc1cf3ae` of 2026-11-01). `tests/unit/test_cloudbuild_configs.py` keeps every Cloud Build config of the repository under that limit. The build impersonates `anyplot-renderer@` for that token (`gcloud auth print-identity-token --impersonate-service-account`), because a build cannot mint an ID token for its own identity: gcloud refuses `--include-email` for it and Cloud Build's metadata server has no `identity` endpoint (builds `da6f53f5` and `a9d3ac7f` of 2026-10-10). The token is minted for the service URL while the requests go to the `candidate` tag URL, because Cloud Run accepts no other audience. `.github/workflows/ci-image.yml` builds the same image before merge and renders a seaborn plot through its harness. | Service | Runs | Called by | Identity and roles | @@ -677,7 +679,7 @@ Defaults apply until the owner decides otherwise. | The reviewer is a weak judge (expert correlation about 0.43) | Deterministic gates first, a short checklist, at most one repair by default (`AGENT_MAX_ATTEMPTS`), residual defects shown as notes, owner acceptance as the real metric | | ADK ships weekly with feature-flag drift | An exact pin to 2.11.0; re-check `FUNCTION_TOOL_ARG_VALIDATION` and `JSON_SCHEMA_FOR_FUNC_DECL` on upgrade; plain-code modules without ADK imports; the harness report stamps the ADK version | | Caller trust: anyplot-api runs as the shared compute service account | Acceptable while admin-only; the IAM-forwarded claims check; a dedicated service account before public use | -| The fan-out's data copies (`/dataset/copy`) count as no paste, and each one takes the dataset's full size in the shared 256 MiB store again | Acceptable while admin-only: a fan-out session takes one copy, and a user at most 30 a minute and 150 a day (`COPIES_PER_MINUTE`, `COPIES_PER_DAY`, review of 2026-11-01, about ten fan-outs a day); a per-user byte budget in the dataset store before public use | +| The fan-out's data copies (`/dataset/copy`) count as no paste, and each one takes the dataset's full size in the shared 256 MiB store again | Acceptable while admin-only: a fan-out session takes one copy, and a public user at most 30 a minute and 150 a day (`COPIES_PER_MINUTE`, `COPIES_PER_DAY`, review of 2026-11-01); an admin is exempt since the same day (owner decision: three windows copy 45 datasets at once), so the store's 256 MiB cap (`503 capacity`) bounds the admin's copies; a per-user byte budget in the dataset store before public use | | Deadline overrun (2 renders of one theme times 60 s plus LLM calls) | A 480 s soft deadline inside the pipeline (140 s before 2026-10-13, which skipped the repair in 11 of 20 measured runs; skip the repair when less than 75 s are left, 60 s for its adapter call and 15 s for its render; clamp the render timeout); the Gemini adapter's 8,192-token edit cap, at which a cut-off first call takes about 58 s at spike X's fitted rate, so a cut-off still leaves the repair its 75 s (at 10,240 such a call would take about 72 s and cancel the repair; in exchange, a plan above 8,192 tokens loses its own repair round and comes back on attempt 2), a `try/finally` that always yields a `PlotResult`, the 540 s `abort_signal` backstop | | Long SSE streams hold anyplot-api concurrency slots on its single instance | The 540 s deadline, the 1,190 s turn cap, one run per user, and a queue of at most 10 waiting streams; the route moves out of the API before public use | | A queued turn outlasts a request timeout on its path: the 600 s maximum wait plus the 540 s deadline is 1,140 s, plus the root's first call, since the wait happens mid-turn (2026-10-14) | anyplot-api runs with `--timeout=1200`; the BFF restarts its turn budget while the run waits but never past `AGENT_TURN_MAX_S` (1,190 s): a turn still queued when its run could no longer finish inside the cap ends with `error{code:"capacity"}` and leaves the queue before it spends a token, so Cloud Run never cuts a stream silently | diff --git a/docs/reference/agent-telemetry.md b/docs/reference/agent-telemetry.md index 436abbbfff4..d80d0b59dbc 100644 --- a/docs/reference/agent-telemetry.md +++ b/docs/reference/agent-telemetry.md @@ -35,7 +35,7 @@ The agents service writes one wide JSON line per event to stdout on its own logg |---|---|---| | `data_input` | A paste or a dropped file reaches the dataset route, or the file conversion refuses a file | `format`, `entry` (`paste` or `file`), `outcome`, `cause`, `size_bucket`, `rows_bucket`, `cols_bucket`, column counts by type, `max_categories_bucket`, `missing_bucket`, `warnings` (codes), `tables`, `sheets`, `notes` and `file_name` (booleans), `spec_fit`, judge tokens, `latency_ms` | | `chat_turn` | A `/messages` turn ends, refused ones included; `manual_binding_changed` counts only a `PUT /bindings` whose bindings differ from the ones the session held, because the page echoes the parse's defaults after every paste | `kind` (`plot`, `prep`, `formula`, `code`, `example`, `talk`, `refused`, `error`), `action`, `asked_question`, `tools`, `prep_ops`, `undo`, `to_long`, `manual_binding_changed`, `binding_warnings`, `refusal`, `error`, `reply_reasked`, `language_source` and `plot_language` (both as the turn left them), `steps` (calls, tokens, thinking tokens and model per agent), `latency_ms` | -| `pipeline_run` | A `plot_pipeline` run ends, or the root asked for one before the data was ready (`status: not_ready` with its reason) | `run_index` (1 is the session's first plot), `base`, `status`, `reason`, `stage`, `stages`, `attempts`, `rounds`, `round_scores`, `score_round1`, `score_round2` (buckets), `shipped_attempt`, `version` (the shipped version's number, which `outcome.version` names), `shipped_score`, `forced_repair`, `gates_failed`, `reviewer_defects`, `residual_defects`, `sandbox_failures` (`syntax`, `name`, `key`, `value`, `type`, `timeout`, `memory`, `other`), `drift_restored` (true when a plan that only changed the protected theme statements had them restored after the round's check retry), `plot_language`, `readiness`, `loader_notes`, `edit_failures`, `validator_rules`, `adaptation_rules`, the pipeline configuration and prompt hashes, time per stage, `steps` | +| `pipeline_run` | A `plot_pipeline` run ends, or the root asked for one before the data was ready (`status: not_ready` with its reason) | `run_index` (1 is the session's first plot), `base`, `status`, `reason`, `stage`, `stages`, `attempts`, `rounds`, `round_scores`, `score_round1`, `score_round2` (buckets), `shipped_attempt`, `version` (the shipped version's number, which `outcome.version` names), `shipped_score`, `forced_repair`, `gates_failed`, `reviewer_defects`, `residual_defects`, `sandbox_failures` (`syntax`, `name`, `key`, `value`, `type`, `timeout`, `memory`, `other`), `drift_restored` (true when a plan that only changed the protected theme statements had them restored after the round's check retry), `plot_language`, `readiness`, `loader_notes`, `edit_failures`, `validator_rules`, `adaptation_rules`, the pipeline configuration and prompt hashes, time per stage, `steps`, and since 2026-11-01 `lane`, `queue_wait_ms`, `queue_position`, `user_runs_today`, `user_tokens_today` (see [Fields added on 2026-11-01](#fields-added-on-2026-11-01)) | | `outcome` | The page's beacon after a copy, a download, a session save, or a feedback send | `action` (`copied_code`, `copied_image`, `downloaded_png`, `downloaded_code`, `downloaded_page`, `opened_preview`, `downloaded_data`, `saved_session`, `feedback`; `downloaded_page` is a JavaScript plot's interactive `plot.html`, and `opened_preview` that page shown in the card, a look that keeps nothing, both since 2026-10-31), `version`, `reaction` | Every line carries the same envelope: @@ -150,6 +150,33 @@ step that is not a change in use: catalogue reviewer's instruction, which the live reviewer runs. The combined `prompt_hash` of every case shifts once on that day without any prompt changing; every case before it is an admin test case. +### Fields added on 2026-11-01 + +These fields show whether anyone waits long in the run queue or comes close to a limit, without saying who +(owner request of 2026-11-01). The owner's rule of 2026-10-30 holds for each of them: no user id, no hash of one, +no key that stays stable across sessions. Each field is a closed set or a bounded integer, so nothing that names a +user fits into it, and a test checks that the user id of the run appears nowhere in its line. They sit on +`pipeline_run` rather than `chat_turn`, because the per-user counts are counts of pipeline runs and the queue entry +is the run's. A `not_ready` run never enters the queue, so it carries `lane` only, and lines written before +2026-11-01 lack all five; the views read both as `NULL`. + +| Event | Field | Meaning | +|---|---|---| +| `pipeline_run` | `lane` | `admin` or `public`: the tier the run was served in (`X-Anyplot-Tier`, which the BFF sets for an admin). It tells the admin's test runs, which skip the per-user day caps, from the runs the public limits bound | +| `pipeline_run` | `queue_wait_ms` | How long the turn's run-queue entry waited before the run could start, in milliseconds; `0` when it started at once. The queue starts at most one run every 10 seconds and holds at most 5 runs in flight, so a fan-out's later libraries wait by design | +| `pipeline_run` | `queue_position` | Where the entry stood when it was submitted: `0` when it started at once, `1` when it ran next, at most the queue's capacity (60 at the defaults) | +| `pipeline_run` | `user_runs_today` | A bucket of the run's ordinal among the same user's pipeline runs of the UTC day: `1`, `2_5`, `6_10`, `11_20`, `21_30`, `31_40`, `41_plus`. The count lives only in the service's memory (`UsageBook`), so a redeploy or a scale-to-zero starts it again; the public cap is 50 runs a day (`AGENT_DAILY_PIPELINE_RUNS`) | +| `pipeline_run` | `user_tokens_today` | A bucket of the share of the per-user daily token budget (`AGENT_DAILY_TOKEN_BUDGET`, 10,000,000 weighted tokens, about $1) the user had spent when the run was booked: `lt25`, `25_50`, `50_75`, `75_90`, `90_100`, `over`. An admin's share is measured against the same public budget, so the buckets stay comparable; `over` is a share past the budget, which only an admin, who is exempt from it, can reach | + +The limit refusals were recorded before and stay where they were: `chat_turn.refusal` is `rate_limited` or +`daily_limit` for the message limits, and `chat_turn.error` is `capacity` (a full queue, a wait that ran out, or a +model service still refusing a call after its retries) or `run_limit` (the user's cap of runs in flight across +their windows). An admin's turns land there the same way. The `failures` view lists both. + +The typed columns are in `agents/telemetry/views/02_event_views.sql` (`pipeline_runs`). Applying the changed view +in BigQuery is a write to the shared dataset: it waits for the owner's approval after the merge, and until then the +fields sit in `payload` of the `events` view. See [Set up the BigQuery side](#set-up-the-bigquery-side). + #### Shifts a report notices on 2026-11-01 Example data on request adds values to two closed sets: diff --git a/docs/reference/api.md b/docs/reference/api.md index ad6906e4cf2..42bc0372e13 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -472,6 +472,15 @@ The agents service never sees an email address. The BFF sends HMAC-SHA256 over the admin's email with `AGENT_USER_ID_KEY`; every caller on the `X-Admin-Token` path shares the id derived from the string `token`. +The BFF also sends `X-Anyplot-Tier: admin` on every upstream call, from the +identity the admin gate verified, never from anything the browser sends. +The agents service then lets you have up to five plots queued or running at +once (two for a public user), and exempts you from the per-user daily caps and +the per-minute message limit. Every run shares the service's five slots and +its start rate of one run every 10 seconds. The global daily budget still applies. An agents service reads a +missing or other value as the public tier, so the two services deploy in +either order. + A request that gets past the three checks and the body validation is given an `X-Request-Id`, which the BFF sends upstream and returns on the response, also as `ref` in an error body or an SSE `error` event. Quote it when you report a @@ -491,7 +500,7 @@ The routes mirror the agents service's `/v1` API. All paths below start with | Route | Request | Response | |---|---|---| -| `GET /status` | None | The service's `{libraries, model, location, version, provider, waiting, in_flight, user_max_runs}` plus `"enabled": true`; `waiting` counts the turns in the agents service's run queue, `in_flight` the runs it is running, and `user_max_runs` is how many sandbox runs one user may have queued or running at once (`AGENT_USER_MAX_RUNS`) | +| `GET /status` | None | The service's `{libraries, model, location, version, provider, waiting, in_flight, user_max_runs, tier}` plus `"enabled": true`; `waiting` counts the turns in the agents service's run queue, `in_flight` the runs it is running, `user_max_runs` is how many sandbox runs you may have queued or running at once (`AGENT_ADMIN_USER_MAX_RUNS`, 5, for an admin; `AGENT_USER_MAX_RUNS`, 2, for a public user), and `tier` is `admin` or `public` | | `GET /eligibility?spec=&library=` | Spec id and library id | The agents service's answer, passed through | | `POST /sessions` | `{spec_id, library, locale, source?}` (`source`: `fanout` only, sent by the admin's "plot in all libraries" for each session it opens, so the usage telemetry files the session apart from `live` ones) | `{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 | @@ -501,7 +510,7 @@ The routes mirror the agents service's `/v1` API. All paths below start with | `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. A plain 1-D `.npy` array is one column, `value`; an `.npz` of two or more plain 1-D arrays of one length (what `np.savez(path, t=t, y=y)` writes) is one table, a column per array named after it, with `sheet` null and `sheets` empty, and its 0-D members are left out and named in `dropped`; any other `.npz` is read one array at a time, a 2-D or structured array before a 1-D one, which is the column of its name (`value` where the name could read as a value, such as `2024`). An array row whose cells are all missing comes as `NaN`, so the paste keeps it, and an array without any value is `file_empty`; `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 1-D or 2-D array and no 1-D structured one), `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, or its sandbox could not install the process filter every run requires) | | `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/copy` | `{from_session}`: the session id of the page that opened the fan-out, and nothing else | The admin's "plot in all libraries": the agents service copies the confirmed current data of `from_session` into this fan-out session as it is stored, whatever its origin (pasted, opened, computed or changed by the assistant), with its bindings, their confirmation, the session language and the language of its plot's text, and answers like the dataset route (version 1) plus `confirmed: true`, `complete`, `missing_roles` and `copied_from` (the source's version number). Nothing is parsed or judged again, and the copy counts as no paste. Both sessions are looked up under your user id: another user's session is `404 session_expired`. `409 has_dataset` when this session already has data, `422 no_dataset` or `422 unconfirmed` when `from_session` has no confirmed data, `422 invalid` for a session that is no fan-out session or another spec, `409 run_active`, `429 rate_limited` / `daily_limit` past 30 copies a minute or 150 a day, `503 capacity`. An agents service without the route answers `404 not_found`, on which the page posts the paste again | +| `POST /sessions/{sid}/dataset/copy` | `{from_session}`: the session id of the page that opened the fan-out, and nothing else | The admin's "plot in all libraries": the agents service copies the confirmed current data of `from_session` into this fan-out session as it is stored, whatever its origin (pasted, opened, computed or changed by the assistant), with its bindings, their confirmation, the session language and the language of its plot's text, and answers like the dataset route (version 1) plus `confirmed: true`, `complete`, `missing_roles` and `copied_from` (the source's version number). Nothing is parsed or judged again, and the copy counts as no paste. Both sessions are looked up under your user id: another user's session is `404 session_expired`. `409 has_dataset` when this session already has data, `422 no_dataset` or `422 unconfirmed` when `from_session` has no confirmed data, `422 invalid` for a session that is no fan-out session or another spec, `409 run_active`, `429 rate_limited` / `daily_limit` past 30 copies a minute or 150 a day for a public user (an admin has neither limit, only the store's), `503 capacity`. An agents service without the route answers `404 not_found`, on which the page posts the paste again | | `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?theme=` | `{text}` (at most 2,000 characters), `{"action": "create_plot"}` or `{"action": "summarize_data"}`; the optional query parameter `theme` (`light` or `dark`) is the site theme your page shows, which a plot of the turn renders in unless you asked for a theme in the chat; any other value is `422` | 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, and a question only when the data would be read wrong without the answer) 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 | @@ -932,7 +941,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), `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 | +| `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, the BFF's turn cap ran out while it waited, or the model service still refused a call for its quota or an overload after the retries), `run_limit` (you already have as many plot runs or data snippets queued or running in other sessions as `user_max_runs` of `GET /status` allows: five for an admin, two for a public user), `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 diff --git a/tests/unit/agents/runtime/fakes.py b/tests/unit/agents/runtime/fakes.py index c41490f9ad6..cd541a57e9c 100644 --- a/tests/unit/agents/runtime/fakes.py +++ b/tests/unit/agents/runtime/fakes.py @@ -86,6 +86,8 @@ async def generate_content_async( self.requests.append(llm_request) queue = self.script.get(kind_of(llm_request), []) item = queue.pop(0) if queue else {"text": "fallback"} + if "raise" in item: # the call fails, as a quota (429) or an outage would + raise item["raise"] yield gemini_response(item) diff --git a/tests/unit/agents/runtime/test_admin_lane.py b/tests/unit/agents/runtime/test_admin_lane.py new file mode 100644 index 00000000000..b560a046774 --- /dev/null +++ b/tests/unit/agents/runtime/test_admin_lane.py @@ -0,0 +1,423 @@ +"""The admin tier (owner decision of 2026-11-01): its lane, its exemptions, and what keeps a fan-out fast. + +The BFF asserts the tier with `X-Anyplot-Tier: admin`; the agents service trusts it only behind +its caller check, reads anything else as `public`, and then gives the admin's runs the run +queue's admin lane (the shared five slots, a cap of five runs instead of two) and exempts +them from the per-user day caps and the chat turns' minute. The global day budget binds +every tier. A model quota that outlasts the SDK's retries ends a run as `busy`, and the gates +run off the event loop the runs in flight share. The run's usage telemetry says how long it +waited and how close its user came to a limit, never who the user is. +""" + +import asyncio +import json +import logging +import threading +from collections.abc import AsyncIterator, Iterator +from typing import Any + +import anthropic +import httpx +import pytest +from google.adk.workflow._errors import DynamicNodeFailError +from google.genai import errors as genai_errors +from pydantic import ValidationError + +from agents.anyplot import pipeline, telemetry, theme_render +from agents.anyplot.models import CLAUDE_MAX_RETRIES, AnthropicJudge, VertexClaude, error_chain, make_model, model_busy +from agents.anyplot.plugins.ledger import ATTRIBUTION_LOGGER, RequestLedger, UsageBook, budget_allows +from agents.anyplot.run_queue import RunQueue +from agents.anyplot.services import Services +from agents.anyplot.settings import AgentSettings, get_settings +from agents.main import Runtime, app, caller_tier, get_runtime + +from .fakes import ROOT_REPLY, SCATTER_PLAN, VERDICT_OK, default_script +from .test_run_queue import Clock +from .test_run_queue_flow import GatedBackend, interleaved_runs, until +from .test_service_flow import HEADERS, create_plot, headers_for, open_session, parse_sse, render_theme +from .test_stream import token + + +OTHER = "adm_fedcba9876543210" +ADMIN_HEADERS = {**HEADERS, "X-Anyplot-Tier": "admin"} + + +def quota_error() -> anthropic.RateLimitError: + """What the Anthropic SDK raises for a 429 its retries did not get past.""" + request = httpx.Request("POST", "https://eu-aiplatform.googleapis.com/v1/messages") + return anthropic.RateLimitError("rate limited", response=httpx.Response(429, request=request), body=None) + + +@pytest.fixture +def backend() -> GatedBackend: + """Fixture renders behind an open gate; a test that holds a run in flight closes it first.""" + gated = GatedBackend() + gated.gate.set() + return gated + + +@pytest.fixture +def clock() -> Clock: + return Clock() + + +@pytest.fixture +def runtime(clock: Clock) -> Iterator[Runtime]: + """The production limits but the start gap, which the fake clock would hold forever: five in flight, sixty + waiting, two runs per public user and five per admin.""" + queue = RunQueue( + concurrency=5, per_minute=6, max_wait_s=600, clock=clock, per_user=2, admin_per_user=5, start_gap_s=0 + ) + fresh = Runtime(queue=queue) + app.dependency_overrides[get_runtime] = lambda: fresh + yield fresh + app.dependency_overrides.clear() + + +@pytest.fixture +async def client(runtime: Runtime, services: Services) -> AsyncIterator[httpx.AsyncClient]: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://agents") as http: + yield http + + +def attribution_lines(caplog: pytest.LogCaptureFixture, hook: str) -> list[dict[str, Any]]: + lines = [json.loads(record.getMessage()) for record in caplog.records if record.name == ATTRIBUTION_LOGGER] + return [line for line in lines if line.get("hook") == hook] + + +class TestTier: + def test_only_exactly_admin_is_the_admin_tier(self) -> None: + assert caller_tier("admin") == "admin" + for value in (None, "", "public", "Admin", "ADMIN", "premium", "true"): + assert caller_tier(value) == "public", value + + async def test_status_reports_the_callers_own_cap(self, client: httpx.AsyncClient) -> None: + public = (await client.get("/v1/status", headers=HEADERS)).json() + admin = (await client.get("/v1/status", headers=ADMIN_HEADERS)).json() + other = (await client.get("/v1/status", headers={**HEADERS, "X-Anyplot-Tier": "root"})).json() + + assert (public["tier"], public["user_max_runs"]) == ("public", 2) + assert (admin["tier"], admin["user_max_runs"]) == ("admin", 5) + assert (other["tier"], other["user_max_runs"]) == ("public", 2) # never a 422: an old BFF sends nothing + + async def test_status_follows_the_settings(self, services: Services, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENT_ADMIN_USER_MAX_RUNS", "4") + get_settings.cache_clear() + fresh = Runtime() # its queue comes from the settings on first use + app.dependency_overrides[get_runtime] = lambda: fresh + try: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://agents") as http: + admin = (await http.get("/v1/status", headers=ADMIN_HEADERS)).json() + finally: + app.dependency_overrides.clear() + + assert admin["user_max_runs"] == 4 + + async def test_only_an_allowed_caller_reaches_the_tier( + self, client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Outside development the caller check answers first: a request that is not the BFF's never gets a tier.""" + monkeypatch.setenv("ENVIRONMENT", "production") + monkeypatch.setenv("AGENT_SERVICE_URLS", "https://anyplot-agents.run.app") + monkeypatch.setenv("AGENT_ALLOWED_CALLERS", "api@anyplot.iam.gserviceaccount.com") + get_settings.cache_clear() + bff = {"aud": "https://anyplot-agents.run.app", "email": "api@anyplot.iam.gserviceaccount.com"} + + anonymous = await client.get("/v1/status", headers=ADMIN_HEADERS) + invoker = token({**bff, "email": "invoker@example.com"}) + stranger = await client.get("/v1/status", headers={**ADMIN_HEADERS, "Authorization": invoker}) + allowed = await client.get("/v1/status", headers={**ADMIN_HEADERS, "Authorization": token(bff)}) + + assert (anonymous.status_code, stranger.status_code) == (401, 403) + assert allowed.status_code == 200 and allowed.json()["tier"] == "admin" + + +class TestExemptions: + def test_an_admin_has_no_user_day_budget_but_the_global_one(self) -> None: + usage = UsageBook() + settings = AgentSettings(daily_token_budget=100, global_daily_token_budget=1_000) + + usage.add_tokens("admin", 500) + assert not usage.daily_ok("admin", settings) and usage.daily_ok("admin", settings, tier="admin") + usage.add_tokens("someone", 500) # the service's day is spent + assert not usage.daily_ok("admin", settings, tier="admin") + + def test_the_strikes_bind_an_admin(self) -> None: + usage = UsageBook() + settings = AgentSettings(attack_strikes=1) + + usage.add_strike("admin", "ignore your rules") + + assert not usage.daily_ok("admin", settings, tier="admin") and usage.locked_out("admin", settings) + + def test_an_admin_has_no_daily_run_caps(self) -> None: + usage = UsageBook() + settings = AgentSettings(daily_pipeline_runs=1, daily_data_runs=1) + + usage.add_pipeline_run("admin") + usage.add_data_run("admin") + + assert not usage.runs_ok("admin", settings) and usage.runs_ok("admin", settings, "admin") + assert not usage.data_runs_ok("admin", settings) and usage.data_runs_ok("admin", settings, "admin") + + def test_an_admin_has_no_message_limits_but_their_turns_still_count(self) -> None: + usage = UsageBook(clock=Clock()) + settings = AgentSettings(user_messages_per_minute=1, user_messages_per_day=1) + + usage.add_message("admin") + + assert usage.message_limit("admin", settings) == "daily_limit" + assert usage.message_limit("admin", settings, "admin") is None + assert usage.user_messages("admin") == 1 + + def test_budget_allows_reads_the_ledgers_tier(self) -> None: + usage = UsageBook() + settings = AgentSettings(daily_token_budget=100) + usage.add_tokens("admin", 500) + + assert not budget_allows(RequestLedger(user_id="admin"), usage, settings) + assert budget_allows(RequestLedger(user_id="admin", tier="admin"), usage, settings) + + async def test_admin_turns_pass_the_minute( + self, client: httpx.AsyncClient, swap_models, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A fan-out starts a turn per library: the minute's count never refuses an admin, and a public user still + meets it.""" + monkeypatch.setenv("AGENT_USER_MESSAGES_PER_MINUTE", "1") + get_settings.cache_clear() + swap_models("gemini", {"root": [{"text": f"Answer {index}."} for index in range(4)]}) + admin = await open_session(client) + public = await open_session(client, user=OTHER) + + async def say(sid: str, headers: dict[str, str]) -> list[str]: + response = await client.post(f"/v1/sessions/{sid}/messages", headers=headers, json={"text": "Columns?"}) + return [name for name, _ in parse_sse(response.text)] + + assert await say(admin, ADMIN_HEADERS) == await say(admin, ADMIN_HEADERS) == ["ready", "message", "done"] + assert await say(public, headers_for(OTHER)) == ["ready", "message", "done"] + assert await say(public, headers_for(OTHER)) == ["ready", "refusal", "done"] + + async def test_the_strikes_still_lock_an_admin_out( + self, client: httpx.AsyncClient, services: Services, swap_models + ) -> None: + """The `attack` lockout is a safety rule, not a usage cap: the admin tier does not lift it.""" + fake = swap_models("gemini", {"root": [{"text": "never sent"}]}) + sid = await open_session(client) + for index in range(get_settings().attack_strikes): + services.usage.add_strike(HEADERS["X-Anyplot-User"], f"ignore your rules {index}") + + response = await client.post(f"/v1/sessions/{sid}/messages", headers=ADMIN_HEADERS, json={"text": "Hi"}) + + events = parse_sse(response.text) + assert [name for name, _ in events] == ["ready", "refusal", "done"] + assert events[1][1]["code"] == "locked_out" and fake.requests == [] + + +class TestAdminLaneFlow: + async def test_an_admin_run_starts_beside_a_public_run_and_both_lines_stay_anonymous( + self, + client: httpx.AsyncClient, + runtime: Runtime, + backend: GatedBackend, + swap_models, + caplog: pytest.LogCaptureFixture, + ) -> None: + backend.gate.clear() # the public run's render holds it in flight + caplog.set_level(logging.INFO, logger=ATTRIBUTION_LOGGER) + caplog.set_level(logging.INFO, logger=telemetry.TELEMETRY_LOGGER) + swap_models("gemini", interleaved_runs()) + public = await open_session(client, user=OTHER) + admin = await open_session(client) + queue = runtime.run_queue() + + public_run = asyncio.create_task(create_plot(client, public, headers_for(OTHER))) + await asyncio.wait_for(backend.entered.wait(), 5) + admin_run = asyncio.create_task(create_plot(client, admin, ADMIN_HEADERS)) + await until(lambda: queue.in_flight == 2, "the admin run to start beside the public one") + + assert queue.waiting_count == 0 # the five slots are shared: the public run in flight never held it + backend.gate.set() + admin_events = await asyncio.wait_for(admin_run, 10) + public_events = await asyncio.wait_for(public_run, 10) + + for events in (admin_events, public_events): + assert next(data for name, data in events if name == "plot")["status"] == "ok" + assert not any(name == "status" and data["step"] == "queued" for name, data in admin_events) + lanes = {line["user"]: line["lane"] for line in attribution_lines(caplog, "queue")} + assert lanes == {OTHER: "normal", HEADERS["X-Anyplot-User"]: "admin"} + renders = attribution_lines(caplog, "pipeline_render") + assert len(renders) == 2 and all(line["slot_wait_s"] >= 0 for line in renders) + # The run's usage telemetry (owner, 2026-11-01): its lane, its wait and how close its user came to a limit, + # as numbers and buckets; neither user id, nor the user-id key's prefix, appears anywhere in a line. + raw = [record.getMessage() for record in caplog.records if record.name == telemetry.TELEMETRY_LOGGER] + runs = sorted((json.loads(line) for line in raw if '"pipeline_run"' in line), key=lambda line: line["lane"]) + assert [(run["lane"], run["queue_position"], run["queue_wait_ms"]) for run in runs] == [ + ("admin", 0, 0), + ("public", 0, 0), + ] + assert all((run["user_runs_today"], run["user_tokens_today"]) == ("1", "lt25") for run in runs) + assert not any(OTHER in line or HEADERS["X-Anyplot-User"] in line or "adm_" in line for line in raw) + + +class TestLimitTelemetry: + """Whether anyone waits long or comes close to a limit, without saying who (owner, 2026-11-01).""" + + NEW_FIELDS = ("lane", "queue_wait_ms", "queue_position", "user_runs_today", "user_tokens_today") + + def test_the_runs_of_the_day_fall_into_the_owners_buckets(self) -> None: + ordinals = {1: "1", 2: "2_5", 5: "2_5", 6: "6_10", 10: "6_10", 11: "11_20", 20: "11_20", 21: "21_30"} + ordinals |= {30: "21_30", 31: "31_40", 40: "31_40", 41: "41_plus", 500: "41_plus"} + + assert {ordinal: telemetry.runs_today_bucket(ordinal) for ordinal in ordinals} == ordinals + assert telemetry.runs_today_bucket(None) is None and telemetry.runs_today_bucket(0) is None + + def test_the_spent_budget_falls_into_the_owners_buckets(self) -> None: + shares = {0.0: "lt25", 0.249: "lt25", 0.25: "25_50", 0.5: "50_75", 0.75: "75_90", 0.9: "90_100", 1.0: "90_100"} + shares |= {1.01: "over", 3.0: "over"} + + assert {share: telemetry.tokens_today_bucket(share) for share in shares} == shares + assert telemetry.tokens_today_bucket(None) is None + + def test_the_new_fields_validate(self) -> None: + run = telemetry.PipelineRun( + run_index=1, + base="catalogue", + status="ok", + lane="admin", + queue_wait_ms=31_400, + queue_position=4, + user_runs_today="2_5", + user_tokens_today="over", + ) + + assert run.model_dump(include=set(self.NEW_FIELDS)) == { + "lane": "admin", + "queue_wait_ms": 31_400, + "queue_position": 4, + "user_runs_today": "2_5", + "user_tokens_today": "over", + } + + @pytest.mark.parametrize("field", NEW_FIELDS) + def test_no_new_field_can_carry_an_id(self, field: str) -> None: + """A closed set or a bounded integer: a user id, its hash or a key never validates.""" + for value in ("adm_0123456789abcdef", "0123456789abcdef", -1, 10**12): + with pytest.raises(ValidationError): + telemetry.PipelineRun(run_index=1, base="catalogue", status="ok", **{field: value}) + + async def test_an_admins_run_limit_lands_in_the_chat_turn( + self, client: httpx.AsyncClient, runtime: Runtime, swap_models, caplog: pytest.LogCaptureFixture + ) -> None: + """The limit refusals were recorded before; the admin lane's land in the same `chat_turn.error`.""" + caplog.set_level(logging.INFO, logger=telemetry.TELEMETRY_LOGGER) + swap_models("gemini", {"root": [{"call": "plot_pipeline", "args": {}}, {"text": ROOT_REPLY}]}) + sid = await open_session(client) + for index in range(5): # the admin's five places, held by their other windows + runtime.run_queue().submit(HEADERS["X-Anyplot-User"], f"s-other-{index}", lane="admin") + + events = await create_plot(client, sid, ADMIN_HEADERS) + + assert ("error", "run_limit") in [(name, data.get("code")) for name, data in events] + turns = [json.loads(r.getMessage()) for r in caplog.records if r.name == telemetry.TELEMETRY_LOGGER] + assert [turn["error"] for turn in turns if turn["event"] == "chat_turn"] == ["run_limit"] + + +class TestBusy: + def test_a_quota_or_an_overload_is_told_apart_from_a_fault(self) -> None: + quota = quota_error() + gemini_quota = genai_errors.ClientError(429, {"error": {"code": 429, "status": "RESOURCE_EXHAUSTED"}}) + overloaded = anthropic.OverloadedError( + "overloaded", response=httpx.Response(529, request=quota.request), body=None + ) + wrapped = DynamicNodeFailError(message="node failed", error=quota, error_node_path="adapter") + try: + raise RuntimeError("the adapter failed") from overloaded + except RuntimeError as exc: + caused = exc + unrelated = ValueError("an unrelated failure while handling it") + unrelated.__context__ = quota # what a raise inside the `except` of the quota leaves: an implicit context only + unavailable = anthropic.InternalServerError( + "unavailable", response=httpx.Response(503, request=quota.request), body=None + ) + + for busy in (quota, gemini_quota, overloaded, wrapped, caused): + assert model_busy(busy), busy + for fault in (unrelated, unavailable, ValueError("429")): + assert not model_busy(fault), fault + + def test_the_error_chain_ends_at_the_innermost_cause(self) -> None: + quota = quota_error() + wrapped = DynamicNodeFailError(message="node failed", error=quota, error_node_path="adapter") + + assert error_chain(wrapped) == [wrapped, quota] + assert error_chain(quota) == [quota] + + async def test_a_quota_after_the_retries_ends_the_run_as_busy( + self, client: httpx.AsyncClient, swap_models, caplog: pytest.LogCaptureFixture + ) -> None: + caplog.set_level(logging.INFO, logger=ATTRIBUTION_LOGGER) + swap_models( + "gemini", + { + "root": [{"call": "plot_pipeline", "args": {}}, {"text": ROOT_REPLY}], + "adapter": [{"raise": quota_error()}], + }, + ) + sid = await open_session(client) + + events = await create_plot(client, sid, ADMIN_HEADERS) + + plot = next(data for name, data in events if name == "plot") + assert (plot["status"], plot["reason"]) == ("failed", "busy") + result = attribution_lines(caplog, "pipeline_result")[-1] + assert (result["reason"], result["error"]) == ("busy", "RateLimitError") # never ADK's wrapper + + async def test_any_other_failure_stays_an_error(self, client: httpx.AsyncClient, swap_models) -> None: + swap_models( + "gemini", + { + "root": [{"call": "plot_pipeline", "args": {}}, {"text": ROOT_REPLY}], + "adapter": [{"raise": RuntimeError("boom")}], + }, + ) + sid = await open_session(client) + + plot = next(data for name, data in await create_plot(client, sid) if name == "plot") + + assert (plot["status"], plot["reason"]) == ("failed", "error") + + def test_root_adapter_and_reviewer_retry_four_times_and_the_judge_never(self) -> None: + settings = AgentSettings(location="global", project="anyplot-eval") + + for kind in ("root", "adapter", "reviewer"): + model = make_model(kind, settings) + assert isinstance(model, VertexClaude) + assert model._anthropic_client.max_retries == CLAUDE_MAX_RETRIES == 4 + judge = AnthropicJudge(model="claude-haiku-5-5", project="anyplot-eval", region="global", timeout_s=4.0) + assert judge._client.max_retries == 0 + + +class TestGatesOffTheLoop: + async def test_the_pipeline_and_the_theme_toggle_run_their_gates_in_a_worker_thread( + self, client: httpx.AsyncClient, swap_models, monkeypatch: pytest.MonkeyPatch + ) -> None: + loop_thread = threading.get_ident() + threads: list[tuple[str, int]] = [] + + def recording(where: str, evaluate: Any) -> Any: + def wrapped(*args: Any, **kwargs: Any) -> Any: + threads.append((where, threading.get_ident())) + return evaluate(*args, **kwargs) + + return wrapped + + monkeypatch.setattr(pipeline, "evaluate", recording("pipeline", pipeline.evaluate)) + monkeypatch.setattr(theme_render, "evaluate", recording("toggle", theme_render.evaluate)) + swap_models("gemini", default_script(VERDICT_OK, [SCATTER_PLAN])) + sid = await open_session(client) + + await create_plot(client, sid) + assert (await render_theme(client, sid, "dark")).status_code == 200 + + assert [where for where, _ in threads] == ["pipeline", "toggle"] + assert all(thread != loop_thread for _, thread in threads) diff --git a/tests/unit/agents/runtime/test_dataset_copy.py b/tests/unit/agents/runtime/test_dataset_copy.py index 702cbcc06ae..9e9c172bee3 100644 --- a/tests/unit/agents/runtime/test_dataset_copy.py +++ b/tests/unit/agents/runtime/test_dataset_copy.py @@ -420,6 +420,26 @@ async def test_copies_are_counted_per_user( assert refused.dataset_id is None +@pytest.mark.parametrize("cap", ["COPIES_PER_MINUTE", "COPIES_PER_DAY"]) +async def test_an_admin_is_exempt_from_the_copy_caps( + client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch, cap: str +) -> None: + """Owner decision of 2026-11-01: three windows of `.plot_all()` copy 45 datasets within a minute; only the + store's byte cap bounds an admin's copies.""" + monkeypatch.setattr(ledger, cap, 1) + source = await open_session(client) + await paste(client, source) + targets = [await open_session(client, library=library, fanout=True) for library in ("seaborn", "matplotlib")] + admin = {**headers(), "X-Anyplot-Tier": "admin"} + + answers = [ + await client.post(f"/v1/sessions/{target}/dataset/copy", headers=admin, json={"from_session": source}) + for target in targets + ] + + assert [answer.status_code for answer in answers] == [200, 200] + + async def test_a_full_store_refuses_the_copy_and_keeps_nothing( client: httpx.AsyncClient, runtime: Runtime, services: Services ) -> None: diff --git a/tests/unit/agents/runtime/test_run_queue.py b/tests/unit/agents/runtime/test_run_queue.py index 6c587edaf63..516eafcd45c 100644 --- a/tests/unit/agents/runtime/test_run_queue.py +++ b/tests/unit/agents/runtime/test_run_queue.py @@ -125,10 +125,12 @@ def test_next_change_at_without_a_time_bound(self, clock: Clock) -> None: class TestCapacity: - def test_the_defaults_hold_ten_waiting_entries(self) -> None: + def test_the_defaults_hold_sixty_waiting_entries(self) -> None: + """Owner decision of 2026-11-01: five in flight, one start every 10 s, so 6 a minute times 600 s / 60.""" queue = RunQueue.from_settings(AgentSettings()) - assert (queue.concurrency, queue.per_minute, queue.max_wait_s, queue.capacity) == (1, 1, 600, 10) + assert (queue.concurrency, queue.per_minute, queue.max_wait_s, queue.capacity) == (5, 6, 600, 60) + assert queue.start_gap_s == 10 def test_a_full_queue_refuses_a_new_entry(self, clock: Clock) -> None: queue = make(clock, per_minute=1, max_wait_s=120) # two entries wait at most two minutes @@ -163,8 +165,10 @@ def test_a_premium_entry_counts_only_the_entries_ahead_of_it(self, clock: Clock) 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_default_caps_are_two_and_five_for_an_admin(self) -> None: + queue = RunQueue.from_settings(AgentSettings()) + + assert (queue.user_cap("normal"), queue.user_cap("premium"), queue.user_cap("admin")) == (2, 2, 5) 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) @@ -210,6 +214,145 @@ def test_without_a_cap_a_user_may_hold_any_number(self, clock: Clock) -> None: assert queue.per_user is None and queue.user_entries("u") == 5 +def production(clock: Clock) -> RunQueue: + """The queue at the production defaults (owner decision of 2026-11-01): 5 in flight, one start every 10 s, + 60 waiting entries, 2 runs per public user and 5 per admin.""" + return RunQueue.from_settings(AgentSettings(), clock=clock) + + +class TestStartGap: + """`AGENT_RUNS_PER_MINUTE` 6 spaces the starts 10 s apart: a fan-out's runs never start, or render, together.""" + + def test_starts_are_spaced_by_the_gap_even_with_free_slots(self, clock: Clock) -> None: + queue = production(clock) + first, second, third = (queue.submit(user, f"s-{user}") for user in ("a", "b", "c")) + + assert (first.running, second.waiting, third.waiting) == (True, True, True) + assert queue.next_change_at(clock()) == clock() + 10 + clock.advance(10) + queue.pump() + assert second.running and third.waiting + clock.advance(9.9) + queue.pump() + assert third.waiting + clock.advance(0.1) + queue.pump() + assert third.running and queue.in_flight == 3 + + def test_without_a_gap_the_window_alone_decides(self, clock: Clock) -> None: + queue = make(clock, concurrency=3) + + tickets = [queue.submit(user, f"s-{user}") for user in ("a", "b", "c")] + + assert queue.start_gap_s == 0 and all(ticket.running for ticket in tickets) + + def test_the_gap_is_refused_below_zero(self, clock: Clock) -> None: + with pytest.raises(ValueError): + RunQueue(concurrency=1, per_minute=1, max_wait_s=600, clock=clock, start_gap_s=-1) + + +class TestLanes: + """The admin lane (owner decision of 2026-11-01): the same concurrency, rate and order as everyone, a cap of 5.""" + + def test_the_admin_lane_shares_the_concurrency_and_the_rate(self, clock: Clock) -> None: + queue = production(clock) + public = queue.submit("u", "s-u") + admin = [queue.submit("admin", f"s-{index}", lane="admin") for index in range(5)] + + for _ in range(4): + clock.advance(10) + queue.pump() + + assert public.running and [ticket.state for ticket in admin] == ["running"] * 4 + ["waiting"] + clock.advance(10) + queue.pump() + assert admin[-1].waiting # five in flight: the gap passed, but no slot is free + queue.release(public) + assert admin[-1].running and queue.in_flight == 5 + + def test_each_lane_has_its_own_per_user_cap(self, clock: Clock) -> None: + queue = production(clock) + public = [queue.submit("u", f"s-u{index}") for index in range(2)] + admin = [queue.submit("admin", f"s-a{index}", lane="admin") for index in range(5)] + + with pytest.raises(QueueUserLimit): + queue.submit("u", "s-u2") + with pytest.raises(QueueUserLimit): + queue.submit("admin", "s-a5", lane="admin") + assert len(public) == 2 and len(admin) == 5 and queue.user_entries("admin") == 5 + + def test_premium_goes_first_and_admin_keeps_its_arrival_order(self, clock: Clock) -> None: + queue = make(clock) + queue.submit("x", "s-x") + normal = queue.submit("n", "s-n") + admin = queue.submit("admin", "s-a", lane="admin") + premium = queue.submit("p", "s-p", lane="premium") + + positions = [queue.position(ticket) for ticket in (premium, normal, admin)] + assert [position.position for position in positions if position] == [1, 2, 3] + + def test_the_admin_entry_records_where_it_stood_and_how_long_it_waited(self, clock: Clock) -> None: + """The usage telemetry's `queue_position` and `queue_wait_ms` (`pipeline_run`): numbers, never a user.""" + queue = production(clock) + first = queue.submit("u", "s-u") + second = queue.submit("admin", "s-a", lane="admin") + + assert (first.submitted_position, first.waited_s) == (0, 0.0) + assert (second.submitted_position, second.waited_s) == (1, None) + clock.advance(10) + queue.pump() + assert second.waited_s == 10 + + +class TestFanOut: + """The owner's check of 2026-11-01: a 15-library fan-out never loses a library to `capacity` or `run_limit`.""" + + @staticmethod + def fan_out(queue: RunQueue, clock: Clock, *, libraries: int = 15, others: int = 0) -> tuple[int, int, float]: + """The page's pool (`FanOut.tsx`): one admin keeps `user_max_runs` libraries submitted, a new one each time a + run ends; each run takes 60 to 240 s once it starts. `others` public users submit two runs each at the + start. Returns the libraries started, the refusals, and the minutes until the last run ended.""" + durations = [60 + (index * 37) % 181 for index in range(libraries)] # 60 to 240 s, spread + cap = queue.user_cap("admin") or libraries + for user in range(others): + for run in range(2): + queue.submit(f"public-{user}", f"s-p{user}-{run}") + pending, live, started, refused, start = list(range(libraries)), {}, 0, 0, clock() + while pending or live: + while pending and len(live) < cap: + library = pending.pop(0) + try: + live[library] = queue.submit("admin", f"s-{library}", lane="admin") + except QueueFull, QueueUserLimit: + refused += 1 + clock.advance(1) + queue.pump() + for library, ticket in list(live.items()): + if ticket.state == "expired": + refused += 1 + del live[library] + elif ticket.running and clock() - (ticket.started_at or 0) >= durations[library]: + started += 1 + queue.release(ticket) + del live[library] + for entry in list(queue._running): # the public runs end after 120 s + if entry.user.startswith("public") and clock() - (entry.started_at or 0) >= 120: + queue.release(entry) + return started, refused, (clock() - start) / 60 + + def test_fifteen_libraries_of_one_admin_all_start_and_none_is_refused(self, clock: Clock) -> None: + started, refused, minutes = self.fan_out(production(clock), clock) + + assert (started, refused) == (15, 0) + assert minutes < 10 # five at a time, one start every 10 s: about three rounds of runs + + def test_the_fanout_still_loses_nothing_beside_public_users(self, clock: Clock) -> None: + """Three public users hold six runs when the fan-out starts: the admin's entries wait, none is refused.""" + started, refused, _ = self.fan_out(production(clock), clock, others=3) + + assert (started, refused) == (15, 0) + + class TestMaxWait: def test_an_entry_expires_at_the_maximum_wait(self, clock: Clock) -> None: queue = make(clock, max_wait_s=600) diff --git a/tests/unit/agents/runtime/test_run_queue_flow.py b/tests/unit/agents/runtime/test_run_queue_flow.py index 26666b7491c..051b45c7c0b 100644 --- a/tests/unit/agents/runtime/test_run_queue_flow.py +++ b/tests/unit/agents/runtime/test_run_queue_flow.py @@ -129,7 +129,7 @@ async def test_the_second_user_waits_in_the_queue_then_runs( assert root_calls(fake) == 2 status = (await client.get("/v1/status", headers=HEADERS)).json() assert (status["waiting"], status["in_flight"]) == (1, 1) - assert status["user_max_runs"] == get_settings().user_max_runs + assert status["user_max_runs"] == queue.per_user # the caller's own cap, as this queue holds it backend.gate.set() first_events = await asyncio.wait_for(first_run, 10) assert next(data for name, data in first_events if name == "plot")["status"] == "ok" diff --git a/tests/unit/agents/runtime/test_telemetry.py b/tests/unit/agents/runtime/test_telemetry.py index e2ff405fa04..9f3b0302735 100644 --- a/tests/unit/agents/runtime/test_telemetry.py +++ b/tests/unit/agents/runtime/test_telemetry.py @@ -684,7 +684,9 @@ async def test_a_cancelled_turn_records_the_language_the_turn_set( judge.script = [JudgeVerdict(verdict="in_scope", lang="de")] sid = await open_session(client) - response = await post_message(sid, MessageBody(text="Mach die Punkte größer"), USER, runtime, "req-cancelled") + response = await post_message( + sid, MessageBody(text="Mach die Punkte größer"), USER, "public", runtime, "req-cancelled" + ) stream = cast(AsyncIterator[str], response.body_iterator) chunks = [] async for chunk in stream: @@ -900,3 +902,16 @@ async def test_the_run_prompt_hashes_are_the_bundle_hashes( assert run["reviewer_prompt"] == bundle["reviewer"] # the conftest's checklist mode assert run_hashes("matplotlib", "catalogue")["reviewer_prompt"] == bundle_hashes()["reviewer_catalogue"] assert bundle["reviewer_catalogue"] != bundle["reviewer"] + + +@pytest.mark.parametrize( + ("event", "view"), [(telemetry.PipelineRun, "pipeline_runs"), (telemetry.ChatTurn, "chat_turns")] +) +def test_every_field_has_its_typed_column_in_the_views(event: type[telemetry._Event], view: str) -> None: + """`agents/telemetry/views/02_event_views.sql` reads every field of the line, so a field added to the model (the + limit fields of 2026-11-01 among them) cannot be forgotten in the view a report reads.""" + sql = (Path(__file__).resolve().parents[4] / "agents" / "telemetry" / "views" / "02_event_views.sql").read_text() + body = sql.split(f"`anyplot.agent_telemetry.{view}` AS", 1)[1].split("CREATE OR REPLACE VIEW", 1)[0] + + missing = [name for name in event.model_fields if name != "event" and not re.search(rf"payload\.{name}\b", body)] + assert missing == [], f"{view} lacks {missing}" diff --git a/tests/unit/agents/test_image.py b/tests/unit/agents/test_image.py index ad8a9ad362c..7d733bb2c74 100644 --- a/tests/unit/agents/test_image.py +++ b/tests/unit/agents/test_image.py @@ -551,7 +551,8 @@ def test_deploy_flags(self) -> None: "--memory=2Gi", "--min-instances=${_MIN_INSTANCES}", "--max-instances=1", - "--concurrency=20", + # The queue's 60 waiting streams and 5 runs, plus the other routes (2026-11-01) + "--concurrency=80", "--timeout=1200", "TRAFFIC=(--no-traffic)", "--tag=candidate", diff --git a/tests/unit/agents/test_settings.py b/tests/unit/agents/test_settings.py index 12da68e493a..503279c1110 100644 --- a/tests/unit/agents/test_settings.py +++ b/tests/unit/agents/test_settings.py @@ -33,10 +33,10 @@ def test_defaults_are_the_pinned_production_values(self) -> None: assert settings.judge_timeout_s == 4.0 assert settings.render_concurrency == 1 # serial renders: one sandbox per 4 GiB instance assert settings.watermark is True # served PNGs carry the footer strip - 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) + # Owner decision of 2026-11-01: five in flight for the whole instance, one start every 10 s for every lane, + # two runs per public user and five per admin across sessions. + assert (settings.run_concurrency, settings.runs_per_minute, settings.queue_max_wait_s) == (5, 6, 600) + assert (settings.user_max_runs, settings.admin_user_max_runs) == (2, 5) assert settings.service_urls == [] assert settings.dev_fixture is None assert settings.libraries == ["matplotlib", "seaborn"] @@ -46,9 +46,10 @@ def test_defaults_are_the_pinned_production_values(self) -> None: assert settings.max_attempts == 2 # the first attempt and at most one repair round assert settings.max_llm_calls == 16 # two rounds of adapt, self-review and retry, two reviews, the root assert settings.request_token_budget == 800_000 # never cuts a live-flow round short (CLI max ~458k) - assert settings.daily_token_budget == 8_000_000 # about 30 median live-flow runs - assert settings.daily_pipeline_runs == 40 - assert settings.global_daily_token_budget == 24_000_000 # three users at their daily budget + # Owner decision of 2026-11-01: a public user's day is about $1 and 50 runs, the service's about $10. + assert settings.daily_token_budget == 10_000_000 + assert settings.daily_pipeline_runs == 50 + assert settings.global_daily_token_budget == 100_000_000 # the runaway guard of every tier assert settings.render_timeout_s == 60 assert settings.request_deadline_s == 540 # a round with self-review takes about 60 s; never cut short assert settings.soft_deadline_s == 480 @@ -126,6 +127,14 @@ def test_queue_settings_come_from_the_environment(self, monkeypatch: pytest.Monk assert (settings.run_concurrency, settings.runs_per_minute, settings.queue_max_wait_s) == (2, 3, 300) + def test_the_admin_cap_comes_from_the_environment_and_is_positive(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENT_ADMIN_USER_MAX_RUNS", "3") + assert AgentSettings().admin_user_max_runs == 3 + + monkeypatch.setenv("AGENT_ADMIN_USER_MAX_RUNS", "0") + with pytest.raises(ValidationError): + AgentSettings() + @pytest.mark.parametrize(("value", "expected"), [("false", False), ("0", False), ("true", True), ("1", True)]) def test_the_footer_strip_switch_comes_from_the_environment( self, monkeypatch: pytest.MonkeyPatch, value: str, expected: bool diff --git a/tests/unit/api/test_agent_router.py b/tests/unit/api/test_agent_router.py index 1c841283d73..52d352acedb 100644 --- a/tests/unit/api/test_agent_router.py +++ b/tests/unit/api/test_agent_router.py @@ -369,6 +369,7 @@ def test_real_token_gate_passes_through(self, agent_on, upstream) -> None: assert response.status_code == 200 token_user = derive_user_id(KEY.encode(), AdminIdentity(email=None, via="token")) assert upstream.requests[0].headers["X-Anyplot-User"] == token_user + assert upstream.requests[0].headers["X-Anyplot-Tier"] == "admin" # the token path is an admin too @pytest.mark.parametrize(("method", "path", "body"), ALL_ROUTES) def test_every_route_404_when_disabled( @@ -467,6 +468,8 @@ def test_upstream_headers_and_request_id(self, client, upstream) -> None: sent = upstream.requests[0] assert str(sent.url) == f"{SERVICE_URL}/v1/status" assert sent.headers["X-Anyplot-User"] == derive_user_id(KEY.encode(), ADMIN) + # The tier comes from the admin gate's identity: the agents service's admin lane (2026-11-01). + assert sent.headers["X-Anyplot-Tier"] == "admin" assert sent.headers["X-Request-Id"] == response.headers["X-Request-Id"] assert "Authorization" not in sent.headers # localhost: no ID token assert response.headers["Cache-Control"] == "private, no-store" @@ -557,6 +560,16 @@ def test_create_session_forwards_the_fanout_source_only(self, client, upstream, assert refused.status_code == 422, source assert len(upstream.requests) == 1 + def test_the_tier_comes_from_the_gate_never_from_the_client(self, client, upstream, db_with_spec) -> None: + """The upstream headers are built from the verified identity: a browser's own tier header never travels.""" + upstream.handler = lambda request: httpx.Response(200, json={"session_id": "s1", "eligibility": "clean"}) + body = {"spec_id": "scatter-basic", "library": "matplotlib", "locale": "en"} + response = client.post( + "/debug/agent/sessions", json=body, headers={**CLIENT_HEADERS, "X-Anyplot-Tier": "public"} + ) + assert response.status_code == 200 + assert upstream.requests[0].headers.get_list("X-Anyplot-Tier") == ["admin"] + def test_create_session_404_without_snapshot(self, client, upstream, db_with_spec) -> None: db_with_spec.get_by_id_with_code.return_value = None response = client.post(