Skip to content

Commit f50cc7d

Browse files
DavidsonGomesclaude
andcommitted
feat(crm-tools): manage_conversation_labels tool (list/add/remove)
Adds a new ADK tool that lets the agent list, add and remove labels on the current conversation. Gated by the new agent flag allow_manage_labels (toggle "Permitir gerenciar labels" in the System tab of the agent edit page). The backend POST /api/v1/conversations/:id/labels replaces the full label list, so the tool reads current labels first and merges add/remove requests before writing back, preserving labels the user did not ask to touch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 66ff79c commit f50cc7d

3 files changed

Lines changed: 335 additions & 2 deletions

File tree

src/services/adk/tool_builder.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -272,19 +272,29 @@ def build_tools(
272272
self.tools.append(self._create_exit_loop_tool())
273273

274274
# Process CRM tools if enabled
275-
# Enable CRM tools if transfer_to_human, allow_reminders, allow_contact_edit, or allow_pipeline_manipulation is enabled
275+
# Enable CRM tools if transfer_to_human, allow_reminders, allow_contact_edit, allow_pipeline_manipulation
276+
# or allow_manage_labels is enabled
276277
transfer_to_human_enabled = agent_config.get("transfer_to_human_enabled", False) or agent_config.get("transfer_to_human", False)
277278
allow_reminders = agent_config.get("allow_reminders", False)
278279
allow_contact_edit = agent_config.get("allow_contact_edit", False)
279280
allow_pipeline_manipulation = agent_config.get("allow_pipeline_manipulation", False)
280-
enable_crm_tools = agent_config.get("enable_crm_tools", False) or transfer_to_human_enabled or allow_reminders or allow_contact_edit or allow_pipeline_manipulation
281+
allow_manage_labels = agent_config.get("allow_manage_labels", False)
282+
enable_crm_tools = (
283+
agent_config.get("enable_crm_tools", False)
284+
or transfer_to_human_enabled
285+
or allow_reminders
286+
or allow_contact_edit
287+
or allow_pipeline_manipulation
288+
or allow_manage_labels
289+
)
281290

282291
if enable_crm_tools:
283292
from src.services.adk.tools.evo_crm import (
284293
create_transfer_to_human_tool,
285294
create_send_private_message_tool,
286295
create_update_contact_tool,
287296
create_pipeline_manipulation_tool,
297+
create_manage_conversation_labels_tool,
288298
)
289299

290300
try:
@@ -330,6 +340,14 @@ def build_tools(
330340
+ (f" with {len(pipeline_rules)} pipeline rules" if pipeline_rules else "")
331341
)
332342

343+
# Add manage_conversation_labels tool if enabled
344+
if allow_manage_labels:
345+
labels_tool = create_manage_conversation_labels_tool()
346+
self.tools.append(labels_tool)
347+
logger.info(
348+
f"Added manage_conversation_labels tool from CRM tools"
349+
)
350+
333351
except Exception as e:
334352
logger.error(f"Error loading CRM tools: {e}")
335353

src/services/adk/tools/evo_crm/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@
1010
from .send_private_message import create_send_private_message_tool
1111
from .update_contact import create_update_contact_tool
1212
from .pipeline_manipulation import create_pipeline_manipulation_tool
13+
from .manage_conversation_labels import create_manage_conversation_labels_tool
1314

1415
__all__ = [
1516
"create_transfer_to_human_tool",
1617
"create_send_private_message_tool",
1718
"create_update_contact_tool",
1819
"create_pipeline_manipulation_tool",
20+
"create_manage_conversation_labels_tool",
1921
]
2022

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
"""
2+
Manage Conversation Labels Tool
3+
4+
This tool allows agents to list, add and remove labels on the current
5+
conversation. Backend endpoints used:
6+
7+
- GET /api/v1/conversations/{id}/labels -> list current labels
8+
- POST /api/v1/conversations/{id}/labels -> replace labels (with `{labels: [...]}`)
9+
10+
The upstream POST is destructive (it replaces the full label list), so this
11+
tool reads the current labels first and computes the union/difference before
12+
writing back, preserving labels the user did not explicitly remove.
13+
"""
14+
15+
from typing import Any, Dict, List, Optional
16+
17+
from google.adk.tools import FunctionTool, ToolContext
18+
19+
from src.services.adk.tools.evo_crm.base import EvoCrmClient
20+
from src.utils.logger import setup_logger
21+
22+
logger = setup_logger(__name__)
23+
24+
25+
def _extract_conversation_id_from_metadata(tool_context: Optional[ToolContext]) -> Optional[str]:
26+
"""Extract conversation_id from tool_context metadata.
27+
28+
Looks for conversation_id in various possible locations:
29+
- evoai_crm_data.conversation_id (UUID)
30+
- evoai_crm_data.conversation.id (display_id)
31+
- conversation_id (direct)
32+
- conversationId (camelCase)
33+
"""
34+
if not tool_context or not hasattr(tool_context, "state"):
35+
return None
36+
37+
state = tool_context.state
38+
39+
evoai_crm_data = state.get("evoai_crm_data", {})
40+
if isinstance(evoai_crm_data, dict):
41+
conversation_id = evoai_crm_data.get("conversation_id")
42+
if conversation_id:
43+
return str(conversation_id)
44+
45+
conversation = evoai_crm_data.get("conversation", {})
46+
if isinstance(conversation, dict):
47+
conv_id = conversation.get("id")
48+
if conv_id:
49+
return str(conv_id)
50+
51+
for key in ("conversation_id", "conversationId"):
52+
if key in state:
53+
return str(state[key])
54+
55+
return None
56+
57+
58+
def _normalize_labels(raw: Any) -> List[str]:
59+
"""Normalize a labels payload coming from the API to a flat list of titles."""
60+
if raw is None:
61+
return []
62+
63+
if isinstance(raw, list):
64+
return [str(item).strip() for item in raw if str(item).strip()]
65+
66+
if isinstance(raw, dict):
67+
for key in ("data", "labels", "payload"):
68+
if key in raw:
69+
return _normalize_labels(raw[key])
70+
71+
return []
72+
73+
74+
def _coerce_input_list(value: Any) -> List[str]:
75+
"""Accept either a single string or a list, return a deduped list of strings."""
76+
if value is None:
77+
return []
78+
if isinstance(value, str):
79+
items = [value]
80+
elif isinstance(value, list):
81+
items = list(value)
82+
else:
83+
items = [value]
84+
85+
seen = set()
86+
result: List[str] = []
87+
for item in items:
88+
if item is None:
89+
continue
90+
text = str(item).strip()
91+
if not text or text in seen:
92+
continue
93+
seen.add(text)
94+
result.append(text)
95+
return result
96+
97+
98+
def create_manage_conversation_labels_tool() -> FunctionTool:
99+
"""Create the manage_conversation_labels tool.
100+
101+
The tool exposes three actions on the current conversation:
102+
- ``list``: returns the current labels.
103+
- ``add``: appends one or more labels, preserving existing ones.
104+
- ``remove``: removes one or more labels, preserving the rest.
105+
"""
106+
107+
client = EvoCrmClient()
108+
109+
async def manage_conversation_labels(
110+
action: str,
111+
labels: Optional[Any] = None,
112+
conversation_id: Optional[str] = None,
113+
tool_context: Optional[ToolContext] = None,
114+
) -> Dict[str, Any]:
115+
"""Manage labels on the current conversation.
116+
117+
Use this tool when:
118+
- You need to tag the conversation for routing or reporting
119+
(e.g. ``vip``, ``aguardando-pagamento``, ``followup``).
120+
- You need to remove a label that no longer applies.
121+
- You want to inspect which labels are currently attached.
122+
123+
Important: ``add`` and ``remove`` are idempotent and additive — the
124+
tool reads the current labels first and merges your request, so it
125+
never erases labels the user did not ask to remove.
126+
127+
Args:
128+
action: One of ``list``, ``add``, ``remove``.
129+
labels: For ``add`` / ``remove``: a label title (string) or a list
130+
of titles. Ignored when action is ``list``.
131+
conversation_id: Optional UUID of the conversation. Auto-extracted
132+
from the tool context when omitted.
133+
tool_context: Provided automatically by the runtime.
134+
135+
Returns:
136+
Dictionary with the executed action and the resulting label list:
137+
``{"status": "success"|"error", "message": "...",
138+
"conversation_id": "...", "action": "...",
139+
"labels": ["label-a", "label-b"], ...}``.
140+
"""
141+
effective_conversation_id = conversation_id
142+
if not effective_conversation_id and tool_context:
143+
effective_conversation_id = _extract_conversation_id_from_metadata(tool_context)
144+
if effective_conversation_id:
145+
logger.info(f"Extracted conversation_id from metadata: {effective_conversation_id}")
146+
147+
if not effective_conversation_id:
148+
return {
149+
"status": "error",
150+
"message": (
151+
"conversation_id is required. It should be auto-extracted from the "
152+
"conversation context; provide it explicitly if not available."
153+
),
154+
"conversation_id": None,
155+
"action": action,
156+
}
157+
158+
normalized_action = (action or "").strip().lower()
159+
if normalized_action not in {"list", "add", "remove"}:
160+
return {
161+
"status": "error",
162+
"message": "action must be one of: list, add, remove.",
163+
"conversation_id": effective_conversation_id,
164+
"action": action,
165+
}
166+
167+
endpoint = f"/conversations/{effective_conversation_id}/labels"
168+
169+
try:
170+
current_labels_raw = await client.get(endpoint=endpoint)
171+
current_labels = _normalize_labels(current_labels_raw)
172+
except Exception as api_error:
173+
logger.error(
174+
f"Failed to load labels for conversation {effective_conversation_id}: {api_error}"
175+
)
176+
return {
177+
"status": "error",
178+
"message": f"Failed to load current labels: {api_error}",
179+
"conversation_id": effective_conversation_id,
180+
"action": normalized_action,
181+
"error": str(api_error),
182+
}
183+
184+
if normalized_action == "list":
185+
return {
186+
"status": "success",
187+
"message": f"Conversation has {len(current_labels)} label(s).",
188+
"conversation_id": effective_conversation_id,
189+
"action": "list",
190+
"labels": current_labels,
191+
}
192+
193+
requested = _coerce_input_list(labels)
194+
if not requested:
195+
return {
196+
"status": "error",
197+
"message": "Provide at least one label for 'add' or 'remove'.",
198+
"conversation_id": effective_conversation_id,
199+
"action": normalized_action,
200+
}
201+
202+
existing_set = {label.lower(): label for label in current_labels}
203+
204+
if normalized_action == "add":
205+
merged = list(current_labels)
206+
added: List[str] = []
207+
for label in requested:
208+
if label.lower() not in existing_set:
209+
merged.append(label)
210+
existing_set[label.lower()] = label
211+
added.append(label)
212+
213+
if not added:
214+
return {
215+
"status": "success",
216+
"message": "All requested labels were already present; nothing to update.",
217+
"conversation_id": effective_conversation_id,
218+
"action": "add",
219+
"labels": current_labels,
220+
"added": [],
221+
}
222+
223+
payload = {"labels": merged}
224+
else:
225+
removed_lookup = {label.lower() for label in requested}
226+
merged = [label for label in current_labels if label.lower() not in removed_lookup]
227+
removed = [label for label in current_labels if label.lower() in removed_lookup]
228+
229+
if not removed:
230+
return {
231+
"status": "success",
232+
"message": "None of the requested labels were present; nothing to update.",
233+
"conversation_id": effective_conversation_id,
234+
"action": "remove",
235+
"labels": current_labels,
236+
"removed": [],
237+
}
238+
239+
payload = {"labels": merged}
240+
241+
try:
242+
response = await client.post(endpoint=endpoint, json_data=payload)
243+
resulting_labels = _normalize_labels(response) or payload["labels"]
244+
except Exception as api_error:
245+
error_message = str(api_error)
246+
if "404" in error_message or "not found" in error_message.lower():
247+
error_message = (
248+
f"Conversation {effective_conversation_id} not found. "
249+
"Please verify the ID is correct."
250+
)
251+
elif "401" in error_message or "unauthorized" in error_message.lower():
252+
error_message = "Authentication failed. Please check EVOAI_CRM_API_TOKEN configuration."
253+
elif "400" in error_message or "bad request" in error_message.lower():
254+
error_message = (
255+
f"Invalid request when updating labels on conversation "
256+
f"{effective_conversation_id}. Labels: {payload['labels']}"
257+
)
258+
259+
logger.error(f"Failed to update conversation labels: {error_message}")
260+
return {
261+
"status": "error",
262+
"message": error_message,
263+
"conversation_id": effective_conversation_id,
264+
"action": normalized_action,
265+
"labels": current_labels,
266+
"error": str(api_error),
267+
}
268+
269+
if normalized_action == "add":
270+
logger.info(
271+
f"Added labels {added} to conversation {effective_conversation_id}; "
272+
f"now has {resulting_labels}"
273+
)
274+
return {
275+
"status": "success",
276+
"message": f"Added {len(added)} label(s) to the conversation.",
277+
"conversation_id": effective_conversation_id,
278+
"action": "add",
279+
"labels": resulting_labels,
280+
"added": added,
281+
}
282+
283+
logger.info(
284+
f"Removed labels {removed} from conversation {effective_conversation_id}; "
285+
f"now has {resulting_labels}"
286+
)
287+
return {
288+
"status": "success",
289+
"message": f"Removed {len(removed)} label(s) from the conversation.",
290+
"conversation_id": effective_conversation_id,
291+
"action": "remove",
292+
"labels": resulting_labels,
293+
"removed": removed,
294+
}
295+
296+
manage_conversation_labels.__name__ = "manage_conversation_labels"
297+
manage_conversation_labels.__doc__ = """Manage labels (tags) on the current conversation.
298+
299+
Actions:
300+
- list: returns the labels currently attached to the conversation
301+
- add: appends one or more labels, preserving existing ones
302+
- remove: removes one or more labels, preserving the rest
303+
304+
Args:
305+
action: "list" | "add" | "remove"
306+
labels: label title or list of titles (required for add/remove)
307+
conversation_id: optional UUID, auto-extracted from context when omitted
308+
309+
Returns:
310+
Dictionary with action result and the resulting label list.
311+
"""
312+
313+
return FunctionTool(func=manage_conversation_labels)

0 commit comments

Comments
 (0)