construct-labs-crm-env 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- construct_labs_crm_env/__init__.py +33 -0
- construct_labs_crm_env/_vendored/LICENSE.openenv +28 -0
- construct_labs_crm_env/_vendored/__init__.py +14 -0
- construct_labs_crm_env/_vendored/_client.py +216 -0
- construct_labs_crm_env/_vendored/_types.py +82 -0
- construct_labs_crm_env/client.py +1004 -0
- construct_labs_crm_env/models.py +260 -0
- construct_labs_crm_env/protocol.py +25 -0
- construct_labs_crm_env/py.typed +0 -0
- construct_labs_crm_env-0.1.1.dist-info/METADATA +412 -0
- construct_labs_crm_env-0.1.1.dist-info/RECORD +13 -0
- construct_labs_crm_env-0.1.1.dist-info/WHEEL +4 -0
- construct_labs_crm_env-0.1.1.dist-info/licenses/LICENSE +42 -0
|
@@ -0,0 +1,1004 @@
|
|
|
1
|
+
"""CRM Agent Environment Client.
|
|
2
|
+
|
|
3
|
+
This module provides the client for connecting to the Construct Labs CRM Agent
|
|
4
|
+
Environment. The client handles authentication, WebSocket communication, and
|
|
5
|
+
provides an extensible interface for customizing agent behavior.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
>>> from construct_labs_crm_env import CrmAgentEnv, CrmAgentAction, CRMActionType
|
|
9
|
+
>>>
|
|
10
|
+
>>> with CrmAgentEnv(
|
|
11
|
+
... base_url="https://api.construct-labs.com",
|
|
12
|
+
... api_key="your-api-key"
|
|
13
|
+
... ) as env:
|
|
14
|
+
... result = env.reset()
|
|
15
|
+
... result = env.step(CrmAgentAction(
|
|
16
|
+
... action_type=CRMActionType.LIST_COMPANIES,
|
|
17
|
+
... limit=10
|
|
18
|
+
... ))
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
from typing import Any, cast
|
|
26
|
+
|
|
27
|
+
from websockets.sync.client import connect as ws_connect
|
|
28
|
+
|
|
29
|
+
from ._vendored import EnvClient, StepResult
|
|
30
|
+
from .models import (
|
|
31
|
+
CRMActionType,
|
|
32
|
+
CrmAgentAction,
|
|
33
|
+
CrmAgentObservation,
|
|
34
|
+
CrmAgentState,
|
|
35
|
+
)
|
|
36
|
+
from .protocol import ParsedAction
|
|
37
|
+
|
|
38
|
+
# Type alias for JSON-serializable dictionaries
|
|
39
|
+
JsonDict = dict[str, Any]
|
|
40
|
+
|
|
41
|
+
# Protocol version for API compatibility
|
|
42
|
+
_PROTOCOL_VERSION = "v1"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CrmAgentEnv(EnvClient[CrmAgentAction, CrmAgentObservation, CrmAgentState]):
|
|
46
|
+
"""Client for the Construct Labs CRM Agent Environment.
|
|
47
|
+
|
|
48
|
+
This client connects to the CRM environment server via WebSocket and
|
|
49
|
+
provides methods for interacting with CRM data. It supports customization
|
|
50
|
+
through subclassing - override `system_prompt`, `tools`, or
|
|
51
|
+
`format_observation` to customize agent behavior.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
base_url: Base URL of the CRM environment server.
|
|
55
|
+
api_key: API key for authentication. Get one at https://construct-labs.com
|
|
56
|
+
connect_timeout_s: Timeout for establishing connection (default: 10s).
|
|
57
|
+
message_timeout_s: Timeout for receiving responses (default: 60s).
|
|
58
|
+
|
|
59
|
+
Example:
|
|
60
|
+
>>> # Basic usage
|
|
61
|
+
>>> with CrmAgentEnv(
|
|
62
|
+
... base_url="https://api.construct-labs.com",
|
|
63
|
+
... api_key="cl_live_xxx"
|
|
64
|
+
... ) as env:
|
|
65
|
+
... result = env.reset()
|
|
66
|
+
... print(env.system_prompt)
|
|
67
|
+
|
|
68
|
+
Example (custom subclass):
|
|
69
|
+
>>> class SalesAgent(CrmAgentEnv):
|
|
70
|
+
... @property
|
|
71
|
+
... def system_prompt(self) -> str:
|
|
72
|
+
... return "You are a sales assistant..."
|
|
73
|
+
...
|
|
74
|
+
... @property
|
|
75
|
+
... def tools(self) -> list[dict]:
|
|
76
|
+
... # Only expose company and opportunity tools
|
|
77
|
+
... return [t for t in self._default_tools()
|
|
78
|
+
... if 'company' in t['function']['name']
|
|
79
|
+
... or 'opportunity' in t['function']['name']]
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
base_url: str,
|
|
85
|
+
api_key: str | None = None,
|
|
86
|
+
connect_timeout_s: float = 10.0,
|
|
87
|
+
message_timeout_s: float = 60.0,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Initialize the CRM environment client.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
base_url: Base URL of the CRM environment server.
|
|
93
|
+
api_key: API key for authentication. Can also be set via
|
|
94
|
+
CRM_AGENT_API_KEY environment variable.
|
|
95
|
+
connect_timeout_s: Timeout for establishing WebSocket connection.
|
|
96
|
+
message_timeout_s: Timeout for receiving responses.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
ValueError: If no API key is provided or found in environment.
|
|
100
|
+
"""
|
|
101
|
+
# Resolve API key from parameter or environment
|
|
102
|
+
resolved_api_key = api_key or os.environ.get("CRM_AGENT_API_KEY")
|
|
103
|
+
if not resolved_api_key:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
"API key is required. Pass api_key parameter or set "
|
|
106
|
+
"CRM_AGENT_API_KEY environment variable. "
|
|
107
|
+
"Get your API key at https://construct-labs.com/api-keys"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
self._api_key = resolved_api_key
|
|
111
|
+
|
|
112
|
+
# Initialize parent class (but don't connect yet)
|
|
113
|
+
super().__init__(
|
|
114
|
+
base_url=base_url,
|
|
115
|
+
connect_timeout_s=connect_timeout_s,
|
|
116
|
+
message_timeout_s=message_timeout_s,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def connect(self) -> CrmAgentEnv:
|
|
120
|
+
"""Establish authenticated WebSocket connection to the server.
|
|
121
|
+
|
|
122
|
+
The API key is transmitted via WebSocket subprotocol for secure
|
|
123
|
+
authentication during the handshake.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
self for method chaining.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
ConnectionError: If connection cannot be established or
|
|
130
|
+
authentication fails.
|
|
131
|
+
"""
|
|
132
|
+
if self._ws is not None:
|
|
133
|
+
return self
|
|
134
|
+
|
|
135
|
+
# Bypass proxy for localhost connections
|
|
136
|
+
ws_url_lower = self._ws_url.lower()
|
|
137
|
+
is_localhost = "localhost" in ws_url_lower or "127.0.0.1" in ws_url_lower
|
|
138
|
+
|
|
139
|
+
old_no_proxy = os.environ.get("NO_PROXY")
|
|
140
|
+
if is_localhost:
|
|
141
|
+
current_no_proxy = old_no_proxy or ""
|
|
142
|
+
if "localhost" not in current_no_proxy.lower():
|
|
143
|
+
os.environ["NO_PROXY"] = (
|
|
144
|
+
f"{current_no_proxy},localhost,127.0.0.1"
|
|
145
|
+
if current_no_proxy
|
|
146
|
+
else "localhost,127.0.0.1"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
# Authenticate via WebSocket subprotocol
|
|
151
|
+
# Format: crm-{version}.{api_key}
|
|
152
|
+
auth_subprotocol = f"crm-{_PROTOCOL_VERSION}.{self._api_key}"
|
|
153
|
+
|
|
154
|
+
self._ws = ws_connect(
|
|
155
|
+
self._ws_url,
|
|
156
|
+
open_timeout=self._connect_timeout,
|
|
157
|
+
subprotocols=[auth_subprotocol],
|
|
158
|
+
)
|
|
159
|
+
except Exception as e:
|
|
160
|
+
error_msg = str(e)
|
|
161
|
+
if "401" in error_msg or "403" in error_msg or "4001" in error_msg:
|
|
162
|
+
raise ConnectionError(
|
|
163
|
+
"Authentication failed. Please verify your API key. "
|
|
164
|
+
"Get a valid key at https://construct-labs.com/api-keys"
|
|
165
|
+
) from e
|
|
166
|
+
raise ConnectionError(f"Failed to connect to {self._ws_url}: {e}") from e
|
|
167
|
+
finally:
|
|
168
|
+
# Restore original NO_PROXY value
|
|
169
|
+
if is_localhost:
|
|
170
|
+
if old_no_proxy is None:
|
|
171
|
+
os.environ.pop("NO_PROXY", None)
|
|
172
|
+
else:
|
|
173
|
+
os.environ["NO_PROXY"] = old_no_proxy
|
|
174
|
+
|
|
175
|
+
return self
|
|
176
|
+
|
|
177
|
+
def _step_payload(self, action: CrmAgentAction) -> JsonDict:
|
|
178
|
+
"""Convert CrmAgentAction to JSON payload for step request."""
|
|
179
|
+
return action.model_dump()
|
|
180
|
+
|
|
181
|
+
def _parse_result(self, payload: JsonDict) -> StepResult[CrmAgentObservation]:
|
|
182
|
+
"""Parse server response into StepResult."""
|
|
183
|
+
obs_data = payload.get("observation", {})
|
|
184
|
+
observation = CrmAgentObservation.model_validate(obs_data)
|
|
185
|
+
|
|
186
|
+
return StepResult(
|
|
187
|
+
observation=observation,
|
|
188
|
+
reward=payload.get("reward", observation.reward),
|
|
189
|
+
done=payload.get("done", observation.done),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def _parse_state(self, payload: JsonDict) -> CrmAgentState:
|
|
193
|
+
"""Parse server response into CrmAgentState."""
|
|
194
|
+
return CrmAgentState.model_validate(payload)
|
|
195
|
+
|
|
196
|
+
def _reset_payload(self, seed: int | None = None) -> JsonDict:
|
|
197
|
+
"""Create payload for reset request."""
|
|
198
|
+
if seed is not None:
|
|
199
|
+
return {"seed": seed}
|
|
200
|
+
return {}
|
|
201
|
+
|
|
202
|
+
# =========================================================================
|
|
203
|
+
# Extensible Properties - Override these in subclasses
|
|
204
|
+
# =========================================================================
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def system_prompt(self) -> str:
|
|
208
|
+
"""System prompt for the CRM agent.
|
|
209
|
+
|
|
210
|
+
Override this property in a subclass to customize the agent's behavior
|
|
211
|
+
and instructions.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
The system prompt string to use for the agent.
|
|
215
|
+
|
|
216
|
+
Example:
|
|
217
|
+
>>> class CustomAgent(CrmAgentEnv):
|
|
218
|
+
... @property
|
|
219
|
+
... def system_prompt(self) -> str:
|
|
220
|
+
... return '''You are a data entry assistant.
|
|
221
|
+
... Focus on accuracy and completeness.'''
|
|
222
|
+
"""
|
|
223
|
+
return self._default_system_prompt()
|
|
224
|
+
|
|
225
|
+
def _default_system_prompt(self) -> str:
|
|
226
|
+
"""Return the default system prompt.
|
|
227
|
+
|
|
228
|
+
Subclasses can call this to get the default prompt and extend it.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
The default system prompt string.
|
|
232
|
+
"""
|
|
233
|
+
return """You are a tool-using agent interacting with a CRM (Customer Relationship Management) system.
|
|
234
|
+
|
|
235
|
+
GOAL: Complete CRM tasks by creating, updating, and managing business data.
|
|
236
|
+
|
|
237
|
+
AVAILABLE OPERATIONS:
|
|
238
|
+
- Companies: list, get, create, update, delete
|
|
239
|
+
- People/Contacts: list, get, create, update, delete
|
|
240
|
+
- Opportunities: list, get, create, update, delete
|
|
241
|
+
- Notes: list, create (attach to companies, people, or opportunities)
|
|
242
|
+
- Tasks: list, create, update, complete
|
|
243
|
+
|
|
244
|
+
EXAMPLES:
|
|
245
|
+
|
|
246
|
+
1. List companies:
|
|
247
|
+
<tool_call>
|
|
248
|
+
{"name": "list_companies", "arguments": {"limit": 10}}
|
|
249
|
+
</tool_call>
|
|
250
|
+
|
|
251
|
+
2. Create a company:
|
|
252
|
+
<tool_call>
|
|
253
|
+
{"name": "create_company", "arguments": {"company_name": "Acme Corp", "company_domain": "acme.com"}}
|
|
254
|
+
</tool_call>
|
|
255
|
+
|
|
256
|
+
3. Create a contact:
|
|
257
|
+
<tool_call>
|
|
258
|
+
{"name": "create_person", "arguments": {"person_first_name": "John", "person_last_name": "Doe", "person_email": "john@acme.com"}}
|
|
259
|
+
</tool_call>
|
|
260
|
+
|
|
261
|
+
4. Submit final answer:
|
|
262
|
+
<tool_call>
|
|
263
|
+
{"name": "submit_answer", "arguments": {"answer": "The total pipeline value is $1.5M"}}
|
|
264
|
+
</tool_call>
|
|
265
|
+
|
|
266
|
+
IMPORTANT: Output ONLY a tool_call, no other text."""
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def tools(self) -> list[JsonDict]:
|
|
270
|
+
"""Tool definitions for the CRM environment.
|
|
271
|
+
|
|
272
|
+
Override this property in a subclass to customize available tools.
|
|
273
|
+
You can filter, extend, or replace the default tool set.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
List of tool definitions in OpenAI function calling format.
|
|
277
|
+
|
|
278
|
+
Example:
|
|
279
|
+
>>> class ReadOnlyAgent(CrmAgentEnv):
|
|
280
|
+
... @property
|
|
281
|
+
... def tools(self) -> list[dict]:
|
|
282
|
+
... # Only allow read operations
|
|
283
|
+
... read_ops = {'list_', 'get_', 'submit_answer'}
|
|
284
|
+
... return [t for t in self._default_tools()
|
|
285
|
+
... if any(op in t['function']['name'] for op in read_ops)]
|
|
286
|
+
"""
|
|
287
|
+
return self._default_tools()
|
|
288
|
+
|
|
289
|
+
def _default_tools(self) -> list[JsonDict]:
|
|
290
|
+
"""Return the default tool definitions.
|
|
291
|
+
|
|
292
|
+
Subclasses can call this to get all default tools and filter/extend them.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
Complete list of CRM tool definitions.
|
|
296
|
+
"""
|
|
297
|
+
return [
|
|
298
|
+
# =================================================================
|
|
299
|
+
# Company Tools
|
|
300
|
+
# =================================================================
|
|
301
|
+
{
|
|
302
|
+
"type": "function",
|
|
303
|
+
"function": {
|
|
304
|
+
"name": "list_companies",
|
|
305
|
+
"description": "List all companies in the CRM",
|
|
306
|
+
"parameters": {
|
|
307
|
+
"type": "object",
|
|
308
|
+
"properties": {
|
|
309
|
+
"limit": {
|
|
310
|
+
"type": "integer",
|
|
311
|
+
"default": 60,
|
|
312
|
+
"description": "Maximum number of companies to return (max 200)",
|
|
313
|
+
},
|
|
314
|
+
"starting_after": {
|
|
315
|
+
"type": "string",
|
|
316
|
+
"description": "Cursor for pagination - returns objects after this ID",
|
|
317
|
+
},
|
|
318
|
+
"ending_before": {
|
|
319
|
+
"type": "string",
|
|
320
|
+
"description": "Cursor for pagination - returns objects before this ID",
|
|
321
|
+
},
|
|
322
|
+
"order_by": {
|
|
323
|
+
"type": "string",
|
|
324
|
+
"description": "Order by: field_name[ASC|DESC]",
|
|
325
|
+
},
|
|
326
|
+
"filter": {
|
|
327
|
+
"type": "string",
|
|
328
|
+
"description": "Filter: field[eq|gt|lt|contains]:value",
|
|
329
|
+
},
|
|
330
|
+
"depth": {
|
|
331
|
+
"type": "integer",
|
|
332
|
+
"default": 1,
|
|
333
|
+
"description": "Relation depth: 0=primary only, 1=include relations",
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
"required": [],
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
"type": "function",
|
|
342
|
+
"function": {
|
|
343
|
+
"name": "get_company",
|
|
344
|
+
"description": "Get details of a specific company",
|
|
345
|
+
"parameters": {
|
|
346
|
+
"type": "object",
|
|
347
|
+
"properties": {
|
|
348
|
+
"record_id": {
|
|
349
|
+
"type": "string",
|
|
350
|
+
"description": "ID of the company to retrieve",
|
|
351
|
+
},
|
|
352
|
+
"depth": {
|
|
353
|
+
"type": "integer",
|
|
354
|
+
"default": 1,
|
|
355
|
+
"description": "Relation depth: 0=primary only, 1=include relations",
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
"required": ["record_id"],
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
"type": "function",
|
|
364
|
+
"function": {
|
|
365
|
+
"name": "create_company",
|
|
366
|
+
"description": "Create a new company in the CRM",
|
|
367
|
+
"parameters": {
|
|
368
|
+
"type": "object",
|
|
369
|
+
"properties": {
|
|
370
|
+
"company_name": {
|
|
371
|
+
"type": "string",
|
|
372
|
+
"description": "Name of the company",
|
|
373
|
+
},
|
|
374
|
+
"company_domain": {
|
|
375
|
+
"type": "string",
|
|
376
|
+
"description": "Domain/website of the company",
|
|
377
|
+
},
|
|
378
|
+
"company_address": {
|
|
379
|
+
"type": "string",
|
|
380
|
+
"description": "Address of the company",
|
|
381
|
+
},
|
|
382
|
+
"company_employees": {
|
|
383
|
+
"type": "integer",
|
|
384
|
+
"description": "Number of employees",
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
"required": ["company_name"],
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
"type": "function",
|
|
393
|
+
"function": {
|
|
394
|
+
"name": "update_company",
|
|
395
|
+
"description": "Update an existing company",
|
|
396
|
+
"parameters": {
|
|
397
|
+
"type": "object",
|
|
398
|
+
"properties": {
|
|
399
|
+
"record_id": {
|
|
400
|
+
"type": "string",
|
|
401
|
+
"description": "ID of the company to update",
|
|
402
|
+
},
|
|
403
|
+
"company_name": {"type": "string"},
|
|
404
|
+
"company_domain": {"type": "string"},
|
|
405
|
+
"company_address": {"type": "string"},
|
|
406
|
+
"company_employees": {"type": "integer"},
|
|
407
|
+
},
|
|
408
|
+
"required": ["record_id"],
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
"type": "function",
|
|
414
|
+
"function": {
|
|
415
|
+
"name": "delete_company",
|
|
416
|
+
"description": "Delete a company from the CRM",
|
|
417
|
+
"parameters": {
|
|
418
|
+
"type": "object",
|
|
419
|
+
"properties": {
|
|
420
|
+
"record_id": {
|
|
421
|
+
"type": "string",
|
|
422
|
+
"description": "ID of the company to delete",
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
"required": ["record_id"],
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
# =================================================================
|
|
430
|
+
# Person/Contact Tools
|
|
431
|
+
# =================================================================
|
|
432
|
+
{
|
|
433
|
+
"type": "function",
|
|
434
|
+
"function": {
|
|
435
|
+
"name": "list_people",
|
|
436
|
+
"description": "List all contacts/people in the CRM",
|
|
437
|
+
"parameters": {
|
|
438
|
+
"type": "object",
|
|
439
|
+
"properties": {
|
|
440
|
+
"limit": {
|
|
441
|
+
"type": "integer",
|
|
442
|
+
"default": 60,
|
|
443
|
+
"description": "Maximum number of contacts to return (max 200)",
|
|
444
|
+
},
|
|
445
|
+
"starting_after": {"type": "string"},
|
|
446
|
+
"ending_before": {"type": "string"},
|
|
447
|
+
"order_by": {"type": "string"},
|
|
448
|
+
"filter": {"type": "string"},
|
|
449
|
+
"depth": {"type": "integer", "default": 1},
|
|
450
|
+
},
|
|
451
|
+
"required": [],
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
"type": "function",
|
|
457
|
+
"function": {
|
|
458
|
+
"name": "get_person",
|
|
459
|
+
"description": "Get details of a specific contact",
|
|
460
|
+
"parameters": {
|
|
461
|
+
"type": "object",
|
|
462
|
+
"properties": {
|
|
463
|
+
"record_id": {
|
|
464
|
+
"type": "string",
|
|
465
|
+
"description": "ID of the contact to retrieve",
|
|
466
|
+
},
|
|
467
|
+
"depth": {"type": "integer", "default": 1},
|
|
468
|
+
},
|
|
469
|
+
"required": ["record_id"],
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
"type": "function",
|
|
475
|
+
"function": {
|
|
476
|
+
"name": "create_person",
|
|
477
|
+
"description": "Create a new contact/person in the CRM",
|
|
478
|
+
"parameters": {
|
|
479
|
+
"type": "object",
|
|
480
|
+
"properties": {
|
|
481
|
+
"person_first_name": {
|
|
482
|
+
"type": "string",
|
|
483
|
+
"description": "First name",
|
|
484
|
+
},
|
|
485
|
+
"person_last_name": {
|
|
486
|
+
"type": "string",
|
|
487
|
+
"description": "Last name",
|
|
488
|
+
},
|
|
489
|
+
"person_email": {
|
|
490
|
+
"type": "string",
|
|
491
|
+
"description": "Email address",
|
|
492
|
+
},
|
|
493
|
+
"person_phone": {
|
|
494
|
+
"type": "string",
|
|
495
|
+
"description": "Phone number",
|
|
496
|
+
},
|
|
497
|
+
"person_company_id": {
|
|
498
|
+
"type": "string",
|
|
499
|
+
"description": "ID of associated company",
|
|
500
|
+
},
|
|
501
|
+
"person_job_title": {
|
|
502
|
+
"type": "string",
|
|
503
|
+
"description": "Job title",
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
"required": ["person_first_name", "person_last_name"],
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
"type": "function",
|
|
512
|
+
"function": {
|
|
513
|
+
"name": "update_person",
|
|
514
|
+
"description": "Update an existing contact",
|
|
515
|
+
"parameters": {
|
|
516
|
+
"type": "object",
|
|
517
|
+
"properties": {
|
|
518
|
+
"record_id": {
|
|
519
|
+
"type": "string",
|
|
520
|
+
"description": "ID of the contact to update",
|
|
521
|
+
},
|
|
522
|
+
"person_first_name": {"type": "string"},
|
|
523
|
+
"person_last_name": {"type": "string"},
|
|
524
|
+
"person_email": {"type": "string"},
|
|
525
|
+
"person_phone": {"type": "string"},
|
|
526
|
+
"person_job_title": {"type": "string"},
|
|
527
|
+
},
|
|
528
|
+
"required": ["record_id"],
|
|
529
|
+
},
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
"type": "function",
|
|
534
|
+
"function": {
|
|
535
|
+
"name": "delete_person",
|
|
536
|
+
"description": "Delete a contact from the CRM",
|
|
537
|
+
"parameters": {
|
|
538
|
+
"type": "object",
|
|
539
|
+
"properties": {
|
|
540
|
+
"record_id": {
|
|
541
|
+
"type": "string",
|
|
542
|
+
"description": "ID of the contact to delete",
|
|
543
|
+
},
|
|
544
|
+
},
|
|
545
|
+
"required": ["record_id"],
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
# =================================================================
|
|
550
|
+
# Opportunity Tools
|
|
551
|
+
# =================================================================
|
|
552
|
+
{
|
|
553
|
+
"type": "function",
|
|
554
|
+
"function": {
|
|
555
|
+
"name": "list_opportunities",
|
|
556
|
+
"description": "List all opportunities/deals in the CRM",
|
|
557
|
+
"parameters": {
|
|
558
|
+
"type": "object",
|
|
559
|
+
"properties": {
|
|
560
|
+
"limit": {
|
|
561
|
+
"type": "integer",
|
|
562
|
+
"default": 60,
|
|
563
|
+
"description": "Maximum number to return (max 200)",
|
|
564
|
+
},
|
|
565
|
+
"starting_after": {"type": "string"},
|
|
566
|
+
"ending_before": {"type": "string"},
|
|
567
|
+
"order_by": {"type": "string"},
|
|
568
|
+
"filter": {"type": "string"},
|
|
569
|
+
"depth": {"type": "integer", "default": 1},
|
|
570
|
+
},
|
|
571
|
+
"required": [],
|
|
572
|
+
},
|
|
573
|
+
},
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
"type": "function",
|
|
577
|
+
"function": {
|
|
578
|
+
"name": "get_opportunity",
|
|
579
|
+
"description": "Get details of a specific opportunity",
|
|
580
|
+
"parameters": {
|
|
581
|
+
"type": "object",
|
|
582
|
+
"properties": {
|
|
583
|
+
"record_id": {
|
|
584
|
+
"type": "string",
|
|
585
|
+
"description": "ID of the opportunity",
|
|
586
|
+
},
|
|
587
|
+
"depth": {"type": "integer", "default": 1},
|
|
588
|
+
},
|
|
589
|
+
"required": ["record_id"],
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
"type": "function",
|
|
595
|
+
"function": {
|
|
596
|
+
"name": "create_opportunity",
|
|
597
|
+
"description": "Create a new opportunity/deal",
|
|
598
|
+
"parameters": {
|
|
599
|
+
"type": "object",
|
|
600
|
+
"properties": {
|
|
601
|
+
"opportunity_name": {
|
|
602
|
+
"type": "string",
|
|
603
|
+
"description": "Name of the opportunity",
|
|
604
|
+
},
|
|
605
|
+
"opportunity_amount": {
|
|
606
|
+
"type": "number",
|
|
607
|
+
"description": "Deal value",
|
|
608
|
+
},
|
|
609
|
+
"opportunity_stage": {
|
|
610
|
+
"type": "string",
|
|
611
|
+
"enum": ["NEW", "MEETING", "PROPOSAL", "WON", "LOST"],
|
|
612
|
+
"description": "Sales stage",
|
|
613
|
+
},
|
|
614
|
+
"opportunity_close_date": {
|
|
615
|
+
"type": "string",
|
|
616
|
+
"description": "Expected close date (ISO format)",
|
|
617
|
+
},
|
|
618
|
+
"opportunity_company_id": {
|
|
619
|
+
"type": "string",
|
|
620
|
+
"description": "Associated company ID",
|
|
621
|
+
},
|
|
622
|
+
"opportunity_person_id": {
|
|
623
|
+
"type": "string",
|
|
624
|
+
"description": "Point of contact ID",
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
"required": ["opportunity_name"],
|
|
628
|
+
},
|
|
629
|
+
},
|
|
630
|
+
},
|
|
631
|
+
{
|
|
632
|
+
"type": "function",
|
|
633
|
+
"function": {
|
|
634
|
+
"name": "update_opportunity",
|
|
635
|
+
"description": "Update an existing opportunity",
|
|
636
|
+
"parameters": {
|
|
637
|
+
"type": "object",
|
|
638
|
+
"properties": {
|
|
639
|
+
"record_id": {
|
|
640
|
+
"type": "string",
|
|
641
|
+
"description": "ID of the opportunity to update",
|
|
642
|
+
},
|
|
643
|
+
"opportunity_name": {"type": "string"},
|
|
644
|
+
"opportunity_amount": {"type": "number"},
|
|
645
|
+
"opportunity_stage": {
|
|
646
|
+
"type": "string",
|
|
647
|
+
"enum": ["NEW", "MEETING", "PROPOSAL", "WON", "LOST"],
|
|
648
|
+
},
|
|
649
|
+
"opportunity_close_date": {"type": "string"},
|
|
650
|
+
},
|
|
651
|
+
"required": ["record_id"],
|
|
652
|
+
},
|
|
653
|
+
},
|
|
654
|
+
},
|
|
655
|
+
{
|
|
656
|
+
"type": "function",
|
|
657
|
+
"function": {
|
|
658
|
+
"name": "delete_opportunity",
|
|
659
|
+
"description": "Delete an opportunity",
|
|
660
|
+
"parameters": {
|
|
661
|
+
"type": "object",
|
|
662
|
+
"properties": {
|
|
663
|
+
"record_id": {
|
|
664
|
+
"type": "string",
|
|
665
|
+
"description": "ID of the opportunity to delete",
|
|
666
|
+
},
|
|
667
|
+
},
|
|
668
|
+
"required": ["record_id"],
|
|
669
|
+
},
|
|
670
|
+
},
|
|
671
|
+
},
|
|
672
|
+
# =================================================================
|
|
673
|
+
# Note Tools
|
|
674
|
+
# =================================================================
|
|
675
|
+
{
|
|
676
|
+
"type": "function",
|
|
677
|
+
"function": {
|
|
678
|
+
"name": "list_notes",
|
|
679
|
+
"description": "List all notes in the CRM",
|
|
680
|
+
"parameters": {
|
|
681
|
+
"type": "object",
|
|
682
|
+
"properties": {
|
|
683
|
+
"limit": {
|
|
684
|
+
"type": "integer",
|
|
685
|
+
"default": 10,
|
|
686
|
+
"description": "Maximum number of notes to return",
|
|
687
|
+
},
|
|
688
|
+
},
|
|
689
|
+
"required": [],
|
|
690
|
+
},
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
{
|
|
694
|
+
"type": "function",
|
|
695
|
+
"function": {
|
|
696
|
+
"name": "create_note",
|
|
697
|
+
"description": "Create a note attached to a record",
|
|
698
|
+
"parameters": {
|
|
699
|
+
"type": "object",
|
|
700
|
+
"properties": {
|
|
701
|
+
"note_body": {
|
|
702
|
+
"type": "string",
|
|
703
|
+
"description": "Content of the note",
|
|
704
|
+
},
|
|
705
|
+
"note_target_id": {
|
|
706
|
+
"type": "string",
|
|
707
|
+
"description": "ID of record to attach note to",
|
|
708
|
+
},
|
|
709
|
+
"note_target_type": {
|
|
710
|
+
"type": "string",
|
|
711
|
+
"enum": ["company", "person", "opportunity"],
|
|
712
|
+
"description": "Type of record",
|
|
713
|
+
},
|
|
714
|
+
},
|
|
715
|
+
"required": ["note_body"],
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
},
|
|
719
|
+
# =================================================================
|
|
720
|
+
# Task Tools
|
|
721
|
+
# =================================================================
|
|
722
|
+
{
|
|
723
|
+
"type": "function",
|
|
724
|
+
"function": {
|
|
725
|
+
"name": "list_tasks",
|
|
726
|
+
"description": "List all tasks in the CRM",
|
|
727
|
+
"parameters": {
|
|
728
|
+
"type": "object",
|
|
729
|
+
"properties": {
|
|
730
|
+
"limit": {
|
|
731
|
+
"type": "integer",
|
|
732
|
+
"default": 10,
|
|
733
|
+
"description": "Maximum number of tasks to return",
|
|
734
|
+
},
|
|
735
|
+
},
|
|
736
|
+
"required": [],
|
|
737
|
+
},
|
|
738
|
+
},
|
|
739
|
+
},
|
|
740
|
+
{
|
|
741
|
+
"type": "function",
|
|
742
|
+
"function": {
|
|
743
|
+
"name": "create_task",
|
|
744
|
+
"description": "Create a task, optionally linked to a record",
|
|
745
|
+
"parameters": {
|
|
746
|
+
"type": "object",
|
|
747
|
+
"properties": {
|
|
748
|
+
"task_title": {
|
|
749
|
+
"type": "string",
|
|
750
|
+
"description": "Title of the task",
|
|
751
|
+
},
|
|
752
|
+
"task_body": {
|
|
753
|
+
"type": "string",
|
|
754
|
+
"description": "Description",
|
|
755
|
+
},
|
|
756
|
+
"task_due_date": {
|
|
757
|
+
"type": "string",
|
|
758
|
+
"description": "Due date (ISO format)",
|
|
759
|
+
},
|
|
760
|
+
"task_status": {
|
|
761
|
+
"type": "string",
|
|
762
|
+
"enum": ["TODO", "IN_PROGRESS", "DONE"],
|
|
763
|
+
"description": "Status",
|
|
764
|
+
},
|
|
765
|
+
"task_target_id": {
|
|
766
|
+
"type": "string",
|
|
767
|
+
"description": "ID of record to link task to",
|
|
768
|
+
},
|
|
769
|
+
"task_target_type": {
|
|
770
|
+
"type": "string",
|
|
771
|
+
"enum": ["company", "person", "opportunity"],
|
|
772
|
+
"description": "Type of record",
|
|
773
|
+
},
|
|
774
|
+
},
|
|
775
|
+
"required": ["task_title"],
|
|
776
|
+
},
|
|
777
|
+
},
|
|
778
|
+
},
|
|
779
|
+
{
|
|
780
|
+
"type": "function",
|
|
781
|
+
"function": {
|
|
782
|
+
"name": "update_task",
|
|
783
|
+
"description": "Update an existing task",
|
|
784
|
+
"parameters": {
|
|
785
|
+
"type": "object",
|
|
786
|
+
"properties": {
|
|
787
|
+
"record_id": {
|
|
788
|
+
"type": "string",
|
|
789
|
+
"description": "ID of the task to update",
|
|
790
|
+
},
|
|
791
|
+
"task_title": {"type": "string"},
|
|
792
|
+
"task_body": {"type": "string"},
|
|
793
|
+
"task_due_date": {"type": "string"},
|
|
794
|
+
},
|
|
795
|
+
"required": ["record_id"],
|
|
796
|
+
},
|
|
797
|
+
},
|
|
798
|
+
},
|
|
799
|
+
{
|
|
800
|
+
"type": "function",
|
|
801
|
+
"function": {
|
|
802
|
+
"name": "complete_task",
|
|
803
|
+
"description": "Mark a task as complete",
|
|
804
|
+
"parameters": {
|
|
805
|
+
"type": "object",
|
|
806
|
+
"properties": {
|
|
807
|
+
"record_id": {
|
|
808
|
+
"type": "string",
|
|
809
|
+
"description": "ID of the task to complete",
|
|
810
|
+
},
|
|
811
|
+
},
|
|
812
|
+
"required": ["record_id"],
|
|
813
|
+
},
|
|
814
|
+
},
|
|
815
|
+
},
|
|
816
|
+
# =================================================================
|
|
817
|
+
# Submit Answer Tool
|
|
818
|
+
# =================================================================
|
|
819
|
+
{
|
|
820
|
+
"type": "function",
|
|
821
|
+
"function": {
|
|
822
|
+
"name": "submit_answer",
|
|
823
|
+
"description": "Submit final answer and end the session",
|
|
824
|
+
"parameters": {
|
|
825
|
+
"type": "object",
|
|
826
|
+
"properties": {
|
|
827
|
+
"answer": {
|
|
828
|
+
"type": "string",
|
|
829
|
+
"description": "The final answer based on CRM data",
|
|
830
|
+
},
|
|
831
|
+
},
|
|
832
|
+
"required": ["answer"],
|
|
833
|
+
},
|
|
834
|
+
},
|
|
835
|
+
},
|
|
836
|
+
]
|
|
837
|
+
|
|
838
|
+
# =========================================================================
|
|
839
|
+
# Tool Parsing and Observation Formatting
|
|
840
|
+
# =========================================================================
|
|
841
|
+
|
|
842
|
+
def parse_tool_call(self, tool_call: JsonDict) -> ParsedAction:
|
|
843
|
+
"""Parse a tool call from the LLM into a CrmAgentAction.
|
|
844
|
+
|
|
845
|
+
This method maps tool names to action types and extracts arguments.
|
|
846
|
+
Override this in a subclass to handle custom tools.
|
|
847
|
+
|
|
848
|
+
Args:
|
|
849
|
+
tool_call: Dictionary with 'name' and 'arguments' from LLM output.
|
|
850
|
+
|
|
851
|
+
Returns:
|
|
852
|
+
ParsedAction with the action and validity status.
|
|
853
|
+
"""
|
|
854
|
+
tool_name = str(tool_call.get("name", "")).lower().strip()
|
|
855
|
+
arguments_raw = tool_call.get("arguments")
|
|
856
|
+
|
|
857
|
+
# Parse arguments
|
|
858
|
+
if isinstance(arguments_raw, dict):
|
|
859
|
+
arguments = cast(JsonDict, arguments_raw)
|
|
860
|
+
elif isinstance(arguments_raw, str):
|
|
861
|
+
try:
|
|
862
|
+
arguments = json.loads(arguments_raw)
|
|
863
|
+
except json.JSONDecodeError:
|
|
864
|
+
return ParsedAction(
|
|
865
|
+
action=None,
|
|
866
|
+
is_valid=False,
|
|
867
|
+
error_message=f"Invalid JSON in arguments: {arguments_raw}",
|
|
868
|
+
)
|
|
869
|
+
else:
|
|
870
|
+
return ParsedAction(
|
|
871
|
+
action=None,
|
|
872
|
+
is_valid=False,
|
|
873
|
+
error_message=f"Arguments must be dict or JSON string, got: {type(arguments_raw)}",
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
# Map tool names to action types
|
|
877
|
+
tool_to_action_type: dict[str, CRMActionType] = {
|
|
878
|
+
# Company
|
|
879
|
+
"list_companies": CRMActionType.LIST_COMPANIES,
|
|
880
|
+
"get_company": CRMActionType.GET_COMPANY,
|
|
881
|
+
"create_company": CRMActionType.CREATE_COMPANY,
|
|
882
|
+
"update_company": CRMActionType.UPDATE_COMPANY,
|
|
883
|
+
"delete_company": CRMActionType.DELETE_COMPANY,
|
|
884
|
+
# Person
|
|
885
|
+
"list_people": CRMActionType.LIST_PEOPLE,
|
|
886
|
+
"get_person": CRMActionType.GET_PERSON,
|
|
887
|
+
"create_person": CRMActionType.CREATE_PERSON,
|
|
888
|
+
"update_person": CRMActionType.UPDATE_PERSON,
|
|
889
|
+
"delete_person": CRMActionType.DELETE_PERSON,
|
|
890
|
+
# Opportunity
|
|
891
|
+
"list_opportunities": CRMActionType.LIST_OPPORTUNITIES,
|
|
892
|
+
"get_opportunity": CRMActionType.GET_OPPORTUNITY,
|
|
893
|
+
"create_opportunity": CRMActionType.CREATE_OPPORTUNITY,
|
|
894
|
+
"update_opportunity": CRMActionType.UPDATE_OPPORTUNITY,
|
|
895
|
+
"delete_opportunity": CRMActionType.DELETE_OPPORTUNITY,
|
|
896
|
+
# Note
|
|
897
|
+
"list_notes": CRMActionType.LIST_NOTES,
|
|
898
|
+
"create_note": CRMActionType.CREATE_NOTE,
|
|
899
|
+
# Task
|
|
900
|
+
"list_tasks": CRMActionType.LIST_TASKS,
|
|
901
|
+
"create_task": CRMActionType.CREATE_TASK,
|
|
902
|
+
"update_task": CRMActionType.UPDATE_TASK,
|
|
903
|
+
"complete_task": CRMActionType.COMPLETE_TASK,
|
|
904
|
+
# Answer
|
|
905
|
+
"submit_answer": CRMActionType.SUBMIT_ANSWER,
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if not tool_name:
|
|
909
|
+
return ParsedAction(
|
|
910
|
+
action=None,
|
|
911
|
+
is_valid=False,
|
|
912
|
+
error_message="Tool name is required",
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
action_type = tool_to_action_type.get(tool_name)
|
|
916
|
+
if action_type is None:
|
|
917
|
+
return ParsedAction(
|
|
918
|
+
action=None,
|
|
919
|
+
is_valid=False,
|
|
920
|
+
error_message=f"Unknown tool: '{tool_name}'. "
|
|
921
|
+
f"Valid tools: {list(tool_to_action_type.keys())}",
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
# Build action with all valid fields
|
|
925
|
+
action_kwargs: JsonDict = {"action_type": action_type}
|
|
926
|
+
|
|
927
|
+
valid_fields = {
|
|
928
|
+
"record_id",
|
|
929
|
+
"company_name",
|
|
930
|
+
"company_domain",
|
|
931
|
+
"company_address",
|
|
932
|
+
"company_employees",
|
|
933
|
+
"person_first_name",
|
|
934
|
+
"person_last_name",
|
|
935
|
+
"person_email",
|
|
936
|
+
"person_phone",
|
|
937
|
+
"person_company_id",
|
|
938
|
+
"person_job_title",
|
|
939
|
+
"opportunity_name",
|
|
940
|
+
"opportunity_amount",
|
|
941
|
+
"opportunity_stage",
|
|
942
|
+
"opportunity_close_date",
|
|
943
|
+
"opportunity_company_id",
|
|
944
|
+
"opportunity_person_id",
|
|
945
|
+
"note_body",
|
|
946
|
+
"note_target_id",
|
|
947
|
+
"note_target_type",
|
|
948
|
+
"task_title",
|
|
949
|
+
"task_body",
|
|
950
|
+
"task_due_date",
|
|
951
|
+
"task_assignee_id",
|
|
952
|
+
"task_status",
|
|
953
|
+
"task_target_id",
|
|
954
|
+
"task_target_type",
|
|
955
|
+
"limit",
|
|
956
|
+
"cursor",
|
|
957
|
+
"starting_after",
|
|
958
|
+
"ending_before",
|
|
959
|
+
"order_by",
|
|
960
|
+
"filter",
|
|
961
|
+
"depth",
|
|
962
|
+
"answer",
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
for field in valid_fields:
|
|
966
|
+
if field in arguments and arguments[field] is not None:
|
|
967
|
+
action_kwargs[field] = arguments[field]
|
|
968
|
+
|
|
969
|
+
try:
|
|
970
|
+
action = CrmAgentAction(**action_kwargs)
|
|
971
|
+
return ParsedAction(action=action, is_valid=True)
|
|
972
|
+
except Exception as e:
|
|
973
|
+
return ParsedAction(
|
|
974
|
+
action=None,
|
|
975
|
+
is_valid=False,
|
|
976
|
+
error_message=f"Failed to create action: {e}",
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
def format_observation(self, observation: CrmAgentObservation) -> str:
|
|
980
|
+
"""Format an observation as a string for the LLM.
|
|
981
|
+
|
|
982
|
+
Override this in a subclass to customize how observations are
|
|
983
|
+
presented to the agent.
|
|
984
|
+
|
|
985
|
+
Args:
|
|
986
|
+
observation: The observation to format.
|
|
987
|
+
|
|
988
|
+
Returns:
|
|
989
|
+
String representation for the LLM.
|
|
990
|
+
|
|
991
|
+
Example:
|
|
992
|
+
>>> class VerboseAgent(CrmAgentEnv):
|
|
993
|
+
... def format_observation(self, obs):
|
|
994
|
+
... base = super().format_observation(obs)
|
|
995
|
+
... return f"=== CRM Response ===\\n{base}\\n=== End ==="
|
|
996
|
+
"""
|
|
997
|
+
import json
|
|
998
|
+
|
|
999
|
+
lines: list[str] = [json.dumps(observation.data)]
|
|
1000
|
+
|
|
1001
|
+
if observation.error:
|
|
1002
|
+
lines.append(f"Error: {observation.error}")
|
|
1003
|
+
|
|
1004
|
+
return "\n".join(lines)
|