6184 Commits
Author SHA1 Message Date
9e1f77f52a feat: improve external agent APIs and framework cookbooks (#10926)
## Summary

Make Claude and Codex agents easier to configure and test behind
AgentOS. Ordinary settings no longer require untyped `_kwargs`
dictionaries, and response printers return the final run and raise on
failure so cookbook failures are visible.

- Add typed native options and named tools, permissions, MCP and
configuration fields. Named non-None settings take precedence;
caller-owned configuration is copied. Legacy `_kwargs` aliases warn.
- Group related constructor parameters and make built-in adapters
keyword-only. Expose read-only `agent.sdk`; retain the Python
`framework` compatibility alias and legacy session reads. API/session
metadata emits only `sdk`.
- Give sync/async response printers a shared result/error contract,
tool-event deduplication and persistence warnings. Correct public
streaming and async types, including optional final `RunOutput`.
- Improve Claude/Codex cookbooks in their existing framework folders:
streaming printers, native SDK comparisons, tool fixtures, structured
output, sessions, media and AgentOS HTTP/SSE examples. Include a
reproducible reliability kit and separately pinned historical results.
- Integrate current main, including media, retries, metrics, compaction,
structured output, and #10958/#10962 session-busy/replay changes.
Preserve media on successful, failed and cancelled runs and both
upstream/DX regression coverage.

## Type of change

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

---

## 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 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

### Migration

Use named constructor arguments and select the adapter class instead of
passing `framework=`. Use `options`, `thread_options` and `turn_options`
instead of their deprecated `_kwargs` names. Printers return the
terminal `RunOutput` and raise on errors/cancellation by default; use
`raise_on_error=False` to opt out. Unsupported separate media and native
input objects fail explicitly. Import paths and transcript namespaces
remain unchanged. Agno owns session selection; native lifecycle
overrides that conflict with it are rejected.

### Validation — 11 October 2026

Integrated main `dbdca9ac6d9de6604383e6f4f04653a8244eb3c7` into DX head
`c88e47d54de487f4d14b95c684a4c3e5889e8d0f`. From the normal checkout in
the existing `.venvs/claude-dx-validation` environment:

```bash
source .venvs/claude-dx-validation/bin/activate
python -m pytest libs/agno/tests/unit/agents \
  libs/agno/tests/unit/os/test_external_agent_background_stream.py \
  libs/agno/tests/unit/os/test_schemas.py \
  libs/agno/tests/unit/os/test_db_replay_fallback.py \
  libs/agno/tests/unit/os/test_queue_worker.py \
  libs/agno/tests/unit/run/test_queue_store.py \
  libs/agno/tests/unit/os/test_ws_replay_floor.py -q -o addopts=''
./scripts/format.sh
./scripts/validate.sh
```

620 tests pass, including public typing, media/schema combinations,
retries, busy-session handling, replay and queue contracts. Full
formatting and validation pass (1115 Agno / 21 agnoctl mypy files). No
new live SDK calls were made for this merge refresh. Earlier
live/provider and eight-hour paced-soak results are historical, with
pinned revisions, first failures and limitations retained in the
framework and reliability TEST_LOG files; they are not certification of
this combined revision.

The session-busy check is not an atomic distributed claim. Applications
must serialize same-session submissions across replicas; the cookbooks
state that limit. Native Claude resume and Codex bounded-history
fallback remain distinct. Retries can repeat tool effects. Sandbox
execution and final live release acceptance remain separate work.


### Final review follow-up — 11 October 2026

Final revision: `1cc091cc4c`. Found and fixed an interaction between
native options
and media: attachments now use the effective SDK workspace (including
Claude
native/legacy options and Codex thread/turn overrides) and absolute
paths for
relative working directories. Cleanup and recorded upload roots use the
same
workspace. Fifteen targeted cases failed before the fix; all 20 cases
pass after.

Expanded local validation:

```bash
python -m pytest libs/agno/tests/unit/agents libs/agno/tests/unit/os \
  libs/agno/tests/unit/run -q -o addopts=''
python -m pytest libs/agno/tests/unit/os/test_public_json_bounds.py -q -o addopts=''
```

4,484 unique tests pass across these runs; 23 cases skip. The broad run
passed
4,467 cases; 17 HTTP cases initially hit the execution sandbox's
loopback-bind
restriction, then passed when the full 29-case HTTP file was rerun with
loopback
access. An initial optional Telegram import failure was resolved in the
isolated
validation environment. Required full format/validation passes. No new
live
provider calls or soak; final-commit CI is a separate merge gate.

---------

Co-authored-by: kausmeows <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
Co-authored-by: Yash Pratap Solanky <[email protected]>
2026-10-11 13:00:57 +01:00
KaustubhandYash Pratap Solanky dbdca9ac6d fix: harness soak follow-ups: tool errors, resume status, one turn per session (#10958)
## Summary

Follow-ups from the external agent soak report, each reproduced live and
fixed. The three items that fit in one PR are here; the related
event-replay item is covered by #10957 (see below).

### Codex tool failures carry `tool_call_error`

A failing MCP tool stored its error text with `tool_call_error=null`,
and so did a shell command that exited non-zero. Codex marks both items
`status=failed`; an MCP server that returns its error as a text result
(`isError` on the MCP side) arrives with `error=null`, which is why the
result text was right and the flag was not. The adapter now sets
`tool_call_error` from the item status (`failed`, `declined`), the
command exit code and the MCP error, on the streamed path, the
non-streamed path and the per-item capture used for retries. Successful
tools carry `False`.

### `/resume` on a run submitted with `stream=false`

Resume on a still-running background run submitted with `stream=false`
answered `"Run completed but no events stored."` with status `RUNNING`.
A `stream=false` run never publishes to the event stream and stores no
events, so there is nothing to attach to later. The database fallback
now sends one `replay` frame that states the run's real status and,
while it is pending or running, says to poll the run endpoint; a
finished run says it stored no events. One helper serves the agent, team
and workflow resume routes and the workflow websocket. The endpoint
descriptions and both harness READMEs document the restriction: choose
`stream=true` at submission when live or replayed events are needed.
Making a non-streamed background run streamable afterwards would mean
the worker publishing every job, which is a queue change and is not
attempted here.

### One turn per session

Codex accepted a second turn on a session whose first turn was still
running and failed it on the app-server's thread lock (`already has an
active writer`), leaving an error run in the session; Claude would have
appended two turns to one transcript. The harness keeps one conversation
per session on its side, so a session now takes one turn at a time:

- A second turn is refused before anything runs with `SessionBusyError`
(an `AgnoError` with status 409 and `error_type: session_busy`), naming
the run in flight. No run row is written for the refused turn and the
SDK is not called.
- The check covers the turns this process is running and, when a
database is configured, the session's pending and running run rows, so a
turn accepted on another replica counts. A background submission is
checked before its pending row is written, so the caller gets the 409 at
submission.
- Codex's own "already has an active writer" rejection maps to the same
error, as the backstop for a turn that slips past the check on another
replica; with `retries` set, that turn waits out the backoff and resumes
the thread once it is free. The compaction guard reuses the same
in-flight lookup.
- AgentOS answers 409 on the non-streamed and background routes and a
`RunError` event with `error_type: session_busy` on a stream. The
streamer's generic error frame now carries the error's type and id.
- A run row left pending or running by a crash keeps its session busy
until that run is cancelled; the READMEs say so. A queued turn on a busy
session is re-driven after the queue's retry delay when `max_attempts`
allows it, which serializes it behind the turn in flight.

### Event replay after Redis retention expires

The first part of this item, persisting replayable events for external
agents at all, is fixed in #10957 (now on main and merged into this
branch): streamed events are stored on the run row and AgentOS turns
that on, so `/resume` can replay a finished run from the database once
the stream has forgotten it. Verifying that against a Redis event stream
with a 6-second TTL found one more defect, fixed here: the terminal
event is persisted before the event stream stamps its index, so it
replayed at its position (5) after events stamped 8 to 10, sending the
client backwards. Now the run row is written again once the terminal
event carries its stream index (`_arewrite_run_row`, a plain row upsert
that does not re-add session metrics), and `stored_event_replay_dicts`
never lets an unstamped stored event replay behind a stamped one. With
that, the Redis check passes: keys expired, resume with
`last_event_index`, ordered catch-up from the database ending with
`RunCompleted` at its real index.

## 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)
- [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

**Verified live** (claude-sonnet-4-6; openai-codex 0.161.0 with
gpt-5.6-luna):
- Tool errors: `cat` on a missing file and a stdio MCP server whose tool
raises, streamed and non-streamed. Before: `tool_call_error=None` in all
four cases. After: `True` in all four, result text unchanged.
- Resume status: a Claude run submitted with `background=true,
stream=false` against a real uvicorn server. Before: `"Run completed but
no events stored."` with status `RUNNING`. After: `"Run is RUNNING and
was submitted without streaming ... Poll the run endpoint for its
result."`, and after completion `"Run COMPLETED with no stored events:
..."`.
- Redis replay: a background streamed Claude run through AgentOS on a
Redis event stream with a 6s TTL; after the keys expired, resume with
the floor at `ToolCallStarted` replayed the later events and
`RunCompleted` from the database in order with their real indices.
- One turn per session: two concurrent turns on one session, the first
running a 6-second shell command, on Codex and on Claude. Before
(Codex): the second turn ended as an ERROR run with the app-server's
active-writer message after 0.1s. After: the second turn raises
`SessionBusyError` in under a millisecond, no row is written, the first
turn completes, and a turn sent afterwards runs normally.

**Unit tests:** failed MCP calls with the error on the item or only in
the result text, on both paths (`test_codex.py`); the resume frame for a
running and for a completed run without events
(`test_db_replay_fallback.py`); a busy session refused before the
adapter on both paths, a failed turn releasing the session, a pending
row from another replica blocking until cancelled with no pending row
written for the refused background submission, AgentOS 409 and the typed
`RunError` event (`test_external_session_busy.py`); a stored terminal
event carrying its stream index after a queued run, and an unstamped
event after stamped ones keeping the order on replay
(`test_external_store_events.py`, `test_db_replay_fallback.py`); the
Codex active-writer rejection as `session_busy`, retryable, with the
thread kept, and serialization by retry (`test_codex.py`). Agents suite
plus replay and queue-worker tests: 408 passed after merging main; OS
suite 3394 passed (the 42 errors are the telegram module's missing
optional dependency in the local venv).

**Soak items not in this PR:** tool history across retries, queue
retries honoring permanent errors, retry limits docs and streaming retry
semantics are on main via #10937. Claude authentication refresh across
replicas is deferred. Release-candidate acceptance is next.

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

---------

Co-authored-by: Yash Pratap Solanky <[email protected]>
2026-10-11 15:19:16 +05:30
107f535248 feat: output_schema for external agents (Claude, Codex) (#10965)
## Summary

`ClaudeAgent` had no `output_schema` at all, and `CodexAgent` took only
a raw JSON Schema dict and returned the JSON as a string. Native agents
take a Pydantic model or a dict, on the agent or per run, and return the
parsed object. This brings the external agents to the same contract,
with the shared part in `BaseExternalAgent`.

Supersedes #8957 for Claude and Codex (see Additional Notes).

```python
class Movie(BaseModel):
    title: str
    year: int

agent = CodexAgent(name="Codex", output_schema=Movie)
run = agent.run("Invent a movie set on Mars.")
run.content        # Movie(title=..., year=...)
run.content_type   # "Movie"

agent.run("Suggest a title.", output_schema={"type": "object", ...})  # per run, content is a dict
```

**Base class**

- `output_schema: Optional[Union[Type[BaseModel], Dict]]` on
`BaseExternalAgent`, plus a per-run `output_schema` kwarg that wins over
the agent's, as on `Agent`.
- A completed run's content is parsed with the same helpers native
agents use (`parse_response_model_str` / `parse_response_dict_str`), and
`content_type` is set on `RunOutput` and on the `RunCompleted` event. An
answer that cannot be parsed stays text and a warning is logged, as on
`Agent`.
- Adapters opt in with `_supports_output_schema`. LangGraph, DSPy and
Antigravity now raise `ValueError` for `output_schema` instead of
silently ignoring it.
- Adapters can hand the base the answer to parse when it is not the
run's text: `ExternalRunResult.structured_output` (non-streaming) or
`ExternalRunStructuredOutputEvent` (streaming, consumed by the base and
not forwarded, like the metrics event). Reset per retry attempt.
- The AgentOS per-request `output_schema` form field already reached
`arun` and was ignored; it now works for external agents.

**Claude**

- The schema is sent as `output_format` (`json_schema`). Claude Code
delivers the object through a `StructuredOutput` tool call and then
replies in prose, so the base parses `ResultMessage.structured_output`,
not the reply. The streamed text and the stored assistant message are
the prose; `RunCompleted` and the stored run carry the object.

**Codex**

- A Pydantic model is converted to a strict schema
(`additionalProperties: false`, every field required) via
`get_response_schema_for_provider(..., "openai")`; a dict is sent as
given, as before.
- When streaming, the text joins Codex's commentary and the final
answer. The adapter tracks the final answer with the SDK's
`final_response` rule (`final_answer` phase, else the last unphased
message) and only that is parsed.
- Removed an unreachable `raise RuntimeError` after the `CodexTurnError`
raise in the stream path.

**Breaking:** `CodexAgent` with a dict `output_schema` now returns a
dict instead of a JSON string, matching `Agent`. Callers doing
`json.loads(run.content)` need to drop it. The cookbook is updated.

## Type of change

- [ ] Bug fix
- [x] 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)
- [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
- [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

**Relation to #8957:** that PR adds `output_schema` to the external base
with native Claude support and a prompt-based fallback for LangGraph and
DSPy. It predates the Codex adapter, takes Pydantic models only, and
conflicts with `main` after the retries, continuation, metrics and
stored-events changes to `_arun_stream`. This PR is a fresh
implementation on the current base for the two harnesses with native
structured output. The prompt-based fallback for LangGraph and DSPy is
not included: those adapters reject `output_schema` here, and the
fallback can follow on top of the same base hooks by setting
`_supports_output_schema` and passing the schema into the prompt. Credit
to @sannya-singal for the original.

**Verified live**

- Claude (claude-sonnet-4-6),
`cookbook/frameworks/claude-agent-sdk/structured_output.py`: a nested
Pydantic schema with Read and Glob tools, non-streaming and streaming,
both returned a parsed `ProjectSummary`; a per-run dict schema returned
a dict.
- Claude through AgentOS: `POST /agents/claude/runs` with an
`output_schema` form field returned `content_type` "Capital" and
`{'city': 'Paris'}` (previously prose).
- Codex (openai-codex 0.161.0, gpt-5.6-luna),
`cookbook/frameworks/codex/codex_structured_output.py`: non-streaming
and streaming returned a parsed `Movie` (Codex accepted the strict
schema); a per-run dict returned a dict. `codex_basic.py` reran
unchanged.

**Unit tests:** Claude: the structured output is parsed instead of the
prose reply on both paths, the `output_format` shape, per-run dict
override, no `output_format` without a schema, an unparseable answer
stays text. Codex: strict schema from a model, non-streaming final
answer, streaming parses the final answer and not the commentary, dict
schema returns a dict on both paths, failed turns keep the error text.
Base: unsupported adapters reject `output_schema` at construction and
per run. 11 of the new tests fail on `main`; the other two are
regression guards. Agents suite passes (the two `test_sdk_conformance`
cases need `claude_agent_sdk` installed and pass in the demo venv).

**validate.sh:** ruff passes; mypy reports errors only in files this PR
does not touch (google, mcp, genai), from the local `.venv`.
`agno/agents` is clean.

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

---------

Co-authored-by: Kaustubh <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-11 14:20:21 +05:30
Kaustubh df4a5861be feat: preserve Claude subagent tool lineage for tracing (#10959)
## Summary

Claude SDK messages include `parent_tool_use_id`, but the adapter
previously discarded it when creating Agno tool events. This prevented
tracing integrations from distinguishing subagent tools from top-level
tools.

- Add optional `parent_tool_call_id` to `ToolExecution`, including
serialization and deserialization.
- Preserve Claude’s parent IDs in streaming tool events and non-streamed
tool records.
- Carry lineage from tool-start events into completion events when
completion messages omit it.
- Preserve the SDK’s tool-error flag in streaming completion events.
- Add regression tests and a cookbook that verifies nested tool spans
persisted in Agno’s tracing database.

With the corresponding OpenInference update, traces can represent:

```text
ClaudeAgent.run
├── Agent
│   ├── Bash
│   └── Read
└── Bash
```

tracing update on openinference-
https://github.com/Arize-ai/openinference/pull/4018

to test locally use `uv pip install -e
../openinference/python/instrumentation/openinference-instrumentation-agno/`

Quick cookbook (not adding in the PR files since the openinference
update needs to be released first, so the cookbook cant go on main yet.-
```python
mport asyncio
from pathlib import Path
from tempfile import TemporaryDirectory
from uuid import uuid4

from claude_agent_sdk import AgentDefinition
from opentelemetry import trace

from agno.agents.claude import ClaudeAgent
from agno.db.sqlite import SqliteDb
from agno.run.agent import RunOutput
from agno.tracing import setup_tracing


async def main() -> None:
    db = SqliteDb(db_file="tmp/claude-nested-traces.db")
    setup_tracing(db=db)
    run_id = str(uuid4())
    with TemporaryDirectory(prefix="agno-trace-probe-") as directory:
        Path(directory, "probe.txt").write_text("nested_read_probe\n")
        agent = ClaudeAgent(
            name="Claude trace probe",
            model="claude-sonnet-4-6",
            db=db,
            cwd=directory,
            allowed_tools=["Agent", "Bash", "Read"],
            max_turns=8,
            max_budget_usd=1.0,
            options_kwargs={
                "agents": {
                    "trace-probe": AgentDefinition(
                        description="Run the tracing verification tools.",
                        prompt=(
                            "Run Bash with `printf nested_shell_probe` once, then Read probe.txt "
                            "in the working directory. Report both outputs. Do not modify files."
                        ),
                        tools=["Bash", "Read"],
                        model="haiku",
                    )
                }
            },
        )
        output = None
        async for event in agent.arun(
            "Use the trace-probe subagent to perform its verification. After it finishes, "
            "run Bash yourself with `printf main_shell_probe` once. Then report the three outputs.",
            stream=True,
            yield_run_output=True,
            run_id=run_id,
            session_id=run_id,
            user_id="tracing-demo",
        ):
            if isinstance(event, RunOutput):
                output = event

    trace.get_tracer_provider().force_flush()
    assert output is not None and output.status.value.lower() == "completed", (
        "Claude run did not complete"
    )
    record = db.get_trace(run_id=run_id)
    assert record is not None, "No trace persisted; check the instrumenter installation"
    spans = db.get_spans(trace_id=record.trace_id)
    root = next(span for span in spans if span.parent_span_id is None)
    tool_spans = {
        span.attributes["tool_call.id"]: span
        for span in spans
        if "tool_call.id" in span.attributes
    }
    nested = [
        span
        for span in tool_spans.values()
        if span.attributes.get("agno.tool.parent_call_id")
    ]
    assert nested, (
        "No nested tools recorded; check both Agno and the instrumenter have lineage support"
    )
    for span in nested:
        parent = tool_spans[span.attributes["agno.tool.parent_call_id"]]
        assert span.parent_span_id == parent.span_id
    assert any(
        span.name == "Bash" and span.parent_span_id == root.span_id
        for span in tool_spans.values()
    )
    assert {"Bash", "Read"}.issubset({span.name for span in nested})
    assert all(span.status_code == "OK" for span in spans)

    def show_tree(parent_id: str | None, depth: int = 0) -> None:
        for span in sorted(spans, key=lambda span: span.start_time):
            if span.parent_span_id == parent_id:
                print("  " * depth + span.name)
                show_tree(span.span_id, depth + 1)

    show_tree(None)
    print("Trace ID:", record.trace_id)
    print(
        "Verified nested tool spans and top-level sibling in tmp/claude-nested-traces.db"
    )


if __name__ == "__main__":
    asyncio.run(asyncio.wait_for(main(), timeout=120))

```
2026-10-10 20:22:14 -04:00
Kaustubh 24bc39f137 feat: store run events for external agents (#10957)
## Summary

External agents (`ClaudeAgent`, `CodexAgent` and every other
`BaseExternalAgent`) did not keep the events of a run. A finished run in
AgentOS showed its content and tools, but
`/agents/{id}/runs/{run_id}/resume` had nothing to replay once the live
event stream no longer held the run, and the run view had no event
timeline. Native agents keep events through `store_events` and
`events_to_skip`; this adds the same to the external base.

Supersedes #7841 (same idea, written against the base class before
retries, continuation and metrics were added, and conflicting with
`main` since).

Before:

<img width="1772" height="166" alt="image"
src="https://github.com/user-attachments/assets/a4ac1909-145d-4e60-9bf5-7f88375e89ec"
/>

After:

<img width="1984" height="538" alt="image"
src="https://github.com/user-attachments/assets/b85c2ad6-60f2-480a-b285-c1f88ef7b60d"
/>

**What changes**

- `BaseExternalAgent` takes `store_events` (default `False`) and
`events_to_skip` (default `[RunEvent.run_content]`), with the same names
and defaults as `Agent`. With `store_events=True`, a streamed run keeps
on `RunOutput.events` the start event, every event the adapter yielded
that is not skipped (tool calls, custom warning events), the `retry`
warning events emitted between attempts, and the terminal event
(`RunCompleted`, `RunError` or `RunCancelled`). Content deltas are
skipped by default because the run already holds the full content.
- The terminal event is built before the run is persisted, so the stored
run ends with it, as native agents do. Non-streamed runs store no
events, as for `Agent`.
- The started tool-call event is no longer rewritten when the tool
completes: the base keeps a copy of the started event's tool for its
merge, so a stored `ToolCallStarted` shows no result and
`ToolCallCompleted` does.
- AgentOS sets `store_events = True` on every external agent it serves,
next to the database injection, which is what it already does for native
agents.
- Stored events keep the `event_index` the event stream stamped on them
when the run went through the queue worker, so a client's
`last_event_index` applies on a replay from the database. Runs streamed
inline through `/runs` are unstamped and replay from position 0, the
same as native agents on that path.

**Cookbooks and docs**

- `cookbook/frameworks/claude-agent-sdk/claude_stored_events.py` and
`cookbook/frameworks/codex/codex_stored_events.py`: a direct streamed
run read back from SQLite with its stored events, then the agent served
through AgentOS in-process, a streamed run over HTTP, the run dropped
from the live event stream, and `/resume` replaying it from the
database.
- README sections on stored events for both harnesses; the
background-runs note that stored-event replay was a follow-up now points
at it.

## 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
- [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

**Relation to #7841:** that PR added the same two fields and collected
events in `_arun_stream`, but `_arun_stream` has since been rewritten
for retries, continuation and metrics, and the branch is 600+ commits
behind `main` with conflicts. This PR is a fresh implementation of the
same idea on the current base; credit to @anuragts for the original.

**Verified live** (claude-sonnet-4-6; openai-codex 0.161.0 with
gpt-5.6-luna): both cookbooks pass. Claude streamed 6 events and stored
4 (RunStarted, ToolCallStarted, ToolCallCompleted, RunCompleted); Codex
streamed 16 and stored 4. In both, AgentOS turned `store_events` on by
itself and `/resume` replayed the four stored events from the database
with status COMPLETED after the live buffer had forgotten the run.

**Unit tests** (`test_external_store_events.py`): off by default; stored
kinds and persistence round-trip without content deltas;
`events_to_skip` honoured; error runs end with `RunError`; a retried run
keeps both attempts' tool events with the `retry` warning between them;
a run cancelled during backoff ends with `RunCancelled`; non-streamed
runs store none; AgentOS sets the flag; `/resume` replays from the
database on the inline path; a queued run stores stamped indices and a
`last_event_index` floor applies on the database replay. Agents suite
plus the replay-fallback and queue-worker tests pass (396).
2026-10-10 23:38:18 +05:30
e39a56fb7e feat: compaction cookbooks for Claude and Codex, CodexAgent.compact (#10924)
## Summary

Native context compaction for the external agent adapters, verified end
to end and documented with runnable cookbooks.

**Claude (`ClaudeAgent`)** needs no library change. `/compact` is an
ordinary run input, and a `PreCompact` hook passed through
`options_kwargs` sees every compaction. With transcript storage on, the
compaction is mirrored into `agno_transcripts` like every other
transcript line: a `compact_boundary` system entry followed by the
`isCompactSummary` user entry. A later turn on any replica resumes from
the database and continues from that summary.

- `cookbook/frameworks/claude-agent-sdk/compaction.py`: seeds a fact,
adds three turns including a long document, sends `/compact`, prints the
two rows the compaction wrote, then resumes the session from a second
agent instance with a different working directory. That instance answers
from the summary.

**Codex (`CodexAgent`)** has no slash command; compaction is an
app-server request. This PR adds `CodexAgent.compact` /
`acompact(session_id, user_id=None, timeout=120.0)`:

- Resolves the Codex thread behind the Agno session (returns `False`
when none exists yet, or when its rollout is gone: the stored id is then
forgotten, as a run would).
- Resumes the thread through the high-level `AsyncCodex.thread_resume`
with the same kwargs a run uses, so the SDK applies its wire translation
(`full-access` to `danger-full-access`, approval mode to approval
policy, camelCase keys). Only `thread/compact/start` goes to the
low-level app-server client behind it, which also owns the global
notification queue. The first version resumed on the low-level client
with the untranslated kwargs; the app-server rejected
`sandbox="full-access"` and silently dropped instructions and approval
mode (review finding from @ysolanky, replicated against 0.161.0).
- Waits for the thread status to go active and then idle on the global
notification queue. The `thread/compacted` notification is routed to a
per-turn queue the caller never registered, so thread status is the
reliable signal; the `thread/compacted` branch is kept as forward
compatibility and commented as such.
- Refuses with `RuntimeError` while a run on the session is pending or
running, since compaction opens its own app-server on the thread and two
processes would append to one rollout. Documented in the docstring and
README.
- Raises the builtin `TimeoutError` if the thread does not go idle
within `timeout` (mapped from `asyncio.TimeoutError` for Python 3.10);
the client is closed either way.
- The sync wrapper uses `run_coroutine_sync`, now a shared helper in
`agents/base.py` that `BaseExternalAgent.run` uses too.
- `cookbook/frameworks/codex/codex_compaction.py`: same scenario,
calling `acompact` and continuing from the summary.

Both READMEs gain a compaction section and both TEST_LOGs record the
live runs.

## 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

**Verification**

- Both cookbooks run live: claude-sonnet-4-6 with SQLite transcript
storage, and gpt-5.6-luna with openai-codex 0.161.0. In both, the seeded
fact was recalled after compaction.
- Claude: 35 transcript rows before `/compact`, 45 after; the hook fired
with trigger `manual`.
- Codex: `acompact` returned `False` before the first run and `True`
after; the thread went idle again in about eight seconds and the rollout
gained a `compacted` entry.
- Unit tests: eight tests in `libs/agno/tests/unit/agents/test_codex.py`
against fake high-level and low-level clients: no thread, resume and
wait sequence, resume kwargs identical to a run's
(`sandbox="full-access"`, instructions, approval mode, no start-only
keys), missing rollout forgets the id, other resume failures propagate,
in-flight run refused, the compacted notification, timeout closing the
client. Agents suite passes.
- Live: an agent with `sandbox="full-access"`, `instructions` and
`approval_mode="deny_all"` compacted and recalled the fact; a
nonexistent thread id returned False and was removed from the session.
- The Claude cookbook reads transcripts through
`db.get_transcript_entries` instead of raw SQL.

**Notes**

- A conversation of one turn returns "Not enough messages to compact";
the cookbooks add turns first.
- The `/compact` run records an empty assistant message in the Agno run
output. Harmless, and a candidate for a small follow-up.
- Automatic compaction (triggered by context pressure) uses the same
path on both sides; only the manual trigger is exercised here.

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

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
Co-authored-by: Yash Pratap Solanky <[email protected]>
2026-10-10 13:05:05 -04:00
Kaustubhandysolanky 7479103782 fix: keep failed attempts' tools and announce retries to streaming consumers (#10937)
## Summary

Follow-up to #10929 (run retries for external agents). This branch
carries that PR's commits plus one more that fixes what the review and a
live test turned up. The diff against `main` is the whole feature; the
section below is what changed on top of #10929.

**What #10929 adds, unchanged here:** `retries`, `delay_between_retries`
and `exponential_backoff` on `BaseExternalAgent` with the same names and
defaults as `Agent`. A failed attempt is retried with the same run id
and resumes the SDK session or Codex thread the failed attempt started.
Adapters classify errors so limits (`max_turns`, `max_budget_usd`, Codex
budget and usage limits) and permanent failures (authentication,
billing, bad requests, context window, policy) are not retried.
Cancelled attempts are never retried, and cancelling during the backoff
ends the run.

### Changes on top of #10929

**Tool calls from failed attempts are kept on both paths.** Live, a
non-streamed retry's stored run had one Bash call while the same
streamed retry had two, although the tool ran twice in both. The Claude
adapter was already leaving a failed attempt's tools in
`run_state["tools"]`, but nothing read them on retry, and Codex raised
before converting a failed turn's items. Now:
- the base collects a failed attempt's tools before retrying and merges
them with the final attempt's, keeping call order and letting the final
attempt's entry win on a repeated id;
- when retries are exhausted, the error run keeps the tools from every
attempt, and a run cancelled mid-attempt or during the retry backoff
keeps the tools that had completed (review finding: both adapters
persisted CANCELLED with zero tools on the non-streaming path);
- the Codex non-streaming path no longer uses the SDK's `handle.run()`,
which returns items only once the turn ends. It consumes the
notification stream itself (`_acollect_turn`, same item and
final-response rules as `run()`) and records each tool call in
`run_state["tools"]` as it completes, so a turn that ends with a failed
status or dies with a transport error mid-turn both leave the work that
was done for the base to keep.

**Streaming consumers are told about a retry.** A streaming client has
already received the failed attempt's output when the retry starts, and
the final `RunCompleted` only carries the retry's content. Before a new
attempt starts, the stream now emits an `ExternalRunWarningEvent` with
`type: "retry"` (attempt, attempts, delay, error, message), placed after
the failed attempt's last event and before the first event of the new
one. The same warning is stored in the run's metadata. Buffering each
attempt until success was the alternative; it would defeat streaming.

**Cookbooks show the retry instead of only setting the fields.**
`claude_retries.py` and `codex_retries.py` inject a transient failure at
the SDK boundary, clearly labelled as a simulation, and run four cases
each: one failure then success (the second attempt resumes the same
session or thread, printed), exhaustion after the configured retries
with 1s then 2s backoff, a `max_turns` limit that is not retried
(Claude) or the same case streamed (Codex), and the default of no
retries. Each case announces the warnings and the ERROR traceback it is
about to produce.

**The job queue honors the agent's error classification.** The queue
worker re-drove any run that ended in ERROR until `max_attempts`,
including errors the agent had refused to retry (Codex `unauthorized`,
Claude authentication, billing and `max_turns`), because external agents
return an ERROR run rather than raising and the queue's
`_is_permanent_failure` only sees exceptions. An error run now carries
`metadata["retryable"]` (from `_is_retryable_error`) and
`metadata["error_type"]`; the worker fails the ticket at once when
`retryable` is False, on both the streaming and non-streaming job paths,
and the streaming path publishes the terminal sentinel instead of
leaving the stream open for a retry that will not come. The
classification survives a worker restart: a worker that crashed after
writing the ERROR row but before settling the ticket leaves the job to
be reclaimed, and the reclaiming worker reads the row's classification
and fails the ticket without re-executing (`_ahonor_non_retryable_row`,
checked only on reclaims, so fresh claims pay no extra read). If that
row read fails, the worker fails closed: it does not execute and leaves
the claim unsettled so the lease goes stale and a later reclaim reads
the row again (review finding: the first version treated a read failure
as retryable and executed).

**Attempt metadata.** A run that was retried stores
`metadata["attempts"]` (the number of attempts made) next to the retry
warnings, so clients and operators can tell a retried run from a clean
one.

**Docs.** READMEs explain the retry settings and the cookbook, plus what
to know before turning retries on: the combined budget with the queue
(`max_attempts × (retries + 1)` SDK invocations, unless the error is
permanent) on top of the SDKs' own transport retries; what streaming
clients should do with output received before the `retry` warning event
(reset the buffered text, keep the tool events, read `attempt`,
`attempts` and `delay` from the event); and that neither a resumed
session nor a queue re-drive after a crash makes tools exactly-once, so
side effects outside the workspace must be idempotent before enabling
either. The Codex TEST_LOG entry that said `NOT RUN` is now a live PASS.

## Type of change

- [x] 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
- [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

**Relation to #10929:** same feature, same three commits, plus
`db97e3a814`. This PR supersedes it; the review comments there
(streaming leak, lost non-streamed tools, `NOT RUN` status, cookbook not
showing a retry) are addressed here. The positional-constructor note is
left to #10926, which makes the adapter constructors keyword-only.

**Live verification** (claude-sonnet-4-6, openai-codex 0.161.0 with
gpt-5.6-luna), with a failure injected after the SDK session or thread
had started:
- Claude, non-streamed and streamed: two attempts, same SDK session id
on both, completed; stored run keeps the Bash call from each attempt
(two calls on both paths, previously one on the non-streamed path).
- Claude, `max_turns=1` with `retries=2`: one attempt, `error_max_turns`
logged as not retryable.
- Codex, non-streamed and streamed: two attempts, same thread id on
both, completed.
- Both: three attempts with 1s then 2s backoff when failures outnumber
retries, run ends in ERROR with the last error; one attempt with the
default `retries=0`.

**Unit tests:** `test_external_retries.py` adds carried tools on success
and on exhaustion, the retry warning event's position and persistence,
retryability recorded on error runs, and a permanent versus transient
failure run through a real `QueueWorker` (streaming and non-streaming);
`test_claude.py` adds a non-streamed retry keeping a tool call and
queued runs for `max_turns`, authentication, billing and an overloaded
error; `test_codex.py` adds a failed turn and a mid-turn exception each
keeping the completed tool call, exhausted retries keeping tools from
every attempt, and queued runs for `unauthorized` and an overloaded
error; the non-streaming fakes now drive the notification stream. A
small `_queue_harness.py` runs one job through the worker against the
in-memory store. Also: a queued run whose agent retried once keeps the
failed attempt's tool exactly once in tools and messages, a retried run
records its attempt count, a worker-restart test where worker one writes
the non-retryable ERROR row and crashes before settling, and worker two
reclaims the ticket and fails it without running the agent again; the
same with one injected row-read failure on the reclaim, where execution
is deferred and the next reclaim fails the ticket; and a cancel during
the retry backoff keeping the completed tool, streaming and
non-streaming. ruff and mypy clean on `agno/agents` and
`os/job_queue.py`.

**Rebase note:** #10925 introduces the same `_acollect_turn` collector
for metrics (it also keeps the first and last token usage report). The
two versions are written to the same shape so whichever merges second
only has to union the usage fields with the tool capture.

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

---------

Co-authored-by: ysolanky <[email protected]>
2026-10-10 12:11:51 -04:00
KaustubhandClaude Fable 5.1 1069000f7c feat: capture ext agent harness metrics (#10925)
## Summary

Before:

<img width="537" height="251" alt="image"
src="https://github.com/user-attachments/assets/d72630df-d7a8-4b64-b49b-c7adee008f5f"
/>

After:

<img width="523" height="532" alt="image"
src="https://github.com/user-attachments/assets/dcb94cbe-6acc-47be-b215-f156099d90e9"
/>


External agents (`ClaudeAgent`, `CodexAgent`) now report run and session
metrics the same way native agents do, so their runs and sessions show
tokens and cost in AgentOS: on the run output, on the streamed
`RunCompleted` event, on the session, in the sessions list token column,
and in the daily `/metrics` aggregation. Before this,
`RunOutput.metrics` was always empty for external agents and their
sessions were invisible to the metrics page.

**Base (`agents/base.py`)**
- `ExternalRunResult.metrics` for non-streaming adapters, and an
`ExternalRunMetricsEvent` that streaming adapters yield; the base
consumes it and does not forward it to clients.
- A per-run timer in the base sets `duration` and, when streaming,
`time_to_first_token`, so every external agent measures these the same
way. Timing is wall clock from the adapter's point of view (it includes
process startup), matching how native agents measure it.
- `_build_metrics(totals, models, additional_metrics)` assembles
`RunMetrics` from a framework's turn totals and its per-model entries.
Adapters only parse their SDK's usage object. Totals are taken from the
framework's own figures rather than summed from the model entries,
because Claude Code counts its helper model separately from the main
conversation.
- `_apersist_run_in_session` accumulates completed runs into
`session_data["session_metrics"]` (same
`SessionMetrics.accumulate_from_run` path as native agents), before the
worker-owned write so background runs are covered. The persistence
fallback re-reads the session, so a failed write cannot double count.

**Claude (`agents/claude/agent.py`)**
- Maps the SDK `ResultMessage`: `usage` for tokens, `total_cost_usd` for
cost, `model_usage` for one `ModelMetrics` per model (Claude Code's
Haiku helper model shows up as its own entry with its own cost),
`num_turns` and `duration_api_ms` into `additional_metrics`.
- Like the native Anthropic model, `input_tokens` excludes the cached
prefix; cached tokens are in `cache_read_tokens` and
`cache_write_tokens`. Cache reads count every API call inside the turn,
so a turn with one tool call reports roughly two reads of the ~15k
harness prefix.

**Codex (`agents/codex/agent.py`)**
- Codex reports usage after every model request as
`thread/tokenUsage/updated`, with `last` (that request) and `total` (the
thread so far). A turn with tool calls makes several requests, so the
turn's usage is the growth of the thread total over the turn: the final
`total` minus the `total` before the turn (the first report's `total`
minus its own `last`). Reading only `last`, or the SDK's
`TurnResult.usage` which is just the latest report, counts a single
request (review finding from @ysolanky, replicated: a two-tool turn
recorded 14972 of 44739).
- The non-streaming path now consumes the notification stream itself
(`_acollect_turn`, same rules as the SDK's `run()` for items and the
final response) so it sees every usage report; the streaming path keeps
the first and last report. A repeated report does not double count. Like
the OpenAI API, `input_tokens` includes the cached prefix. Codex reports
no cost, so cost stays unset.

**Cookbooks**
- `cookbook/frameworks/claude-agent-sdk/metrics.py` and
`cookbook/frameworks/codex/codex_metrics.py`: run metrics for a plain
and a streamed turn, then session totals.
- `cookbook/frameworks/claude-agent-sdk/metrics_agentos.py` and
`cookbook/frameworks/codex/codex_metrics_agentos.py`: serve the agent
through AgentOS; `--verify` drives two runs over HTTP and prints the run
metrics, the session totals, the sessions list column and the `/metrics`
aggregation.
- READMEs explain the conventions (cache token handling, why Claude
cache reads look large) and TEST_LOGs record the live runs.

Builds on the metrics mapping sketched in #9027 for Claude; that PR's
`adapter_state` side-channel is replaced by the result field and event
above, and nothing from it is rebased.

## 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
- [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

**Verified against the SDKs on the same runs.** The raw SDK result was
captured inside the adapter's own runs and compared field by field with
what Agno recorded.
- Claude: input, output, total, cache read, cache write, cost, turn
count, API duration and every per-model entry identical on a plain turn
and on a streamed tool turn. Session cost equals the sum of the SDK cost
figures to seven decimals.
- Codex: input, cached input, output, reasoning and total identical to
the SDK's usage reports on single-request turns, and on a two-tool turn
(three requests) the recorded usage equals the thread total growth on
both paths: streamed 44725 of 44725, non-streamed 43289 of 43289.
Session total equals the SDK's own running thread total, so nothing is
double counted.
- The only intentional difference is `duration`: the SDKs report their
own turn time, Agno reports wall clock including process startup. The
SDK figure is still available for Claude as
`additional_metrics["api_duration"]`.

**Verified in AgentOS** through the test client and the UI: run output
has `metrics`, the streamed `RunCompleted` has `metrics`, `GET
/sessions/{id}` returns the totals with per-model details, `GET
/sessions` shows the token column, and `GET /metrics` counts the runs
and tokens for the day.

**Unit tests**: eleven new tests across `test_claude.py` and
`test_codex.py`, including three-request turns on both paths where
`last` differs from the per-turn delta, a resumed thread with earlier
usage, and a repeated usage report; the non-streaming fakes now drive
the notification stream against the existing fake SDKs: run metrics from
the result message or usage, streamed metrics on `RunCompleted` without
a forwarded event, session accumulation across runs with per-model
details, and duration reported for runs without usage. Agents and run
suites pass; ruff and mypy clean.

**Why the Codex numbers look large**: a one-line question costs ~15k
input tokens because Codex's prefix is its base instructions (~3.5k), a
developer message with skills, permissions and plugin instructions
(~3.1k), environment context, and the schemas for built-in tools,
configured MCP servers and plugins. The metrics report it as billed.
Claude's `total_tokens` looks small by comparison because cache tokens
are kept in their own fields, following the native Anthropic model.

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-10 15:59:56 +05:30
3be277189d feat: continue ClaudeAgent runs from a step (#10917)
## Summary

Adds step replay to `ClaudeAgent` using the same API as native
`Agent.continue_run`. Stacked on #10905
(`feat/harness-agents-phase2-astra`); it needs #10904's database-backed
Claude SDK transcript store.

- **`ClaudeAgent.continue_run` / `acontinue_run`**: accept
`continue_from` (`"end"`, `"last_user"` or a message index), `fork` and
`input`, and support `stream=True` and `background=True`. Continuing
forks the stored SDK transcript at the chosen message
(`fork_session_via_store(..., up_to_message_id=...)`) and resumes the
fork, so the model only remembers what happened up to that point.
- `fork=True` writes a sibling run with `forked_from_run_id` /
`forked_from_message_index`; `fork=False` replaces the source run in
place and keeps its fork lineage.
- `continue_from="last_user"` without `input` replays the whole turn: it
forks just before the prompt (or starts a fresh SDK session for the
session's first turn) and re-sends the original input.
- A mid-turn continue without `input` sends "Continue from where you
left off."
- Later turns in the session continue from the replayed branch, because
the session's SDK pointer moves to the fork.
- **Transcript positions on runs**: the adapter enables the SDK's
`replay-user-messages` so the prompt arrives with its transcript uuid.
It records the SDK session id and uuid for each top-level prompt, tool
call, tool result and final reply in
`Message.provider_data["claude_sdk"]`. Tool results are marked as
checkpoints, so `GET /agents/{id}/runs/{run_id}/checkpoints` lists each
step.
- **AgentOS**: no router changes. `/continue` and `/checkpoints` already
dispatch on `acontinue_run` / `aget_run_output`, so `ClaudeAgent` moves
from 501 to supported. Unsupported options (`regenerate`,
`replace_original`, `additional_instructions`, HITL `tools`) return 400.
- **`BaseExternalAgent`**: generic plumbing only. `ExternalContinuation`
carries the kept transcript into `_arun_non_stream` / `_arun_stream`, a
per-run `run_state` dict reaches the adapter, and an
`_annotate_run_output` hook (no-op by default) lets an adapter attach
per-message data. Other adapters are unaffected: none forwards run
kwargs to its framework.
- **Resume fallback unchanged for normal runs**: the echoed prompt is
not counted as a received message, so #10903's "stored session cannot be
resumed, start fresh with history" fallback still works. Continuations
never fall back; a failed fork resume errors instead of silently
replaying the full history.

## 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

Base is `feat/harness-agents-phase2-astra` (#10905), not `main`. Merge
after #10903, #10904 and #10905.

Limits:
- Only runs recorded with transcript storage (PostgresDb,
AsyncPostgresDb, SqliteDb, AsyncSqliteDb) can be continued; older runs
and other databases raise a clear error.
- Files and other side effects after the checkpoint are not rewound. The
SDK's file checkpointing does not carry over to forked sessions.
- Steps inside subagents (`parent_tool_use_id`) are not checkpoints;
they live in subagent transcripts.
- `regenerate` / `replace_original` and Codex are follow-ups.

Verification:
- Confirmed against the real SDK (0.2.95) that streamed message uuids
equal stored transcript uuids, and that forks cut at a tool result or
before the prompt only remember up to that point.
- Live end-to-end on SQLite: fork from a tool result, replay of a first
and a later turn, in-place streamed continue, follow-up turn on the
branch, and AgentOS `/checkpoints` + `/continue` (non-stream and SSE).
`cookbook/frameworks/claude-agent-sdk/continue_from.py` passes with
`claude-sonnet-4-6`.
- New unit tests against the fake SDK: transcript positions and
checkpoints (stream and non-stream), fork from a tool result, in-place
continue lineage (sync API), turn replay with and without a parent
entry, no fallback on a failed continuation, rejected options, and
prompt echo not blocking the resume fallback. Each guard was
mutation-checked: breaking lineage, allowing fallback, or counting the
echo as received makes a test fail.
- `validate.sh`: no ruff or mypy findings in touched files; the
remaining mypy errors are pre-existing model/MCP SDK typing issues from
local dependency versions. Postgres integration tests were not run
locally.

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

---------

Co-authored-by: Ashpreet <[email protected]>
Co-authored-by: Ashpreet <[email protected]>
Co-authored-by: kausmeows <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-09 21:39:26 -04:00
Ashpreet c15c55ea7d chore: align dev and demo setup environments (#10920)
## Summary

Local development and cookbook work need the same dependencies as demos.
Both Unix setup scripts now install local editable `agnoctl[dev]` and
`agno[demo]`, while keeping `.venv` for daily development and
`.venvs/demo` for dedicated demos.

- Remove `scripts/cookbook_setup.sh`; use `scripts/dev_setup.sh` for
cookbook development.
- Enable strict shell error handling, explicitly target the
environment's Python, and make clipboard failures non-fatal.
- Keep short activation commands relative to the repository root and
update contributor and agent instructions.

## Type of change

- [x] 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)
- [ ] 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
- [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

### Validation

- `bash -n scripts/dev_setup.sh scripts/demo_setup.sh` and `git diff
--check`: passed.
- Isolated shell smoke checks for both retained setup scripts: passed
for successful setup, active-environment rejection, missing `uv`, and
installation failure. Successful runs also exercised paths with spaces,
failing clipboard commands, and preservation of the other environment.
- `uv pip install --dry-run --python .venv/bin/python -e
'libs/agnoctl[dev]' -e 'libs/agno[demo]'`: resolved 235 packages
successfully. This was a dependency-resolution check, not a fresh
install or a live demo run.
- `./scripts/format.sh`: passed. Its unrelated formatting edits were
reverted to keep this PR focused.
- `./scripts/validate.sh`: failed. The latest run reports 32 mypy errors
in 10 unchanged framework files in the current local environment,
including SQL adapters, public limits, Google model compatibility,
Anthropic, and AWS. The earlier run reported six errors. Other checks
passed during the latest run after formatting. Draft pending the
repository validation gate.

No existing development or demo environment was rebuilt. Windows setup
scripts are outside this change. Removing the cookbook script requires
callers to switch to `./scripts/dev_setup.sh`; any existing `agnoenv`
directory is left untouched. Dev setup now installs the larger demo
bundle, including for CI jobs that call it.

Related PR #8020 only quotes variables in an older version of
`dev_setup.sh`. The current base already quotes those installation and
removal paths; this PR addresses dependency parity, separate environment
roles, script removal, and setup error handling.
2026-10-09 22:13:18 +01:00
ff9b86d5be feat: add background lifecycle and cancellation to external agents (#10905)
## Summary

External agents now support durable-queue run IDs, detached background
execution, resumable indexed SSE and cancellation. Polling sees PENDING,
RUNNING and the terminal run. Claude uses a per-run SDK client; Codex
retains turn handles so the owning process can interrupt either SDK
through direct calls or shared cancellation intent.

Native agents and external agents share the background task/slot/SSE
infrastructure. Queue preparation, error/shutdown persistence, retry
fencing and scoped A2A polling/cancellation dispatch to external-agent
helpers. A cancelled row is preserved if its durable ticket remains
queued after a crash, including on adapters without atomic run
primitives.

## Type of change

- [x] Bug fix
- [x] 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)
- [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

Depends on #10904 (transcripts and tool persistence), which depends on
#10903. Merge in that order before this PR. This PR targets main and
currently includes both dependencies. Review the phase 2 changes with
the comparison
`feat/harness-agents-phase1-astra..feat/harness-agents-phase2-astra`.
Refresh the branch onto main after the dependencies land so the final
diff contains only lifecycle changes.

Validation: 736 unit/regression tests, including the native background
tests unchanged; 5 enabled integration cases with no skips. Real Claude
and Codex turns were cancelled after content arrived and stored
CANCELLED. SQLite and AsyncSQLite exercise status fallbacks, retries,
cancellation, SSE replay and user scoping. Guard mutation results are
retained locally. Required formatting and validation passed.

Codex's cookbook explicitly sets `approval_mode="deny_all"`.
Multi-replica cancellation and replay need shared
cancellation/event-stream backends. Redis multi-replica deployment was
not exercised. Database-stored events after the event-stream TTL, team
background factoring, step replay/HITL and workspace persistence are
follow-ups.

SQLAlchemy 2.0.52 matches the demo environment and passes typing; the
unchanged base has six mypy errors with the freshly resolved 2.1.4.


### Review fixes

Worker-owned terminal persistence failures now propagate so durable
tickets retry instead of
completing over RUNNING rows. PostgreSQL writes SDK session metadata and
the fenced run in one
transaction; stale attempts change neither, and session-write failures
roll back the run update.
Missing prepared worker rows fail closed. Async lookups on sync
in-memory SQLite retain the same
connection thread as persistence. Includes the phase 1 warning and CI
dependency fixes.

Fresh validation: 751 unit/regression tests; 12 external-agent
PostgreSQL integration cases;
13 existing native PostgreSQL fencing/status tests (temporary copies
pointed at the disposable
database port); 13 lifecycle/storage mutations killed. Required format
and validation pass.
One pre-existing native AsyncMock warning remains. The SDK
mirror-failure reproduction passed;
live provider cancellation/resume was not rerun for these fixes.

---------

Co-authored-by: ysolanky <[email protected]>
Co-authored-by: kausmeows <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
Co-authored-by: Yash Pratap Solanky <[email protected]>
2026-10-09 18:32:02 +05:30
4dd9695c2d [feat] Add AnakinTools: scrape, crawl, map and search via Anakin.io (#10650)
## Summary

- Adds `AnakinTools`, a `Toolkit` wrapping Anakin's (anakin.io) URL
Scraper, Crawl, Map and Search API as four flag-gated methods
(`scrape_url`, `crawl_website`, `map_website`, `search_web`), following
the `FirecrawlTools`/`TavilyTools` pattern (`enable_*` flags + `all`
override).
- Scrape, crawl and map are asynchronous jobs on the Anakin API: the
tool submits the job (`POST /url-scraper`, `/crawl`, `/map`), then polls
`GET …/{id}` until the job is `completed` or `failed`, so the model
receives the page content or links rather than a job id. `poll_interval`
(default 2 s) and `max_wait_time` (default 120 s) bound the wait; a
failed or timed-out job is returned as an error string. Search is a
single synchronous `POST /search`.
- Uses `httpx` directly (already a core Agno dependency) instead of a
vendor SDK, matching other direct-HTTP toolkits, since the published
Anakin Python package is still alpha.
- Registers an `anakin` extra (empty, `httpx` is core) in
`pyproject.toml` and adds a usage example at
`cookbook/91_tools/anakin_tools.py`.
- Adds 27 unit tests in `libs/agno/tests/unit/tools/test_anakin.py`,
mocking both the job submission and the result polls (completed, failed,
timeout, missing job id, HTTP and transport errors).

closes #10651

## 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

This PR was built with Claude Code, directed and reviewed by me.

Endpoints and auth follow the published Anakin API reference:
`Authorization: Bearer <key>` is accepted by the hosted API, scrape uses
the asynchronous `POST /v1/url-scraper` + `GET /v1/url-scraper/{id}`
pair, and crawl and map use the equivalent `/v1/crawl` and `/v1/map`
pairs. All four tools were verified live against `api.anakin.io` during
review (scrape, crawl and map return completed content; search returns
results).

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

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Kaustubh <[email protected]>
Co-authored-by: Himanshu singh <[email protected]>
2026-10-09 15:54:38 +05:30
178888676e feat: persist external agent transcripts and tool results (#10904)
## Summary

Claude sessions can now resume on another replica using transcripts
persisted through Agno's database. A project-bound SDK session store
supports PostgresDb, AsyncPostgresDb, SqliteDb and AsyncSqliteDb,
preserves append order across processes and ignores repeated entry
UUIDs. Unsupported databases retain local-file resume.

Claude and Codex non-streaming runs retain tool calls and results in run
history. The two-process cookbook proves transcript recovery with an
empty config directory and no Agno run history.

## Type of change

- [x] Bug fix
- [x] 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)
- [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

Depends on #10903 landing first; its session-id persistence is reused.
This PR targets main and currently includes that dependency. Review the
phase 1 changes with the comparison
`8138922536..feat/harness-agents-phase1-astra`. Merge #10903 before this
PR and refresh the branch after it lands.

Validation: SDK store conformance with bound stores per project;
explicit key-rewrite and retry tests; sync/async SQLite and PostgreSQL
coverage; real two-process Claude resume. The final combined
verification passed 736 unit/regression tests and all 5 enabled
integration cases. Guard mutations failed named tests and were restored
from copies.

Development uses Python 3.12, claude-agent-sdk 0.2.148, openai-codex
0.162.0 and SQLAlchemy 2.0.52. SQLAlchemy 2.1.4 exposes six pre-existing
mypy errors on the base. Both required scripts pass with 2.0.52,
matching the demo environment.

`project_key` defaults to the agent id; set a stable tenant-specific key
when sharing a database. The adapter does not set CLAUDE_CONFIG_DIR.
Empty config directories require environment-based authentication,
documented in the cookbook. Workspace persistence and the optional SDK
session-summary API are out of scope.


### Review fixes

Exhausted transcript-mirror retries now surface as
`transcript_persistence_failed` warnings in
`RunOutput.metadata["warnings"]`, persisted history, and streaming
`CustomEvent.warning`. Completed
content is retained; mirror failures do not reexecute model work or tool
side effects. The test extra
installs `claude-agent-sdk>=0.2.148` so SDK conformance runs in CI.

Fresh validation: 78 phase 1 agent tests, SDK parser/batcher fault
injection, a killed mirror-handler
mutation, formatting and type validation. The combined phase 2
regression run also passes.

---------

Co-authored-by: ysolanky <[email protected]>
Co-authored-by: Yash Pratap Solanky <[email protected]>
Co-authored-by: kausmeows <[email protected]>
2026-10-09 13:46:00 +05:30
30dd4f68c1 fix: include tool calls in Codex fallback history prompt (#10909)
## Summary

Follow-up to #10901. When a Codex thread can't be resumed
(`ephemeral=True`, rollout files removed, a new app-server that doesn't
know the thread), `CodexAgent._build_prompt` prepends the persisted chat
history to the prompt for the fresh thread. It only kept messages with
`role in ("user", "assistant")` and non-empty `content`, which dropped:

- assistant messages carrying `tool_calls` (their `content` is `""`)
- `role: "tool"` result messages

So the fresh thread lost every shell command, `apply_patch`, MCP and web
search call from earlier turns. For Codex that's usually most of what
happened, since the final assistant message is often just "Done." This
also went against `BaseExternalAgent._get_history_from_session`, which
includes tool messages so adapters have full context, and against the
DSPy and LangGraph adapters, which both replay them.

Changes in `libs/agno/agno/agents/codex/agent.py`:
- Tool calls are replayed as `assistant called <name>(<arguments>)`.
- Tool results are replayed as `tool result: ...`, each truncated to
`_HISTORY_TOOL_RESULT_MAX_CHARS` (2000) with a `[truncated]` marker, so
large shell output can't blow up the prompt.
- Any assistant text that comes with tool calls is kept.
- Resumed threads are unchanged: the prompt is still sent as-is.

## 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

- New unit test
`test_history_prompt_includes_tool_calls_and_truncated_results` in
`libs/agno/tests/unit/agents/test_codex.py`. It fails on `main` and
passes with this change. All 17 Codex tests pass.
- Ran `ruff format` and `ruff check` (pinned 0.15.20) on `libs/agno`:
clean. `mypy` reports no errors in `agno/agents/codex`. The only errors
are 4 that already exist in unrelated files (`regex`/`dns` stubs,
`team/_run.py`).
- No cookbook change: the behavior only shows up on the non-resumable
fallback path, and the existing `codex_session.py` covers that flow.

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

https://claude.ai/code/session_01BLQYkEigkXEFZrBKMiduYw

---
_Generated by [Claude
Code](https://claude.ai/code/session_01BLQYkEigkXEFZrBKMiduYw)_

## Review follow-up (2026-10-09)

- Merged `main`. `_build_prompt` moved from `CodexAgent` to
`BaseExternalAgent` in #10903, so the tool-call replay now lives in
`libs/agno/agno/agents/base.py` and applies to `ClaudeAgent`'s fallback
too, which had the same gap. `codex/agent.py` is unchanged by this PR.
- Truncated tool results keep their last 400 characters, so a shell exit
status at the end of long output survives (addresses the Codex review
comment).
- The whole replay is bounded at 24000 characters, dropping the oldest
entries first and noting `[earlier history omitted]`, so a long session
cannot grow the fallback prompt without limit.
- Verified live against Codex and Claude: after a tool call whose output
held a fact the assistant text did not repeat, a forced fallback to a
fresh thread or session recalled the fact with this change and answered
NONE on `main`.
- Unit tests: the original test now imports the limits from
`agno.agents.base`; a second test covers the budget. 61 agent tests
pass.

Co-authored-by: Claude <[email protected]>
Co-authored-by: kausmeows <[email protected]>
2026-10-09 13:27:30 +05:30
Sannya Singal b45c8410d5 fix: make partial-tool collision test independent of FastMCP's naming (#10914)
## Summary

FastMCP 4.1.0 broke
`test_exposed_id_colliding_with_fastmcp_derived_name_raises`, CI run:
https://github.com/agno-agi/agno/actions/runs/37889156624/job/113685902995?pr=10903
that failed.
FastMCP 4.1.0 changed the name a `functools.partial` tool is registered
under:
| fastmcp | `Tool.from_function(partial(base_tool, ...)).name` |
|---------|----------------------------------------------------|
| 4.0.11 | `"partial"` |
| 4.1.0 | `"base_tool"` |
The test hard-coded the agent id `"partial"` to force a collision. On
4.1.0 the tool registers as `base_tool`, so nothing collides and the
test fails with `DID NOT RAISE ValueError`.
                                               

## 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-09 12:22:33 +05:30
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