Commit Graph
6169 Commits
Author SHA1 Message Date
Yash Pratap Solankyandkausmeows 78fa25982e fix: persist ClaudeAgent SDK session id across restarts (#10903)
## Summary

`ClaudeAgent` kept the Agno `session_id` -> Claude SDK session id
mapping only in memory (`self._sdk_session_ids`). After a process
restart, or on a different AgentOS worker, the same Agno session
silently started a fresh Claude conversation. The AgentOS UI still
showed the full history from the DB, so it looked as if the session had
continued.

- **Persist the mapping:** the SDK session id is now stored in
`session.session_data["claude_sdk_session_id"]`, which the base class
already upserts after each run. The in-memory map stays as the fallback
when no `db` is configured. This matches what `CodexAgent` and
`AntigravityAgent` already do.
- **Fall back when a stored id can't be resumed:** the SDK keeps its
transcript on local disk under the agent's `cwd`, so a stored id may
point at a transcript that isn't there (another host, changed `cwd`,
deleted transcript). In that case the CLI exits 1 before yielding any
message, and without a fallback the session would fail on every run from
then on. If a resume fails before the first message, the adapter forgets
the id and starts a fresh SDK session, with the Agno history added to
the start of the prompt. Errors after messages have started streaming
are still raised, so output is never duplicated.
- **Share the prompt builder:** `_build_prompt` moved from `CodexAgent`
to `BaseExternalAgent` so both adapters use it. Codex behavior is
unchanged.
- **Simpler adapter hooks:** both hooks now go through a single
`_aquery` helper, which removes the duplicated session-id capture code
in the streaming and non-streaming paths.

## Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [ ] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [ ] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

**Tests:** new file `libs/agno/tests/unit/agents/test_claude.py`, using
a fake `claude_agent_sdk`. It covers:
- persistence and resume across a fresh agent instance (streaming and
non-streaming)
- the stale-id fallback, including the history added to the prompt
- the in-memory mapping when no db is configured
- no retry when the run wasn't resuming

The 3 persistence and fallback tests fail on `main`; the other 3 are
regression guards. All 58 tests in `tests/unit/agents/` pass.

**End-to-end check with the real SDK (0.2.95):** each turn ran in a
separate Python process against a shared SQLite db.
1. Turn 1 stored the SDK session id.
2. Turn 2, in the same `cwd`, resumed that session and recalled a fact
from turn 1.
3. Turn 3, in a different `cwd`, logged the resume warning, fell back to
a fresh SDK session, still recalled the fact from the added history, and
stored the new id.

