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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The model is **Claude Haiku 5.5 on Vertex AI** (`claude-haiku-5-5`) by default.
| `main.py` | The `anyplot-agents` FastAPI service: the `/v1` routes the BFF (`api/routers/agent.py`) calls, the caller check, the run registry behind `409 run_active`, in-memory session and artifact services, and the idle sweeper |
| `stream.py` | The `anyplot/1` stream translator: ADK events in, sanitised `ready`, `status` (including `queued`), `message`, `plot`, `refusal`, `error` and `done` events out |
| `Dockerfile`, `cloudbuild.yaml` | The `anyplot-agents` image (the `agents` extra, the source tree with its prompts and fonts, uid 1000) and its deploy: build, a `candidate` revision without traffic, the IAM and `/v1/status` smoke, promote. The config's header lists the owner's bootstrap steps |
| `anyplot/run_queue.py` | The run queue in front of the sandbox runs (since 2026-10-14 a chat turn starts at once, and only its `plot_pipeline` or `run_data_code` call waits, mid-turn): one run in flight, one start a minute, a 600-second maximum wait, a `premium` lane that nothing sets yet |
| `anyplot/run_queue.py` | The run queue in front of the sandbox runs (since 2026-10-14 a chat turn starts at once, and only its `plot_pipeline` or `run_data_code` call waits, mid-turn): one run in flight, one start a minute, a 600-second maximum wait, at most three runs per user across sessions (since 2026-10-21 a user's browser windows work in parallel, and only their sandbox runs share the queue), a `premium` lane that nothing sets yet |
| `anyplot/theme_render.py` | The theme toggle: renders another theme of a finished version from its stored run form, behind waiting pipeline renders, with no model call |
| `anyplot/agent.py` | The root agent `anyplot` with its session block (`session_context`, appended to the system instruction by `add_session_context`, never to the user's turn), the `ALL_AGENTS` registry and `app` (the ADK `App` with its plugins), which `adk web` loads |
| `anyplot/models.py` | The only place that builds a model or a model client: `make_model`, `make_content_config` and `make_judge_client`, for Claude on Vertex AI and for Gemini |
Expand Down Expand Up @@ -85,6 +85,7 @@ Three rules hold for everything here:
| `AGENT_RUN_CONCURRENCY` | `1` | Sandbox runs in flight (a turn's `plot_pipeline` or `run_data_code` call, holding its entry until the turn ends); the run queue holds the rest. A chat turn without one never enters the queue |
| `AGENT_RUNS_PER_MINUTE` | `1` | Sandbox runs that may start within any 60 seconds (a sliding window), one per turn |
| `AGENT_QUEUE_MAX_WAIT_S` | `600` | Longest wait in the run queue, after which the turn ends with `error{code:"capacity"}`; the queue holds rate x wait / 60 entries (10), and a sandbox run beyond that ends its turn the same way. Through the BFF the whole turn, this wait included, ends by `AGENT_TURN_MAX_S` (1,190 s, see `docs/reference/api.md`), which the root's first call, the full wait and the 540 s run fit into |
| `AGENT_USER_MAX_RUNS` | `3` | Sandbox runs one user may have queued or running at the same time, across all their sessions (browser windows); the turn whose run would be one more ends with `error{code:"run_limit"}`, before the queue's length is checked. Everything else runs per session at once: a session answers `409 run_active` only to a second thing of its own |
| `AGENT_USER_MESSAGES_PER_MINUTE` | `10` | Chat turns (typed messages and actions) per user within any 60 seconds; one more gets the fixed `rate_limited` refusal before the scope judge, so it costs no model call |
| `AGENT_USER_MESSAGES_PER_DAY` | `200` | Chat turns per user and UTC day; one more gets the fixed `daily_limit` refusal before the scope judge |
| `AGENT_SESSION_REFUSALS` | `3` | Scope-judge refusals of type `out_of_scope` or `attack` (never `needs_data`; `other_library` only with `AGENT_OTHER_LIBRARY_COUNTS`) after which a session is closed: that refusal and every later turn answer `session_closed`, a paste `403 session_closed`, and the user reloads the page for a new session |
Expand Down
34 changes: 32 additions & 2 deletions agents/anyplot/run_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@
`capacity`. Admission therefore promises a place, not a start. A run holds its slot
until its turn ends, the root's closing reply included.

**Per user.** A user may hold at most `AGENT_USER_MAX_RUNS` entries (default 3),
waiting and running together, across all their sessions (owner decision of
2026-10-21: each browser window is a session of its own, and a user's windows work in
parallel, so the cap bounds what one user puts into the shared queue). `submit` refuses
one more with `QueueUserLimit`, which the route ends as `error{code:"run_limit"}`, before
it looks at the queue's length: a user at the cap learns that from their own runs, not
from `capacity`. The cap is checked in `submit` because the queue already holds every
entry with its user; it changes neither the queue's capacity nor its order, and an
entry the cap refuses never took a place, a start or a position.

**Lanes.** An entry carries a lane: `premium` entries go before every `normal` entry,
first come first served within a lane. Nothing sets `premium` yet; it is the lane for
users who later pay for their own tokens. It bypasses the normal lane only: the
Expand Down Expand Up @@ -74,6 +84,10 @@ class QueueFull(Exception):
"""The entry would wait longer than the maximum; nothing was queued."""


class QueueUserLimit(Exception):
"""The user already holds the most entries a user may hold, waiting or running; nothing was queued."""


class QueueTimeout(Exception):
"""The entry waited the maximum and left the queue without running."""

Expand Down Expand Up @@ -127,13 +141,18 @@ def __init__(
max_wait_s: float,
clock: Callable[[], float] = time.monotonic,
window_s: float = RATE_WINDOW_S,
per_user: int | None = None,
) -> None:
if concurrency < 1 or per_minute < 1 or max_wait_s <= 0 or window_s <= 0:
raise ValueError("concurrency, rate, maximum wait and window must be positive")
if per_user is not None and per_user < 1:
raise ValueError("the per-user cap must be positive")
self.concurrency = concurrency
self.per_minute = per_minute
self.max_wait_s = max_wait_s
self.window_s = window_s
self.per_user = per_user
"""Entries one user may hold, waiting and running (`AGENT_USER_MAX_RUNS`); None sets no cap."""
self._clock = clock
self._waiting: list[Ticket] = []
self._running: list[Ticket] = []
Expand All @@ -147,6 +166,7 @@ def from_settings(cls, settings: AgentSettings, clock: Callable[[], float] = tim
per_minute=settings.runs_per_minute,
max_wait_s=settings.queue_max_wait_s,
clock=clock,
per_user=settings.user_max_runs,
)

def now(self) -> float:
Expand All @@ -166,10 +186,20 @@ def waiting_count(self) -> int:
def in_flight(self) -> int:
return len(self._running)

def user_entries(self, user: str) -> int:
"""The user's entries in the queue, waiting and running, in every session."""
return sum(1 for entry in (*self._waiting, *self._running) if entry.user == user)

def submit(self, user: str, session_id: str, lane: Lane = "normal") -> Ticket:
"""Queue a run, or start it at once; raises `QueueFull` when it would wait past the maximum."""
"""Queue a run, or start it at once.

Raises `QueueUserLimit` when the user already holds `per_user` entries (checked first), and `QueueFull`
when the run would wait past the maximum.
"""
now = self._clock()
self._pump(now)
self._pump(now) # an entry that expired just now no longer counts against its user
if self.per_user is not None and self.user_entries(user) >= self.per_user:
raise QueueUserLimit(f"the user already holds {self.per_user} entries")
ticket = Ticket(user=user, session_id=session_id, lane=lane, seq=next(self._seq), enqueued_at=now)
position = 1 + sum(1 for other in self._waiting if other.order < ticket.order)
if position > self.capacity and not (position == 1 and self._can_start(now)):
Expand Down
7 changes: 7 additions & 0 deletions agents/anyplot/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
| `AGENT_RUN_CONCURRENCY` | `1` | Sandbox runs (a `plot_pipeline` or `run_data_code` call, held until its turn ends) in flight per instance; the run queue holds the rest. A chat turn without one never enters the queue |
| `AGENT_RUNS_PER_MINUTE` | `1` | Sandbox runs that may start within any 60 seconds (a sliding window); one start per turn |
| `AGENT_QUEUE_MAX_WAIT_S` | `600` | Longest wait in the run queue; it also sizes the queue (`AGENT_RUNS_PER_MINUTE` x this / 60 entries) |
| `AGENT_USER_MAX_RUNS` | `3` | Sandbox runs one user may have queued or running at the same time, across all their sessions (browser windows); one more ends its turn with `error{code:"run_limit"}`. Everything else in a session (chat turns, pastes, data steps, the theme toggle, file conversions) runs at once, whatever the user's other sessions do |
| `AGENT_USER_MESSAGES_PER_MINUTE` | `10` | Chat turns (typed messages and actions) per user within any 60 seconds; more get the `rate_limited` refusal before the scope judge runs |
| `AGENT_USER_MESSAGES_PER_DAY` | `200` | Chat turns per user and UTC day; more get the `daily_limit` refusal before the scope judge runs |
| `AGENT_SESSION_REFUSALS` | `3` | Scope-judge refusals (`out_of_scope` or `attack`, not `needs_data`, and `other_library` only with `AGENT_OTHER_LIBRARY_COUNTS`) after which the session is closed: the last of them and every later turn, and every paste, get the `session_closed` refusal |
Expand Down Expand Up @@ -329,6 +330,12 @@ class AgentSettings(BaseSettings):
entries and refuses more with `capacity`. That length assumes runs shorter than the 60 s
rate window: longer runs delay later starts, so admission does not guarantee a start."""

user_max_runs: PositiveInt = 3
"""Sandbox runs one user may have in the run queue at the same time, waiting and running together, across all
their sessions (`AGENT_USER_MAX_RUNS`). Owner decision of 2026-10-21: a user's browser windows work in parallel,
and only their sandbox runs share the queue; one more than this ends its turn with `error{code:"run_limit"}`
instead of `capacity`, so the page can say that the user's own runs fill it."""

user_messages_per_minute: PositiveInt = 10
"""Chat turns per user within any 60 seconds (`AGENT_USER_MESSAGES_PER_MINUTE`), typed messages and actions
alike, counted in the usage book. Checked before the scope judge, so a flood costs no model call: since chat turns
Expand Down
5 changes: 3 additions & 2 deletions agents/anyplot/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,8 +253,9 @@ def _library(value: str) -> str:
"daily_limit",
"other",
]
ErrorCode = Literal["guard_unavailable", "capacity", "deadline", "internal", "cancelled", "other"]
"""`cancelled`: the client closed the stream before the turn ended (the page's cancel, a reload, a lost connection)."""
ErrorCode = Literal["guard_unavailable", "capacity", "run_limit", "deadline", "internal", "cancelled", "other"]
"""`cancelled`: the client closed the stream before the turn ended (the page's cancel, a reload, a lost connection);
`run_limit`: the turn's sandbox run would have been one more than `AGENT_USER_MAX_RUNS` across the user's sessions."""
LanguageSource = Literal["default", "pasted", "detected", "chosen"]
Step = Literal["root", "adapter", "reviewer", "judge", "other"]

Expand Down
5 changes: 3 additions & 2 deletions agents/anyplot/theme_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@
before it raises `RenderBusy` (`503 capacity`), so wait plus render stay inside the
request deadline, like a run.
* **One at a time.** The route registers the toggle in the run registry, so a
session with a toggle in flight refuses `/messages` and a second toggle, and a user
has at most one run or toggle in flight (`409 run_active`).
session with a toggle in flight refuses `/messages` and a second toggle, and a session
has at most one run or toggle in flight (`409 run_active`); the user's other sessions
are not blocked.
* **Records.** A theme the version already holds is answered from its record without
a render. A `render` failure is recorded too: the same code and data fail the same
way, so it is rendered again only while it failed fewer than `MAX_THEME_TRIES`
Expand Down
10 changes: 8 additions & 2 deletions agents/anyplot/tools/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -565,12 +565,18 @@ async def run_data_code(code: str, source_language: str, tool_context: ToolConte
return _error_lines("invalid_code", lines)
services = get_services()
user = ledger.user_id or "anonymous"
# Checked before the queue wait and counted after it, so a run that never starts is never counted; the user's
# runs waiting in their other sessions at the same time (at most `AGENT_USER_MAX_RUNS` - 1) can each pass this
# check and overshoot the day's count by as many, a small overshoot accepted with the parallel windows (2026-10-21).
if not services.usage.data_runs_ok(user, settings):
attribution("data_code", ledger, outcome="daily_limit", language=language)
return _error("daily_limit")
if ledger.run_slot is not None and not await ledger.run_slot(): # the run queue's slot and the turn's start
attribution("data_code", ledger, outcome="capacity", language=language)
return _error("capacity")
# The route failed the turn with `run_limit` (the user's runs in other sessions fill their share) or
# `capacity` (a full queue, an expired wait), or the wait was cancelled; the turn is aborted either way.
code = "run_limit" if ledger.error == "run_limit" else "capacity"
attribution("data_code", ledger, outcome=code, language=language)
return _error(code)
services.usage.add_data_run(user)
job = DataJob(job_id=f"d{secrets.token_hex(8)}", source=prepared, timeout_s=settings.data_run_timeout_s)
try:
Expand Down
Loading
Loading