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,33 @@
1
+ """Construct Labs CRM Agent Environment SDK.
2
+
3
+ This package provides a Python client for interacting with the Construct Labs
4
+ CRM Agent Environment - a reinforcement learning environment for training
5
+ agents to interact with CRM systems.
6
+
7
+ For more information, see https://docs.construct-labs.com/crm-agent
8
+ """
9
+
10
+ from .client import CrmAgentEnv
11
+ from .models import (
12
+ CRMActionType,
13
+ CrmAgentAction,
14
+ CrmAgentObservation,
15
+ CrmAgentState,
16
+ )
17
+ from .protocol import ParsedAction
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = [
22
+ # Main client
23
+ "CrmAgentEnv",
24
+ # Data models
25
+ "CrmAgentAction",
26
+ "CrmAgentObservation",
27
+ "CrmAgentState",
28
+ "CRMActionType",
29
+ # For custom parse_tool_call implementations
30
+ "ParsedAction",
31
+ # Version
32
+ "__version__",
33
+ ]
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ (c) Meta Platforms, Inc. and affiliates.
4
+
5
+ Redistribution and use in source and binary forms, with or without modification,
6
+ are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice,this list
9
+ of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice, this
12
+ list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its contributors may
16
+ be used to endorse or promote products derived from this software without specific
17
+ prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
20
+ EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
21
+ OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
22
+ SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
23
+ INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
24
+ TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
25
+ BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
26
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
27
+ ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH
28
+ DAMAGE.
@@ -0,0 +1,14 @@
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-3-Clause license.
5
+ # See LICENSE.openenv in this directory for the full license text.
6
+ #
7
+ # This module contains vendored code from the OpenEnv project:
8
+ # https://github.com/meta-pytorch/OpenEnv
9
+ """Vendored OpenEnv core components."""
10
+
11
+ from ._client import EnvClient
12
+ from ._types import Action, Observation, State, StepResult
13
+
14
+ __all__ = ["Action", "EnvClient", "Observation", "State", "StepResult"]
@@ -0,0 +1,216 @@
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-3-Clause license.
5
+ # See LICENSE.openenv in this directory for the full license text.
6
+ """WebSocket-based environment client from OpenEnv."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from abc import ABC, abstractmethod
13
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar
14
+
15
+ from ._types import StateT, StepResult
16
+
17
+ if TYPE_CHECKING:
18
+ from websockets.sync.client import ClientConnection
19
+
20
+ from websockets.sync.client import connect as ws_connect
21
+
22
+ ActT = TypeVar("ActT")
23
+ ObsT = TypeVar("ObsT")
24
+
25
+
26
+ def _convert_to_ws_url(url: str) -> str:
27
+ """Convert an HTTP/HTTPS URL to a WS/WSS URL."""
28
+ ws_url = url.rstrip("/")
29
+ if ws_url.startswith("http://"):
30
+ ws_url = "ws://" + ws_url[7:]
31
+ elif ws_url.startswith("https://"):
32
+ ws_url = "wss://" + ws_url[8:]
33
+ elif not ws_url.startswith("ws://") and not ws_url.startswith("wss://"):
34
+ ws_url = "ws://" + ws_url
35
+ return ws_url
36
+
37
+
38
+ class EnvClient(ABC, Generic[ActT, ObsT, StateT]):
39
+ """WebSocket client for environment interactions.
40
+
41
+ Maintains a persistent connection to an environment server for efficient
42
+ multi-step interactions without HTTP overhead.
43
+
44
+ Example:
45
+ >>> with MyEnvClient(base_url="ws://localhost:8000") as env:
46
+ ... result = env.reset(seed=42)
47
+ ... while not result.done:
48
+ ... action = agent.predict(result.observation)
49
+ ... result = env.step(action)
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ base_url: str,
55
+ connect_timeout_s: float = 10.0,
56
+ message_timeout_s: float = 60.0,
57
+ ):
58
+ """Initialize environment client.
59
+
60
+ Args:
61
+ base_url: Server URL (http://, https://, ws://, or wss://).
62
+ connect_timeout_s: Timeout for establishing connection.
63
+ message_timeout_s: Timeout for receiving responses.
64
+ """
65
+ ws_url = _convert_to_ws_url(base_url)
66
+ self._ws_url = f"{ws_url}/ws"
67
+ self._connect_timeout = connect_timeout_s
68
+ self._message_timeout = message_timeout_s
69
+ self._ws: ClientConnection | None = None
70
+
71
+ def connect(self) -> EnvClient[ActT, ObsT, StateT]:
72
+ """Establish WebSocket connection.
73
+
74
+ Returns:
75
+ self for method chaining.
76
+
77
+ Raises:
78
+ ConnectionError: If connection fails.
79
+ """
80
+ if self._ws is not None:
81
+ return self
82
+
83
+ # Bypass proxy for localhost
84
+ ws_url_lower = self._ws_url.lower()
85
+ is_localhost = "localhost" in ws_url_lower or "127.0.0.1" in ws_url_lower
86
+
87
+ old_no_proxy = os.environ.get("NO_PROXY")
88
+ if is_localhost:
89
+ current_no_proxy = old_no_proxy or ""
90
+ if "localhost" not in current_no_proxy.lower():
91
+ os.environ["NO_PROXY"] = (
92
+ f"{current_no_proxy},localhost,127.0.0.1"
93
+ if current_no_proxy
94
+ else "localhost,127.0.0.1"
95
+ )
96
+
97
+ try:
98
+ self._ws = ws_connect(
99
+ self._ws_url,
100
+ open_timeout=self._connect_timeout,
101
+ )
102
+ except Exception as e:
103
+ raise ConnectionError(f"Failed to connect to {self._ws_url}: {e}") from e
104
+ finally:
105
+ if is_localhost:
106
+ if old_no_proxy is None:
107
+ os.environ.pop("NO_PROXY", None)
108
+ else:
109
+ os.environ["NO_PROXY"] = old_no_proxy
110
+
111
+ return self
112
+
113
+ def disconnect(self) -> None:
114
+ """Close the WebSocket connection."""
115
+ if self._ws is not None:
116
+ try:
117
+ self._send({"type": "close"})
118
+ except Exception:
119
+ pass
120
+ try:
121
+ self._ws.close()
122
+ except Exception:
123
+ pass
124
+ self._ws = None
125
+
126
+ def _ensure_connected(self) -> None:
127
+ """Ensure connection is established."""
128
+ if self._ws is None:
129
+ self.connect()
130
+
131
+ def _send(self, message: dict[str, Any]) -> None:
132
+ """Send a message over WebSocket."""
133
+ self._ensure_connected()
134
+ assert self._ws is not None
135
+ self._ws.send(json.dumps(message))
136
+
137
+ def _receive(self) -> dict[str, Any]:
138
+ """Receive and parse a message."""
139
+ assert self._ws is not None
140
+ raw = self._ws.recv(timeout=self._message_timeout)
141
+ return json.loads(raw)
142
+
143
+ def _send_and_receive(self, message: dict[str, Any]) -> dict[str, Any]:
144
+ """Send a message and wait for response."""
145
+ self._send(message)
146
+ response = self._receive()
147
+
148
+ if response.get("type") == "error":
149
+ error_data = response.get("data", {})
150
+ raise RuntimeError(
151
+ f"Server error: {error_data.get('message', 'Unknown error')} "
152
+ f"(code: {error_data.get('code', 'UNKNOWN')})"
153
+ )
154
+
155
+ return response
156
+
157
+ @abstractmethod
158
+ def _step_payload(self, action: ActT) -> dict[str, Any]:
159
+ """Convert action to JSON payload for the server."""
160
+ raise NotImplementedError
161
+
162
+ @abstractmethod
163
+ def _parse_result(self, payload: dict[str, Any]) -> StepResult[ObsT]:
164
+ """Convert server response to StepResult."""
165
+ raise NotImplementedError
166
+
167
+ @abstractmethod
168
+ def _parse_state(self, payload: dict[str, Any]) -> StateT:
169
+ """Convert server response to State object."""
170
+ raise NotImplementedError
171
+
172
+ def reset(self, **kwargs: Any) -> StepResult[ObsT]:
173
+ """Reset the environment.
174
+
175
+ Args:
176
+ **kwargs: Parameters for reset (e.g., seed, episode_id).
177
+
178
+ Returns:
179
+ StepResult with initial observation.
180
+ """
181
+ message = {"type": "reset", "data": kwargs}
182
+ response = self._send_and_receive(message)
183
+ return self._parse_result(response.get("data", {}))
184
+
185
+ def step(self, action: ActT, **kwargs: Any) -> StepResult[ObsT]:
186
+ """Execute an action.
187
+
188
+ Args:
189
+ action: The action to execute.
190
+ **kwargs: Additional parameters.
191
+
192
+ Returns:
193
+ StepResult with observation, reward, and done status.
194
+ """
195
+ message = {"type": "step", "data": self._step_payload(action)}
196
+ response = self._send_and_receive(message)
197
+ return self._parse_result(response.get("data", {}))
198
+
199
+ def state(self) -> StateT:
200
+ """Get current environment state."""
201
+ message = {"type": "state"}
202
+ response = self._send_and_receive(message)
203
+ return self._parse_state(response.get("data", {}))
204
+
205
+ def close(self) -> None:
206
+ """Close connection and clean up resources."""
207
+ self.disconnect()
208
+
209
+ def __enter__(self) -> EnvClient[ActT, ObsT, StateT]:
210
+ """Enter context manager."""
211
+ self.connect()
212
+ return self
213
+
214
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
215
+ """Exit context manager."""
216
+ self.close()
@@ -0,0 +1,82 @@
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-3-Clause license.
5
+ # See LICENSE.openenv in this directory for the full license text.
6
+ """Type definitions from OpenEnv."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Any, Generic, TypeVar
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+
15
+ ObsT = TypeVar("ObsT")
16
+ StateT = TypeVar("StateT")
17
+
18
+
19
+ @dataclass
20
+ class StepResult(Generic[ObsT]):
21
+ """Result of one environment step.
22
+
23
+ Attributes:
24
+ observation: The environment's observation after the action.
25
+ reward: Scalar reward for this step.
26
+ done: Whether the episode is finished.
27
+ """
28
+
29
+ observation: ObsT
30
+ reward: float | None = None
31
+ done: bool = False
32
+
33
+
34
+ class Action(BaseModel):
35
+ """Base class for environment actions."""
36
+
37
+ model_config = ConfigDict(
38
+ extra="forbid",
39
+ validate_assignment=True,
40
+ arbitrary_types_allowed=True,
41
+ )
42
+
43
+ metadata: dict[str, Any] = Field(
44
+ default_factory=dict, description="Additional metadata for the action"
45
+ )
46
+
47
+
48
+ class Observation(BaseModel):
49
+ """Base class for environment observations."""
50
+
51
+ model_config = ConfigDict(
52
+ extra="forbid",
53
+ validate_assignment=True,
54
+ arbitrary_types_allowed=True,
55
+ )
56
+
57
+ done: bool = Field(default=False, description="Whether the episode has terminated")
58
+ reward: bool | int | float | None = Field(
59
+ default=None, description="Reward signal from the last action"
60
+ )
61
+ metadata: dict[str, Any] = Field(
62
+ default_factory=dict, description="Additional metadata for the observation"
63
+ )
64
+
65
+
66
+ class State(BaseModel):
67
+ """Base class for environment state."""
68
+
69
+ model_config = ConfigDict(
70
+ extra="allow",
71
+ validate_assignment=True,
72
+ arbitrary_types_allowed=True,
73
+ )
74
+
75
+ episode_id: str | None = Field(
76
+ default=None, description="Unique identifier for the current episode"
77
+ )
78
+ step_count: int = Field(
79
+ default=0,
80
+ ge=0,
81
+ description="Number of steps taken in the current episode",
82
+ )