fast_a2a_app 0.1.0__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.
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-05-05
4
+
5
+ Initial release.
6
+
7
+ ### Added
8
+
9
+ - `fast_a2a_app.server` — A2A protocol adapter extracted and cleaned from ppt-agent
10
+ - `build_a2a_app()` factory assembling a Starlette ASGI app
11
+ - `build_agent_invoke()` / `build_agent_stream_invoke()` wrappers for pydantic-ai
12
+ - `report_progress()` for live tool status updates via ContextVar
13
+ - `RedisTaskStore` with context-id indexing and cross-instance cancel signals
14
+ - `ConfigurableAgentExecutor` with `on_task_start` / `on_task_cancel` hooks
15
+ - `ContextAwareRequestContextBuilder` for multi-turn history injection
16
+ - `debug` parameter for controlling error verbosity
17
+ - `redis_url` parameter replacing hard-coded config import
18
+ - `fast_a2a_app.ui` — Self-contained browser chat UI (no build step, no npm)
19
+ - Streaming SSE with real-time progress indicator
20
+ - localStorage persistence (context ID, transcript, active task)
21
+ - Page-reload recovery via `tasks/resubscribe` and `tasks/get` fallback
22
+ - Markdown rendering with DOMPurify sanitisation
23
+ - Collapsible agent card panel
24
+ - `examples/holiday_planner/` — Full example application
25
+ - Holiday planning agent with 4 tools: destinations, itinerary, budget, essentials
26
+ - FastAPI app wiring agent + fast_a2a_app server + fast_a2a_app UI
@@ -0,0 +1,511 @@
1
+ Metadata-Version: 2.4
2
+ Name: fast_a2a_app
3
+ Version: 0.1.0
4
+ Summary: fast_a2a_app — Drop-in A2A server and chat UI for any AI agent
5
+ Project-URL: Homepage, https://github.com/boegeg/fast_a2a_app
6
+ Project-URL: Repository, https://github.com/boegeg/fast_a2a_app
7
+ Project-URL: Issues, https://github.com/boegeg/fast_a2a_app/issues
8
+ Author-email: Georg Boegerl <georg.boegerl@henkel.com>
9
+ License: MIT
10
+ Keywords: a2a,agent,ai,chat,fastapi,llm,server
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: a2a-sdk>=0.3.0
23
+ Requires-Dist: fastapi>=0.115.0
24
+ Requires-Dist: redis>=7.4.0
25
+ Requires-Dist: sse-starlette>=2.1.3
26
+ Requires-Dist: starlette>=0.41.0
27
+ Requires-Dist: uvicorn[standard]>=0.34.0
28
+ Description-Content-Type: text/markdown
29
+
30
+ # fast_a2a_app
31
+
32
+ **Drop-in A2A server and chat UI for any FastAPI application that runs ai agents — installable from PyPI.**
33
+
34
+ fast_a2a_app packages the battle-tested A2A protocol adapter and self-contained browser chat UI into a standalone pip-installable library. Get a fully
35
+ spec-compliant A2A server plus a ready-to-use chat interface in under 20 lines.
36
+
37
+ ```
38
+ pip install fast_a2a_app
39
+ ```
40
+
41
+ ---
42
+
43
+ ## Why fast_a2a_app?
44
+
45
+ [Pydantic AI](https://ai.pydantic.dev/) ships its own `FastA2A` integration, which is excellent if you are already inside the Pydantic AI ecosystem. fast_a2a_app exists for a different set of needs:
46
+
47
+ - **Mount point, not a framework.** fast_a2a_app is a plain Starlette app you mount into an existing FastAPI application at any path prefix. Everything outside that prefix — authentication middleware, custom routes, dependency injection, observability — is yours to own and compose however you like.
48
+ - **Framework-agnostic.** The library has zero dependency on Pydantic AI. Wire in any agent: raw Anthropic/OpenAI API calls, LangChain, LlamaIndex, or plain Python — as long as it exposes an `async (str) -> str` function or an async generator.
49
+ - **Separation of concerns.** Your FastAPI application stays in charge of the HTTP layer (auth, rate limiting, CORS, health checks). fast_a2a_app only handles the A2A protocol inside its mounted prefix, keeping agent logic cleanly decoupled from transport concerns.
50
+
51
+ We hope this contributes to a composable AI agent architecture where protocol adapters, agent frameworks, and application infrastructure are independent choices.
52
+
53
+ ---
54
+
55
+ ## What's inside
56
+
57
+ | Module | What it does |
58
+ |---|---|
59
+ | `fast_a2a_app.server` | A2A JSON-RPC server (streaming SSE, multi-turn history, cross-instance cancel) |
60
+ | `fast_a2a_app.ui` | Self-contained browser chat UI — no build step, no npm |
61
+
62
+ ### Protocol features (via `a2a-sdk`)
63
+
64
+ - `message/stream` — streaming SSE responses
65
+ - `tasks/cancel` — immediate or cross-replica cancellation
66
+ - `tasks/resubscribe` — reconnect to an in-flight stream after a network blip
67
+ - `tasks/get` — snapshot fallback for page-reload recovery
68
+ - `.well-known/agent-card.json` — agent discovery
69
+
70
+ ### Server features
71
+
72
+ - **Multi-turn history** — every turn is stored in Redis and injected as a "Conversation so far:" prefix, giving the agent continuity without client-side replay
73
+ - **Cross-instance cancellation** — cancel signals flow through Redis so any replica can stop a task running on another replica
74
+ - **Live progress updates** — call `report_progress("step 2/5…")` from any tool and the chat UI spinner updates in real time
75
+ - **Lifecycle hooks** — `on_task_start` / `on_task_cancel` callbacks for metrics, locks, or state resets
76
+
77
+ ---
78
+
79
+ ## Storage
80
+
81
+ fast_a2a_app currently uses **Redis** for all server-side state:
82
+
83
+ | What is stored | Key pattern | TTL |
84
+ |---|---|---|
85
+ | Task JSON (full A2A task object) | `a2a:task:{id}` | 24 h |
86
+ | Conversation index (task_id → sequence) | `a2a:context:{cid}:tasks` | 24 h |
87
+ | Cross-instance cancel signal | `a2a:cancel:{id}` | 5 min |
88
+
89
+ Start a local Redis instance before running any example:
90
+
91
+ ```bash
92
+ docker run -d -p 6379:6379 redis:7-alpine
93
+ ```
94
+
95
+ Or point `REDIS_URL` at any managed Redis-compatible service (Redis Cloud, AWS ElastiCache, Azure Cache for Redis, etc.).
96
+
97
+ > **Roadmap** — pluggable storage backends (MongoDB, PostgreSQL) are planned.
98
+ > The `RedisTaskStore` already implements the `A2ATaskStore` Protocol, so a
99
+ > Mongo or Postgres backend can be swapped in by passing a custom
100
+ > `a2a_task_store` to `build_a2a_app()` without any library changes.
101
+
102
+ ---
103
+
104
+ ## Framework-agnostic design
105
+
106
+ fast_a2a_app has **no dependency on any AI framework**. `build_a2a_app` accepts two
107
+ plain callables that you implement however you like:
108
+
109
+ | Callable | Signature |
110
+ |---|---|
111
+ | `invoke` | `async (prompt: str) -> str` |
112
+ | `stream_invoke` | `async (prompt: str) -> AsyncIterable[str]` |
113
+
114
+ Wrap them with the two helpers and pass to `build_a2a_app`:
115
+
116
+ ```python
117
+ invoke=build_invoke(my_async_fn)
118
+ stream_invoke=build_stream_invoke(my_async_generator_fn)
119
+ ```
120
+
121
+ `build_stream_invoke` automatically sets up the `report_progress()` ContextVar,
122
+ so any code called during streaming can push live status updates to the chat UI —
123
+ regardless of which framework (or none) your agent uses.
124
+
125
+ You can also implement `invoke` and `stream_invoke` directly as bare callables
126
+ and pass them straight to `build_a2a_app` — no wrapper needed.
127
+
128
+ ---
129
+
130
+ ## Quickstart
131
+
132
+ ### 1. Install
133
+
134
+ ```bash
135
+ pip install fast_a2a_app
136
+ ```
137
+
138
+ ### 2. Implement your agent
139
+
140
+ Any `async (str) -> str` function works as the non-streaming invoke.
141
+ Any `async (str) -> AsyncIterable[str]` generator works for streaming.
142
+
143
+ ```python
144
+ # agent.py — Azure OpenAI chat completions, no framework needed
145
+ import os
146
+ from collections.abc import AsyncIterable
147
+ from openai import AsyncOpenAI
148
+
149
+ AZURE_AI_ENDPOINT = os.environ.get("AZURE_AI_ENDPOINT", "")
150
+ AZURE_AI_DEPLOYMENT = os.environ.get("AZURE_AI_DEPLOYMENT", "gpt-4o")
151
+ AZURE_AI_KEY = os.environ.get("AZURE_AI_KEY", "")
152
+ AZURE_AI_BASE_URL = os.environ.get(
153
+ "AZURE_AI_BASE_URL",
154
+ f"{AZURE_AI_ENDPOINT.rstrip('/')}/" if AZURE_AI_ENDPOINT else "",
155
+ )
156
+
157
+ if AZURE_AI_KEY:
158
+ api_key = AZURE_AI_KEY
159
+ else:
160
+ from azure.identity import AzureCliCredential, get_bearer_token_provider
161
+ api_key = get_bearer_token_provider(
162
+ AzureCliCredential(), "https://ai.azure.com/.default"
163
+ )
164
+
165
+ client = AsyncOpenAI(base_url=AZURE_AI_BASE_URL, api_key=api_key)
166
+
167
+ async def invoke(prompt: str) -> str:
168
+ resp = await client.chat.completions.create(
169
+ model=AZURE_AI_DEPLOYMENT, max_tokens=1024,
170
+ messages=[{"role": "user", "content": prompt}],
171
+ )
172
+ return (resp.choices[0].message.content or "").strip()
173
+
174
+ async def stream_invoke(prompt: str) -> AsyncIterable[str]:
175
+ stream = await client.chat.completions.create(
176
+ model=AZURE_AI_DEPLOYMENT, max_tokens=1024,
177
+ messages=[{"role": "user", "content": prompt}],
178
+ stream=True,
179
+ )
180
+ async for chunk in stream:
181
+ text = chunk.choices[0].delta.content or ""
182
+ if text:
183
+ yield text
184
+ ```
185
+
186
+ ### 3. Wire up the server
187
+
188
+ ```python
189
+ # main.py
190
+ from fastapi import FastAPI
191
+ from a2a.types import AgentCapabilities, AgentCard
192
+ from fast_a2a_app import a2a_ui, build_a2a_app, build_invoke, build_stream_invoke
193
+ from agent import invoke, stream_invoke
194
+
195
+ app = FastAPI()
196
+
197
+ agent_card = AgentCard(
198
+ name="My Agent",
199
+ description="Does cool things",
200
+ version="1.0.0",
201
+ url="http://localhost:8000/a2a/",
202
+ capabilities=AgentCapabilities(streaming=True),
203
+ default_input_modes=["text"],
204
+ default_output_modes=["text"],
205
+ )
206
+
207
+ app.mount("/a2a", build_a2a_app(
208
+ agent_card=agent_card,
209
+ invoke=build_invoke(invoke),
210
+ stream_invoke=build_stream_invoke(stream_invoke),
211
+ ))
212
+
213
+ app.mount("/", a2a_ui) # built-in chat UI at http://localhost:8000/
214
+ ```
215
+
216
+ ### 4. Run
217
+
218
+ ```bash
219
+ # Start Redis (required for conversation history)
220
+ docker run -d -p 6379:6379 redis:7-alpine
221
+
222
+ # Run the app
223
+ uvicorn main:app --reload
224
+ ```
225
+
226
+ Open `http://localhost:8000/` — you're chatting.
227
+
228
+ ---
229
+
230
+ ## API reference
231
+
232
+ ### `build_a2a_app(...)`
233
+
234
+ Assembles a Starlette ASGI app. Mount it at any path prefix.
235
+
236
+ | Parameter | Type | Default | Description |
237
+ |---|---|---|---|
238
+ | `agent_card` | `AgentCard` | required | Pre-built A2A agent card (name, description, version, url, skills, capabilities) |
239
+ | `invoke` | `Callable` | required | Non-streaming callable — use `build_invoke()` to wrap |
240
+ | `stream_invoke` | `Callable \| None` | `None` | Streaming callable — use `build_stream_invoke()` to wrap |
241
+ | `prompt_builder` | `Callable \| None` | history+cid prefix | Custom prompt assembly function |
242
+ | `on_task_start` | `Callable[[str], Awaitable] \| None` | `None` | Called before each task |
243
+ | `on_task_cancel` | `Callable[[str], Awaitable] \| None` | `None` | Called on cancel |
244
+ | `a2a_task_store` | `A2ATaskStore \| None` | auto | Custom task store |
245
+ | `redis_client` | `aioredis.Redis \| None` | auto | Custom Redis client |
246
+ | `redis_url` | `str` | `"redis://localhost:6379"` | Redis connection string |
247
+ | `debug` | `bool` | `False` | Include exception details in failure messages |
248
+
249
+ ### `build_invoke(run)`
250
+
251
+ Wraps any `async (prompt: str) -> str` function as a non-streaming A2A invoke.
252
+ Works with any AI framework or plain API call.
253
+
254
+ ### `build_stream_invoke(run)`
255
+
256
+ Wraps any `async (prompt: str) -> AsyncIterable[str]` generator as a streaming A2A invoke.
257
+ Also sets up the `report_progress()` ContextVar so live progress updates work
258
+ out of the box — call `report_progress("step 2/5…")` anywhere during execution
259
+ and it will appear as a working-status event in the chat UI.
260
+
261
+ ### `report_progress(message)`
262
+
263
+ Call from any agent tool to push a status string to the chat UI spinner.
264
+ Has no effect outside a streaming context (safe to call unconditionally).
265
+
266
+ ```python
267
+ @agent.tool_plain
268
+ async def long_computation(n: int) -> str:
269
+ report_progress(f"Computing step 1/{n}…")
270
+ # …
271
+ report_progress(f"Computing step 2/{n}…")
272
+ return result
273
+ ```
274
+
275
+ ### `a2a_ui`
276
+
277
+ A Starlette ASGI app serving a self-contained single-page chat interface.
278
+ No build step, no npm. Mount it at `"/"` to serve the UI.
279
+
280
+ ```python
281
+ app.mount("/", a2a_ui)
282
+ ```
283
+
284
+ The UI reads the agent card from `/a2a/.well-known/agent-card.json` to populate
285
+ the header name and the collapsible info panel.
286
+
287
+ ---
288
+
289
+ ## Example: Holiday Planner
290
+
291
+ `examples/holiday_planner/` is a complete example showing how to build a
292
+ domain-specific agent on fast_a2a_app.
293
+
294
+ ```
295
+ examples/holiday_planner/
296
+ ├── agent.py # pydantic-ai agent with 4 tools
297
+ ├── main.py # FastAPI app + fast_a2a_app wiring
298
+ └── requirements.txt
299
+ ```
300
+
301
+ ### Running the example
302
+
303
+ ```bash
304
+ # One-time: create your .env from the shared template
305
+ cp examples/.env.example examples/.env
306
+ # edit examples/.env — set AZURE_AI_ENDPOINT and AZURE_AI_DEPLOYMENT
307
+
308
+ cd examples/holiday_planner
309
+ pip install -e ../../ # install fast_a2a_app from repo root
310
+ pip install -r requirements.txt
311
+
312
+ docker run -d -p 6379:6379 redis:7-alpine
313
+
314
+ uvicorn main:app --reload
315
+ ```
316
+
317
+ Open `http://localhost:8000/` and ask:
318
+
319
+ > *"I want to plan a 10-day trip somewhere in Southeast Asia in September,
320
+ > moderate budget, interested in food, temples, and nature. Can you help?"*
321
+
322
+ The agent will ask follow-up questions, then use its tools to recommend
323
+ destinations, build a day-by-day itinerary, estimate costs, and provide
324
+ travel essentials — all with live progress updates in the UI.
325
+
326
+ ### Holiday planner tools
327
+
328
+ | Tool | Description |
329
+ |---|---|
330
+ | `recommend_destinations` | 2-3 tailored destination suggestions with pros/cons |
331
+ | `create_itinerary` | Day-by-day plan with restaurants and local tips |
332
+ | `estimate_budget` | Cost breakdown table per person per day |
333
+ | `get_travel_essentials` | Visa, health, weather, and packing guide |
334
+
335
+ ---
336
+
337
+ ## Example: Echo Agent (no LLM, no external dependencies)
338
+
339
+ `examples/echo_agent/` is the minimal fast_a2a_app integration — pure Python, no API key, no AI framework.
340
+
341
+ ```
342
+ examples/echo_agent/
343
+ ├── agent.py # Two plain async functions, zero external imports
344
+ ├── main.py # FastAPI app
345
+ └── requirements.txt # fast_a2a_app only
346
+ ```
347
+
348
+ ```python
349
+ # agent.py
350
+ async def invoke(prompt: str) -> str:
351
+ return f"Echo: {prompt}"
352
+
353
+ async def stream_invoke(prompt: str) -> AsyncIterable[str]:
354
+ for i, word in enumerate(f"Echo: {prompt}".split()):
355
+ yield word if i == len(words) - 1 else word + " "
356
+ await asyncio.sleep(0.05) # makes streaming visible in the UI
357
+ ```
358
+
359
+ ### Running the echo agent
360
+
361
+ ```bash
362
+ cd examples/echo_agent
363
+ pip install -e ../../
364
+ pip install -r requirements.txt
365
+
366
+ docker run -d -p 6379:6379 redis:7-alpine
367
+ uvicorn main:app --reload
368
+ ```
369
+
370
+ > No `.env` needed — the echo agent requires no API key. A `REDIS_URL` can
371
+ > be set via `examples/.env` if you need a non-default Redis address.
372
+
373
+ No API key needed. Open `http://localhost:8000/` and type anything.
374
+
375
+ ---
376
+
377
+ ## Example: Joke Agent (raw chat completions, no agent framework)
378
+
379
+ `examples/joke_agent/` shows fast_a2a_app wired to plain Azure OpenAI chat completions — no agent framework at all.
380
+
381
+ ```
382
+ examples/joke_agent/
383
+ ├── agent.py # Two plain async functions: run_joke_agent + stream_joke_agent
384
+ ├── main.py # FastAPI app using build_invoke / build_stream_invoke
385
+ └── requirements.txt
386
+ ```
387
+
388
+ `agent.py` defines two callables that satisfy the fast_a2a_app contract:
389
+
390
+ ```python
391
+ # Non-streaming: async (str) -> str
392
+ async def run_joke_agent(prompt: str) -> str:
393
+ response = await client.chat.completions.create(model=..., messages=[...])
394
+ return (response.choices[0].message.content or "").strip()
395
+
396
+ # Streaming: async (str) -> AsyncIterable[str]
397
+ async def stream_joke_agent(prompt: str) -> AsyncIterable[str]:
398
+ stream = await client.chat.completions.create(model=..., messages=[...], stream=True)
399
+ async for chunk in stream:
400
+ text = chunk.choices[0].delta.content or ""
401
+ if text:
402
+ yield text
403
+ ```
404
+
405
+ `main.py` wires them in with the helpers:
406
+
407
+ ```python
408
+ from a2a.types import AgentCapabilities, AgentCard, AgentSkill
409
+ from fast_a2a_app import build_a2a_app, build_invoke, build_stream_invoke, a2a_ui
410
+ from agent import run_joke_agent, stream_joke_agent
411
+
412
+ agent_card = AgentCard(
413
+ name="Joke Agent",
414
+ description="Your AI stand-up comedian.",
415
+ version="0.1.0",
416
+ url="http://localhost:8000/a2a/",
417
+ capabilities=AgentCapabilities(streaming=True),
418
+ default_input_modes=["text"],
419
+ default_output_modes=["text"],
420
+ skills=[AgentSkill(id="tell_joke", name="Tell a joke", description="Tells a joke on any topic.")],
421
+ )
422
+
423
+ app.mount("/a2a", build_a2a_app(
424
+ agent_card=agent_card,
425
+ invoke=build_invoke(run_joke_agent),
426
+ stream_invoke=build_stream_invoke(stream_joke_agent),
427
+ ))
428
+ app.mount("/", a2a_ui)
429
+ ```
430
+
431
+ ### Running the joke agent
432
+
433
+ ```bash
434
+ # One-time: create your .env from the shared template
435
+ cp examples/.env.example examples/.env
436
+ # edit examples/.env — set AZURE_AI_ENDPOINT and AZURE_AI_DEPLOYMENT
437
+
438
+ cd examples/joke_agent
439
+ pip install -e ../../
440
+ pip install -r requirements.txt
441
+
442
+ docker run -d -p 6379:6379 redis:7-alpine
443
+ uvicorn main:app --reload
444
+ ```
445
+
446
+ Open `http://localhost:8000/` and try:
447
+
448
+ > *"Tell me a programming joke"* or *"Give me your best dad joke"*
449
+
450
+ Tokens stream directly from the Azure OpenAI API to the browser as they arrive.
451
+
452
+ ---
453
+
454
+ ## Architecture
455
+
456
+ ```
457
+ FastAPI app
458
+ ├── /a2a ← Starlette ASGI app (build_a2a_app)
459
+ │ ├── POST / message/stream, tasks/cancel, …
460
+ │ └── GET /.well-known/agent-card.json
461
+ └── / ← a2a_ui (Starlette, single HTML file)
462
+
463
+ Redis
464
+ ├── a2a:task:{id} Task JSON (24 h TTL)
465
+ ├── a2a:context:{cid}:tasks Context index (task_id → sequence)
466
+ ├── a2a:context:{cid}:sequence Sequence counter
467
+ └── a2a:cancel:{id} Cancel signal (5 min TTL)
468
+ ```
469
+
470
+ ### Conversation history injection
471
+
472
+ Each A2A task has a `context_id` shared across all turns of a conversation.
473
+ `ContextAwareRequestContextBuilder` fetches all prior tasks for the same
474
+ `context_id` from Redis, extracts the last 12 lines of dialogue, and
475
+ prepends them as `"Conversation so far:\n…"` to each new prompt.
476
+
477
+ The agent therefore sees recent history without the client needing to
478
+ replay it — multi-turn conversation just works.
479
+
480
+ ### How streaming works
481
+
482
+ `build_stream_invoke` wraps your generator in an `asyncio.Queue`-based
483
+ relay. Before starting the generator it sets a `ContextVar` callback that
484
+ `report_progress()` reads. Strings from `report_progress()` are placed in
485
+ the queue with a sentinel prefix; `ConfigurableAgentExecutor` routes them
486
+ to non-final `working` SSE events. All other yielded strings become
487
+ `artifact-update` events — the streaming text the user sees.
488
+
489
+ ---
490
+
491
+ ## Publishing to PyPI
492
+
493
+ ```bash
494
+ pip install hatch
495
+ hatch build
496
+ hatch publish
497
+ ```
498
+
499
+ Or with `twine`:
500
+
501
+ ```bash
502
+ pip install build twine
503
+ python -m build
504
+ twine upload dist/*
505
+ ```
506
+
507
+ ---
508
+
509
+ ## License
510
+
511
+ MIT