Skip to content

Commit 70471d0

Browse files
authored
add marvin example to samples/python (google#106)
* merge conflict * merge conflict * update readme * small fix * multi turn * improve and fix multiturn clean up update link update type readme * clean up nit * lockfile * update readme * address review comments clarify
1 parent 9485e26 commit 70471d0

File tree

10 files changed

+1050
-97
lines changed

10 files changed

+1050
-97
lines changed

Diff for: README.md

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ The Agent2Agent (A2A) protocol facilitates communication between independent AI
5757
* [LangGraph](/samples/python/agents/langgraph/README.md)
5858
* [Genkit](/samples/js/src/agents/README.md)
5959
* [LlamaIndex](/samples/python/agents/llama_index_file_chat/README.md)
60+
* [Marvin](/samples/python/agents/marvin/README.md)
6061
* [Semantic Kernel](/samples/python/agents/semantickernel/README.md)
6162
* 📑 Review key topics to understand protocol details
6263
* [A2A and MCP](https://google.github.io/A2A/#/topics/a2a_and_mcp.md)

Diff for: samples/python/agents/marvin/.python-version

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.12

Diff for: samples/python/agents/marvin/README.md

+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Marvin Contact Extractor Agent (A2A Sample)
2+
3+
This sample demonstrates an agent using the [Marvin](https://github.com/prefecthq/marvin) framework to extract structured contact information from text, integrated with the Agent2Agent (A2A) protocol.
4+
5+
## Overview
6+
7+
The agent receives text, attempts to extract contact details (name, email, phone, etc.) into a structured format using Marvin. It manages conversational state across multiple turns to gather required information (name, email) before confirming the extracted data. The agent's response includes both a textual summary/question and the structured data via A2A.
8+
9+
10+
## Key Components
11+
12+
- **Marvin `ExtractorAgent` (`agent.py`)**: Core logic using `marvin` for extraction and managing multi-turn state via a dictionary.
13+
- **A2A `AgentTaskManager` (`task_manager.py`)**: Integrates the agent with the A2A protocol, managing task state (including streaming via SSE) and response formatting.
14+
- **A2A Server (`__main__.py`)**: Hosts the agent and task manager.
15+
16+
## Prerequisites
17+
18+
- Python 3.12+
19+
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
20+
- `OPENAI_API_KEY` (or other LLM provider creds supported by pydantic-ai)
21+
22+
## Setup & Running
23+
24+
1. Navigate to the Python samples directory:
25+
```bash
26+
cd samples/python/agents/marvin
27+
```
28+
29+
2. Set an LLM provider API key:
30+
```bash
31+
export OPENAI_API_KEY=your_api_key_here
32+
```
33+
34+
3. Set up the Python environment:
35+
```bash
36+
uv venv
37+
source .venv/bin/activate
38+
uv sync
39+
```
40+
41+
4. Run the Marvin agent server:
42+
```bash
43+
# Default host/port (localhost:10030)
44+
MARVIN_DATABASE_URL=sqlite+aiosqlite:///test.db MARVIN_LOG_LEVEL=DEBUG uv run .
45+
46+
# Custom host/port
47+
# uv run . --host 0.0.0.0 --port 8080
48+
```
49+
50+
Without `MARVIN_DATABASE_URL` set, conversation history will not be persisted by session id.
51+
52+
5. In a separate terminal, run an A2A [client](/samples/python/hosts/README.md) (e.g., the sample CLI):
53+
```bash
54+
# from samples/python/agents/marvin
55+
cd ../..
56+
57+
# from root
58+
cd samples/python
59+
60+
uv run hosts/cli --agent http://localhost:10030
61+
```
62+
63+
64+
## Extracted Data Structure
65+
66+
The structured data returned in the `DataPart` is defined as:
67+
68+
```python
69+
class ContactInfo(BaseModel):
70+
name: str = Field(description="Person's first and last name")
71+
email: EmailStr
72+
phone: str = Field(description="standardized phone number")
73+
organization: str | None = Field(None, description="org if mentioned")
74+
role: str | None = Field(None, description="title or role if mentioned")
75+
```
76+
77+
with a validator to render things nicely if you want and maybe serialize weird things.
78+
79+
## Learn More
80+
81+
- [Marvin Documentation](https://www.askmarvin.ai/)
82+
- [Marvin GitHub Repository](https://github.com/prefecthq/marvin)
83+
- [A2A Protocol Documentation](https://google.github.io/A2A/#/documentation)

Diff for: samples/python/agents/marvin/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

Diff for: samples/python/agents/marvin/__main__.py

+99
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""
2+
This is a sample agent that uses the Marvin framework to extract structured contact information from text.
3+
It is integrated with the Agent2Agent (A2A) protocol.
4+
"""
5+
6+
import logging
7+
8+
import click
9+
from agents.marvin.agent import ExtractorAgent
10+
from agents.marvin.task_manager import AgentTaskManager
11+
from common.server import A2AServer
12+
from common.types import AgentCapabilities, AgentCard, AgentSkill
13+
from common.utils.push_notification_auth import PushNotificationSenderAuth
14+
from pydantic import BaseModel, EmailStr, Field
15+
16+
logging.basicConfig(level=logging.INFO)
17+
logger = logging.getLogger(__name__)
18+
19+
20+
class ContactInfo(BaseModel):
21+
"""Structured contact information extracted from text."""
22+
23+
name: str = Field(description="Person's first and last name")
24+
email: EmailStr = Field(description="Email address")
25+
phone: str = Field(description="Phone number if present")
26+
organization: str | None = Field(
27+
None, description="Organization or company if mentioned"
28+
)
29+
role: str | None = Field(None, description="Job title or role if mentioned")
30+
31+
32+
@click.command()
33+
@click.option("--host", "host", default="localhost")
34+
@click.option("--port", "port", default=10030)
35+
@click.option("--result-type", "result_type", default="ContactInfo")
36+
@click.option(
37+
"--instructions",
38+
"instructions",
39+
default="Politely interrogate the user for their contact information. The schema of the result type implies what things you _need_ to get from the user.",
40+
)
41+
def main(host, port, result_type, instructions):
42+
"""Starts the Marvin Contact Extractor Agent server."""
43+
try:
44+
result_type = eval(result_type)
45+
except Exception as e:
46+
logger.error(f"Invalid result type: {e}")
47+
exit(1)
48+
49+
try:
50+
capabilities = AgentCapabilities(streaming=True, pushNotifications=True)
51+
skill = AgentSkill(
52+
id="extract_contacts",
53+
name="Contact Information Extraction",
54+
description="Extracts structured contact information from text",
55+
tags=["contact info", "structured extraction", "information extraction"],
56+
examples=[
57+
"My name is John Doe, email: [email protected], phone: (555) 123-4567"
58+
],
59+
)
60+
agent_card = AgentCard(
61+
name="Marvin Contact Extractor",
62+
description="Extracts structured contact information from text using Marvin's extraction capabilities",
63+
url=f"http://{host}:{port}/",
64+
version="1.0.0",
65+
defaultInputModes=ExtractorAgent.SUPPORTED_CONTENT_TYPES,
66+
defaultOutputModes=ExtractorAgent.SUPPORTED_CONTENT_TYPES,
67+
capabilities=capabilities,
68+
skills=[skill],
69+
)
70+
71+
notification_sender_auth = PushNotificationSenderAuth()
72+
notification_sender_auth.generate_jwk()
73+
server = A2AServer(
74+
agent_card=agent_card,
75+
task_manager=AgentTaskManager(
76+
agent=ExtractorAgent(
77+
instructions=instructions, result_type=result_type
78+
),
79+
notification_sender_auth=notification_sender_auth,
80+
),
81+
host=host,
82+
port=port,
83+
)
84+
85+
server.app.add_route(
86+
"/.well-known/jwks.json",
87+
notification_sender_auth.handle_jwks_endpoint,
88+
methods=["GET"],
89+
)
90+
91+
logger.info(f"Starting Marvin Contact Extractor server on {host}:{port}")
92+
server.start()
93+
except Exception as e:
94+
logger.exception(f"An error occurred during server startup: {e}", exc_info=e)
95+
exit(1)
96+
97+
98+
if __name__ == "__main__":
99+
main()

Diff for: samples/python/agents/marvin/agent.py

+116
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import logging
2+
import os
3+
import threading
4+
from collections.abc import AsyncIterable
5+
from typing import Annotated, Any, ClassVar
6+
7+
from common.types import TextPart
8+
from pydantic import BaseModel, Field
9+
10+
import marvin
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
ClarifyingQuestion = Annotated[
16+
str, Field(description="A clarifying question to ask the user")
17+
]
18+
19+
20+
def _to_text_part(text: str) -> TextPart:
21+
return TextPart(type="text", text=text)
22+
23+
24+
class ExtractionOutcome[T](BaseModel):
25+
"""Represents the result of trying to extract contact info."""
26+
27+
extracted_data: T
28+
summary: str = Field(
29+
description="summary of the extracted information.",
30+
)
31+
32+
33+
class ExtractorAgent[T]:
34+
"""Contact information extraction agent using Marvin framework."""
35+
36+
SUPPORTED_CONTENT_TYPES: ClassVar[list[str]] = [
37+
"text",
38+
"text/plain",
39+
"application/json",
40+
]
41+
42+
def __init__(self, instructions: str, result_type: type[T]):
43+
self.instructions = instructions
44+
self.result_type = result_type
45+
46+
async def invoke(self, query: str, sessionId: str) -> dict[str, Any]:
47+
"""Process a user query with marvin
48+
49+
Args:
50+
query: The user's input text.
51+
sessionId: The session identifier
52+
53+
Returns:
54+
A dictionary describing the outcome and necessary next steps.
55+
"""
56+
try:
57+
logger.debug(
58+
f"[Session: {sessionId}] PID: {os.getpid()} | PyThread: {threading.get_ident()} | Using/Creating MarvinThread ID: {sessionId}"
59+
)
60+
61+
result = await marvin.run_async(
62+
query,
63+
context={
64+
"your personality": self.instructions,
65+
"reminder": "Use your memory to help fill out the form",
66+
},
67+
thread=marvin.Thread(id=sessionId),
68+
result_type=ExtractionOutcome[self.result_type] | ClarifyingQuestion,
69+
)
70+
71+
if isinstance(result, ExtractionOutcome):
72+
return {
73+
"is_task_complete": True,
74+
"require_user_input": False,
75+
"text_parts": [_to_text_part(result.summary)],
76+
"data": result.extracted_data.model_dump(),
77+
}
78+
else:
79+
assert isinstance(result, str)
80+
return {
81+
"is_task_complete": False,
82+
"require_user_input": True,
83+
"text_parts": [_to_text_part(result)],
84+
"data": None,
85+
}
86+
87+
except Exception as e:
88+
logger.exception(f"Error during agent invocation for session {sessionId}")
89+
return {
90+
"is_task_complete": False,
91+
"require_user_input": True,
92+
"text_parts": [
93+
_to_text_part(
94+
f"Sorry, I encountered an error processing your request: {str(e)}"
95+
)
96+
],
97+
"data": None,
98+
}
99+
100+
async def stream(self, query: str, sessionId: str) -> AsyncIterable[dict[str, Any]]:
101+
"""Stream the response for a user query.
102+
103+
Args:
104+
query: The user's input text.
105+
sessionId: The session identifier.
106+
107+
Returns:
108+
An asynchronous iterable of response dictionaries.
109+
"""
110+
yield {
111+
"is_task_complete": False,
112+
"require_user_input": False,
113+
"content": "Analyzing your text for contact information...",
114+
}
115+
116+
yield await self.invoke(query, sessionId)

Diff for: samples/python/agents/marvin/pyproject.toml

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[project]
2+
name = "a2a-samples-marvin"
3+
version = "0.1.0"
4+
description = "Currency conversion using A2A and Marvin"
5+
readme = "README.md"
6+
requires-python = ">=3.12"
7+
dependencies = ["marvin>=3.0.0", "a2a-samples"]
8+
9+
[tool.uv.sources]
10+
a2a-samples = { workspace = true }
11+
12+
[tool.ruff.lint]
13+
extend-select = ["I", "UP"]

0 commit comments

Comments
 (0)