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
4 changes: 2 additions & 2 deletions agents/anyplot/dataset_versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
DATASET_HISTORY,
DATASET_ID,
DATASET_VERSION,
PENDING_REQUEST,
PENDING_CLEARED,
CatalogueSnapshot,
SessionView,
)
Expand Down Expand Up @@ -162,5 +162,5 @@ def _delta(dataset_id: str, bindings: list[Binding], history: list[str], version
BINDINGS: [binding.model_dump() for binding in bindings],
DATASET_HISTORY: history,
DATASET_VERSION: version,
PENDING_REQUEST: None,
**PENDING_CLEARED,
}
4 changes: 2 additions & 2 deletions agents/anyplot/opening.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
DATASET_HISTORY,
DATASET_ID,
DATASET_VERSION,
PENDING_REQUEST,
PENDING_CLEARED,
CatalogueSnapshot,
initial_state,
)
Expand Down Expand Up @@ -112,7 +112,7 @@ def state_delta(self) -> dict[str, Any]:
BINDINGS: [binding.model_dump() for binding in self.bindings],
DATASET_HISTORY: [],
DATASET_VERSION: 1,
PENDING_REQUEST: None,
**PENDING_CLEARED,
}


Expand Down
88 changes: 77 additions & 11 deletions agents/anyplot/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,11 @@
from .render.gates import data_rows, evaluate
from .render.runtimes.python import PythonRuntime
from .schemas import (
EXPECTED_TEXT,
MAX_CHANGE_CHARS,
MAX_CHANGE_REQUEST_CHARS,
MAX_CHANGES,
MAX_EXPECTED_TEXTS,
MAX_FEEDBACK,
MAX_LINE_CHARS,
MAX_NOTE_CHARS,
Expand Down Expand Up @@ -166,7 +168,14 @@
)
from .scoring import ScoreAnswer, ScoreError, mend_answer, parse_answer
from .services import PADDED_REASON, CodeVersion, Services, ThemeRender, get_services
from .session_state import PENDING_REQUEST, SessionView, read_session
from .session_state import (
BINDINGS,
PENDING_BINDINGS,
PENDING_EXPECTED_TEXTS,
PENDING_REQUEST,
SessionView,
read_session,
)
from .settings import AgentSettings, get_settings
from .sub_agents.adapter import ADAPTERS, adapter_name
from .sub_agents.reviewer import REVIEWER_NAME, use_reviewer_mode
Expand Down Expand Up @@ -568,8 +577,39 @@ def hold_request(state: Any, args: PipelineArgs) -> None:
Also the path of a run that passed the check but got no slot in the run queue (`capacity`,
or a cancelled wait): its note waits for the next `.create_plot()` like a blocked one's.
"""
if args.change_request and args.base != "previous":
state[PENDING_REQUEST] = merged_request(state.get(PENDING_REQUEST), args.change_request)
if (args.change_request or args.expected_texts) and args.base != "previous":
state[PENDING_REQUEST] = merged_request(state.get(PENDING_REQUEST), args.change_request) or None
state[PENDING_EXPECTED_TEXTS] = chosen_texts(state.get(PENDING_EXPECTED_TEXTS), args) or None
# The bindings the note was written for: a later binding change that only fills roles open then keeps it.
bindings = state.get(BINDINGS)
state[PENDING_BINDINGS] = list(bindings) if isinstance(bindings, list) else []


def kept_texts(value: object) -> list[str]:
"""The stored `expected_texts` that pass the same validator as `PipelineArgs.expected_texts`, item by item,
without repeats and at most `MAX_EXPECTED_TEXTS`; anything else stored there is dropped."""
texts: list[str] = []
for item in value if isinstance(value, list) else []:
try:
text = EXPECTED_TEXT.validate_python(item, strict=True)
except ValidationError:
continue
if text not in texts:
texts.append(text)
return texts[:MAX_EXPECTED_TEXTS]


def chosen_texts(pending: object, args: PipelineArgs) -> list[str]:
"""The texts a run checks: its own when it brings a request or texts of its own, else the kept ones.

Review of 2026-10-15: the root never sees the kept texts, so joining them to a run's own kept
a text the user had since withdrawn or replaced ("call it 'Study time'" after "Lernzeit"),
which then counted as a miss and started a repair. A run that brings its own word replaces
them; only a bare run (`.create_plot()`) fills in the kept ones.
"""
if args.change_request or args.expected_texts:
return list(args.expected_texts)
return kept_texts(pending)


MIN_KEPT_NOTE_CHARS = 40
Expand Down Expand Up @@ -608,19 +648,45 @@ def take_pending_request(state: Any, args: PipelineArgs) -> PipelineArgs:
(`settle_pending_request`), so a run that fails loses no note (review of 2026-10-15).
"""
pending = state.get(PENDING_REQUEST)
if pending is None or args.base == "previous":
texts = state.get(PENDING_EXPECTED_TEXTS)
if (pending is None and texts is None) or args.base == "previous":
return args
return args.model_copy(update={"change_request": merged_request(pending, args.change_request)})
# Validated like the root's own arguments: a stored value never reaches the run unchecked.
joined = {
**args.model_dump(),
"change_request": merged_request(pending, args.change_request),
"expected_texts": chosen_texts(texts, args),
}
try:
return PipelineArgs.model_validate(joined)
except ValidationError:
return args


