construct-labs-crm-env 0.1.1__tar.gz → 0.1.3__tar.gz

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,23 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ .venv/
6
+
7
+ # Testing
8
+ .pytest_cache/
9
+ .coverage
10
+ htmlcov/
11
+
12
+ # Linting
13
+ .ruff_cache/
14
+ .mypy_cache/
15
+
16
+ # Build
17
+ dist/
18
+ build/
19
+ *.egg-info/
20
+
21
+ # IDE
22
+ .idea/
23
+ .vscode/
@@ -0,0 +1,56 @@
1
+ Construct Labs CRM Environment SDK - Evaluation License
2
+
3
+ Copyright (c) 2024 Construct Labs GmbH. All rights reserved.
4
+
5
+ TERMS AND CONDITIONS
6
+
7
+ 1. EVALUATION LICENSE
8
+ This software and associated documentation files (the "Software") are
9
+ provided by Construct Labs GmbH for evaluation purposes only.
10
+
11
+ You may use this Software solely to evaluate its functionality and
12
+ suitability for your needs. This evaluation license does not grant
13
+ any rights to use the Software in production environments.
14
+
15
+ 2. RESTRICTIONS
16
+ Under this evaluation license, you may NOT:
17
+ - Deploy the Software in production environments
18
+ - Use the Software for commercial purposes
19
+ - Train models intended for production use
20
+ - Distribute, sublicense, or transfer the Software to third parties
21
+ - Remove or alter any proprietary notices
22
+
23
+ 3. COMMERCIAL LICENSE REQUIRED
24
+ Any use beyond evaluation requires a commercial license agreement
25
+ with Construct Labs GmbH. This includes but is not limited to:
26
+ - Production deployment
27
+ - Commercial use of any kind
28
+ - Training models for production use
29
+ - Integration into commercial products or services
30
+
31
+ To obtain a commercial license, contact:
32
+
33
+ Construct Labs GmbH
34
+ Email: hello@construct-labs.com
35
+
36
+ 4. DATA AND PRIVACY
37
+ During evaluation, the Software may connect to Construct Labs servers.
38
+ Usage data may be collected for service improvement purposes.
39
+
40
+ 5. WARRANTY DISCLAIMER
41
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
42
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
43
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
44
+
45
+ 6. LIMITATION OF LIABILITY
46
+ IN NO EVENT SHALL CONSTRUCT LABS GMBH BE LIABLE FOR ANY CLAIM, DAMAGES OR
47
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
48
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
49
+ DEALINGS IN THE SOFTWARE.
50
+
51
+ 7. TERMINATION
52
+ This evaluation license may be terminated by Construct Labs GmbH at any
53
+ time. Your rights under this license will terminate automatically without
54
+ notice if you fail to comply with any of its terms.
55
+
56
+ For licensing inquiries: hello@construct-labs.com
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: construct-labs-crm-env
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: CRM Agent Environment SDK by Construct Labs - Train RL agents to interact with CRM systems
5
5
  Project-URL: Homepage, https://construct-labs.com
6
6
  Author-email: Construct Labs GmbH <hello@construct-labs.com>
@@ -19,6 +19,7 @@ Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
20
  Classifier: Typing :: Typed
21
21
  Requires-Python: >=3.10
22
+ Requires-Dist: openenv-core>=0.2.0
22
23
  Requires-Dist: pydantic>=2.0.0
23
24
  Requires-Dist: websockets>=12.0
24
25
  Provides-Extra: dev
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "construct-labs-crm-env"
7
- version = "0.1.1"
7
+ version = "0.1.3"
8
8
  description = "CRM Agent Environment SDK by Construct Labs - Train RL agents to interact with CRM systems"
9
9
  readme = "README.md"
10
10
  license = { text = "Proprietary" }
@@ -34,6 +34,7 @@ classifiers = [
34
34
  "Typing :: Typed",
35
35
  ]
36
36
  dependencies = [
37
+ "openenv-core>=0.2.0",
37
38
  "pydantic>=2.0.0",
38
39
  "websockets>=12.0",
39
40
  ]
@@ -88,7 +89,7 @@ warn_unused_ignores = true
88
89
  disallow_untyped_defs = true
89
90
 
90
91
  [[tool.mypy.overrides]]
