Skip to content

Release: staging → main (111 commits) - #560

Open
mindsdb-release-train[bot] wants to merge 111 commits into
mainfrom
staging
Open

mindsdb-release-train[bot] wants to merge 111 commits into
mainfrom
staging

Conversation

@mindsdb-release-train

@mindsdb-release-train mindsdb-release-train Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Release: staging → main

111 commit(s) queued for the next production release.

Changes

bee1826e feat(sandbox): lock a person's agent code to their own folders with Landlock (ENG-3476) (#588)
725cdb3c [ENG-3448] Redact a printed token value that contains a quote or a space (#574)
539f21bc fix(session): fire the scratchpad observers on streaming turns, so the cerebellum learns (ENG-3422) (#590)
2bce5a8f feat(memory): add an optional read-only memory layer to Cortex (ENG-3422) (#572)
68573575 fix(session): name a content-filter stop in the turn's reason and retry a hand-back with no answer (#589)
d79bbc36 Handled Content Filters on Hand-back Call (#584)
919dee86 fix(session): keep a turn's tool rows through compaction and hard truncation (ENG-3433) (#585)
fd15c637 Wait out Azure rate limits instead of failing the answer (#565)
090eac8a ENG-3456: Attribute end-of-turn memory calls to their turn (#583)
4d29626e docs: note each piece of text cowork-server's figure sources read (ENG-3433) (#582)
d1fb80ab ENG-3452: Trace the Jev completion check like the LLM verifier it replaces (#578)
eff72bbd ENG-3349:  Fullstack artifact generator: correct STATE usage, no invented page content, stable API step (#553)
2b2cb93c [ENG-1706] Show an unnamed connection's engine as its label in the datasource context (#563)
dd2e4efb Don't guess at the work limit (#566)
70549376 [ENG-3408] Redact OAuth tokens in scratchpad output before they reach the model or storage (#567)
058df823 Replace open_artifact with read_text_file (ENG-3307 1/3) (#545)
2c1752e8 fix(session): name the limit that stopped a turn and let the user continue past it (ENG-3343) (#547)
2da58bd3 Fail the turn instead of showing raw provider errors as a reply (ENG-3410) (#569)
312a8821 fix(product): answer local-model setup questions per surface (ENG-3321) (#546)
2267e213 Put report_tools blocks and table rows on their own lines (ENG-3358) (#550)
2727d049 Tell the agent what to pass to publish_or_preview, not where artifacts live (ENG-3294 1/2) (#548)
3e6425a5 Reject root-relative paths in generated pages (ENG-3226) (#552)
dbc17ed0 Keep owner-side files and files outside the artifact folder out of published bundles (ENG-3346) (#544)
1521bab1 feat(artifacts): act first, ask later in generate_artifact discovery (ENG-3206) (#533)
0fa02f72 fix(generate_artifact): keep the brief in the language the user writes in (ENG-3283) (#562)
8b9e4e9d ENG-3067 Preload the cloud turn in a resident pod process (#520)
83df8ea0 test: name incident fixtures and example hosts by what they show (#559)
2e3a22fe perf(datasources): parse each YAML text once and reuse the package probe (ENG-3362) (#558)
c0f4a98e Send the turn's trace on a cell's model call (#551)
07e5bf26 fix(logging): omit provider payloads from warnings and cloud diagnostics (ENG-3304) (#542)
9337907e Add new tests
bf68a22a Improve verifier
2fc473a2 feat(publish): keep the password hash while the password is unchanged and never copy another owner's artifact (ENG-3225) (#543)
f1a61b90 fix(scratchpad): run a pad's venv work off the event loop, and end the turn when Stop lands in a cell (ENG-3189) (#537)
61f4dd29 fix(llm): report silent model calls to hosts and end one after 10 minutes of no output (ENG-3281) (#539)
d115eb77 Bump docosaurus (#541)
1ab047ea [ENG-2422] Tell a web turn where credentials go instead of asking for them in chat (#466)
5356e1cd report_tools: refuse dict rows that miss a column
0f7534e0 report_tools: add the filter script when a filter joins an existing page
3ac5a179 report_tools: replace the element when new markup carries its id
107cda53 report_tools: match filter options and row keys for floats and blanks
7b8b4bb1 report_tools: keep line endings when editing a page in place
57637a50 report_tools: locate elements by "\n" line starts only
4ae2d6ef Fix tests
a9bbe360 Min tools to 2
ced70d55 Edit existing reports in place; keep the dashboard skill for dashboard code
b77c615c report_tools: keep section headings on update, add insert, flag tags shown as text
23c72226 Direct reports: use report_tools as documented, without reading its source
60cfb00f Build fully specified reports directly instead of through the generator
3a442218 Return a small primary file's text from open_artifact
073a7113 Add report_tools: building blocks for self-contained HTML reports
6f0fd40b Record Jev's token usage in the turn's cost
d3c384fa Cache the shared system prompt across sessions on OpenAI Responses
e875e134 fix(scratchpad): build a fresh model client for each helper call (ENG-3191) (#528)
b6b5faac Responses API: report truncation and failures, forward tool images, send trace headers (#525)
860e9c66 fix(scratchpad): report venv failure facts instead of a misleading fix hint (ENG-1239) (#524)
c8dff67a feat(cloud_turn): interactive ask_user in cloud turns (ENG-3049) (#513)
4ddab4d3 ENG-1985: single source of truth for artifact non-content names (#521)
15d567b1 Render connector usage notes for connected engines (#519)
6c23c8cc Memoryy test
ab7f1f48 Fix test
3def488f Fix memory
89526a27 FIlter in paralel
446e614c Bytoced compile
b2fd71d6 Pass Google Ads' developer token through the cloud data vault (#514)
cd0c609f Match example.com's current body text in the live fetch test (#515)
20343c57 [ENG-2193] Let a host share the completion-verifier latch across sessions (#511)
628211d2 Why do we even need seed?
32dbaa00 Add tests
606ba5b0 On cloud off
ab59301c MAke seed optional
ea536106 ADddress comments
87d37f25 Rmv stupid comments
f7582660 Add jev
b47ccfe6 Add tests
6f0b4769 Add jev settings
e162a0d0 Use jev
5cad2f85 fix(minds_client): keep only chat models from the catalog (ENG-1958) (#508)
94b3ef0f fix(llm): raise one exception type per MindsHub billing stop (ENG-813) (#505)
ff5008f0 fix(cloud_turn): never rate-limit tool progress lines (ENG-2981) (#507)
8bb7f68e test(skills): drop the duplicated BROWSER STORAGE marker (ENG-2875) (#503)
fb442a41 Scrub Google AQ. authorization keys at chat input (ENG-2684) (#498)
a61c5b0d fix(scratchpad): stop reporting explainability bookkeeping errors as tool failures (#499)
aa2d15a0 ENG-2863: run the verifier live matrix only when the verifier changed (#486)
6c785dab [ENG-2807] Reach the datasource gateway through the turn's own inference connection (#501)
9118ccdf ENG-1816: MCP client in anton (Stage 1) (#478)
4840c619 feat: generate_artifact — one-tool web artifact pipeline (ENG-968, ENG-969) (#335)
59cb11c6 ENG-1827: emit the turn's compaction result from the cloud entrypoint (#479)
4d3d8650 fix: floor pydantic-settings to >=2.14.2 to clear secrets_dir symlink traversal CVE (#496)
aaa4d09a fix: pin anyio to >=4.14.2 to clear IDNA TLS cert-spoofing CVE (#495)
19e16f4e fix: pin click to >=8.3.3 to clear command-injection CVE (#494)
093409b5 ENG-2419: Name the kill in single-line readers of a scratchpad cell error (#497)
8f3e99ab ENG-2892: treat a gateway alias echo as "served model not disclosed", not a repoint (#490)
883d1c14 [ENG-2121] Tie each completed turn to the account that ran it (#493)
fae9d0b6 [ENG-2175] Check decks and Word files before they are handed over (#492)
74735c9a ENG-2689: Classify an oversized-image refusal as permanent, not transient (#484)
1d639625 [ENG-2807] Parse the datasource block and provide the typed cloud helper (#487)

Contributors

  • Alejandro Cantu
  • Andrey
  • David Fraser
  • Hamish Fagg
  • Ian Unsworth
  • Konstantin Sivakov
  • Lucas Koontz
  • Max Stepanov
  • Minura Punchihewa
  • Paul Newsam
  • Zoran Pandovski
  • ZoranPandovski
  • ianu82
  • martyna-mindsdb
  • mindsdb-release-train[bot]

Review checklist

  • Staging has been validated
  • No known blockers

The release freeze is open: staging is the release candidate and this PR is ready to merge once it has been validated.

Opened and kept current automatically. The commit list is rewritten on every
staging merge, so it always reflects what would ship right now.

mindsdb-release-train Bot and others added 30 commits September 21, 2026 09:40
…per (#487)

* Parse the block and provide the typed Anton datasource helper

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Let the helper report what the gateway said

On an HTTP error the helper returns the gateway's own code when the body
carries one shaped like a code, so the agent can tell a denied or stale
grant from an outage; any other body stays datasource_unavailable. A result
is accepted only when it echoes the requested protocol version, operation,
connection and credential version, and the truncation reason passes through.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Address the review findings on the helper

The helper posts over one verified TLS connection that never follows a
redirect, so the turn key cannot be replayed to wherever a response points.
It relays only the gateway's own error codes, bounds column descriptors like
values, and says on stderr why it was not injected. The prompt names the
return shape. The cloud turn's bearer and references stay out of artifact
backends, and the child environment composition is a tested helper. The
legacy static-key path is now proven absent from a cloud pod that carries
its full configuration.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Check the envelope version rather than coercing it

The guard read int(d["protocol_version"]), and int(1.9) and int(True) are
both 1, so an envelope that is not v1 passed it and was then parsed with the
v1 schema, dropping whatever else it carried. That is the opposite of what a
version guard is for. The value is now compared as it arrived, which is what
the datasource block one level down already does and what both producers do
in their own repos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Refuse a version that is only equal to the supported one

`True == 1` and `1.0 == 1`, so the datasource block's equality check accepted
both and the parser then normalized them to v1, which is the same hole the
envelope guard had one level up. Both now test the value's type as well, the
way the connection ids in the same function already did, through one helper
that says why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ient (#484)

* fix(llm): classify an oversized-image refusal as permanent, not transient

ENG-2689. A provider 400 rejecting an image for its SIZE fell off the end of
the status ladder into the generic "temporarily unavailable, try again in a
moment" ConnectionError. That is wrong twice: the refusal is permanent by
construction, and the retry it invites cannot work. The session then retried
it four times against four byte-identical requests before the turn died.

- classify_content_rejection() moves the heuristic into provider.py beside
  classify_404, covering both the ENG-1992 shape family and the new size
  family, and is now called from BOTH mappers — anthropic.py had no content
  branch at all, so BYOK Anthropic had the bug entirely untreated.
- ContentTooLargeError subclasses ContentValidationError so existing callers
  (the no-retry rule, the host's history repair) keep working unchanged; the
  distinct class NAME is what survives the remote/pod wire.
- The session raises it terminally, after the dangling-tool_use seal so a
  mid-tool-round refusal cannot 400 the next turn too.
- The user-facing message carries the provider's own remedy sentence,
  credential-scrubbed and length-capped.

The size family additionally requires the provider to have mentioned an image:
misclassifying here makes the host strip every image from the conversation, so
a 400 about a too-long text field must not cost the user their images.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(llm): state the real reason size is classified before shape

Self-review: the comment claimed a size refusal 'can also carry a param that
satisfies the shape test'. Checked both live bodies — neither does (the OpenAI
patch-limit 400 carries param='input'; Anthropic puts its content path in the
message, not the param). The ordering is still right, but as a tie-break for a
body that does not exist yet, not as a description of one that does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(llm): route a 400 to the content classifier it could never reach

Review finding on #484, confirmed by execution: no HTTP 400 ever reached
_raise_for_status_error. BadRequestError subclasses APIStatusError, so the
'except openai.BadRequestError' clause at each call site matches first, and its
bare 'raise' re-raises out of the whole try — a sibling except cannot catch an
exception re-raised from its own handler. The classifier this PR added was
unreachable on exactly the path the incident came from.

ENG-1992's branch had been unreachable the same way since it shipped; the
repair users actually got came from cowork-server independently matching the
provider's message text, not from anton's type.

- _raise_for_bad_request() classifies the 400s we can name (context overflow
  first, then content shape/size) and returns otherwise, so the caller
  re-raises the SDK error exactly as before. Six call sites across both
  providers now route through it; the anthropic twin had the identical shape.
- Deliberately NOT 'send 400s through the full ladder': classify_transient maps
  a 400 carrying type=api_error to a retryable TransientProviderError
  (verified), which would silently make terminal 400s retry. Naming only what
  we can name leaves every other 400 byte-identical.
- _error_body() extracts the Gemini list-unwrap once so the 400 path and the
  ladder cannot read the wire differently.

tests/test_bad_request_reaches_the_classifier.py drives the REAL provider
methods against the real SDK over a mock transport, plus one end-to-end case
through LLMClient and ChatSession. Measured on the incident's own body:
before, 4 provider requests and the turn ending as prose carrying raw nested
JSON; after, 1 request and a typed ContentTooLargeError.

The existing mapper and session tests pass unchanged against the defect — they
sit on either side of the join, which is why neither saw it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(llm): require content corroboration before the destructive repair path

Review findings on #484.

Major: the shape predicate matched on message prose alone, so an
invalid_request_error for ANY bad enum qualified. Confirmed by execution —
reasoning_effort, tool_choice and service_tier typos all classified as content
rejections, which makes the host strip every image from the conversation's
stored history and report it fixed while the real configuration error goes
unmentioned. A bare shape phrase is now corroborated: either the param points
into a content array, or the message names a content-block type. Both live
dialects still qualify; unquoted providers fall through to the old generic
handling, which is the safe direction to be wrong in.

I had already applied this reasoning to the SIZE family (it requires the
message to mention an image) and did not carry it to the shape family I
inherited. Same hazard, same guard.

Minor: the message promised the image 'will be removed automatically so the
conversation can continue'. True only where the host implements it —
cowork-server does, the standalone CLI does not, so there the next turn
re-sent the same image forever. Rather than weaken the copy, the session now
strips image blocks from its own history before the terminal raise, making the
promise true for every host. For cowork-server it is a harmless no-op on a
history about to be discarded; for the CLI it is the difference between a dead
turn and a dead conversation. Nested tool_result images are covered too.

Negative regression tests run on the REAL provider path, per the finding, plus
an end-to-end case asserting an unrelated config error leaves the
conversation's images intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(llm): a named non-content param rules out a content rejection

Self-review of the previous commit's own fix. The corroboration guard closed
the reported reasoning_effort/tool_choice family but still let through the
nastiest member: 'modalities' legitimately ACCEPTS the value 'image', so its
enum message ('Supported values are: 'image', 'audio'') names a content-block
token while having nothing to do with content — and still reached the path
that deletes every image in the conversation.

Both live dialects are unaffected: the OpenAI one carries a content-indexed
param, the Anthropic one carries no param at all. So a param that is present
and is NOT a content path is positive evidence the rejected thing was not
content, and the phrase+token route now applies only when the provider named
no param.

Also verified the previous commit's history repair by execution rather than
by reading: idempotent on its own output, preserves tool_use_id, leaves
non-list and image-free content untouched, and the repaired history passes
_validate_history_for_provider.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(session): repair both image shapes, and persist the repair

Two review gaps on #484, both confirmed by execution.

image_url: turn_stream accepts the OpenAI-shaped block as public input and the
provider translates it onward (openai.py:524), so stripping only the Anthropic
'image' shape left the other in history to be re-sent on the next turn — the
exact failure the repair exists to stop. This file already treats
('image', 'image_url') as the pair at its two other image sites; the repair now
matches that idiom instead of inventing a narrower one.

Persistence: the turn-end _persist_history() is below this raise and never
runs, and close() does not save either, so the repair lived only in memory and
/resume reloaded the original image and repeated the refusal. Saved explicitly
when anything was removed, wrapped because this is the error path and an escape
would convert a handled, card-mappable failure into an unexpected one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ENG-2175: Check .pptx and .docx artifacts before the agent hands them over

A generated deck did not open, and the agent said it had validated it.
The artifact lint registry checked .xlsx and .html only; .pptx and .docx
went out unexamined.

- ooxml_lint.py: stdlib zipfile + ElementTree structural check. Flags a
  truncated or non-zip file, a corrupt member, missing [Content_Types].xml
  or _rels/.rels, a part with no content type, a relationship to a missing
  part, malformed XML, an r:id with no relationship, and a package of the
  wrong kind. None only when the file cannot be read.
- office_open_check.py: LibreOffice PDF export as the "does it open"
  oracle, reusing the xlsx oracle's discovery and conversion. None when
  LibreOffice is absent or times out.
- Registry: .pptx and .docx run structure first, then the oracle only if
  the structure is sound. Findings reach the agent through the existing
  [artifact lint] block.

Co-Authored-By: Claude

* ENG-2175: Fix false passes and false flags in the deck and document check

- A part too large to parse, or a package too large in total, now reports
  "could not check" instead of clean. Before, an oversized broken
  [Content_Types].xml returned no findings.
- Strict OOXML relationship URIs are accepted.
- Part names compare case-insensitively, as OPC requires.
- New test pins the zip checksum pass on a damaged image, which no XML
  parse would catch.
- Comment on macro-enabled content types corrected.

Co-Authored-By: Claude
* ENG-2121: Key turn_completed on the account when the host knows it

turn_completed carried only the install fingerprint, so completed work could
not be joined to sign-up, payment or the staff/test exclusion.

- ChatSessionConfig gains user_id and organization_id (Keycloak sub and
  active org). Hosts supply them; only UUIDs pass, anything else (an email
  address being the likely mistake) is dropped without being logged.
- turn_completed carries both, and a non-empty user_id becomes the event's
  distinct_id, the same key the console, the desktop renderer and the auth
  service's billing mirror use. $process_person_profile stays false, so the
  event attaches to an existing person without creating or updating one.
- The cloud pod reads both from the trace block, like surface, and keeps them
  out of the per-turn Langfuse metadata.
- The CLI supplies neither and keeps the install fingerprint.
- User-facing analytics and security docs now say the Cowork turn event
  carries the opaque account and organisation ids.

Co-Authored-By: Claude

* [ENG-2121] Check the account id at the PostHog sender too

The sender now keeps a user_id or organization_id only if it is a UUID, and
uses only a valid user_id as the distinct_id, so a future caller cannot send
free text such as an email address. The privacy page's heading now reads
"Pseudonymous analytics", since the per-turn event carries account ids.

Co-Authored-By: Claude
… not a repoint (#490)

* test(verifier-eval): an alias echoed as the served model is "not disclosed", not a repoint

ENG-2892. The mindshub_inference release of 2026-09-21 (#535) changed the
client-visible `model` of a chat completion to a configured response model,
which for the eval's aliases is the alias itself: `haiku` -> "haiku",
`mindshub_air` -> "mindshub_air", where the 18th returned the resolved ids.
Langfuse still records the resolved ids server-side; there is no response
header carrying them.

_check_served_model raised AliasRepointed on every call — "alias 'haiku' now
serves 'haiku'" — so every verifier-touching PR went red (first: #486,
2026-09-21 15:36 UTC, 24 failed in 59s; last green 07:47 the same day).

Now an echo is recorded as "<alias> (alias echoed — served model not
disclosed)" so the served-models report shows the identity check is blind
for that slot, and it does not raise. A genuine repoint — a different real
id — still fails, and the pinned id is still accepted verbatim. Gate test
covers all three.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(verifier-eval): confirmed pin match wins over the alias-echo test; the echo is ENG-2751, by design

Self-review of #490:

- The echo branch ran before the pin comparison, so a one-off run whose
  alias is itself a real id (VERIFIER_EVAL_*_MODEL=gpt-5.6-luna) recorded
  "gpt-5.6-luna (alias echoed — served model not disclosed)" although the
  gateway had disclosed. Pass/fail was unaffected; the report lied. The
  confirmed match is now recorded verbatim first; gate test pins it.
- The comment framed the blindness as waiting on the gateway to "grow a
  header". It is not a gap: ENG-2751 decided on 2026-09-15 to hide the served
  model (mindshub_inference#553), across the whole catalog and failover
  routes, and the public docs now say so. A customer-visible header would
  undo that decision. Re-arming ENG-1687 needs a channel customers cannot
  see — Langfuse still records the resolved id server-side. Comment and
  docstring corrected; the eval also cannot tell when a fallback backend
  served a matrix call, and now says so.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…rror (#497)

* fix(scratchpad): name the kill in single-line readers of a cell error

A killed cell's error ended with fixed MySQL/Snowflake cancel advice,
appended to every kill whatever the cell ran. Readers that keep one line
of a cell error took the last one, so the root-cause classifier filed
every kill as `unclassified` and the memory consolidator turned PDF-read
kills into Snowflake lessons.

Drop the advice, and read the first line of a runtime kill message (which
leads with its cause) while tracebacks keep their last line. Applies to
the streaming exec's tool reason, the consolidator summary, the notebook
view and the CLI demo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(scratchpad): lead a cancelled cell's error with "Cancelled"

A cancelled exec raises a CancelledError whose str() is empty, so the kill
message began with a bare ". ". Not starting with a kill prefix, its
headline fell to the partial-output note, and the kill was not recorded
as one. Name the cancellation, matching the user-cancel path.

Also drops ticket references from comments this branch rewrote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
typer>=0.12.4 pulls in click 8.3.1, vulnerable to CVE-2026-7246
(SNYK-PYTHON-CLICK-16347201, PoC exploit maturity): click.edit()
invokes a subprocess with shell=True over an attacker-controlled
filename, letting a maliciously crafted filename with embedded
double-quoted strings inject OS commands. typer doesn't pin click
itself, so a direct >=8.3.3 constraint forces the fix.

Neither anton's own CLI code calls click.edit()/launch() anywhere
(confirmed via grep) nor does it expose that path through typer's own
API, so this is a low-blast-radius pin despite anton being a
typer-based CLI itself.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
anthropic/openai/httpx2 all pull in anyio 4.12.1, vulnerable to
GHSA-8bx3-mgpq-m8m9: TLSStream/connect_tcp() encode internationalized
host names per IDNA 2003 rather than IDNA 2009, so a connection to an
internationalized domain that's been hijacked/redirected can present a
certificate obtained under the IDNA-2003 encoding and still validate.
None of the three consumers pin anyio themselves, so a direct
>=4.14.2 constraint forces the fix.

uv lock also pulled typing-extensions 4.15.0 -> 4.16.0 as a floor
anyio 4.15.1 now requires; verified no other package versions moved.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
… traversal CVE (#496)

pydantic-settings==2.13.1 is vulnerable to GHSA-c8vh-cgw2-cfmc:
NestedSecretsSettingsSource's size check (Path.glob) and loader
(glob.iglob) disagreed on whether to follow symlinked directories
inside secrets_dir, so a symlink pointing outside secrets_dir was
invisible to the secrets_dir_max_size accounting while still being
fully read by the loader — an out-of-tree local file read plus a
size-cap bypass, when secrets_nested_subdir=True. 2.14.2 is the fix.

We don't use that source anywhere in this repo (no secrets_dir /
secrets_nested_subdir / NestedSecretsSettingsSource, confirmed via
grep) so this isn't reachable here, but the floor bump costs nothing.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…G-969) (#335)

* Feat/stateless backend creation tool (#242)

* tool for artifacts generation

* make api spec in json, not md

* do not save api spec in file

* fix prompt

* prompts

* del `data_refs` from `generate_artifact`

* tool update

* tests

* fix issues and logging

* make generate_artifact the primary path for html and fullstack artifacts

* ENG-969: generate_prd — PRD generation tool (#321)

* feat: ArtifactStore.update() can change artifact type

* feat: raise the shared per-turn question budget from 3 to 8

* feat: generate_prd sub-tool schemas and direct-elicit ask_user dispatch

* feat: generate_prd phase 1 gathering loop

* feat: generate_prd phase 2 steps (draft_brief, show_and_confirm, classify_feedback, write_prd)

* feat: generate_prd orchestrator.run — full two-phase sequence

* feat: register generate_prd as a tool

* feat: point create_artifact's description at generate_prd for web artifacts

* fix: draft_brief/write_prd prompts — lead-in sentence, no process meta-commentary, no technical detail in the short brief

Live-testing feedback on ENG-969: the short brief landed on the user with
no framing, echoed the tool's own regeneration framing into Goal, listed
data sources not used, and leaked CSS/JS implementation detail (clamp(),
hex colors) into a document meant for a non-technical reader.

* fix: remove Cyrillic from source and docstrings (project convention: code in English)

- prompt instruction/label text: 'Тип артефакта' -> 'Artifact type',
  'Принять'/'Отменить' button labels -> 'Accept'/'Cancel'
- docstrings referencing prd-design.md section titles translated to English

* fix: allow multiple lines in the brief's Data model section

'One short line' was too strict for multi-table cases (e.g. a dashboard
reading several DB tables) — the actual intent was 'no verbose negatives
and no connection details', not a hard one-line cap. Multiple sources now
each get their own short line.

* feat: default-value support for choice questions — Enter accepts the PRD brief

AskRequest gains default_value: the value of an option chosen when the
user presses Enter with no input, instead of the channel's usual
'cancelled'. CLIElicitor._ask_choice forwards it to prompt_or_cancel
(which already substitutes it on blank Enter and shows it in the prompt
suffix) and now also resolves a directly-typed option value, not just
its number.

generate_prd's show_and_confirm sets default_value="accept" — a bare
Enter now continues instead of cancelling, matching what users actually
do most of the time. draft_brief's prompt also asks the model to close
the brief with a short in-language line making that explicit.

ask_user's own JSON schema is untouched — only orchestrator code that
builds AskRequest directly (select_path, generate_prd) can set a
default; the LLM never picks one on the human's behalf.

* feat: compact rendering for self-explanatory choice prompts

Live-testing feedback (ENG-969): the PRD brief ends with its own
'continue, or changes?' sentence, then repeats the same choice as a
numbered Accept/Cancel list plus a descriptive input caption — pure
noise once the sentence already explains the mechanic.

AskRequest.compact (opt-in, off by default) tells StreamDisplay.show_question
to skip the option list and CLIElicitor._ask_choice to drop the
descriptive caption in favor of a bare input point; prompt_or_cancel's
own default-value suffix (e.g. '(accept):') still carries the hint.
Parsing is unaffected — numbers, typed values, and free text all still
resolve the same way.

generate_prd's show_and_confirm sets compact=True. Every other
ask_user/select_path caller is untouched.

* fix: restart the spinner after ask_user before the next direct LLM call

elicit() stops the host spinner (phase="interactive") for ask_user/
show_and_confirm but nothing restarts it — the outer agent loop's own
reasoning_start signal never fires for generate_prd's direct
_llm.plan/code/generate_object calls, since they run outside that
loop's tool-round machinery. Made the gap visible: after answering the
brief confirmation with free-text feedback, the UI showed nothing for
a couple seconds until the revised brief appeared.

Add sub_tools.signal_thinking() and call it before every direct LLM
call in phase 1's gathering loop and phase 2's draft_brief/
classify_feedback/write_prd.

* fix: restart the Live spinner context on reasoning_start, not just update it

phase="interactive" tears down the spinner's Live context entirely
(_stop_spinner sets _live = None) so the user can type. The preceding
fix (signal_thinking, previous commit) emits reasoning_start right
after an ask_user/show_and_confirm answer, but that phase's handler
only called _update_spinner(), a no-op once _live is None — so the
event reached the display and still produced no visible spinner.

Elsewhere reasoning_start already finds a running Live (a tool-result
line printed in between implicitly restarts it), which is why this
gap went unnoticed until generate_prd's direct LLM calls exposed it.

* fix: constrain finish_gathering's artifact_type to the closed enum

Nothing stopped the model from inventing an artifact_type string
outside ArtifactType's closed set (html-app, document, dataset,
image, mixed, fullstack-stateless-app, fullstack-stateful-app) — the
tool schema had no enum and the gathering system prompt never listed
the valid values. An invented type sailed through unvalidated until
write_prd's ArtifactStore.update(type=...) call, which raises
ValueError for anything outside the enum — crashing the whole
generate_prd call with a message the outer agent sometimes
misreads as a transient glitch worth silently retrying, producing an
apparently-stuck "same brief shown again" loop.

Add the enum to FINISH_GATHERING_SCHEMA, list the valid types in the
gathering system prompt, and — since a schema enum is a hint, not an
enforced constraint — fall back to the originally registered type in
engine.py if the model returns one outside it anyway.

* feat: step/LLM-call trace logging for generate_prd

Mirrors generate_artifact/debug_trace.py's GenTrace/NullTrace shape and,
deliberately, reads the SAME ANTON_DEBUG_ARTIFACT_GENERATE_TOOL env var:
the two tools are the two steps of one end-to-end artifact-generation
pipeline (PRD, then code), and both loggers append rather than truncate,
so running them back to back writes one combined, chronologically
ordered log — viewable as-is in artifact_trace_viewer.html.

Instruments phase 1's gathering loop (every LLM call, ask_user,
scratchpad/web_search/web_fetch dispatch, and how gathering ended) and
phase 2's steps (draft_brief, show_and_confirm, classify_feedback,
write_prd). generate_prd.generate() wraps orchestrator.run with
run_start/run_result, including on a crash.

Off by default (NullTrace) when the env var is unset — no behavior
change for existing callers.

* fix(generate_prd): drop the input-affordance hint from the brief

The brief's closing line asked the model to spell out that a bare Enter
means "continue". That hint is CLI-specific text living in host-agnostic
content: the same line is rendered in the terminal, where
`prompt_or_cancel` already prints the `(accept)` default, and in a GUI,
where the host draws Accept/Cancel buttons and there is no Enter to
press. It was also LLM-generated, so its wording, language and very
presence drifted between runs — an input affordance belongs to whatever
code draws the input.

The instruction already forbade inventing accept/cancel option labels for
the same reason; the Enter hint just was not covered by it. Keep the
closing question itself (added from ENG-969 live-testing feedback, and
what makes `compact=True` legible) and forbid describing how to answer.

* feat(generate_artifact): report each FSM step's start as tool progress

ENG-970. A fullstack generation runs for minutes and emitted nothing to
the UI for the whole time, which reads as a hang.

`handle_generate_artifact` becomes an async generator so it can use the
streaming-tool protocol from ENG-763: `dispatch_tool_stream` forwards
every yielded `ToolProgress` and takes the last non-marker item as the
tool result, so all the existing return paths are unchanged apart from
`return` becoming `yield`+`return`. That protocol was built and tested but
had no production user until now.

Yielding rather than calling `session.emit` directly (the way
generate_prd's `signal_thinking` does) is what gets the marker its
originating `tool_use` id: a handler never sees that id, only
`dispatch_tool` does, and it stamps relayed markers with it. Without the
id a marker is not the "first per id" the cloud wire never drops, so it
falls under the rate limit, and a step UI has nothing to correlate it to.

Progress travels up on an `asyncio.Queue` because the FSM cannot yield
from the handler: steps start several frames down, including inside the
`asyncio.gather` that generates backend and frontend at once. The
channel's sentinel is pushed from a `finally`, so a crashed generation
closes the drain loop instead of hanging it; the handler cancels the
generation task from its own `finally`, so a cancelled turn does not
leave the FSM writing files nobody will collect.

`GenState.step_started` is deliberately separate from `record`, which
fires when a node is already done — the two longest nodes would otherwise
report only in hindsight. `progress.py` maps node names to plain-language
lines, since graph vocabulary (`is_data_enough`) must never reach a user;
an AST test over orchestrator.py fails if a call site has no label.

* feat(generate_artifact): build from the confirmed PRD, not just the brief

`generate_prd` wrote prd.md and nothing ever read it. The artifact was
built from whatever the calling agent chose to put in `context`, so the
document the user actually reviewed and accepted had no effect on the
result — the two tools were a pipeline only in the tool descriptions.

The generator now reads prd.md from the artifact folder itself. That
needs no new schema parameter: the handler already resolves the folder
from `slug`, so the calling agent cannot forget to pass the PRD,
mis-transcribe it, or paraphrase it. Every failure to load degrades to
"no PRD" rather than stopping the run — an agent may legitimately skip
the PRD step, artifacts created before ENG-969 have none, and a file
that cannot be read must not cost a generation `context` alone can still
complete. Which mode ran is recorded, so a wrong-looking artifact can be
traced back to the requirements it was really built from.

`context` is redefined as a supplement rather than a competing source of
truth. Its `## Functional Requirements Specification` section is gone —
requirements live in the PRD, and two copies invite disagreement — and
`## Data` is narrowed to data that already exists in scratchpad cells,
with `### Sample` rows only from cells actually run. Expected-but-unfetched
sources are what the PRD describes; repeating them here is a guess
competing with an accepted document. All three surfaces that state this
contract are updated together, and the tool prompt now names generate_prd
before generate_artifact — without that step the model has no reason to
believe a PRD exists and every run would take the fallback.

Pad matching now searches the PRD alongside the brief: generate_prd must
cite the scratchpad and cell behind every source it describes, so on the
normal path the PRD is where a pad name appears at all. Cells the PRD
points at land in `data_notes`, which lets `is_data_enough` answer from
what is already there instead of starting its own fetch loop.

PRD_FILENAME is shared by the writer and the reader: a literal in both
packages could drift apart silently, and the reader would then simply
find nothing and build from a brief the user never confirmed.

* fix(prompts): route the always-on artifact workflow through generate_prd

`grep generate_prd anton/core/llm/prompts.py` came back empty: every
always-on block sent the agent straight from `create_artifact` to
`generate_artifact`, while `create_artifact`'s own description said to
draft a PRD first. Which instruction won was luck.

Since the generator now takes its requirements from the `prd.md` that
`generate_prd` leaves in the artifact folder, this is no longer a missing
step but a broken one: a run reached that way has no PRD at all and
silently falls back to building from `context` — which, with the
requirements section removed from it, carries considerably less than it
used to.

ARTIFACTS_PROMPT's workflow grows a PRD step ahead of generation, and
states that the generator reads prd.md itself so the agent does not quote
or paraphrase it back into `context`. The two visualization blocks and
BACKEND_GENERATION_PROMPT get the same three-call normal path.

A test locks the ordering across all four blocks. `generate_prd` has no
`ToolDef.prompt` of its own, so these blocks and its tool description are
the only places the model can learn the step exists — a new always-on
artifact block would otherwise omit it again.

* fix(artifacts): keep generation inputs and backend.log out of files[]

`_reconcile_files` re-derives `files[]` from disk and excluded only the
store's own files, so `prd.md`, `spec.md` and `openapi.json` counted as
artifact content: they inflated file_count, appeared in the rendered
README, and invited the agent to hand the user a specification as the
thing it built. They are what the generator built FROM.

Publication was not affected, contrary to what the handoff assumed — no
publish path reads `files[]`. An html-app publishes its primary file plus
the siblings the HTML references, and `_zip_fullstack` bundles an explicit
allowlist (backend.py, requirements.txt, static/**), so root-level inputs
never entered a bundle. (`_zip_html`'s directory branch would take them,
but no artifact path reaches it.)

`backend.log` goes in too. The set is documented as mirroring
cowork-server's artifacts service, and that service, `publish_access` and
the publish bundle all excluded the launched backend's runtime log — the
store was the one copy that did not, which is precisely the copy the agent
and the UI read their file list from.

Generation inputs stay in a set of their own rather than being folded into
the housekeeping one: they are authored by the generation tools, not owned
by the store, and merging them would quietly falsify the mirror claim.
Exclusion is by relative path, so a `static/openapi.json` the artifact
genuinely serves remains its own content.

The three filenames now live together in `artifacts/internal_files.py`
(renamed from prd.py), because spec.md and openapi.json were literals in
the orchestrator — the exclusion set and the code writing the files would
have drifted apart on the next rename.

* fix(generate_artifact): stop feeding a truncated spec to the generators

ENG-1116. `make_tech_spec` and `make_api_spec` each produce a whole
document in one call on the client's default 8192-token budget, and
neither checked for truncation. The two then failed very differently:
a cut API spec broke `json.loads` and was reported as "not valid JSON",
blaming the model's syntax for an output-cap hit, while a cut tech spec
was written to spec.md and handed to backend and frontend generation by
`_spec_context` — half a spec building half a system with nothing
anywhere reporting the loss. That silence is the defect, not the length.

Both calls now run with an explicit budget and, when cut off, are re-asked
once with more room plus an instruction to write compactly. The re-ask has
to change the call: the main loop's own recovery measured three unchanged
retries dying identically. If the second attempt is also cut, the node
returns an error naming the output limit — nothing partial is written, and
the API-spec path reports the cap rather than the JSON parse that follows
from it.

The budgets are measured, not chosen: against api.mindshub.ai's `opus`
alias, 20480 answers normally and 24576 and above return HTTP 500. A 500
is classified as a transient provider error, so an over-large budget does
not fail fast — it burns the retry ladder on every generation before
dying. 16384/20480 keeps real headroom (reasoning models spend thinking
from the same budget) while staying inside what the gateway serves; both
constants carry the measurement and must be re-measured before a raise.

Truncation is detected with the shared `looks_truncated`, which also
honours stop_reason — the gateway has reported it correctly since
2026-08-03, and a cut that stops just under the cap is invisible to a
token count alone.

* feat(generate_artifact): build stateful artifacts on the anton_state STATE store

Align the generation pipeline with the STATE SDK model merged from staging
(#259). Before this, the pipeline still carried the old stateful model (a
local sqlite file in the artifact root), and a backend written to the new
contract could not pass verification at all.

- _STATEFUL_RULES rewritten to the STATE contract: module-level STATE = None
  slot, store built at point of use via get_store(), Collection-first API,
  no scan / no secondary indexes, atomic increment/update, no manual retry
  around mutations, never list anton_state in requirements.txt, and the flat
  state_manifest.json format (written by the generator sub-agent via
  write_file; validated by the verifier, so hand-written JSON is safe).
- Stateful backend task is now three files; step injections chain
  backend.py -> state_manifest.json -> requirements.txt.
- verify_backend runs its introspection subprocess with build_backend_env
  (anton_state on PYTHONPATH, same injection the launcher uses) — without it
  a correct stateful backend failed its own SDK import. The env builder went
  public in backend_launcher; the underscored name stays as an alias.
- evaluate_backend gains artifact_type/state_manifest kwargs and six new
  contract errors: anton_state listed in requirements (any type); for
  stateful — missing STATE slot, missing/invalid manifest (validated via
  anton_state.schema.StateSchema), store built at import time (AST check);
  for stateless — anton_state imported at all. The requirements parser also
  drops anton_state before install, mirroring the launcher.
- Spec prompts know about durable state: api-spec gets a stateful
  counterpart to the stateless constraint (key-per-access-pattern, one
  query per listing), tech-spec pins the STATE store in the fixed stack so
  spec.md stops inventing sqlite.
- Type-selection surfaces rewritten to the new model: create_artifact's
  fullstack-stateful-app description, the ARTIFACTS_PROMPT paragraph (the
  staging merge had left it self-contradictory), and generate_prd gathering
  criteria; the PRD data-model sections now capture app-owned durable state.
- Store/publish_access housekeeping sets exclude the local STATE driver's
  .anton_state.db(-wal/-shm) and the publisher's schema snapshot;
  state_manifest.json itself stays a visible deliverable. cowork-server's
  mirror copy still needs the same names.
- Contract-lock RULES extended for the six new verifier messages; 19 new
  tests (pure checks, subprocess integration incl. a PYTHONPATH-injection
  proof, orchestrator wiring, store exclusions, prompt locks).

Known limitation: a live end-to-end run is currently impossible — the
api.mindshub.ai gateway WAF returns 403 on any request body containing
"<script" (pre-auth), which breaks all artifact generation including the
pre-existing html path. See claude_workspace
docs/artifact-generation-tools/tracking-stateful.md (S-13).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(generate_artifact): stream plan/code calls to avoid Cloudflare 524 on long generations

api.mindshub.ai sits behind Cloudflare, which kills a proxied connection
after ~100s of silence (524). The FSM's spec/code-writing calls used the
one-shot plan()/code(), which sends no bytes over the wire until the whole
completion is ready — a large tech spec or a full HTML/JS file easily
exceeds that window and the run dies with "the model provider returned
524", discarding an already-confirmed PRD.

Swap _plan_whole_document and _run_loop onto the existing plan_stream(),
plus a new code_stream() (mirrors plan_stream(), previously only code()
had no streaming sibling). A small _drain_stream() helper consumes the
token-level deltas and returns the terminal StreamComplete's response,
so callers keep the exact same LLMResponse shape they had before. Bytes
now flow continuously regardless of total generation length, so the proxy
never observes silence.

generate_prd has the identical plan()/code() call sites and is exposed to
the same failure mode; left out of this change to keep it scoped to the
reported incident.

* fix(llm): escape <script>/</script> in requests to the MindsHub gateway (ENG-1986)

api.mindshub.ai sits behind a Cloudflare WAF rule that 403s any request
body containing the literal `<script` or `</script` (case-insensitive,
even with injected whitespace) before authentication. html-app generation
necessarily asks the model to emit `<script>...</script>` tags, and once
written the content is echoed back into the conversation history on every
later round, so the block was unavoidable through prompt wording alone.

OpenAIProvider.complete()/stream() now escape `<script`/`</script` to
`<_script`/`<_/script` in the outgoing system prompt and messages, and
reverse it on the incoming response content and tool-call input, so
genuine `<script` never appears in a request body but callers still see
real tags. The marker was verified against the live gateway to fall
outside the WAF rule's match. Scoped to FLAVOR_MINDS_PASSTHROUGH only —
no other provider flavor talks to this gateway.

* fix(generate_artifact): exempt reduced-motion/print media from the universal !important rule

The `* { ... !important }` check fired on the standard accessibility reset
inside `@media (prefers-reduced-motion: reduce)` — a block models emit
reflexively and one that cannot override host styles, which is what the rule
exists to prevent. Live run 2026-08-27: an otherwise valid 14-slide
presentation was rejected on exactly this false positive.

Universal !important blocks are now allowed inside `prefers-reduced-motion`
and `print` media queries (block spans found by brace counting — nested rule
blocks must not truncate or extend the span). Everywhere else the rule and
its wording are unchanged, so the contract lock stays intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(generate_artifact): separate generation-loop and verification retry budgets

One shared `range(GEN_VERIFY_MAX_RETRIES + 1)` counted a loop failure (round
budget, no tool calls) and a verification failure as the same kind of attempt.
Live run 2026-08-27: attempt 0 died on the round budget before any verifier
ran, attempt 1 failed verification on a one-line CSS fix — and the run was
terminal, because the loop failure had already burned the only retry meant for
acting on verifier findings.

`GEN_LOOP_MAX_RETRIES` and `GEN_VERIFY_MAX_RETRIES` now count independently in
both `_gen_verify_frontend` and `_gen_verify_backend` (worst case three
attempts instead of two). The terminal message names the budget that actually
ran out: "verification failed after N attempt(s)" only when the verifier
rejected, "generation failed" otherwise — the old wording claimed a
verification retry that, on the loop-failure path, never happened.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(generate_artifact): salvage round-budget output and tighten the generation loop

Four changes to `_run_loop` and its messages, all from the 2026-08-27 live
run, where a complete 48 KB page was deleted and regenerated because the loop
ran out of rounds counting its own slides:

- Round-budget exhaustion with files on disk now returns a normal result dict
  (`finished: False`) instead of an error, so the caller verifies what was
  actually written. A missing `finish` call is not evidence the files are bad.
  With no files written it stays an error.
- Every tool-result message now carries a `[N round(s) left]` note, switching
  to an explicit "wrap up NOW" instruction on the last five rounds. The model
  had no way to see the budget it kept dying against.
- Truncation detection honours `stop_reason` (`length`/`max_tokens`) alongside
  the token count, and reads the client's public `max_tokens` property instead
  of the private `_max_tokens` (I-08). The rejection messages name a concrete
  next-chunk size (4,000 chars) instead of "well under ~6 KB" — the vague
  form did not stop either live-run attempt from burning a full 8192-token
  reply on an oversized append.
- `read_file` passes the new `full` flag through to the sub-tool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(generate_artifact): read_file returns size+tail by default; write_file warns on oversized chunks

`read_file` existed so the model could check what landed, but returning the
whole content meant re-reading a 48 KB page into the context (and through the
prompt-cache prefix) just to confirm it ends with `</html>` — measured
2026-08-27 at ~19k input tokens per check. It now returns the size plus the
last 500 characters; `full=true` (advertised in the schema as expensive)
returns everything, and files at or under the tail size come back whole.

`write_file` still accepts an oversized chunk — the call that arrived fit
under the output cap by definition — but its result now warns that the next
one may not: in both live-run attempts the model followed a lucky oversized
chunk with a bigger one and lost a full 8192-token reply to truncation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(generate_artifact): compact tech spec next to a PRD; tell the model verification is code's job

Three prompt changes, all measured against the 2026-08-27 live run (929 s,
failed):

- html-app with a confirmed `prd.md`: `make_tech_spec` is told the generator
  receives the PRD verbatim next to its document, so it must not restate it —
  only `## Insights` plus terse implementation notes. The full spec was
  near-pure duplication (190 s / 13k output tokens restating a 20 KB PRD as
  35 KB of spec that then rode into every generation prompt). Fullstack types
  keep the full spec — it feeds the API design.
- `_ROLE_WRITE` states that a deterministic verifier checks the output after
  `finish` and re-prompts with exact errors on failure: re-reading and
  re-counting one's own files is what burned nine of twenty rounds.
- `_WRITE_DISCIPLINE` names a hard 6,000-character chunk limit that explicitly
  covers the first `mode="w"` chunk and the first append — the two calls that
  actually got cut — and `_ROLE_COMMON` forbids creating new scratchpad names
  (the once-per-turn challenge in `handle_scratchpad` makes the rejection
  non-deterministic across attempts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(generate_prd): keep long-form source content out of the PRD

The 2026-08-27 live run retold one article three times on the way to the
artifact: scratchpad pad → prd.md (20 KB) → spec.md (35 KB) → index.html.
Only the last copy is necessary. `_WRITE_PRD_INSTRUCTION` now forbids copying
long-form source content that already lives in scratchpad cells — the PRD
describes the structure (e.g. a slide outline, one line per slide) and cites
the pad and cell; the generator reads those cells directly. Short samples
stay allowed, and the existing Data-model requirements (connection code,
sample rows) are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(llm): widen the ENG-1986 script-tag unescape to model-mutated variants

The WAF escape is visible to the model: it reads `<_script`-style forms in
its system prompt and in its own echoed chunks, and reproduces the underscore
in positions the strict reverse pattern does not cover. Live run 2026-08-27:
a generator closed its only script block with `</_script>` (underscore after
the slash), the unescape left it untouched, and the mangled tag reached the
file on disk — the page's JavaScript never ran, and the calling agent spent
most of its token bill discovering and hand-fixing it.

The unescape now normalises every underscore variant (`<_script`,
`<_/script`, `</_script`, `<__script`, `<_/_script`) — none of these is
meaningful anywhere else, so being wider than the escape is safe. The escape
side is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(generate_artifact): verifier rejects mangled and unclosed script tags

Two new frontend rules, both from the 2026-08-27 live run, where a page
whose only script block was closed with `</_script>` passed verification and
shipped with all of its JavaScript disabled:

- any underscore variant of a script tag (`<_script`, `<_/script`,
  `</_script`) is an error — residue of the ENG-1986 WAF escape that the
  provider-level unescape may not have caught;
- an opening `<script` with no closing `</script` anywhere in the document is
  an error — the general "JS never runs" class. Deliberately narrow (absence
  of ANY closer, not a count mismatch): a literal `"<script>"` inside an
  inline-JS string legitimately unbalances the raw counts.

Both rules are announced in the HARD OUTPUT CONTRACT prompt block and added
to the contract-lock table — a conscious rule addition, not a table refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(generate_artifact): tell the calling agent not to re-verify a successful generation

After a successful run the calling agent re-verified the pipeline's work by
hand: the 2026-08-27 run spent 11 planning-model calls (each resending a 42k-
char system prompt plus the whole conversation) re-reading and re-parsing an
artifact the generator had already verified — the bulk of the run's token
bill. The success payload now carries an explicit instruction: the files were
statically verified, do not re-read or re-verify them, act only on problems
the user actually reports.

Same nudge one level down: `read_file`'s schema now says `full=true` is never
for verifying finished work — the inner loop pulled the entire 40 KB page
back into its context right after the size+tail check said it was complete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(generate_artifact): drop the absolute-URL rule for href/src

verify_frontend rejected any absolute `href`/`src` outside a `<script>` tag.
Two consecutive live runs failed on it, both wrongly: a dashboard built from a
web article links back to its source and shows that source's images, and the
PRD the user had already accepted asked for exactly those.

The retry then "fixed" it by moving the same URLs into JS strings rendered
through innerHTML — identical DOM, one full regeneration burned (~50% of that
run's tokens), and the model now knows the workaround. A check that a correct
artifact fails and an incorrect one passes is worse than no check.

Only fetch() keeps the rule: a hardcoded host there breaks the artifact the
moment it is published, because the backend it names is the local one. No
warning replaces the removed rule — of the remaining cases, a `<link>` to a
font degrades appearance without the network, and an `<img>` from the source
article IS the content.

The contract changes, so the Rule is removed from RULES in the contract lock
deliberately, and the bullet is removed from _VISUAL_RULES: that block is
declared to the model as "a static verifier checks each of these", and leaving
a rule there that no longer exists is a shipped contradiction.

Restoring this needs a check that sees the rendered DOM, not the HTML text.

Co-Authored-By: Claude <noreply@anthropic.com>

* perf(generate_artifact): raise the write-round output budget, survive dropped streams

The generation loop ran every round on the client default of 8192 output
tokens, a quarter of what the gateway accepts. A 41 570-character artifact
(16 063 tokens) therefore had to be written in ~8 chunks, and each chunk is
re-sent on every later round — 23% of the node's context was chunk bodies
being paid for again.

Measured 2026-08-28 against api.mindshub.ai:
  - 20480 is accepted on BOTH aliases. The note claiming 16384 for the coding
    model was wrong; its ceiling is the same 20480.
  - One write_file call at that budget delivered 50 402 characters of Russian
    HTML in 15 754 tokens.

Round 0 deliberately does NOT get the raised budget. It runs on the planning
model, ~2.3x slower per token, and at 20480 a write there runs long enough to
lose its connection — 4 failures out of 4 at 131-143s. At 8192 the same call is
merely truncated, which the loop recovers from.

Truncation is now judged against the budget the round actually ran on. Against
the client default instead, every reply over 8192 tokens would be called
truncated and its last tool call rejected — the exact failure the raised budget
exists to remove.

Dropped streams are retried once, halving the budget. A large tool-call
argument is NOT streamed incrementally: the connection carries nothing for the
whole generation and everything arrives in one burst (112s of silence for a
59 000-character argument). That profile is identical when talking straight to
api.anthropic.com, so it is not the gateway's doing and cannot be fixed here.
Whether such a call survives is a race against the proxy's idle timeout, so a
retry on the same budget would mostly reproduce the failure; half the budget is
half the silence. Nothing has executed when a drop happens, so the retry cannot
double-apply a write.

The chunk limit moves 6 000 -> 16 000 characters and is now derived from
duration, not from the token budget: ~170 tok/s and ~2.59 characters per token
on Cyrillic prose gives ~37s of silence, a ~3x margin against the shortest
observed drop. The number was written out in four places the model reads; all
four now read the one constant, as does the halved recovery size.

test_write_discipline_names_a_hard_chunk_limit was passing vacuously:
"6,000 characters" is a substring of "16,000 characters", so it would have
stayed green through this change while checking nothing. It now asserts against
the constant, and a new test pins the limit across all four surfaces.

httpx becomes a declared dependency: already installed as a hard requirement of
both SDKs, but now imported directly to catch transport errors by type.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(deps): lock httpx, declared in 6cfdb717 but never locked

`uv lock --check` is the first step of run-tests and it failed the whole job in
12s: pyproject gained `httpx>=0.27` (needed to catch transport errors by type
when a tool-call stream dies mid-generation) without a matching uv.lock update.

Regenerated with `uv lock`. The diff is exactly the two lines that declare
httpx as a direct dependency — no version churn elsewhere, since httpx was
already in the graph as a transitive requirement of anthropic and openai.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(generate_artifact): SpendGuard and a public spend-ceiling read (I-20)

generate_artifact runs a whole pipeline inside one tool-use round, and the
spend ceiling is only checked between rounds. A measured run crossed the
threshold mid-call, spent ~1.25M tokens over 35 LLM calls unobserved, and
only tripped the ceiling once the money was gone.

SpendGuard gives the pipeline a place to look. It adds no accounting of its
own — TurnCost already sees every internal call — and holds a single sticky
latch. The closing-round budget deliberately lives in each write loop, not
here: the fullstack path runs two loops concurrently and a shared counter
would leave each with too few rounds to close its file.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(artifacts): register discovery.json as a generation input file

The merged pipeline persists its phase boundary — fingerprints, stage,
declared sources, brief, rendered notes — so a repeat call can resume
without re-gathering. That file lands next to dashboard.html, so it goes in
the one constant both ends read: the tools that write these files and the
store that keeps them out of files[].

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(generate_artifact): discovery.json checkpoint with stage-based re-entry

Where a repeat call resumes is decided from recorded state, not inferred
from matching free text. Two independent checks: request_fingerprint answers
"is this the same work" from user_request alone — the only input field the
outer model does not re-type in its own words each call — and pipeline_stage
answers "how far did the last call get".

There is deliberately no `confirmed` flag: it is derivable from the stage,
and two fields describing one fact drift apart silently. Normalization means
whitespace noise cannot cost a full re-gather, questions to the user
included. An unreadable or unknown-shaped file degrades to "start over"
rather than raising.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(generate_artifact): web_notes channel and shared notes renderers (I-06)

The generator never had a channel for anything fetched from the web: phase A
could web_fetch an article, and the only way that reached generation was as
prose inside the PRD. render_web_notes closes that — code writes it, not a
model, so a source URL cannot go missing in a summary.

Capped well below a fetched page on purpose. The article body itself is read
by make_tech_spec, the last node that sees it; what travels onward is the
pointer plus enough text to recognise the source, because phase E re-sends
its context on every write round.

render_exec_notes moves here unchanged from the orchestrator: both notes are
the same kind of thing — a deterministic record of what discovery found —
and they now sit together.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: move generate_prd into generate_artifact/discovery, merge the trace

Mechanical move, no behaviour change: the two tools still exist and still do
exactly what they did. Kept separate from the semantic work on purpose — a
red test after this commit means the move broke something, which is a
question worth being able to answer on its own.

The two step logs become one. They already read the same env var and
appended to the same file so a back-to-back run produced one ordered log;
with one package there is nothing left to keep in sync. `run_start` becomes
open-ended rather than folding its inputs into a `brief` string: there is one
run_start per run now, so the folding has nothing left to reconcile, and a
debug log that reshapes its input cannot be compared against the call that
produced it.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(discovery): split brief and prd out of the phase orchestrator

372 lines held phase B, phase C and the sequence they run in. The merge adds
re-entry logic to that file, so the split happens before it grows, not after.

Bodies are unchanged; the sequencer keeps only the order and the revise loop.
Tests follow the code: the write_prd cases move to their own file, and the
sequencer's tests now patch `brief.sub_tools` rather than reaching through
the orchestrator for something it no longer owns.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: one GenState for all five pipeline phases

The shared message list is the point of the merge, and it cannot live in a
second dataclass without being threaded across a package boundary — which
was the strongest argument against keeping two packages. GenState absorbs
the discovery fields; discovery/state.py stays as an alias so the phase
modules and their tests did not all have to move in this commit.

Two fields stop being mandatory, and for different reasons. `brief` defaults
to empty because that is now true for most of a run: the pipeline starts at
gathering, not at a brief handed in by a caller. `is_fullstack` becomes
derived from `artifact_type` unless passed, because two fields describing one
fact drift apart silently — a state where they disagree would send an
html-app down the fullstack branch with nothing reporting it.

`brief_markdown` folds into `brief`: after the merge they were the same
thing under two names.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(generate_artifact): stable prefix for phases A-D, tool gating in code

System prompt, tool array and messages form the provider's cached prefix.
Phases A-D now share one immutable system prompt and one fixed tool array,
so the region becomes an append-only prefix and each call reads the previous
ones from cache instead of paying for them again. Everything that changes
mid-run — current artifact type, remaining question budget, the instruction
for the step being executed — moves into a step message, because the prompt
cannot be rewritten without discarding the cache it exists to keep.

That costs the old way of making a tool unavailable. Dropping it from the
array is now a prefix change, so availability moves into
ALLOWED_TOOLS_BY_STEP and a disallowed call is refused in code without
running. The refusal names the step and the expected action: a model left to
guess spends another round, and on a spec step that round re-sends the whole
shared history. `tool_rejected` records each miss with its node, because the
trade — a stable prefix in exchange for misses costing a round rather than
being impossible — is only defensible if the misses are countable.

draft_brief and redraw_brief are separate rows over one tool.
`finish_gathering` is denied on the first (a model reaches for it whenever a
prompt says "you have finished gathering", which is why the old code removed
it outright) and required on the second, where it is what re-declares the
artifact type and data sources after a user correction.

The four call fields move from the phase-1 system prompt into a kickoff
message; without that the merged pipeline would gather for a request it
cannot see. `restored_context` carries the same material on the cold path,
where there is no conversation to continue.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(generate_artifact): spec nodes continue the shared history

The spec nodes are the last ones that see the material gathered earlier, so
they now run on the shared message list instead of a fresh conversation with
a restated summary. `tools` travels with it: a history carrying
tool_use/tool_result blocks is rejected outright unless tools are declared.

Both nodes branch on whether a history exists. Without one — the cold-start
path, where the context was rebuilt from disk — the call keeps its original
one-message shape, which is exactly what the pre-merge generator did.

Two consequences worth naming. The tech-spec ask now states that this is the
last step that can see the source material and that anything needed verbatim
must be carried into spec.md; nothing else from the conversation reaches the
code-writing steps. And a tool call on a spec step is refused once, then
fails the node — availability is enforced in code now, so a spec node can be
handed a call it must not run, and arguing costs a full re-send of the shared
history per round. That refusal deliberately does not consume a rung of the
truncation budget ladder, which exists for cut answers and would otherwise
leave a genuinely truncated spec with no room to retry.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: merge generate_prd into generate_artifact — one tool, five phases

The pipeline now runs gather -> brief -> PRD -> spec -> code behind a single
call. Phases A-D share one message list; the spec nodes are the last to see
it, and it is dropped at that boundary so the code-writing rounds stay as
small as they were. `spec.md` becomes the recoding point `prd.md` used to be,
which lets the PRD go back to being what it is: the record of what the user
agreed to.

Where a repeat call resumes is read from `discovery.json`, not guessed. Only
`user_request` decides whether this is the same work — the other three input
fields are re-typed in the model's own words every call — and the recorded
stage decides how far the last one got. `awaiting_confirmation` means the
repeat call IS the confirmation: the contract already told the agent to get
it, so asking twice would be asking twice. That also makes the flow converge
in environments where the user cannot be reached at all, which the previous
shape could not.

Two nodes are gone. `inspect_scratchpads` rebuilt data notes by matching live
pads against the brief text — a workaround for a generator that did not know
what had been gathered; it does now. `is_data_enough` asked a second model to
re-derive the verdict the gathering phase gave by calling `finish_gathering`.
What survives is the fetch loop, entered from state rather than history:
`unverified_sources` is tracked explicitly, because after a user correction
the notes are full of the PREVIOUS gathering's cells and an "are the notes
empty" check would read that as "everything is covered".

The tool contract loses `context` and with it the three-surface markdown
contract that had to be kept in sync by hand. Five typed fields replace it.
Five statuses replace the old string-or-dict return, each carrying the
instruction for what the agent should do next.

Handlers now return ToolOutcome with an explicit verdict (I-03). A successful
run's trace legitimately contains the word "failed" — "backend.py failed to
import in venv" is what a successful retry looks like — and the legacy
substring classifier counted that as an error, feeding the per-tool streak.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(generate_artifact): serialize the user question against the progress stream

The pipeline now asks the user from inside the task the tool handler is
draining, which the two-tool shape never did. A progress marker emitted
mid-question lands on top of a live prompt, and the CLI's Live context does
not survive that — the same seam that already cost this branch a handful of
spinner fixes.

Both paths to `elicit` are wrapped, not just one. The `ask_user` sub-tool is
the obvious one; the brief's confirmation reaches `elicit` on its own path
and is the longest question of the run and the only one that always happens.
They nest, so the drain counts depth rather than holding a flag — a flag
would unmute on the inner close, while the brief is still on screen.

Lines produced while a question is open are not dropped silently: the last
one is emitted once the answer arrives, so the user still learns where the
pipeline got to. Replaying them all would only describe steps that finished
while they were reading.

The label-coverage lock now walks the discovery phases too, and resolves
`sub_tools.STEP_*` constants rather than only string literals — the phases
pass constants, and a walk that understood only literals would wave exactly
the new half of the pipeline through unchecked.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(generate_artifact): spend guard across all five phases (I-20)

The checks were only ever going to be in the FSM, which is where the tool
used to be one round of work. The pipeline is five phases now: gathering can
spend twenty rounds with web_fetch, the revise loop up to ten iterations of
two calls each, and neither is an FSM node or a `_run_loop`.

Phases A-C wind down by converting what was spent into disk state and handing
back: gathering stops, the PRD gets written, and the stage says
`awaiting_confirmation` so a continuation resumes cold instead of
re-gathering. The brief is deliberately not shown on that path — "confirm
this" and "we stopped because this got expensive, continue?" are one
question, and the outer agent is the one holding the conversation to ask it
in.

The write loop counts its own closing rounds rather than the guard holding
one counter for the run. Fullstack runs two loops at once over one guard, and
a shared budget would leave each with about one round — not enough to both
emit a final chunk and call `finish`. `gather` still waits for both: killing
the second mid-call would leave a half-written file, which is worse than one
extra round.

A loop that runs out of closing rounds returns a sentinel, not an error
string. The cause is the forbidden retry, not unusable code, and the
instruction the agent gets differs accordingly.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(prompts): one-step artifact pipeline, retire the PRD-step lock

All four always-on blocks and the create_artifact description sent the agent
through generate_prd first. That tool no longer exists, so the instruction is
now a pointer at nothing. The workflow drops from seven numbered steps to
six: registering and generating stay, the separate PRD step folds into the
generator, and what replaces it is the part the agent still has to get right
— follow the `instruction` the status carries, and put a correction in
`agent_understanding` while leaving `user_request` alone.

Invariant 13 is retired deliberately, not broken and patched.
`TestEveryPathNamesThePrdStep` guarded the ORDER of two tools, and existed
because `generate_prd` had no `ToolDef.prompt` of its own — these blocks were
the only place the model could learn the step was there. With one tool the
ordering has nothing to order, so the lock is replaced by its inverse: no
block may name a tool that no longer exists, every block must still name the
generator, and the artifacts block must say the tool agrees the requirements
itself, so the agent neither pre-interviews the user nor writes a PRD by hand.

Co-Authored-By: Claude <noreply@anthropic.com>

* perf(discovery): narrow the PRD's mandate to agreed requirements (I-25)

The PRD weighed 18-21KB because it was the only channel to the generator: it
had to restate the data-access code, the environment variables and enough of
the source content to build from. It is not that channel any more — the spec
node carries the source material forward and `data_notes` holds the code that
actually ran — so the document goes back to being what its name says.

The earlier attempt at this (a "do not copy long content" line) left the size
unchanged, because the demand for connection code was still in the same
instruction. Removing the demand is what removes the bulk.

`write_prd` also now updates `state.prd`, not just the file. `prd_section`
renders that field and declares it the authoritative requirements source, so
a PRD rewritten during this call — which is what every user correction
produces — would otherwise never reach the spec node, and the correction
would be lost inside a single run.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(artifact): close the major findings from the branch review

Three defects the merge shipped, all found by review of cb851ec9..1e39c2ac.

1. The web_notes/data_notes channel was dead (I-06 was a no-op). Phase A
   recorded every scratchpad exec and web call onto the state, and nothing
   ever rendered them: `render_web_notes` had no production call site, and
   `render_exec_notes` ran only over the emergency data loop's own result.
   So `web_notes` was written to `discovery.json` always empty and read back
   always empty. On the hot path the spec node still saw the raw material
   through the shared history, but phase E and every cold start got nothing
   — which is precisely the case the channel exists for, since a model
   summarising a page into spec.md is where a source URL goes missing.

   `_absorb_discovery_notes` now renders both channels once per call, before
   either checkpoint save. Additive rather than rebuilt from scratch: the
   recorded calls hold only this call's work while the notes may already
   carry an earlier call's, restored from disk.

2. `_run_and_verify_app`'s retry branch treated the OVER_BUDGET sentinel as
   an error string, so a budget stop during a backend rebuild reached the
   user as "generation failed: __over_budget__" and forbade the repeat call
   — the opposite of what a budget stop means.

3. The data phase ran outside the spend guard: `_fetch_data_sample` never
   passed `spend`, and nothing re-checked the ceiling between that phase and
   `make_tech_spec`, the most expensive call in the pipeline.

Also: a fetched web page now verifies the source it came from. Requiring a
scratchpad cell instead sent every web-sourced request through the emergency
data loop on every run, to re-download an article already read.

And `cancelled` no longer counts as "Generated artifact files" — it is the
one outcome that writes nothing.

Tests: the phase-boundary lock the plan called for but never got. It asserts
the FILLING of the channels, not just their transport — a test handed a
pre-filled state proves the renderer works and says nothing about whether
anything calls it, which is how the channel shipped dead. Each fix was
mutation-checked: reverting it fails its lock.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(artifact): correct docstrings the merge left behind

The package docstring still described `generate(..., context, slug)` and the
brief's `## Data` section, neither of which survived the merge. It now names
the real signature and the boundary that replaced them.

`progress.py` named `make_api_spec` twice where it meant the two spec nodes.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(artifacts): apply the declared-datasource rule to both launch paths

ENG-1382 stopped a backend subprocess from inheriting every DS_* in the
process: it now receives only the credentials its artifact declared. That
was built inside `handle_launch_backend` — which `generate_artifact` never
calls. The pipeline launches the backend itself at the end of a fullstack
run, so the generator's own launch was the single path still handing the
subprocess the whole environment.

Nothing about that was visible as a merge conflict: the two sides never
touched the same lines. It surfaced only from asking what staging changed
about artifacts, not what git could not auto-merge.

`build_datasource_env` now owns the rule and both paths call it. The
pipeline already declares its sources (`_declare_datasources` runs during
backend generation, before this node), so the list is on disk by then. It
passes `{}` rather than None when nothing is declared — `{}` strips the
inherited DS_*, None would leave them, and an artifact that needs no
datasource should get the strip.

Locked two ways: the pipeline test asserts the launch receives exactly the
declared credentials while an undeclared DS_* is stripped, and an AST
inventory check fails if either path stops routing through the helper.
Both mutation-checked.

Also corrects two comments that cited "ARTIFACTS workflow step 4" for the
delivery instruction ENG-2421 overrides by name — it is step 6 since the
pipeline became one tool. The tripwire locks the phrase, not the number,
which is why the renumbering was harmless; the comments were not.

Tests: 3637 passed, 30 skipped.

Co-Authored-By: Claude <noreply@anthropic.com>

* perf(generate_artifact): give the write loop a line map instead of a reread

Measured on the 2026-09-14 `найди пару` run. After a successful append the
model spent one round on `read_file` that learned nothing the write result
had not already said, then escalated to `read_file(full=true)` — 26,006
characters, 31% of the final round's context.

The escalation was not disobedience so much as design. The cheap read
returns size plus tail; the model's question was "are the key sections
right", which is structural. The tail cannot answer it, so the cheap read
did not prevent the expensive one — it motivated it, after first supplying
enough confidence to declare the file "complete and well-formed".

The prompt surfaces disagreed exactly where it mattered. `full` was named
ONLY in the tool schema, which also carried the only warning about it; the
system prompt showed `read_file(path)` as a one-argument call and, in its
chunk section, promised the tail was "all you need" — a claim this run
falsified.

So:

- `write_file` reports lines beside bytes, for the chunk and for the file.
  An append's span is then the last N of M lines: a map, obtained without
  spending a round reading anything.
- Both `read_file` forms are in the system prompt now, with what `full=true`
  costs (~7k tokens, re-sent every later round) and the one case it is for —
  re-reading in order to keep WRITING, never to check finished work.
- The "all you need" promise is gone. It sent the model looking for a way
  around a limit the prompt pretended did not exist.

Applied to all three write-loop builders, not just the html-app one: they
share these sections, and fixing one surface is how they diverged before.

I-31 records the rest — the structural outline that would answer the
question the model actually asks (measured: 12% of the file), and why a
line-range API without that map would just rename `full=true`.

Tests: 3643 passed, 30 skipped.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(generate_artifact): file bodies travel as text, not as a tool argument

The Anthropic API buffers and validates a tool parameter in full before
streaming it, so a `write_file` carrying a whole page left the connection
silent for its entire generation — measured 112s of dead air for a 59,000
character argument, reproduced against api.anthropic.com, so neither the
gateway nor our client. Plain text through the same channel streams evenly.
The supported fix, `eager_input_streaming`, is dropped by the gateway, and
patching the gateway is out of scope for now.

So the body moves out of the argument: content goes in the reply as text
between `<<<ANTON_FILE_BEGIN>>>` / `<<<ANTON_FILE_END>>>`, and `write_file`
carries only `path` and `mode`. `content` is removed from the schema rather
than made optional — two ways to deliver a body drift apart, and the day they
disagree a wrong file is written with nothing to notice it.

Strictness is asymmetric, by what a mistake costs. A duplicated marker or an
empty body could put the WRONG bytes on disk, so those refuse; prose around
the markers cannot, so it is recorded as a violation and the body is used.
Refusing that would break on the model's most ordinary habit: the order of
text and tool_use blocks is already lost by the time a response reaches us,
so a trailing "Done." is indistinguishable from prose after the end marker.

Two branches the old loop did not have:

- A reply with a body and no `write_file` is retried, keeping the body, so the
  second reply only has to name the path. Regenerating 20 KB to recover a
  missing tool call pays twice for one file.
- A body alongside `finish` does NOT end the run. That is not hypothetical:
  "final part plus finish in one reply" is how the model normally finishes,
  and accepting it would ship an artifact missing its last section — while
  `require_files` passes on the parts earlier rounds wrote, reporting success.

Truncation detection improves as a side effect. It used to be inferred from
`stop_reason` and the token budget; a missing end marker is a fact. A body cut
off by the output cap and a body whose marker the model forgot now get
opposite advice — send it smaller, or add one line — because telling the
second it was "cut off" makes it regenerate a file that is already complete.

The chunk limit and its warning are gone from the model-facing text. Both were
derived from how long a silent connection survives, and there is no silence
left to survive; keeping the number would make the model split files that fit,
and under one body per reply every needless split costs a whole round.

Tests: 3643 -> 3655. The four protocol locks are mutation-checked; the
content-argument one was weak on the first attempt (a body was present, so
ignoring the stray argument looked identical to refusing it) and was rewritten
until reverting the check fails it.

Plan and measurement thresholds:
docs/artifact-generation-tools/2026-09-15-text-then-tool-protocol-plan.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(generate_artifact): close both rounds the first protocol run wasted

Live run 2026-09-15 on "найди пару": the protocol itself held — one body, one
write_file, 26 828 characters, verifier clean, zero protocol violations. But
two of six generation rounds produced nothing, and neither was the model's
fault.

Round 0 stopped at exactly 8192 output tokens in the middle of a body. That
cap was withheld from round 0 deliberately (invariant 15): while the body rode
in a tool argument, a planning-model write at the raised budget stayed silent
long enough to be dropped — 4 failures out of 4. At 8192 it merely truncated,
which the loop survives. With the body streamed as text there is no silence
left to survive, so the cap now only costs rounds. Every round gets
GEN_WRITE_MAX_TOKENS.

That correction matters more than expected, because the planning model is far
less token-efficient on this content than assumed: measured 1.57 characters
per token against the coding model's 2.62. The plan estimated round 0 could
hold ~21 300 characters at 8192; it held ~12 800 — under the size of an
average artifact, which is why it truncated at all.

Round 1 was caused by the message I wrote for round 0's truncation. It said
"emit the first part now, then append the rest on the next turn", and the
model read that as a two-turn plan: it replied "I'll write this large file in
parts. Starting with the first chunk:" and called write_file with no body.
The wording invited it. The message now says the shorter part goes in the NEXT
REPLY, body and call together, and states outright that announcing a part
instead of sending it writes nothing.

The round-0 budget lock is reversed rather than deleted, and keeps the
measurement that justified the old rule: if bulk content ever goes back into a
tool argument, that lock is the first thing to revisit.

Tests: 3655, unchanged in count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(generate_artifact): remind where the next body goes, in the write result

Observed in both protocol runs, at the same point: after a successful first
part the model replies "Now I'll append the JavaScript logic:" and calls
write_file with no body. One round lost each time.

It is not a misread rule — the call itself is flawless: right path, right
mode="a", no stray content. In the tool-use format the ordinary shape of a
reply is a short preamble followed by the call, with the payload inside the
call. This protocol puts the payload outside it, and a continuation round
opens by answering a tool result, which is exactly the moment a preamble feels
natural. By then the system prompt is thousands of tokens back.

So the correction rides on the write result instead, which is the one message
the model is certain to be looking at when it decides what the next reply
looks like.

Not fixed here, and worth separating: the model split a 33 687-character file
that would have fit in one reply. The prompt asks it to write the whole file
"when it fits in your output budget" — a condition it cannot evaluate, since
it has no view of its remaining tokens — and then gives a concrete structural
recipe for splitting. The unusable condition loses to the usable procedure.
That needs a size the model CAN compare against, and a demoted recipe; it is a
separate change and a separate measurement.

Tests: 3656.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* perf(generate_artifact): give the model a size it can actually check

The write discipline asked for the whole file "in ONE reply when it fits in
your output budget". The model has no view of its remaining tokens, so that
condition cannot be evaluated — and directly underneath it sat a concrete
recipe for splitting: first part head/style/opening body, then the data block,
then the markup, then the scripts. One instruction was unusable, the other was
executable. Measured 2026-09-15: it split a 33 687-character file that fit,
and the cut fell exactly on the boundary the recipe named.

So the condition becomes a number in CHARACTERS, which is the unit the model
can hold against the file it is about to write, and the recipe stops being a
procedure. REPLY_BODY_CHARS is derived from GEN_WRITE_MAX_TOKENS and the
ratios measured on real bodies — the planning model that writes round 0 ran
1.57 / 1.78 / 1.99 characters per token across three runs, the coding model
2.62 and 2.91 — taken at the planning model's worst and rounded down. Erring
low is deliberate: overshooting loses the whole part, since a body with no end
marker writes nothing, while undershooting costs one round.

Splitting is now stated as the exception, after the whole-file case, and says
only to continue where the file ends. Nothing tells the model where to cut.

This is not expected to be free of surprises. The last run wrote its file in
one body without any of this, so the split is not reliably reproducible, and
the next run tests the guidance rather than confirming it. It may also bear on
I-21/I-22: that same…
* ENG-1816: MCP client in anton (Stage 1 — general infra, not HubSpot-specific)

Adds anton/core/mcp/: transport/session handling on top of the official
modelcontextprotocol SDK (v2), tool discovery, fail-closed read/write/none
access filtering, and {engine}__{tool_name} namespacing. Wires discovery
into build_cloud_chat_session — a safe no-op today, since no auth
Connection carries _method == "mcp" yet (Stage 3).

Discovery has to happen before ChatSession/ChatSessionConfig exist, not
after: ChatSession._build_tools() populates the tool registry exactly once
per session, and on the cloud path re-enforces a fixed tool_allowlist on
every rebuild — a tool registered afterward and left out of that allowlist
would survive exactly one round then get silently unregistered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Fix McpSession.call_tool to reconnect on retry, preserve real cancellation

Found by hand-testing against a real (non-in-process) MCP server: killing
it mid-session surfaces as anything from a plain httpx2.ConnectError to an
asyncio.CancelledError/ExceptionGroup, depending on which of the transport's
own background tasks noticed first. Two bugs followed from that:

1. `except Exception` doesn't catch CancelledError (a BaseException in
   Python 3.8+), so the "one silent retry" never engaged for that shape.
2. Retrying by calling call_tool() again on the same client can't work
   once one task in its transport task group has died — the whole group,
   and the Client built on it, is unusable. The retry now reconnects
   (fresh Client, same stored token) before retrying.

If the reconnect itself also fails (a genuinely dead server, not a blip),
the ORIGINAL exception propagates as-is rather than being converted to
McpTransientError — real task cancellation (e.g. the whole turn being
cancelled) must never be silently disguised as a business-logic failure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* ENG-1816: update access.py's tool table from a live HubSpot MCP server run

Ran anton's McpSession directly against a real HubSpot MCP Auth App +
test account (bypassing Stage 2/3 entirely) to validate the doc-derived
read/write table before Stage 2 starts relying on it.

- 5 tools the live server advertises weren't in the table at all
  (get_aeo_metrics, search_intent_signals, manage_aeo_prompts,
  manage_aeo_recommendations, manage_segment — AEO/intent features added
  since the table was first drafted). Confirms the fail-closed default
  worked as intended: none of them leaked into a read-mode connection's
  registered tools before being classified here. Now classified by
  inspecting their actual schemas (query-only vs. has a create/mutate
  operation).
- 3 tools in the table never appeared live (render_landing_page_ui,
  render_asset, manage_blog_post) — removed as stale.
- manage_crm_objects (the primary CRM-write tool) also didn't appear live.
  get_user_details's own response showed most write-capable CRM object
  types as "write": "REQUIRES_REAUTHORIZATION", which reads as: the test
  OAuth grant only requested read-level access, and the server omits
  write tools from tools/list() until a broader grant exists — not a
  permanent removal. Left in the table (harmless — a tool the server
  never returns is never filtered), but flagged: re-verify with a
  write-scoped re-auth before Stage 2 assumes it works.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* ENG-1816: reflect confirmed write-scope re-verification in access.py

Follow-up to 3b2658c: re-ran the live check after enabling
crm.objects.{contacts,companies,deals}.write on the HubSpot app and
re-authorizing with those scopes granted. manage_crm_objects is now
confirmed live (previously only inferred from get_user_details showing
REQUIRES_REAUTHORIZATION) and the live tool list matches this table
exactly in both directions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* ENG-1816: fix 5 real bugs found in code review

1. McpSession.call_tool's retry loop could swallow a REAL task
   cancellation: asyncio.CancelledError is a BaseException, and the retry
   logic treated it the same as a leaked transport-teardown CancelledError,
   reconnecting and retrying instead of propagating it. If the retry
   succeeded, the caller's actual cancellation silently vanished. Fixed
   using Task.cancelling() (3.11+, verified empirically both ways) to tell
   a real cancellation (cancelling() > 0, always re-raised immediately)
   apart from a CancelledError merely leaking from the transport's own
   internal task group (cancelling() == 0, still safe to retry).

2. classify_mcp_error checked mcp.MCPError's JSON-RPC error code against
   {401, 403} — but the SDK's own reserved code range is -32000..-32700
   (INTERNAL_ERROR, CONNECTION_CLOSED, ...), never an HTTP status, so that
   branch could never match. A genuine auth failure surfacing as an
   MCPError (rather than httpx2.HTTPStatusError) fell through to
   McpTransientError, meaning a dead credential would retry forever
   instead of ever surfacing "reconnect HubSpot". Removed the dead code
   path; every MCPError is now transient by design, with the reasoning
   documented (auth is an HTTP-layer concern, not a JSON-RPC one here).

3. namespaced_tool_name() only namespaced by engine, not connection —
   but the vault explicitly supports multiple connections per engine.
   Two MCP connections for the same engine in one turn produced identical
   ToolDef names, and ToolRegistry.register_tool silently drops
   duplicates by name, so the second connection's tools were silently
   unreachable. Fixed: discover_mcp_tools_async now detects >1 connection
   per engine and threads the connection's own name into the namespace
   only then — the common single-connection case is unaffected.

4. ChatSession.close() ran MCP session teardown inside the try block,
   not finally — if ScratchpadManager.close_all() raised (no internal
   try/except per scratchpad), the MCP cleanup step, and therefore every
   open MCP session's httpx2.AsyncClient + SDK transport, never ran.
   Nested inside finally now, same guarantee the LLM client cleanup
   already had.

5. If ChatSession(config) itself raised in build_cloud_chat_session,
   the MCP sessions discover_mcp_tools() already opened had nowhere to
   be closed — there's no ChatSession yet for close() to run on. Wrapped
   construction in try/except that closes them before re-raising.

Also parallelized discover_mcp_tools_async's per-connection MCP round
trips via asyncio.gather (previously sequential) — the vault's own
blocking HTTP calls (TurnKeyDataVault._fetch) are a separate, pre-existing
limitation shared by every vault caller, not fixed here.

9 new tests covering all 5 fixes; full suite 3185 passed, 0 failed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…nce connection (#501)

* Send datasource reads to the gateway on the turn's own inference host with the turn's own key, and refuse a turn whose connection a pod setting replaced

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Prove the scratchpad child gets the connection the session checked and that the helper refuses a base it cannot use, compare the key in constant time, and correct the comments that still spoke of a bearer

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Refuse a datasource turn with an empty turn key or an inference base with no host, in the session and in the helper

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…#486)

* ci(verifier-eval): run the live matrix only when the verifier changed; one-attempt control at 6 runs

ENG-2863. The verdict eval triggers on session.py (~6k lines, touched by most
PRs) and two plumbing files, and ran its full live matrix ~200 times in the
last 30 days while only ~26 of those diffs touched anything the verifier's
judgment depends on. After ENG-2686 that matrix is ~103 gateway calls, so
roughly 20k calls a month guarded code that did not change.

scripts/verifier_eval_scope.py decides per PR by LINE SPANS, not by grepping
changed text: both sides of each file are parsed with `ast`, the spans of the
verdict class, its constants, the request builder, the transcript renderer
and its clipper, and the structured-output call path are taken, and the PR's
hunks are intersected with them. A body-only edit inside
_render_verify_transcript counts; an edit anywhere in turn_stream, which
merely calls the verifier, does not. (A first cut used `git diff -W` and
grepped the function context — that fired on every turn_stream edit because
the method contains the verifier call site.) Any change to the eval module,
the structured-output builder, or the script itself is always full.

Otherwise only test_narrating_model_reaches_a_verdict_at_shipped_budgets
runs — one call, the ENG-1081 truncation guard, which is why the plumbing
files are trigger paths at all. The decision is made at the job level, never
via pytest.skip, so the out-of-money branch keeps its every-skip-is-a-
gateway-denial invariant. The job summary says when only the guard ran.

tests/test_verifier_eval_gate.py pins: every marker resolves to a span in
the real code (a rename fails the unit test instead of silently switching the
matrix off); a body edit inside a marker counts and one outside does not; the
-k selects exactly the guard test; the step contains no pytest.skip.

Also: one_attempt_give_up runs at 6 on mindshub_air, not 12 — 0 slips in 48+
runs, twelve bought nothing over six. Full matrix is now ~97 calls.

Measured on real diffs: #483 (verifier) -> full; #480 and #485 (session.py
untouched / non-verifier) -> guard.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ci(verifier-eval): scope decision fails closed to the full matrix

A git error or unparsable revision inside the decision must not red the job
for an unrelated reason, and must never fall to the one-call guard — that
would re-create ENG-1334. Pinned by a gate test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ci(verifier-eval): diff from the merge base; guard the forced-tool-call body; pin marker sets by equality

Self-review of #486, three findings, all reproduced before fixing:

- pull_request.base.sha is the base branch TIP, and a two-dot diff against
  it includes everything staging gained since the branch point. Five real
  guard-eligible PRs read "full" purely because #483 had landed on staging
  after they branched. decide() now reduces base to `git merge-base` first;
  a tmp-repo test pins it.
- generate_object / generate_object_code are 5-line delegators; the forced
  tool_choice, budget and truncation classification live in
  _generate_object_with, which was in no span — a tool_choice mutation there
  read "guard". It and _call_with_auth_confirmation are markers now.
- The gate test pinned markers by subset, so deleting six entries one at a
  time passed every time. Sets are now asserted by equality, and a pure-
  deletion hunk (zero-length new side on the span's first line) exercises the
  max(length, 1) guard the insertion case never reached.

Also corrects the eval module's stale "37 calls per session" comment (~97).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ci(verifier-eval): guard tier reaches both aliases; pin the YAML fallback and trigger path; move the script under .github/scripts

Round-2 self-review of #486, findings 6-9:

- The guard tier ran one call on mindshub_air only, so _check_served_model
  never saw haiku on guard runs and a haiku-only repoint would have waited
  for the next verifier-touching PR (~26/month) instead of the next trigger-
  path PR (~200/month) — narrowing ENG-1687's latency guarantee. The guard
  test is parametrized over _MODELS: two calls per guard run. Gate test pins
  that it reaches every pinned alias.
- The YAML half of the decision is pinned like the Python half: the empty-
  payload fallback must write scope=full (Y3 mutation now reds) and the
  script must be a trigger path (Y5 now reds).
- The recount comment's parenthetical and its two derived numbers were
  wrong: seven cases at 3 runs (not twelve), and a sustained throttle is
  ~32 min at the default / ~97 at the cap for ~97 calls, not 12 / 37.
- scripts/ duplicated .github/scripts/ (check_dependency_pr_scope.py is the
  structural twin, same testing pattern); the script moves there and every
  reference follows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ci(verifier-eval): address ea-rus review — decorator/re-assignment spans, image placeholder marker, env-passed pytest args, gated summary step, one-attempt back to 12

Six review comments on #486, each reproduced before fixing:

- marker_spans kept only a marker's FIRST definition and ignored AugAssign,
  so a later `_VERIFIER_JUDGMENT_RUBRIC = ...` or `+=` read "guard". Every
  assignment of a name now yields a span; AugAssign is matched.
- A decorated marker's span started at the `def` line, so an `@lru_cache`
  added above `_render_verify_transcript` read "guard". Spans now start at
  the first decorator.
- `_IMAGE_PLACEHOLDER` is inlined into the transcript the verifier judges
  and was not a marker; changing "[image]" to "" read "guard". Added.
- The guard-only summary was written by grepping $GITHUB_OUTPUT back; it is
  now its own step gated on `steps.scope.outputs.scope == 'guard'`.
- pytest_args reached the run line as a `${{ }}` expression interpolated
  into shell — the one injection point the PR body had to explain. It now
  arrives as the PYTEST_ARGS env var and is expanded unquoted on purpose.
- one_attempt_give_up goes back to 12 runs on air. "0 slips in 48 runs"
  bounds false positives only; at haiku's 1-in-16 slip rate 12 runs catch a
  regression ~54% of the time and 6 runs ~32%, and the saving was 6 calls
  per full run against ~26 full runs a month. The comment states the
  trade-off. Full matrix is ~103 calls again; the guard tier is 2.

Gate tests pin each: decorator span, every-assignment + AugAssign spans,
the marker set including _IMAGE_PLACEHOLDER, env-based PYTEST_ARGS with no
expression in the run line, the gated summary step, and 12 runs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…tool failures (#499)

A successful cell was reported to the model as "Tool 'scratchpad' failed:
Invalid IPv6 URL" when the post-cell source-URL scan hit a URL-shaped regex
literal. Guard the bookkeeping so it can never fail the tool call, and skip
matches urlparse rejects so the remaining sources are still recorded.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Scrub Google AQ. authorization keys at chat input (ENG-2684)

Google moved AI Studio keys from `AIza…` to an `AQ.` prefix; the
shape-based scrubber matched `AIza` only, so pasted keys reached model
context and Langfuse. Add the `AQ.` shape (53 chars, alphanumerics and
dot) and parametrize the tests over every currently-issued Google
format, bare and inside a `?key=…&…` URL query.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Allow _ and - in the Google authorization-key body

Real AQ. keys are URL-safe base64, so a single underscore or hyphen in
the body defeated the exact-count match. Widen the class and add a
sample containing both; drop ticket IDs from code comments.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Shorten scrubbing test comment

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…503)

PIPELINE_MIRRORED_MARKERS is derived from FRONTEND_MARKERS, which already
carries the marker, so appending it again reported it twice on regression.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* fix(cloud_turn): never rate-limit tool progress lines (ENG-2981)

On the remote turn backend the pod rate-limits progress before cowork-server
sees it, and reasoning_start from generate_artifact's own LLM calls took the
window, dropping the step line next to it. Every tool_progress line with an
id now reaches the wire and does not move the limiter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cloud_turn): pin that id-less tool progress stays rate-limited (ENG-2981)

Only tool_progress lines with an id are exempt from the pod wire rate
limit. Without this test, dropping the id check would keep the suite green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
#505)

* fix(llm): raise one exception type per MindsHub billing stop (ENG-813)

- WalletEmptyError, AllowanceExhaustedError and FreeServingPausedError subclass
  TokenLimitExceeded, so a hosted turn's "TypeName: message" names the limit.
- The free-Air daily fuse is a billing stop, never retried as a rate limit.
- ModelRestrictedError for a 403 carrying X-MindsHub-Deny-Detail model_restricted.
- One shared helper for the OpenAI, mid-stream and Anthropic mappers; the
  MindsHub-origin gate is unchanged, and reset_at is read off X-MindsHub-Reset-At.

Lucas Koontz, ENG-813: Make every billing stop name the limit that fired, in Cowork and the console.

Refs: ENG-813

* docs(llm): fix comment punctuation in the anthropic mapper (ENG-813)

Lucas Koontz, ENG-813: Make every billing stop name the limit that fired, in Cowork and the console.

Refs: ENG-813

* fix(llm): send the gate's reset time on turn_failed and read the error body once (ENG-813)

- A hosted turn_failed frame from a MindsHub billing stop that knows when
  its limit lifts now carries reset_at beside the error string, so the web
  fuse and allowance cards can name the time. Every other failure is
  unchanged.
- The allowance stop says "you have no free MindsHub Air allowance left",
  true for an org that spent its allowance and one that never had one.
- One typed parse of the provider error body (ProviderErrorBody) replaces
  the hand-split top-level and envelope reads in the OpenAI and Anthropic
  mappers, wallet_denial_code, mindshub_model_restriction and
  classify_transient, with each reader's field order unchanged.
- The origin-gate docstring states cowork-server's check order correctly;
  ragged comments are re-flowed.

Lucas Koontz, ENG-813: Make every billing stop name the limit that fired, in Cowork and the console.

Refs: ENG-813
…508)

* fix(minds_client): keep only chat models from the catalogue (ENG-1958)

list_models filtered /v1/models only on the embedding and enabled
flags. A zero-priced decision model (kind "decision", such as jev and
jev-1.13.0) therefore reached resolve_minds_models. For an org whose
only enabled rows were the jev models, the ids[0] fallback set jev as
the planning and coding model, and the setup chat probe then failed
with 400 unsupported_model_kind.

list_models now skips any row whose kind is present and is not "chat".
Rows without kind, which older hosts send, keep the existing embedding
and enabled checks. A jev-only catalogue now falls back to the tier
defaults with the mindshub_air escalation. Regression tests cover the
jev-only org, a decision model listed ahead of a chat model, the
resolve_and_probe probe sequence, and the kind/no-kind filter matrix.
The provider docs now say setup picks chat models.

Work by Lucas Koontz for "Stop account farms from scripting signups and
draining free Jev in production, and prove it with alerts".

Refs: ENG-1958

* test(minds_client): pin setup's 402 stop for a Jev-only org (ENG-1958)

The Jev-only setup test faked a 403 model_disabled for the paid default
probe and asserted that setup then escalated to mindshub_air. MindsHub
never sends that answer to an org with no wallet. It refuses haiku with
402 wallet_empty, and resolve_and_probe escalates only on a 403 or a
404, so the real run stops at haiku. The test pinned an escalation this
org never reaches, and it still passed when resolve_and_probe was
changed to escalate on a 402 as well.

The test is now test_jev_only_org_stops_at_the_wallet_empty_probe. It
fakes the HTTP layer instead of test_llm, so the real probe and error
parsing run on MindsHub's own answers: 402 wallet_empty for haiku and
sonnet, and 429 included_allowance_exhausted for mindshub_air. It
asserts that setup probes only haiku, gets "wallet_empty: Your wallet
has no balance to cover the model 'haiku'." to show the user, keeps the
sonnet and haiku pair, and never names jev. The Jev-only catalog
fixture now also lists mindshub_air as disabled, so the org has only
the Jev rows enabled. An org like this therefore gets a billing message
from setup and does not fall back to mindshub_air. The existing 403 and
404 escalation tests are unchanged.

The list_models docstring and the decision-model test docstring now
name the error a user actually sees when the probe names jev: 400
invalid_param, "Model 'jev' does not support this endpoint."
unsupported_model_kind is only auth's internal reason, and inference
does not forward it.

Work by Lucas Koontz for "Stop account farms from scripting signups and
draining free Jev in production, and prove it with alerts".

Refs: ENG-1958
Use Jev for the completion check on MindsHub
…G-3433) (#582)

Lucas Koontz, ENG-3433 "Show which source each figure in an answer came from".

cowork-server's figure sources read text anton writes: format_cell_result's labels, truncation note and no-output sentence; the "[error] exec failed" prefix of a discarded call; the [artifact lint] block; the is_error flag on results anton makes up itself; the cloud result's truncation marker; read_text_file's header and line-shortening mark; and the default cloud workspace path. A comment at each site names the cowork-server file that reads it, so a change here gets the matching change there. Comments only; no behavior changes.

Refs: ENG-3433
@mindsdb-release-train mindsdb-release-train Bot changed the title Release: staging → main (101 commits) Release: staging → main (102 commits) Oct 15, 2026
* fix(memory): attribute end-of-turn memory calls to their turn

Identity extraction and the cerebellum post-mortem are started after the
turn's trace context is reset, so their LLM calls went out with no
Langfuse headers and the gateway filed them as direct-API use.

Start both with the turn's trace context, detached from the conversation's
session: the console marks a conversation failed when any call in it fails,
and these are background calls the turn does not depend on. The
conversation id rides in the metadata instead.

ENG-3456

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(memory): run the memory vacuum in the background context too

maybe_vacuum() runs after the turn's trace context is reset and starts
compact_all() itself, a coding-model call, so memory compaction still went
out with no Langfuse headers. Run it inside the same detached background
context the other end-of-turn memory tasks use, so the task it starts
inherits it.

ENG-3456

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(memory): guard that in-turn consolidation keeps the session

The end-of-turn memory calls are detached from the conversation's session;
scratchpad consolidation starts during the turn and must keep it. Assert
both in the same turn, so a change that detaches consolidation too fails.

ENG-3456

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@mindsdb-release-train mindsdb-release-train Bot changed the title Release: staging → main (102 commits) Release: staging → main (103 commits) Oct 16, 2026
* Azure retry

* Check for retry

* Add tests

* Address concers
@mindsdb-release-train mindsdb-release-train Bot changed the title Release: staging → main (103 commits) Release: staging → main (104 commits) Oct 16, 2026
@mindsdb-release-train
mindsdb-release-train Bot marked this pull request as ready for review October 16, 2026 13:51
@mindsdb-release-train
mindsdb-release-train Bot requested a review from a team October 16, 2026 13:51
@entelligence-ai-pr-reviews

Copy link
Copy Markdown

Automatic Review Skipped

This PR changes 138 files, above the 100-file limit for automatic reviews.

Trigger a full review by commenting:

@entelligence review

Once you do, later pushes to this PR are reviewed automatically.

…ncation (ENG-3433) (#585)

* fix(session): keep a turn's tool rows through a mid-turn compaction (ENG-3433)

Compaction and hard truncation replace ChatSession.history with a new list
that no longer holds the turn's earlier tool_use and tool_result messages.
Hosts sliced history from the turn's start, so the cloud pod skipped its
history frame after a compaction and the next turn replayed text only.

ChatSession now routes every history swap through _replace_history. While a
turn runs, it keeps the outgoing list as a segment and starts a new segment
on the incoming list. turn_messages() returns every segment from its start
offset, in order, so the rows survive the swap without the host watching
events. A merge into the message just before a new segment (the carried-over
last message, or the hard-truncation separator) moves that message into the
segment once, so a tool_use merged there is not lost and a second merge does
not read a message twice. Without a swap, turn_messages() equals the old
slice, including when the turn's input merges into a seed that ends with a
user message.

When the turn ends, turn_stream copies the turn's messages into one segment.
The session then holds no replaced history lists between turns, and CLI
commands that append to or rewrite history between turns do not change
turn_messages(). The close runs in a nested finally, so a failing inner
cleanup still closes the turn.

The cloud pod builds its history frame from turn_messages() and sends it
after a compaction too, still before turn_completed and beside the
compaction frame. contract.py documents both frames accordingly, including
that the summary text may describe rows the history frame also sends. The
history_store docstring now names turn_messages() as what cowork-server
persists.

anton's history is raw dicts throughout, so the new helpers take and return
list[dict]; a typed history-message model is missing.

Lucas Koontz, ENG-3433: Give each figure in an answer its sources

Refs: ENG-3433

* fix(session): mark a failed tool call's stored result is_error (ENG-3433)

A handler that reports a failure (ToolOutcome.ok=False, or a raise) now gets is_error: true on its stored tool_result, the Anthropic Messages API's marker for a failed tool call. Before, the result was a plain tool_result. The model could usually tell a failure from the words, since an MCP error body starts with "Error executing tool". cowork-server's figure matcher could not, because it reads only numbers, so it took a number from the error text (the 500 in "received 500") as a figure source. The flag gives the model the API's own failure marker and lets cowork-server leave the call out of figure sources.

The flag means the call produced no usable output. An unclassified result (ok=None) stays unflagged, because its verdict is a text guess. Every scratchpad result stays unflagged, because a cell that raised may have printed real data first. On a failed text result from any other tool, the resilience nudge and circuit-breaker text sit inside the flagged result; a list result gets no nudge text.

The results anton writes itself for a call that never ran now carry the flag too: the "Cancelled by user." result that repair_history adds, and the "[no result was recorded for this call]" fill for a replayed call. The compaction summarizer labels a flagged result "[tool_result error]", so the failure survives a fold into the summary.

Anthropic receives the flag as is. OpenAI Chat Completions and the Responses API have no error field on a tool output, so the OpenAI adapter drops it and starts the output text with "Error: " unless the text's first word is already "Error" (a data key such as "error_rate" still gets the prefix). The debug LLM-history serializer keeps the flag instead of dropping it. The tool-system developer doc describes the behavior.

Lucas Koontz, ENG-3433: Give each figure in an answer its sources

Refs: ENG-3433

* feat(mcp): say which connector tools return the customer's records (ENG-3433)

The read access tier mixes tools that return the customer's records (CRM
objects, campaign, email and content analytics, intent signals, AEO metrics,
conversations) with tools that return docs, schema or account metadata
(tool_guidance, discover_hubspot_schema, get_properties, search_properties,
get_user_details, get_organization_details, search_owners,
get_conversation_channel_metadata). A caller that attributes an answer's
figures to tool calls must count only the first kind, or a limit quoted from
tool_guidance reads as a sourced figure.

access.py gains TOOL_OUTPUT, a records/metadata table for every read-tier
tool directly under TOOL_ACCESS, and returns_records(*, engine, tool). It is
False for metadata tools, write-tier tools, and any tool or engine
TOOL_OUTPUT does not list, including a read-tier tool nobody has classified
yet. A test fails when a read-tier tool has no output classification or when
TOOL_OUTPUT names an engine TOOL_ACCESS lacks, so a new tool is classified in
one place.

registry.py gains parse_namespaced_tool_name(*, name), the inverse of
namespaced_tool_name. The engine is the text before the first "__" and the
tool is the text after the last "__"; anything between is the connection
name. Connection names are free text and may start or end with "_" or hold
"__", while tool names come from the MCP server's fixed list, so the split
trusts the tool side. A connection named "crm_" therefore parses back as
"crm_" rather than as "crm" with a tool of "_search_crm_objects". A test
round-trips every classified HubSpot tool through connection names with
leading, trailing and repeated underscores.

Lucas Koontz, ENG-3433: Give each figure in an answer its sources

Refs: ENG-3433

* fix(session): keep a tool loop's tool rows through a hard truncation (ENG-3433)

After a second context overflow, hard_truncate_history keeps the last 4 messages and the planning call is retried. In a tool loop those 4 messages are all tool rows: an assistant tool_use, then the user tool_result that answers it, twice. The truncation dropped a leading assistant message as if it had to start the history. That left the tool_result after it without its tool_use, so it dropped that too, and so on through the tail. The retried request was the placeholder and an assistant "Understood.". It ended with an assistant message, which Anthropic models from 4.6 on reject as a prefill, and it had lost the tool round the model was about to answer.

The placeholder is a user message and comes first, so a leading assistant message can follow it. The branch that leaves out the "Understood." separator when the tail starts with an assistant message was already there, but never ran. Now the truncation keeps that message. It still strips the tool_result blocks of a leading user message, whose tool_use is in the dropped part. In a tool loop the retried request is the placeholder plus the last two tool rounds, ending with the latest tool_result.

The new tests run the retried request of a real turn that overflows twice in a tool loop, and of plan_with_recovery, through checks of what Anthropic, OpenAI Chat Completions and the OpenAI Responses API reject, and check that the latest tool round ends the request. Two turn-rows tests pinned the old output: a truncation inside a tool call now leaves 4 messages instead of 2, and the separator-merge test uses keep=1 to get an empty tail. The real-turn test reuses the turn-rows tests' scripted planner and tool-row helpers. That planner now takes an optional PlanCalls recorder, which keeps each planning request and the indexes of the ones that overflowed.

Lucas Koontz, ENG-3433: Give each figure in an answer its sources

Refs: ENG-3433

* revert: keep failed tool results without an is_error flag (ENG-3433)

This reverts commit a08381e. cowork-server already admits a native or
connector tool result as figure evidence only when anton's tool_done event
reports ok=True, which every non-scratchpad tool emits with the handler's
outcome. Flagging the stored tool_result as well changed what the model sees
on a failed call across providers without anything needing it.

Lucas Koontz, ENG-3433: Give each figure in an answer its sources

Refs: ENG-3433

* refactor(mcp): split tool names without importing the MCP SDK (ENG-3433)

namespaced_tool_name, ToolNameParts and parse_namespaced_tool_name move
from anton.core.mcp.registry into anton.core.mcp.names. registry imports
the MCP SDK through client, so a caller that only splits a stored tool
row's name, such as cowork-server's figure sources, loaded the SDK to do
it. names imports only dataclasses. registry imports all three from
names, so imports from registry keep working, and access.returns_records
points at names.

A test imports each module in a fresh interpreter and reads sys.modules:
names loads no mcp or mcp_types module, and registry does, which shows the
probe sees the SDK when it is there.

Lucas Koontz, ENG-3433: Give each figure in an answer its sources

Refs: ENG-3433

* test(session): share the scripted tool-loop turn through a helpers module (ENG-3433)

tests/test_hard_truncate_in_tool_loop.py imported the scripted planner,
PlanCalls, ANSWER and the tool-row builders from
tests/test_turn_rows_through_compaction.py, private names included. They
move unchanged into tests/_turn_rows_helpers.py under public names
(scripted_llm, scripted_session, run_turn, tool_use, tool_result), and
both test files import them from there.

A docstring line in tests/test_eng2420_empty_tool_call_id.py is re-wrapped
to its paragraph's width.

Lucas Koontz, ENG-3433: Give each figure in an answer its sources

Refs: ENG-3433

* fix(session): stub oversized kept tool results and correct the history frame contract (ENG-3433)

- hard_truncate_history stubs the largest kept tool_results until the rest
  fit a quarter of the planning model's window, so a tool loop whose latest
  result overflowed on its own retries with a valid request that fits,
  instead of failing every retry. turn_messages reads a stubbed last message
  once, as the stub the model saw.
- contract.py: for a turn that compacted, cowork-server since
  mindsdb/cowork-server#712 reads the history rows for figure sources and
  stores none; older servers store them.
- Tests for the separator branch of _summarize_history and the claim
  walk-back over an empty segment, the compaction frame going before the
  history frame, and anton.core.mcp.access importing no MCP SDK.
- names.py says a tool name holding __ never parses back; registry no longer
  re-exports the parse helpers; access.py comments on write tools and
  read_campaign_data.
- make_mock_llm sets planning_model, so window-derived budgets in tests read
  a real model id.
@mindsdb-release-train mindsdb-release-train Bot changed the title Release: staging → main (104 commits) Release: staging → main (105 commits) Oct 17, 2026
MinuraPunchihewa and others added 2 commits October 16, 2026 20:27
* held text from handback and returned only if it is not content_filter

* added a unit test

* re-worded the prompt addition to not appear as injection

* kept turn() returning the answer and Escape responsive while a hand-back is held

turn() skips anton's trailing notes, so a dropped hand-back still returns the
answer. The held hand-back checks for Escape on each delta and stops the call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* labelled every note anton adds to the history instead of opening it with SYSTEM:

A "SYSTEM:" note reads as an injection, which models refuse and Azure's filter
stops. Each note now opens with ANTON_NOTE (or _CHECK_NOTE for the completion
check). Readers still match "SYSTEM:" for histories saved before this. A test
scans the package so no note brings the old shape back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fixed the transcript docstring's wording

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* booked Escape during a held hand-back as cancelled

The hand-back sets the cancel flag itself before the books close, then yields
one event for the CLI to stop on. Both Escape tests assert the turn books
cancelled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* removed the hand-back's note when its reply is dropped

Left last in history, the note merged into the user's next message, so the
model and the verifier read it as the user's words. A filtered or cancelled
hand-back now takes its standalone note with it, and the turn ends on the
answer. A note merged into tool results stays, so those results aren't lost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ry a hand-back with no answer (#589)

A model call the provider's content filter stops now books the turn as
ended_by=content_filter, so the final event tells a client the filter fired.
Every call goes through plan_stream_with_recovery, which notes it; the books
resolve it at close, after cancel and error.

A filtered hand-back after a reply with no text is tried once more. Filtered
again, the turn ends with no text. A reply with text still ends the turn.
@mindsdb-release-train mindsdb-release-train Bot changed the title Release: staging → main (105 commits) Release: staging → main (107 commits) Oct 17, 2026
…422) (#572)

* feat(memory): read an optional read-only managed layer in Cortex

Cortex takes a keyword-only managed_hc. Its rules (through the same
relevance filter, in the same gather) and lessons render first in the
system prompt under "Operator Memory" headings, and its scratchpad
wisdom leads the scratchpad tool description. Nothing writes it:
encode, compaction, vacuum, identity extraction and the project-profile
move name only the person's two tiers.

The project-profile move now lives in its own method. A global tier
that can't be written logs the error type and keeps the project lines,
instead of failing every Cortex construction after it.

With managed_hc unset the prompt and scratchpad context are
byte-identical to before.

Refs: ENG-3422

* fix(memory): skip ACC rules the managed layer already holds

The ACC's end-of-turn de-dupe read only the person's global rules, so a
rule the operator's read-only layer already gives every turn was copied
into the person's tier and shown twice. It now checks managed_hc's rules
first, then the global tier's.

Refs: ENG-3422

* docs(memory): document the Cortex read-only managed layer

Refs: ENG-3422

* fix(memory): say managed minds-datasource lessons render as operator lessons

The README said the managed layer's minds-datasource topic is never read.
Cortex reads every managed lesson outside the scratchpad, tagged or not,
under Operator Memory — Lessons. Only the Minds datasource section skips
the layer, because it reads project_hc alone. Reword the note to match and
pin the behavior with a test.

Refs: ENG-3422

* fix(memory): say operator rules win, and document the read-only layer

The Operator Memory section now tells the model which tier wins. The first
operator heading that renders carries one line: the operator's rules and
lessons apply to everyone and win over the person's own memory when the two
conflict. Without the operator layer the prompt stays byte-identical. A test
pins the line once under the first operator heading and checks it is absent
when there is no layer.

The README and the developer docs (memory-systems, brain-mapping,
llm-dispatch) now describe the optional read-only layer and say its budgets
add to the prompt totals. They also correct the claim that anton.md is
injected after memory: anton.md comes earlier, and memory rides the tail of
the prompt, followed only by workspace discovery. The README notes that only
the ACC checks the operator layer, so memorize, the consolidator or the
cerebellum can save a verbatim copy of an operator entry that then renders
under both headings.

* fix(memory): label the operator's scratchpad wisdom and parse a managed rule's kind comment (ENG-3422)

- Cortex.get_scratchpad_context puts the operator's scratchpad wisdom
  under "Operator (applies to everyone, wins on conflict):" and the
  person's under "Your own:". With no operator wisdom, or no managed
  layer, the output is the person's lines alone, unlabeled.
- Hippocampus.get_rules drops a kind key from a rule's trailing comment,
  since the "## " heading decides the kind; a managed rule with such a
  comment raised TypeError on every turn.
- One _LESSONS_BUDGET_TOKENS constant for the four lessons budgets.
- README: the scratchpad labels, and which managed lessons drop first
  past the lessons budget.
@mindsdb-release-train mindsdb-release-train Bot changed the title Release: staging → main (107 commits) Release: staging → main (108 commits) Oct 17, 2026
…e cerebellum learns (ENG-3422) (#590)

The inline streaming exec in ChatSession._stream_and_handle_tools, which
every host's main agent takes, ran cells without firing the scratchpad
observers. So the cerebellum buffered no cell, and no lesson was ever
learned from a failed cell outside the generate_artifact sub-loops.

- The streaming exec fires the pre-execute observers before a cell and
  the post-execute observers after it, in handle_scratchpad's order. A
  cancelled exec fires pre and skips post. Both paths build the
  preliminary cell with one preliminary_cell helper.
- The end-of-turn cerebellum flush uses _background_memory_active, so
  memory mode off makes no diff call.
- In copilot mode, cerebellum lessons queue for confirmation like the
  scratchpad consolidator's, instead of writing straight to lessons.md.
- A cancelled turn drops its buffered cells instead of learning them at
  the next turn.
- The CLI's memory settings honor memory_enabled through
  AntonSettings.effective_memory_mode.
- Docs: both exec paths fire the observers.
@mindsdb-release-train mindsdb-release-train Bot changed the title Release: staging → main (108 commits) Release: staging → main (109 commits) Oct 17, 2026
…ace (#574)

* Add scrub_output, which also redacts OAuth tokens, JWTs and bearer values.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Scrub scratchpad cell output once, where the runtime records the cell.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Scrub replayed scratchpad cells when a runtime starts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Tell the agent to keep credential responses out of printed output.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Keep source code and prose out of token redaction.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Drop the HubSpot OAuth2 connect flow that prints tokens.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Redact any quoted value after a token key, whatever its characters.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Redact a quoted token value even when output ends before its closing quote.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Close bearer, line-break and escaped-slash gaps in token redaction, and keep scrub markers readable.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Keep only whole markers and placeholders readable, and close the remaining escape and line-break gaps.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Scrub scratchpad cells restored from an imported session.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Scrub conversation history restored from an imported session.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Keep the token filter on scratchpad output only, so other tools' source reaches the model as written.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Keep PascalCase types and UPPER_SNAKE constants readable after a token key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Tell the agent to move HubSpot OAuth2 connections to a Private App token.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Test the shared replay list across pads and say why imported user turns get the output filter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Scrub backend log tails before they reach the model or the retry prompt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Treat only letter words as type and constant names, so mixed tokens after a key are redacted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Scrub the whole backend log before taking its tail, and check the replay list is shared again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Scrub backend import errors, introspection stderr and browser findings before they reach the retry prompt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Say what the name rules and the tool-result filter actually cover.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Trim a killed cell's salvaged output at a line start, so a token never loses its key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Read and scrub backend logs in a worker thread, so a large log does not stall the event loop.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Scrub a killed cell's whole salvage before cutting it, since the scrub matches keys across line breaks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Drop the partial lines at the start of kept salvage after a chunk is dropped, so a token never outlives its key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Read and scrub the generator's backend log tail in one worker thread call.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Scrub kept salvage before cutting its leading lines, so a key on a kept line still redacts its token.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Drop a separator line along with the value line after a salvage drop, and mark a crashed cell's cut output as partial.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Tell the agent when a killed cell's output was withheld, and check a crash keeps the latest output scrubbed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Restore the HubSpot OAuth2 connect flow; its change ships separately.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Test that a printed saved secret shows its placeholder in the stream and the recorded cell.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Scrub a cell's stdout before cutting it to the output limit, so a saved secret across the cut stays whole.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Test the worker-cut margin with a saved secret across the cut after text the scrub shrinks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Keep the worker's stdout field as it was, cover secrets longer than the margin, and say when cut output was withheld.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Size the held-back tail by the values the scrub matches, so a long non-secret field does not withhold output.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Send the worker's uncut stdout only for output the limit cuts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Check every slice of a cut secret, pin which values set the held-back tail, and test cut output by what it reads.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Withhold a saved secret cut off at the end of a crashed cell's salvage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Withhold a multi-line saved secret cut at the start of dropped salvage, and say a withheld salvage was cut without naming the edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Redact a quoted token value up to its closing quote, whatever it contains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Redact a long value past the scan limit, keep template values readable, and treat PHP, Lua and SQL joins as code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Treat a quote as code only when it closes a literal opened before the key, and withhold the rest of the line past the scan limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Close a literal only with its own quote kind, read prose apostrophes as prose, and treat the rest of a line as data after a redacted value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(scrub): read a b'', f'' or rb'' literal before a token key as code (ENG-3448) (#591)

Lucas Koontz, ENG-3448 "Hide a printed token whose value contains a quote
or a space".

A lowercase string prefix before a single quote now opens a literal. Before
this, a letter after the quote marked it as prose, so
`body = b'client_secret=' + secret_bytes + b'&grant_type=client_credentials'`
lost its concatenation to [REDACTED_TOKEN]. Capitalized words such as
"Plan B's" still read as prose.

Refs: ENG-3448

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Lucas Koontz <lucas.emanuel.koontz@gmail.com>
@mindsdb-release-train mindsdb-release-train Bot changed the title Release: staging → main (109 commits) Release: staging → main (110 commits) Oct 17, 2026
…andlock (ENG-3476) (#588)

* feat(memory): read an optional read-only managed layer in Cortex

Cortex takes a keyword-only managed_hc. Its rules (through the same
relevance filter, in the same gather) and lessons render first in the
system prompt under "Operator Memory" headings, and its scratchpad
wisdom leads the scratchpad tool description. Nothing writes it:
encode, compaction, vacuum, identity extraction and the project-profile
move name only the person's two tiers.

The project-profile move now lives in its own method. A global tier
that can't be written logs the error type and keeps the project lines,
instead of failing every Cortex construction after it.

With managed_hc unset the prompt and scratchpad context are
byte-identical to before.

Refs: ENG-3422

* fix(memory): skip ACC rules the managed layer already holds

The ACC's end-of-turn de-dupe read only the person's global rules, so a
rule the operator's read-only layer already gives every turn was copied
into the person's tier and shown twice. It now checks managed_hc's rules
first, then the global tier's.

Refs: ENG-3422

* docs(memory): document the Cortex read-only managed layer

Refs: ENG-3422

* fix(memory): say managed minds-datasource lessons render as operator lessons

The README said the managed layer's minds-datasource topic is never read.
Cortex reads every managed lesson outside the scratchpad, tagged or not,
under Operator Memory — Lessons. Only the Minds datasource section skips
the layer, because it reads project_hc alone. Reword the note to match and
pin the behavior with a test.

Refs: ENG-3422

* fix(memory): say operator rules win, and document the read-only layer

The Operator Memory section now tells the model which tier wins. The first
operator heading that renders carries one line: the operator's rules and
lessons apply to everyone and win over the person's own memory when the two
conflict. Without the operator layer the prompt stays byte-identical. A test
pins the line once under the first operator heading and checks it is absent
when there is no layer.

The README and the developer docs (memory-systems, brain-mapping,
llm-dispatch) now describe the optional read-only layer and say its budgets
add to the prompt totals. They also correct the claim that anton.md is
injected after memory: anton.md comes earlier, and memory rides the tail of
the prompt, followed only by workspace discovery. The README notes that only
the ACC checks the operator layer, so memorize, the consolidator or the
cerebellum can save a verbatim copy of an operator entry that then renders
under both headings.

* feat(sandbox): lock a person's agent code to their own folders with Landlock (ENG-3476)

Lucas Koontz, ENG-3476: Lock each person's agent code to their own space
with Landlock on the self-hosted stack.

A host passes a CellSandbox policy through ChatSessionConfig.cell_sandbox.
With a policy set, every process that runs agent code (the cell, the venv
health check, package installs and the artifact verifier's two checks)
starts through a stdlib launcher that sets no_new_privs, locks itself with
Landlock to the policy's folders and the system paths, and execs the
target with an allowlisted environment. The policy and environment travel
in a memfd, never in argv. App backends and the browser and LibreOffice
lints are refused under a policy. A refusal names itself and never deletes
the venv or the saved namespace.

With no policy, every spawn's argv, env, cwd and keyword arguments are
unchanged, pinned by capture tests. In every mode, a pad name that leads
outside the venv folder is refused, and anton's writes inside its venvs
no longer follow symlinks.

Refs: ENG-3476

* fix(memory): label the operator's scratchpad wisdom and parse a managed rule's kind comment (ENG-3422)

- Cortex.get_scratchpad_context puts the operator's scratchpad wisdom
  under "Operator (applies to everyone, wins on conflict):" and the
  person's under "Your own:". With no operator wisdom, or no managed
  layer, the output is the person's lines alone, unlabeled.
- Hippocampus.get_rules drops a kind key from a rule's trailing comment,
  since the "## " heading decides the kind; a managed rule with such a
  comment raised TypeError on every turn.
- One _LESSONS_BUDGET_TOKENS constant for the four lessons budgets.
- README: the scratchpad labels, and which managed lessons drop first
  past the lessons budget.

* fix(sandbox): let pip read the os-release files under the lock (ENG-3476)

Lucas Koontz, ENG-3476: Lock each person's agent code to their own space
with Landlock on the self-hosted stack.

pip's vendored distro opens /etc/debian_version and /etc/os-release to
build its user agent, and only a missing file is treated as normal, so a
denied read crashed every pip install under the lock with exit 2. The
launcher now grants them, and /etc/lsb-release, read-only. Found by
make e2e's installs-off pass.

Refs: ENG-3476
@mindsdb-release-train mindsdb-release-train Bot changed the title Release: staging → main (110 commits) Release: staging → main (111 commits) Oct 18, 2026

This branch was successfully deployed

1 active deployment
pypi — bee1826e Deployed Oct 18, 2026 by lucas-koontz via Build and publish pre-release to PyPI #202
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.