Skip to content

Commit f5b8833

Browse files
authored
feat: Initial multi-agent scenario (#11)
details: - Simple multi-agent workflow. - Removed LlamaIndex and switched to Pydantic AI. - Added OAuth authentication for the web frontend when exposed using ngrok using a traffic policy. - Added live streaming in command line mode.
1 parent 2e3dfe1 commit f5b8833

28 files changed

+2359
-2451
lines changed

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ However, DQA is experimental with the emphasis on standardising agentic communic
1919
## Installation
2020

2121
- Install [`uv` package manager](https://docs.astral.sh/uv/getting-started/installation/).
22+
- Install self-hosted ([with Docker](https://docs.prefect.io/v3/how-to-guides/self-hosted/server-docker)) a Prefect server by running `docker run -p 4200:4200 -d --restart unless-stopped --name prefect prefecthq/prefect:3-latest -- prefect server start --host 0.0.0.0`. _This is only necessary for [Durable Agents](https://ai.pydantic.dev/durable_execution/overview/) connecting to a self-hosted Prefect server_. Note that Durable Agents support is experimental and may be dropped in the future.
2223
- Install project dependencies by running `uv sync --all-groups`.
2324
- Configure Dapr to run [with docker](https://docs.dapr.io/operations/hosting/self-hosted/self-hosted-with-docker/).
2425
- Run `dapr init` to initialise `daprd` and the relevant containers.
@@ -49,12 +50,15 @@ The following API keys are optional but maybe provided for additional functional
4950
The following environment variables are all optional.
5051
- `APP_LOG_LEVEL`: The general log level of the DQA app. Defaults to `INFO`.
5152
- `DQA_MCP_SERVER_TRANSPORT`, `FASTMCP_HOST` and `FASTMCP_PORT`: These specify the transport type, the host and port for the built-in MCP server of DQA. The default values are `stdio`, `localhost` and `8000` respectively.
53+
- `HTTPX_TIMEOUT`: This specifies the timeout value in seconds a HTTP client will wait for the server to respond before raising an error. This applies, for example, to the communication timeout between the web frontend or CLI frontend to the A2A endpoint(s). Default value is 120 (seconds).
5254
- `LLM_CONFIG_FILE` and `MCP_CONFIG_FILE`: These specify where the LLM and MCP configurations These default to `conf/llm.json` and `conf/mcp.json` respectively.
53-
- [Gradio environment variables](https://www.gradio.app/guides/environment-variables) to configure the DQA web app. However, MCP server (not to be confused with DQA's built-in MCP server), SSR mode, API and public sharing will be disabled, irrespective of what is specified through the environment variables.
55+
- [Gradio environment variables](https://www.gradio.app/guides/environment-variables) to configure the DQA web app. However, MCP server (not to be confused with DQA's built-in MCP server), server-side rendering (SSR) mode, API, Progressive Web App (PWA), analytics and public sharing will be disabled, irrespective of what is specified through the environment variables.
56+
- `PREFECT_API_URL`: This can be used to specify the Prefect Cloud API URL (in which case, you must set the `PREFECT_API_KEY`, see details) or the local self-hosted API URL at `http://localhost:4200/api`. The default value is None, which _will turn off Durable Agents_!
5457
- `BROWSER_STATE_SECRET`: This is the secret used by Gradio to encrypt the browser state data. The default value is `a2a_dapr_bstate_secret`.
5558
- `BROWSER_STATE_CHAT_HISTORIES`: This is the key in browser state used by Gradio to store the chat histories (local values). The default value is `a2a_dapr_chat_histories`.
5659
- `APP_DAPR_SVC_HOST` and `APP_DAPR_SVC_PORT`: The host and port at which Dapr actor service will listen on. These default to `127.0.0.1` and `32768`. Should you change these, you must change the corresponding information in `dapr.yaml`.
5760
- `APP_DAPR_PUBSUB_STALE_MSG_SECS`: This specifies how old a message should be on the Dapr publish-subscribe topic queue before it will be considered too old, and dropped. The default value is 60 seconds.
61+
- `APP_DAPR_PUBSUB_MEMORY_STREAM_BUFFER_SIZE`: This specifies the internal memory object stream buffer size that is used to receive messages through the Dapr publish-subscribe subscription and pass it on to the A2A executor. Default value is 65536.
5862
- `APP_DAPR_ACTOR_RETRY_ATTEMPTS`: This specifies the number of times an agent executor will try to invoke a method on an actor, if it fails to succeed. The default value is 3.
5963
- `DAPR_PUBSUB_NAME`: The configured name of the publish-subscribe component at `.dapr/components/pubsub.yaml`. Change this environment variable only if you change the corresponding pub-sub component configuration.
6064
- `APP_A2A_SRV_HOST` and `APP_MHQA_A2A_SRV_PORT`: The host and port at which A2A endpoint will be available. These default to `127.0.0.1` and `32770`. Should you change these, you must change the corresponding information in `dapr.yaml`.
@@ -66,6 +70,10 @@ The following environment variables are all optional.
6670
- Invoke the A2A agent using JSON-RPC by calling `uv run dqa-cli --help` to learn about the various skills-based A2A endpoint invocations.
6771
- Or, start the Gradio web app by running `uv run dqa-web-app` and then browse to http://localhost:7860.
6872
- Once done, stop the dapr sidecars by running `./stop_dapr_multi.sh`.
73+
- Alternatively, you could also run `./unified_dapr_webapp.sh` to run the Dapr sidecars as well as the web app, which will be available at http://localhost:7860. Press Ctrl+C to abort the server and the unified runner script will also shutdown the Dapr sidecars.
74+
- Further to exposing the web app on localhost, you could also call `./run_ngrok.sh` (which requires you to have `ngrok` setup, [see instructions](https://ngrok.com/download/)) with an optional parameter `--domain your-ngrok-FQDN` to make your app available through `ngrok` publicly at https://your-ngrok-FQDN/.
75+
- Note that a feature of exposing the app over `ngrok` is that the configured traffic policy for `ngrok` will require any user accessing the app to authenticate themselves using an OAuth provider (GitHub). This is necessary to transparently create a user namespace such that two users concurrently each naming a chat ID as `test-chat` (for instance) will _not_ have a chat ID collision because each chat will be effectively handled by a different actor ID in the namespace based on the underlying OAuth provider supplied user ID. Thus, `user1__test-chat` is different from `user2__test-chat`.
76+
- Also note that the same OAuth-authenticated user using more than one separate browsers will _not_ have the same list of chat IDs visible on each browser. This is because the chat IDs list is local to each browser. However, the user will be able to load a specific chat ID on one browser in another browser by manually adding that chat ID. Since those chat IDs are in their OAuth-authenticated user namespaces, an authenticated user will only be able to add their own chat ID, not someone else's.
6977

7078
## Tests and coverage
7179

conf/llm.json

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
{
2-
"ollama": {
3-
"base_url": "http://localhost:11434",
4-
"model": "gpt-oss:20b-cloud",
5-
"request_timeout": 300,
6-
"thinking": false
2+
"responder": {
3+
"base_url": "http://localhost:11434/v1",
4+
"model": "gpt-oss:20b-cloud"
5+
},
6+
"reviewer": {
7+
"base_url": "http://localhost:11434/v1",
8+
"model": "gpt-oss:20b-cloud"
79
}
810
}

conf/mcp.json

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
{
2-
"builtin": {
3-
"transport": "stdio",
4-
"command": "uv",
5-
"args": [
6-
"run",
7-
"dqa-mcp"
8-
],
9-
"env": {
10-
"APP_LOG_LEVEL": "CRITICAL",
11-
"DQA_MCP_SERVER_TRANSPORT": "stdio"
2+
"mcpServers": {
3+
"builtin": {
4+
"transport": "stdio",
5+
"command": "uv",
6+
"args": [
7+
"run",
8+
"dqa-mcp"
9+
],
10+
"env": {
11+
"APP_LOG_LEVEL": "CRITICAL",
12+
"DQA_MCP_SERVER_TRANSPORT": "stdio"
13+
}
1214
}
1315
}
1416
}

dapr.yaml

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,6 @@ apps:
1919
maxBodySize: 256Mi
2020
# appLogDestination: file # (optional), can be file, console or fileAndConsole. default is fileAndConsole.
2121
# daprdLogDestination: file # (optional), can be file, console or fileAndConsole. default is file.
22-
- appID: echo-a2a-srv # optional
23-
appDirPath: . # REQUIRED
24-
appChannelAddress: 127.0.0.1
25-
command: ["uv", "run", "echo-a2a-srv"]
26-
readBufferSize: 32Ki
27-
maxBodySize: 256Mi
2822
- appID: mhqa-a2a-srv # optional
2923
appDirPath: . # REQUIRED
3024
appChannelAddress: 127.0.0.1

ngrok_traffic_policy.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# See: https://ngrok.com/blog/block-threats-waf-actions -- this is a paid feature
2+
on_http_request:
3+
- actions:
4+
- type: oauth
5+
config:
6+
provider: github
7+
idle_session_timeout: 15m
8+
max_session_duration: 1h
9+
- actions:
10+
- type: add-headers
11+
config:
12+
headers:
13+
x-auth-user-id: "${actions.ngrok.oauth.identity.provider_user_id}"
14+
x-auth-user-name: "${actions.ngrok.oauth.identity.name}"

pyproject.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ readme = "README.md"
66
authors = [
77
{ name = "Anirban Basu", email = "anirbanbasu@users.noreply.github.com" }
88
]
9-
requires-python = ">=3.12.0"
9+
requires-python = ">=3.12.0,<3.14"
1010
dependencies = [
1111
"a2a-sdk[http-server]>=0.3.6",
1212
"anyio>=4.11.0",
@@ -16,22 +16,22 @@ dependencies = [
1616
"fastmcp>=2.12.4",
1717
"frankfurtermcp>=0.3.6",
1818
"gradio>=5.46.1",
19-
"llama-index-llms-ollama>=0.7.4",
20-
"llama-index-tools-mcp>=0.4.1",
2119
"ollama>=0.6.0",
20+
"pydantic-ai-slim[openai,fastmcp,prefect]>=1.7.0",
21+
"pydantic-graph>=1.7.0",
2222
"typer>=0.19.1",
2323
"yfmcp>=0.4.8",
2424
]
2525

2626
[project.scripts]
2727
pad = "dqa:main"
2828
dapr-srv = "dqa.server.dapr:main"
29-
echo-a2a-srv = "dqa.server.echo_a2a:main"
3029
mhqa-a2a-srv = "dqa.server.mhqa_a2a:main"
3130
dqa-mcp = "dqa.mcp.primary:main"
3231
dqa-cli = "dqa.cli.a2a:main"
3332
dqa-web-app = "dqa.web.gradio:main"
3433
demo-client = "dqa.cli.demo:main"
34+
test-ma = "dqa.agent_workflow.wf_orchestrator:main"
3535

3636
[build-system]
3737
requires = ["uv_build>=0.8.15,<0.9.0"]

run_ngrok.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#!/bin/bash
2+
# Add a domain such as --domain your-custom-domain.ngrok-free.app
3+
# Add --traffic-policy-file ngrok_traffic_policy.yml if your ngrok plan supports it
4+
ngrok http 7860 --host-header='localhost:7860' --name 'dqa-ngrok' --log-format 'json' --traffic-policy-file ngrok_traffic_policy.yml $@

run_upgrade.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
#!/bin/bash
22
# Upgrade the dependencies including those from Git sources
3-
uv lock -U
3+
uv lock -U --prerelease=allow

src/dqa/__init__.py

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import logging
2+
import re
23
from typing import ClassVar
34
from rich.logging import RichHandler
45
from environs import Env
56

6-
from marshmallow.validate import OneOf
7+
from marshmallow.validate import OneOf, Range, Regexp
78

89
try:
910
from icecream import ic
@@ -17,46 +18,83 @@
1718
env.read_env()
1819

1920

20-
class ParsedEnvVars:
21+
class EnvVars:
2122
APP_LOG_LEVEL: str = env.str("APP_LOG_LEVEL", default="INFO").upper()
23+
HTTPX_TIMEOUT: float = env.float(
24+
"HTTPX_TIMEOUT",
25+
default=120.0,
26+
validate=Range(min=5.0, max=600.0),
27+
)
2228
DQA_MCP_SERVER_TRANSPORT: str = env.str(
2329
"DQA_MCP_SERVER_TRANSPORT",
2430
default="stdio",
2531
validate=OneOf(["stdio", "sse", "streamable-http"]),
2632
)
2733
FASTMCP_HOST: str = env.str("FASTMCP_HOST", default="localhost")
2834
FASTMCP_PORT: int = env.int("FASTMCP_PORT", default=8000)
35+
2936
LLM_CONFIG_FILE: str = env.str("LLM_CONFIG_FILE", default="conf/llm.json")
3037
MCP_CONFIG_FILE: str = env.str("MCP_CONFIG_FILE", default="conf/mcp.json")
3138
APP_DAPR_SVC_HOST: str = env.str("APP_DAPR_SVC_HOST", default="127.0.0.1")
32-
APP_DAPR_SVC_PORT: int = env.int("APP_DAPR_SVC_PORT", default=32768)
39+
APP_DAPR_SVC_PORT: int = env.int(
40+
"APP_DAPR_SVC_PORT",
41+
default=32768,
42+
validate=Range(min=4096, max=65535),
43+
)
3344
APP_DAPR_PUBSUB_STALE_MSG_SECS: int = env.int(
34-
"APP_DAPR_PUBSUB_STALE_MSG_SECS", default=60
45+
"APP_DAPR_PUBSUB_STALE_MSG_SECS",
46+
default=60,
47+
validate=Range(min=5, max=3600),
3548
)
3649
APP_DAPR_ACTOR_RETRY_ATTEMPTS: int = env.int(
37-
"APP_DAPR_ACTOR_RETRY_ATTEMPTS", default=3
50+
"APP_DAPR_ACTOR_RETRY_ATTEMPTS",
51+
default=3,
52+
validate=Range(min=1, max=10),
53+
)
54+
AGENT_RETRY_ATTEMPTS: int = env.int(
55+
"AGENT_RETRY_ATTEMPTS",
56+
default=3,
57+
validate=Range(min=1, max=10),
3858
)
3959
APP_A2A_SRV_HOST: str = env.str("APP_A2A_SRV_HOST", default="127.0.0.1")
40-
APP_MHQA_A2A_SRV_PORT: int = env.int("APP_MHQA_A2A_SRV_PORT", default=32770)
60+
APP_MHQA_A2A_SRV_PORT: int = env.int(
61+
"APP_MHQA_A2A_SRV_PORT",
62+
default=32770,
63+
validate=Range(min=4096, max=65535),
64+
)
4165
APP_MHQA_A2A_REMOTE_URL: str = env.str("APP_MHQA_A2A_REMOTE_URL", default=None)
42-
APP_ECHO_A2A_SRV_PORT: int = env.int("APP_ECHO_A2A_SRV_PORT", default=32769)
4366
DAPR_PUBSUB_NAME: str = env.str("DAPR_PUBSUB_NAME", default="pubsub")
67+
APP_DAPR_PUBSUB_MEMORY_STREAM_BUFFER_SIZE: int = env.int(
68+
"APP_DAPR_PUBSUB_MEMORY_STREAM_BUFFER_SIZE",
69+
default=65536,
70+
validate=Range(min=32768, max=(2**31 - 1)),
71+
)
4472
MCP_SERVER_HOST: str = env.str("FASTMCP_HOST", default="localhost")
4573
MCP_SERVER_PORT: int = env.int("FASTMCP_PORT", default=8000)
4674
BROWSER_STATE_SECRET: str = env.str(
47-
"BROWSER_STATE_SECRET", default="a2a_dapr_bstate_secret"
75+
"BROWSER_STATE_SECRET",
76+
default="a2a_dapr_bstate_secret",
77+
validate=Regexp(
78+
r"^[A-Za-z0-9]{1,8}(?:_[A-Za-z0-9]{1,8}){1,4}$", flags=re.IGNORECASE
79+
),
4880
)
4981
BROWSER_STATE_CHAT_HISTORIES: str = env.str(
50-
"BROWSER_STATE_CHAT_HISTORIES", default="a2a_dapr_chat_histories"
82+
"BROWSER_STATE_CHAT_HISTORIES",
83+
default="a2a_dapr_chat_histories",
84+
validate=Regexp(
85+
r"^[A-Za-z0-9]{1,8}(?:_[A-Za-z0-9]{1,8}){1,4}$", flags=re.IGNORECASE
86+
),
5187
)
5288

89+
PREFECT_API_URL: str = env.str("PREFECT_API_URL", default=None)
90+
5391
API_KEY_ALPHAVANTAGE: str = env.str("ALPHAVANTAGE_API_KEY", default=None)
5492
API_KEY_TAVILY: str = env.str("TAVILY_API_KEY", default=None)
5593
API_KEY_OLLAMA: str = env.str("OLLAMA_API_KEY", default=None)
5694

5795
_instance: ClassVar = None
5896

59-
def __new__(cls: type["ParsedEnvVars"]) -> "ParsedEnvVars":
97+
def __new__(cls: type["EnvVars"]) -> "EnvVars":
6098
if cls._instance is None:
6199
# Create instance using super().__new__ to bypass any recursion
62100
instance = super().__new__(cls)
@@ -65,7 +103,7 @@ def __new__(cls: type["ParsedEnvVars"]) -> "ParsedEnvVars":
65103

66104

67105
logging.basicConfig(
68-
level=ParsedEnvVars().APP_LOG_LEVEL,
106+
level=EnvVars.APP_LOG_LEVEL,
69107
format="%(message)s",
70108
datefmt="[%X]",
71109
handlers=[RichHandler(show_time=True, show_level=True, show_path=True)],

src/dqa/actor/echo_task.py

Lines changed: 0 additions & 95 deletions
This file was deleted.

0 commit comments

Comments
 (0)