91
- module = ["websockets.*"]
92
+ module = ["websockets.*", "openenv.*"]
92
93
  ignore_missing_imports = true
93
94
 
94
95
  [tool.pytest.ini_options]
@@ -4,9 +4,11 @@ This package provides a Python client for interacting with the Construct Labs
4
4
  CRM Agent Environment - a reinforcement learning environment for training
5
5
  agents to interact with CRM systems.
6
6
 
7
- For more information, see https://docs.construct-labs.com/crm-agent
7
+ For licensing and support, contact hello@construct-labs.com
8
8
  """
9
9
 
10
+ from importlib.metadata import PackageNotFoundError, version
11
+
10
12
  from .client import CrmAgentEnv
11
13
  from .models import (
12
14
  CRMActionType,
@@ -16,7 +18,10 @@ from .models import (
16
18
  )
17
19
  from .protocol import ParsedAction
18
20
 
19
- __version__ = "0.1.0"
21
+ try:
22
+ __version__ = version("construct-labs-crm-env")
23
+ except PackageNotFoundError:
24
+ __version__ = "0.0.0+dev"
20
25
 
21
26
  __all__ = [
22
27
  # Main client
@@ -0,0 +1,515 @@
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 openenv.core import EnvClient
28
+ from openenv.core.client_types import StepResult
29
+ from websockets.sync.client import connect as ws_connect
30
+
31
+ from .models import (
32
+ CRMActionType,
33
+ CrmAgentAction,
34
+ CrmAgentObservation,
35
+ CrmAgentState,
36
+ )
37
+ from .protocol import ParsedAction
38
+ from .tools import DEFAULT_TOOLS
39
+
40
+ # Type alias for JSON-serializable dictionaries
41
+ JsonDict = dict[str, Any]
42
+
43
+ # Protocol version for API compatibility
44
+ _PROTOCOL_VERSION = "v1"
45
+
46
+
47
+ class CrmAgentEnv(EnvClient[CrmAgentAction, CrmAgentObservation, CrmAgentState]):
48
+ """Client for the Construct Labs CRM Agent Environment.
49
+
50
+ This client connects to the CRM environment server via WebSocket and
51
+ provides methods for interacting with CRM data. It supports customization
52
+ through subclassing - override `system_prompt`, `tools`, or
53
+ `format_observation` to customize agent behavior.
54
+
55
+ Args:
56
+ base_url: Base URL of the CRM environment server.
57
+ api_key: API key for authentication. Get one at https://construct-labs.com
58
+ connect_timeout_s: Timeout for establishing connection (default: 10s).
59
+ message_timeout_s: Timeout for receiving responses (default: 60s).
60
+
61
+ Example:
62
+ >>> # Basic usage
63
+ >>> with CrmAgentEnv(
64
+ ... base_url="https://api.construct-labs.com",
65
+ ... api_key="cl_live_xxx"
66
+ ... ) as env:
67
+ ... result = env.reset()
68
+ ... print(env.system_prompt)
69
+
70
+ Example (custom subclass):
71
+ >>> class SalesAgent(CrmAgentEnv):
72
+ ... @property
73
+ ... def system_prompt(self) -> str:
74
+ ... return "You are a sales assistant..."
75
+ ...
76
+ ... @property
77
+ ... def tools(self) -> list[dict]:
78
+ ... # Only expose company and opportunity tools
79
+ ... return [t for t in self._default_tools()
80
+ ... if 'company' in t['function']['name']
81
+ ... or 'opportunity' in t['function']['name']]
82
+ """
83
+
84
+ def __init__(
85
+ self,
86
+ base_url: str,
87
+ api_key: str | None = None,
88
+ connect_timeout_s: float = 10.0,
89
+ message_timeout_s: float = 60.0,
90
+ ) -> None:
91
+ """Initialize the CRM environment client.
92
+
93
+ Args:
94
+ base_url: Base URL of the CRM environment server.
95
+ api_key: API key for authentication. Can also be set via
96
+ CRM_AGENT_API_KEY environment variable.
97
+ connect_timeout_s: Timeout for establishing WebSocket connection.
98
+ message_timeout_s: Timeout for receiving responses.
99
+
100
+ Raises:
101
+ ValueError: If no API key is provided or found in environment.
102
+ """
103
+ # Resolve API key from parameter or environment
104
+ resolved_api_key = api_key or os.environ.get("CRM_AGENT_API_KEY")
105
+ if not resolved_api_key:
106
+ raise ValueError(
107
+ "API key is required. Pass api_key parameter or set "
108
+ "CRM_AGENT_API_KEY environment variable. "
109
+ "Contact hello@construct-labs.com to obtain an API key."
110
+ )
111
+
112
+ self._api_key = resolved_api_key
113
+
114
+ # Initialize parent class (but don't connect yet)
115
+ super().__init__(
116
+ base_url=base_url,
117
+ connect_timeout_s=connect_timeout_s,
118
+ message_timeout_s=message_timeout_s,
119
+ )
120
+
121
+ def connect(self) -> CrmAgentEnv:
122
+ """Establish authenticated WebSocket connection to the server.
123
+
124
+ The API key is transmitted via WebSocket subprotocol for secure
125
+ authentication during the handshake.
126
+
127
+ Returns:
128
+ self for method chaining.
129
+
130
+ Raises:
131
+ ConnectionError: If connection cannot be established or
132
+ authentication fails.
133
+ """
134
+ if self._ws is not None:
135
+ return self
136
+
137
+ # Bypass proxy for localhost connections
138
+ ws_url_lower = self._ws_url.lower()
139
+ is_localhost = "localhost" in ws_url_lower or "127.0.0.1" in ws_url_lower
140
+
141
+ old_no_proxy = os.environ.get("NO_PROXY")
142
+ if is_localhost:
143
+ current_no_proxy = old_no_proxy or ""
144
+ if "localhost" not in current_no_proxy.lower():
145
+ os.environ["NO_PROXY"] = (
146
+ f"{current_no_proxy},localhost,127.0.0.1"
147
+ if current_no_proxy
148
+ else "localhost,127.0.0.1"
149
+ )
150
+
151
+ try:
152
+ # Authenticate via WebSocket subprotocol
153
+ # Format: crm-{version}.{api_key}
154
+ auth_subprotocol = f"crm-{_PROTOCOL_VERSION}.{self._api_key}"
155
+
156
+ self._ws = ws_connect(
157
+ self._ws_url,
158
+ open_timeout=self._connect_timeout,
159
+ subprotocols=[auth_subprotocol],
160
+ )
161
+ except Exception as e:
162
+ error_msg = str(e)
163
+ if "401" in error_msg or "403" in error_msg or "4001" in error_msg:
164
+ raise ConnectionError(
165
+ "Authentication failed. Please verify your API key. "
166
+ "Contact hello@construct-labs.com if you need assistance."
167
+ ) from e
168
+ raise ConnectionError(f"Failed to connect to {self._ws_url}: {e}") from e
169
+ finally:
170
+ # Restore original NO_PROXY value
171
+ if is_localhost:
172
+ if old_no_proxy is None:
173
+ os.environ.pop("NO_PROXY", None)
174
+ else:
175
+ os.environ["NO_PROXY"] = old_no_proxy
176
+
177
+ return self
178
+
179
+ def _step_payload(self, action: CrmAgentAction) -> JsonDict:
180
+ """Convert CrmAgentAction to JSON payload for step request."""
181
+ return action.model_dump()
182
+
183
+ def _parse_result(self, payload: JsonDict) -> StepResult[CrmAgentObservation]:
184
+ """Parse server response into StepResult."""
185
+ obs_data = payload.get("observation", {})
186
+ observation = CrmAgentObservation.model_validate(obs_data)
187
+
188
+ return StepResult(
189
+ observation=observation,
190
+ reward=payload.get("reward", observation.reward),
191
+ done=payload.get("done", observation.done),
192
+ )
193
+
194
+ def _parse_state(self, payload: JsonDict) -> CrmAgentState:
195
+ """Parse server response into CrmAgentState."""
196
+ return CrmAgentState.model_validate(payload)
197
+
198
+ # =========================================================================
199
+ # Extensible Properties - Override these in subclasses
200
+ # =========================================================================
201
+
202
+ @property
203
+ def system_prompt(self) -> str:
204
+ """System prompt for the CRM agent.
205
+
206
+ Override this property in a subclass to customize the agent's behavior
207
+ and instructions.
208
+
209
+ Returns:
210
+ The system prompt string to use for the agent.
211
+
212
+ Example:
213
+ >>> class CustomAgent(CrmAgentEnv):
214
+ ... @property
215
+ ... def system_prompt(self) -> str:
216
+ ... return '''You are a data entry assistant.
217
+ ... Focus on accuracy and completeness.'''
218
+ """
219
+ return self._default_system_prompt()
220
+
221
+ def _default_system_prompt(self) -> str:
222
+ """Return the default system prompt.
223
+
224
+ Subclasses can call this to get the default prompt and extend it.
225
+
226
+ Returns:
227
+ The default system prompt string.
228
+ """
229
+ return """You are a tool-using agent interacting with a CRM (Customer Relationship Management) system.
230
+
231
+ GOAL: Complete CRM tasks by creating, updating, and managing business data.
232
+
233
+ AVAILABLE OPERATIONS:
234
+ - Companies: list, get, create, update, delete
235
+ - People/Contacts: list, get, create, update, delete
236
+ - Opportunities: list, get, create, update, delete
237
+ - Notes: list, create (attach to companies, people, or opportunities)
238
+ - Tasks: list, create, update, complete
239
+
240
+ EXAMPLES:
241
+
242
+ 1. List companies:
243
+ <tool_call>
244
+ {"name": "list_companies", "arguments": {"limit": 10}}
245
+ </tool_call>
246
+
247
+ 2. Create a company:
248
+ <tool_call>
249
+ {"name": "create_company", "arguments": {"company_name": "Acme Corp", "company_domain": "acme.com"}}
250
+ </tool_call>
251
+
252
+ 3. Create a contact:
253
+ <tool_call>
254
+ {"name": "create_person", "arguments": {"person_first_name": "John", "person_last_name": "Doe", "person_email": "john@acme.com"}}
255
+ </tool_call>
256
+
257
+ 4. Submit final answer:
258
+ <tool_call>
259
+ {"name": "submit_answer", "arguments": {"answer": "The total pipeline value is $1.5M"}}
260
+ </tool_call>
261
+
262
+ IMPORTANT: Output ONLY a tool_call, no other text."""
263
+
264
+ @property
265
+ def tools(self) -> list[JsonDict]:
266
+ """Tool definitions for the CRM environment.
267
+
268
+ Returns tool definitions formatted by `format_tools()`. Override
269
+ `format_tools()` to transform the tool schema for different providers
270
+ (e.g., Anthropic, Google).
271
+
272
+ Returns:
273
+ List of tool definitions (OpenAI format by default).
274
+
275
+ Example:
276
+ >>> class ReadOnlyAgent(CrmAgentEnv):
277
+ ... @property
278
+ ... def tools(self) -> list[dict]:
279
+ ... # Only allow read operations
280
+ ... read_ops = {'list_', 'get_', 'submit_answer'}
281
+ ... return [t for t in self._default_tools()
282
+ ... if any(op in t['function']['name'] for op in read_ops)]
283
+ """
284
+ return self.format_tools(self._default_tools())
285
+
286
+ def format_tools(self, tools: list[JsonDict]) -> list[JsonDict]:
287
+ """Format tool definitions for the target LLM provider.
288
+
289
+ Override this method to transform tool schemas for different providers.
290
+ The default implementation returns OpenAI-compatible format unchanged.
291
+
292
+ Args:
293
+ tools: List of tool definitions in OpenAI format.
294
+
295
+ Returns:
296
+ Formatted tool definitions for your target provider.
297
+
298
+ Example (Anthropic format):
299
+ >>> class AnthropicCrmAgent(CrmAgentEnv):
300
+ ... def format_tools(self, tools):
301
+ ... # Convert OpenAI format to Anthropic format
302
+ ... return [
303
+ ... {
304
+ ... "name": t["function"]["name"],
305
+ ... "description": t["function"]["description"],
306
+ ... "input_schema": t["function"]["parameters"],
307
+ ... }
308
+ ... for t in tools
309
+ ... ]
310
+ """
311
+ return tools
312
+
313
+ def _default_tools(self) -> list[JsonDict]:
314
+ """Return the default tool definitions.
315
+
316
+ Subclasses can call this to get all default tools and filter/extend them.
317
+
318
+ Returns:
319
+ Complete list of CRM tool definitions in OpenAI format.
320
+ """
321
+ return list(DEFAULT_TOOLS)
322
+
323
+ # =========================================================================
324
+ # Tool Parsing and Observation Formatting
325
+ # =========================================================================
326
+
327
+ def parse_tool_call(self, tool_call: JsonDict) -> ParsedAction:
328
+ """Parse a tool call from the LLM into a CrmAgentAction.
329
+
330
+ This method maps tool names to action types and extracts arguments.
331
+ Override this in a subclass to handle custom tools.
332
+
333
+ Args:
334
+ tool_call: Dictionary with 'name' and 'arguments' from LLM output.
335
+
336
+ Returns:
337
+ ParsedAction with the action and validity status.
338
+ """
339
+ tool_name = str(tool_call.get("name", "")).lower().strip()
340
+ arguments_raw = tool_call.get("arguments")
341
+
342
+ # Parse arguments
343
+ if isinstance(arguments_raw, dict):
344
+ arguments = cast(JsonDict, arguments_raw)
345
+ elif isinstance(arguments_raw, str):
346
+ try:
347
+ arguments = json.loads(arguments_raw)
348
+ except json.JSONDecodeError:
349
+ return ParsedAction(
350
+ action=None,
351
+ is_valid=False,
352
+ error_message=f"Invalid JSON in arguments: {arguments_raw}",
353
+ )
354
+ else:
355
+ return ParsedAction(
356
+ action=None,
357
+ is_valid=False,
358
+ error_message=f"Arguments must be dict or JSON string, got: {type(arguments_raw)}",
359
+ )
360
+
361
+ # Map tool names to action types
362
+ tool_to_action_type: dict[str, CRMActionType] = {
363
+ # Company
364
+ "list_companies": CRMActionType.LIST_COMPANIES,
365
+ "get_company": CRMActionType.GET_COMPANY,
366
+ "create_company": CRMActionType.CREATE_COMPANY,
367
+ "update_company": CRMActionType.UPDATE_COMPANY,
368
+ "delete_company": CRMActionType.DELETE_COMPANY,
369
+ # Person
370
+ "list_people": CRMActionType.LIST_PEOPLE,
371
+ "get_person": CRMActionType.GET_PERSON,
372
+ "create_person": CRMActionType.CREATE_PERSON,
373
+ "update_person": CRMActionType.UPDATE_PERSON,
374
+ "delete_person": CRMActionType.DELETE_PERSON,
375
+ # Opportunity
376
+ "list_opportunities": CRMActionType.LIST_OPPORTUNITIES,
377
+ "get_opportunity": CRMActionType.GET_OPPORTUNITY,
378
+ "create_opportunity": CRMActionType.CREATE_OPPORTUNITY,
379
+ "update_opportunity": CRMActionType.UPDATE_OPPORTUNITY,
380
+ "delete_opportunity": CRMActionType.DELETE_OPPORTUNITY,
381
+ # Note
382
+ "list_notes": CRMActionType.LIST_NOTES,
383
+ "create_note": CRMActionType.CREATE_NOTE,
384
+ # Task
385
+ "list_tasks": CRMActionType.LIST_TASKS,
386
+ "create_task": CRMActionType.CREATE_TASK,
387
+ "update_task": CRMActionType.UPDATE_TASK,
388
+ "complete_task": CRMActionType.COMPLETE_TASK,
389
+ # Answer
390
+ "submit_answer": CRMActionType.SUBMIT_ANSWER,
391
+ }
392
+
393
+ if not tool_name:
394
+ return ParsedAction(
395
+ action=None,
396
+ is_valid=False,
397
+ error_message="Tool name is required",
398
+ )
399
+
400
+ action_type = tool_to_action_type.get(tool_name)
401
+ if action_type is None:
402
+ return ParsedAction(
403
+ action=None,
404
+ is_valid=False,
405
+ error_message=f"Unknown tool: '{tool_name}'. "
406
+ f"Valid tools: {list(tool_to_action_type.keys())}",
407
+ )
408
+
409
+ # Build action with all valid fields
410
+ action_kwargs: JsonDict = {"action_type": action_type}
411
+
412
+ valid_fields = {
413
+ "record_id",
414
+ "company_name",
415
+ "company_domain",
416
+ "company_address",
417
+ "company_employees",
418
+ "person_first_name",
419
+ "person_last_name",
420
+ "person_email",
421
+ "person_phone",
422
+ "person_company_id",
423
+ "person_job_title",
424
+ "opportunity_name",
425
+ "opportunity_amount",
426
+ "opportunity_stage",
427
+ "opportunity_close_date",
428
+ "opportunity_company_id",
429
+ "opportunity_person_id",
430
+ "note_body",
431
+ "note_target_id",
432
+ "note_target_type",
433
+ "task_title",
434
+ "task_body",
435
+ "task_due_date",
436
+ "task_assignee_id",
437
+ "task_status",
438
+ "task_target_id",
439
+ "task_target_type",
440
+ "limit",
441
+ "cursor",
442
+ "starting_after",
443
+ "ending_before",
444
+ "order_by",
445
+ "filter",
446
+ "depth",
447
+ "answer",
448
+ }
449
+
450
+ for field in valid_fields:
451
+ if field in arguments and arguments[field] is not None:
452
+ action_kwargs[field] = arguments[field]
453
+
454
+ # Convert generic note_target_id/type to specific fields
455
+ # Tool schema uses: note_target_id + note_target_type
456
+ # Server expects: note_target_person_id, note_target_company_id, etc.
457
+ note_target_id = action_kwargs.pop("note_target_id", None)
458
+ note_target_type = action_kwargs.pop("note_target_type", None)
459
+ if note_target_id and note_target_type:
460
+ target_type = str(note_target_type).lower()
461
+ if target_type == "person":
462
+ action_kwargs["note_target_person_id"] = note_target_id
463
+ elif target_type == "company":
464
+ action_kwargs["note_target_company_id"] = note_target_id
465
+ elif target_type == "opportunity":
466
+ action_kwargs["note_target_opportunity_id"] = note_target_id
467
+
468
+ # Same conversion for tasks
469
+ task_target_id = action_kwargs.pop("task_target_id", None)
470
+ task_target_type = action_kwargs.pop("task_target_type", None)
471
+ if task_target_id and task_target_type:
472
+ target_type = str(task_target_type).lower()
473
+ if target_type == "person":
474
+ action_kwargs["task_target_person_id"] = task_target_id
475
+ elif target_type == "company":
476
+ action_kwargs["task_target_company_id"] = task_target_id
477
+ elif target_type == "opportunity":
478
+ action_kwargs["task_target_opportunity_id"] = task_target_id
479
+
480
+ try:
481
+ action = CrmAgentAction(**action_kwargs)
482
+ return ParsedAction(action=action, is_valid=True)
483
+ except Exception as e:
484
+ return ParsedAction(
485
+ action=None,
486
+ is_valid=False,
487
+ error_message=f"Failed to create action: {e}",
488
+ )
489
+
490
+ def format_observation(self, observation: CrmAgentObservation) -> str:
491
+ """Format an observation as a string for the LLM.
492
+
493
+ Override this in a subclass to customize how observations are
494
+ presented to the agent.
495
+
496
+ Args:
497
+ observation: The observation to format.
498
+
499
+ Returns:
500
+ String representation for the LLM.
501
+
502
+ Example:
503
+ >>> class VerboseAgent(CrmAgentEnv):
504
+ ... def format_observation(self, obs):
505
+ ... base = super().format_observation(obs)
506
+ ... return f"=== CRM Response ===\\n{base}\\n=== End ==="
507
+ """
508
+ import json
509
+
510
+ lines: list[str] = [json.dumps(observation.data)]
511
+
512
+ if observation.error:
513
+ lines.append(f"Error: {observation.error}")
514
+
515
+ return "\n".join(lines)
@@ -9,10 +9,9 @@ from __future__ import annotations
9
9
  from enum import Enum
10
10
  from typing import Any
11
11
 
12
+ from openenv.core import Action, Observation, State
12
13
  from pydantic import ConfigDict, Field
13
14
 
14
- from ._vendored import Action, Observation, State
15
-
16
15
 
17
16
  class CRMActionType(str, Enum):
18
17
  """Types of actions available in the CRM environment.