URLCheck read the host from a regex capture group whose character class
excluded "@", so a userinfo-prefixed URL passed its userinfo off as the
host: https://allowed.com@evil.tld/ satisfied an allow_hosts entry for
allowed.com, and http://ok.com@blocked.tld/ evaded block_hosts. Match the
whole URL and take the host from the parsed authority instead. A URL that
will not parse is now redacted rather than waved through.
The PRIVATE_KEY secret pattern matched only the BEGIN header, so a redact
action masked the header and released the key material and END line in
the clear. Span the whole armored block, falling back to the header when
the block is unterminated or over the cap, and raise max_match_chars so
the streaming guard's window still covers a full PEM. The pattern also
now catches ENCRYPTED, DSA and PGP BLOCK headers, which it missed
entirely.
The documented GUARDRAILS_FLOOR example omitted "enabled": true, and the
field defaults to false, so an operator copying it got a floor that
parsed clean, merged to nothing and warned nowhere. Fix the example and
warn when a floor is set but disabled.
Bumps the Python backend dependency set, holding back the ones that are
resolver-blocked or that regress behaviour this repo depends on.
Notable upgrades:
cryptography 46.0.7 -> 50.0.0 (requires msal 1.37.0, which relaxes its cap)
protobuf 6.33.6 -> 7.35.1 (floats opentelemetry-* to 1.44.0)
openai 2.32.0 -> 2.53.0, anthropic 0.88.0 -> 0.121.0
google-genai 1.73.1 -> 2.17.0 (the 2.0 break is scoped to the Interactions
API, which this repo does not use)
fastmcp 3.2.4 -> 3.4.6, gunicorn 25.3.0 -> 26.0.0
starlette 1.0.0 -> 1.6.0, uvicorn 0.42.0 -> 0.52.1
sentence-transformers 5.3.0 -> 5.7.0, numpy 2.4.4 -> 2.5.1
faiss-cpu 1.13.2 -> 1.15.0, pillow -> 12.3.0 (pinned; security release)
pypdf 6.9.2 -> 6.15.0, lxml 6.0.2 -> 6.1.1 (CVE-2026-41066)
Code changes needed by the bumps:
- openai >= 2.53 rejects a falsy api_key at construction. Keyless
OpenAI-compatible backends (Ollama, llama.cpp, vLLM) legitimately have
none, and pydantic-settings yields "" for a bare `API_KEY=` in .env, so
both call sites now fall back to a placeholder.
- Flask >= 3.1.2 tears a stream_with_context request down twice, and a
ContextVar token may only be reset once. The log-context teardown hook
is now idempotent.
- sentence-transformers renamed get_sentence_embedding_dimension to
get_embedding_dimension in 5.4; use the new name with a fallback.
Held back deliberately:
torch 2.11.0 - 2.13.0 drags torchvision 0.26 -> 0.28 and the whole
docling stack; torch is only probed for cuda.empty_cache()
tokenizers 0.22.2 - transformers pins <=0.23.0 and no stable 0.23.0 exists
transformers - capped <5.9.0: 5.9+ breaks docling's PDF layout model on
Apple Silicon (MPS float64), matching docling-core's own
darwin pin
docling 2.84.0 - 2.118.1 needs rapidocr >=3.9.1 and conflicts with
transformers on macOS; wants its own PR with a
golden-corpus diff
redis 7.4.0 - 8.x defaults socket_timeout to 5s, silently capping the
blocking reads in the device broker and SSE tail
websockets 16.0 - google-genai caps <17.0
marshmallow - dataclasses-json hard-caps <4; spec tightened to match
langchain block - langchain-community 0.4.2 deletes the Qdrant vectorstore
this repo imports; langchain 1.3.x needs websockets <16
Source access control
---------------------
`active_docs` is client-supplied and reached the retriever unchecked, and the
retriever queries `WHERE source_id = <id>` with no owner predicate — so any
caller could pass any source id to /stream or /api/answer and have another
tenant's documents quoted back, while /api/sources/<id>/search correctly
refused the same id. Gate it through `can_access`, the helper the guarded
endpoints already use, and filter `self.source` down to the authorized set.
Fails closed: no principal, or a check that errors, drops the source.
Three sibling paths had the same gap:
- workflow agent nodes: `AgentNodeConfig.sources` is written verbatim from
client JSON at save time and nothing validated it, so a node could name any
tenant's source. Gate against the workflow owner, so shared workflows keep
reading their owner's sources like shared agents do.
- /api/share: `_resolve_source_pg_id` resolved any id with no ownership
predicate and baked it into the agent the share creates; /api/search then
searched it. Authorize before attaching.
- search_service: re-resolve the ids stored on an agent row instead of
trusting them, so a row written by any future path with the same gap cannot
be read back.
Team grantees previously lost their source's retrieval config: the post-check
read was still owner-scoped, so it missed and fell back to defaults (an
`agentic_tool` source was bulk-prefetched for every grantee). Read unscoped
after `can_access` passes.
Retrieval
---------
`PGVectorStore._ensure_table_exists` created an IVFFlat index on the empty
table it had just created. IVFFlat computes centroids at build time, so those
centroids were random, and combined with the `source_id` post-filter a source
with hundreds of embedded chunks returned zero rows — retrieval reported no
documents, the model answered from memory, and nothing was logged. Stop
creating the index (exact search is correct and fast well past the sizes most
deployments reach); raise `ivfflat.probes` to sqrt(lists) where an index still
exists; and re-run a short indexed search exactly, since post-filtering means
no index setting can guarantee a full result. `graphrag` had the same
empty-table index with no fallback at all.
Also: bound `chunks` to 0-500 on both the request and agent paths (0 still
means "skip retrieval"), let a source's configured `retrieval.chunks` outrank
the request body, and cap ClassicRAG's per-source floor at
max(top_k, n_sources) so attaching sources cannot inflate the result set.
Silent failures
---------------
An empty retrieval was invisible to both the model and the client: the `source`
event was suppressed when the list was empty, so "searched and found nothing"
looked identical to "no source attached", and the prompt said nothing at all.
Emit the event always, and tell the model when a search ran and returned
nothing. A file that parses to nothing now fails ingest with a message naming
the cause instead of storing an embedding of the empty string. `score_threshold`
returns warnings when the active store or retriever cannot honour it.
Prompt structure
----------------
Retrieved documents move from the system prompt into the user turn, with the
injection guard restated next to them: they change every turn (defeating prefix
caching), they are third-party text that should not carry system authority, and
routing them through the query budget makes them truncatable rather than
silently crowding it out. Documents are shed lowest-ranked-first before the
question is touched.
The six chat presets (3 tones x 2 retrieval modes) differed only in their
Answering section; they are now composed from single-source fragments at load
time, not through Jinja inheritance, which would have opened a file-read
surface in the template sandbox and broken the tool-prefetch parser. Per-tool
guidance moves out of the prompt into tool schemas, so it travels with the tool
and cannot render when the tool is absent. A plain-text custom prompt is staged
as a persona value inside the skeleton instead of replacing it wholesale — it
used to silently lose the injection guard, platform block, memory and
attachments, and its braces are now inert.
Other fixes
-----------
- agents/base: an oversized system prompt drove the query budget negative and
dispatched a full-price request with an empty question; raise instead.
- llm/anthropic: migrate off the retired Text Completions API. It flattened
history to first+last message and ignored tools entirely. Adds the missing
Anthropic handler, without which every tool call was silently dropped.
- sources/upload: `sitemap` had no branch, so every sitemap ingest died on a
TypeError; `validate_url` now rejects a falsy URL cleanly.
- workflow nodes: retrieved documents never reached the node agent, so a
classic node with a source and an ordinary prompt answered "I have no
documents" while the run reported completed.
- parser/bulk: copy the metadata dict, or every chunk reports the last chunk's
token_count.
- crawler_loader: carry the page title, or citations render the whole chunk
body as the label.
Two follow-ups from PR review.
_fit_attachment_text tokenized the extraction twice on the oversized
path — once for the budget check and once for chars_per_token. That is a
full BPE pass over the whole document (~12ms per 250k chars) on the hot
path of every attachment turn. Compute it once.
The CEL error summarizer kept quoted fragments that looked like
identifiers, on the theory that those are variable names rather than
data. They are not distinguishable: celpy interpolates state values into
messages (`StringType('SECRET')`), and a one-word user query such as
`invoice` is a valid identifier, so it survived redaction and reached
both the log line and — since config errors now bypass
sanitize_api_error — the user. The earlier test passed only because its
sample value contained hyphens.
Quoted fragments are now redacted by default, allowing only the two
positions known to hold names rather than data: the variable in
"undeclared reference to 'x'" and the class in "<class 'ValueError'>".
Tests cover identifier-shaped values, that the exception class survives,
the truncation backstop, and the empty-expression guard.
Workflows ship two expression languages in adjacent fields. Agent
`prompt_template` and end `output_template` are Jinja2, so `{{query}}` is
correct there. State and condition `expression` fields are bare CEL,
where the same string is a parse error.
Nothing told users this. docs/content/Agents/nodes.mdx documented the
pre-CEL Set State node with `{{variable_name}}` examples and Set /
Increment / Append operations that no longer exist — "CEL" appeared
nowhere in the docs. The builder's own placeholder was `input.foo + 1`,
referencing an `input` namespace that is not in workflow state, while the
panel beside it advertised `{{ agent.variable }}`.
There was also no validation anywhere: neither validateWorkflow nor
validate_workflow_structure looked at state nodes at all, and conditions
were only checked for a non-empty string. A workflow containing
`{{query}}` saved and published clean, then aborted on the first message
with a bare caret dump that sanitize_api_error collapsed into "An error
occurred … please try again later" — advice that sends the user round the
same loop, which is what the 2026-08-01 report shows.
Adds validate_cel_expression (compile-only, since state is built at run
time and unresolved names are not knowable when saving) and wires it into
the workflow save path for both node types, plus the half-configured
state operations the engine silently skips. `{{ }}` gets a targeted hint,
raised only after compilation has already failed so valid CEL that
contains braces — `x == "{{y}}"`, or a nested map literal — is not
rejected.
celpy errors are now summarized: undeclared-reference messages embed a
repr of the entire activation, thousands of characters including the
user's own query, and others interpolate the offending state value.
Quoted fragments that are not bare identifiers are redacted, since
config errors now bypass sanitize_api_error to reach the user, and a
shared agent's runner is not its owner.
Docs rewritten with a table of which field takes which syntax, CEL
examples for Set State, and the Condition node section that basics.mdx
has been linking to all along.
_upload_file_to_openai short-circuited on `"openai_file_id" in attachment`.
Attachment dicts are built by row_to_dict from `SELECT *`, so that key is
always present and is NULL until an upload caches one — every fresh
attachment read as a cache hit. The method returned None without
uploading and, critically, without raising, so the except branch that
inlines the extracted text never ran. The part went out as
`{"file_id": None}`, which _resolve_file_part degraded to
`[File 'upload.pdf' could not be processed]`.
The model then relayed that to the user while thousands of tokens of
extracted text sat unused on the attachment row. Reproduced live against
the Foundry deployment: the model answers "I couldn't access the attached
PDF because it could not be processed; please re-upload it or paste the
text". The same bug was fixed in google_ai.py by 8e9f661e ("Truthy check,
not membership") which never touched this file; d29a7aaf later turned the
resulting hard 400 into this silent wrong answer.
Truthy check restored, a falsy file_id now raises so the fallback runs,
and the fallback tests `content` truthily — it too was a membership test,
so an empty extraction sent a bare "File content:" header that reads as a
successfully-read document. With no content at all we emit a note naming
the user's own file instead of the `upload.pdf` placeholder.
The file part deliberately carries `file_id` only: sending `filename`
alongside it is rejected with
`400 Unknown parameter: 'messages[0].content[1].file.filename'`.
Two consequences of making this path live for the first time are handled
here. `attachments.openai_file_id` is a single global column, so ids are
now stamped with an endpoint fingerprint — an id minted against one
deployment replayed against another answers "No such File object" on
every retry, permanently, since the row keeps the bad id. A foreign or
legacy unscoped value is treated as a miss and re-uploaded, so it
self-heals. And inlined text is bounded against the model's context
window, because _enforce_context_window runs before attachments are
merged into the messages and nothing downstream would trim a 200-page
extraction.
WorkflowEngine reports node failures by *yielding* `{"type": "error"}`
rather than raising, so complete_stream's generator returns normally and
the except handler never runs. The turn was finalized `status="complete"`
with an empty response.
Live, the client renders an error bubble with a Retry button. On reload
it does not: mapServerQueryToClient only surfaces `metadata.error` for
`failed` rows, so history showed a blank message with no error and no way
to retry. A user hitting this re-sends the same prompt into new
conversations, which is exactly what the 2026-08-01 report shows — nine
blank first messages in seven hours.
Tracks a `stream_error` flag alongside the existing `paused` machinery
and finalizes `failed` when the turn produced no answer, recording the
user-facing message in `metadata.error`. An error arriving *after* output
keeps `complete` so partial text is not discarded; structured answers
count as output too, since they live in `structured_chunks` rather than
`response_full`. The flag is recorded before the pause branches so those
paths cannot lose it.
save_conversation grew a `status` parameter (default `complete`) for the
non-WAL branch, which took no status and so landed on the column default
— the same blank-complete row on a path the WAL fix did not cover.
Title generation now also runs for failed turns: _maybe_generate_title
only regenerates while the name is still the question-prefix fallback, so
skipping it would strand a conversation whose first turn failed with the
raw prompt as its name forever.
logging.py counts a yielded error toward `activity_finished.status`.
These failures previously logged `status=ok` with `answer_length=0`,
which is why user-visible blank answers never appeared in error metrics.
/api/models reports `display_provider` when a catalog YAML sets one
(`foundry`, `azure_foundry`, `cloudflare`), and the builder persists that
string as a node's `llm_name`. The engine passed it straight to
LLMCreator, which only knows the names in PROVIDERS_BY_NAME and raises
`No LLM class found for type <label>`.
That fails the node before any LLM call, so the turn ends in ~60ms with
an empty answer and no tokens generated. It hits the *default* path: the
platform-default model's label is stamped into every newly dragged agent
node and validateWorkflow requires an agent node, so a new user's
untouched workflow could not produce a token regardless of what they
typed. Ordinary chat was unaffected because it resolves the provider from
the model registry and never reads the stored name.
Adds `resolve_dispatch_provider`, which prefers a stored name that is a
real dispatch provider, then the registry lookup, then the parent agent.
Nodes already saved with a label are repaired at run time, so no
migration is needed. The api_key now resolves from the normalized name
too — `get_api_key_for_provider` falls back to settings.API_KEY for names
it does not recognize, which would have sent the deployment key to
whatever endpoint the label happened to select.