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.
@@ -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)