Skip to content

Commit 5a9627a

Browse files
authored
Merge pull request #1453 from patched-codes/feature/ZohoDeskAgent
Add ZohoDeskAgent
2 parents da54dfb + 4cd6093 commit 5a9627a

File tree

8 files changed

+411
-1
lines changed

8 files changed

+411
-1
lines changed

patchwork/common/tools/api_tool.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing_extensions import Literal
66

77
from patchwork.common.tools.tool import Tool
8+
from patchwork.logger import logger
89

910

1011
class APIRequestTool(Tool, tool_name="make_api_request", abc_register=False):
@@ -93,6 +94,15 @@ def execute(
9394

9495
header_string = "\n".join(f"{key}: {value}" for key, value in headers.items())
9596

97+
msg = (
98+
f"HTTP/{response.raw.version / 10:.1f} {status_code} {response.reason}\n"
99+
f"{header_string}\n"
100+
f"\n"
101+
f"{response_text}"
102+
)
103+
104+
logger.debug(msg)
105+
96106
return (
97107
f"HTTP/{response.raw.version / 10:.1f} {status_code} {response.reason}\n"
98108
f"{header_string}\n"
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import os
2+
import yaml
3+
import time
4+
import requests
5+
from typing import Dict, Optional, Callable
6+
from pathlib import Path
7+
8+
9+
class ZohoTokenManager:
10+
"""Utility class to manage Zoho Desk API tokens with configurable save callback."""
11+
12+
def __init__(
13+
self,
14+
client_id: str,
15+
client_secret: str,
16+
refresh_token: Optional[str] = None,
17+
access_token: Optional[str] = None,
18+
grant_token: Optional[str] = None,
19+
expires_at: Optional[int] = None,
20+
on_save: Optional[Callable[[Dict], None]] = None,
21+
):
22+
"""Initialize the token manager with client credentials.
23+
24+
Args:
25+
client_id: Zoho API client ID
26+
client_secret: Zoho API client secret
27+
grant_token: Grant token for initial authorization if access_token and refresh_token aren't initialized
28+
refresh_token: Issued refresh token for token renewal
29+
access_token: Last issued access token if available
30+
expires_at: Optional timestamp when the token expires
31+
on_save: Optional callback function to save token updates
32+
"""
33+
self.client_id = client_id
34+
self.client_secret = client_secret
35+
self.refresh_token = refresh_token
36+
self.access_token = access_token
37+
self.grant_token = grant_token
38+
self.expires_at = expires_at
39+
self._on_save = on_save
40+
41+
def _save_tokens(self, token_data: Dict):
42+
"""Save token updates using the provided callback.
43+
44+
Args:
45+
token_data: Dictionary containing token information to save
46+
"""
47+
if self._on_save:
48+
try:
49+
self._on_save(token_data)
50+
except Exception as e:
51+
print(f"Error in token save callback: {e}")
52+
53+
def get_access_token_from_grant(self, grant_token: Optional[str] = None) -> Dict:
54+
"""Get access and refresh tokens using a grant token.
55+
56+
Args:
57+
grant_token: The grant token obtained from Zoho authorization.
58+
If None, uses the grant_token from initialization.
59+
60+
Returns:
61+
Dict containing access_token, refresh_token and other details
62+
"""
63+
if not grant_token and not self.grant_token:
64+
raise ValueError("No grant token provided")
65+
66+
token_to_use = grant_token or self.grant_token
67+
68+
url = "https://accounts.zoho.com/oauth/v2/token"
69+
params = {
70+
"grant_type": "authorization_code",
71+
"client_id": self.client_id,
72+
"client_secret": self.client_secret,
73+
"code": token_to_use,
74+
}
75+
76+
response = requests.post(url, params=params)
77+
if response.status_code != 200:
78+
raise Exception(f"Failed to get access token: {response.text}")
79+
80+
token_data = response.json()
81+
self.access_token = token_data.get("access_token")
82+
self.refresh_token = token_data.get("refresh_token")
83+
self.expires_at = time.time() + token_data.get("expires_in", 3600)
84+
85+
# Prepare token data for saving
86+
save_data = {
87+
"zoho_access_token": self.access_token,
88+
"zoho_refresh_token": self.refresh_token,
89+
"zoho_expires_at": self.expires_at,
90+
"zoho_grant_token": "", # Clear grant token after use
91+
}
92+
self._save_tokens(save_data)
93+
94+
return {
95+
"access_token": self.access_token,
96+
"refresh_token": self.refresh_token,
97+
"expires_at": self.expires_at,
98+
}
99+
100+
def refresh_access_token(self) -> Dict:
101+
"""Refresh the access token using the refresh token.
102+
103+
Returns:
104+
Dict containing the new access_token and other details
105+
"""
106+
if not self.refresh_token:
107+
raise ValueError("No refresh token available")
108+
109+
url = "https://accounts.zoho.com/oauth/v2/token"
110+
params = {
111+
"grant_type": "refresh_token",
112+
"client_id": self.client_id,
113+
"client_secret": self.client_secret,
114+
"refresh_token": self.refresh_token,
115+
}
116+
117+
response = requests.post(url, params=params)
118+
if response.status_code != 200:
119+
raise Exception(f"Failed to refresh access token: {response.text}")
120+
121+
token_data = response.json()
122+
self.access_token = token_data.get("access_token")
123+
self.expires_at = time.time() + token_data.get("expires_in", 3600)
124+
125+
# Prepare token data for saving
126+
save_data = {
127+
"zoho_access_token": self.access_token,
128+
"zoho_expires_at": self.expires_at,
129+
}
130+
self._save_tokens(save_data)
131+
132+
return {
133+
"access_token": self.access_token,
134+
"refresh_token": self.refresh_token,
135+
"expires_at": self.expires_at,
136+
}
137+
138+
def get_valid_access_token(self) -> str:
139+
"""Get a valid access token, refreshing if necessary.
140+
141+
If no refresh token is available but a grant token is, it will
142+
attempt to get a new access token using the grant token.
143+
144+
Returns:
145+
A valid access token string
146+
"""
147+
# If no refresh token but grant token is available, get tokens from grant
148+
if not self.refresh_token and self.grant_token:
149+
self.get_access_token_from_grant()
150+
return self.access_token
151+
152+
if not self.access_token:
153+
raise ValueError("No access token available")
154+
155+
if not self.refresh_token:
156+
raise ValueError("No refresh token available")
157+
158+
# If token is expired or will expire in the next 5 minutes, refresh it
159+
if time.time() > (self.expires_at - 300):
160+
self.refresh_access_token()
161+
162+
return self.access_token
163+
164+
165+
def create_yml_save_callback(config_path: Path) -> Callable[[Dict], None]:
166+
"""Create a callback function to save token updates to a YAML file.
167+
168+
Args:
169+
config_path: Path to the YAML configuration file
170+
171+
Returns:
172+
A callable that can be used as an on_save callback
173+
"""
174+
175+
def save_callback(token_updates: Dict):
176+
"""Save token updates to the YAML configuration file.
177+
178+
Args:
179+
token_updates: Dictionary of token updates to save
180+
"""
181+
# Load existing configuration
182+
with open(config_path, "r") as f:
183+
config = yaml.safe_load(f)
184+
185+
# Update configuration with token updates
186+
config.update(token_updates)
187+
188+
# Save updated configuration
189+
with open(config_path, "w") as f:
190+
yaml.dump(config, f)
191+
192+
return save_callback
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Zoho Desk Agent
2+
3+
This agent allows you to interact with the Zoho Desk API to manage tickets, contacts, and other Zoho Desk resources.
4+
5+
## Requirements
6+
7+
- Zoho Desk API access token
8+
- Zoho Desk organization ID
9+
- Patchwork framework
10+
11+
## Usage
12+
13+
The ZohoDeskAgent can be used to:
14+
- Retrieve ticket information
15+
- Create new tickets
16+
- Update existing tickets
17+
- Manage contacts
18+
- Query departments and other Zoho Desk resources
19+
20+
## Input Parameters
21+
22+
Required:
23+
- `zoho_access_token`: Your Zoho Desk API access token
24+
- `org_id`: Zoho Desk organization ID (required for all API calls)
25+
- `user_prompt`: The prompt template to use for the agent
26+
- `prompt_value`: Dictionary of values to render in the user prompt template
27+
28+
Optional:
29+
- `max_agent_calls`: Maximum number of agent calls (default: 1)
30+
- `system_prompt`: Custom system prompt
31+
- `example_json`: Example JSON for the agent
32+
- LLM API keys (one of the following):
33+
- `openai_api_key`
34+
- `anthropic_api_key`
35+
- `google_api_key`
36+
37+
## Example
38+
39+
```python
40+
from patchwork.steps.ZohoDeskAgent import ZohoDeskAgent
41+
42+
# Initialize the agent
43+
agent = ZohoDeskAgent({
44+
"zoho_access_token": "your_zoho_access_token",
45+
"org_id": "your_organization_id",
46+
"user_prompt": "Get information about ticket {{ticket_id}}",
47+
"prompt_value": {"ticket_id": "12345"},
48+
"anthropic_api_key": "your_anthropic_api_key",
49+
"max_agent_calls": 3
50+
})
51+
52+
# Run the agent
53+
result = agent.run()
54+
print(result)
55+
```
56+
57+
## API Endpoints
58+
59+
The agent can interact with various Zoho Desk API endpoints, including:
60+
61+
- `GET /tickets` - List tickets
62+
- `GET /tickets/{ticketId}` - Get ticket details
63+
- `POST /tickets` - Create a ticket
64+
- `PUT /tickets/{ticketId}` - Update a ticket
65+
- `GET /departments` - List departments
66+
- `GET /contacts` - List contacts
67+
- `GET /contacts/{contactId}` - Get contact details
68+
- `POST /contacts` - Create a contact
69+
- `GET /contacts/{contactId}/tickets` - List tickets by contact
70+
71+
## Query Parameters
72+
73+
Common query parameters for listing resources:
74+
- `include`: Additional information to include (e.g., 'products', 'departments', 'team', 'isRead', 'assignee')
75+
- `from`: Index number to start fetching from
76+
- `limit`: Number of items to fetch (range: 1-100)
77+
- `sortBy`: Sort by a specific attribute (e.g., 'createdTime', 'modifiedTime')
78+
79+
For more information about the Zoho Desk API, refer to the [official documentation](https://desk.zoho.com/DeskAPIDocument).
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from patchwork.common.client.llm.aio import AioLlmClient
2+
from patchwork.common.multiturn_strategy.agentic_strategy_v2 import (
3+
AgentConfig,
4+
AgenticStrategyV2,
5+
)
6+
from patchwork.common.tools.api_tool import APIRequestTool
7+
from patchwork.common.utils.utils import mustache_render
8+
from patchwork.step import Step
9+
10+
from .typed import ZohoDeskAgentInputs, ZohoDeskAgentOutputs
11+
12+
13+
class ZohoDeskAgent(Step, input_class=ZohoDeskAgentInputs, output_class=ZohoDeskAgentOutputs):
14+
def __init__(self, inputs: dict):
15+
super().__init__(inputs)
16+
17+
if not inputs.get("zoho_access_token"):
18+
raise ValueError("zoho_access_token is required")
19+
if not inputs.get("user_prompt"):
20+
raise ValueError("user_prompt is required")
21+
if not inputs.get("org_id"):
22+
raise ValueError("org_id is required for Zoho Desk API calls")
23+
24+
# Configure conversation limit
25+
self.conversation_limit = int(inputs.get("max_agent_calls", 1))
26+
27+
# Prepare system prompt with Zoho Desk context
28+
system_prompt = inputs.get(
29+
"system_prompt",
30+
"Please summarise the conversation given and provide the result in the structure that is asked of you.",
31+
)
32+
33+
# Set up headers for Zoho Desk API
34+
self.headers = {
35+
"Authorization": f"Zoho-oauthtoken {inputs.get('zoho_access_token')}",
36+
"orgId": inputs.get("org_id"),
37+
"Content-Type": "application/json",
38+
"Accept": "application/json",
39+
}
40+
41+
llm_client = AioLlmClient.create_aio_client(inputs)
42+
43+
# Configure agentic strategy with Zoho Desk-specific context
44+
self.agentic_strategy = AgenticStrategyV2(
45+
model="claude-3-7-sonnet-latest",
46+
llm_client=llm_client,
47+
system_prompt_template=system_prompt,
48+
template_data={},
49+
user_prompt_template=mustache_render(inputs.get("user_prompt"), inputs.get("prompt_value")),
50+
agent_configs=[
51+
AgentConfig(
52+
name="Zoho Desk Assistant",
53+
model="claude-3-7-sonnet-latest",
54+
tool_set=dict(
55+
make_api_request=APIRequestTool(
56+
headers=self.headers,
57+
)
58+
),
59+
system_prompt="""\
60+
You are a senior software developer helping users interact with Zoho Desk via the Zoho Desk API.
61+
Your goal is to retrieve, create, or modify tickets, contacts, and other Zoho Desk resources.
62+
Use the `make_api_request` tool to interact with the Zoho Desk API.
63+
Skip the headers for the API requests as they are already provided.
64+
65+
The base URL for the Zoho Desk API is https://desk.zoho.com/api/v1
66+
67+
For modifying or creating data, the data should be a JSON string.
68+
When you have the result of the information user requested, return the response of the final result tool as is.
69+
70+
Here are some common Zoho Desk API endpoints:
71+
- GET /tickets - List tickets
72+
- GET /tickets/{ticketId} - Get ticket details
73+
- POST /tickets - Create a ticket
74+
- PUT /tickets/{ticketId} - Update a ticket
75+
- GET /departments - List departments
76+
- GET /contacts - List contacts
77+
- GET /contacts/{contactId} - Get contact details
78+
- POST /contacts - Create a contact
79+
- GET /contacts/{contactId}/tickets - List tickets by contact
80+
81+
Additional query parameters:
82+
- include: Additional information related to tickets. Values allowed are: 'products', 'departments', 'team', 'isRead', and 'assignee'. Multiple values can be comma-separated.
83+
- from: Index number to start fetching from
84+
- limit: Number of items to fetch (range: 1-100)
85+
- sortBy: Sort by a specific attribute like 'createdTime' or 'modifiedTime'. Prefix with '-' for descending order.
86+
87+
The orgId is already included in the headers for all API calls.
88+
""",
89+
)
90+
],
91+
example_json=inputs.get("example_json"),
92+
)
93+
94+
def run(self) -> dict:
95+
# Execute the agentic strategy
96+
result = self.agentic_strategy.execute(limit=self.conversation_limit)
97+
98+
# Return results with usage information
99+
return {**result, **self.agentic_strategy.usage()}

patchwork/steps/ZohoDeskAgent/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)