Repository navigation
Release: staging → main (111 commits) - #560
Open
mindsdb-release-train[bot] wants to merge 111 commits into
Open
mindsdb-release-train[bot] wants to merge 111 commits into
mindsdb-release-train[bot] wants to merge 111 commits into
Conversation
…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>
…#479) * emit last_compaction * review fixes
…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
* 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>
* Azure retry * Check for retry * Add tests * Address concers
|
Automatic Review Skipped This PR changes 138 files, above the 100-file limit for automatic reviews. Trigger a full review by commenting: 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.
* 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.
…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.
…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.
…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>
…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
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release: staging → main
111 commit(s) queued for the next production release.
Changes
Contributors
Review checklist