agno-agent-builder 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agno_agent_builder-0.1.1/.gitignore +7 -0
- agno_agent_builder-0.1.1/.python-version +1 -0
- agno_agent_builder-0.1.1/CHANGELOG.md +8 -0
- agno_agent_builder-0.1.1/PKG-INFO +79 -0
- agno_agent_builder-0.1.1/README.md +51 -0
- agno_agent_builder-0.1.1/agno_agent_builder/__init__.py +73 -0
- agno_agent_builder-0.1.1/agno_agent_builder/app.py +166 -0
- agno_agent_builder-0.1.1/agno_agent_builder/builder.py +78 -0
- agno_agent_builder-0.1.1/agno_agent_builder/config.py +44 -0
- agno_agent_builder-0.1.1/agno_agent_builder/db.py +61 -0
- agno_agent_builder-0.1.1/agno_agent_builder/dependencies.py +19 -0
- agno_agent_builder-0.1.1/agno_agent_builder/exceptions.py +112 -0
- agno_agent_builder-0.1.1/agno_agent_builder/health.py +36 -0
- agno_agent_builder-0.1.1/agno_agent_builder/instructions.py +104 -0
- agno_agent_builder-0.1.1/agno_agent_builder/logging.py +60 -0
- agno_agent_builder-0.1.1/agno_agent_builder/middleware.py +121 -0
- agno_agent_builder-0.1.1/agno_agent_builder/py.typed +0 -0
- agno_agent_builder-0.1.1/agno_agent_builder/registry.py +70 -0
- agno_agent_builder-0.1.1/agno_agent_builder/reload_listener.py +71 -0
- agno_agent_builder-0.1.1/agno_agent_builder/schemas.py +31 -0
- agno_agent_builder-0.1.1/agno_agent_builder/sources/__init__.py +15 -0
- agno_agent_builder-0.1.1/agno_agent_builder/sources/base.py +14 -0
- agno_agent_builder-0.1.1/agno_agent_builder/sources/payload.py +128 -0
- agno_agent_builder-0.1.1/agno_agent_builder/sources/types.py +25 -0
- agno_agent_builder-0.1.1/pyproject.toml +43 -0
- agno_agent_builder-0.1.1/tests/__init__.py +0 -0
- agno_agent_builder-0.1.1/tests/conftest.py +9 -0
- agno_agent_builder-0.1.1/tests/test_builder.py +36 -0
- agno_agent_builder-0.1.1/tests/test_db.py +26 -0
- agno_agent_builder-0.1.1/tests/test_exceptions.py +87 -0
- agno_agent_builder-0.1.1/tests/test_payload_source.py +80 -0
- agno_agent_builder-0.1.1/tests/test_reload_listener.py +18 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.1](https://github.com/Zetesis-Labs/PayloadAgents/compare/agno-agent-builder-v0.1.0...agno-agent-builder-v0.1.1) (2026-04-30)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* uv workspace backend + MCP token taxonomies + release-please ([#44](https://github.com/Zetesis-Labs/PayloadAgents/issues/44)) ([5ffdff5](https://github.com/Zetesis-Labs/PayloadAgents/commit/5ffdff5b574026a6a16be52166c1be350c1ad326))
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agno-agent-builder
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Parametrizable Agno-based agent runtime — FastAPI factory with pluggable agent sources, multi-tenant headers, and LISTEN/NOTIFY hot reload.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Zetesis-Labs/PayloadAgents
|
|
6
|
+
Project-URL: Repository, https://github.com/Zetesis-Labs/PayloadAgents
|
|
7
|
+
Project-URL: Issues, https://github.com/Zetesis-Labs/PayloadAgents/issues
|
|
8
|
+
Author: Zetesis Labs
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: agent,agno,fastapi,llm,mcp,rag
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Framework :: FastAPI
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Requires-Dist: agno[anthropic,mcp,openai,os,postgres]>=2.5.16
|
|
21
|
+
Requires-Dist: fastapi>=0.115
|
|
22
|
+
Requires-Dist: httpx>=0.28
|
|
23
|
+
Requires-Dist: psycopg[binary]>=3.2
|
|
24
|
+
Requires-Dist: pydantic>=2.9
|
|
25
|
+
Requires-Dist: structlog>=24.1
|
|
26
|
+
Requires-Dist: uvicorn[standard]>=0.34
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# agno-agent-builder
|
|
30
|
+
|
|
31
|
+
Parametrizable Agno runtime as a library. Build a fully configured FastAPI app
|
|
32
|
+
for chat with hot-reloadable agents fetched from any source you plug in.
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from agno_agent_builder import create_app, RuntimeConfig, PayloadAgentSource
|
|
38
|
+
|
|
39
|
+
app = create_app(
|
|
40
|
+
RuntimeConfig(
|
|
41
|
+
app_name="my-runtime",
|
|
42
|
+
agent_source=PayloadAgentSource(
|
|
43
|
+
base_url="http://payload:3000",
|
|
44
|
+
internal_secret="...",
|
|
45
|
+
),
|
|
46
|
+
mcp_url="http://mcp:3001/mcp",
|
|
47
|
+
database_url="postgresql://user:pass@host:5432/db",
|
|
48
|
+
internal_secret="...",
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Public API
|
|
54
|
+
|
|
55
|
+
| Symbol | Purpose |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `create_app(config)` | Returns a configured FastAPI app |
|
|
58
|
+
| `RuntimeConfig` | Pydantic model — required + optional knobs |
|
|
59
|
+
| `AgentSource` | Protocol — implement `async fetch_agents() -> list[AgentConfig]` |
|
|
60
|
+
| `AgentConfig` | Normalized per-agent record (CMS-agnostic) |
|
|
61
|
+
| `PayloadAgentSource` | Default source for Payload CMS |
|
|
62
|
+
| `build_agent`, `build_model`, `build_mcp_tools` | Lower-level builders for advanced wiring |
|
|
63
|
+
| `compose_instructions`, `DEFAULT_TOOL_PROTOCOL`, `DEFAULT_OUTPUT_FORMAT` | Override-friendly prompt building blocks |
|
|
64
|
+
|
|
65
|
+
## What you get
|
|
66
|
+
|
|
67
|
+
- AgentOS REST surface (`/agents`, `/sessions`, `/metrics`, …)
|
|
68
|
+
- `POST /agents/{slug}/runs` SSE chat
|
|
69
|
+
- `/health`, `/ready` Kubernetes probes
|
|
70
|
+
- `POST /internal/agents/reload` admin endpoint
|
|
71
|
+
- Postgres `LISTEN/NOTIFY` hot reload + 5-min belt-and-braces resync
|
|
72
|
+
- ASGI middlewares: `X-Request-ID`, `X-Internal-Secret` auth, `X-Tenant-Id` → `request.state.metadata`
|
|
73
|
+
|
|
74
|
+
## Reference consumer
|
|
75
|
+
|
|
76
|
+
The default consumer that ships in this repo lives in
|
|
77
|
+
[`../agno-agent`](../agno-agent) — it wraps `create_app` with env-driven
|
|
78
|
+
settings and is what runs in the `agno-agent` devcontainer service. ZP and
|
|
79
|
+
nexus install this lib from PyPI and write their own thin consumer.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# agno-agent-builder
|
|
2
|
+
|
|
3
|
+
Parametrizable Agno runtime as a library. Build a fully configured FastAPI app
|
|
4
|
+
for chat with hot-reloadable agents fetched from any source you plug in.
|
|
5
|
+
|
|
6
|
+
## Quick start
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from agno_agent_builder import create_app, RuntimeConfig, PayloadAgentSource
|
|
10
|
+
|
|
11
|
+
app = create_app(
|
|
12
|
+
RuntimeConfig(
|
|
13
|
+
app_name="my-runtime",
|
|
14
|
+
agent_source=PayloadAgentSource(
|
|
15
|
+
base_url="http://payload:3000",
|
|
16
|
+
internal_secret="...",
|
|
17
|
+
),
|
|
18
|
+
mcp_url="http://mcp:3001/mcp",
|
|
19
|
+
database_url="postgresql://user:pass@host:5432/db",
|
|
20
|
+
internal_secret="...",
|
|
21
|
+
)
|
|
22
|
+
)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Public API
|
|
26
|
+
|
|
27
|
+
| Symbol | Purpose |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `create_app(config)` | Returns a configured FastAPI app |
|
|
30
|
+
| `RuntimeConfig` | Pydantic model — required + optional knobs |
|
|
31
|
+
| `AgentSource` | Protocol — implement `async fetch_agents() -> list[AgentConfig]` |
|
|
32
|
+
| `AgentConfig` | Normalized per-agent record (CMS-agnostic) |
|
|
33
|
+
| `PayloadAgentSource` | Default source for Payload CMS |
|
|
34
|
+
| `build_agent`, `build_model`, `build_mcp_tools` | Lower-level builders for advanced wiring |
|
|
35
|
+
| `compose_instructions`, `DEFAULT_TOOL_PROTOCOL`, `DEFAULT_OUTPUT_FORMAT` | Override-friendly prompt building blocks |
|
|
36
|
+
|
|
37
|
+
## What you get
|
|
38
|
+
|
|
39
|
+
- AgentOS REST surface (`/agents`, `/sessions`, `/metrics`, …)
|
|
40
|
+
- `POST /agents/{slug}/runs` SSE chat
|
|
41
|
+
- `/health`, `/ready` Kubernetes probes
|
|
42
|
+
- `POST /internal/agents/reload` admin endpoint
|
|
43
|
+
- Postgres `LISTEN/NOTIFY` hot reload + 5-min belt-and-braces resync
|
|
44
|
+
- ASGI middlewares: `X-Request-ID`, `X-Internal-Secret` auth, `X-Tenant-Id` → `request.state.metadata`
|
|
45
|
+
|
|
46
|
+
## Reference consumer
|
|
47
|
+
|
|
48
|
+
The default consumer that ships in this repo lives in
|
|
49
|
+
[`../agno-agent`](../agno-agent) — it wraps `create_app` with env-driven
|
|
50
|
+
settings and is what runs in the `agno-agent` devcontainer service. ZP and
|
|
51
|
+
nexus install this lib from PyPI and write their own thin consumer.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""agno-agent — parametrizable Agno runtime as a library.
|
|
2
|
+
|
|
3
|
+
Build a fully configured FastAPI app from a `RuntimeConfig`:
|
|
4
|
+
|
|
5
|
+
from agno_agent_builder import create_app, RuntimeConfig, PayloadAgentSource
|
|
6
|
+
|
|
7
|
+
app = create_app(
|
|
8
|
+
RuntimeConfig(
|
|
9
|
+
app_name="my-runtime",
|
|
10
|
+
agent_source=PayloadAgentSource(
|
|
11
|
+
base_url="http://payload:3000",
|
|
12
|
+
internal_secret=secret,
|
|
13
|
+
),
|
|
14
|
+
mcp_url="http://mcp:3001/mcp",
|
|
15
|
+
database_url="postgresql://...",
|
|
16
|
+
internal_secret=secret,
|
|
17
|
+
)
|
|
18
|
+
)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from agno_agent_builder.app import create_app
|
|
24
|
+
from agno_agent_builder.builder import build_agent, build_mcp_tools, build_model
|
|
25
|
+
from agno_agent_builder.config import (
|
|
26
|
+
DEFAULT_BOOT_BACKOFF_BASE,
|
|
27
|
+
DEFAULT_BOOT_BACKOFF_MAX,
|
|
28
|
+
DEFAULT_BOOT_MAX_RETRIES,
|
|
29
|
+
DEFAULT_PUBLIC_PATHS,
|
|
30
|
+
DEFAULT_RELOAD_CHANNEL,
|
|
31
|
+
DEFAULT_RESYNC_INTERVAL_S,
|
|
32
|
+
RuntimeConfig,
|
|
33
|
+
)
|
|
34
|
+
from agno_agent_builder.exceptions import (
|
|
35
|
+
AgentConfigError,
|
|
36
|
+
AgentRuntimeError,
|
|
37
|
+
AuthenticationError,
|
|
38
|
+
InvalidModelError,
|
|
39
|
+
MissingApiKeyError,
|
|
40
|
+
UnsupportedProviderError,
|
|
41
|
+
)
|
|
42
|
+
from agno_agent_builder.instructions import (
|
|
43
|
+
DEFAULT_OUTPUT_FORMAT,
|
|
44
|
+
DEFAULT_TOOL_PROTOCOL,
|
|
45
|
+
compose_instructions,
|
|
46
|
+
)
|
|
47
|
+
from agno_agent_builder.sources import AgentConfig, AgentSource, PayloadAgentSource
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"DEFAULT_BOOT_BACKOFF_BASE",
|
|
51
|
+
"DEFAULT_BOOT_BACKOFF_MAX",
|
|
52
|
+
"DEFAULT_BOOT_MAX_RETRIES",
|
|
53
|
+
"DEFAULT_OUTPUT_FORMAT",
|
|
54
|
+
"DEFAULT_PUBLIC_PATHS",
|
|
55
|
+
"DEFAULT_RELOAD_CHANNEL",
|
|
56
|
+
"DEFAULT_RESYNC_INTERVAL_S",
|
|
57
|
+
"DEFAULT_TOOL_PROTOCOL",
|
|
58
|
+
"AgentConfig",
|
|
59
|
+
"AgentConfigError",
|
|
60
|
+
"AgentRuntimeError",
|
|
61
|
+
"AgentSource",
|
|
62
|
+
"AuthenticationError",
|
|
63
|
+
"InvalidModelError",
|
|
64
|
+
"MissingApiKeyError",
|
|
65
|
+
"PayloadAgentSource",
|
|
66
|
+
"RuntimeConfig",
|
|
67
|
+
"UnsupportedProviderError",
|
|
68
|
+
"build_agent",
|
|
69
|
+
"build_mcp_tools",
|
|
70
|
+
"build_model",
|
|
71
|
+
"compose_instructions",
|
|
72
|
+
"create_app",
|
|
73
|
+
]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""`create_app(config)` factory — wires AgentOS, registry, listener, lifespan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import hmac
|
|
8
|
+
from collections.abc import AsyncIterator
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
from agno.agent import Agent, AgentFactory
|
|
13
|
+
from agno.agent.protocol import AgentProtocol
|
|
14
|
+
from agno.agent.remote import RemoteAgent
|
|
15
|
+
from agno.os import AgentOS
|
|
16
|
+
from fastapi import APIRouter, Depends, FastAPI, Header
|
|
17
|
+
|
|
18
|
+
from agno_agent_builder.config import RuntimeConfig
|
|
19
|
+
from agno_agent_builder.db import EngineHolder
|
|
20
|
+
from agno_agent_builder.dependencies import get_registry
|
|
21
|
+
from agno_agent_builder.exceptions import (
|
|
22
|
+
AgentRuntimeError,
|
|
23
|
+
AuthenticationError,
|
|
24
|
+
agno_agent_builder_exception_handler,
|
|
25
|
+
)
|
|
26
|
+
from agno_agent_builder.health import router as health_router
|
|
27
|
+
from agno_agent_builder.logging import configure_logging, get_logger
|
|
28
|
+
from agno_agent_builder.middleware import (
|
|
29
|
+
InternalAuthMiddleware,
|
|
30
|
+
RequestIdMiddleware,
|
|
31
|
+
SessionMetadataMiddleware,
|
|
32
|
+
)
|
|
33
|
+
from agno_agent_builder.registry import AgentRegistry
|
|
34
|
+
from agno_agent_builder.reload_listener import run_reload_listener
|
|
35
|
+
from agno_agent_builder.schemas import ErrorResponse, ReloadResponse
|
|
36
|
+
|
|
37
|
+
_AgentList = list[Agent | RemoteAgent | AgentProtocol | AgentFactory]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _agents_as_union(agents: list[Agent]) -> _AgentList:
|
|
41
|
+
return cast(_AgentList, agents)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def create_app(config: RuntimeConfig) -> FastAPI:
|
|
45
|
+
"""Build a fully configured FastAPI app for the Agno runtime."""
|
|
46
|
+
configure_logging(config.log_level)
|
|
47
|
+
logger = get_logger(config.app_name)
|
|
48
|
+
|
|
49
|
+
secret = config.internal_secret.get_secret_value()
|
|
50
|
+
registry = AgentRegistry(
|
|
51
|
+
source=config.agent_source,
|
|
52
|
+
database_url=config.database_url,
|
|
53
|
+
database_schema=config.database_schema,
|
|
54
|
+
mcp_url=config.mcp_url,
|
|
55
|
+
tool_protocol=config.tool_protocol,
|
|
56
|
+
output_format=config.output_format,
|
|
57
|
+
)
|
|
58
|
+
engine_holder = EngineHolder(config.database_url)
|
|
59
|
+
reload_lock = asyncio.Lock()
|
|
60
|
+
|
|
61
|
+
async def reload_registry(_payload: str | None = None) -> None:
|
|
62
|
+
async with reload_lock:
|
|
63
|
+
await registry.reload()
|
|
64
|
+
agent_os.agents = _agents_as_union(registry.all())
|
|
65
|
+
logger.info(
|
|
66
|
+
"Registry reloaded via notify", count=len(registry.all()), slugs=registry.slugs()
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
async def periodic_resync() -> None:
|
|
70
|
+
while True:
|
|
71
|
+
await asyncio.sleep(config.resync_interval_s)
|
|
72
|
+
try:
|
|
73
|
+
await reload_registry()
|
|
74
|
+
except Exception:
|
|
75
|
+
logger.exception("Periodic resync failed, will retry on next tick")
|
|
76
|
+
|
|
77
|
+
@asynccontextmanager
|
|
78
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
79
|
+
app.state.registry = registry
|
|
80
|
+
app.state.engine_holder = engine_holder
|
|
81
|
+
|
|
82
|
+
for attempt in range(1, config.boot_max_retries + 1):
|
|
83
|
+
try:
|
|
84
|
+
await registry.load_all()
|
|
85
|
+
agent_os.agents = _agents_as_union(registry.all())
|
|
86
|
+
logger.info("AgentOS initialised", agent_count=len(registry.all()))
|
|
87
|
+
break
|
|
88
|
+
except Exception:
|
|
89
|
+
delay = min(config.boot_backoff_base**attempt, config.boot_backoff_max)
|
|
90
|
+
if attempt < config.boot_max_retries:
|
|
91
|
+
logger.warning(
|
|
92
|
+
"Bootstrap failed, retrying",
|
|
93
|
+
attempt=attempt,
|
|
94
|
+
max_retries=config.boot_max_retries,
|
|
95
|
+
delay_s=delay,
|
|
96
|
+
exc_info=True,
|
|
97
|
+
)
|
|
98
|
+
await asyncio.sleep(delay)
|
|
99
|
+
else:
|
|
100
|
+
logger.critical(
|
|
101
|
+
"Failed to bootstrap after max retries — service will only expose health endpoints",
|
|
102
|
+
max_retries=config.boot_max_retries,
|
|
103
|
+
exc_info=True,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
listener_task = asyncio.create_task(
|
|
107
|
+
run_reload_listener(
|
|
108
|
+
reload_registry,
|
|
109
|
+
database_url=config.database_url,
|
|
110
|
+
channel=config.reload_channel,
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
resync_task = asyncio.create_task(periodic_resync())
|
|
114
|
+
yield
|
|
115
|
+
for task in (listener_task, resync_task):
|
|
116
|
+
task.cancel()
|
|
117
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
118
|
+
await task
|
|
119
|
+
|
|
120
|
+
logger.info("Shutting down — disposing shared DB engine")
|
|
121
|
+
await engine_holder.dispose()
|
|
122
|
+
|
|
123
|
+
agent_os_kwargs: dict[str, Any] = {
|
|
124
|
+
"telemetry": False,
|
|
125
|
+
"authorization": False,
|
|
126
|
+
"auto_provision_dbs": True,
|
|
127
|
+
**config.agent_os_kwargs,
|
|
128
|
+
}
|
|
129
|
+
agent_os = AgentOS(
|
|
130
|
+
name=config.app_name,
|
|
131
|
+
db=registry.db,
|
|
132
|
+
agents=[],
|
|
133
|
+
lifespan=lifespan,
|
|
134
|
+
**agent_os_kwargs,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
app: FastAPI = agent_os.get_app()
|
|
138
|
+
app.add_middleware(SessionMetadataMiddleware)
|
|
139
|
+
app.add_middleware(RequestIdMiddleware)
|
|
140
|
+
app.add_middleware(InternalAuthMiddleware, secret=secret, public_paths=config.public_paths)
|
|
141
|
+
app.add_exception_handler(AgentRuntimeError, agno_agent_builder_exception_handler)
|
|
142
|
+
app.include_router(health_router)
|
|
143
|
+
|
|
144
|
+
internal_router = APIRouter(prefix="/internal", tags=["internal"])
|
|
145
|
+
|
|
146
|
+
@internal_router.post(
|
|
147
|
+
"/agents/reload",
|
|
148
|
+
response_model=ReloadResponse,
|
|
149
|
+
responses={401: {"model": ErrorResponse}},
|
|
150
|
+
)
|
|
151
|
+
async def reload_agents(
|
|
152
|
+
reg: AgentRegistry = Depends(get_registry),
|
|
153
|
+
x_internal_secret: str | None = Header(default=None, alias="X-Internal-Secret"),
|
|
154
|
+
) -> ReloadResponse:
|
|
155
|
+
if not hmac.compare_digest(x_internal_secret or "", secret):
|
|
156
|
+
raise AuthenticationError()
|
|
157
|
+
async with reload_lock:
|
|
158
|
+
await reg.reload()
|
|
159
|
+
agent_os.agents = _agents_as_union(reg.all())
|
|
160
|
+
count = len(reg.all())
|
|
161
|
+
logger.info("Agents reloaded", count=count, slugs=reg.slugs())
|
|
162
|
+
return ReloadResponse(count=count, slugs=reg.slugs())
|
|
163
|
+
|
|
164
|
+
app.include_router(internal_router)
|
|
165
|
+
|
|
166
|
+
return app
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Agent construction — maps `AgentConfig` records into Agno `Agent` instances."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from agno.agent import Agent
|
|
6
|
+
from agno.db.postgres import PostgresDb
|
|
7
|
+
from agno.models.anthropic import Claude
|
|
8
|
+
from agno.models.base import Model
|
|
9
|
+
from agno.models.openai import OpenAIChat, OpenAIResponses
|
|
10
|
+
from agno.tools.mcp import MCPTools
|
|
11
|
+
from agno.tools.mcp.params import StreamableHTTPClientParams
|
|
12
|
+
|
|
13
|
+
from agno_agent_builder.exceptions import InvalidModelError, UnsupportedProviderError
|
|
14
|
+
from agno_agent_builder.instructions import compose_instructions
|
|
15
|
+
from agno_agent_builder.sources.types import AgentConfig
|
|
16
|
+
|
|
17
|
+
_OPENAI_RESPONSES_PREFIXES = ("o1", "o3", "o4", "gpt-4.1", "gpt-5")
|
|
18
|
+
_NATIVE_REASONER_PREFIXES = ("o1", "o3", "o4")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_agent(
|
|
22
|
+
cfg: AgentConfig,
|
|
23
|
+
*,
|
|
24
|
+
db: PostgresDb,
|
|
25
|
+
mcp_url: str,
|
|
26
|
+
tool_protocol: str | None = None,
|
|
27
|
+
output_format: str | None = None,
|
|
28
|
+
) -> Agent:
|
|
29
|
+
"""Construct an Agno Agent from a normalized AgentConfig."""
|
|
30
|
+
provider, _, model_id = cfg.llm_model.partition("/")
|
|
31
|
+
if not model_id:
|
|
32
|
+
raise InvalidModelError(slug=cfg.slug, llm_model=cfg.llm_model)
|
|
33
|
+
|
|
34
|
+
is_native_reasoner = any(model_id.startswith(p) for p in _NATIVE_REASONER_PREFIXES)
|
|
35
|
+
|
|
36
|
+
return Agent(
|
|
37
|
+
name=cfg.name,
|
|
38
|
+
id=cfg.slug,
|
|
39
|
+
model=build_model(provider, model_id, cfg.api_key.get_secret_value()),
|
|
40
|
+
instructions=compose_instructions(
|
|
41
|
+
cfg, tool_protocol=tool_protocol, output_format=output_format
|
|
42
|
+
),
|
|
43
|
+
db=db,
|
|
44
|
+
tools=[build_mcp_tools(mcp_url, cfg.tenant_slug, cfg.taxonomy_slugs)],
|
|
45
|
+
add_history_to_context=True,
|
|
46
|
+
num_history_runs=5,
|
|
47
|
+
reasoning=not is_native_reasoner,
|
|
48
|
+
tool_call_limit=cfg.tool_call_limit,
|
|
49
|
+
telemetry=False,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_model(provider: str, model_id: str, api_key: str) -> Model:
|
|
54
|
+
"""Map a provider/model-id tuple to an Agno model instance."""
|
|
55
|
+
if provider == "anthropic":
|
|
56
|
+
return Claude(id=model_id, api_key=api_key)
|
|
57
|
+
if provider == "openai":
|
|
58
|
+
if any(model_id.startswith(p) for p in _OPENAI_RESPONSES_PREFIXES):
|
|
59
|
+
return OpenAIResponses(id=model_id, api_key=api_key)
|
|
60
|
+
return OpenAIChat(id=model_id, api_key=api_key)
|
|
61
|
+
raise UnsupportedProviderError(provider=provider)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_mcp_tools(
|
|
65
|
+
mcp_url: str,
|
|
66
|
+
tenant_slug: str | None = None,
|
|
67
|
+
taxonomy_slugs: list[str] | None = None,
|
|
68
|
+
) -> MCPTools:
|
|
69
|
+
"""Build an MCPTools instance with tenant/taxonomy headers."""
|
|
70
|
+
headers: dict[str, str] = {}
|
|
71
|
+
if tenant_slug:
|
|
72
|
+
headers["x-tenant-slug"] = tenant_slug
|
|
73
|
+
if taxonomy_slugs:
|
|
74
|
+
headers["x-taxonomy-slugs"] = ",".join(taxonomy_slugs)
|
|
75
|
+
if headers:
|
|
76
|
+
params = StreamableHTTPClientParams(url=mcp_url, headers=headers)
|
|
77
|
+
return MCPTools(server_params=params, transport="streamable-http")
|
|
78
|
+
return MCPTools(url=mcp_url, transport="streamable-http")
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Runtime configuration accepted by `create_app`.
|
|
2
|
+
|
|
3
|
+
Pure data model — no env loading inside the library. Consumers build a
|
|
4
|
+
`RuntimeConfig` from their own settings layer (typically pydantic-settings)
|
|
5
|
+
and pass it to `create_app`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
|
13
|
+
|
|
14
|
+
from agno_agent_builder.sources.base import AgentSource
|
|
15
|
+
|
|
16
|
+
DEFAULT_PUBLIC_PATHS: tuple[str, ...] = ("/health", "/ready", "/docs", "/openapi.json")
|
|
17
|
+
DEFAULT_RELOAD_CHANNEL = "agent_reload"
|
|
18
|
+
DEFAULT_RESYNC_INTERVAL_S = 300.0
|
|
19
|
+
DEFAULT_BOOT_MAX_RETRIES = 10
|
|
20
|
+
DEFAULT_BOOT_BACKOFF_BASE = 2.0
|
|
21
|
+
DEFAULT_BOOT_BACKOFF_MAX = 30.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RuntimeConfig(BaseModel):
|
|
25
|
+
"""Top-level configuration for `create_app`."""
|
|
26
|
+
|
|
27
|
+
model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
|
|
28
|
+
|
|
29
|
+
app_name: str
|
|
30
|
+
agent_source: AgentSource
|
|
31
|
+
mcp_url: str
|
|
32
|
+
database_url: str
|
|
33
|
+
internal_secret: SecretStr
|
|
34
|
+
database_schema: str = "agno"
|
|
35
|
+
log_level: str = "INFO"
|
|
36
|
+
reload_channel: str = DEFAULT_RELOAD_CHANNEL
|
|
37
|
+
resync_interval_s: float = DEFAULT_RESYNC_INTERVAL_S
|
|
38
|
+
boot_max_retries: int = DEFAULT_BOOT_MAX_RETRIES
|
|
39
|
+
boot_backoff_base: float = DEFAULT_BOOT_BACKOFF_BASE
|
|
40
|
+
boot_backoff_max: float = DEFAULT_BOOT_BACKOFF_MAX
|
|
41
|
+
public_paths: tuple[str, ...] = DEFAULT_PUBLIC_PATHS
|
|
42
|
+
tool_protocol: str | None = None
|
|
43
|
+
output_format: str | None = None
|
|
44
|
+
agent_os_kwargs: dict[str, Any] = Field(default_factory=dict)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Async SQLAlchemy engine helper for health checks.
|
|
2
|
+
|
|
3
|
+
Stateless — `EngineHolder` is instantiated per-app inside `create_app` so
|
|
4
|
+
multiple runtime instances (tests, multi-tenancy) don't share a cached
|
|
5
|
+
engine. `normalize_pg_url` is exported because `reload_listener` and
|
|
6
|
+
session storage need the same scheme normalization.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
|
|
13
|
+
from sqlalchemy import text
|
|
14
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
|
15
|
+
|
|
16
|
+
from agno_agent_builder.logging import get_logger
|
|
17
|
+
|
|
18
|
+
logger = get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize_pg_url(url: str) -> str:
|
|
22
|
+
"""Force psycopg v3 driver (installed via agno[postgres])."""
|
|
23
|
+
for prefix in ("postgresql://", "postgres://"):
|
|
24
|
+
if url.startswith(prefix):
|
|
25
|
+
return "postgresql+psycopg://" + url[len(prefix) :]
|
|
26
|
+
return url
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EngineHolder:
|
|
30
|
+
"""Lazy async engine cache, scoped to one runtime instance."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, database_url: str) -> None:
|
|
33
|
+
self._database_url = database_url
|
|
34
|
+
self._engine: AsyncEngine | None = None
|
|
35
|
+
self._lock = asyncio.Lock()
|
|
36
|
+
|
|
37
|
+
async def get(self) -> AsyncEngine:
|
|
38
|
+
if self._engine is not None:
|
|
39
|
+
return self._engine
|
|
40
|
+
async with self._lock:
|
|
41
|
+
if self._engine is not None:
|
|
42
|
+
return self._engine
|
|
43
|
+
sync_url = normalize_pg_url(self._database_url)
|
|
44
|
+
async_url = sync_url.replace("postgresql+psycopg://", "postgresql+psycopg_async://")
|
|
45
|
+
self._engine = create_async_engine(async_url, pool_size=5, pool_pre_ping=True)
|
|
46
|
+
return self._engine
|
|
47
|
+
|
|
48
|
+
async def check(self) -> bool:
|
|
49
|
+
try:
|
|
50
|
+
engine = await self.get()
|
|
51
|
+
async with engine.connect() as conn:
|
|
52
|
+
await conn.execute(text("SELECT 1"))
|
|
53
|
+
return True
|
|
54
|
+
except Exception:
|
|
55
|
+
logger.warning("DB health check failed", exc_info=True)
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
async def dispose(self) -> None:
|
|
59
|
+
if self._engine is not None:
|
|
60
|
+
await self._engine.dispose()
|
|
61
|
+
self._engine = None
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""FastAPI dependencies for dependency injection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from fastapi import Request
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from agno_agent_builder.db import EngineHolder
|
|
11
|
+
from agno_agent_builder.registry import AgentRegistry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def get_registry(request: Request) -> AgentRegistry:
|
|
15
|
+
return request.app.state.registry # type: ignore[no-any-return]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def get_engine_holder(request: Request) -> EngineHolder:
|
|
19
|
+
return request.app.state.engine_holder # type: ignore[no-any-return]
|