bfa-irc-a-sdk 0.1.0__py3-none-any.whl
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.
- bfa_irc_a_sdk-0.1.0.dist-info/METADATA +145 -0
- bfa_irc_a_sdk-0.1.0.dist-info/RECORD +12 -0
- bfa_irc_a_sdk-0.1.0.dist-info/WHEEL +5 -0
- bfa_irc_a_sdk-0.1.0.dist-info/top_level.txt +1 -0
- bfa_sdk/__init__.py +13 -0
- bfa_sdk/config.py +45 -0
- bfa_sdk/core/__init__.py +1 -0
- bfa_sdk/core/agent.py +353 -0
- bfa_sdk/core/gateway.py +529 -0
- bfa_sdk/core/mcp.py +336 -0
- bfa_sdk/router/embedder.py +110 -0
- bfa_sdk/router/search.py +135 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bfa-irc-a-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A generic Backend for Agents (BFA) SDK with FAISS semantic routing.
|
|
5
|
+
Author: Sandro Garcia
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: fastapi
|
|
12
|
+
Requires-Dist: uvicorn
|
|
13
|
+
Requires-Dist: httpx
|
|
14
|
+
Requires-Dist: python-dotenv
|
|
15
|
+
Requires-Dist: a2a-sdk
|
|
16
|
+
Requires-Dist: numpy
|
|
17
|
+
Requires-Dist: faiss-cpu
|
|
18
|
+
Requires-Dist: pydantic
|
|
19
|
+
Requires-Dist: fastmcp
|
|
20
|
+
Requires-Dist: pyyaml
|
|
21
|
+
Requires-Dist: mangum
|
|
22
|
+
Requires-Dist: openai
|
|
23
|
+
Requires-Dist: pyjwt
|
|
24
|
+
Requires-Dist: cryptography
|
|
25
|
+
Provides-Extra: local
|
|
26
|
+
Requires-Dist: sentence-transformers; extra == "local"
|
|
27
|
+
|
|
28
|
+
# Backend for Agents SDK (BFA) & IRC-A Protocol
|
|
29
|
+
|
|
30
|
+
A generic and opinionated framework and SDK to implement the **BFA (Backend for Agents)** pattern and the **IRC-A (Internet Relay Chat for Agents)** protocol, featuring native support for **FAISS-based Semantic Routing (vector search)**, asymmetric zero-trust security boundaries, and standard abstractions for A2A Agents and MCP Servers.
|
|
31
|
+
|
|
32
|
+
Read the official protocol specification:
|
|
33
|
+
👉 **[IRC-A Protocol Whitepaper (v1.0.0)](IRC-A_Whitepaper.md)** - *Decentralized Agent Networks, Semantic Capability Routing, and Secure-by-Design Software Architecture.*
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Multilingual Documentation
|
|
38
|
+
* [Português (Portuguese)](README.pt.md)
|
|
39
|
+
* [Español (Spanish)](README.es.md)
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## BFA / IRC-A Protocol Architecture
|
|
44
|
+
|
|
45
|
+
The BFA Gateway acts as a semantic middleware and registry broker layer between consumers (e.g., messaging UIs, chat systems) and specialized agents/tools.
|
|
46
|
+
|
|
47
|
+
```mermaid
|
|
48
|
+
graph TD
|
|
49
|
+
Consumer["Consumer UI / Whatsapp / WebApp"] -->|1. Resolve Query| BFA[BFA Gateway]
|
|
50
|
+
|
|
51
|
+
subgraph BFA_Gateway ["BFA Gateway (Backend for Agents)"]
|
|
52
|
+
Router[Semantic Router] -->|2. Search Embeddings| FAISS[FAISS Vector Store]
|
|
53
|
+
Registry[Registry] -->|Load metadata| Router
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
BFA -->|3. Route & Invoke| Agent1["Cuentas Agent (A2A)"]
|
|
57
|
+
BFA -->|3. Route & Invoke| Agent2["Tarjetas Agent (A2A)"]
|
|
58
|
+
BFA -->|4. Execute Tool| MCP1["MDBank MCP (FastMCP)"]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Key Features
|
|
64
|
+
|
|
65
|
+
1. **FAISS-Based Semantic Routing:** Instead of matching exact keywords (like BM25), the BFA Gateway indexes the descriptions, tags, and examples of agents and tools in a local FAISS vector index. This resolves queries to matching functions even when synonyms are used (e.g., matching *"plastic"* to *"credit card"*).
|
|
66
|
+
2. **`BFAAgent` Abstraction:** Simplifies building A2A agents using the `a2a-sdk` and Starlette. Forces standard metadata declarations (`tags`, `examples`, `description`) required for semantic indexing.
|
|
67
|
+
3. **`BFAMCP` Abstraction:** Wraps and extends `FastMCP` servers. Automatically exposes a standardized `/tools` endpoint returning input schemas, descriptions, and custom tags/examples for discovery.
|
|
68
|
+
4. **Secure-by-Design IRC-A Security (Roadmap):** Employs asymmetric challenge-response registration handshakes, logical channel masking (via container-level `IRCA_CHANNELS` env variables) to segregate vector search spaces, and Ephemeral DET (Delegated Execution Tokens) to enable direct decentralized P2P invocation without gateway bottlenecks.
|
|
69
|
+
5. **Serverless (AWS Lambda) Ready:** Includes a built-in **Mangum** adapter in the Gateway. Combined with the cloud-based `OpenAIEmbedder`, the BFA Gateway runs serverless on demand with zero cold-starts.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Configuring Embedding Providers & Chunking
|
|
74
|
+
|
|
75
|
+
The BFA Gateway uses semantic embeddings to index agent/tool metadata in FAISS. You can choose between local models, cloud APIs, or offline mock routing via environment variables:
|
|
76
|
+
|
|
77
|
+
| Mode / Provider | Environment Variables | Dependencies | Description |
|
|
78
|
+
|---|---|---|---|
|
|
79
|
+
| **Local Real (Default)** | None | `bfa-sdk[local]` | Uses `sentence-transformers` locally. Recommended for Python <= 3.12 environments. |
|
|
80
|
+
| **OpenAI (Cloud)** | `BFA_USE_OPENAI_EMBEDDINGS=true`, `OPENAI_API_KEY="..."` | `openai` | Queries OpenAI's `text-embedding-3-small` endpoint. Perfect for serverless/Lambda environments. |
|
|
81
|
+
| **Offline Mock (Feature Hashing)** | `BFA_USE_MOCK_EMBEDDINGS=true` | None | Uses a stable MD5 feature hashing trick to route queries based on keywords. Zero dependencies, fast, and local. |
|
|
82
|
+
|
|
83
|
+
> [!NOTE]
|
|
84
|
+
> **Why is there no Chunking in the Gateway?**
|
|
85
|
+
> The BFA Gateway is a semantic router of services, not a document retrieval engine (RAG). It indexes short microservice metadata cards (names, descriptions, tags, examples) which fit completely within embedding token limits.
|
|
86
|
+
> If you need to perform **Document Chunking** (RAG over PDFs/manuals), it should be implemented **inside the respective A2A Agent's internal database/logic**, keeping the Gateway lightweight and decoupled from document storage.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Installation
|
|
91
|
+
|
|
92
|
+
You can install the BFA SDK directly from GitHub using `pip`:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
# Install the development version from the main branch
|
|
96
|
+
pip install git+https://github.com/SandroG1977/bfa-sdk.git
|
|
97
|
+
|
|
98
|
+
# Or install a specific version with the secure IRC-A protocol support
|
|
99
|
+
pip install git+https://github.com/SandroG1977/bfa-sdk.git@feature/docstrings-init
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Running the Demo
|
|
105
|
+
|
|
106
|
+
### 2. Run the MDBank Demo
|
|
107
|
+
The demo launches three mock servers in the background:
|
|
108
|
+
1. A mock MDBank MCP server (`examples/mock_mdbank_mcp.py`) on port `8001`.
|
|
109
|
+
2. A mock Cuentas A2A Agent (`examples/mock_cuentas_agent.py`) on port `8002`.
|
|
110
|
+
3. A mock Tarjetas A2A Agent (`examples/mock_tarjetas_agent.py`) on port `8003`.
|
|
111
|
+
4. The BFA Gateway on port `8000`, running dynamic discovery and performing test queries.
|
|
112
|
+
|
|
113
|
+
To run:
|
|
114
|
+
```bash
|
|
115
|
+
python examples/run_demo.py
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### 3. Run the UI Dashboard (IRC-A Central Hub)
|
|
119
|
+
We have included a React-based UI Dashboard under `examples/frontend` to visually monitor the active agents/tools registry, register new microservices dynamically (plug-and-play), and chat with the routed banking agents:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
# Navigate to the frontend folder
|
|
123
|
+
cd examples/frontend
|
|
124
|
+
|
|
125
|
+
# Install dependencies
|
|
126
|
+
npm install
|
|
127
|
+
|
|
128
|
+
# Start the development server
|
|
129
|
+
npm start
|
|
130
|
+
```
|
|
131
|
+
Open `http://localhost:3000` to interact with your local agent hub in real-time.
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Credits & Acknowledgements
|
|
137
|
+
|
|
138
|
+
This SDK is a community-driven implementation and expansion of the **BFA (Backend for Agents)** architectural pattern originally designed and documented by **Michael Douglas Barbosa Araujo** (Staff AI Architect).
|
|
139
|
+
|
|
140
|
+
You can read his original article introducing the pattern here:
|
|
141
|
+
👉 [O padrão Back-end para Agentes (BFA) - Medium](https://medium.com/@mdbaraujo/o-padr%C3%A3o-back-end-para-agentes-bfa-a53c1c6d87fb)
|
|
142
|
+
|
|
143
|
+
The goal of this project is to provide a standardized, packaged SDK extending his original concept with semantic vector routing (FAISS) and unified base adapters. All credit for the underlying protocol and architectural vision belongs to him.
|
|
144
|
+
|
|
145
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
bfa_sdk/__init__.py,sha256=0qwUPj_WSXuq0ZruXWUuP9ZWzS8WAW_e_27K3sno_YU,418
|
|
2
|
+
bfa_sdk/config.py,sha256=WaF5ElPo7hWOqsP4gq5pT32FsSylicZWP4b8g3WuPKw,2032
|
|
3
|
+
bfa_sdk/core/__init__.py,sha256=p_Q2qovu0ZrKSimv_SSVNy_5FK3RYxcd2eutui23pTg,27
|
|
4
|
+
bfa_sdk/core/agent.py,sha256=Ipy27GzhR_m6BTJhcxys-_fMi8Atd9C-xG9jnuNJAE4,14519
|
|
5
|
+
bfa_sdk/core/gateway.py,sha256=FIl5KTKfGNSwMAnvhAIkpgL3B3C0qXiXLwJKXlMeoPg,19724
|
|
6
|
+
bfa_sdk/core/mcp.py,sha256=bkt7vgKrMN0_vmkGtDkSKfB6yOzE4OGUyRBUDMz0D2c,15257
|
|
7
|
+
bfa_sdk/router/embedder.py,sha256=Qu6OQCrsPKbFtO6XgUuTmFxpGxQw27LRpDOuiTSbbQM,3708
|
|
8
|
+
bfa_sdk/router/search.py,sha256=oqsb43no4Dn3hCyjcVs-V07nqmaeYQHQWLWA22xbp4I,4652
|
|
9
|
+
bfa_irc_a_sdk-0.1.0.dist-info/METADATA,sha256=Ue5-36QiJVunjNkY6U9OEM0sytNB5KKhlvC4xE7ZEnQ,6909
|
|
10
|
+
bfa_irc_a_sdk-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
11
|
+
bfa_irc_a_sdk-0.1.0.dist-info/top_level.txt,sha256=yp9ist1-yHCot3fdt1MtKbJW74E3ICOVqhYuboxTxFY,8
|
|
12
|
+
bfa_irc_a_sdk-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bfa_sdk
|
bfa_sdk/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Backend for Agents SDK (BFA)
|
|
2
|
+
# Version 0.1.0
|
|
3
|
+
"""
|
|
4
|
+
BFA SDK: A lightweight framework for Backend for Agents (BFA) architecture with FAISS semantic routing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
from bfa_sdk.core.agent import BFAAgent
|
|
9
|
+
from bfa_sdk.core.mcp import BFAMCP
|
|
10
|
+
from bfa_sdk.core.gateway import create_gateway_app
|
|
11
|
+
from bfa_sdk.router.search import BFASemanticRouter
|
|
12
|
+
|
|
13
|
+
__all__ = ["BFAAgent", "BFAMCP", "create_gateway_app", "BFASemanticRouter"]
|
bfa_sdk/config.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import yaml
|
|
3
|
+
from typing import List, Dict, Any
|
|
4
|
+
|
|
5
|
+
class BFAConfig:
|
|
6
|
+
"""
|
|
7
|
+
Configuration helper parsing endpoints and models from environment variables
|
|
8
|
+
or a YAML configuration file.
|
|
9
|
+
"""
|
|
10
|
+
def __init__(self, config_path: str = None):
|
|
11
|
+
self.agent_endpoints: List[str] = []
|
|
12
|
+
self.mcp_endpoints: List[str] = []
|
|
13
|
+
self.embedding_model: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
|
|
14
|
+
self.use_mock_embeddings: bool = False
|
|
15
|
+
self.use_openai_embeddings: bool = False
|
|
16
|
+
self.openai_api_key: str = ""
|
|
17
|
+
|
|
18
|
+
# Load from config file if provided
|
|
19
|
+
if config_path and os.path.exists(config_path):
|
|
20
|
+
self.load_from_file(config_path)
|
|
21
|
+
else:
|
|
22
|
+
self.load_from_env()
|
|
23
|
+
|
|
24
|
+
def load_from_file(self, path: str):
|
|
25
|
+
with open(path, "r") as f:
|
|
26
|
+
data = yaml.safe_load(f) or {}
|
|
27
|
+
self.agent_endpoints = data.get("agent_endpoints", [])
|
|
28
|
+
self.mcp_endpoints = data.get("mcp_endpoints", [])
|
|
29
|
+
self.embedding_model = data.get("embedding_model", self.embedding_model)
|
|
30
|
+
self.use_mock_embeddings = data.get("use_mock_embeddings", False)
|
|
31
|
+
self.use_openai_embeddings = data.get("use_openai_embeddings", False)
|
|
32
|
+
self.openai_api_key = data.get("openai_api_key", "")
|
|
33
|
+
|
|
34
|
+
def load_from_env(self):
|
|
35
|
+
# Parse comma-separated strings
|
|
36
|
+
agents_env = os.getenv("BFA_AGENT_ENDPOINTS", "")
|
|
37
|
+
self.agent_endpoints = [x.strip() for x in agents_env.split(",") if x.strip()]
|
|
38
|
+
|
|
39
|
+
mcps_env = os.getenv("BFA_MCP_ENDPOINTS", "")
|
|
40
|
+
self.mcp_endpoints = [x.strip() for x in mcps_env.split(",") if x.strip()]
|
|
41
|
+
|
|
42
|
+
self.embedding_model = os.getenv("BFA_EMBEDDING_MODEL", self.embedding_model)
|
|
43
|
+
self.use_mock_embeddings = os.getenv("BFA_USE_MOCK_EMBEDDINGS", "false").lower() == "true"
|
|
44
|
+
self.use_openai_embeddings = os.getenv("BFA_USE_OPENAI_EMBEDDINGS", "false").lower() == "true"
|
|
45
|
+
self.openai_api_key = os.getenv("OPENAI_API_KEY", "")
|
bfa_sdk/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Core modules for BFA SDK
|
bfa_sdk/core/agent.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import os
|
|
3
|
+
import httpx
|
|
4
|
+
import jwt
|
|
5
|
+
import asyncio
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from typing import List, Dict, Any, Optional
|
|
8
|
+
from starlette.applications import Starlette
|
|
9
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
10
|
+
from starlette.responses import JSONResponse
|
|
11
|
+
from a2a.server.request_handlers import DefaultRequestHandler
|
|
12
|
+
from a2a.server.tasks import InMemoryTaskStore
|
|
13
|
+
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
|
|
14
|
+
from a2a.types import AgentCapabilities, AgentCard, AgentSkill, AgentInterface
|
|
15
|
+
from a2a.server.agent_execution import AgentExecutor
|
|
16
|
+
from a2a.server.agent_execution.context import RequestContext
|
|
17
|
+
from a2a.server.events.event_queue import EventQueue
|
|
18
|
+
from a2a.helpers import new_text_message
|
|
19
|
+
from a2a.types import Role
|
|
20
|
+
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
|
21
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
22
|
+
|
|
23
|
+
class BFAAgentExecutor(AgentExecutor):
|
|
24
|
+
"""
|
|
25
|
+
Internal executor that maps standard A2A execution to BFAAgent's run method.
|
|
26
|
+
"""
|
|
27
|
+
def __init__(self, agent_instance):
|
|
28
|
+
super().__init__()
|
|
29
|
+
self.agent_instance = agent_instance
|
|
30
|
+
|
|
31
|
+
async def execute(self, context: RequestContext, event_queue: EventQueue):
|
|
32
|
+
try:
|
|
33
|
+
user_input = context.get_user_input()
|
|
34
|
+
# Run the concrete agent logic
|
|
35
|
+
response_text = await self.agent_instance.run(user_input, context=context)
|
|
36
|
+
# Enqueue response as a text message
|
|
37
|
+
await event_queue.enqueue_event(
|
|
38
|
+
new_text_message(str(response_text), role=Role.ROLE_AGENT)
|
|
39
|
+
)
|
|
40
|
+
except Exception as e:
|
|
41
|
+
await event_queue.enqueue_event(
|
|
42
|
+
new_text_message(f"Error in Agent execution: {str(e)}", role=Role.ROLE_AGENT)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
async def cancel(self, context: RequestContext, event_queue: EventQueue):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class IRCAHeaderTracingMiddleware(BaseHTTPMiddleware):
|
|
50
|
+
"""
|
|
51
|
+
Starlette middleware for loop detection and propagation of transaction correlation context.
|
|
52
|
+
"""
|
|
53
|
+
def __init__(self, app, agent_instance):
|
|
54
|
+
super().__init__(app)
|
|
55
|
+
self.agent_instance = agent_instance
|
|
56
|
+
|
|
57
|
+
async def dispatch(self, request, call_next):
|
|
58
|
+
trace_id = request.headers.get("x-trace-id")
|
|
59
|
+
visited_raw = request.headers.get("x-visited-nodes", "")
|
|
60
|
+
|
|
61
|
+
if trace_id:
|
|
62
|
+
visited_nodes = [x.strip() for x in visited_raw.split(",") if x.strip()]
|
|
63
|
+
|
|
64
|
+
# Execution loop mitigation check
|
|
65
|
+
if self.agent_instance.agent_id in visited_nodes:
|
|
66
|
+
return JSONResponse(
|
|
67
|
+
{
|
|
68
|
+
"jsonrpc": "2.0",
|
|
69
|
+
"error": {
|
|
70
|
+
"code": -32000,
|
|
71
|
+
"message": f"IRC-A Circular Loop Detected: Node '{self.agent_instance.agent_id}' visited twice."
|
|
72
|
+
},
|
|
73
|
+
"id": None
|
|
74
|
+
},
|
|
75
|
+
status_code=409
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Store values on request state
|
|
79
|
+
request.state.trace_id = trace_id
|
|
80
|
+
request.state.visited_nodes = visited_nodes + [self.agent_instance.agent_id]
|
|
81
|
+
|
|
82
|
+
return await call_next(request)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class BFAAgent(abc.ABC):
|
|
86
|
+
"""
|
|
87
|
+
Abstract Base Class for BFA Agents.
|
|
88
|
+
Inheriting from this class automatically configures an A2A server.
|
|
89
|
+
Developers only need to implement the async run() method.
|
|
90
|
+
"""
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
agent_id: str,
|
|
94
|
+
name: str,
|
|
95
|
+
description: str,
|
|
96
|
+
tags: List[str],
|
|
97
|
+
examples: List[str],
|
|
98
|
+
url: str,
|
|
99
|
+
version: str = "1.0.0",
|
|
100
|
+
private_key: Any = None,
|
|
101
|
+
gateway_public_key: Any = None,
|
|
102
|
+
gateway_url: str = None
|
|
103
|
+
):
|
|
104
|
+
self.agent_id = agent_id
|
|
105
|
+
self.name = name
|
|
106
|
+
self.description = description
|
|
107
|
+
self.tags = tags
|
|
108
|
+
self.examples = examples
|
|
109
|
+
self.url = url
|
|
110
|
+
self.version = version
|
|
111
|
+
self.gateway_url = gateway_url or os.getenv("BFA_GATEWAY_URL")
|
|
112
|
+
|
|
113
|
+
# Load/Generate keys
|
|
114
|
+
if private_key is None:
|
|
115
|
+
self._private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
116
|
+
elif isinstance(private_key, str):
|
|
117
|
+
self._private_key = serialization.load_pem_private_key(private_key.encode("utf-8"), password=None)
|
|
118
|
+
else:
|
|
119
|
+
self._private_key = private_key
|
|
120
|
+
self._public_key = self._private_key.public_key()
|
|
121
|
+
|
|
122
|
+
# Load gateway public key
|
|
123
|
+
if isinstance(gateway_public_key, str):
|
|
124
|
+
self.gateway_public_key = serialization.load_pem_public_key(gateway_public_key.encode("utf-8"))
|
|
125
|
+
else:
|
|
126
|
+
self.gateway_public_key = gateway_public_key
|
|
127
|
+
|
|
128
|
+
# Resolve channels
|
|
129
|
+
raw_channels = os.getenv("IRCA_CHANNELS", "#public")
|
|
130
|
+
self.channels = [ch.strip() for ch in raw_channels.split(",")]
|
|
131
|
+
|
|
132
|
+
self.session_token = None
|
|
133
|
+
self.token_expiry = 0
|
|
134
|
+
|
|
135
|
+
# Try to download gateway public key if missing
|
|
136
|
+
if not self.gateway_public_key and self.gateway_url:
|
|
137
|
+
try:
|
|
138
|
+
res = httpx.get(f"{self.gateway_url.rstrip('/')}/public_key", timeout=5)
|
|
139
|
+
if res.status_code == 200:
|
|
140
|
+
pem_str = res.json().get("public_key")
|
|
141
|
+
self.gateway_public_key = serialization.load_pem_public_key(pem_str.encode("utf-8"))
|
|
142
|
+
except Exception as e:
|
|
143
|
+
print(f"BFAAgent Warning: Could not download gateway public key: {e}")
|
|
144
|
+
|
|
145
|
+
# Create default A2A skill representing this agent
|
|
146
|
+
self.skill = AgentSkill(
|
|
147
|
+
id=self.agent_id,
|
|
148
|
+
name=self.name,
|
|
149
|
+
description=self.description,
|
|
150
|
+
tags=self.tags,
|
|
151
|
+
examples=self.examples
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Create AgentCard representation for BFA Discovery
|
|
155
|
+
self.agent_card = AgentCard(
|
|
156
|
+
name=self.name,
|
|
157
|
+
description=self.description,
|
|
158
|
+
default_input_modes=["text"],
|
|
159
|
+
default_output_modes=["text"],
|
|
160
|
+
skills=[self.skill],
|
|
161
|
+
version=self.version,
|
|
162
|
+
capabilities=AgentCapabilities(streaming=True),
|
|
163
|
+
supported_interfaces=[
|
|
164
|
+
AgentInterface(
|
|
165
|
+
protocol_binding="JSONRPC",
|
|
166
|
+
url=self.url,
|
|
167
|
+
)
|
|
168
|
+
]
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
self.task_store = InMemoryTaskStore()
|
|
172
|
+
self.executor = BFAAgentExecutor(self)
|
|
173
|
+
self.http_handler = DefaultRequestHandler(
|
|
174
|
+
agent_executor=self.executor,
|
|
175
|
+
task_store=self.task_store,
|
|
176
|
+
agent_card=self.agent_card
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Setup standard routes
|
|
180
|
+
routes = []
|
|
181
|
+
routes.extend(create_agent_card_routes(self.agent_card))
|
|
182
|
+
routes.extend(create_jsonrpc_routes(request_handler=self.http_handler, rpc_url="/"))
|
|
183
|
+
self.app = Starlette(routes=routes)
|
|
184
|
+
self.app.add_middleware(IRCAHeaderTracingMiddleware, agent_instance=self)
|
|
185
|
+
|
|
186
|
+
# Register shutdown hook to disconnect from Gateway cleanly
|
|
187
|
+
async def shutdown_event():
|
|
188
|
+
if self.gateway_url:
|
|
189
|
+
try:
|
|
190
|
+
async with httpx.AsyncClient() as client:
|
|
191
|
+
await client.post(
|
|
192
|
+
f"{self.gateway_url.rstrip('/')}/register/disconnect",
|
|
193
|
+
json={"node_id": self.agent_id},
|
|
194
|
+
timeout=3
|
|
195
|
+
)
|
|
196
|
+
except Exception as e:
|
|
197
|
+
print(f"BFAAgent Warning: Failed to disconnect from gateway on shutdown: {e}")
|
|
198
|
+
|
|
199
|
+
# Register lifespan-based teardown handler dynamically
|
|
200
|
+
old_lifespan = self.app.router.lifespan_context
|
|
201
|
+
@asynccontextmanager
|
|
202
|
+
async def wrapped_lifespan(app_inst):
|
|
203
|
+
async with old_lifespan(app_inst) as yielded_val:
|
|
204
|
+
yield yielded_val
|
|
205
|
+
await shutdown_event()
|
|
206
|
+
self.app.router.lifespan_context = wrapped_lifespan
|
|
207
|
+
|
|
208
|
+
# Run auto-registration
|
|
209
|
+
if self.gateway_url:
|
|
210
|
+
self._auto_register_to_gateway()
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def public_key_pem(self) -> str:
|
|
214
|
+
pem = self._public_key.public_bytes(
|
|
215
|
+
encoding=serialization.Encoding.PEM,
|
|
216
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
|
217
|
+
)
|
|
218
|
+
return pem.decode("utf-8")
|
|
219
|
+
|
|
220
|
+
def _auto_register_to_gateway(self) -> bool:
|
|
221
|
+
"""Spawns register_with_gateway as a non-blocking background task."""
|
|
222
|
+
if not self.gateway_url:
|
|
223
|
+
return False
|
|
224
|
+
try:
|
|
225
|
+
loop = asyncio.get_running_loop()
|
|
226
|
+
loop.create_task(self.register_with_gateway(self.gateway_url))
|
|
227
|
+
return True
|
|
228
|
+
except RuntimeError:
|
|
229
|
+
import threading
|
|
230
|
+
threading.Thread(
|
|
231
|
+
target=lambda: asyncio.run(self.register_with_gateway(self.gateway_url)),
|
|
232
|
+
daemon=True
|
|
233
|
+
).start()
|
|
234
|
+
return True
|
|
235
|
+
|
|
236
|
+
async def register_with_gateway(self, gateway_url: str) -> bool:
|
|
237
|
+
"""
|
|
238
|
+
Dynamically register this agent with BFA Gateway using cryptographic challenge-response,
|
|
239
|
+
falling back to simple registration if unsupported.
|
|
240
|
+
"""
|
|
241
|
+
# Delay slightly to allow the local Uvicorn/Starlette port to bind and listen
|
|
242
|
+
await asyncio.sleep(1.0)
|
|
243
|
+
|
|
244
|
+
self.gateway_url = gateway_url
|
|
245
|
+
init_url = f"{gateway_url.rstrip('/')}/register/init"
|
|
246
|
+
verify_url = f"{gateway_url.rstrip('/')}/register/verify"
|
|
247
|
+
fallback_url = f"{gateway_url.rstrip('/')}/register/agent"
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
# 1. Initialize challenge
|
|
251
|
+
async with httpx.AsyncClient() as client:
|
|
252
|
+
# Try to download gateway public key if still missing
|
|
253
|
+
if not self.gateway_public_key:
|
|
254
|
+
try:
|
|
255
|
+
res_pub = await client.get(f"{gateway_url.rstrip('/')}/public_key", timeout=5)
|
|
256
|
+
if res_pub.status_code == 200:
|
|
257
|
+
pem_str = res_pub.json().get("public_key")
|
|
258
|
+
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
|
259
|
+
self.gateway_public_key = load_pem_public_key(pem_str.encode("utf-8"))
|
|
260
|
+
except Exception as e:
|
|
261
|
+
print(f"BFAAgent Warning: Could not download gateway public key during registration: {e}")
|
|
262
|
+
|
|
263
|
+
res = await client.post(init_url, json={"node_id": self.agent_id, "channels": self.channels}, timeout=5)
|
|
264
|
+
if res.status_code in (404, 405, 501):
|
|
265
|
+
raise NotImplementedError("Gateway does not support cryptographic challenge-response")
|
|
266
|
+
if res.status_code != 200:
|
|
267
|
+
raise NotImplementedError()
|
|
268
|
+
challenge = res.json()["challenge_bytes"]
|
|
269
|
+
|
|
270
|
+
# 2. Sign Challenge
|
|
271
|
+
signature = self._private_key.sign(
|
|
272
|
+
challenge.encode("utf-8"),
|
|
273
|
+
padding.PKCS1v15(),
|
|
274
|
+
hashes.SHA256()
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# 3. Verify Challenge and obtain Session JWT
|
|
278
|
+
res_verify = await client.post(verify_url, json={
|
|
279
|
+
"node_id": self.agent_id,
|
|
280
|
+
"signature": signature.hex(),
|
|
281
|
+
"public_key": self.public_key_pem
|
|
282
|
+
}, timeout=5)
|
|
283
|
+
|
|
284
|
+
if res_verify.status_code == 200:
|
|
285
|
+
data = res_verify.json()
|
|
286
|
+
self.session_token = data["session_token"]
|
|
287
|
+
self.token_expiry = data["expiry"]
|
|
288
|
+
|
|
289
|
+
# Also register URL/channels in gateway index
|
|
290
|
+
await client.post(
|
|
291
|
+
fallback_url,
|
|
292
|
+
params={"url": self.url, "channels": ",".join(self.channels)},
|
|
293
|
+
timeout=5
|
|
294
|
+
)
|
|
295
|
+
print(f"BFAAgent: Successfully registered '{self.agent_id}' via cryptographic handshake.")
|
|
296
|
+
return True
|
|
297
|
+
else:
|
|
298
|
+
raise NotImplementedError()
|
|
299
|
+
except (NotImplementedError, KeyError, Exception):
|
|
300
|
+
# Fall back to simple, unauthenticated registration for compatibility
|
|
301
|
+
try:
|
|
302
|
+
async with httpx.AsyncClient() as client:
|
|
303
|
+
res_simple = await client.post(
|
|
304
|
+
fallback_url,
|
|
305
|
+
params={"url": self.url, "channels": ",".join(self.channels)},
|
|
306
|
+
timeout=5
|
|
307
|
+
)
|
|
308
|
+
if res_simple.status_code == 200:
|
|
309
|
+
print(f"BFAAgent: Successfully registered '{self.agent_id}' via simple registration fallback.")
|
|
310
|
+
return True
|
|
311
|
+
else:
|
|
312
|
+
print(f"BFAAgent Error: Registration fallback failed with status {res_simple.status_code}: {res_simple.text}")
|
|
313
|
+
return False
|
|
314
|
+
except Exception as ex:
|
|
315
|
+
print(f"BFAAgent Error: Failed to connect to Gateway fallback at {fallback_url}: {ex}")
|
|
316
|
+
return False
|
|
317
|
+
|
|
318
|
+
def verify_incoming_det(self, delegated_token: str, expected_function: str, runtime_args: dict) -> bool:
|
|
319
|
+
"""
|
|
320
|
+
Offline Decentralized Verification performed locally by the agent node.
|
|
321
|
+
Validates the BFA-Gateway signature and enforces parameter lock-down.
|
|
322
|
+
"""
|
|
323
|
+
if not self.gateway_public_key:
|
|
324
|
+
return False
|
|
325
|
+
try:
|
|
326
|
+
decoded_det = jwt.decode(
|
|
327
|
+
delegated_token,
|
|
328
|
+
self.gateway_public_key,
|
|
329
|
+
algorithms=["RS256"],
|
|
330
|
+
audience=self.agent_id
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# Scope validation
|
|
334
|
+
if decoded_det.get("permitted_action") != expected_function:
|
|
335
|
+
return False
|
|
336
|
+
|
|
337
|
+
# Parameter lockdown verification
|
|
338
|
+
for key, value in decoded_det.get("restricted_params", {}).items():
|
|
339
|
+
if runtime_args.get(key) != value:
|
|
340
|
+
return False
|
|
341
|
+
|
|
342
|
+
return True
|
|
343
|
+
except Exception:
|
|
344
|
+
return False
|
|
345
|
+
|
|
346
|
+
@abc.abstractmethod
|
|
347
|
+
async def run(self, user_message: str, context: RequestContext) -> str:
|
|
348
|
+
"""
|
|
349
|
+
Process the user message and return the agent's response.
|
|
350
|
+
Must be implemented by subclasses.
|
|
351
|
+
"""
|
|
352
|
+
pass
|
|
353
|
+
|