**validate.sh:** ruff passes. mypy reports 0 errors in `agno/agents`.
The 36 mypy errors it does report are in modules that wrap third-party
SDKs (`tools/mcp`, `utils/models/_genai_compat`, `models/google`,
`models/anthropic`). They come from SDK-version drift in my local venv
and are not touched by this PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: kausmeows <[email protected]>
2026-10-09 11:26:38 +05:30
5f1fd0ce28 chore: Release v3.1.2 (#10892)
# Changelog

## New Features:

- **Conversation Compaction**: `Agent(compaction=True)` keeps long
sessions inside the context window by folding older turns into a
summary. Stored messages are never rewritten: a `CompactionRecord` in
the new `agno_compactions` table drives a derived model payload, so
removing the record restores the full conversation. Tune with
`Compaction(model=..., compact_at_tokens=..., uncompacted_runs=...)`,
fold on demand with `agent.compact()` / `acompact()`, and inspect
`run.compaction`. With `searchable=True` the agent gets a
`search_compacted_history` tool, a regex grep over the archived
transcript. See
[cookbook](https://github.com/agno-agi/agno/blob/main/cookbook/02_agents/03_context_management/compaction/compaction.py).
(#9873)
- **Codex External Agent**: Added `CodexAgent`, an adapter for OpenAI
Codex built on the `openai-codex` SDK, alongside the Claude Agent SDK,
LangGraph, DSPy and Antigravity adapters. Codex runs its own agent loop;
the adapter translates its notifications into Agno run and tool-call
events, maps each Agno session to one Codex thread (persisted when a
`db` is set), and exposes `sandbox`, `approval_mode`,
`reasoning_effort`, `output_schema` and MCP config. Works standalone and
through AgentOS. See
[cookbook](https://github.com/agno-agi/agno/blob/main/cookbook/frameworks/codex/codex_basic.py).
(#10901)
- **HyDE Query Transform**: Added `QueryTransformer` as a new retrieval
hook and `HyDE` as its first implementation: `Knowledge(vector_db=...,
query_transformer=HyDE())` searches with a hypothetical answer instead
of the raw question, while rerankers still score against the original
query. `HyDE` uses its own `model`, then the calling agent or team's
model, then the framework default. See
[cookbook](https://github.com/agno-agi/agno/blob/main/cookbook/07_knowledge/02_building_blocks/12_hyde_query_transformer.py).
(#10527)
- **Browser Origin Policy**: Added `AgentOS(cors=CORSConfig(origins=...,
origin_regex=..., merge_base_app=...))` and opt-in
`PublicSurface(enforce_browser_origins=True)`. One origin policy is now
shared by CORS preflights, public run and cancel admission, auth error
headers, workflow WebSockets and MCP aliases, and CORS is the outermost
middleware so error responses carry consistent headers.
`cors_allowed_origins` keeps its existing behaviour. See
[cookbook](https://github.com/agno-agi/agno/blob/main/cookbook/05_agent_os/27_public_pages/browser_origins.py).
(#10869)
- **Configurable Follow-ups**: `followups: bool | FollowupConfig` on
Agent and Team. `FollowupConfig` sets a count range (`num_followups` or
`min_followups` / `max_followups`), a separate model and custom
instructions that go only to the follow-up call. The default prompt now
stays within the answer's boundaries and no longer re-offers a request
the answer declined. Existing `num_followups` callers keep getting
exactly N suggestions. See
[cookbook](https://github.com/agno-agi/agno/blob/main/cookbook/02_agents/14_advanced/followup_instructions.py).
(#10087)
- **Heabsy**: Added `Heabsy` as an OpenAI-compatible model provider,
also resolvable as `"heabsy:<model>"`. See
[cookbook](https://github.com/agno-agi/agno/blob/main/cookbook/90_models/heabsy/basic.py).
(#10828)
- **FlexAI**: Added `FlexAI` as an OpenAI-compatible model provider for
open-weight models, also resolvable as `"flexai:<model>"`. See
[cookbook](https://github.com/agno-agi/agno/blob/main/cookbook/90_models/flexai/basic.py).
(#10871)
- **SixtyDBTools**: New toolkit for the 60db hosted speech API: discover
workspace voices and generate speech as WAV audio artifacts. See
[cookbook](https://github.com/agno-agi/agno/blob/main/cookbook/91_tools/sixtydb_tools.py).
(#10786)

## Improvements:

- **Page Sync Logs**: Page synchronization now logs lock waits,
discovery, progress every 25 pages and final counts, so long syncs show
progress without a custom callback. Added an AgentOS cookbook that syncs
documentation into PostgreSQL-backed knowledge pages and exposes them
through the agent's filesystem. See
[cookbook](https://github.com/agno-agi/agno/blob/main/cookbook/05_agent_os/27_filesystem/knowledge_fs.py).
(#10868)
- **OpenAIResponses & OpenRouterResponses**: Recognise GPT-6 (OpenAI)
and GPT-5/6 (OpenRouter) as reasoning models, so reasoning-state
chaining and encrypted reasoning replay apply and tool-call follow-ups
no longer fail with missing reasoning-item errors. (#10830)
- **PerplexitySearch**: Requests now send an `X-Pplx-Integration: agno`
header so Perplexity can attribute traffic from the integration.
(#10827)

## Bug Fixes:

- **Approvals**: Each `@approval` tool call in a run now gets its own
approval record, for agents and teams. Previously a second pause in the
same run created no record and the next continue ran the new call
against the approval resolved for the first one. (#10812)
- **Model Response Cache**: Pydantic `output_schema` classes are keyed
by their JSON schema instead of their class name, so two schemas with
the same name but different fields no longer share a cached response.
(#10621)
- **Usage Metrics**: `MessageMetrics.cache_write_tokens` is now
populated from OpenAI Chat Completions and Responses usage (#10313) and
from LiteLLM's `prompt_tokens_details` (#10476). Previously it was
always zero.
- **JsonDb**: Tables are serialized to a staging file and published with
`os.replace`, so a failed write leaves the last successfully saved table
readable instead of a truncated file. (#10794)
- **MongoDb & MongoVectorDb**: `session_name`, `search_content` and
keyword-search filters are regex-escaped, so a value like `(draft` no
longer returns HTTP 500 from `GET /sessions` and `GET /memories`, and
`v1.2` no longer matches `v1x2`. (#10709)
- **Tool Schemas**: `Annotated[T, ...]` hints are unwrapped to `T`
before JSON schema generation instead of falling through to an empty
object schema. (#10858)
- **SQLTools**: Connection URLs are built with `URL.create`, so a
password like `p@ssword` is no longer split into password `p` and host
`ssword@...`. (#10800)
- **FileTools**: `replace_file_chunk` rejects negative, reversed and
out-of-bounds line ranges with an error and leaves the file untouched; a
valid replacement preserves the trailing newline. (#10798)
- **GoogleCalendarTools**: `find_available_slots` compares the full slot
end against closing time on its start date, so a 45 minute slot is no
longer offered at 16:30 for a 17:00 close and slots cannot cross
midnight. (#10878)
- **BaiduSearchTools, Searxng & SpiderTools**: An explicit
`max_results=0` / `fixed_max_results=0` now means no results instead of
falling back to the default. (#10877)
- **Readers**: `TextReader`, `MarkdownReader` (#10779) and `JSONReader`
(#10634) accept streams whose `name` is an integer or `None`, such as
descriptor-backed temporary files, instead of returning nothing or
raising.
- **Cookbooks**: Fixed the `DynamoDb` import path in the DynamoDB
storage README. (#10788)

---------

Co-authored-by: Yash Pratap Solanky <[email protected]>
Co-authored-by: ysolanky <[email protected]>
v3.1.2
2026-10-08 15:50:35 -04:00
83dd1329bc feat: auto compaction with a searchable archive (#9873)
## Summary

Adds **conversation compaction** — keeping a long session inside the
context window by folding old turns into a summary, while the originals
stay recoverable.

### The problem

A long session has no good options in Agno today:

| Existing mechanism | Scope | Why it doesn't solve this |
|---|---|---|
| `num_history_runs` / `num_history_messages` | History window | Drops
old turns entirely — lossy, no summary |
| `CompressionManager` | Individual **tool results**, inside the model
loop | Per-run only. Never touches conversation turns |
| `ResultStore` (offload) | Oversized **tool results** → AgentFS |
Tool-call-scoped by construction. No message equivalent |
| `SessionSummaryManager` | Session summary → system prompt |
*Additive*: you get the summary **plus** all raw history. Fires every
run, no threshold. Shrinks nothing |

### The approach: derived views over an append-only transcript

Compaction never rewrites stored messages. A **`CompactionRecord`** —
summary, boundary anchor, elision watermark — is written to
`agno_compactions`, and the model payload is **derived fresh per call**
from the canonical messages plus that record.

```
CANONICAL              RECORD (one row)              DERIVED (per call, discarded)
runs[].messages   +    first_kept_message_id    →    [system] [summary] [kept tail]
(never mutated)        elision_watermark_id
                       summary · archived_messages
```

Three properties fall out: nothing is destroyed (drop the record and the
full conversation returns), concurrent readers are safe, and an anchor
that no longer resolves **fails open** to the full list rather than
cutting in the wrong place.

By default (`searchable=True`) the agent also gets a **grep over the
archived transcript**, so a summary that dropped a detail is still
recoverable mid-conversation.

---

## DX

**One flag**

```python
agent = Agent(
    model=OpenAIResponses(id="gpt-5.6-luna"),
    db=SqliteDb(db_file="tmp/compaction.db"),
    add_history_to_context=True,
    compaction=True,
)
```

**Tuned — every field a plain scalar**

```python
from agno.compaction import Compaction

compaction=Compaction(
    model=OpenAIResponses(id="gpt-5-mini"),   # a cheap model does the summarizing
    compact_at_tokens=100_000,
    uncompacted_runs=5,
)
```

**Folding on demand**

```python
result = agent.compact(session_id="s1")      # and acompact()

if result.compacted:
    print(result.record.tokens_before, "->", result.record.tokens_after)
else:
    print(result.status.value, result.message)
```

Only the size trigger is bypassed — a caller asking to compact has
supplied the judgement `compact_at_tokens` exists to make. Every other
guard still applies, and **declining is reported, not raised**: a
summary costs a few hundred tokens whatever it replaces, so folding a
smaller span would leave the context *bigger*. `CompactionResult`
carries a status and a message written for display.

**Searchable archive**

```python
compaction=Compaction(uncompacted_runs=5, searchable=True)
```

The agent gains one tool, `search_compacted_history(pattern,
context_lines)` — a **regex grep** returning numbered lines with
context, `grep -n -C` style, scoped to this session by construction.
Observed in a live run, unprompted:

```
search_compacted_history(pattern=Pinned build hash|Rotation window|b7f2ae91c4|47 days, context_lines=2)
```

**Inspecting what happened**

```python
run = agent.run("...")
print(run.compaction)
# CompactionRecord(messages_compacted=6, tokens_before=17786, tokens_after=5537, archived=True)
```

**Streaming events**

```python
async for chunk in agent.arun(q, stream=True, stream_events=True):
    if chunk.event == RunEvent.compaction_completed.value:
        print(chunk.messages_compacted, chunk.tokens_before, chunk.tokens_after)
```

### Full surface

| kwarg | default | meaning |
|---|---|---|
| `model` | agent's model | model used to summarize |
| `compact_at_tokens` | `150_000` | fold when the context exceeds this;
`None` disables the automatic trigger |
| `uncompacted_runs` | `5` | recent runs kept verbatim |
| `uncompacted_tokens` | `None` | recent history kept verbatim, bounded
by size instead of turns; mutually exclusive with `uncompacted_runs` |
| `archive` | `True` | store the folded transcript alongside the record
|
| `searchable` | `True` | give the agent grep over the archive; off when
`archive=False` leaves nothing to search |
| `elide_tool_results` | `True` | render old tool results as a
placeholder |
| `min_fold_ratio` | `2.0` | skip a fold not meaningfully larger than
the tail it keeps |
| `summary_budget_tokens` | `2_000` | soft length target given to the
summarizer |
| `on_context_overflow` | `False` | also fold reactively when the
provider rejects a request as too long; bare `compaction=True` turns
this on (and leaves `compact_at_tokens` unset) |
| `instructions` | `None` | custom summarization prompt |

`bool | object` matches every existing Agno manager, so nothing new is
learned.

**The tail is measured in runs, not messages.** A run is one turn, so a
run-based tail never
cuts through the middle of one — which is what the pair-safe boundary
walk wants anyway. A message
count was also invisible to the planner-window guard below, which
reasons in runs, so it could
strand a boundary anchor with no warning.

**Size is the only automatic trigger.** Run and message counts were
considered and dropped: twenty short exchanges and twenty research turns
differ by orders of magnitude, so counting them fires on conversations
far too small to fold and stays quiet on ones that overflow.
`agent.compact()` covers the "fold at a seam I choose" case that a count
was standing in for.

---

## Design notes

**Where it plugs in.** `get_run_messages()` / `aget_run_messages()`,
right after the history block — one seam shared by all four run paths,
so none of them needed changing.

**Anchors are message ids, not indexes.** History is rebuilt from stored
runs every run, so a positional boundary means something different each
time and the cut silently crawls forward. An id resolves to the same
message forever, or not at all.

**Three tiers, increasing cost.** Elision replaces old tool results with
a placeholder (zero inference, reclaims the bulk of a tool-heavy
transcript); folding summarizes old turns (one LLM call); archiving
stores the originals (one write, buys recoverability). Independent
knobs, not one mode.

**Cut safety.** `choose_boundary` never splits a tool batch — in either
direction — and never anchors on a message that won't survive to the
next run (`temporary`, `add_to_agent_memory=False`). Returns `None`
rather than cutting unsafely.

**Storage.** `agno_compactions`, one row per fold, written once and
never updated. Records are immutable facts about single runs, so
concurrent writers can't collide and a resumed run can resolve the fold
it actually saw. Optional-table pattern following `agno_tool_results`:
base raises `NotImplementedError`, SQLite and PostgreSQL implement,
other backends degrade to summary-only with a warning.

### Bounding the kept tail by size

`uncompacted_runs` says how many turns survive a fold. It does not say
how large they are — and
compaction only folds what sits in FRONT of the tail, so a verbose turn
landing inside the tail
is one it can never bring back down. Five huge runs produce an unbounded
tail that no amount of
folding reaches.

`uncompacted_tokens` bounds the tail's size instead. On five detailed
turns of ~4,700 tokens each:

```
uncompacted_runs=2       fold 14,178  tail 9,452   ratio 1.50  -> declined
uncompacted_tokens=2000  fold 18,929  tail 4,701   ratio 4.03  -> folds
```

Both kept "the recent conversation". The run count kept two enormous
turns, leaving too little
in front of them to pay for a summary — so nothing happened and all
23,630 tokens stayed.

The cut is still pair-safe: it snaps to a turn boundary rather than
severing a tool call from
its result, so the tail can come out somewhat larger than the budget. It
is a target, not a hard
cap — and once the tail holds a single turn, no smaller budget can
shrink it further.

The two are **mutually exclusive and raise** if both are set. They
describe the same tail in
different units, and silently honouring one of two values the user
deliberately set is the kind
of surprise that costs an afternoon.

### The history window compaction reads

`num_history_runs` defaults to `3`; `uncompacted_runs` defaults to `5`.
Left alone, those two independent defaults collide and
**`compaction=True` could never fold** — the tail is larger than the
whole window, so nothing is ever in front of it.

Worse, a fold's anchor is a message id but `num_history_runs` is a
sliding count. A few runs after a fold the anchor falls outside the
window, stops resolving, and the view fails open — so the model loses
the folded turns *and* the summary that replaced them, silently.

So the planner reads its **own bounded window** rather than the replay
window. Bounded, not unlimited: capped so a 10,000-run session costs no
more than a short one. An explicit `num_history_runs` is respected
as-is, except where it sits at or below `uncompacted_runs` — a
configuration that cannot express a working compaction — where it is
raised to the minimum that keeps the anchor findable, **with a
warning**. Silently ignoring a number the user set is worse than the
misconfiguration it works around.

```
compaction=True (both defaulted)       planner window widened
num_history_runs=50                    50, respected exactly
num_history_runs=3, uncompacted_runs=5 6, and warns
```

`num_history_runs` continues to govern what the model replays. Only the
planner's read is widened.

---

## How a fold is decided

Two gates, and both have to pass.

**Gate 1 — the trigger: "is there enough here?"** `compact_at_tokens`,
measured against the view about to be sent — system text, tool schemas
and the history that survives the active record. Passing it only means
*look closer*.

The obvious cheaper signal is wrong here. `RunMetrics.input_tokens` is
billing telemetry: it sums *every* model call in a run, so a tool loop
that re-sends a 20k context four times reports 80k, and a 25k threshold
fires on a request that never exceeded 21k. It also absorbs background
and compaction-model calls, so folding inflates the number that decides
whether to fold again. Per-call sizes are not recoverable either —
`ModelMetrics` entries merge by provider+id before compaction sees them.

So the trigger estimates locally instead, via
`agno.utils.tokens.count_tokens` rather than `Model.count_tokens` (a
network call on some providers). ~150ms on a 400-message transcript,
once per run, against a summarizer call costing seconds. It is also the
same unit the ratio guard below already uses, so both gates now measure
the same quantity.

**Gate 2 — the ratio: "is it worth it?"** This is the one that decides:

```
ratio = tokens being folded  ÷  tokens in the kept tail
```

`min_fold_ratio` defaults to `2.0` — the fold has to be at least twice
the tail.

A summary is never free: it costs a few hundred tokens whatever it
replaces. Folding 20,000 tokens to save 19,500 is obviously worth it;
folding 600 to save 100 is not, and it discards the prompt-cache prefix
to do it. A ratio catches that at any scale, where a fixed size
threshold would not.

From a live session (`compact_at_tokens=4_000`, `uncompacted_runs=1`, an
agent instructed to answer at length):

```
Q1   fold  9,691   tail  8,265   ratio 1.17   skipped - barely more than is kept
Q2   FOLD                                     24,965 → 8,620 tokens
Q3   fold  7,009   tail 12,075   ratio 0.58   skipped - little new since the fold
Q4   fold 19,084   tail 10,962   ratio 1.74   skipped - just under the bar
manual compact()                              59,045 → 14,173 tokens
```

Q3 is the clearest case: right after a fold, the summary plus the tail
*is* most of the context, so there is little in front of the tail left
to reclaim. Q4 shows the bar doing its job on a near miss — and note
that Q3's total (19,084) becomes Q4's foldable span exactly, once one
more turn pushes a new tail in front of it.

**Tuning.** Continuing the conversation is usually the real answer: the
fold grows with every
turn while the tail stays roughly fixed, so the ratio climbs on its own.
Shrinking the tail helps
only while it still holds more than one turn — the cut is pair-safe, so
no setting can cut inside
a turn, and a decline names that setting only when changing it could
actually move the boundary.
`min_fold_ratio=1.5` is the other lever.

A declined fold says why, with the numbers:

```
This fold would replace 1234 tokens against a 1234-token tail (ratio 1.00, needs 2.0),
so the context would not shrink. Continue the conversation, or lower uncompacted_runs=2
or min_fold_ratio to fold sooner.
```

**Known limit.** The ratio measures the *input*, and cannot know how
large the summary will be until it exists. On a span that is mostly raw
data — a table, a log dump — the prompt requires identifiers be
reproduced verbatim, so the summary can come back nearly the size of
what it replaced. Observed one fold saving only 20 tokens for this
reason. Catching that needs a post-hoc check; see Additional Notes.

### Mental model

```
    ← fold grows forever ──────┤ window is fixed at N turns ├→
    (everything ever said)      (but its SIZE varies wildly,
                                 because turns vary in length)

ratio = fold ÷ window        want ≥ 2.0
```

### Provider interaction worth flagging

`OpenAIResponses` chains via `previous_response_id`, and the server then
replays the **whole** pre-fold conversation — so the savings would be
imaginary and the agent would never consult the archive. The view strips
that key from assistant copies. Only that key: reasoning items survive,
because a `function_call` without its paired reasoning item is a hard
400.

**Only `OpenAIResponses` has been audited for this.** Other providers
with server-side conversation state may need the same treatment.

### Composing with `offload_tool_results`

Supported. Offload envelopes are never elided, and when one is folded
away its `result_id` is carried forward in the summary so `read_result`
still resolves the payload. Envelopes are also excluded from the
fold-ratio calculation, since they're pinned cost that folding can't
reclaim.

---

## AgentOS route

```
POST /agents/{agent_id}/sessions/{session_id}/compact
```

Returns **200 with a status** whether or not it folded, because
declining is a normal outcome rather than a failure — raising would make
"folding would not help here" indistinguishable from an error and force
every caller to catch it.

```json
{
  "status": "compacted",
  "message": "Compacted 18 messages (15864 -> 3981 tokens).",
  "compacted": true,
  "record": { "messages_compacted": 18, "tokens_before": 15864, "tokens_after": 3981 }
}
```

| status | meaning |
|---|---|
| `compacted` | the fold happened; `record` carries the counts |
| `not_worth_it` | the span is too small to pay for the summary
replacing it |
| `nothing_to_fold` | the kept tail covers the whole conversation |
| `already_compacted` | a previous fold already covers everything
foldable |
| `no_history` | the session has no stored history yet |
| `not_enabled` | compaction is not configured on this agent |
| `summary_failed` | the summarizer returned nothing |

A missing agent is a 404 — that is a real error. Consumed by the AgentOS
chat UI as a "Compact now" action (separate repo, not in this diff).

---

## Type of change

- [x] New feature
- [ ] Bug fix
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [x] Examples and guides: cookbook examples included
- [x] Tested in clean environment
- [x] Tests added/updated

### Duplicate and AI-Generated PR Check

- [x] I have searched existing open pull requests
- [x] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Relationship to #9834 and #9291

**#9834** (context compaction for agents and teams) is the more complete
implementation, and this PR **adopts its core architecture**: derived
views (`_view.py`), pair-safe durable cut selection (`_cut.py`),
local-only token estimation, and its structured summarization prompt.
Credit where due — those are better than what this PR had.

What this PR keeps that #9834 does not have:

- **A searchable archive.** #9834's summary *is* the history; a detail
it dropped is gone. Here the folded transcript is stored and greppable,
so the summary works as an index over ground truth.
- **Records in a table** rather than on the session, giving
`continue_run` correctness and multi-writer safety.
- **Manual folding** — `agent.compact()` plus an AgentOS route, with
declines reported as statuses rather than raised.

What #9834 still has that this does not: background folds, an in-run
loop seam, and Teams.

**#9291** is superseded by both. It rewrites stored history in place,
has no handling for provider-side conversation state (so its savings are
partly illusory on `OpenAIResponses`), and its own PR body notes the DX
is under review across three companion PRs.

---

## Testing

**83 unit tests** in `libs/agno/tests/unit/compaction/` plus **4 route
tests** in `libs/agno/tests/unit/os/routers/`: trigger matrix, boundary
safety (property test over every cut point), grep behaviour, archive
round-trip and cross-session isolation, provider-state detachment, both
fold guards, `continue_run` resolution, the planner-window fix and its
warning, the manual-compaction result contract, the trigger measuring a
tool loop's context rather than its billing total, and the size-bounded
tail with its mutual exclusion.

Cookbooks, run end-to-end against a live model:

- `cookbook/02_agents/03_context_management/compaction/` — basic,
thresholds, searchable archive, streaming events, async, manual, with
tools, Anthropic, Anthropic + thinking
- `cookbook/05_agent_os/21_compaction/` — an AgentOS serving both an
automatic and a manual-only agent, plus a REST script driving a fold
over HTTP

---

## Additional Notes

Deliberate follow-ups, not gaps:

- **Team parity.** `Team` mirrors every compression flag but has none of
this wired yet — the largest remaining gap.
- **Background folds and the in-run seam.** The summarizer call
currently blocks the turn, and compaction only fires at run start, so a
single long tool-calling run can still overflow. These are coupled: a
fold started in the background needs a loop-top to be adopted at.
- **`compress_tool_results` deprecation.** The existing surface still
runs in parallel. Nothing here changes or breaks it — it also still
covers in-run tool results that compaction cannot reach.
- **A post-hoc size check.** The ratio bounds the input but cannot
guarantee the outcome; verifying a fold actually shrank the context
needs a check after the summary exists.

---------

Co-authored-by: Anurag <[email protected]>
Co-authored-by: Harsh <[email protected]>
Co-authored-by: Harsh Sinha <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Yash Pratap Solanky <[email protected]>
2026-10-08 15:15:26 -04:00
Kaustubh 98e3751e52 feat: add support for codex harness external agent (#10901)
## Summary

Adds `CodexAgent`, an external-agent adapter for OpenAI Codex, alongside
the existing Claude Agent SDK, LangGraph, DSPy and Antigravity adapters.
Codex agents can now be served through AgentOS or used standalone via
`.run()` / `.print_response()`.

The adapter wraps the official [Codex Python
SDK](https://github.com/openai/codex/tree/main/sdk/python) (`pip install
openai-codex`), which bundles the Codex CLI and runs it locally as an
app-server subprocess. Codex runs the full agent loop itself (shell
commands, file edits, web search, MCP tools); the adapter translates its
notifications into Agno run events and maps each Agno session to one
Codex thread.

**Adapter** (`libs/agno/agno/agents/codex/`)
- Subclasses `BaseExternalAgent`, implementing `_arun_adapter` and
`_arun_adapter_stream`.
- Options map onto the SDK: `model`, `instructions` (developer
instructions), `base_instructions`, `sandbox` (`read-only` /
`workspace-write` / `full-access`), `approval_mode` (`auto_review` /
`deny_all`), `reasoning_effort`, `cwd`, `ephemeral`, `output_schema`,
`config` (Codex config overrides, e.g. `mcp_servers`), `codex_bin`,
`env`, plus `thread_kwargs` and `turn_kwargs` escape hatches.
- Streaming translation: `item/agentMessage/delta` -> `RunContentEvent`;
`commandExecution`, `fileChange`, `mcpToolCall`, `webSearch` and
`dynamicToolCall` items -> `ToolCallStartedEvent` /
`ToolCallCompletedEvent` (tool names `shell`, `apply_patch`,
`mcp__<server>__<tool>`, `web_search`); reasoning deltas ->
`reasoning_content`; a failed `turn/completed` or non-retryable `error`
-> `RunErrorEvent`.
- Sessions: the Codex thread id is stored in the Agno session's
`session_data["codex_thread_id"]` when a `db` is configured (survives
restarts and new app-server processes), with an in-memory fallback
otherwise. If a thread cannot be resumed (`ephemeral=True`, rollout
files removed) the adapter starts a fresh thread and prepends the
persisted chat history to the prompt.
- One `AsyncCodex` client per run keeps the client bound to the current
event loop, so the sync `run()` path works unchanged.
- `login_api_key` / `alogin_api_key` helpers for environments where the
`codex` CLI is not on PATH.

**Cookbooks** (`cookbook/frameworks/codex/`, plus
`00_quickstart/codex_agent.py`)
- `codex_basic.py`, `codex_tools.py`, `codex_session.py`,
`codex_mcp_tools.py`, `codex_structured_output.py`, `codex_agentos.py`,
`codex_session_agentos.py`, with `README.md` and `TEST_LOG.md`.
- Quickstart README updated to list Codex.

**Packaging**
- `openai-codex` added to the `demo` extra; `openai_codex.*` added to
the mypy ignore list.

**Docs** are in a separate agno-docs-v2 change: new
`agent-os/multi-framework/codex.mdx`, overview table/card and nav entry.

## Type of change

- [ ] Bug fix
- [x] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [x] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [x] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

**Testing**
- 16 new unit tests in `libs/agno/tests/unit/agents/test_codex.py` run
against a fake `openai_codex` module (option mapping, notification
translation, session/thread persistence, resume fallback, ephemeral
handling). No live calls in CI.
- All cookbooks were run live against openai-codex 0.161.0 (bundled
Codex CLI 0.161.0) with `gpt-5.6-luna`; results are recorded in
`cookbook/frameworks/codex/TEST_LOG.md`. Verified: thread resume across
turns and across separate app-server processes, MCP tool calls rendering
in the Tool Calls panel, structured output parsing as JSON, SSE
streaming and non-streaming runs through AgentOS, and session recall
across requests.
- `ruff` clean; `mypy` reports no errors in the new code (the remaining
errors on `main` are pre-existing in unrelated files).

**Things to know**
- The Codex Python SDK requires Python 3.10+. The adapter imports it
lazily, so Agno on 3.9 is unaffected.
- Authentication: `codex login` (ChatGPT) or `CODEX_API_KEY` /
`OPENAI_API_KEY`. There is no interactive approval prompt in this
context, so `approval_mode="deny_all"` is the safe choice for unattended
servers.
- `ephemeral=True` threads cannot be resumed by Codex, so multi-turn
context falls back to history injection in that mode.
- Same scope as the other external adapters: registration, streaming,
sessions and tool visibility. Background runs, durable queue and
workflow step replay are not wired up for external agents.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-09 00:00:51 +05:30
Sannya Singal b53271584d feat: add HyDE query transform (#10527)
## Summary

This PR adds HyDE retrieval strategy. A new hook, which changes what is
searched for, so it needs somewhere earlier to sit:
Knowledge(vector_db=..., query_transformer=HyDE(),
reranker=CohereReranker())
`QueryTransform` mirrors `Reranker` - one method, sync and async, the
async default running the sync one off the event loop. The transform
changes the query the store receives; the reranker still scores against
the question the caller asked, so relevance is judged on what the user
wanted rather than on a stand in for it.
`HyDE()` uses its own `model` if set, then the model of the agent or
team that triggered the search, then the same OpenAI default the rest of
the framework falls back to.
`get_model_kwarg` offers it only to a callee that declares `model`.
Unlike the owner kwarg, where a dropped `user_id` leaks data across
users and `**kwargs` is worth the risk, this is an enhancement: a legacy
`retrieve(query, **kwargs)` forwarding to a narrower search would raise
on an argument it never asked for, and losing the transform is the
lesser cost.

## Type of change

- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [ ] Code complies with style guidelines
- [ ] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [ ] Self-review completed
- [ ] Documentation updated (comments, docstrings)
- [ ] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [ ] Tested in clean environment
- [ ] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [ ] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [ ] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

Add any important context (deployment instructions, screenshots,
security considerations, etc.)

---------
2026-10-08 19:30:58 +05:30
Jialiang LiangandSannya Singal 718ae26fb9 [fix] Keep Google Calendar slots within working hours (#10878)
## Summary

Fixes #10880.

`find_available_slots` can offer 16:30–17:15 for a 45-minute appointment
when working hours end at 17:00. Comparing only the end hour also admits
appointments that cross midnight. Compare the complete slot end with the
closing time on its start date, preserving appointments that end exactly
at closing time and custom working hours.

## Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [ ] Documentation updated (comments, docstrings)
- [ ] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [x] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing open pull requests and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

Validation in a fresh Python 3.12 environment:

- Before the fix, six new regression cases failed and the 30-minute
control passed.
- After the fix, all 70 tests in
`libs/agno/tests/unit/tools/test_google_calendar.py` passed.
- Changed files pass Ruff lint, Ruff formatting, and `git diff --check`.
- `scripts/format.sh` passed.
- `scripts/validate.sh` passed: all Ruff checks, mypy for 1,091 Agno and
21 agnoctl source files, and the cookbook pattern check.

The tests cover minute overflow, exact closing time, a full working day,
oversized and next-day appointments, and custom closing hours. No Google
credentials or network calls are used. AI tool: Codex.

Co-authored-by: Sannya Singal <[email protected]>
2026-10-08 12:08:58 +05:30
Yash Pratap SolankyandAshpreet 1a07cbcb4f feat: unify browser origin policy across AgentOS surfaces (#10869)
## Summary

Supersedes #10093, rebased onto current main. The original branch still
carried the pre-squash #10090 commit and conflicted with v3.1. This PR
re-applies only the browser-origin-policy commit (authorship preserved)
on top of main.

What the change does:
- Adds `AgentOS(cors=CORSConfig(origins=..., origin_regex=...,
merge_base_app=...))` and opt-in
`PublicSurface(enforce_browser_origins=True)`
- One exact/full-match `OriginPolicy` shared by CORS preflights, public
run/cancel admission, auth error headers, workflow WebSockets and
opted-in MCP aliases
- CORS is now always the outermost middleware, so 401/403/413/429
responses carry consistent headers
- Base-app CORS origins and regex patterns are merged by default;
`CORSConfig(merge_base_app=False)` replaces them

Changes vs #10093:
- The two new loose kwargs (`cors_allowed_origin_regex`,
`cors_merge_base_app_origins`) are grouped into `CORSConfig`, matching
`MCPConfig` / `QueueConfig`. They were never released, so nothing is
removed from the public API
- Not a breaking change: `cors_allowed_origins` keeps its existing
behavior (`[]` or `None` still mean settings defaults).
`CORSConfig(origins=[])` is the opt-in way to allow no exact origins.
Passing both `cors` and `cors_allowed_origins` raises `ValueError`
- Origin patterns are combined with scoped inline flags, so a `(?i)` or
`(?x)` regex (from `CORSConfig` or a `base_app` `CORSMiddleware`) no
longer crashes `get_app()`. `origin_regex` is validated when
`CORSConfig` is constructed
- `get_app()` no longer rewrites `AgentOS.cors_allowed_origins` with
merged base-app origins

Conflict resolution:
- `AuthMiddleware._is_origin_allowed` keeps main's fail-closed behavior
(no allow-list reflects nothing). The shared policy is checked first;
the original PR's `None -> allow all` branch is dropped since it would
have reverted that fix
- Kept main's `test_reflects_nothing_when_empty_origins_list`; the PR's
rename is redundant now
- Merged the new `cors_*` params into the v3.1 `AgentOS` docstring
- README, TEST_LOG and `test_mcp_routing.py`: kept both sides

## Type of change

- [x] Bug fix
- [x] New feature
- [ ] Breaking change
- [x] Improvement

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [x] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [ ] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [x] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

- Replaces #10093 to avoid force-pushing over the original branch.
- `ruff format --check` and `ruff check` pass on touched files. mypy
errors locally are only in unrelated MCP/knowledge modules (local `mcp`
package version).
- PR tests: 202 passed (`test_origin_policy`,
`test_jwt_middleware_helpers`, `test_mcp_routing`,
`test_public_authorization`). The new legacy-`[]`, no-mutation and
inline-flag tests fail against the previous commit. Full
`tests/unit/os`: the 53 failures are the same set that fails on main in
the same env (`croniter` missing, `mcp` version).
- Behavior notes for existing apps: CORS is now always the outermost
middleware (it only adds CORS headers to auth errors for already-allowed
origins), and a `base_app` `CORSMiddleware(allow_origin_regex=...)` is
now honored instead of silently dropped.

---------

Co-authored-by: Ashpreet <[email protected]>
2026-10-07 17:00:26 -04:00
kafomichevandKaustubh 6f01068cc0 [feat] Add Heabsy as an OpenAI-compatible model provider (#10828)
## Summary

Adds [Heabsy](https://heabsy.com/platform), an OpenAI-compatible
inference API for open models, as a model provider. It follows the
"Adding a new Model Provider" section of CONTRIBUTING.md and the layout
of #10326 (Y-API) and #9788 (Synthorai):

- `libs/agno/agno/models/heabsy/heabsy.py`: `Heabsy(OpenAILike)` with
`id="qwen38"`, `name="Heabsy"`, `provider="Heabsy"`,
`base_url="https://api.heabsy.com/v1"` and a `HEABSY_API_KEY` fallback
in `_get_client_params()` that raises `ModelAuthenticationError` when no
key is set.
- `libs/agno/agno/models/utils.py`: one row in `_PROVIDERS`, `"heabsy":
("agno.models.heabsy", "Heabsy", "Heabsy", "heabsy")`, between `groq`
and `huggingface`.
- `libs/agno/tests/unit/models/test_heabsy.py`: defaults, env-var key,
missing-key error, client params, `"heabsy:qwen38"` string resolution
and the `to_dict()` / `get_model_from_dict()` round trip.
- `cookbook/90_models/heabsy/`: `README.md` (key setup, model notes,
links), `basic.py`, `tool_use.py`, `structured_output.py`,
`TEST_LOG.md`, with both the class and the string syntax.

The API serves `/v1/models` and `/v1/chat/completions` with streaming.
There is no `/v1/embeddings` endpoint, so no embedder is added. Keys are
issued in the Heabsy console (https://platform.heabsy.com); accounts are
opened on request at https://heabsy.com/contacts.

## Type of change

- [ ] Bug fix
- [x] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [x] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [x] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

Checked in a fresh venv (Python 3.13, `pip install -e libs/agnoctl -e
"libs/agno[dev,models]"`, ruff 0.15.20, mypy 2.1.0):

| Check | Result |
| --- | --- |
| `pytest libs/agno/tests/unit/models/test_heabsy.py` | 7 passed |
| `pytest libs/agno/tests/unit/models
libs/agno/tests/unit/utils/test_model_string.py` | 1520 passed, 3
skipped (1511 passed before; the new ones are `test_heabsy.py` and the
two `heabsy` cases in `test_provider_resolution.py`;
`test_every_model_subclass_is_registered` passes) |
| `./scripts/format.sh` | no changes to the files in this PR (it
re-sorts imports in 6 unrelated files on `main`, left out) |
| `./scripts/validate.sh` | ruff clean for agno, agnoctl and cookbook;
mypy reports the same 6 errors as on `main`, none in `models/heabsy` |
| `python3 cookbook/scripts/check_cookbook_pattern.py --base-dir
cookbook/90_models/heabsy --recursive` | 3 files, 0 violations |

The cookbook examples were imported but not run against the live API
from this environment, so `TEST_LOG.md` marks them SKIP.

If you want to run the cookbook examples yourself, I can send a test API
key for review; just say where to send it.

I work on Heabsy.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Kaustubh <[email protected]>
2026-10-08 01:18:31 +05:30
e1f4771529 fix: honor zero max_results in BaiduSearch, Searxng and Spider toolkits (#10877)
## Summary

`BaiduSearchTools`, `Searxng` and `SpiderTools` resolve their result
limits with falsy `or` fallbacks, so a configured value of **0** is
silently discarded:

- `BaiduSearchTools(fixed_max_results=0)` falls back to the per-call
`max_results` argument (`agno/tools/baidusearch.py`).
- `Searxng(fixed_max_results=0)` does the same when truncating the
backend payload (`agno/tools/searxng.py`).
- `SpiderTools(max_results=0)` falls back to the hardcoded default `5`
(`agno/tools/spider.py` — the explicit call argument path was already
fixed in #6401; only the constructor path was left).

Reproduction (offline, no keys needed):

```python
from agno.tools.spider import SpiderTools

tools = SpiderTools(max_results=0)
# tools.search_web("q") sends {"num": 5} to the client; expected {"num": 0}
```

The fix resolves the toolkit value with an explicit `is None` check, so
`0` means "return no results" while `None` keeps the existing fallbacks.
This matches the behavior already being added for
`WebSearchTools`/`DuckDuckGoTools` in #10806 and extends it to the
remaining toolkits that share the same pattern.

Regression tests (all offline, mocked clients) cover: zero and non-zero
`fixed_max_results`, explicit zero call arguments, the untouched
defaults (`5`), and the BaiduSearch output format. The new
`tests/unit/tools/test_baidusearch.py` follows the `pytest.importorskip`
pattern used by `test_spider.py`.

## Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`ruff check` and `ruff format
--check` with the repo-pinned ruff 0.15.20)
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [ ] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [x] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue (#10806 covers
WebSearchTools/DuckDuckGoTools only; #6401 fixed Spider's
explicit-argument path only)
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

## Additional Notes

Local validation: red-green confirmed — the three zero-value regression
tests fail on the unpatched code and pass with the fix; the full
`tests/unit/tools` suites for the three toolkits (33 tests) pass, plus
`ruff check` and `ruff format --check` clean.

Co-authored-by: Weijian He <[email protected]>
Co-authored-by: Kaustubh <[email protected]>
2026-10-08 00:51:06 +05:30
3713888fcc [fix] Unwrap Annotated types in tool JSON schema generation (#10858)
## Summary

Fixes #10857

`get_json_schema_for_arg` had no branch for `typing.Annotated`, so
Annotated hints (including dataclass fields that keep Annotated
metadata) fell through to
`{"type":"object","properties":{},"additionalProperties":false}`.

This PR unwraps `Annotated[T, ...]` to `T` before schema generation.

## Type of change

- [x] Bug fix

## Checklist

- [x] I have read and understood the contribution guidelines
- [x] I have added tests that fail without this fix
- [x] PR title follows `[fix] ...` format

## Test plan

- [x] `pytest libs/agno/tests/unit/utils/test_json_schema.py` — 22
passed
- [x] Manual repro: `@tool` with dataclass `Annotated[int, Field(ge=0)]`
fields now emit `{"type":"integer"}`

Co-authored-by: michaelkillgta <[email protected]>
Co-authored-by: Kaustubh <[email protected]>
2026-10-08 00:23:45 +05:30
b2f642a468 chore: auto-close external PRs that skip triage or the PR template (#10866)
## Summary

Tightens the contribution policy for external PRs, modeled on [MLflow's
`auto-close-pr`
workflow](https://github.com/mlflow/mlflow/blob/master/.github/workflows/auto-close-pr.js).
External PRs now need a linked issue that a maintainer has triaged with
a `ready` label, and must keep the PR template. This cuts down on
unsolicited and low-effort AI-generated PRs: work only starts once a
maintainer has agreed the issue is worth doing.

Everything lives in the existing `PR Triage` workflow
(`.github/workflows/pr-triage.yml`).

**New auto-close rules (external contributors only)**

| Rule | Label | Closes when |
|---|---|---|
| No linked issue | `missing-issue-link` | The PR doesn't close any
issue. Previously label-only; now closes regardless of PR size |
| Issue not triaged | `issue-not-ready` | Any linked issue lacks the
`ready` label |
| PR template not followed | `template-not-followed` | More than half of
the template's headings are missing from the body. With the current 5
headings, 3 or more missing closes the PR |
| Duplicate / assigned (existing) | `possible-duplicate`,
`issue-assigned-to-other` | Unchanged, except duplicates now only count
*earlier* open PRs |

The close comment lists every finding, names the reasons, and tells the
contributor to address them and reopen. It also asks them not to delete
or force-push the branch, because a force-pushed branch can't be
reopened.

**Grandfathering**

A `CUTOFF_DATE` constant, set to 7 October 2026
(`2026-10-07T00:00:00Z`), keeps the new rules away from in-flight work:
- The `ready` check only applies to issues created on or after the
cutoff.
- The missing-issue and template closes only apply to PRs created on or
after the cutoff. Older PRs still get the label but stay open.

**Reopen flow**
- The workflow now runs on `reopened` as well as `opened`. When a
maintainer later labels the issue `ready`, the contributor reopens the
PR and it passes. No extra comment is posted when a reopened PR passes.
- If a maintainer reopens a PR, all checks are skipped. A sender with
`admin`, `maintain` or `write` permission can always override an
auto-close.

**Exemptions**

All checks are skipped for:
- bots, e.g. Dependabot, which would otherwise be closed for having no
issue
- authors with `OWNER`, `MEMBER` or `COLLABORATOR` association
- authors with write access, which covers org members with private
membership

**Other changes in `pr-triage.yml`**
- Linked issues come from GraphQL `closingIssuesReferences` instead of
regex. It covers `Fixes/Closes/Resolves #N`, full issue URLs and
sidebar-linked issues. A bare `#123` or `see #5` no longer counts as a
link.
- The assignee check reuses the same GraphQL result, which removes one
REST call per issue.
- Template headings are read from the base branch's
`.github/pull_request_template.md`, so the check stays in sync when the
template changes.
- If the linked-issue lookup or the template fetch fails, that check is
skipped, so a transient API error never closes a PR.

**Docs**
- `.github/pull_request_template.md`: the issue line now says external
contributors must use `Fixes #<issue_number>` and that the issue needs
the `ready` label.
- `CONTRIBUTING.md`: "Link to Issue" became "Link to a Ready Issue",
which explains the triage flow and the reopen steps. Added a "Use the PR
Template" rule.

## Type of change

- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [x] Improvement
- [ ] Model update
- [x] Other: CI / contribution policy

---

## Checklist

- [ ] Code complies with style guidelines
- [ ] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [ ] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [ ] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [ ] Tested in clean environment
- [ ] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

**Before merging**
1. Create the label: `gh label create ready -R agno-agi/agno -c 0E8A16
-d "Triaged by a maintainer; open for PRs"`. The other labels
(`issue-not-ready`, `template-not-followed`) are created automatically
the first time they're applied.
2. Make sure maintainers know that outside issues now need a `ready`
label before contributors can open PRs for them. If triage falls behind,
good contributions will stall.

**Testing**

`pull_request_target` runs the workflow from the base branch, so this PR
can't test itself. I extracted the script and ran it against mocked
GitHub API calls in 18 scenarios, and all of them behaved as expected:
- External PR with no issue: closed. A pre-cutoff PR reopened with no
issue: label only.
- New issue without `ready`: closed. With `ready`: passes. Pre-cutoff
issue without the label: passes.
- `ready` issue assigned to someone else, or claimed by an earlier open
PR: closed. Claimed by a *later* PR: passes.
- Author reopens while the issue is still not ready: closed again. After
the issue is marked `ready`: passes with no comment.
- Maintainer reopens: all checks skipped.
- Template stripped, or 3 of 5 headings missing: closed. 2 of 5 missing:
passes. Pre-cutoff PR: label only.
- Org member or bot with no issue and no template: passes.

An end-to-end run on a fork before or after merge is still worthwhile.
The format and validate scripts don't apply because no Python files
changed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Harsh <[email protected]>
Co-authored-by: Yash Pratap Solanky <[email protected]>
2026-10-08 00:02:10 +05:30
Max AzatianandKaustubh ea21593946 [fix] Preserve cache-write tokens in the LiteLLM usage parser (#10476)
## Summary

`LiteLLM._get_metrics` never read the prompt cache write from LiteLLM's
usage, so `MessageMetrics.cache_write_tokens` was always 0 for Claude
(and any other cache-writing provider) behind LiteLLM. LiteLLM reports
it as `prompt_tokens_details.cache_write_tokens`, its canonical name,
mirrored to `cache_creation_tokens`. The parser now reads the first and
falls back to the second, on both the dict and the object path. Absent
or null fields keep producing 0.

Same defect as #10314, whose fix #10313 covers the two OpenAI parsers
and not this file. The net/gross `input_tokens` question from #9538 is
left alone here.

Fixes #10475.

## Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [ ] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [x] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [x] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

#10313 is the sibling fix for `openai/chat.py` and
`openai/responses.py`; this PR only touches `litellm/chat.py`, so the
two do not overlap. Tests cover both usage shapes plus the real
`litellm.types.utils.Usage` type; `mypy` reports the same six
pre-existing errors on the file before and after. Written with Claude
Code, reviewed and run locally by me.

Co-authored-by: Kaustubh <[email protected]>
2026-10-07 21:52:34 +05:30
Bas NijholtandKaustubh 326d7e29a6 [fix] Preserve OpenAI cache-write usage metrics (#10313)
## Summary

OpenAI reports `cache_write_tokens`, but both usage parsers leave
`MessageMetrics.cache_write_tokens` at zero. Preserve the counter in
Chat Completions and Responses, defaulting to zero when absent or null
for older SDKs and compatible providers.

Two production lines, plus eight parameterized regression cases in the
existing metrics tests. No dependency or API changes.

Fixes #10314.

## Type of change

- [x] Bug fix

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [x] Tests added/updated
- [x] Tested in clean environment
- Documentation and cookbook changes are not applicable to this internal
metrics correction.

### Duplicate and AI-Generated PR Check

- [x] Searched existing open pull requests; no duplicate found
- [x] This PR was entirely AI-generated with Codex

## Validation

- Before the fix: both nonzero-write cases fail (`0 != 3000`).
- After the fix: all 108 OpenAI and Azure Responses unit tests pass with
OpenAI SDK 3.16.1.
- All 13 metrics tests also pass with the minimum supported OpenAI SDK
1.106.0.
- Both repository format/validation scripts pass.
- Full unit-suite collection is blocked by missing optional provider,
tool, and database dependencies in the development environment (124
collection errors); no full-suite pass is claimed.

Co-authored-by: Kaustubh <[email protected]>
2026-10-07 21:49:44 +05:30
932c89fa9c [feat] Add FlexAI as an OpenAI-compatible model provider (#10871)
Closes #10872

## Summary

Adds **FlexAI** (`https://api.flex.ai/v1`) as an OpenAI-compatible model
provider, following the "Adding a new Model Provider" section of
`CONTRIBUTING.md` and the same layout as the Y-API provider (#10326):

- `libs/agno/agno/models/flexai/flexai.py`: `FlexAI(OpenAILike)`, with
`id` / `name` / `provider` / `api_key` / `base_url` attributes and a
`FLEXAI_API_KEY` fallback in `_get_client_params()`. The default id is
`DeepSeek-V4-Flash-0731` (1M context, supports tool calls).
- `libs/agno/agno/models/utils.py`: one row in the `_PROVIDERS` table
(`"flexai": ("agno.models.flexai", "FlexAI", "FlexAI", "flexai")`),
placed alphabetically after `fireworks`.
- `libs/agno/tests/unit/models/flexai/test_flexai.py`: unit tests for
defaults, env-var auth, missing-key error, client params,
`"flexai:<model>"` string resolution, and the `to_dict()` /
`get_model_from_dict()` round trip.
- `cookbook/90_models/flexai/`: `README.md`, `basic.py`, `tool_use.py`,
`TEST_LOG.md`, showing both the class and the string syntax.

FlexAI serves open-weight models (DeepSeek, Qwen, GLM, Gemma, gpt-oss,
Llama, MiniMax) behind an OpenAI-compatible API (`/chat/completions`,
`/completions`, `/embeddings`, `/models`). Docs:
https://docs.flex.ai/inference-api/quickstart. The cookbook README lists
the models with verified tool-calling and image support, and points to
`GET /v1/models` for the live catalogue rather than hardcoding it.

(If applicable, issue number: none; this adds a provider rather than
fixing reported behaviour.)

## Type of change

- [ ] Bug fix
- [x] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [x] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [x] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

**AI disclosure:** this PR was drafted with Claude Code and reviewed
before submission.

**Verification** (fresh `.venv` from `./scripts/dev_setup.sh`; ruff
0.15.20, mypy 2.1.0):

| Check | Result |
| --- | --- |
| `pytest tests/unit/models/flexai
tests/unit/models/test_provider_resolution.py
tests/unit/utils/test_model_string.py -q` | 151 passed, 45 skipped
(includes `test_every_model_subclass_is_registered`) |
| `ruff format --check` / `ruff check` on the touched paths | clean |
| `python cookbook/scripts/check_cookbook_pattern.py --base-dir
cookbook/90_models/flexai --recursive` | 2 files, 0 violations |
| `./scripts/validate.sh` | ruff check clean for agno, agnoctl and
cookbook. mypy reports 6 errors in files this PR does not touch
(`os/public/_limits.py`, `db/sql/authz.py`,
`db/{sqlite,postgres,mysql}/async_*.py`); none in `models/flexai` or
`models/utils.py` |
| `./scripts/format.sh` | no changes to the files in this PR (it also
reformats 8 pre-existing files on `main`; those changes are not included
here) |

**Live test** against `https://api.flex.ai/v1` with a real
`FLEXAI_API_KEY`:

```
1) Agent.run with tool: DeepSeek-V4-Flash-0731 FlexAI | tool calls: ['Paris'] | The temperature in Paris is currently **21 degrees Celsius**.
2) string syntax: FlexAI https://api.flex.ai/v1 | ok
3) async stream: ok
```

(1) is `Agent(model=FlexAI(), tools=[get_temperature]).run(...)`, where
the model called the local tool and used its result. (2) is
`Agent(model="flexai:DeepSeek-V4-Flash-0731")`. (3) is `arun(...,
stream=True)`. A `response_format` `json_schema` request with `strict:
true` also returned valid JSON, so the inherited
`supports_native_structured_outputs` default is left as is.

**API notes** (also in the cookbook README): the chat endpoint rejects
`n` greater than 1 and `verbosity`, and neither is sent by default.
Reasoning models return `reasoning_content`, which `OpenAIChat` already
parses.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01KhTQnAVFEyMD4SDoAdpJ6h

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Kaustubh <[email protected]>
2026-10-07 20:48:28 +05:30
fe6d6d7ddd fix: keep configurable follow-ups within response boundaries (#10087)
## Summary

Follow-up suggestions can re-offer a request the main answer declined.
This PR makes the default suggestions respect the answer's boundaries
and makes follow-ups configurable through one argument.

- **Boundary-aware default prompt.** The follow-up prompt asks the model
to stay within what the answer covered, not to re-offer a declined
request, not to invent missing information, and to treat the
conversation as data. It applies to every Agent and Team with follow-ups
enabled. This is prompt guidance, not enforcement: structure and count
are validated, semantics are not.
- **`followups: bool | FollowupConfig`.** `False` disables, `True`
enables the defaults, and a `FollowupConfig` enables and configures:
count range, separate model, custom instructions. The instructions go
only to the follow-up call, never the main model call.
- **Count on the config.** Either `num_followups` (exactly N, the same
meaning as the released top-level argument) or a
`min_followups`/`max_followups` range. `max_followups` alone lets the
model return fewer or none when a declined request leaves nothing useful
in scope. The maximum is enforced by clipping; the minimum is requested
in the prompt.
- **Not a breaking change.** Existing code keeps getting exactly N
suggestions; only the prompt wording changes.
- **Stale suggestions are cleared** before every regeneration, including
when generation is skipped. `None` means follow-ups were absent, failed,
malformed or cancelled; a follow-up failure never replaces the main
answer.

This refreshes and extends #7248 by @fenil210-cactus, retaining the
contributor's commits, on an Agno-owned branch. It is the proposed
replacement for #7248.

## API

```python
from agno.agent import Agent, FollowupConfig

Agent(followups=False)                    # disabled
Agent(followups=True)                     # exactly 3, main model
Agent(followups=True, num_followups=5)    # exactly 5, as today
Agent(
    followups=FollowupConfig(
        max_followups=3,                  # up to 3, possibly none
        model="openai:gpt-5.5",
        instructions="Suggest only documentation questions.",
    )
)
```

`Team` accepts the same forms.

| You write | You get |
|---|---|
| `followups=True` | exactly 3 |
| `followups=True, num_followups=5` | exactly 5 |
| `FollowupConfig()` | exactly 3, the same as `True` |
| `FollowupConfig(num_followups=5)` | exactly 5 |
| `FollowupConfig(max_followups=3)` | up to 3, possibly none |
| `FollowupConfig(min_followups=1, max_followups=3)` | 1 to 3 |
| `FollowupConfig(min_followups=1)` | 1 to 3 (the maximum defaults to 3)
|
| `FollowupConfig(num_followups=5, max_followups=3)` | `ValueError`:
`num_followups` or `min_followups`/`max_followups`, not both |

- The prompt's count line follows the range: "exactly N", "at most N" or
"between min and max". The "return fewer, including an empty list"
sentence is added only when the minimum is 0.
- The maximum is enforced by clipping. The minimum is only requested; if
the model returns fewer, they are passed through as-is.
- `FollowupConfig` validates its count when built: `num_followups >= 1`,
`max_followups >= 1`, `0 <= min_followups <= max_followups`, and
`num_followups` is not combined with a range.
- **Schema-level enforcement was considered and left out.** OpenAI
strict JSON schema does enforce `minItems`/`maxItems`, but in a live
check with schema and prompt in agreement it changed nothing: the prompt
alone hit the requested counts in 72 of 72 calls. Support for these
keywords varies by provider, and when the schema and the prompt
disagreed, calls timed out or failed. The maximum is already guaranteed
for every provider by clipping.

- `agent.followups` reads back as what was passed: the bool, or the
config. A string model on the config is resolved at construction on a
copy, so a config shared between components is never mutated.
- `agent.num_followups` is the effective maximum.
- **Not a breaking change.** The top-level arguments keep working
exactly as before: `followups=True` with `num_followups=` and
`followup_model=`.
- **One form or the other.** Passing a `FollowupConfig` together with
`num_followups` or `followup_model` raises `ValueError`, even when the
values agree. There are no precedence rules between the two forms. With
a config, unset values use the defaults: 3 suggestions and the component
model.
- `deep_copy()` keeps the config. `deep_copy(update={"followups":
FollowupConfig(...)})` replaces it; `deep_copy(update={"num_followups":
n})` on a component with a config raises, like the constructor.
- `to_dict` / `from_dict` store whichever form was used: `followups` as
`True`, `False` or the config's fields, plus top-level `num_followups`
and `followup_model` only when there is no config. Models are stored by
identity only (`id`, `name`, `provider`), never with request headers or
credentials; register the live model in a `Registry` to keep custom
endpoints.

## Implementation

| Area | Change |
|---|---|
| `agno/agent/followup.py` | `FollowupConfig` with `num_followups` or
`min_followups`/`max_followups`, and `count_range()`;
`resolve_followup_settings` (rejects a config combined with the
top-level arguments, returns the effective count);
`prepare_followup_call` (clears stale suggestions, picks model, count
and instructions, resolves a string model assigned after construction);
`drop_derived_followup_fields` for `deep_copy` |
| `agno/agent/_response.py` | Boundary-aware prompt, count line per
range, clipping to the maximum; `_run_followups_model` /
`_arun_followups_model` shared by all eight Agent and Team generate
functions |
| `agno/team/_response.py` | The four Team generate functions call the
shared helpers |
| `agent.py`, `team.py`, `team/_init.py`, `*/_utils.py`, `*/_storage.py`
| Union-typed `followups`, `num_followups: Optional[int] = None`, the
one-form check at construction, `deep_copy` handling, storage round trip
|
| `agno/environments/runner.py` | Rollout isolation gives the config's
model a cache-off copy per attempt |
| Cookbook | `02_agents/14_advanced/followup_instructions.py`, README
section, TEST_LOG entry |

## Validation

On the latest commit (`6f526f350d`), Python 3.12:

- `tests/unit/{agent,team,environments,run,utils}`: 2869 passed, 3
skipped, 1 failed. The failure,
`tests/unit/team/test_team_skills.py::test_system_message_contains_skills_snippet`,
fails identically on `main`.
- New tests fail on the previous head and pass now. They cover each
count mode through the public paths for Agent and Team (sync, async,
streaming): prompt wording, clipping, and when the "return fewer"
sentence appears. They also cover config validation, fewer-than-minimum
results, and a round trip of the range. Earlier tests still cover the
one-form rule, a string config model assigned after construction, and
stale suggestions.
- `./scripts/format.sh` clean. `./scripts/validate.sh`: ruff passes;
mypy reports errors only in files this PR does not touch (optional SDK
stubs such as `utils/models/_genai_compat.py`).
- Live prompt check: four refusal scenarios plus a normal answer, 5 runs
each on `gpt-5.6-luna` and `gpt-5.5`, counting suggestions that point
back at the declined topic.

  | Prompt | luna | gpt-5.5 |
  |---|---|---|
  | previous prompt, exactly 3 | 51/60 | 26/60 |
  | this PR, exactly 3 (default) | 7/60 | 2/60 |
  | this PR, up to 3 (`max_followups=3`) | 2/59 | 4/60 |
  | this PR, 1 to 3 | 6/59 | 5/60 |

With this PR, none re-offered the declined request itself. The remaining
hits point elsewhere ("Provide a reliable weather source for Paris") or
ask about availability ("Ask whether a Rust SDK is planned").
Differences between the three modes are within noise at this sample
size. The normal answer got 3 suggestions in every run.
- Cookbook (`max_followups=3`): three live runs with `gpt-5.5`; the
agent declined the poem each time and every follow-up stayed within
Python documentation.

## Remaining considerations

- Team continuation does not regenerate follow-ups today (inherited,
unchanged here). A separate PR should cover it across
sync/async/streaming and success/failure/cancel, and weigh the extra
model call.
- #9507 proposes a differently shaped `FollowupConfig`. It needs a
decision before this merges so we don't ship two shapes.
- Semantic boundaries are best-effort; an out-of-scope suggestion can
still be returned. Public docs are deferred; this PR updates the
cookbook only.

## Type of change

- [x] Bug fix
- [x] New feature
- [ ] Breaking change
- [x] Improvement
- [ ] Model update
- [ ] Other:

The follow-up prompt wording changes for every component with follow-ups
enabled; suggestion counts do not change unless a `FollowupConfig` asks
for a range.

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`) - format clean; validate fails only on
pre-existing mypy errors in untouched files (see Validation)
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [x] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [ ] Tested in clean environment (left to hosted CI)
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [ ] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue (related: #7248, #9507)
- [x] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

Related PRs: #7248 (the contributor's original; this PR retains those
commits and replaces it) and #9507 (competing config shape; see
Remaining considerations).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Yash Pratap Solanky <[email protected]>
Co-authored-by: ysolanky <[email protected]>
2026-10-07 07:54:13 -04:00
e7f7bd3415 [fix] Include response schema in model cache keys (#10621)
## Summary

`_get_model_cache_key` currently serializes a Pydantic response-format
class using its class name. Two different `Output` classes with
different fields therefore generate the same key for identical messages
and model settings. With `cache_response=True`, the second call can
reuse a response generated for the wrong output schema.

Serialize Pydantic response-format classes using `model_json_schema()`
instead. This includes nested field definitions in the cache key while
leaving other class serialization unchanged. This follows up on #7155,
which made class inputs serializable but used the class name as the
cache identity.

## Type of change

- [x] Bug fix

## Checklist

- [x] Code complies with style guidelines
- [x] Self-review completed
- [x] Documentation updated (regression test docstrings; no user-facing
docs change)
- [x] Tests added/updated

### Duplicate and AI-Generated PR Check

- [x] I have searched existing open pull requests and confirmed that no
other PR already addresses this issue
- [x] This PR was generated with AI assistance and reviewed with focused
tests.

## Additional Notes

The new same-name schema tests cover streaming and non-streaming keys;
equivalent schemas keep the same key. A mocked HTTP regression verifies
that sync and async requests do not replay another output schema's
cached response. `pytest -q
libs/agno/tests/unit/models/test_cache_key_serialization.py`: 10 passed.
`ruff check`, `ruff format --check`, and `git diff --check` pass. The
broader model suite was not run locally because optional provider
packages are missing; no provider call is needed for these focused
tests.

---------

Co-authored-by: Sannya Singal <[email protected]>
Co-authored-by: sannya-singal <[email protected]>
2026-10-07 16:41:24 +05:30
Ayush Jha f1911c12c0 cookbook: add knowledge filesystem example and sync progress logs (#10868)
## Summary

Add an AgentOS cookbook that syncs Agno documentation into
PostgreSQL-backed knowledge pages, indexes them with PgVector and OpenAI
embeddings, and exposes the `product-docs` namespace through the agent's
filesystem. The agent can search the synced documentation, and the
server starts after synchronization succeeds.

Add built-in page synchronization logs for lock waiting, discovery,
progress every 25 processed pages, and final counts. Long syncs now show
progress without a custom CLI or progress callback in the cookbook.

## Type of change

- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [x] Improvement
- [ ] Model update
- [x] Other: Cookbook example

---

## Checklist

- [ ] Code complies with style guidelines
- [ ] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [x] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [ ] Tested in clean environment
- [ ] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

The cookbook uses PostgreSQL at `localhost:5532` and requires
`OPENAI_API_KEY` to sync documentation. A partial sync prints its report
and stops before starting the server. Embedding retry behavior is
unchanged.

No tests or cookbook execution were run for this PR. Formatting ran on
an isolated copy; its incidental import-order change was reverted.
Validation was stopped at the user's request, and no further checks were
run.
2026-10-07 16:34:20 +05:30
Yash Raj PandeyandSannya Singal 97d3f588d2 fix: match Mongo text filters as literal text (#10709)
## Summary

`MongoDb.get_sessions(session_name=...)` and
`MongoDb.get_user_memories(search_content=...)` put the raw user string
into `$regex`. `AsyncMongoDb` does the same, and
`MongoVectorDb.keyword_search` does it with the search query. A value
with an unbalanced `(` is an invalid pattern, so `GET /sessions` and
`GET /memories` return HTTP 500. `.` matches any character, so a search
for `v1.2` also returns `v1x2`.

This PR passes the value through `re.escape()` at the 5 sites. The
`$options: "i"` flag stays, so the match stays case-insensitive.

(If applicable, issue number: #10708)

I ran the AgentOS routes through `TestClient` on a `MongoDb` backed by
mongomock. The rows are `Release v1.2 (draft)` and `Release v1x2 final`
for sessions, and `likes v1.2 (draft) builds` and `likes v1x2 builds`
for memories.

| request | `main` | this branch |
|---|---|---|
| `GET /sessions?type=agent&session_name=v1.2` | 200, both rows | 200,
`Release v1.2 (draft)` only |
| `GET /memories?search_content=v1.2` | 200, both rows | 200, `likes
v1.2 (draft) builds` only |
| `GET /sessions?type=agent&session_name=(draft` | 500 | 200, `Release
v1.2 (draft)` |
| `GET /memories?search_content=(draft` | 500 | 200, `likes v1.2 (draft)
builds` |

## Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [ ] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [ ] Documentation updated (comments, docstrings)
- [ ] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [x] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

3 new tests call `get_sessions`, `get_user_memories` and
`keyword_search` with `v1.2(draft` and assert the literal query
`{"$regex": "v1\\.2\\(draft", "$options": "i"}`. One test is for
`MongoDb`, one for `AsyncMongoDb` and one for `MongoVectorDb`. With the
fix reverted, all 3 fail. With the fix, `test_mongo.py`,
`test_async_mongo.py` and `vectordb/test_mongodb.py` give 53 passed and
1 skipped.

`./scripts/format.sh` reformats the whole repo, so I ran `ruff format
--check` and `ruff check` on the 6 changed files. Both pass. mypy
reports the same 6 errors on `main` and on this branch, all in untouched
files. Versions: ruff 0.15.20, mypy 2.1.0, pymongo 4.18.2, mongomock
4.3.0, Python 3.12.14.

Open PR #10373 also edits `mongo.py` and `async_mongo.py`, in
`upsert_sessions` only. The two diffs do not overlap.


Co-authored-by: Sannya Singal <[email protected]>
2026-10-07 14:27:00 +05:30
Udit Goenkaanduditgoenka a44cd8f123 [feat] Add 60db workspace voice tools (#10786)
## Summary

60db provides a hosted speech API with workspace-scoped voices. This
adds `SixtyDBTools` for discovering those voices and generating speech
as Agno WAV audio artifacts.

The toolkit uses the existing HTTP dependency, supports a configured
voice with per-call overrides, and reads one JSON response with
`audio_base64` at the top level or directly under `backendResponse`. It
decodes the audio once and validates and strips a WAV header when
present. The cookbook discovers a workspace voice when none is
configured, saves audio under `tmp/`, and prints the result. Failures
log concise diagnostics without credentials or response bodies.

Closes #10787

## Type of change

- [ ] Bug fix
- [x] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [x] Examples and guides: Relevant cookbook examples have been included
or updated
- [x] Tested in clean environment
- [x] Tests added/updated

### Duplicate and AI-Generated PR Check

- [x] I have searched existing open pull requests and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

## Additional Notes

Validation:

- 157 tests pass: 62 provider/cookbook checks plus existing toolkit and
shared audio regressions. Provider checks cover both JSON locations,
PCM/WAV output, malformed responses, unsupported response shapes,
timeouts, response limits and redirects.
- All 62 provider/cookbook tests pass against the rebuilt wheel
installed in a separate environment.
- `./scripts/format.sh` and `./scripts/validate.sh` pass, including
repository lint, typing and cookbook pattern checks. Unrelated baseline
import-format changes were excluded from this PR; scoped formatting
checks pass.
- Wheel and source distribution builds pass.

Run `pytest libs/agno/tests/unit/tools/test_sixtydb.py` for provider
checks using local HTTP fixtures without service credentials.

The current JSON decoder and cookbook setup/output paths were verified
locally. Live synthesis and the cookbook's LLM-driven conversation have
not been rerun for this revision.

---------

Co-authored-by: uditgoenka <[email protected]>
2026-10-06 14:27:03 -04:00
Ayush JhaandKaustubh f529ba0ff4 fix: recognize GPT reasoning models in OpenAI and OpenRouter Responses (#10830)
## Summary

GPT-6 models were missing from OpenAI reasoning-model detection, and
GPT-5/6 models were missing from OpenRouter detection. This could bypass
reasoning-state handling and cause tool-call follow-ups to fail with
missing reasoning-item errors.

- Recognize GPT-6 in `OpenAIResponses` and GPT-5/6 in
`OpenRouterResponses`, enabling existing chaining and encrypted
reasoning replay.
- Extend regression coverage for OpenAI sync/async streaming and
non-streaming paths, plus OpenRouter stateless tool follow-ups.
- Update the OpenRouter tool-use cookbook to demonstrate GPT reasoning.

## Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Improvement
- [x] Model update
- [ ] Other:

---

## Checklist

- [ ] Code complies with style guidelines
- [ ] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [x] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [ ] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [ ] I have searched existing open pull requests and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

Tests, cookbook execution, formatting, and validation remain pending,
deferred per user instruction.

## Summary

Describe key changes, mention related issues or motivation for the
changes.

(If applicable, issue number: #\_\_\_\_)

## Type of change

- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [ ] Code complies with style guidelines
- [ ] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [ ] Self-review completed
- [ ] Documentation updated (comments, docstrings)
- [ ] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [ ] Tested in clean environment
- [ ] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [ ] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [ ] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

Add any important context (deployment instructions, screenshots,
security considerations, etc.)

Co-authored-by: Kaustubh <[email protected]>
2026-10-06 18:57:36 +05:30
2ddddfb527 [fix] Preserve saved JsonDb tables when writes fail (#10794)
## Summary

Fixes #10793.

JsonDb overwrites a live table before JSON serialization and the
filesystem write finish. A failed save can therefore destroy previously
persisted sessions rather than leaving the last successful table
readable.

I reproduced this through public `JsonDb.upsert_session` with valid
session data on macOS/Python 3.12 using an isolated subprocess and
`RLIMIT_FSIZE`. The second save raises native `EFBIG`: the previous
347-byte valid table becomes a 411-byte partial JSON file. With this
change the same error leaves all 347 prior bytes intact, and
`get_session` still returns the previously saved session.

Regular tables are serialized into a unique sibling file and published
with `os.replace` only after the file closes successfully. Failed
serialization/publication removes only the staging file this writer
created. Existing write permissions, mode bits, file/directory symlinks
and direct writes to nonregular paths are preserved.

## Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [ ] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [x] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

This patch and its regression tests were prepared with AI assistance.
The actual filesystem failure was reproduced before the change, the
resulting patch was reviewed, and the verification results below come
from executed tests.

### Verification

- Before the fix, three focused failure-preservation tests fail; seven
success/compatibility controls pass.
- After the fix, 132 tests pass and 35 are skipped across the changed
writer tests and adjacent JSON session migration/run/eval tests. Tests
cover native file-size failure, partial serialization failure, rename
failure, private mode preservation, write denial, write-only files,
table/directory symlinks, an owned FIFO, UTF-8 and staging filename
collision ownership.
- Root `./scripts/format.sh` and `./scripts/validate.sh` pass using the
declared-compatible SQLAlchemy 2.0.48 development environment. The
format script also touches seven unrelated pre-existing style
discrepancies; those changes are excluded from this PR.
- Fresh hosted [unchanged
Validation](https://github.com/rudycelekli/agno/actions/runs/37157702686)
passes all ten jobs: all five full unit-test groups with the configured
PostgreSQL service, four style groups, and supported-runtime page
guards. The QA pull-request merge tree exactly equals reviewed head
`09a6851fa034c50ced55a3d1b796ed4be85cfd5e`. PR Lint also passes. No
workflow, test selection or dependency constraints were changed.
- After the capability-aware Windows symlink fixture helper, all 12
focused writer/UTF-8 tests pass and root format/validation pass again.
The symlink controls skip on Windows only if native creation actually
fails for missing privilege.
- This local development environment omits optional all-test SDKs. An
attempted adjacent Mongo-containing suite stops collection because
`pymongo` is absent; that local run is not described as passing. Full
test verification comes from the fresh hosted matrix above.

This change protects a single regular table's prior content when
publishing that table fails. It does not merge concurrent writers, make
multi-table operations transactional, or guarantee power-loss
durability. Atomic replacement requires the table directory to allow
creation/rename. Existing mode bits are preserved; no claim is made
about extended ACLs or arbitrary hard-link aliases. No LLM/API calls or
real database services were needed for the focused reproduction.

---------

Signed-off-by: Rudy Celekli <[email protected]>
Co-authored-by: Sannya Singal <[email protected]>
Co-authored-by: sannya-singal <[email protected]>
2026-10-06 15:35:19 +05:30
9f80ff55d9 [fix] Preserve reserved characters in SQLTools credentials (#10800)
## Summary

Constructing `SQLTools` with `password="p@ssword"` currently passes
password `p` and host `ssword@localhost` to SQLAlchemy. Use `URL.create`
to preserve the original connection fields and add eight offline
regression cases for reserved characters, percent sequences and an
omitted database.

This was reproduced on current main; no existing issue is being claimed.

## Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other

## Checklist

- [x] Code complies with style guidelines
- [ ] Ran format/validation scripts successfully (Windows root
validation limitation explained below)
- [x] Self-review completed
- [x] Documentation updated (regression-test docstring)
- [x] Examples and guides reviewed: no public API change or new example
needed
- [x] Tested in a dedicated Python 3.11 virtual environment
- [x] Tests added/updated

### Duplicate and AI-Generated PR Check

- [x] Searched existing open PRs; no matching credential-construction
fix found
- [x] Related PR differences explained below
- [x] Code and tests were generated with Codex

## Additional Notes

Compatibility note: `user=` and `password=` connection fields are now
passed verbatim to SQLAlchemy. Pass raw credentials, not percent-encoded
values. If you previously passed an encoded password such as `p%40ss` as
a workaround for the old parser behavior, pass the original password
`p@ss` instead. A literal `%40` in a raw password remains literal. The
`db_url=` path is unchanged and still accepts a properly encoded full
connection URL.

The 12 SQL tests pass with SQLAlchemy 2.0.54 and previously passed with
2.1.3. With the missing local dependencies installed and SQLAlchemy
2.0.54, the Agno package validator passes Ruff and Mypy for all 1087
source files. Changed-file Mypy, agnoctl lint/Mypy and the cookbook
lint/pattern checks also pass.

Additional offline verification covers 32 cases across raw connection
fields and encoded full `db_url` values, including reserved characters,
literal percent sequences, Unicode credentials, and an omitted database.
The generated PyMySQL connection arguments preserve the expected
credentials; no live database connection was attempted.

The Windows root formatter ran successfully; its unrelated formatting
changes were excluded from this patch. The root `validate.bat` cannot
complete because its final `ELSE` block has a commented-out closing
parenthesis. Its package validators and the checks specified by the
Linux root validator were run directly instead. The full integration/CI
matrix and a live MySQL connection have not been exercised. Native
SQLAlchemy 2.1 full-package type diagnostics were also reproduced on
unchanged upstream; related compatibility work exists in #10656.

PR #10417 changes engine precedence in the same constructor; #5008
expands SQL tooling and retains interpolated connection URLs. This fix
preserves raw credentials without changing engine precedence or query
behavior and may need rebasing if those PRs alter the same hunk.

Co-authored-by: wToTw123 <[email protected]>
Co-authored-by: Sannya Singal <[email protected]>
2026-10-06 14:16:16 +05:30
Saleh AlghussonandKaustubh ab0120677e [feat] Add Perplexity integration attribution (#10827)
## Summary

- Perplexity would like to identify requests made through Agno's
Perplexity integration.
- Add `X-Pplx-Integration: agno` to the headers of both
`PerplexitySearch.search` and `PerplexitySearch.asearch`, alongside the
existing `X-Source: agno`.
- Leave every other provider and request behavior unchanged.

## Type of change

- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [x] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [ ] Documentation updated (comments, docstrings)
- [ ] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [x] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

Verification: `ruff format --check`, `ruff check .` and `mypy .
--config-file pyproject.toml` pass in `libs/agno`, and `pytest
libs/agno/tests/unit/tools/test_perplexity.py` passes (17 tests). The
sync and async success tests now assert both headers.

Co-authored-by: Kaustubh <[email protected]>
2026-10-06 13:03:06 +05:30
1a91338857 [fix] Reject invalid file replacement line ranges (#10798)
`FileTools.replace_file_chunk` previously accepted negative, reversed,
and out-of-bounds inclusive ranges, allowing invalid tool calls to alter
an existing file. Validate the range before saving and return a useful
error while preserving the original bytes.

Also address the review's trailing-separator example: `a\nb\nc\n`
contains three real lines, although splitting produces a fourth empty
element. Exclude that virtual element from the valid line count while
retaining it during reconstruction, so a valid replacement preserves the
final terminator. Empty files, real blank lines, Unicode, and custom
separators are covered.

Changes are limited to `libs/agno/agno/tools/file/file.py` and its
existing unit-test module. The review follow-up is an atomic append
commit, `ea15e2aefae2a955074327ce3c32e0e1485cfa18`, preserving the
original PR commit. It does not change the existing CRLF translation
behavior.

Local validation of this exact head on 2026-10-05:

- Full official unit-test collection on Debian bookworm arm64 / Python
3.12.15: **21,512 passed, 97 skipped, 4,415 warnings**, 911.86s pytest /
923.569s wall, exit 0. Command: `python -m pytest ./libs/agno/tests/unit
--ignore=libs/agno/tests/unit/models/litellm
--ignore=libs/agno/tests/unit/tools/test_crawl4ai.py
--ignore=libs/agno/tests/unit/tools/test_scrapegraph.py -q`. These are
the repository's existing CI exclusions; no new exclusions or timeouts
were added.
- Complete file-tool module: `PYTHONPATH=libs/agno python -m pytest
libs/agno/tests/unit/tools/test_filetools.py -q`: **50 passed**, 0.20s
pytest / 0.438s wall, exit 0, using the existing project dev
environment, Python 3.10.6.
- The same 18 new boundary cases against the previous PR head: **7
failed, 11 passed**, 0.09s pytest / 0.345s wall, exit 1. Against the
unmodified target base: **8 failed, 10 passed**, 0.12s pytest / 0.848s
wall, exit 1. Both execute the real SDK against local files.
- Repository `scripts/validate.sh` and the library validation command:
exit 0, 3.257s and 25.637s respectively. Ruff, Mypy, and cookbook checks
passed.
- Repository formatting ran successfully on an isolated copy; the
changed files stayed byte-identical. Seven existing formatting
differences match the unmodified baseline and are excluded from this
fix.
- Compilation, import/source checks, and `git diff --check`: exit 0.
Source hashes were verified before and after the full unit run.

The first fresh-container attempt failed collection because `libGL.so.1`
was missing. The same step failed on the unmodified base; installing the
image's system dependencies in the isolated test container resolved it
before the successful full run. Host services and the shared Python
environment were not changed.

All 958 currently open PR file lists were checked, with fresh data for
changed heads; related file-tool patches and exact-head production
methods were inspected. No other related change covers this
terminal-line-count fix.

AI assistance: implementation and tests were developed with Codex using
`gpt-6.1-sol` agents and reviewed against the complete diff and real
local execution. These results cover local SDK/unit behavior; live model
and production-service behavior were not tested. The author remains
responsible for the contribution.

---------

Co-authored-by: sannya-singal <[email protected]>
Co-authored-by: Sannya Singal <[email protected]>
2026-10-06 12:04:49 +05:30
f3aa7be6af fix: create a separate approval record for each approval-required tool call in a run (#10812)
## Summary

Each approval-required (`@approval`) tool call in a run now gets its own
approval record. This applies to agents and teams.

### The issue

When a run paused a second time for a new `@approval` tool call, no
approval record was created for that call. The next continue then ran it
using the approval resolved for the first call.

Example with `pay_invoice` decorated with `@approval` +
`@tool(requires_confirmation=True)`:

1. The run pauses on `pay_invoice(INV-1)`, and one pending record is
created.
2. The admin approves it, and the run is continued by `run_id` with no
requirements (the documented database-resolution flow).
3. INV-1 is paid. The model calls `pay_invoice(INV-2)`, and the run
pauses again.
4. No pending record exists for INV-2, so there is nothing for the admin
to approve.
5. Continuing the run pays INV-2 anyway. The run has one approval
record, and it names INV-1 only.

There were two causes, both in `agno/run/approval.py`:

- **No record for the second call.** `create_approval_from_pause`
skipped creating a record if any required tool in `run_response.tools`
already had an `approval_id`. That list grows across every pause in a
run, so the earlier call (already approved and run) made the check
return the old id.
- **The old record was reused.** The new call had no `approval_id`, so
`_group_tools_by_approval` used the run-level fallback. That fallback
returned the run's latest record, which was the approved INV-1 record,
and the new call was confirmed with it.

### The fix

- **`create_approval_from_pause` / `acreate_approval_from_pause`:** a
record is skipped only when every required tool already has an
`approval_id`. Tools without one get a new record.
`_build_approval_dict` builds the record from the current pause only:
`tool_name`, `tool_args` and `context.tool_names` come from the new
approval calls, and `requirements` lists those calls plus any plain HITL
calls paused in the same turn. The second record names INV-2, not INV-1.
A repeated pause hook on the same pause still creates no duplicate.
- **`_group_tools_by_approval` / `_agroup_tools_by_approval`:** a record
found by a run-level or member-run lookup is not paired with a tool call
it doesn't name in its `requirements`. Such a call is left without a
record, so the continue is blocked. Records that don't list their tool
calls behave as before.
- **`check_and_apply_approval_resolution` /
`acheck_and_apply_approval_resolution`:** only
`approval_type="required"` tools are gated. Audit tools never get a
required record, so they no longer block the continue.
`metadata["approval"]` is now the record of the call this continue runs,
not the oldest record in the run.
- **Agent and team continue with requirements:** a requirement no longer
replaces a tool call that already has a result. After a pause resolved
from the approvals table, the requirement can hold an out-of-date copy
of the call (confirmed, no result), and resending `run.requirements` ran
the call a second time.

### Agent and team coverage

| Scenario | Before | After |
|---|---|---|
| Agent with an `@approval` tool | Second call ran under the first
call's approval | Separate record and admin decision per call |
| Team with an `@approval` tool on the team leader | Same bug (the team
pause hook uses the same `create_approval_from_pause`) | Separate record
and admin decision per call |
| Team with an `@approval` tool on a member agent | Each pause got its
own record, but the second also named the first call and showed its
arguments. If the second record was deleted, the first call's approval
ran the second call | Second record names only the second call; a
deleted record blocks the continue |

The sync, async and streaming paths all go through the shared functions
above.

Related: #10600 handles a different route to the approval gate
(continues that send tool or approval fields from the client). This PR
covers the continue with no requirements, where the call picked up the
old record inside `approval.py`.

## Reproduction

<details>
<summary>Reproduction code (AgentOS app)</summary>

```python
"""
Approval Reuse Across Pauses (AgentOS repro)
============================================

A second @approval tool call in the same run must get its own approval record.
Before the fix, the second call had no record, and continuing the run executed
it under the approval an admin resolved for the first call.

Components served:
- invoice-agent:          agent on OpenAIResponses (needs OPENAI_API_KEY)
- invoice-team:           team with the tool on the team leader, OpenAIResponses
- invoice-agent-scripted: agent, deterministic model (no API key needed)
- invoice-team-scripted:  team-level tool, deterministic model

The real-model components run with parallel tool calls off, so every
pay_invoice call is its own pause. The scripted model pays every invoice named
in the user message (e.g. INV-1, INV-2), one tool call per model turn.

Run the server:  python <file>.py
Run the client:  python <file>.py --demo [--scripted]
"""

import argparse
import json
import os
import re
from typing import Any, AsyncIterator, Iterator, List

import httpx
from agno.agent import Agent
from agno.approval import approval
from agno.db.sqlite import SqliteDb
from agno.metrics import MessageMetrics
from agno.models.base import Model
from agno.models.openai import OpenAIResponses
from agno.models.response import ModelResponse, ModelResponseEvent
from agno.os import AgentOS
from agno.team import Team
from agno.tools import tool

BASE_URL = "http://localhost:7777"
DB_FILE = "tmp/approval_repro.db"
PAYMENTS: List[str] = []


# ---------------------------------------------------------------------------
# Scripted Model: one pay_invoice call per turn for each invoice in the request
# ---------------------------------------------------------------------------
class InvoiceScriptedModel(Model):
    """Stateless, so it is safe to share across runs and sessions.

    Reads the latest user message for invoice numbers and counts the tool
    results that followed it: with n results, it pays invoice n + 1, or answers
    once every invoice is paid.
    """

    def __init__(self):
        super().__init__(id="invoice-scripted", name="invoice-scripted", provider="test")

    def _next(self, messages: List[Any]) -> ModelResponse:
        last_user = max((i for i, m in enumerate(messages) if m.role == "user"), default=-1)
        user_text = str(messages[last_user].content) if last_user >= 0 else ""
        invoices = re.findall(r"\b[A-Z]+-\d+\b", user_text) or ["INV-1", "INV-2"]
        answered = sum(1 for m in messages[last_user + 1 :] if m.role == "tool")

        if answered < len(invoices):
            r = ModelResponse(role="assistant")
            args = {"invoice": invoices[answered], "payee": "ACME", "amount": "100.00"}
            r.tool_calls = [
                {
                    "id": f"call_{answered + 1}",
                    "type": "function",
                    "function": {"name": "pay_invoice", "arguments": json.dumps(args)},
                }
            ]
        else:
            r = ModelResponse(content=f"Paid {', '.join(invoices)}.", role="assistant")
            r.event = ModelResponseEvent.assistant_response.value
        r.response_usage = MessageMetrics(input_tokens=10, output_tokens=5, total_tokens=15)
        return r

    def invoke(self, *a, **k):
        return self._next(k["messages"])

    async def ainvoke(self, *a, **k):
        return self._next(k["messages"])

    def invoke_stream(self, *a, **k) -> Iterator[ModelResponse]:
        yield self._next(k["messages"])

    async def ainvoke_stream(self, *a, **k) -> AsyncIterator[ModelResponse]:
        yield self._next(k["messages"])

    def _parse_provider_response(self, response: Any, **k) -> ModelResponse:
        return response if isinstance(response, ModelResponse) else ModelResponse()

    def _parse_provider_response_delta(self, response: Any) -> ModelResponse:
        return response if isinstance(response, ModelResponse) else ModelResponse()


# ---------------------------------------------------------------------------
# Approval-gated tool
# ---------------------------------------------------------------------------
@approval
@tool(requires_confirmation=True)
def pay_invoice(invoice: str, payee: str, amount: str) -> str:
    """Pay a vendor invoice. This moves money and cannot be undone.

    Args:
        invoice (str): Invoice number
        payee (str): Payee account
        amount (str): Amount to pay
    """
    PAYMENTS.append(invoice)
    print(f"[ledger] PAID {invoice} to {payee} ({amount}). All payments: {PAYMENTS}")
    return f"PAID {invoice}"


# ---------------------------------------------------------------------------
# Create AgentOS
# ---------------------------------------------------------------------------
os.makedirs("tmp", exist_ok=True)
db = SqliteDb(id="approval-repro-db", db_file=DB_FILE, approvals_table="approvals")

LLM_INSTRUCTIONS = [
    "You pay vendor invoices with the pay_invoice tool.",
    "Pay EVERY invoice the user lists. Call pay_invoice for one invoice, and as soon as its result comes back, "
    "call pay_invoice for the next unpaid invoice.",
    "Do not reply to the user or ask for confirmation until every listed invoice has been paid.",
    "Use payee ACME and amount 100.00 unless the user says otherwise.",
]

agents = [
    Agent(
        id="invoice-agent",
        name="Invoice Agent",
        model=OpenAIResponses(id="gpt-5.6-luna", parallel_tool_calls=False),
        tools=[pay_invoice],
        instructions=LLM_INSTRUCTIONS,
        db=db,
    ),
    Agent(
        id="invoice-agent-scripted",
        name="Invoice Agent (scripted)",
        model=InvoiceScriptedModel(),
        tools=[pay_invoice],
        db=db,
    ),
]
teams = [
    Team(
        id="invoice-team",
        name="Invoice Team",
        model=OpenAIResponses(id="gpt-5.6-luna", parallel_tool_calls=False),
        members=[
            Agent(
                id="helper",
                name="Helper",
                role="Answers general questions",
                model=OpenAIResponses(id="gpt-5.6-luna"),
            )
        ],
        tools=[pay_invoice],
        instructions=LLM_INSTRUCTIONS + ["Call pay_invoice yourself; do not delegate payments."],
        db=db,
    ),
    Team(
        id="invoice-team-scripted",
        name="Invoice Team (scripted)",
        model=InvoiceScriptedModel(),
        members=[Agent(id="helper-scripted", name="Helper", model=InvoiceScriptedModel())],
        tools=[pay_invoice],
        db=db,
    ),
]

agent_os = AgentOS(id="approval-reuse-os", db=db, agents=agents, teams=teams)
app = agent_os.get_app()


# ---------------------------------------------------------------------------
# Demo client: the documented "database resolution" flow over HTTP
# ---------------------------------------------------------------------------
def describe_approvals(client: httpx.Client, run_id: str) -> List[str]:
    out = []
    resp = client.get("/approvals", params={"run_id": run_id, "approval_type": "required"})
    for a in resp.json().get("data", []):
        calls = [
            (
                (r.get("tool_execution") or {}).get("tool_call_id"),
                ((r.get("tool_execution") or {}).get("tool_args") or {}).get("invoice"),
            )
            for r in a.get("requirements") or []
        ]
        out.append(f"{a['id'][:8]} {a['status']} (by {a.get('resolved_by')}) for {calls}")
    return out


def describe_tools(run: dict) -> list:
    return [
        (t.get("tool_call_id"), (t.get("tool_args") or {}).get("invoice"), t.get("confirmed"))
        for t in run.get("tools") or []
        if t.get("tool_name") == "pay_invoice"
    ]


def run_demo(kind: str, component_id: str) -> None:
    print(f"\n=== {kind}/{component_id} ===")
    with httpx.Client(base_url=BASE_URL, timeout=120.0) as client:
        resp = client.post(
            f"/{kind}/{component_id}/runs",
            data={"message": "Pay invoices INV-1 and INV-2 to ACME.", "stream": "false"},
        )
        resp.raise_for_status()
        run = resp.json()
        run_id, session_id = run["run_id"], run["session_id"]
        print(f"run: {run['status']} tools={describe_tools(run)}")

        for step in range(1, 5):
            print(f"  approvals: {describe_approvals(client, run_id)}")
            if run.get("status") != "PAUSED":
                break
            pending = client.get(
                "/approvals", params={"run_id": run_id, "status": "pending", "approval_type": "required"}
            ).json()["data"]
            for a in pending:
                client.post(f"/approvals/{a['id']}/resolve", json={"status": "approved", "resolved_by": "admin"})
            print(f"  resolved {len(pending)} pending approval(s)")
            resp = client.post(
                f"/{kind}/{component_id}/runs/{run_id}/continue",
                data={"tools": "", "session_id": session_id, "stream": "false"},
            )
            if resp.status_code >= 400:
                print(f"continue {step}: HTTP {resp.status_code} {resp.text[:160]}")
                break
            run = resp.json()
            print(f"continue {step}: {run['status']} tools={describe_tools(run)}")

        records = client.get("/approvals", params={"run_id": run_id, "approval_type": "required"}).json()
        executed = [t for t in describe_tools(run) if t[2]]
        print(
            f"pay_invoice calls executed: {len(executed)}; required approval records: {records['meta']['total_count']}"
        )


# ---------------------------------------------------------------------------
# Run AgentOS
# ---------------------------------------------------------------------------
if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--demo", action="store_true", help="Run the HTTP client against a server on port 7777.")
    parser.add_argument("--scripted", action="store_true", help="With --demo, drive the scripted components.")
    args = parser.parse_args()

    if args.demo:
        suffix = "-scripted" if args.scripted else ""
        run_demo("agents", f"invoice-agent{suffix}")
        run_demo("teams", f"invoice-team{suffix}")
    else:
        agent_os.serve(app=app, port=7777)
```

</details>

## Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [ ] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [ ] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [ ] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

**Tests added**

- `tests/unit/run/test_approval.py`: record creation on a later pause
(new record for the new call only, with that call's requirements and
args), no duplicate record from a repeated pause hook, and the fallback
refusing a record that names a different call, including when the tool's
own record is missing. Sync and async.
- `tests/unit/run/test_approval_multi_pause.py`: two calls one after the
other in one run produce two records and two admin decisions, and the
second call doesn't run while its record is pending. Covers an agent and
a team-leader tool, sync and async, with a scripted model.
- `tests/integration/os/test_approval_multi_pause.py`: the AgentOS HTTP
flow (`GET /approvals`, `POST /approvals/{id}/resolve`, `POST
/{agents|teams}/{id}/runs/{run_id}/continue` with empty tools) for the
agent and the team. It checks that the second call gets its own pending
record, isn't run while that record is pending, and doesn't run if
rejected. Uses a scripted model, so no API key is needed.
-
`tests/integration/agent/human_in_the_loop/test_approval_multi_pause.py`
and
`tests/integration/teams/human_in_the_loop/test_team_approval_multi_pause.py`:
the same checks with a real model (`OpenAIResponses`,
`parallel_tool_calls=False`). Skipped without `OPENAI_API_KEY`.

All new tests fail on `main` and pass with this change. The existing
run, agent, team and os unit suites pass. `validate.sh` reports mypy
errors only in files this PR doesn't touch, and the same errors appear
on `main`.

**Behaviour change:** a continue with no requirements now blocks if a
required tool call is paired with a record that names other tool calls.
Before, that call ran under the other record.

**Not in this PR** (same behaviour on `main`, to be fixed separately):
one approval can still pay a call twice when a run is forked and the
original is continued too, or when two continues for the same run arrive
at once. A workflow continue with no payload doesn't read the approval
record (see #9494).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Harsh <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 11:30:49 +05:30
c44b082010 [fix] Read text and Markdown streams with non-string names (#10779)
## Summary

`TextReader` and `MarkdownReader` call `.split()` on an input stream's
`name`. For a stream opened from a file descriptor, `name` is an
integer; a stream can also expose `name=None`. Both readers catch the
resulting exception and return `[]`, dropping otherwise valid content.

Check that the stream name is a string before extracting its prefix, in
both `read()` and `async_read()`. Other names use each reader's existing
fallback (`text_file` or `file`). Explicit document names and
string-name behavior are preserved.

The regression tests use a real descriptor-backed stream, plus binary
and text in-memory streams. They check content, names, rewinding
partially read inputs, and leaving caller-owned streams open.

## Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed (Codex reviewed the diff and executed the
regression tests)
- [x] Documentation updated (comments explain descriptor-backed stream
names)
- [ ] Examples and guides: Relevant cookbook examples have been included
or updated (not applicable: existing reader interface)
- [x] Tested in clean environment (isolated virtual environment)
- [x] Tests added/updated

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

Implementation, regression tests, diff review, and validation were
performed by Codex.

Validation on Windows with Python 3.11.9, Ruff 0.15.20, mypy 2.1.0, and
SQLAlchemy 2.0.54:

```sh
python -X utf8 -m pytest libs/agno/tests/unit/reader/test_text_stream_names.py libs/agno/tests/unit/reader/test_text_reader.py libs/agno/tests/unit/reader/test_markdown_reader.py -q
./scripts/format.sh
./scripts/validate.sh
git diff --check
```

- New regression suite before the fix: **20 failed, 12 passed**.
- All three related test files after the fix: **81 passed**.
- Repository formatting and final validation passed, including Ruff,
mypy (1,087 Agno files and 21 agnoctl files), and the cookbook pattern
check. Unrelated formatter changes were removed from the diff before
final validation.
- SQLAlchemy 2.1.3 initially produced six type errors outside the
changed files. The same errors were reproduced on an unmodified checkout
of this base; validation passed with 2.0.54, which is permitted by the
current dependency specification. The separate 2.1 compatibility work is
tracked in #10655 / #10656.
- UTF-8 mode is enabled because existing tests write Unicode fixtures
using the platform's default encoding. The new descriptor fixture writes
explicit UTF-8 bytes.
- These tests do not call models or require external services.

Related PRs #10634 and #10489 fix JSONReader and CSVReader respectively.
This patch only changes TextReader and MarkdownReader. #9976 concerns
async chunking and changes a different method.

Co-authored-by: zjp <你的邮箱>
Co-authored-by: Sannya Singal <[email protected]>
2026-10-05 13:06:50 +05:30
XxandSannya Singal 3feab68adf [cookbook] Fix DynamoDb import path in the DynamoDB cookbook README (#10788)
## Summary

Fixes #10801

`cookbook/06_storage/dynamodb/README.md` imports `DynamoDb` from
`agno.db.dynamodb`, a module that does not exist. The package ships
`agno/db/dynamo/`, and `DynamoDb` is exported from the package root
(`libs/agno/agno/db/dynamo/__init__.py`, `__all__ = ["DynamoDb"]`).
Copying the snippet as written raises `ModuleNotFoundError`.

The cookbook's own runnable example in this directory
(`dynamo_for_agent.py`) already imports it correctly as `from agno.db
import DynamoDb`, so this just aligns the README with the code sitting
next to it.

How it was found: I extracted every `from agno...` import in all 754
markdown files and checked each module path against the source tree. Of
the 62 distinct `agno.*` modules referenced, this is the only one that
does not exist.

(If applicable, issue number: **#10801** — tracked by issue
[#10801](https://github.com/agno-agi/agno/issues/10801).)

## Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [ ] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`) — not run: this is a one-line markdown change
and touches no Python
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [x] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [ ] Tested in clean environment
- [ ] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] This PR was produced with AI assistance, disclosed per
CONTRIBUTING §5

---

## Additional Notes

Verification performed:

- `ls libs/agno/agno/db/` shows `dynamo/`, not `dynamodb/`.
- `libs/agno/agno/db/dynamo/__init__.py` does `from
agno.db.dynamo.dynamo import DynamoDb` with `__all__ = ["DynamoDb"]`.
- `grep -rn "agno.db.dynamodb"` across the repo returns exactly one
line: the one changed here.
- The sibling example `cookbook/06_storage/dynamodb/dynamo_for_agent.py`
uses `from agno.db import DynamoDb`.

Co-authored-by: Sannya Singal <[email protected]>
2026-10-05 12:54:13 +05:30
a184c9011b [fix] Handle non-string stream names in JSONReader (#10634)
## Summary

Fixes #10632.

`JSONReader.read()` and `async_read()` raise `AttributeError` for valid
temporary file streams whose `name` is an integer or `None`. Use the
existing `json_file` fallback for non-string stream names in the shared
reading path. Explicit names and existing string-filename behavior are
preserved.

Add sync/async regression coverage for both non-string cases, plus
explicit-name and multi-dot filename controls. The tests also verify
parsed content, rewinding from EOF, and caller ownership of the stream.

## Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [ ] Documentation updated (comments, docstrings)
- [ ] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [x] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [ ] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

AI wrote the code, and I reviewed it line by line.

---

## Additional Notes

Validation on macOS / Python 3.12.13:

- Before the fix: the JSONReader module had **4 failed, 42 passed**,
with all failures reproducing the reported `AttributeError`.
- After the fix: `python -m pytest
libs/agno/tests/unit/reader/test_json_reader.py -q` — **46 passed**.
- Also reproduced the fix with real `TemporaryFile` and
`SpooledTemporaryFile` inputs in both reading modes.
- `./scripts/format.sh`, `./scripts/validate.sh`, `uv pip check`, and
`git diff --check` passed.
- Validation used SQLAlchemy 2.0.44 in the local development
environment. Installing SQLAlchemy 2.1.1 produced four unrelated mypy
errors in unchanged database/public-limit files; using 2.0.44 resolved
them without changing source, dependency declarations, or check
configuration.
- Broader test execution was attempted but could not complete in the
development environment: the reader directory had 12 collection errors,
and `./scripts/test.sh` had 123 collection errors because optional
provider/reader/vector-database SDKs were unavailable. No full-suite
pass is claimed.

No public API or usage pattern changes; documentation and cookbook
updates are not applicable to this compatibility fix. The focused
regression tests do not need model credentials or external services.

Co-authored-by: bmh201708 <[email protected]>
Co-authored-by: Sannya Singal <[email protected]>
2026-10-05 12:39:40 +05:30
Kaustubh 3ca74c272f chore: Release v3.1.1 (#10736)
# Changelog

## New Features

- Live progress & cancellation for page
sync: `Knowledge.stream_sync_pages()` / `astream_sync_pages()` yield
typed `PageSyncProgress` snapshots plus one terminal `SyncReport`,
and `sync_pages` / `async_sync_pages` accept an `on_progress` observer.
A workflow function step can now yield `StepProgress(content=...,
data=...)` before its `StepOutput`, emitted as a
native `StepProgressEvent` under the existing workflow run/step ID —
AgentOS streams it over the existing workflow REST/SSE route,
and `AgentOSClient.run_workflow_stream()` parses it. Cancelling a sync
now actually reaches the work instead of draining to the end. (#10098)
- Page sync reports name failed pages: `SyncReport.failed_paths` lists
the site paths of pages that failed to publish/delete (capped at the
first 20, like `errors`; `failed` keeps the full count; defaults
to `()` so existing callers/stored reports are unaffected). Logs now
name the page and its cause chain, e.g. `Page sync failed for
/guides/setup.md (SyncFailed: sync_failed <- ConnectError: [Errno 104]
Connection reset by peer)`, plus a new `Page delete failed for
…` warning on the prune path. (#10729)

## Bug Fixes

- Retry transient page-fetch failures with backoff: on large corpora
(e.g. [docs.agno.com](http://docs.agno.com/), 3,913 pages) 1–2 random
pages failed per run with `Connection reset by peer`. Each attempt
previously consumed the entire 30s fetch deadline (leaving no time to
retry) and retries were too short — both are fixed with a bounded
per-attempt timeout and backoff. (#10730)
- Complete page sync for Mintlify-style sites: large Mintlify sites
(e.g. [docs.langchain.com](http://docs.langchain.com/)) synced
as `partial` even when every page indexed — and a `partial` result skips
pruning, so removed pages stayed searchable indefinitely. Discovery now
skips links to non-page files (`.json`, `.yaml`, `.xml`, `.csv`, `.pdf`,
images/audio/video, archives, fonts — while keeping `.js`/`.css` as
valid page names like `/guides/node.js`), and handles nested indexes,
MDX code blocks and redirect aliases. Off-host page links still block
pruning (by design). (#10726)
v3.1.1
2026-10-02 18:03:45 +05:30
KaustubhandClaude Opus 5.5 18413f7f60 fix: complete page sync for Mintlify-style sites (non-page links, nested indexes, MDX code, redirect aliases) (#10726)
## Summary

Large Mintlify sites sync as `partial` today even though every
documentation page indexes. A `partial` result skips pruning, so pages
removed from the site stay searchable indefinitely. docs.langchain.com
reproduces it. Four causes, all fixed here:

1. **Links to non-page files fail discovery.** LangChain's `llms.txt`
has an "OpenAPI Specs" section:
- `https://api.host.langchain.com/openapi.json` is off-host, which marks
discovery incomplete.
- Two same-site `.json` specs are treated as pages, fetched as
`…openapi.json.md`, and 404 as page failures.

Discovery now skips links to files that cannot be pages (specs, data,
media, archives: `.json`, `.yaml`, `.xml`, `.csv`, `.pdf`, images, audio
and video, archives, fonts) wherever they point. `.js` and `.css` are
excluded, because pages can be named like `/guides/node.js`. **Off-host
page links still block pruning:**
`test_collisions_and_foreign_destinations_cannot_prune` is unchanged.

2. **Depth-first traversal exceeds `max_depth` on indexes the root lists
directly.** Mintlify roots list every `/_llms/` index directly, and each
index also links its children. Depth-first traversal follows the first
index's chain down to depth 4+ and flags the run, even though the same
indexes are reachable at depth 1. Discovery is now breadth-first, so
each index is reached at its shallowest depth. `max_depth` and
`max_indexes` are unchanged.

3. **Mintlify inlines component code into page Markdown.** Pages carry
top-level MDX statements such as `export const PatternEmbed = ({...}) =>
{...}`. On LangChain's Deep Agents overview that's 48 KB of a 77 KB
page. The code was chunked and embedded as content. The `fumadocs` and
`mintlify` profiles now drop top-level `import`/`export` statements.
`strip_esm=False` keeps them, and the `markdown` profile stays verbatim.
The matching is conservative:
- A statement must start a block (first line or after a blank line) and
match ESM syntax, so a wrapped prose line beginning "export the file…"
is kept.
   - Statements inside code fences are kept.
- Multi-line statements end when brackets balance outside strings and
comments. Quotes reset at line ends, so an apostrophe in embedded JSX
text can't swallow the page.
- A statement that never closes is kept rather than risk dropping the
rest of the page.

4. **Listed pages that redirect elsewhere fail as pages.** A live sync
of docs.langchain.com ended `partial` with 38 failed pages, and every
one was a listed page that redirects somewhere else:
- 24 to other hosts (reference.langchain.com, GitHub,
academy.langchain.com, status and marketing pages): `SyncFailed`.
   - 4 to a `#section` of another page: `invalid_source_url`.
- 10 to a same-site URL without `.md`, which serves HTML. That HTML was
fetched and embedded as page text until the provider rejected it at its
8,192-token input limit.

A listed `.md` page that redirects off-origin, to a section or to a
non-Markdown URL now raises `PageMoved`. The page is **skipped, not
failed**: it isn't retried and is never stored, and it doesn't make the
run `partial`. If an older sync stored it, it's pruned.
`SyncReport.skipped` and `skipped_paths` (up to 20) report skipped
pages, and each is logged with its target. A move to another `.md` URL
on the same origin is still followed. Index (`llms.txt`) redirects
behave as before, and foreign index redirects still fail. A `.md` page
answered with `text/html` now raises `PageNotMarkdown`, a failed page,
instead of being embedded.

The same sync surfaced a normalizer gap, also fixed: component tags with
`>` or `<` inside quoted attributes (`<Tab title="From Fleet >
Integrations">`, `<ResponseField type="Record<string, string>">`) were
left in page text. They now render, and `ParamField` is named by its
`path`/`query`/`body`/`header` attribute.

## Results on live corpora

Discovery and profile output were measured without a database or model
calls. The full sync ran on a docs-agent template stack with this branch
mounted:

| | Before | After |
|---|---|---|
| docs.langchain.com discovery | 1,668 entries incl. 2 `.json` specs,
`complete=False` | 1,666 pages, `complete=True` |
| docs.langchain.com `mintlify` output | inlined component code indexed
| 1.7 MB removed across 55 of 1,666 pages; all 29 headings and every
prose line kept on the Deep Agents page; the cut ends at the component's
closing `};` |
| docs.langchain.com full sync (Postgres + pgvector, `mintlify`) |
`partial` in 1,147 s: 1,628 updated, **38 failed**, pruning skipped |
`completed` in 243 s: 0 failed, 38 skipped, 1,628 pages stored, no alias
stored |
| docs.agno.com discovery | 3,913 pages, complete | unchanged |
| docs.agno.com `fumadocs` output | — | byte-identical with and without
`strip_esm` on all 3,913 pages, so existing indexes need no
`index_version` bump |

## Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [x] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [x] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [ ] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

**Tests**
- New unit tests:
- Non-page links (same-site, off-host, mixed case, a `node.js` page) are
skipped without marking discovery incomplete.
  - A five-deep `/_llms/` chain listed directly by the root completes.
  - ESM stripping on a Mintlify-shaped page, for both profiles.
  - The keep cases: disabled, unclosed, verbatim profile.
- Redirects: aliases to another host, to a `#section` or to a non-`.md`
URL raise `PageMoved` on the first request, with no retry. A move to
another `.md` URL is followed, `llms.txt` redirects still are, and a
`.md` page served as HTML raises `PageNotMarkdown`.
- MDX: quoted attribute values containing `<` and `>`, and `ParamField`
location names.
- New integration test: an alias is skipped, not failed. The run stays
`completed`, and the alias's copy stored by an earlier sync is pruned.
- `tests/unit/knowledge` and `tests/unit/reader`: same results as
`main`, plus the 4 new passing tests. Both trees share 35 failures from
optional dependencies (docling, PDF image) missing in the local venv.
- `tests/unit/knowledge/test_page_source_reliability.py` and
`tests/unit/reader/test_mdx_normalize.py`: 53 passed.
- `tests/integration/knowledge/test_page_storage.py` against PostgreSQL
18 + pgvector: 160 passed. The CLI test needs this worktree on
`PYTHONPATH`, because it runs a subprocess.

**Validation**
- `./scripts/format.sh`: also reformats five unrelated files on current
`main`. I reverted those to keep this diff scoped.
- `./scripts/validate.sh`: ruff passes. mypy reports the same 54 errors
as `main`, none in the changed files.

**Cookbook**
- `27_public_pages/README.md` documents breadth-first nested indexes,
skipped non-page links, `strip_esm` and skipped redirect aliases
(`SyncReport.skipped`).
- TEST_LOG has entries for the discovery change and for the live
LangChain sync before and after the redirect fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 17:34:57 +05:30
150a4e4123 feat: add AgentOS lifecycle support for Knowledge page synchronization (#10098)
## Summary

Function-based indexing workflows had no native way to report progress
or to be stopped: applications created synthetic agent runs and a
task/queue/heartbeat bridge only to show "N of M pages", and cancelling
a workflow drained a function step to its end. This PR adds the
primitives, proves them with one consumer that already exists in the
repository, and makes cancellation reach the work.

- `Knowledge.stream_sync_pages()` / `astream_sync_pages()` yield typed
`PageSyncProgress` snapshots and one terminal `SyncReport`. `sync_pages`
/ `async_sync_pages` accept an `on_progress` observer.
- A workflow function step can yield `StepProgress(content=...,
data=...)` before its `StepOutput`. Streaming emits a native
`StepProgressEvent` under the existing workflow run ID, step ID and
retry attempt. No agent run or executor history is invented;
non-streaming execution ignores progress.
- AgentOS streams the event over its existing workflow REST/SSE route,
and the existing `AgentOSClient.run_workflow_stream()` parses it. The
`27_public_pages` example uses this end to end, with the sync trigger
still bearer-protected.
- Cancelling a workflow now stops a function step at its next yield:
`Step` checks the run between the items a function yields, closes the
function's generator so its cleanup runs, and raises the established
`RunCancelledException`. For the example that closes the Knowledge
stream, which cancels the page worker's budget: the worker stops before
its next page, nothing is pruned and the writer lock is released.

Related #8710 handles AG-UI presentation of workflow structure; it adds
neither a function progress event nor Knowledge sync progress.

## How progress and cancellation travel

```
 Knowledge.astream_sync_pages()            libs/agno/agno/knowledge/knowledge.py
        │  PageSyncProgress (waiting → discovered → publishing → pruning), then SyncReport
        ▼
 sync_source()  function step              cookbook/05_agent_os/27_public_pages/public_pages.py
        │  StepProgress(content="Processed 2 of 3 pages …", data=<all counts>), then StepOutput
        ▼
 Step._afunction_events()                  libs/agno/agno/workflow/step.py
        │  between items: araise_if_cancelled(run_id); on cancel: aclose() the function, raise
        ▼
 Workflow streaming loop                   libs/agno/agno/workflow/workflow.py (unchanged)
        │  StepProgressEvent (run_id, step_id, step_name, step_index, attempt)
        │  on cancel: WorkflowCancelled, then WorkflowCompleted as the stream-end sentinel
        ▼
 AgentOS  POST /workflows/sync-docs/runs   existing REST/SSE route, bearer-protected trigger
 AgentOS  POST …/runs/{run_id}/cancel      existing cancel route
        ▼
 AgentOSClient.run_workflow_stream()       existing client; the new registry entry lets it parse the event
 AgentOSClient.cancel_workflow_run()
        ▼
 page_sync_progress.py                     prints each line, then the report; non-zero exit on failure
```

Closing the function's generator is what carries the cancel down:
`sync_source`'s `finally` closes the Knowledge stream, whose `finally`
closes the bounded worker stream, whose `finally` sets the worker
budget's cancel flag. The coordinator already checks that budget before
every page and at its lock wait.

## Decisions a reviewer should know about

| Decision | Reason |
|---|---|
| Consumer is the existing `AgentOSClient`, not MCP and not a second
server | main already has a protected workflow trigger and a typed SSE
client. One server, port 7777. |
| The sync trigger stays bearer-only (`PAGE_DEMO_SYNC_TOKEN`) |
Anonymous users can chat, search and read; they cannot start a sync
(401, tested against PostgreSQL). |
| Cancellation is fixed in `Step`, not in the workflow loop and not in
the cookbook | `Step` owns the function's iterator. Agent and team
executors are cancelled by the existing member-run cascade and raise
from inside their own loops; a function has no run of its own, so the
step checks the workflow run for it. The workflow's drain of agent/team
executors is untouched. |
| Cancellation is cooperative | It is observed at the function's next
yield. A progress snapshot reports a page that has just been published,
so that page and any page already in progress complete their own
transactions before the worker stops; a function that never yields
cannot be stopped. Measured live: against three pages, one and then two
pages completed after the cancel request, the third never started. The
README says so. |
| `BoundedWorkers.stream`/`astream` stop applying the deadline once the
worker has finished | A consumer that resumed after the deadline got
`TimeoutError` instead of the buffered snapshots and the result,
contradicting the documented contract. The deadline still applies while
the worker runs. |
| `astream_sync_pages` uses `try/finally` + `aclose()` |
`contextlib.aclosing` does not exist on Python 3.9, which the library
supports. A compatibility test guards it; the docstring says so. |
| The example's workflow sets
`events_to_skip=[WorkflowRunEvent.step_progress]` | AgentOS sets
`store_events=True` on every workflow it serves and saved events are not
capped; one progress event per page made a 3,913-item run save 2.05 MB
of events (measured with a synthetic step of the same event shape).
Progress is still streamed live. Follows `run_control/event_storage.py`.
|
| No `--check` mode in `page_sync_progress.py` | An offline check would
bypass the consumer being demonstrated; the same path is covered by a
real-route test. |

Not in this PR: MCP delivery of step progress, Control Plane or AG-UI
rendering of `StepProgress`, a framework-level default skip for
Workflow, any authentication change.

## Changes

| Area | Files |
|---|---|
| Knowledge progress | `knowledge/knowledge.py`,
`knowledge/page/__init__.py`, `knowledge/page/_coordinator.py`,
`knowledge/page/types.py`, `utils/bounded.py` |
| Workflow progress event and cancellation | `workflow/types.py`,
`workflow/step.py`, `run/workflow.py` |
| Example | `27_public_pages/public_pages.py`, `page_sync_progress.py`,
`README.md`, `TEST_LOG.md` |
| Tests | `unit/workflow/test_function_progress.py`,
`unit/workflow/test_workflow_event_stream.py`, `unit/os/test_client.py`,
`unit/knowledge/test_page_contract.py`,
`unit/knowledge/test_sync_progress_workers.py`,
`unit/test_py39_compat.py`, `integration/os/test_workflow_runs.py`,
`integration/os/test_public_surface.py`,
`integration/knowledge/test_page_storage.py`,
`integration/workflows/test_workflow_cancellation.py` |

Merged with main at `3c83750cd`; the six conflicts (README, TEST_LOG,
`knowledge.py`, `page/__init__.py`, `page/_coordinator.py`,
`test_page_storage.py`) keep both sides.

## Test plan

Python 3.12, PostgreSQL 18 with pgvector 0.8, all with
`PYTHONPATH=libs/agno`. Every test that was written for a fix was seen
failing first; stateful tests run twice.

- [x] Cancellation, framework: a function step yielding ten pages,
cancelled after the first, does no further work, its `finally` runs
before the stream ends, exactly one `WorkflowCancelled` then
`WorkflowCompleted`, run stored as cancelled; sync and async, streaming
and non-streaming, twice each (`test_workflow_cancellation.py`)
- [x] Cancellation, AgentOS: a cancel through `POST
/workflows/sync-docs/runs/{run_id}/cancel` while the step is between
pages stops it the same way, twice (`test_workflow_runs.py`)
- [x] Cancellation, page sync: cancelling the example's `sync-docs` run
cancels the page worker's budget, the worker ends and its capacity
returns, twice (`test_page_contract.py`)
- [x] `BoundedWorkers`: a finished worker's buffered snapshots and
result survive a consumer that resumes after the deadline; an unfinished
worker still hits the deadline; sync and async
(`test_sync_progress_workers.py`)
- [x] Focused set (client, page contract, function progress, event
stream, sync workers, 3.9 compatibility): 112 passed, 2 skipped by
design, twice
- [x] `integration/os/test_workflow_runs.py`: 20 passed, twice
- [x] `integration/os/test_public_surface.py` (PostgreSQL): 7 passed,
twice. Anonymous 401; the trusted token receives progress before the
report
- [x] `unit/workflow`: 795 passed, 9 skipped. `unit/knowledge`: 1110
passed, 13 skipped. `unit/os`: 3331 passed, 26 skipped
- [x] `integration/workflows`: no test fails that does not also fail on
main without `OPENAI_API_KEY`
- [x] `integration/knowledge/test_page_storage.py` (PostgreSQL): 159
passed, 0 skipped; progress tests twice; no temporary database left
behind
- [x] Python 3.9.6: every changed file parses; `bounded.py` runs the
late-consumer scenario there
- [x] `ruff` clean on every changed file; `./scripts/validate.sh`: 53
mypy diagnostics, identical to main at `3c83750cd` (zero new)
- [x] Live on the final tree (AgentOS on 127.0.0.1:7777, PostgreSQL
18/pgvector on 5532, `https://llmstxt.org/llms.txt`, OpenAI embeddings):
anonymous and wrong-token sync 401; normal sync `unchanged`; `--reindex`
`completed`, 3 updated, saved runs hold 4 events and no `StepProgress`;
two cancels through `AgentOSClient.cancel_workflow_run` after
"Discovered 3 pages": no progress after the cancel, `WorkflowCancelled`
then `WorkflowCompleted`, run stored `CANCELLED`, no advisory lock left,
revision 6→7 then 7→9 (one, then two pages completed after the request;
the third never started), a normal sync right after each cancel took the
lock and reported `unchanged`; clean shutdown, port closed, no leftover
process, lock or database

## Type of change

- [x] New feature
- [x] Bug fix
- [x] Improvement

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [x] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [ ] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [x] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

Control Plane rendering remains an adoption gate: a consumer must render
`StepProgress` before an application can remove its old display adapter.
SSE transport keepalives remain separate from progress snapshots.

---------

Co-authored-by: Himanshu Singh <[email protected]>
Co-authored-by: kausmeows <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 16:29:45 +05:30
KaustubhandClaude Opus 5.5 1e72e54b8c fix: retry transient page fetch failures with backoff (#10730)
## Summary

A Render deployment syncing docs.agno.com (3,913 pages) failed 1–2
random pages on every run, reported only as `Page sync failed
(SyncFailed)`. Fetching each page in isolation never failed, from Render
or locally. Wrapping `PageSource.fetch` during a real sync revealed the
cause, on a different page each run:

```
SyncFailed: sync_failed <- ConnectError: [Errno 104] Connection reset by peer
```

Two problems combined:

1. **A stalled connection used up the whole fetch.** Each attempt's HTTP
timeout was the entire remaining 30 s fetch deadline. Timing the
failures on Render showed the failing attempt took about 30 s: a TLS
handshake that hung, then ended in `Connection reset by peer`. No time
was left for a retry.
2. **The retries were too short.** The fetcher retried transient
failures only twice, waiting 0.25 s and then 0.5 s.

This PR makes `PageSource.fetch`, used for both pages and nested
indexes, more patient with transient failures:

- **Each attempt gets its own timeout:** 5 s to connect, including the
TLS handshake, and 10 s overall, always within the fetch deadline. A
stalled handshake is abandoned after 5 s and retried on a fresh
connection. Healthy page fetches take 0.05–0.2 s.

- **Up to 5 attempts** with jittered exponential backoff: about 0.5, 1,
2 and 4 s, each multiplied by a random 0.5–1.0, so concurrent fetch
workers don't retry in lockstep.
- **`Retry-After` is honored** on 429/5xx (delta-seconds or HTTP date),
capped at 10 s.
- **Dropped connections are retried too:** `RemoteProtocolError` and
`WriteError`, via `httpx.NetworkError`, alongside connect/read errors,
timeouts, 429 and 5xx.
- **The existing 30 s per-fetch deadline still bounds everything.** A
retry that would wait past it fails immediately, with the transport
error as `__cause__`.
- **Unchanged:** other 4xx responses, foreign redirects, oversized
bodies and cancellation still fail on the first request. DNS pinning and
SNI behavior are unchanged.
- **Configurable:** the policy is set by class attributes on
`PageSource` (`fetch_attempts`, `retry_base_seconds`,
`retry_max_seconds`, `retry_after_max_seconds`,
`connect_timeout_seconds`, `attempt_timeout_seconds`), like the existing
`max_pages`/`max_depth`.

## Live results on Render

Each run was a real sync of docs.agno.com from the deployment's shell,
with this PR's fetch logic swapped in at runtime.

| Policy | Result |
|---|---|
| agno 3.1.0 (3 attempts, attempt timeout = the whole 30 s deadline) |
1–2 failed pages per run (`SyncFailed <- ConnectError: Connection reset
by peer`) |
| Backoff only (5 attempts, attempt timeout = the whole deadline) |
Still 1 failed page: the attempt took about 30 s, so no retry could run
|
| This PR (backoff + 5 s connect / 10 s attempt timeouts) | The stalled
handshake timed out at 5.0 s (`ConnectTimeout: The handshake operation
timed out`), recovered on attempt 2; **0 failed pages** |

A local reproduction with injected reset bursts (1.5 s every 20 s) gave
129 failed pages under the old policy and 0 under this one.

## Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [x] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [x] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [ ] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

**Tests** (`tests/unit/knowledge/test_page_source_reliability.py`)
- A burst of four `Connection reset by peer` errors recovers on the
fifth attempt, with waits of exactly `[0.5, 1, 2, 4]` (jitter pinned).
- A 502, a `RemoteProtocolError` and a `WriteError` are each retried
once.
- `Retry-After: 3` is honored, and `Retry-After: 99` is capped at 10.
- A `Retry-After` past the fetch deadline fails with no wait and keeps
the `HTTPStatusError` cause.
- `Retry-After` parsing covers delta-seconds, a future and a past HTTP
date, and invalid values.
- Each attempt's client gets `connect=5`/`read=10` timeouts, and a
`ConnectTimeout` (stalled handshake) is retried. Attempt timeouts never
exceed the remaining fetch deadline.
- The existing pinned-DNS retry test now expects `fetch_attempts`
requests when exhausted. Existing no-retry cases (foreign redirect,
oversize, permanent 404, cancellation) are unchanged and pass.

**Results**
- After merging `main` (which now includes agno-agi/agno#10729):
`test_page_storage.py` against PostgreSQL 18 + pgvector, together with
the fetch reliability, page contract and per-page content tests: 255
passed. The `tests/unit/knowledge` failures are the same 33
optional-dependency failures as `main`, none page-related.
- `./scripts/validate.sh`: ruff passes; mypy reports the same 54
pre-existing errors as `main`, none in `_source.py`. `ruff format
--check` is clean on the changed files. `format.sh` was not applied
because it reformats unrelated files on current `main`.

**Live check and follow-ups**
- A possible follow-up: each fetch opens a fresh TLS connection to the
pinned IP. Reusing connections within a sync would also reduce the
connection churn that likely triggers the resets.
- Related: agno-agi/agno#10729 (merged) logs the cause chain for failed
pages, which is how this was diagnosed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 15:53:19 +05:30
KaustubhandClaude Opus 5.5 516f599723 feat: name failed pages and their causes in page sync reports (#10729)
## Summary

When a page fails during `Knowledge.sync_pages` / `async_sync_pages`,
nothing says which page it was. `SyncReport` carries only counts and
error codes, and the log line is `Page sync failed (RuntimeError)`. On a
deployed docs corpus, one of 3,913 pages failed, and the only way to
find it would have been querying the catalog tables directly.

- **`SyncReport.failed_paths`:** the site paths (e.g.
`"/guides/setup.md"`) of pages that failed to publish or delete in the
run. It's capped at the first 20, like `errors`; `failed` stays the full
count. It defaults to `()`, so existing callers and stored reports are
unaffected.
- **Logs name the page and the cause:** `Page sync failed for
/guides/setup.md (SyncFailed: sync_failed <- ConnectError: [Errno 104]
Connection reset by peer)`, plus a new `Page delete failed for …`
warning on the prune path. Previously the fetcher's underlying error was
dropped and the log said only `SyncFailed`. The cause chain is one line,
bounded to 300 characters, logs only.

How this was found: on a Render deployment, every full sync of
docs.agno.com failed 1–2 random pages. Fetching each page in isolation,
from Render or locally, never failed. Only wrapping the fetcher during a
real sync revealed `ConnectError: [Errno 104] Connection reset by peer`
on a different page each run. With this PR, the first failed run's log
would have said that.

This lets applications tell users which pages to check, and set their
own tolerance. One example: treat a run where a handful of thousands of
pages failed as a success that needs a retry. Agno's own
`status="partial"` semantics, and pruning only after a clean run, are
unchanged.

## Type of change

- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [x] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [x] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [ ] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

- Tests: `tests/integration/knowledge/test_page_storage.py` now asserts
`failed_paths` for three cases: a publication failure after the vector
write (plus its logged path and cause), a failed first sync and its
clean retry (which reports none), and a failed prune deletion. A unit
test formats the exact `SyncFailed <- ConnectError` chain from
production. Against PostgreSQL 18 + pgvector, together with
`test_page_contract.py` and `test_per_page_content_rows.py`: 234 passed.
- `ruff format --check` is clean on the changed files; `format.sh` was
not applied because it reformats unrelated files on current `main`.
`./scripts/validate.sh`: ruff passes, and mypy has the same 54
pre-existing errors as `main`, none in these files.
- Cookbook: `27_public_pages/README.md` describes reading a partial
report. TEST_LOG has an entry.
- Independent of agno-agi/agno#10726 (discovery and MDX fixes); the two
touch different files.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 14:29:41 +05:30
KaustubhandSannya Singal ab1d6007f0 feat: Release v3.1.0 (#10702)
## New Features

- **User Management / Authorization (RBAC):** a
new `agno.os.authz` package adds role-based access control to AgentOS —
a role store, scope policy, audit log, user directory and an admin
router, with pluggable authorization engines (a native engine plus a
fine-grained `fga` engine). Scopes and auth middleware were extended to
enforce it.
- **AgentOS Filesystem:** a new `agno.fs` filesystem (`DbFileSystem`)
with dedicated `/filesystem` routes (list/read/manage files) backed by
an `agno_fs` table.
- **MCP config / auth updates:** refreshed MCP server config and
built-in MCP auth handling.
- **AIMLAPITools:** new toolkit for image, video, speech and
transcription via the AI/ML API.

## ⚠️ Breaking Changes

- Filesystem table re-key (`agno_fs`): v3.1 keys the filesystem table
by `(namespace, user_id, path)`, where `user_id` is the user partition
(`""` for the shared/no-user partition). A table created by an earlier
release is refused with `SchemaOutdatedError` until upgraded — the
re-key never runs automatically. Upgrade once, with the application
stopped, using the script for your database (#10530):

This affects you only if you use `DbFileSystem`. The table is managed
by `DbFileSystem` (its own schema, buildable from a bare `db_url`), so
it is deliberately not part of `MigrationManager`.
    
```python
python libs/agno/migrations/migrate_filesystem_postgres.py   # PostgreSQL
python libs/agno/migrations/migrate_filesystem_sqlite.py     # SQLite
 ```
    

## Bug Fixes

- **HITL:** continuing a paused run with `background` + `stream` no longer duplicates the paused run in its own history.
- **OpenAIResponses:** recover requests when the chained (previous) response is gone.
- **DB:** an async delete that *failed* is no longer reported as a run that *wasn't there* — the two cases are distinguished.
- **Structured output:** don't crash when a JSON fragment provides a scalar before a list.
- **Python tool results:** distinguish `None`valued results from missing variables.
- **Chunking:** reject an overlap that is not smaller than the chunk size.
- **CSV readers:** handle CR (`\r`) record endings.
- **CsvTools:** honor explicit zero row limits.
- **Shell / workspace command tools:** honor an explicit zero `tail`.
- **GeminiTools:** store raw video bytes in the `generate_video` artifact.
- **GithubTools:** build branch links from the repository web URL (#10509); `get_file_content` preserves valid non-ASCII UTF-8 text
- **LocalFileSystemTools:** read and write local files as UTF-8.
- **PubMed:** preserve metadata for short abstracts.
- **Claude citations:** preserve nullable document titles.

Co-authored-by: Sannya Singal <[email protected]>
v3.1.0
2026-10-01 14:58:11 +05:30
657c9f70e9 feat: make MCPConfig default and lifecycle tools opt-in (#10285)
## Summary

Target release: **the next Agno 3.0.x patch release** (exact patch
version TBD).
This changes configuration defaults and requires migration for
integrations that
rely on automatically included tools.

`MCPConfig(tools=[...])` publishes exactly the application's listed
tools. Both
`default_tools` and `lifecycle_tools` default to `False`.
`AgentOS(mcp=True)` and
`MCPConfig(default_tools=True)` retain the complete eight-tool built-in
surface,
including continuation and cancellation.

| Configuration | Tools published |
| --- | --- |
| `MCPConfig(tools=[...])` | Listed tools only |
| `MCPConfig(tools=[...], lifecycle_tools=True)` | Listed tools plus
`continue_run` and `cancel_run` for exposed components |
| `MCPConfig(tools=[...], default_tools=True)` | Listed tools plus all
eight built-ins |
| `mcp=True` | All eight built-ins |

`lifecycle_tools` is additive: `lifecycle_tools=False` does not remove
tools enabled
by `default_tools=True`. Existing tag filters, publication boundaries,
scopes, and
run ownership checks remain in effect. Ordinary session follow-ups do
not need
lifecycle tools.

- Preserve deprecated aliases and provide actionable errors for
configurations with no tools.
- Migrate examples/tests that intentionally enable default or lifecycle
tools; update public MCP validation guidance and migration
documentation.
- Show the MCP endpoint below the OS address in the startup panel when
MCP is enabled, respecting the configured public URL/path and serving
address.
- Clarify stateless behavior: modern MCP requests are already
sessionless; `stateless=True` also disables transport sessions for
legacy clients. Agno conversation/run state remains independent.
- Add full-app HTTP coverage for both protocol eras, authentication,
registration, method-header validation, FastMCP settings inheritance,
streamed progress, and disconnect cancellation. Run this coverage in PR
CI.

Migration examples:

```python
# Publish exactly this tool; neither False flag is required.
mcp = MCPConfig(tools=[product_agent.as_tool(name="ask_product_agent")])

# Custom component integrations using approval continuation/cancellation opt in.
mcp = MCPConfig(tools=[approval_agent.as_tool(name="ask_approval_agent")], lifecycle_tools=True)

# Metadata/transport-only configurations explicitly enable the complete default set.
mcp = MCPConfig(default_tools=True, name="Support", stateless=True)
```

Bare `MCPConfig()` raises: pass `tools=[...]` or set
`default_tools=True`.
`include_tags` does not implicitly enable default tools. If a custom
exposed
component pauses without continuation enabled, its result explains how
to resume
through REST or enable lifecycle tools.

## Type of change

- [ ] Bug fix
- [ ] New feature
- [x] Breaking change
- [x] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [x] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [x] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

Validation of the final lifecycle defaults:

- Minimum supported stack (`mcp==2.1.0`, `fastmcp==4.0.0`): **812
passed, 6 skipped**, across MCP server/auth/exposure/routing/client and
public-authorization suites.
- Newer stack (`mcp==2.1.1`, `fastmcp==4.0.3`): **362 passed**, covering
MCP server/exposure/result/public-configuration and HTTP protocol
regressions.
- Exact tool lists verified across agents, teams, and workflows for both
flags, explicit overrides, and tag filtering. Lifecycle scope,
ownership, cancellation, continuation, and collision tests explicitly
enable the capability they exercise.
- Real Agent execution with SQLite and an offline model verifies that
custom-tool session follow-ups load prior messages without any built-in
tools.
- **Nine cookbook configuration/discovery checks passed** in an isolated
demo environment; cookbook test logs updated.
- Required `./scripts/format.sh`, `./scripts/validate.sh`, and `git diff
--check` passed. Unrelated formatter changes were excluded.

Earlier startup-banner validation covered eight configurations: disabled
MCP,
default endpoint, custom path, public URL, environment host/port, IPv6,
TLS/root-path, and dedicated root host.

The six skipped tests require PostgreSQL. Cookbook checks build apps and
verify
registration; they do not call live models, ingest remote documentation,
or
validate hosted clients. The disconnect test uses a deterministic agent
stream
and real ASGI transport/middleware.

This PR leaves the stateless default and outbound
`MCPTools.protocol_mode`
unchanged; native MCP Tasks and interactive approvals remain separate
work.

---------

Co-authored-by: Sannya Singal <[email protected]>
Co-authored-by: sannya-singal <[email protected]>
2026-10-01 13:35:13 +05:30
+10 6a7b205414 feat: v3.1 (#10530)
- User Management 
- File system page
- Optum Requests 
- MCP config update

---------

Co-authored-by: Sannya Singal <[email protected]>
Co-authored-by: Samuel Jupe <[email protected]>
Co-authored-by: sannya-singal <[email protected]>
Co-authored-by: Harsh <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: Yash Pratap Solanky <[email protected]>
Co-authored-by: ysolanky <[email protected]>
Co-authored-by: Mustafa Esoofally <[email protected]>
Co-authored-by: Harshita Jain <[email protected]>
Co-authored-by: Ashpreet <[email protected]>
Co-authored-by: Harsh Sinha <[email protected]>
Co-authored-by: Monalisha Mishra <[email protected]>
Co-authored-by: Monalisha Mishra <[email protected]>
Co-authored-by: Harsh Sinha <[email protected]>
Co-authored-by: Ayush Jha <[email protected]>
Co-authored-by: Ayush Jha <[email protected]>
Co-authored-by: zocern <[email protected]>
Co-authored-by: 许兆程 <[email protected]>
Co-authored-by: Samuel Jupe <[email protected]>
2026-10-01 12:16:12 +05:30
54468b8371 [fix] Don't crash when a JSON fragment provides a scalar before a list (#10654)
## Summary

`_parse_individual_json()` merges streamed JSON fragments field by
field. For a
list field it only created its accumulator when the key was **absent**,
so an
earlier fragment that supplied a scalar for the same field was extended
instead
of replaced:

```python
AttributeError: 'str' object has no attribute 'extend'
```

An implementation-level exception escaped instead of the helper's
documented
`None`-on-invalid result. Models commonly emit a draft scalar before the
real
list (`{"items": "draft"}` followed by `{"items": ["final"]}`), so this
is easy
to hit with `output_schema` on a list field.

## Root cause

```python
if isinstance(field_value, list):
    if field_name not in merged_data:      # key IS present (bound to a scalar)
        merged_data[field_name] = []
    merged_data[field_name].extend(field_value)   # -> str/int/None/dict.extend
```

The same failure occurs with an earlier `str`, `int`, `None`, `bool` or
`dict`.

## Fix

Re/initialize the accumulator whenever its current value is not a list:

```diff
- if field_name not in merged_data:
+ if not isinstance(merged_data.get(field_name), list):
      merged_data[field_name] = []
  merged_data[field_name].extend(field_value)
```

This discards the incompatible earlier value and keeps the later list,
which
matches the last-value-wins treatment the function already applies to
scalar
fragments. The reverse order (list then scalar) is unchanged: the scalar
still
overwrites, validation fails, and the helper still returns `None`.

## Tests

Added to `libs/agno/tests/unit/utils/test_string.py`:

- `test_parse_individual_json_merges_lists` — existing list+list
behaviour preserved
- `test_parse_individual_json_discards_scalar_before_list` — `str` /
`int` / `null` / `bool` / `dict` before a list (the reported crash)
- `test_parse_individual_json_list_before_scalar_still_fails_softly`
- `test_parse_individual_json_scalar_only_still_fails_softly`

Verification:

- With the fix reverted (`git stash`),
`test_parse_individual_json_discards_scalar_before_list` fails with the
reported `AttributeError`; with it, all pass — the tests are
discriminating, not tautological.
- `pytest libs/agno/tests/unit/utils/test_string.py` → **48 passed**.
- `pytest libs/agno/tests/unit/utils` → the same 6 collection errors
before and after the change (missing optional deps: `openai`,
`google-genai`, `slack_sdk`, `fastapi`), i.e. no regression.
- Coverage of `agno/utils/string.py`: the new branch is fully covered;
no previously covered line becomes uncovered.
- `ruff format --check` clean; `ruff check` reports 21 diagnostics both
before and after (all pre-existing) — I used `list[str]` rather than
`List[str]` so as not to add a new one.

## Note on overlap

`avinash-git-24` commented on the issue shortly before this PR was
opened with
the same diagnosis and fix, and asked whether they could take it. No PR
existed
at the time of opening and the issue is unassigned. I have replied on
the issue
as well: if they would rather submit it themselves, I am happy to close
this one
rather than leave two duplicate patches in the queue.

## AI disclosure

This contribution was produced with AI assistance (ZCode / GLM, run
through the
WorkBuddy agent harness). The author reviewed the diagnosis, wrote and
ran the
reproduction, chose the fix over alternatives, and verified the tests
fail
before the change and pass after it.

Fixes #10649

---------

Co-authored-by: inchang-ing <[email protected]>
Co-authored-by: Sannya Singal <[email protected]>
2026-10-01 11:18:52 +05:30
Eric DeloreficeandKaustubh 13d3c9469c [fix] db: an async delete that failed is not a run that was not there (#10682)
Closes #10685

## Problem

`AsyncPostgresDb.delete_run` and `AsyncMySQLDb.delete_run` return
`False` when the delete fails. `False` already means something else here
- the docstring says *"True if the run was deleted, False otherwise"*,
and it comes from `result.rowcount > 0`. So a dropped connection, a
deadlock or a timeout reaches the caller as "there was no such run", and
the run is still in the table.

`delete_runs` in both files has the same handler without even the
`return`, so it logs and falls out of the function.

This is not an async convention. Across `agno/db`, 13 of 17 backends
re-raise from these handlers, and that includes `AsyncSqliteDb` and
`AsyncMongoDb` - two async backends that get it right. The two files
here disagree with **their own sync twins**:

| backend | sync `delete_run` | async `delete_run` |
|---|---|---|
| sqlite | raise | raise |
| mongo | raise | raise |
| postgres | raise | **return False** |
| mysql | raise | **return False** |

`postgres.py:1234` and `mysql.py:841` both end `log_error(...)` with
`raise e`. The async copies kept the log line and dropped the raise.

## Changes

Four lines. `raise e` in `delete_run` and `delete_runs` in
`async_postgres.py` and `async_mysql.py`, matching the sync twin in each
case exactly, including the `raise e` form the rest of the file uses.

Nothing else is touched. `dynamo` and `surrealdb` swallow here too, but
they have no sync twin to contradict them, so whether that is deliberate
is a question for whoever wrote them rather than something to change in
this PR.

## Testing

Six unit tests, no database needed. The existing
`test_async_postgres.py` fixture pattern is reused, and
`test_async_mysql.py` is new because there was no unit test file for
that backend.

Against unmodified `main` four fail and two pass. The two that pass are
the point: they pin that `delete_run` still returns `False` when there
genuinely is no such run, so the change cannot quietly turn a missing
run into an exception.

```
11 passed                        # the two files
642 -> 642, 382 -> 382           # ruff findings before and after, unchanged
4 files already formatted        # ruff format --check
```

The nine collection errors in `tests/unit/db/` are missing optional
backend drivers. They are identical on a clean tree, so they are not
from this change.



### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [ ] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

On that last box, since guideline 5 asks for it: I have left it unticked
because "entirely AI-generated" is not accurate, and I would rather say
what this is than tick or skip the box. The patch was written with AI
assistance (Claude Code). The author reviewed it, ran everything quoted
above, and takes responsibility for it, and the tests fail on `main` and
pass here.

---------

Co-authored-by: Kaustubh <[email protected]>
2026-10-01 00:47:04 +05:30
eecade71ff fix: HITL continue with background and stream duplicates the paused run in its own history (#10597)
## Summary

Fixes #10086. Supersedes #10088 by @rodrigocoliveira, who found the root
cause and wrote the continue wiring tests adapted here. Thanks!

**This is a major fix. Human-in-the-loop (HITL) does not work in AgentOS
with its default settings.** The AgentOS UI sends `background=true` and
`stream=true` on every run and continue: background mode is on by
default for AgentOS above 2.6.0 when the agent or team has a database.
With that setting, continuing a paused run fails for any agent or team
that has `add_history_to_context=True` and uses a model that replays the
full conversation, such as `OpenAIChat`:

```
An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'.
```

Every HITL type is affected (confirmation, user input, external
execution), for both agents and teams. Retrying doesn't help, because
each background continue produces the same duplicate.

### Root cause

A background continue saves the run as `PENDING`, then `RUNNING`, before
it rebuilds the run's messages, so the run shows as in progress to
anyone polling it. History then excludes runs only by status (`PAUSED`,
`CANCELLED`, `ERROR`, `REGENERATED`), and the continued run no longer
reads `PAUSED`, so it comes back as history. The model receives the
paused turn twice: once from history, with no tool result, and once as
the current turn. Chat Completions rejects the unanswered copy.

### Fix

- **Continue history:** `continue_history_session()` in
`session/_utils.py` returns a copy of the session without the continued
run, matched by run id whatever the stored status. It runs before
`num_history_runs` and `num_history_messages` are applied, so earlier
turns in the session are kept. The original session object, including a
cached one, is not changed.
- **Wiring:** the Agent and Team continue message builders receive
`run_response` at all 12 continue call sites (3 on Agent, 9 on Team).

## Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [ ] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`): ran `ruff format`, `ruff check` and `mypy` on
the changed files, which were clean apart from 4 existing mypy errors in
files this PR doesn't touch.
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [ ] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [x] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [x] If a similar PR exists, I have explained below why this PR is a
better approach
- [ ] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

### Relation to #10088

#10088 fixes the same bug by excluding the continued run by id, and its
root-cause write-up is correct. This PR keeps that approach and differs
in two ways:
1. It keeps `exclude_run_ids` off the public `get_messages()` signature.
2. The change is much smaller.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Harsh <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Harsh Sinha <[email protected]>
2026-09-30 17:41:27 +05:30
4db4180ad9 [fix] Handle CR record endings in CSV readers (#10657)
## Summary

Fix CSV ingestion silently returning no documents for CR-delimited
records in `CSVReader` and `FieldLabeledCSVReader`.

Both readers already open synchronous path inputs with `newline=""`, but
their in-memory wrappers use the default `StringIO` newline mode. With
CR-only record separators, `csv.reader` raises `new-line character seen
in unquoted field`; the readers catch it and return `[]`. This affects
binary/text uploads in both modes and path inputs in async mode.

Use `newline=""` for the six `StringIO` wrappers, matching the existing
path handling. This recognizes LF, CRLF, and CR record boundaries while
preserving quoted multiline cells for the existing cell normalization.

Minimal reproduction (no network, model, or database needed):

```python
import asyncio
from io import BytesIO
from agno.knowledge.reader.csv_reader import CSVReader

payload = b"name,city\rAlice,Paris\rBob,Rome\r"
reader = CSVReader(chunk=False)
print(reader.read(BytesIO(payload)))
print(asyncio.run(reader.async_read(BytesIO(payload))))
```

Before: both calls log a CSV error and return `[]`. After: each returns
a document containing all three rows.

## Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [ ] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [x] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

Generated by Codex with the account holder's authorization. Codex
performed the diff review, reproduction, and checks described here. No
claim of independent human code review is made.

Regression coverage includes both readers, sync/async calls,
path/bytes/text inputs, LF/CRLF/CR separators, quoted multiline cells,
small and paginated inputs, row order, names, and caller-owned stream
lifetime. On upstream `476f61b000ef15a77ab3a6851b2c6cefbc7571c9`, the
new test matrix reports **20 failed, 52 passed**. After the fix, it
reports **72 passed**; combined with both existing CSV reader suites,
**171 passed**.

Validation on macOS ARM64, Python 3.12.14, pytest 9.1.1, Ruff 0.15.20,
mypy 2.1.0, SQLAlchemy 2.0.54:

```text
pytest libs/agno/tests/unit/reader/test_csv_newlines.py libs/agno/tests/unit/reader/test_csv_reader.py libs/agno/tests/unit/reader/test_csv_field_label_reader.py -q
# 171 passed
./scripts/format.sh
# passed; no unrelated formatting changes
./scripts/validate.sh
# passed: Agno (1062 source files), agnoctl (21), cookbook lint and pattern checks
git diff --check
# passed
```

The initial development dependency resolution installed SQLAlchemy
2.1.1, under which full validation reported four type errors in
unchanged database/public-limit modules. Validation passes with
SQLAlchemy 2.0.54, within the project's declared dependency range. This
PR does not alter dependency constraints or those modules. The entire
repository test suite and live integrations were not run.

No new public API or cookbook is needed; the regression test documents
the expected newline behavior.

Co-authored-by: lakers-abing <[email protected]>
Co-authored-by: Sannya Singal <[email protected]>
2026-09-30 11:06:40 +05:30
澍andKaustubh c0642f495c [fix] Distinguish None-valued Python tool results from missing variables (#10637)
## Summary

Fixes #10636.

PythonTools uses `namespace.get(variable_to_return)` to distinguish a
result from a missing name. A defined variable with value `None` is
consequently reported as missing after successful execution.

Check namespace membership before retrieving the value in
`run_python_code`, `save_to_file_and_run` and
`run_python_file_return_variable`. Preserve the existing error for
absent names and the string conversion of returned values.

## Type of change

- [x] Bug fix

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`) — limitations below
- [x] Self-review completed by Codex; no human review is claimed
- [ ] Documentation updated (existing return-value contract is
unchanged)
- [ ] Examples and guides updated (not applicable)
- [x] Tested in clean environment
- [x] Tests added/updated

### Duplicate and AI-Generated PR Check

- [x] Searched existing open pull requests and found no matching fix
- [ ] Similar PR explanation (not applicable)
- [x] This PR was entirely AI-generated

## Verification

Windows, Python 3.12.14, editable `agno[dev]` and `agnoctl[dev]` in an
isolated virtual environment.

- `test_python_tools.py` with new tests against unchanged main: **3
failed, 40 passed**. All three failures are the defined-None case, one
for each execution helper.
- With the fix: **43 passed**. New coverage includes `None`, `False`,
`0`, empty strings/lists/dicts and genuinely missing names across all
three helpers. Tests execute real local Python code and temporary files
without a model service.
- `scripts/format.sh`: passed; no unrelated changes.
- `scripts/validate.sh`: Ruff checks and agnoctl mypy pass. Agno mypy
has the same four upstream errors previously reproduced on unchanged
main: `_limits.py:132`, `db/sqlite/async_sqlite.py:2469`,
`db/postgres/async_postgres.py:2179`, `db/mysql/async_mysql.py:2096`.
None of these files is changed.
- The cookbook check's `python3` invocation is unavailable in the
Windows shell. The equivalent command using `.venv/Scripts/python.exe`
passes (13 files, 0 violations).
- `git diff --check`: passed.

```sh
.venv/Scripts/python.exe -m pytest libs/agno/tests/unit/tools/test_python_tools.py -q
```

Full-suite and live-provider end-to-end tests were not run; CI status is
separate from these local checks.

## Additional Notes

Based directly on upstream main, independent of the other submitted
fixes. Implementation and tests were generated, run and self-reviewed by
Codex under the account owner's authorization.

Signed-off-by: icearia0219 <[email protected]>
Co-authored-by: Kaustubh <[email protected]>
2026-09-29 19:08:24 +05:30
476f61b000 [fix] Preserve PubMed metadata for short abstracts (#10640)
## Summary

Fixes #10639.

`PubmedTools.search_pubmed()` currently omits `Title` and `Published`
from its default concise output whenever an abstract has 200 characters
or fewer. This also affects articles without an abstract. Restrict the
conditional expression to summary truncation so the metadata is always
included, while preserving the existing 200-character cutoff and
expanded output.

Add regression coverage for short abstracts, exactly 200 characters, 201
characters, absent abstracts, and expanded results. The tests mock HTTP
responses and exercise the real XML parsing and JSON output paths.

## Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [ ] Documentation updated (comments, docstrings)
- [ ] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [x] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [ ] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

AI wrote the code, and I reviewed it line by line.

---

## Additional Notes

Validation on macOS / Python 3.12.13:

- Before the fix, `python -m pytest
libs/agno/tests/unit/tools/test_pubmed.py -q`: **3 failed, 9 passed**.
The three failures reproduced missing title/year for short, exactly
200-character, and absent abstracts.
- After the fix, the same command: **12 passed**.
- Repeated the focused module in a newly created virtual environment
with editable local `agno`/`agnoctl` packages and the required pytest
plugins: **12 passed**.
- `./scripts/format.sh`: passed, with no additional file changes.
- `./scripts/validate.sh`: passed, including Ruff, mypy, and cookbook
pattern checks.
- `git diff --check`: passed.
- `./scripts/test.sh` was attempted in the development environment but
stopped during collection with **124 errors and 30 skips** because
optional SDKs/dependencies were unavailable (including Google API
clients, NumPy, Beautiful Soup, Anthropic, and vector-database
adapters). The full suite did not run to completion; no full-suite pass
is claimed.

No new public API or usage pattern is introduced, so documentation and
cookbook changes are not applicable. Existing result-limit and
XML-parser PRs address separate behavior; this change is confined to
concise result formatting and its tests.

Co-authored-by: lily <[email protected]>
Co-authored-by: Sannya Singal <[email protected]>
2026-09-28 19:19:22 +05:30
Bas NijholtandSannya Singal c2d8fd0070 [fix] Preserve nullable document titles in Claude citations (#10147)
## Summary

Claude can return a document citation with `document_title: null`. Agno
serializes the text block with `model_dump(exclude_none=True)`, which
removes that key. On the next turn, replaying the saved assistant
message fails with HTTP 400: `page_location.document_title: Field
required`.

The shared content-block coercion helper now restores a missing
`document_title` as `None` for page, character and content-block
document citations. This covers response capture and replay, including
histories already saved without the field and list-shaped assistant
content. Existing titles are kept, and normalization copies the affected
dictionaries so formatting a request does not change stored history.

The production change is confined to one helper. Signed thinking blocks,
block order, web citations and omission of unrelated null fields keep
their existing behavior.

## Where this appeared

We hit this in production with Agno 3.0.9 while running MindRoom: two
follow-up replies were rejected because a saved page citation lacked
`document_title`.

The regression reproduces the field loss with the real Anthropic SDK and
Agno's response parsers, then checks the stored representation and the
next request. No live provider request was needed. Anthropic's request
types declare `document_title` as `Required[Optional[str]]` for
[page](https://github.com/anthropics/anthropic-sdk-python/blob/main/src/anthropic/types/citation_page_location_param.py),
[character](https://github.com/anthropics/anthropic-sdk-python/blob/main/src/anthropic/types/citation_char_location_param.py)
and
[content-block](https://github.com/anthropics/anthropic-sdk-python/blob/main/src/anthropic/types/citation_content_block_location_param.py)
citations: null is valid, omission is not.

## Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [ ] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [x] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull requests](../../pulls) and
confirmed that no other PR already addresses this issue
- [x] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

Related: #8686 removes output-only citations from server-tool result
blocks. This addresses a different request-schema failure: restoring a
required nullable field inside text-block document citations. The
changes are complementary.

Added 19 regression cases in
`libs/agno/tests/unit/models/anthropic/test_citation_replay.py`. They
cover streaming and non-streaming capture, JSON storage and replay, all
three document-location types, existing titles, old histories in both
supported representations, nonmutation, repeated formatting and web
citations. On unmodified Agno, 12 cases fail and seven preservation
checks pass; with the fix, all 19 pass.

Local validation on Python 3.12 with Anthropic SDK 1.5.0:

- Anthropic model and Claude utility tests: 267 passed, seven skipped.
- Repository format and validation scripts pass with the standard
development dependencies, including Ruff and mypy.
- Independent review reran all 19 regression cases and found no
high-confidence issues.

The provider-enabled test environment additionally reports three mypy
errors in unchanged Anthropic/AWS adapter code; the clean development
environment passes full validation. No unrelated adapter changes are
included.

Drafted, tested and self-reviewed with Codex, then independently
reviewed by a separate coding agent.

Co-authored-by: Sannya Singal <[email protected]>
2026-09-28 13:09:59 +05:30
Sannya SingalandKaustubh 8c3d8ec52b chore: move Docling knowledge tests into their own CI job (#10499)
## Summary

`test-knowledge-1` in Main Validation keeps hitting its 30-minute
`timeout-minutes` and being cancelled, even after #10498 dropped the
IMDB CSV. `test_docling_knowledge.py` is the largest single file in the
job, it converts documents with local layout and OCR models, so it's
slow on its own even when the API is fast.

CI run:
https://github.com/agno-agi/agno/actions/runs/35858299707/attempts/1?pr=10444

New docling CI job run:
https://github.com/agno-agi/agno/actions/runs/35871483384/job/107216425586?pr=10499

## Type of change

- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [ ] Code complies with style guidelines
- [ ] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [ ] Self-review completed
- [ ] Documentation updated (comments, docstrings)
- [ ] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [ ] Tested in clean environment
- [ ] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [ ] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [ ] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

Add any important context (deployment instructions, screenshots,
security considerations, etc.)

---------

Co-authored-by: Kaustubh <[email protected]>
2026-09-26 01:07:04 +05:30
yetugeandKaustubh 087691ecb0 [fix] Honor explicit zero tail in shell and workspace command tools (#10593)
## Summary

Fixes #10592.

`ShellTools.run_shell_command`, `agno.utils.shell.run_shell_command`,
and `Workspace.run_command` / `arun_command` slice command output with
`[-tail:]`. For an explicit `tail=0` that slice becomes `[-0:]` == the
whole list, so asking for zero lines returns **all** output. Guard the
slice with `tail > 0` so `0` returns an empty string, mirroring the
explicit-zero semantics #10492 established for
`CsvTools.read_csv_file(row_limit=0)` (and proposed for these same sites
in closed PR #8987). Positive tails and all error paths keep their
current behavior.

Sites changed:
- `libs/agno/agno/tools/shell.py` (`run_shell_command`)
- `libs/agno/agno/utils/shell.py` (`run_shell_command`)
- `libs/agno/agno/tools/workspace.py` (`run_command` stdout + error
paths, `arun_command` stdout + error paths)

## Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (ruff 0.15.20 check + format on the
touched files)
- [x] Self-review completed
- [ ] Documentation updated (comments, docstrings)
- [ ] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [x] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing open pull requests and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

## Additional Notes

Six new regression/control cases across `test_shell_tools.py` and
`test_workspace.py` (`tail=0` -> empty, positive tail sanity for both
tools): **4 failed on the original code** (both zero-tail tool paths,
the util path, and the async path) and all pass with the fix; the full
`test_shell_tools.py` (6 passed) and `test_workspace.py` suites show an
identical failure set before and after on this machine (3 pre-existing
POSIX-only failures: `pwd`/`printf`-based tests).

---------

Co-authored-by: Kaustubh <[email protected]>
2026-09-26 01:00:34 +05:30
9f697b2db8 fix: store raw video bytes in GeminiTools.generate_video artifact (#10554)
## Summary

`GeminiTools.generate_video` built the returned `Video` artifact with
base64 **text** instead of the raw video bytes
(`libs/agno/agno/tools/models/gemini.py:186`):

```python
content=base64.b64encode(generated_video.video_bytes).decode("utf-8"),
```

`agno.media.Video.content` is declared `Optional[bytes]` ("Raw video
bytes"), so Pydantic coerced that string into the UTF-8 bytes of the
base64 text. Every consumer of `Video.get_content_bytes()` — media
offload uploads, writing the artifact to disk — then received base64
text instead of the video.

This PR passes the raw bytes instead, matching the `generate_image`
branch in the same toolkit and the other video toolkits (`opencv.py`,
`fal.py`, `minimax.py`, `replicate.py`, `wavespeed.py`, `lumalab.py`),
and drops the now-unused `base64` import.

Issue number: #10550

## Type of change

- [x] Bug fix

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [ ] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

### Testing

Commands and results (run from `libs/agno` with the repo `.venv`):

**Regression test on unpatched `main`** (assertion updated to expect raw
bytes — fails before the fix):

```
$ pytest tests/unit/tools/models/test_gemini.py::test_generate_video_success -q
>           assert video_artifact.content == b"fake_video_bytes"
E           AssertionError: assert b'ZmFrZV92aWRlb19ieXRlcw==' == b'fake_video_bytes'
FAILED tests/unit/tools/models/test_gemini.py::test_generate_video_success
1 failed in 0.33s
```

**After the fix:**

```
$ pytest tests/unit/tools/models/test_gemini.py -q
11 passed in 0.40s
```

**Format / validation:**

```
$ ./scripts/format.sh
✓ ruff format — 2481 files left unchanged (agno); all targets pass

$ ./scripts/validate.sh
✓ ruff check — All checks passed! (agno, agnoctl, cookbook)
✗ mypy (agno) — 30 errors in 10 files
```

The mypy errors are pre-existing on unpatched `main`: stashing this
change and re-running produces the identical 30 errors in the same 10
files (`utils/models/_genai_compat.py`, `models/google/gemini.py`,
`knowledge/page/*`, `db/*`, ...). None are in the files changed here.

**Broader sweep** (no regressions): `pytest tests/unit/tools/ -q
--continue-on-collection-errors` reports an identical failure/error set
before and after this change (66 failed / 269 errors both times; the
failures are in unrelated modules and stem from optional dependencies
not installed in the local environment, e.g. `googleapiclient`, `ddgs`,
`mcp`).

---------

Co-authored-by: Sannya Singal <[email protected]>
Co-authored-by: sannya-singal <[email protected]>
2026-09-25 13:43:21 +05:30
0a9043c704 fix: reject an overlap that is not smaller than the chunk size (#10321)
Fixes #10347

## Summary

`DocumentChunking` did not validate its `overlap` argument, while
`FixedSizeChunking` and `RecursiveChunking` raise for `overlap >=
chunk_size`. The invalid configuration was accepted and silently
produced chunks longer than the configured `chunk_size`:

```python
from agno.knowledge.chunking.document import DocumentChunking
from agno.knowledge.document.base import Document

text = ("Para one. " * 20) + "\n\n" + ("Para two. " * 20)
chunks = DocumentChunking(chunk_size=100, overlap=150).chunk(Document(name="d", content=text))
print([len(c.content) for c in chunks])
# before: [206, 206, 206, 161]   chunk_size=100
# after:  ValueError: Invalid parameters: overlap (150) must be less than chunk size (100).
```

The same arguments on `FixedSizeChunking` and `RecursiveChunking` raise
`ValueError`, so the three strategies disagreed on what a valid overlap
is. This is not the oversized-content case tracked in #10187 / #10188:
here the arguments are invalid rather than the text.

(If applicable, issue number: none — no issue was filed for this.)

## Type of change

- [x] Bug fix
- [ ] New feature
- [x] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [x] Code complies with style guidelines
- [x] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [x] Self-review completed
- [x] Documentation updated (comments, docstrings)
- [ ] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [x] Tested in clean environment
- [x] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [x] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [x] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

Disclosure: this PR was prepared with AI assistance; I directed the work
and reviewed the final diff, which only adds the validation the sibling
strategies already perform.

Verification:

- New tests in
`libs/agno/tests/unit/knowledge/chunking/test_overlap_validation.py`
parametrise all three strategies: `overlap` equal to and greater than
`chunk_size` must raise, and the largest valid overlap (`chunk_size -
1`) must still chunk. Removing the check fails the two
`DocumentChunking` cases (`2 failed, 7 passed`), so they pin the bug.
- `pytest
libs/agno/tests/unit/knowledge/chunking/test_overlap_validation.py
test_fixed_size_chunking.py test_recursive_chunking.py` -> `15 passed`.
Some modules of that directory need optional dependencies
(`unstructured`, `chonkie`) and were not collected locally.
- `./scripts/format.sh` and `./scripts/validate.sh` pass for `agno` ruff
check, `agnoctl` (ruff + mypy) and `cookbook`. The `agno` mypy stage
ends with 7 pre-existing `import-not-found` errors in files this PR does
not touch (`knowledge/page/*`, `os/interfaces/whatsapp/router.py`,
`utils/cryptography.py`, `utils/encryption.py`), because the local
environment does not install every optional dependency.

Co-authored-by: sclfcz <[email protected]>
Co-authored-by: Sannya Singal <[email protected]>
2026-09-25 12:00:10 +05:30
025a01f768 [fix] Build GithubTools branch links from repository web URL (#10509)
## Summary

`GithubTools.create_branch()` currently derives its returned branch link
by replacing parts of the Git reference API URL. That works for
`api.github.com`, but an Enterprise API URL such as
`https://github.example.com/api/v3/repos/test-org/test-repo/git/refs/heads/feature/agent`
becomes
`https://github.example.com/api/v3/repos/test-org/test-repo/tree/feature/agent`,
which is not the repository's branch page.

Build the link from `repo.html_url`, the repository's web URL, instead.
The existing GitHub.com test still passes, and a new test covers an
Enterprise API URL and a branch name containing `/`.

Fixes #10510.

## Type of change

- [x] Bug fix

---

## Checklist

- [x] Code complies with style guidelines
- [ ] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`): equivalent targeted Ruff checks and
full-library mypy passed; the wrappers were not run
- [x] Self-review completed
- [ ] Documentation updated: no documentation change is needed for this
return-value fix
- [ ] Examples and guides: no new example is needed
- [ ] Tested in clean environment: tests ran in an existing isolated
virtual environment
- [x] Tests added/updated

### Duplicate and AI-Generated PR Check

- [x] I searched existing open pull requests and found no PR addressing
this Enterprise branch URL issue
- [ ] If a similar PR exists: none found
- [x] This PR was prepared with an AI coding assistant. The complete
diff and regression tests were reviewed and run locally.

---

## Validation

- Before the fix, the new Enterprise regression failed: it returned a
link containing `/api/v3/repos/`.
- After the fix, `pytest .../test_github.py -q`: **32 passed**. The two
changed files were fetched from current `main` (commit `8063a7e`); tests
used a local source snapshot for the remaining package files.
- Ruff check and Ruff format check with the repository's
`pyproject.toml`: passed.
- Full `libs/agno` mypy check on that local source snapshot with the
patched `github.py`: **no issues in 1,061 source files**.

No live GitHub Enterprise instance was needed; the test uses a mocked
PyGithub repository and reference URL.

---------

Co-authored-by: Sannya Singal <[email protected]>
Co-authored-by: sannya-singal <[email protected]>
2026-09-25 11:46:21 +05:30
yetugeandSannya Singal 76cde021e1 [fix] Read and write local files as UTF-8 in LocalFileSystemTools (#10538)
## Description

`LocalFileSystemTools.read_file`/`write_file` used
`Path.read_text()`/`write_text()` without an explicit encoding, so they
default to the **locale** encoding. On a host whose preferred encoding
is not UTF-8 (cp1252, GBK, or ASCII under `LC_ALL=C`), the tool mangles
or rejects valid non-ASCII text:

- `write_file` with non-ASCII content raises `UnicodeEncodeError` →
swallowed by the tool's `except` → the agent gets `Error: Failed to
write file: 'gbk' codec can't encode ...` and the file is never written.
- `read_file` on a UTF-8 file under a non-UTF-8 locale either raises
(`ascii` codec) or returns mojibake.

Same class as the recently merged UTF-8 fixes for the
YAML/Antigravity/Airflow readers and `GithubTools.get_file_content`.

Fixes: none (found by auditing the codebase for text file IO without an
explicit encoding).

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update

## Checklist

- [x] I have read
[CONTRIBUTING.md](https://github.com/agno-agi/agno/blob/main/CONTRIBUTING.md)
and my code follows the project's style
- [x] I have run `make lint` / formatting on the changed files (`ruff
check` + `ruff format --check` clean)
- [x] I have added tests —
`test_read_write_round_trip_non_ascii_under_non_utf8_locale` in
`libs/agno/tests/unit/tools/test_local_file_system.py` drives the
round-trip in a subprocess with a forced non-UTF-8 locale: fails on the
previous head, passes with the change; full file: 28 passed
- [ ] I have updated the documentation (N/A — no public API change; both
methods still accept and return `str`)
- [x] My changes generate no new warnings

## AI Disclosure

This change was prepared with AI assistance and reviewed line by line.
Verification (red/green, lint, 28 passed) was run on this machine with
the local checkout.

Co-authored-by: Sannya Singal <[email protected]>
2026-09-25 11:26:40 +05:30
Sannya Singal 660c0ee8c5 fix: make test_encoded_sse_cannot_bypass_public_error_inspection test more robust (#10533)
## Summary

`test_encoded_sse_cannot_bypass_public_error_inspection` fails
intermittently in CI
https://github.com/agno-agi/agno/actions/runs/35972742169/job/107546006628,
the follow-up request after the first run gets `503` instead of `200`,
caused due to a race in the test setup. Adds a `post_when_slot_free`
helper. It retries a request (up to ~5s) only while the response is
`503` with error code `run_capacity`.


## Type of change

- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Improvement
- [ ] Model update
- [ ] Other:

---

## Checklist

- [ ] Code complies with style guidelines
- [ ] Ran format/validation scripts (`./scripts/format.sh` and
`./scripts/validate.sh`)
- [ ] Self-review completed
- [ ] Documentation updated (comments, docstrings)
- [ ] Examples and guides: Relevant cookbook examples have been included
or updated (if applicable)
- [ ] Tested in clean environment
- [ ] Tests added/updated (if applicable)

### Duplicate and AI-Generated PR Check

- [ ] I have searched existing [open pull
requests](https://github.com/agno-agi/agno/pulls) and confirmed that no
other PR already addresses this issue
- [ ] If a similar PR exists, I have explained below why this PR is a
better approach
- [ ] Check if this PR was entirely AI-generated (by Copilot, Claude
Code, Cursor, etc.)

---

## Additional Notes

Add any important context (deployment instructions, screenshots,
security considerations, etc.)
2026-09-24 23:49:54 +05:30