PENDING_KEYS = (PENDING_REQUEST, PENDING_EXPECTED_TEXTS, PENDING_BINDINGS)


def settle_pending_request(state: Any, args: PipelineArgs, shipped: bool) -> None:
"""After a run: a shipped plot used the kept request, so it goes; a run that shipped nothing keeps its request
(the kept one with the run's own, as `take_pending_request` joined them) for the next first plot."""
"""After a run, the kept note as the run leaves it; only a first-plot run touches it, as only it took it.

A shipped first plot used the kept request and texts, so they go. A first-plot run that
shipped nothing keeps what it ran with (`take_pending_request` joined the kept and its own)
for the next one. A change of an existing version (`base="previous"`) took nothing, so it
leaves the note as it is (review of 2026-10-15), except that stored texts that no longer
pass the validator are cleaned (`kept_texts`).
"""
if args.base == "previous":
stored = state.get(PENDING_EXPECTED_TEXTS)
if stored is not None and kept_texts(stored) != stored:
state[PENDING_EXPECTED_TEXTS] = kept_texts(stored) or None
return
if shipped:
if state.get(PENDING_REQUEST) is not None:
state[PENDING_REQUEST] = None
elif args.change_request and args.base != "previous":
state[PENDING_REQUEST] = args.change_request
for key in PENDING_KEYS:
if state.get(key) is not None:
state[key] = None
elif args.change_request or args.expected_texts:
state[PENDING_REQUEST] = args.change_request or None
state[PENDING_EXPECTED_TEXTS] = list(args.expected_texts) or None


def render_adapt_request(request: AdaptRequest, view: SessionView) -> str:
Expand Down
29 changes: 27 additions & 2 deletions agents/anyplot/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,21 @@

import json
import re
import unicodedata
from collections.abc import Iterable
from typing import Annotated, Any, Literal, Self, get_args

from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, ValidationInfo, field_validator, model_validator
from pydantic import (
AfterValidator,
BaseModel,
ConfigDict,
Field,
NonNegativeInt,
TypeAdapter,
ValidationInfo,
field_validator,
model_validator,
)

from .language import DEFAULT_LANGUAGE, LANGUAGE_PATTERN

Expand Down Expand Up @@ -79,7 +90,21 @@
DefectText = Annotated[str, Field(min_length=1, max_length=MAX_DEFECT_TEXT_CHARS)]
MAX_EXPECTED_TEXTS = 5
MAX_EXPECTED_TEXT_CHARS = 120
ExpectedText = Annotated[str, Field(min_length=1, max_length=MAX_EXPECTED_TEXT_CHARS)]


def _visible_text(text: str) -> str:
"""An expected text holds something to find in the render: not only whitespace, no control character."""
if not text.strip():
raise ValueError("an expected text is not only whitespace")
if any(unicodedata.category(char) == "Cc" for char in text):
raise ValueError("an expected text has no control character")
return text


ExpectedText = Annotated[str, Field(min_length=1, max_length=MAX_EXPECTED_TEXT_CHARS), AfterValidator(_visible_text)]
EXPECTED_TEXTS: TypeAdapter[list[ExpectedText]] = TypeAdapter(list[ExpectedText])
"""Validates `expected_texts` read back from session state the way `PipelineArgs` validates them from the root."""
EXPECTED_TEXT: TypeAdapter[ExpectedText] = TypeAdapter(ExpectedText)
MAX_DATASET_CONTEXT_CHARS = 1_600
"""The dataset context block (`dataset_context.MAX_CONTEXT_CHARS`, 1,500) with room to spare."""
MAX_SHOWN_TEXT_CHARS = 80
Expand Down
39 changes: 35 additions & 4 deletions agents/anyplot/session_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,18 @@
`prepare_data`, `generate_data`, `run_data_code`, `.to_long()`, undo) or library switch drops it: it was written
for the data and bindings before (owner decision of 2026-10-15). The pipeline reads it defensively: a value that is
no text is ignored, and text is cut to `MAX_CHANGE_REQUEST_CHARS` (`pipeline.merged_request`)."""
PENDING_EXPECTED_TEXTS = PREFIX + "pending_expected_texts"
"""The `expected_texts` that came with `PENDING_REQUEST` (the title or number format the user asked to see), kept
and dropped with it, so a bare `.create_plot()` checks the render for the same texts; a run that brings a request or
texts of its own checks only its own (`pipeline.chosen_texts`). Read through the `PipelineArgs` validator
(`pipeline.kept_texts`)."""
PENDING_BINDINGS = PREFIX + "pending_bindings"
"""The bindings as they were when the note was kept (`pipeline.hold_request`): a binding write that keeps every one
of them and only fills roles open then (the step `incomplete_bindings` waits for) keeps the note (`apply_bindings`,
review of 2026-10-15)."""
PENDING_CLEARED: dict[str, Any] = {PENDING_REQUEST: None, PENDING_EXPECTED_TEXTS: None, PENDING_BINDINGS: None}
"""The state delta that drops a kept first-plot note and its texts: every binding write, new data version and
library switch carries it."""
LAST_STATUS = PREFIX + "last_status"
VERSIONS = PREFIX + "versions"

Expand Down Expand Up @@ -204,7 +216,7 @@ def initial_state(
NORMALISED: normalised,
HINTS: list(readiness.get("hints", [])),
READINESS: readiness,
PENDING_REQUEST: None, # a note kept for another library's first plot does not carry over
**PENDING_CLEARED, # a note kept for another library's first plot does not carry over
}


Expand Down Expand Up @@ -243,16 +255,35 @@ def read_session(state: Any) -> SessionView | None:


def apply_bindings(
bindings: list[Binding], roles: list[DataRole], profile: DatasetProfile
bindings: list[Binding], roles: list[DataRole], profile: DatasetProfile, pending_bindings: object = None
) -> tuple[BindingCheck, dict[str, Any]]:
"""`bindings.apply`: validate bindings against the spec roles and the profile.

