Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions patchwork/common/tools/api_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing_extensions import Literal

from patchwork.common.tools.tool import Tool
from patchwork.logger import logger


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

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

msg = (
f"HTTP/{response.raw.version / 10:.1f} {status_code} {response.reason}\n"
f"{header_string}\n"
f"\n"
f"{response_text}"
)

logger.debug(msg)

return (
f"HTTP/{response.raw.version / 10:.1f} {status_code} {response.reason}\n"
f"{header_string}\n"
Expand Down
192 changes: 192 additions & 0 deletions patchwork/common/utils/zoho_token_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import os
import yaml
import time
import requests
from typing import Dict, Optional, Callable
from pathlib import Path


class ZohoTokenManager:
"""Utility class to manage Zoho Desk API tokens with configurable save callback."""

def __init__(
self,
client_id: str,
client_secret: str,
refresh_token: Optional[str] = None,
access_token: Optional[str] = None,
grant_token: Optional[str] = None,
expires_at: Optional[int] = None,
on_save: Optional[Callable[[Dict], None]] = None,
):
"""Initialize the token manager with client credentials.

Args:
client_id: Zoho API client ID
client_secret: Zoho API client secret
grant_token: Grant token for initial authorization if access_token and refresh_token aren't initialized
refresh_token: Issued refresh token for token renewal
access_token: Last issued access token if available
expires_at: Optional timestamp when the token expires
on_save: Optional callback function to save token updates
"""
self.client_id = client_id
self.client_secret = client_secret
self.refresh_token = refresh_token
self.access_token = access_token
self.grant_token = grant_token
self.expires_at = expires_at
self._on_save = on_save

def _save_tokens(self, token_data: Dict):
"""Save token updates using the provided callback.

Args:
token_data: Dictionary containing token information to save
"""
if self._on_save:
try:
self._on_save(token_data)
except Exception as e:
print(f"Error in token save callback: {e}")

def get_access_token_from_grant(self, grant_token: Optional[str] = None) -> Dict:
"""Get access and refresh tokens using a grant token.

Args:
grant_token: The grant token obtained from Zoho authorization.
If None, uses the grant_token from initialization.

Returns:
Dict containing access_token, refresh_token and other details
"""
if not grant_token and not self.grant_token:
raise ValueError("No grant token provided")

token_to_use = grant_token or self.grant_token

url = "https://accounts.zoho.com/oauth/v2/token"
params = {
"grant_type": "authorization_code",
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": token_to_use,
}

response = requests.post(url, params=params)
if response.status_code != 200:
raise Exception(f"Failed to get access token: {response.text}")

token_data = response.json()
self.access_token = token_data.get("access_token")
self.refresh_token = token_data.get("refresh_token")
self.expires_at = time.time() + token_data.get("expires_in", 3600)

# Prepare token data for saving
save_data = {
"zoho_access_token": self.access_token,
"zoho_refresh_token": self.refresh_token,
"zoho_expires_at": self.expires_at,
"zoho_grant_token": "", # Clear grant token after use
}
self._save_tokens(save_data)

return {
"access_token": self.access_token,
"refresh_token": self.refresh_token,
"expires_at": self.expires_at,
}

def refresh_access_token(self) -> Dict:
"""Refresh the access token using the refresh token.

Returns:
Dict containing the new access_token and other details
"""
if not self.refresh_token:
raise ValueError("No refresh token available")

url = "https://accounts.zoho.com/oauth/v2/token"
params = {
"grant_type": "refresh_token",
"client_id": self.client_id,
"client_secret": self.client_secret,
"refresh_token": self.refresh_token,
}

response = requests.post(url, params=params)
if response.status_code != 200:
raise Exception(f"Failed to refresh access token: {response.text}")

token_data = response.json()
self.access_token = token_data.get("access_token")
self.expires_at = time.time() + token_data.get("expires_in", 3600)

# Prepare token data for saving
save_data = {
"zoho_access_token": self.access_token,
"zoho_expires_at": self.expires_at,
}
self._save_tokens(save_data)

return {
"access_token": self.access_token,
"refresh_token": self.refresh_token,
"expires_at": self.expires_at,
}

def get_valid_access_token(self) -> str:
"""Get a valid access token, refreshing if necessary.

If no refresh token is available but a grant token is, it will
attempt to get a new access token using the grant token.

Returns:
A valid access token string
"""
# If no refresh token but grant token is available, get tokens from grant
if not self.refresh_token and self.grant_token:
self.get_access_token_from_grant()
return self.access_token

if not self.access_token:
raise ValueError("No access token available")

if not self.refresh_token:
raise ValueError("No refresh token available")

# If token is expired or will expire in the next 5 minutes, refresh it
if time.time() > (self.expires_at - 300):
self.refresh_access_token()

return self.access_token


def create_yml_save_callback(config_path: Path) -> Callable[[Dict], None]:
"""Create a callback function to save token updates to a YAML file.

Args:
config_path: Path to the YAML configuration file

Returns:
A callable that can be used as an on_save callback
"""

def save_callback(token_updates: Dict):
"""Save token updates to the YAML configuration file.

Args:
token_updates: Dictionary of token updates to save
"""
# Load existing configuration
with open(config_path, "r") as f:
config = yaml.safe_load(f)

# Update configuration with token updates
config.update(token_updates)

# Save updated configuration
with open(config_path, "w") as f:
yaml.dump(config, f)

return save_callback
79 changes: 79 additions & 0 deletions patchwork/steps/ZohoDeskAgent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Zoho Desk Agent

This agent allows you to interact with the Zoho Desk API to manage tickets, contacts, and other Zoho Desk resources.

## Requirements

- Zoho Desk API access token
- Zoho Desk organization ID
- Patchwork framework

## Usage

The ZohoDeskAgent can be used to:
- Retrieve ticket information
- Create new tickets
- Update existing tickets
- Manage contacts
- Query departments and other Zoho Desk resources

## Input Parameters

Required:
- `zoho_access_token`: Your Zoho Desk API access token
- `org_id`: Zoho Desk organization ID (required for all API calls)
- `user_prompt`: The prompt template to use for the agent
- `prompt_value`: Dictionary of values to render in the user prompt template

Optional:
- `max_agent_calls`: Maximum number of agent calls (default: 1)
- `system_prompt`: Custom system prompt
- `example_json`: Example JSON for the agent
- LLM API keys (one of the following):
- `openai_api_key`
- `anthropic_api_key`
- `google_api_key`

## Example

```python
from patchwork.steps.ZohoDeskAgent import ZohoDeskAgent

# Initialize the agent
agent = ZohoDeskAgent({
"zoho_access_token": "your_zoho_access_token",
"org_id": "your_organization_id",
"user_prompt": "Get information about ticket {{ticket_id}}",
"prompt_value": {"ticket_id": "12345"},
"anthropic_api_key": "your_anthropic_api_key",
"max_agent_calls": 3
})

# Run the agent
result = agent.run()
print(result)
```

## API Endpoints

The agent can interact with various Zoho Desk API endpoints, including:

- `GET /tickets` - List tickets
- `GET /tickets/{ticketId}` - Get ticket details
- `POST /tickets` - Create a ticket
- `PUT /tickets/{ticketId}` - Update a ticket
- `GET /departments` - List departments
- `GET /contacts` - List contacts
- `GET /contacts/{contactId}` - Get contact details
- `POST /contacts` - Create a contact
- `GET /contacts/{contactId}/tickets` - List tickets by contact

## Query Parameters

Common query parameters for listing resources:
- `include`: Additional information to include (e.g., 'products', 'departments', 'team', 'isRead', 'assignee')
- `from`: Index number to start fetching from
- `limit`: Number of items to fetch (range: 1-100)
- `sortBy`: Sort by a specific attribute (e.g., 'createdTime', 'modifiedTime')

For more information about the Zoho Desk API, refer to the [official documentation](https://desk.zoho.com/DeskAPIDocument).
99 changes: 99 additions & 0 deletions patchwork/steps/ZohoDeskAgent/ZohoDeskAgent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
from patchwork.common.client.llm.aio import AioLlmClient
from patchwork.common.multiturn_strategy.agentic_strategy_v2 import (
AgentConfig,
AgenticStrategyV2,
)
from patchwork.common.tools.api_tool import APIRequestTool
from patchwork.common.utils.utils import mustache_render
from patchwork.step import Step

from .typed import ZohoDeskAgentInputs, ZohoDeskAgentOutputs


class ZohoDeskAgent(Step, input_class=ZohoDeskAgentInputs, output_class=ZohoDeskAgentOutputs):
def __init__(self, inputs: dict):
super().__init__(inputs)

if not inputs.get("zoho_access_token"):
raise ValueError("zoho_access_token is required")
if not inputs.get("user_prompt"):
raise ValueError("user_prompt is required")
if not inputs.get("org_id"):
raise ValueError("org_id is required for Zoho Desk API calls")

# Configure conversation limit
self.conversation_limit = int(inputs.get("max_agent_calls", 1))

# Prepare system prompt with Zoho Desk context
system_prompt = inputs.get(
"system_prompt",
"Please summarise the conversation given and provide the result in the structure that is asked of you.",
)

# Set up headers for Zoho Desk API
self.headers = {
"Authorization": f"Zoho-oauthtoken {inputs.get('zoho_access_token')}",
"orgId": inputs.get("org_id"),
"Content-Type": "application/json",
"Accept": "application/json",
}

llm_client = AioLlmClient.create_aio_client(inputs)

# Configure agentic strategy with Zoho Desk-specific context
self.agentic_strategy = AgenticStrategyV2(
model="claude-3-7-sonnet-latest",
llm_client=llm_client,
system_prompt_template=system_prompt,
template_data={},
user_prompt_template=mustache_render(inputs.get("user_prompt"), inputs.get("prompt_value")),
agent_configs=[
AgentConfig(
name="Zoho Desk Assistant",
model="claude-3-7-sonnet-latest",
tool_set=dict(
make_api_request=APIRequestTool(
headers=self.headers,
)
),
system_prompt="""\
You are a senior software developer helping users interact with Zoho Desk via the Zoho Desk API.
Your goal is to retrieve, create, or modify tickets, contacts, and other Zoho Desk resources.
Use the `make_api_request` tool to interact with the Zoho Desk API.
Skip the headers for the API requests as they are already provided.

The base URL for the Zoho Desk API is https://desk.zoho.com/api/v1

For modifying or creating data, the data should be a JSON string.
When you have the result of the information user requested, return the response of the final result tool as is.

Here are some common Zoho Desk API endpoints:
- GET /tickets - List tickets
- GET /tickets/{ticketId} - Get ticket details
- POST /tickets - Create a ticket
- PUT /tickets/{ticketId} - Update a ticket
- GET /departments - List departments
- GET /contacts - List contacts
- GET /contacts/{contactId} - Get contact details
- POST /contacts - Create a contact
- GET /contacts/{contactId}/tickets - List tickets by contact

Additional query parameters:
- include: Additional information related to tickets. Values allowed are: 'products', 'departments', 'team', 'isRead', and 'assignee'. Multiple values can be comma-separated.
- from: Index number to start fetching from
- limit: Number of items to fetch (range: 1-100)
- sortBy: Sort by a specific attribute like 'createdTime' or 'modifiedTime'. Prefix with '-' for descending order.

The orgId is already included in the headers for all API calls.
""",
)
],
example_json=inputs.get("example_json"),
)

def run(self) -> dict:
# Execute the agentic strategy
result = self.agentic_strategy.execute(limit=self.conversation_limit)

# Return results with usage information
return {**result, **self.agentic_strategy.usage()}
Empty file.
Loading
Loading