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
8 changes: 4 additions & 4 deletions agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This directory holds the anyplot agent network: the service that lets an admin p

## What is built

The runtime core runs locally: the agents, the plot pipeline, the guardrail plugins, the render layer, the private `/v1` service with its run queue, the theme toggle, and the footer strip on every PNG the service serves ("made with any.plot()" and "anyplot.ai/<spec-id>", see [Footer strip](../docs/concepts/agent-network.md#footer-strip)). The renderer service `anyplot-renderer` (`renderer/`), which runs the adapted code in Cloud Run sandboxes, and the `remote` backend that calls it are built, and the renderer has run in Cloud Run since 2026-10-11 (deployed by the `deploy-renderer` trigger; see [Render through the deployed renderer](#render-through-the-deployed-renderer)). The model-regression harness, the 120 synthetic spike-X cases, the 23 stress cases with their model-free static stage, the blind two-run review gallery and the catalogue eligibility sweep are built (see [Run the regression harness](#run-the-regression-harness)), and the Claude Haiku 5.5 baseline from the thinking run of 2026-10-11 on main is committed (`evals/baselines/claude-haiku-5-5.json`: 122 runs, all passed the gates, 50.8 % ended `ok`); the Gemini baseline is not. The chat page that uses the service through the API's `/debug/agent` routes is in the app (`app/src/pages/AgentChatPage.tsx`, built with `VITE_ENABLE_AGENT_CHAT=true`); since 2026-10-11 its data field also takes free text ("Hund 7 Katze 3 und Vogel 4"), which the judge model reads as a table that the user confirms before it is stored (`anyplot/data/transcribe.py`, rubric `prompts/transcriber.md`), and the flow has two steps: the user confirms the data with its bindings (`POST /v1/sessions/{sid}/dataset/confirm`) before the pipeline draws anything, and in step 1 the root's `prepare_data` tool changes the data on request from a fixed set of operations the server applies (`anyplot/data/prepare.py`, `anyplot/dataset_versions.py`), with the `done` event carrying the new version, the `summarize_data` action for the step-1 summary and `POST /v1/sessions/{sid}/dataset/undo` as the user's undo, and its `generate_data` tool computes the data from a formula the model chose, such as `sin(x)` from -pi to pi (`anyplot/data/generate.py`; a formula pasted into the data field answers `422 formula`) (see [Two steps: data first, then the plot](../docs/concepts/agent-network.md#two-steps-data-first-then-the-plot)); the prep eval scores the root's choices (see [Run the prep eval](#run-the-prep-eval)). Since 2026-10-12 a session has one language for the replies and every text in the plot, English by default whatever the browser's locale, set by the first in-scope typed message (`detected`), or for good by the chip in the chat header (`PUT /v1/sessions/{sid}/language`) or the root's `set_language` tool (`chosen`); code, comments, column names and labels from the data stay as they are (`anyplot/language.py`, see [Session language](../docs/concepts/agent-network.md#session-language)). The service's own image (`Dockerfile`) and Cloud Build config (`cloudbuild.yaml`) are built, with a CI job that builds and smokes the image before merge, and the service has run in Cloud Run since 2026-10-11, deployed by the `deploy-agents` trigger (see [Build and deploy the service](#build-and-deploy-the-service)); the API side is switched on for admins since 2026-10-11 (the user-id secret is bound, the API trigger sets the service URL and `_AGENT_ENABLED=true`). The judge-only scope eval and its cases are built, and its first full run on 2026-10-10 passed both gates (see [Run the scope eval](#run-the-scope-eval)). Real sessions reach the harness too: `evals/import_case.py` turns a saved session or a stored feedback case into an eval case, and since 2026-10-12 `evals/sync_cases.py` pulls the cases you promoted on the debug page from the private case bucket (see [Sync promoted cases from the bucket](#sync-promoted-cases-from-the-bucket)). Not built yet: the scope cases' second stage through the root, and the `agents-eval.yml` workflow.
The runtime core runs locally: the agents, the plot pipeline, the guardrail plugins, the render layer, the private `/v1` service with its run queue, the theme toggle, and the footer strip on every PNG the service serves ("made with any.plot()" and "anyplot.ai/<spec-id>", see [Footer strip](../docs/concepts/agent-network.md#footer-strip)). The renderer service `anyplot-renderer` (`renderer/`), which runs the adapted code in Cloud Run sandboxes, and the `remote` backend that calls it are built, and the renderer has run in Cloud Run since 2026-10-11 (deployed by the `deploy-renderer` trigger; see [Render through the deployed renderer](#render-through-the-deployed-renderer)). The model-regression harness, the 120 synthetic spike-X cases, the 23 stress cases with their model-free static stage, the blind two-run review gallery and the catalogue eligibility sweep are built (see [Run the regression harness](#run-the-regression-harness)), and the Claude Haiku 5.5 baseline from the thinking run of 2026-10-11 on main is committed (`evals/baselines/claude-haiku-5-5.json`: 122 runs, all passed the gates, 50.8 % ended `ok`); the Gemini baseline is not. The chat page that uses the service through the API's `/debug/agent` routes is in the app (`app/src/pages/AgentChatPage.tsx`, built with `VITE_ENABLE_AGENT_CHAT=true`); since 2026-10-11 its data field also takes free text ("Hund 7 Katze 3 und Vogel 4"), which the judge model reads as a table that the user confirms before it is stored (`anyplot/data/transcribe.py`, rubric `prompts/transcriber.md`), and the flow has two steps: the user confirms the data with its bindings (`POST /v1/sessions/{sid}/dataset/confirm`) before the pipeline draws anything, and in step 1 the root's `prepare_data` tool changes the data on request from a fixed set of operations the server applies (`anyplot/data/prepare.py`, `anyplot/dataset_versions.py`), with the `done` event carrying the new version, the `summarize_data` action for the step-1 summary and `POST /v1/sessions/{sid}/dataset/undo` as the user's undo (since 2026-10-14 the lines pasted above or below a table, which the parser leaves out of it, are kept as the user's notes about the data: the dataset route runs the pre-filter and the scope judge on them, returns the kept ones as `context_lines`, and the `summarize_data` turn and `get_dataset_profile` hand them to the root fenced as `<user_notes>`, see [Request flow](../docs/concepts/agent-network.md#request-flow-for-use-with-my-data)), and its `generate_data` tool computes the data from a formula the model chose, such as `sin(x)` from -pi to pi (`anyplot/data/generate.py`; a formula pasted into the data field answers `422 formula`) (see [Two steps: data first, then the plot](../docs/concepts/agent-network.md#two-steps-data-first-then-the-plot)); the prep eval scores the root's choices (see [Run the prep eval](#run-the-prep-eval)). Since 2026-10-12 a session has one language for the replies and every text in the plot, English by default whatever the browser's locale, set by the first in-scope typed message (`detected`), or for good by the chip in the chat header (`PUT /v1/sessions/{sid}/language`) or the root's `set_language` tool (`chosen`); code, comments, column names and labels from the data stay as they are (`anyplot/language.py`, see [Session language](../docs/concepts/agent-network.md#session-language)). The service's own image (`Dockerfile`) and Cloud Build config (`cloudbuild.yaml`) are built, with a CI job that builds and smokes the image before merge, and the service has run in Cloud Run since 2026-10-11, deployed by the `deploy-agents` trigger (see [Build and deploy the service](#build-and-deploy-the-service)); the API side is switched on for admins since 2026-10-11 (the user-id secret is bound, the API trigger sets the service URL and `_AGENT_ENABLED=true`). The judge-only scope eval and its cases are built, and its first full run on 2026-10-10 passed both gates (see [Run the scope eval](#run-the-scope-eval)). Real sessions reach the harness too: `evals/import_case.py` turns a saved session or a stored feedback case into an eval case, and since 2026-10-12 `evals/sync_cases.py` pulls the cases you promoted on the debug page from the private case bucket (see [Sync promoted cases from the bucket](#sync-promoted-cases-from-the-bucket)). Not built yet: the scope cases' second stage through the root, and the `agents-eval.yml` workflow.

The model is **Claude Haiku 5.5 on Vertex AI** (`claude-haiku-5-5`) by default. **Gemini 3.8 Flash** is the second arm: set `AGENT_PROVIDER=gemini` together with Gemini model ids, so the two can be compared on price and quality later. Every agent and the scope judge run on the configured provider.

Expand Down Expand Up @@ -47,7 +47,7 @@ The model is **Claude Haiku 5.5 on Vertex AI** (`claude-haiku-5-5`) by default.
| `evals/import_case.py` | Turns one feedback case, a saved session bundle or a case directory copied from the bucket, into an eval case in the git-ignored `evals/.cases/` ([Import a feedback case](#import-a-feedback-case)) |
| `evals/sync_cases.py` | Keeps `evals/.cases/` in line with the `agent_feedback` table: imports the promoted cases from the private case bucket, removes the retired ones, and with `--backfill` indexes cases that have no row ([Sync promoted cases from the bucket](#sync-promoted-cases-from-the-bucket)) |
| `evals/eligibility.py` | The model-free sweep of the catalogue's matplotlib and seaborn files: eligible, coupled and blocked pairs, and why |
| `evals/scope.py`, `evals/scope.evalset.json` | The judge-only scope eval and its 188 synthetic cases: in-scope, off-topic and adversarial messages, scored for adversarial recall and false refusals |
| `evals/scope.py`, `evals/scope.evalset.json` | The judge-only scope eval and its 195 synthetic cases: in-scope, off-topic and adversarial messages and notes pasted with a table, scored for adversarial recall and false refusals |
| `evals/prep.py`, `evals/fixtures/prep/` | The live prep eval and its 33 German and English data requests (10 of them `hard`): each through `/v1` in process with the real model, scored on the `prepare_data` operations and `generate_data` calls the root chose ([Run the prep eval](#run-the-prep-eval)) |
| `evals/fixtures/cases/` | The eval cases: 120 generated (`<spec>-<library>-<perturbation>`), the hand-written `scatter-basic-matplotlib`, `bar-grouped-seaborn` and `scatter-basic-matplotlib-german-message` (a German follow-up that sets the session language), and 23 stress cases (`stress-<name>`) ([fixtures README](evals/fixtures/README.md)) |
| `evals/baselines/` | Committed baseline reports, one per model ([baselines README](evals/baselines/README.md)) |
Expand Down Expand Up @@ -709,9 +709,9 @@ uv run --extra agents python -m agents.evals.make_fixtures --check # exit 1 wh

## Run the scope eval

The scope eval (`evals/scope.py`) measures the scope judge alone: it sends every case of `evals/scope.evalset.json` to the judge the settings name, with the rubric `scope_judge.md` and the same context the ScopeGuard plugin builds (the user's earlier turns and the root's last reply), and scores the verdicts. It runs no agent, no render and no session, so a run costs about $0.05 on Claude Haiku 5.5 (188 judge calls of about 2,500 input and 60 output tokens) and takes about 7 minutes, because it paces the judge calls (see below). Run it after every change to `scope_judge.md`, the judge model or the judge's input.
The scope eval (`evals/scope.py`) measures the scope judge alone: it sends every case of `evals/scope.evalset.json` to the judge the settings name, with the rubric `scope_judge.md` and the same context the ScopeGuard plugin builds (the user's earlier turns and the root's last reply), and scores the verdicts. It runs no agent, no render and no session, so a run costs about $0.05 on Claude Haiku 5.5 (195 judge calls of about 2,500 input and 60 output tokens; the cases tagged `notes` go to the judge inside `<user_notes>`, as the dataset route sends pasted notes) and takes about 7 minutes, because it paces the judge calls (see below). Run it after every change to `scope_judge.md`, the judge model or the judge's input.

The eval paces itself because of the Vertex AI quota. In the project `anyplot`, the quota `eu_multi_region_online_prediction_requests_per_base_model` for `anthropic-claude-haiku` is 30 requests per minute, an override far below Google's default of 1,500. A failed call counts against it too. Two unpaced runs on 2026-10-10 answered about 60 cases and then got HTTP 429 `RESOURCE_EXHAUSTED` for every remaining call, at `--concurrency 4` and at `--concurrency 1` alike. The flag `--calls-per-minute` (25 by default) therefore spaces the starts of the judge calls at least 60/N seconds apart across all concurrent cases, which makes 188 cases take about 7.5 minutes. The judge's own retry after a failed call is not paced, and 25 leaves room for a few of those a minute. Set `--calls-per-minute 0` only for a judge whose quota you have checked.
The eval paces itself because of the Vertex AI quota. In the project `anyplot`, the quota `eu_multi_region_online_prediction_requests_per_base_model` for `anthropic-claude-haiku` is 30 requests per minute, an override far below Google's default of 1,500. A failed call counts against it too. Two unpaced runs on 2026-10-10 answered about 60 cases and then got HTTP 429 `RESOURCE_EXHAUSTED` for every remaining call, at `--concurrency 4` and at `--concurrency 1` alike. The flag `--calls-per-minute` (25 by default) therefore spaces the starts of the judge calls at least 60/N seconds apart across all concurrent cases, which makes 195 cases take about 7.5 minutes. The judge's own retry after a failed call is not paced, and 25 leaves room for a few of those a minute. Set `--calls-per-minute 0` only for a judge whose quota you have checked.

1. Complete steps 1 and 2 of [Before you begin](#before-you-begin): the dependencies and Application Default Credentials. The scorer reads no `.env` file.
2. Run the eval:
Expand Down
9 changes: 8 additions & 1 deletion agents/anyplot/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
* `root_agent` ("anyplot") is the only agent that talks to the user. Its policy is the
constant `static_instruction` (`policy.root_instruction`), never templated; the
`InstructionProvider` `session_context` adds only server-validated values (spec id,
library, the session language and its source, dataset and binding status, plot
library, the session language and its source, dataset and binding status, whether
the user pasted notes with the data (a fixed line, never the notes), plot
versions and the latest version's theme, so a change keeps it), which ADK sends
as a marked instruction block after the static prefix. Catalogue text such as the
spec title started as a public issue, so it never enters that block: the root reads
Expand Down Expand Up @@ -66,6 +67,9 @@
(`20261012T211551Z-bcc5bcf1`): with the bare word `chosen` and a heading that called the
value "never an instruction", the root refused the user's chip choice as "the block in
your message" and stayed in English."""
NOTES_LINE = "yes; get_dataset_profile returns them under user_notes"
"""The session block's line when the stored dataset kept notes pasted around the table: a fixed text,
so the root knows to read them again after the context filter dropped the turn that carried them."""


async def session_context(context: ReadonlyContext) -> str:
Expand Down Expand Up @@ -99,6 +103,9 @@ async def session_context(context: ReadonlyContext) -> str:
else:
profile = dataset.profile
lines.append(f"- Dataset: {profile.rows} rows, {len(profile.columns)} columns")
if dataset.context_lines:
# A fixed line, never the notes: they are the user's text and reach the root only fenced.
lines.append(f"- Notes pasted with the data: {NOTES_LINE}")
check = check_bindings(view.bindings, view.snapshot.roles(), profile)
if check.complete:
lines.append("- Bindings: complete")
Expand Down
Loading
Loading