Returns the check and, when it has no errors, the state delta that stores the
bindings (incomplete bindings are stored too; the pipeline answers `not_ready`).
An empty delta means nothing may be written.

A kept plot note (`PENDING_REQUEST`, its texts) was written for the bindings of the moment
it was kept (`pending_bindings`, the state's `PENDING_BINDINGS`), so a binding write drops
it, unless it keeps every one of those bindings and only fills roles that were open then
(`fills_open_roles`): that is the step a note held on `incomplete_bindings` waits for.
"""
check = check_bindings(bindings, roles, profile)
if check.errors:
return check, {}
# A kept plot note (`PENDING_REQUEST`) was written for the bindings before; any binding write drops it.
return check, {BINDINGS: [binding.model_dump() for binding in bindings], PENDING_REQUEST: None}
delta: dict[str, Any] = {BINDINGS: [binding.model_dump() for binding in bindings]}
if not fills_open_roles(pending_bindings, bindings):
delta.update(PENDING_CLEARED)
return check, delta


def fills_open_roles(held: object, bindings: list[Binding]) -> bool:
"""Whether `bindings` keep every binding of `held` (stored bindings) and only add roles `held` left open."""
if not isinstance(held, list):
return False
try:
before = {(binding.role, binding.column) for binding in (Binding.model_validate(item) for item in held)}
except ValueError:
return False
after = {(binding.role, binding.column) for binding in bindings}
return before <= after
11 changes: 9 additions & 2 deletions agents/anyplot/tools/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,14 @@
from ..render.serial import RenderBusy
from ..schemas import MAX_COLUMNS, MAX_ROWS, Binding, DatasetProfile, PipelineArgs
from ..services import get_services
from ..session_state import SessionView, apply_bindings, language_delta, narrowed_confirmation, read_session
from ..session_state import (
PENDING_BINDINGS,
SessionView,
apply_bindings,
language_delta,
narrowed_confirmation,
read_session,
)
from ..settings import get_settings


Expand Down Expand Up @@ -211,7 +218,7 @@ async def set_bindings(bindings: list[dict[str, str]], tool_context: ToolContext
except ValueError:
return _error("invalid_bindings")
roles = view.snapshot.roles()
check, delta = apply_bindings(parsed, roles, profile)
check, delta = apply_bindings(parsed, roles, profile, tool_context.state.get(PENDING_BINDINGS))
if not delta:
# The errors quote column names from the user's dataset.
errors = DATA_PREAMBLE + "\n" + fence("user_data", json.dumps(check.errors[:10], ensure_ascii=False))
Expand Down
3 changes: 2 additions & 1 deletion agents/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@
from agents.anyplot.session_state import (
BINDINGS,
CONFIRMED,
PENDING_BINDINGS,
CatalogueSnapshot,
SessionView,
apply_bindings,
Expand Down Expand Up @@ -1051,7 +1052,7 @@ async def put_bindings(
parsed = [Binding(role=item.role, column=item.column) for item in bindings if item.column]
except ValueError:
raise AgentsError(422, "invalid") from None
check, delta = apply_bindings(parsed, view.snapshot.roles(), stored.profile)
check, delta = apply_bindings(parsed, view.snapshot.roles(), stored.profile, session.state.get(PENDING_BINDINGS))
if not delta:
return JSONResponse(
status_code=422, content={"detail": "invalid", "errors": check.errors[:20]}, headers=_NO_STORE
Expand Down
11 changes: 11 additions & 0 deletions changelog.d/agent-pending-expected-texts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
### Fixed

- **A kept plot note keeps its requested texts.** When a first plot stops
before the confirmation, the texts and number formats the user asked to see
(`expected_texts`) are now kept with the note, so the next bare
`.create_plot()` checks the render for the same title or format; a run that
brings its own request or texts checks only its own, so a withdrawn title is
never reported missing. The note follows one lifecycle: kept until a plot
ships, kept through a binding change that only fills roles that were still
open, dropped by any other change to the bindings, the data or the library.
(#12186)
Loading
Loading