Skip to content

Commit c7bc02d

Browse files
DavidsonGomesclaude
andcommitted
feat(products): catalog injection + link_product_to_pipeline_item tool
llm_agent_builder injects a <product-catalog> block built from agent.config.assigned_products (capped by MAX_PRODUCTS_PER_PROMPT, default 50) so the LLM cites only catalog items with correct name, price and purchase link. When agent.config.allow_product_sales is true, registers the link_product_to_pipeline_item tool: it pulls pipeline_item_id from tool_context state and POSTs to the CRM, snapshotting unit price at call time. Tool instruction makes clear it only fires after confirmed purchase intent, not on mere product questions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fee6f25 commit c7bc02d

4 files changed

Lines changed: 308 additions & 1 deletion

File tree

src/services/adk/agents/llm_agent_builder.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -687,7 +687,8 @@ async def _create_llm_agent(
687687
allow_contact_edit = agent.config.get("allow_contact_edit", False)
688688
allow_pipeline_manipulation = agent.config.get("allow_pipeline_manipulation", False)
689689
allow_manage_labels = agent.config.get("allow_manage_labels", False)
690-
690+
allow_product_sales = agent.config.get("allow_product_sales", False)
691+
691692
if transfer_to_human_enabled:
692693
transfer_rules = agent.config.get("transfer_rules", [])
693694
if transfer_rules:
@@ -770,6 +771,78 @@ async def _create_llm_agent(
770771
"Only manage labels when the user's request, the conversation state or your routing rules clearly justify it — do not invent random tags."
771772
)
772773

774+
if allow_product_sales:
775+
crm_tools_instructions.append(
776+
"Link Product to Pipeline Item Tool: Available. Use this tool to record a sale on the current pipeline card when the user has confirmed they want to purchase one of the products listed in the <product-catalog> block. "
777+
"Required: product_id (the UUID from the catalog) and quantity (positive integer). Optional: product_variant_id (size/color) and notes. "
778+
"The pipeline_item_id is auto-extracted from context. The CRM snapshots the unit price at the moment of the call, so calling this prematurely (before purchase intent is confirmed) commits the sale incorrectly. "
779+
"Do NOT call this tool just because the user asked about a product — only call it when the purchase intent is explicit."
780+
)
781+
782+
# Build a <product-catalog> block from the products attached to this agent.
783+
# The CRM populates `assigned_products` in agent.config via the
784+
# Ai::AgentProductSyncService whenever the user attaches/detaches a
785+
# product on the "Products" tab of the agent editor. We cap the list
786+
# at MAX_PRODUCTS_PER_PROMPT so we don't blow up the context window
787+
# on large catalogs (RAG / search tool is the path forward there).
788+
assigned_products = agent.config.get("assigned_products") or []
789+
max_products = int(os.getenv("MAX_PRODUCTS_PER_PROMPT", "50"))
790+
if isinstance(assigned_products, list) and assigned_products:
791+
truncated = False
792+
if len(assigned_products) > max_products:
793+
logger.warning(
794+
f"product catalog truncated: {len(assigned_products)} -> {max_products}"
795+
)
796+
assigned_products = assigned_products[:max_products]
797+
truncated = True
798+
799+
lines = []
800+
for product in assigned_products:
801+
if not isinstance(product, dict):
802+
continue
803+
kind = product.get("kind") or "physical"
804+
name = product.get("name") or product.get("id") or "unknown"
805+
price = product.get("default_price")
806+
currency = product.get("currency") or "BRL"
807+
url = product.get("purchase_url") or ""
808+
description = (product.get("description") or "").strip()
809+
if len(description) > 200:
810+
description = description[:200].rstrip() + "..."
811+
812+
price_str = ""
813+
if price is not None:
814+
try:
815+
price_str = f"{currency} {float(price):.2f}"
816+
except (TypeError, ValueError):
817+
price_str = f"{currency} {price}"
818+
819+
pieces = [f"[{kind}] {name}"]
820+
if price_str:
821+
pieces.append(price_str)
822+
if url:
823+
pieces.append(url)
824+
if description:
825+
pieces.append(description)
826+
lines.append("- " + " — ".join(pieces))
827+
828+
header = (
829+
"You can recommend the following catalog products during this conversation. "
830+
"Always cite the exact name and purchase link as listed below. "
831+
"Do not invent products that are not in this list."
832+
)
833+
footer = (
834+
"\n(Catalog truncated to the first {max} entries.)".format(max=max_products)
835+
if truncated else ""
836+
)
837+
agent_config_sections.append(
838+
"<product-catalog>\n"
839+
+ header
840+
+ "\n\n"
841+
+ "\n".join(lines)
842+
+ footer
843+
+ "\n</product-catalog>"
844+
)
845+
773846
# Check if Google Calendar integration is enabled
774847
integrations = agent.config.get("integrations", {})
775848
google_calendar_config = integrations.get("google-calendar") or integrations.get("google_calendar")

src/services/adk/tool_builder.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,13 +279,15 @@ def build_tools(
279279
allow_contact_edit = agent_config.get("allow_contact_edit", False)
280280
allow_pipeline_manipulation = agent_config.get("allow_pipeline_manipulation", False)
281281
allow_manage_labels = agent_config.get("allow_manage_labels", False)
282+
allow_product_sales = agent_config.get("allow_product_sales", False)
282283
enable_crm_tools = (
283284
agent_config.get("enable_crm_tools", False)
284285
or transfer_to_human_enabled
285286
or allow_reminders
286287
or allow_contact_edit
287288
or allow_pipeline_manipulation
288289
or allow_manage_labels
290+
or allow_product_sales
289291
)
290292

291293
if enable_crm_tools:
@@ -295,6 +297,7 @@ def build_tools(
295297
create_update_contact_tool,
296298
create_pipeline_manipulation_tool,
297299
create_manage_conversation_labels_tool,
300+
create_link_product_to_pipeline_item_tool,
298301
)
299302

300303
try:
@@ -348,6 +351,14 @@ def build_tools(
348351
f"Added manage_conversation_labels tool from CRM tools"
349352
)
350353

354+
# Add link_product_to_pipeline_item tool if enabled
355+
if allow_product_sales:
356+
product_link_tool = create_link_product_to_pipeline_item_tool()
357+
self.tools.append(product_link_tool)
358+
logger.info(
359+
f"Added link_product_to_pipeline_item tool from CRM tools"
360+
)
361+
351362
except Exception as e:
352363
logger.error(f"Error loading CRM tools: {e}")
353364

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@
1111
from .update_contact import create_update_contact_tool
1212
from .pipeline_manipulation import create_pipeline_manipulation_tool
1313
from .manage_conversation_labels import create_manage_conversation_labels_tool
14+
from .link_product_to_pipeline_item import create_link_product_to_pipeline_item_tool
1415

1516
__all__ = [
1617
"create_transfer_to_human_tool",
1718
"create_send_private_message_tool",
1819
"create_update_contact_tool",
1920
"create_pipeline_manipulation_tool",
2021
"create_manage_conversation_labels_tool",
22+
"create_link_product_to_pipeline_item_tool",
2123
]
2224

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
"""
2+
Link Product to Pipeline Item Tool
3+
4+
Lets the agent record a sale during the conversation by linking a product
5+
(optionally a variant + quantity + notes) to the pipeline_item attached to
6+
the conversation. The CRM endpoint snapshots the price at link time, so
7+
later changes to Product.default_price never alter recorded sales.
8+
9+
Backend endpoint:
10+
POST /api/v1/pipeline_items/{pipeline_item_id}/products
11+
12+
Body:
13+
{ product_id, product_variant_id?, quantity, notes?,
14+
created_by_type: "AiAgent", created_by_id: <agent_id> }
15+
"""
16+
17+
from typing import Any, Dict, Optional
18+
19+
from google.adk.tools import FunctionTool, ToolContext
20+
21+
from src.services.adk.tools.evo_crm.base import EvoCrmClient
22+
from src.utils.logger import setup_logger
23+
24+
logger = setup_logger(__name__)
25+
26+
27+
def _extract_pipeline_item_id(tool_context: Optional[ToolContext]) -> Optional[str]:
28+
"""Extract pipeline_item_id from tool_context.state.
29+
30+
Looked up in order:
31+
- evoai_crm_data.pipeline_item_id
32+
- evoai_crm_data.pipeline_item.id
33+
- pipeline_item_id (direct)
34+
- pipelineItemId (camelCase)
35+
"""
36+
if not tool_context or not hasattr(tool_context, "state"):
37+
return None
38+
39+
state = tool_context.state
40+
41+
evoai_crm_data = state.get("evoai_crm_data", {})
42+
if isinstance(evoai_crm_data, dict):
43+
direct = evoai_crm_data.get("pipeline_item_id")
44+
if direct:
45+
return str(direct)
46+
47+
pipeline_item = evoai_crm_data.get("pipeline_item", {})
48+
if isinstance(pipeline_item, dict):
49+
value = pipeline_item.get("id")
50+
if value:
51+
return str(value)
52+
53+
for key in ("pipeline_item_id", "pipelineItemId"):
54+
if key in state:
55+
return str(state[key])
56+
57+
return None
58+
59+
60+
def _extract_agent_id(tool_context: Optional[ToolContext]) -> Optional[str]:
61+
"""Best-effort lookup for the AI agent id so the sale row records the actor."""
62+
if not tool_context or not hasattr(tool_context, "state"):
63+
return None
64+
65+
state = tool_context.state
66+
for key in ("agent_id", "ai_agent_id", "agentId"):
67+
if key in state:
68+
return str(state[key])
69+
70+
evoai_crm_data = state.get("evoai_crm_data", {})
71+
if isinstance(evoai_crm_data, dict):
72+
for key in ("agent_id", "ai_agent_id"):
73+
if key in evoai_crm_data:
74+
return str(evoai_crm_data[key])
75+
76+
return None
77+
78+
79+
def create_link_product_to_pipeline_item_tool() -> FunctionTool:
80+
"""Factory for the link_product_to_pipeline_item tool."""
81+
82+
client = EvoCrmClient()
83+
84+
async def link_product_to_pipeline_item(
85+
product_id: str,
86+
quantity: int = 1,
87+
product_variant_id: Optional[str] = None,
88+
notes: Optional[str] = None,
89+
pipeline_item_id: Optional[str] = None,
90+
tool_context: Optional[ToolContext] = None,
91+
) -> Dict[str, Any]:
92+
"""Link a product (optionally a variant) to the current pipeline item.
93+
94+
Use this tool when the user has confirmed they will purchase one or
95+
more of the catalog products listed in the <product-catalog> block of
96+
your instruction. It registers the sale on the pipeline card so a
97+
human can pick up follow-up actions. The unit price is locked at the
98+
moment of the call — do NOT call this tool just because the user
99+
asked about a product; only call it when the purchase intent is
100+
clear.
101+
102+
Args:
103+
product_id: UUID of the product to link. Required.
104+
quantity: How many units. Must be a positive integer. Defaults to 1.
105+
product_variant_id: Optional UUID of the variant (e.g. size/color).
106+
notes: Optional free-form note recorded with the sale.
107+
pipeline_item_id: Optional UUID; auto-extracted from context when
108+
omitted (the conversation's pipeline_item).
109+
tool_context: Provided automatically by the runtime.
110+
111+
Returns:
112+
Dictionary with status, the created link details and a
113+
human-readable message.
114+
"""
115+
effective_pi_id = pipeline_item_id
116+
if not effective_pi_id and tool_context:
117+
effective_pi_id = _extract_pipeline_item_id(tool_context)
118+
if effective_pi_id:
119+
logger.info(f"Extracted pipeline_item_id from context: {effective_pi_id}")
120+
121+
if not effective_pi_id:
122+
return {
123+
"status": "error",
124+
"message": (
125+
"pipeline_item_id is required. It should be auto-extracted from the "
126+
"conversation context; provide it explicitly only if the conversation "
127+
"is not attached to a pipeline."
128+
),
129+
"pipeline_item_id": None,
130+
}
131+
132+
if not product_id:
133+
return {
134+
"status": "error",
135+
"message": "product_id is required.",
136+
"pipeline_item_id": effective_pi_id,
137+
}
138+
139+
try:
140+
qty = int(quantity)
141+
except (TypeError, ValueError):
142+
return {
143+
"status": "error",
144+
"message": "quantity must be a positive integer.",
145+
"pipeline_item_id": effective_pi_id,
146+
}
147+
148+
if qty < 1:
149+
return {
150+
"status": "error",
151+
"message": "quantity must be at least 1.",
152+
"pipeline_item_id": effective_pi_id,
153+
}
154+
155+
agent_id = _extract_agent_id(tool_context)
156+
157+
payload: Dict[str, Any] = {
158+
"product_id": str(product_id),
159+
"quantity": qty,
160+
"created_by_type": "AiAgent",
161+
}
162+
if product_variant_id:
163+
payload["product_variant_id"] = str(product_variant_id)
164+
if notes:
165+
payload["notes"] = str(notes)
166+
if agent_id:
167+
payload["created_by_id"] = agent_id
168+
169+
endpoint = f"/pipeline_items/{effective_pi_id}/products"
170+
171+
try:
172+
response = await client.post(endpoint=endpoint, json_data=payload)
173+
except Exception as api_error:
174+
error_message = str(api_error)
175+
if "404" in error_message or "not found" in error_message.lower():
176+
error_message = (
177+
f"Pipeline item {effective_pi_id} or product {product_id} not found."
178+
)
179+
elif "401" in error_message or "unauthorized" in error_message.lower():
180+
error_message = (
181+
"Authentication failed. Check EVOAI_CRM_API_TOKEN configuration."
182+
)
183+
elif "422" in error_message or "unprocessable" in error_message.lower():
184+
error_message = (
185+
"The CRM rejected the link payload (validation error). "
186+
"Double-check product_id, product_variant_id (must belong to the product) "
187+
"and quantity (>0)."
188+
)
189+
190+
logger.error(f"link_product_to_pipeline_item failed: {error_message}")
191+
return {
192+
"status": "error",
193+
"message": error_message,
194+
"pipeline_item_id": effective_pi_id,
195+
"product_id": product_id,
196+
"error": str(api_error),
197+
}
198+
199+
data = response.get("data") if isinstance(response, dict) else None
200+
product_summary = data.get("product") if isinstance(data, dict) else None
201+
product_name = (
202+
product_summary.get("name") if isinstance(product_summary, dict) else None
203+
) or product_id
204+
205+
logger.info(
206+
f"Linked product {product_id} (qty={qty}) to pipeline_item {effective_pi_id}"
207+
)
208+
209+
return {
210+
"status": "success",
211+
"message": f"Recorded {qty}x {product_name} on the pipeline card.",
212+
"pipeline_item_id": effective_pi_id,
213+
"product_id": product_id,
214+
"product_variant_id": product_variant_id,
215+
"quantity": qty,
216+
"details": data,
217+
}
218+
219+
link_product_to_pipeline_item.__name__ = "link_product_to_pipeline_item"
220+
221+
return FunctionTool(func=link_product_to_pipeline_item)

0 commit comments

Comments
 (0)