utcp-socket 1.0.2__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,18 @@
1
+ from utcp.plugins.discovery import register_communication_protocol, register_call_template
2
+ from utcp_socket.tcp_communication_protocol import TCPTransport
3
+ from utcp_socket.udp_communication_protocol import UDPTransport
4
+ from utcp_socket.tcp_call_template import TCPProviderSerializer
5
+ from utcp_socket.udp_call_template import UDPProviderSerializer
6
+
7
+
8
+ def register() -> None:
9
+ # Register communication protocols
10
+ register_communication_protocol("tcp", TCPTransport())
11
+ register_communication_protocol("udp", UDPTransport())
12
+
13
+ # Register call templates and their serializers
14
+ register_call_template("tcp", TCPProviderSerializer())
15
+ register_call_template("udp", UDPProviderSerializer())
16
+
17
+
18
+ __all__ = ["register"]
@@ -0,0 +1,99 @@
1
+ from utcp.data.call_template import CallTemplate
2
+ from typing import Optional, Literal
3
+ from pydantic import Field
4
+ from utcp.interfaces.serializer import Serializer
5
+ from utcp.exceptions import UtcpSerializerValidationError
6
+ import traceback
7
+
8
+ class TCPProvider(CallTemplate):
9
+ """Provider configuration for raw TCP socket tools.
10
+
11
+ Enables direct communication with TCP servers using custom protocols.
12
+ Supports flexible request formatting, response decoding, and multiple
13
+ framing strategies for message boundaries.
14
+
15
+ Request Data Handling:
16
+ - 'json' format: Arguments formatted as JSON object
17
+ - 'text' format: Template-based with UTCP_ARG_argname_UTCP_ARG placeholders
18
+
19
+ Response Data Handling:
20
+ - If response_byte_format is None: Returns raw bytes
21
+ - If response_byte_format is encoding string: Decodes bytes to text
22
+
23
+ TCP Stream Framing Options:
24
+ 1. Length-prefix: Set framing_strategy='length_prefix' + length_prefix_bytes
25
+ 2. Delimiter-based: Set framing_strategy='delimiter' + message_delimiter
26
+ 3. Fixed-length: Set framing_strategy='fixed_length' + fixed_message_length
27
+ 4. Stream-based: Set framing_strategy='stream' (reads until connection closes)
28
+
29
+ Attributes:
30
+ call_template_type: Always "tcp" for TCP providers.
31
+ host: The hostname or IP address of the TCP server.
32
+ port: The port number of the TCP server.
33
+ request_data_format: Format for request data ('json' or 'text').
34
+ request_data_template: Template string for 'text' format with placeholders.
35
+ response_byte_format: Encoding for response decoding (None for raw bytes).
36
+ framing_strategy: Method for detecting message boundaries.
37
+ length_prefix_bytes: Number of bytes for length prefix (1, 2, 4, or 8).
38
+ length_prefix_endian: Byte order for length prefix ('big' or 'little').
39
+ message_delimiter: Delimiter string for message boundaries.
40
+ fixed_message_length: Fixed length in bytes for each message.
41
+ max_response_size: Maximum bytes to read for stream-based framing.
42
+ timeout: Connection timeout in milliseconds.
43
+ auth: Always None - TCP providers don't support authentication.
44
+ """
45
+
46
+ call_template_type: Literal["tcp"] = "tcp"
47
+ host: str
48
+ port: int
49
+ request_data_format: Literal["json", "text"] = "json"
50
+ request_data_template: Optional[str] = None
51
+ response_byte_format: Optional[str] = Field(default="utf-8", description="Encoding to decode response bytes. If None, returns raw bytes.")
52
+ # TCP Framing Strategy
53
+ framing_strategy: Literal["length_prefix", "delimiter", "fixed_length", "stream"] = Field(
54
+ default="stream",
55
+ description="Strategy for framing TCP messages"
56
+ )
57
+ # Length-prefix framing options
58
+ length_prefix_bytes: Literal[1, 2, 4, 8] = Field(
59
+ default=4,
60
+ description="Number of bytes for length prefix (1, 2, 4, or 8). Used with 'length_prefix' framing."
61
+ )
62
+ length_prefix_endian: Literal["big", "little"] = Field(
63
+ default="big",
64
+ description="Byte order for length prefix. Used with 'length_prefix' framing."
65
+ )
66
+ # Delimiter-based framing options
67
+ message_delimiter: str = Field(
68
+ default='\x00',
69
+ description="Delimiter to detect end of TCP response (e.g., '\n', '\r\n', '\x00'). Used with 'delimiter' framing."
70
+ )
71
+ interpret_escape_sequences: bool = Field(
72
+ default=True,
73
+ description="If True, interpret Python-style escape sequences in message_delimiter (e.g., '\\n', '\\r\\n', '\\x00'). If False, use the delimiter literally as provided."
74
+ )
75
+ # Fixed-length framing options
76
+ fixed_message_length: Optional[int] = Field(
77
+ default=None,
78
+ description="Fixed length of each message in bytes. Used with 'fixed_length' framing."
79
+ )
80
+ # Stream-based options
81
+ max_response_size: int = Field(
82
+ default=65536,
83
+ description="Maximum bytes to read from TCP stream. Used with 'stream' framing."
84
+ )
85
+ timeout: int = 30000
86
+ auth: None = None
87
+
88
+
89
+ class TCPProviderSerializer(Serializer[TCPProvider]):
90
+ def to_dict(self, obj: TCPProvider) -> dict:
91
+ return obj.model_dump()
92
+
93
+ def validate_dict(self, data: dict) -> TCPProvider:
94
+ try:
95
+ return TCPProvider.model_validate(data)
96
+ except Exception as e:
97
+ raise UtcpSerializerValidationError(
98
+ f"Invalid TCPProvider: {e}\n{traceback.format_exc()}"
99
+ )
@@ -0,0 +1,434 @@
1
+ """
2
+ Transmission Control Protocol (TCP) transport for UTCP client.
3
+
4
+ This transport communicates with tools over TCP sockets.
5
+ """
6
+ import asyncio
7
+ import json
8
+ import socket
9
+ import struct
10
+ import sys
11
+ from typing import Dict, Any, List, Optional, Callable, Union
12
+
13
+ from utcp.interfaces.communication_protocol import CommunicationProtocol
14
+ from utcp_socket.tcp_call_template import TCPProvider, TCPProviderSerializer
15
+ from utcp.data.tool import Tool
16
+ from utcp.data.call_template import CallTemplate, CallTemplateSerializer
17
+ from utcp.data.register_manual_response import RegisterManualResult
18
+ from utcp.data.utcp_manual import UtcpManual
19
+ import logging
20
+
21
+ logging.basicConfig(
22
+ level=logging.INFO,
23
+ format="%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s"
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ class TCPTransport(CommunicationProtocol):
29
+ """Transport implementation for TCP-based tool providers.
30
+
31
+ This transport communicates with tools over TCP sockets. It supports:
32
+ - Tool discovery via TCP messages
33
+ - Tool execution by sending TCP packets with arguments
34
+ - Multiple framing strategies: length-prefix, delimiter, fixed-length, and stream
35
+ - JSON and text-based request formatting
36
+ - Template-based argument substitution
37
+ - Configurable response byte format (text encoding or raw bytes)
38
+ - Connection management for each request
39
+ """
40
+
41
+ def __init__(self, logger: Optional[Callable[[str], None]] = None):
42
+ """Initialize the TCP transport.
43
+
44
+ Args:
45
+ logger: Optional logger function for debugging
46
+ """
47
+ self._log = logger or (lambda *args, **kwargs: None)
48
+
49
+ def _log_info(self, message: str):
50
+ """Log informational messages."""
51
+ self._log(f"[TCPTransport] {message}")
52
+
53
+ def _log_error(self, message: str):
54
+ """Log error messages."""
55
+ logger.error(f"[TCPTransport Error] {message}")
56
+
57
+ def _format_tool_call_message(
58
+ self,
59
+ tool_args: Dict[str, Any],
60
+ provider: TCPProvider
61
+ ) -> str:
62
+ """Format a tool call message based on provider configuration.
63
+
64
+ Args:
65
+ tool_args: Arguments for the tool call
66
+ provider: The TCPProvider with formatting configuration
67
+
68
+ Returns:
69
+ Formatted message string
70
+ """
71
+ if provider.request_data_format == "json":
72
+ return json.dumps(tool_args)
73
+ elif provider.request_data_format == "text":
74
+ # Use template-based formatting
75
+ if provider.request_data_template is not None and provider.request_data_template != "":
76
+ message = provider.request_data_template
77
+ # Replace placeholders with argument values
78
+ for arg_name, arg_value in tool_args.items():
79
+ placeholder = f"UTCP_ARG_{arg_name}_UTCP_ARG"
80
+ if isinstance(arg_value, str):
81
+ message = message.replace(placeholder, arg_value)
82
+ else:
83
+ message = message.replace(placeholder, json.dumps(arg_value))
84
+ return message
85
+ else:
86
+ # Fallback to simple key=value format
87
+ return " ".join([str(v) for k, v in tool_args.items()])
88
+ else:
89
+ # Default to JSON format
90
+ return json.dumps(tool_args)
91
+
92
+ def _ensure_tool_call_template(self, tool_data: Dict[str, Any], manual_call_template: TCPProvider) -> Dict[str, Any]:
93
+ """Normalize tool definition to include a valid 'tool_call_template'.
94
+
95
+ - If 'tool_call_template' exists, validate it.
96
+ - Else if legacy 'tool_provider' exists, convert using TCPProviderSerializer.
97
+ - Else default to the provided manual_call_template.
98
+ """
99
+ normalized = dict(tool_data)
100
+ try:
101
+ if "tool_call_template" in normalized and normalized["tool_call_template"] is not None:
102
+ try:
103
+ ctpl = CallTemplateSerializer().validate_dict(normalized["tool_call_template"]) # type: ignore
104
+ normalized["tool_call_template"] = ctpl
105
+ except Exception:
106
+ normalized["tool_call_template"] = manual_call_template
107
+ elif "tool_provider" in normalized and normalized["tool_provider"] is not None:
108
+ try:
109
+ ctpl = TCPProviderSerializer().validate_dict(normalized["tool_provider"]) # type: ignore
110
+ normalized.pop("tool_provider", None)
111
+ normalized["tool_call_template"] = ctpl
112
+ except Exception:
113
+ normalized.pop("tool_provider", None)
114
+ normalized["tool_call_template"] = manual_call_template
115
+ else:
116
+ normalized["tool_call_template"] = manual_call_template
117
+ except Exception:
118
+ normalized["tool_call_template"] = manual_call_template
119
+ return normalized
120
+
121
+ def _encode_message_with_framing(self, message: str, provider: TCPProvider) -> bytes:
122
+ """Encode message with appropriate TCP framing.
123
+
124
+ Args:
125
+ message: Message to encode
126
+ provider: TCPProvider with framing configuration
127
+
128
+ Returns:
129
+ Framed message bytes
130
+ """
131
+ message_bytes = message.encode('utf-8')
132
+
133
+ if provider.framing_strategy == "length_prefix":
134
+ # Add length prefix before the message
135
+ length = len(message_bytes)
136
+ if provider.length_prefix_bytes == 1:
137
+ length_bytes = struct.pack(f"{'>' if provider.length_prefix_endian == 'big' else '<'}B", length)
138
+ elif provider.length_prefix_bytes == 2:
139
+ length_bytes = struct.pack(f"{'>' if provider.length_prefix_endian == 'big' else '<'}H", length)
140
+ elif provider.length_prefix_bytes == 4:
141
+ length_bytes = struct.pack(f"{'>' if provider.length_prefix_endian == 'big' else '<'}I", length)
142
+ elif provider.length_prefix_bytes == 8:
143
+ length_bytes = struct.pack(f"{'>' if provider.length_prefix_endian == 'big' else '<'}Q", length)
144
+ else:
145
+ raise ValueError(f"Invalid length_prefix_bytes: {provider.length_prefix_bytes}")
146
+ return length_bytes + message_bytes
147
+
148
+ elif provider.framing_strategy == "delimiter":
149
+ # Add delimiter after the message
150
+ delimiter = provider.message_delimiter or "\x00"
151
+ if provider.interpret_escape_sequences:
152
+ # Handle escape sequences (e.g., "\n", "\r\n", "\x00")
153
+ delimiter = delimiter.encode('utf-8').decode('unicode_escape')
154
+ delimiter_bytes = delimiter.encode('utf-8')
155
+ else:
156
+ # Use delimiter literally as provided
157
+ delimiter_bytes = delimiter.encode('utf-8')
158
+ return message_bytes + delimiter_bytes
159
+
160
+ elif provider.framing_strategy in ("fixed_length", "stream"):
161
+ # No additional framing needed
162
+ return message_bytes
163
+
164
+ else:
165
+ raise ValueError(f"Unknown framing strategy: {provider.framing_strategy}")
166
+
167
+ def _decode_response_with_framing(self, sock: socket.socket, provider: TCPProvider, timeout: float) -> bytes:
168
+ """Decode response based on TCP framing strategy.
169
+
170
+ Args:
171
+ sock: Connected TCP socket
172
+ provider: TCPProvider with framing configuration
173
+ timeout: Read timeout in seconds
174
+
175
+ Returns:
176
+ Response message bytes
177
+ """
178
+ sock.settimeout(timeout)
179
+
180
+ if provider.framing_strategy == "length_prefix":
181
+ # Read length prefix first
182
+ length_bytes = sock.recv(provider.length_prefix_bytes)
183
+ if len(length_bytes) < provider.length_prefix_bytes:
184
+ raise Exception(f"Incomplete length prefix: got {len(length_bytes)} bytes, expected {provider.length_prefix_bytes}")
185
+
186
+ # Unpack length
187
+ if provider.length_prefix_bytes == 1:
188
+ length = struct.unpack(f"{'>' if provider.length_prefix_endian == 'big' else '<'}B", length_bytes)[0]
189
+ elif provider.length_prefix_bytes == 2:
190
+ length = struct.unpack(f"{'>' if provider.length_prefix_endian == 'big' else '<'}H", length_bytes)[0]
191
+ elif provider.length_prefix_bytes == 4:
192
+ length = struct.unpack(f"{'>' if provider.length_prefix_endian == 'big' else '<'}I", length_bytes)[0]
193
+ elif provider.length_prefix_bytes == 8:
194
+ length = struct.unpack(f"{'>' if provider.length_prefix_endian == 'big' else '<'}Q", length_bytes)[0]
195
+ else:
196
+ raise ValueError(f"Invalid length_prefix_bytes: {provider.length_prefix_bytes}")
197
+
198
+ # Read the message data
199
+ response_data = b""
200
+ while len(response_data) < length:
201
+ chunk = sock.recv(length - len(response_data))
202
+ if not chunk:
203
+ raise Exception("Connection closed while reading message")
204
+ response_data += chunk
205
+
206
+ return response_data
207
+
208
+ elif provider.framing_strategy == "delimiter":
209
+ # Read until delimiter is found
210
+ # Delimiter handling:
211
+ # The code supports both literal delimiters (e.g., "\\x00") and escape-sequence interpreted delimiters (e.g., "\x00")
212
+ # via the `interpret_escape_sequences` flag in TCPProvider. This ensures compatibility with both legacy and updated
213
+ # wire protocols. The delimiter is interpreted according to the flag, so no breaking change occurs unless the flag
214
+ # is set differently than expected by the server/client.
215
+ # Example:
216
+ # If interpret_escape_sequences is True, "\\x00" becomes a null byte; if False, it remains four literal bytes.
217
+ # delimiter = delimiter.encode('utf-8')
218
+ delimiter = provider.message_delimiter or "\x00"
219
+ if provider.interpret_escape_sequences:
220
+ delimiter_bytes = delimiter.encode('utf-8').decode('unicode_escape').encode('utf-8')
221
+ else:
222
+ delimiter_bytes = delimiter.encode('utf-8')
223
+
224
+ response_data = b""
225
+ while True:
226
+ chunk = sock.recv(1)
227
+ if not chunk:
228
+ raise Exception("Connection closed while reading message")
229
+ response_data += chunk
230
+
231
+ # Check if we've received the delimiter
232
+ if response_data.endswith(delimiter_bytes):
233
+ # Remove delimiter from response
234
+ return response_data[:-len(delimiter_bytes)]
235
+
236
+ elif provider.framing_strategy == "fixed_length":
237
+ # Read exactly fixed_message_length bytes
238
+ if provider.fixed_message_length is None:
239
+ raise ValueError("fixed_message_length must be set for fixed_length framing")
240
+
241
+ response_data = b""
242
+ while len(response_data) < provider.fixed_message_length:
243
+ chunk = sock.recv(provider.fixed_message_length - len(response_data))
244
+ if not chunk:
245
+ raise Exception("Connection closed while reading message")
246
+ response_data += chunk
247
+
248
+ return response_data
249
+
250
+ elif provider.framing_strategy == "stream":
251
+ # Read until connection closes or max_response_size is reached
252
+ response_data = b""
253
+ while len(response_data) < provider.max_response_size:
254
+ try:
255
+ chunk = sock.recv(min(4096, provider.max_response_size - len(response_data)))
256
+ if not chunk:
257
+ # Connection closed
258
+ break
259
+ response_data += chunk
260
+ except socket.timeout:
261
+ # Timeout reached
262
+ break
263
+
264
+ return response_data
265
+
266
+ else:
267
+ # Copilot AI (5 days ago):
268
+ # The else branch for unknown framing strategies was previously removed,
269
+ # which could cause silent fallthrough and confusing behavior. Add explicit
270
+ # validation to raise a descriptive error when an unsupported strategy is provided.
271
+ raise ValueError(f"Unknown framing strategy: {provider.framing_strategy!r}")
272
+
273
+ async def _send_tcp_message(
274
+ self,
275
+ host: str,
276
+ port: int,
277
+ message: str,
278
+ provider: TCPProvider,
279
+ timeout: float = 30.0,
280
+ response_encoding: Optional[str] = "utf-8"
281
+ ) -> Union[str, bytes]:
282
+ """Send a TCP message and wait for response.
283
+
284
+ Args:
285
+ host: Host to connect to
286
+ port: Port to connect to
287
+ message: Message to send
288
+ provider: TCPProvider with framing configuration
289
+ timeout: Timeout in seconds
290
+ response_encoding: Encoding to decode response bytes. If None, returns raw bytes.
291
+
292
+ Returns:
293
+ Response message or raw bytes if encoding is None
294
+ """
295
+ loop = asyncio.get_event_loop()
296
+
297
+ def _send_and_receive():
298
+ """Blocking function to send TCP message and receive response."""
299
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
300
+ try:
301
+ # Set connection timeout
302
+ sock.settimeout(timeout)
303
+
304
+ # Connect to server
305
+ sock.connect((host, port))
306
+
307
+ # Encode message with framing
308
+ framed_message = self._encode_message_with_framing(message, provider)
309
+
310
+ # Send message
311
+ sock.sendall(framed_message)
312
+
313
+ # Receive response based on framing strategy
314
+ response_bytes = self._decode_response_with_framing(sock, provider, timeout)
315
+
316
+ return response_bytes
317
+
318
+ except socket.timeout:
319
+ raise Exception(f"TCP connection timeout after {timeout} seconds")
320
+ except Exception as e:
321
+ raise Exception(f"TCP communication error: {e}")
322
+ finally:
323
+ sock.close()
324
+
325
+ try:
326
+ # Run blocking socket operations in executor
327
+ response_bytes = await loop.run_in_executor(None, _send_and_receive)
328
+
329
+ # Return based on encoding preference
330
+ if response_encoding is None:
331
+ return response_bytes
332
+ else:
333
+ try:
334
+ return response_bytes.decode(response_encoding)
335
+ except UnicodeDecodeError as e:
336
+ self._log_error(f"Failed to decode response with encoding '{response_encoding}': {e}")
337
+ # Return raw bytes as fallback
338
+ return response_bytes
339
+
340
+ except Exception as e:
341
+ self._log_error(f"Error in TCP communication: {e}")
342
+ raise
343
+
344
+ async def register_manual(self, caller, manual_call_template: CallTemplate) -> RegisterManualResult:
345
+ """Register a TCP manual and discover its tools."""
346
+ if not isinstance(manual_call_template, TCPProvider):
347
+ raise ValueError("TCPTransport can only be used with TCPProvider")
348
+
349
+ self._log_info(f"Registering TCP provider '{manual_call_template.name}'")
350
+
351
+ try:
352
+ discovery_message = json.dumps({"type": "utcp"})
353
+ response = await self._send_tcp_message(
354
+ manual_call_template.host,
355
+ manual_call_template.port,
356
+ discovery_message,
357
+ manual_call_template,
358
+ manual_call_template.timeout / 1000.0,
359
+ manual_call_template.response_byte_format
360
+ )
361
+ try:
362
+ response_str = response.decode('utf-8') if isinstance(response, bytes) else response
363
+ response_data = json.loads(response_str)
364
+ tools: List[Tool] = []
365
+ if isinstance(response_data, dict) and 'tools' in response_data:
366
+ tools_data = response_data['tools']
367
+ for tool_data in tools_data:
368
+ try:
369
+ normalized = self._ensure_tool_call_template(tool_data, manual_call_template)
370
+ tools.append(Tool(**normalized))
371
+ except Exception as e:
372
+ self._log_error(f"Invalid tool definition in TCP provider '{manual_call_template.name}': {e}")
373
+ continue
374
+ self._log_info(f"Discovered {len(tools)} tools from TCP provider '{manual_call_template.name}'")
375
+ else:
376
+ self._log_info(f"No tools found in TCP provider '{manual_call_template.name}' response")
377
+ manual = UtcpManual(utcp_version="1.0", manual_version="1.0", tools=tools)
378
+ return RegisterManualResult(
379
+ manual_call_template=manual_call_template,
380
+ manual=manual,
381
+ success=True,
382
+ errors=[]
383
+ )
384
+ except json.JSONDecodeError as e:
385
+ self._log_error(f"Invalid JSON response from TCP provider '{manual_call_template.name}': {e}")
386
+ return RegisterManualResult(
387
+ manual_call_template=manual_call_template,
388
+ manual=UtcpManual(utcp_version="1.0", manual_version="1.0", tools=[]),
389
+ success=False,
390
+ errors=[str(e)]
391
+ )
392
+ except Exception as e:
393
+ self._log_error(f"Error registering TCP provider '{manual_call_template.name}': {e}")
394
+ return RegisterManualResult(
395
+ manual_call_template=manual_call_template,
396
+ manual=UtcpManual(utcp_version="1.0", manual_version="1.0", tools=[]),
397
+ success=False,
398
+ errors=[str(e)]
399
+ )
400
+
401
+ async def deregister_manual(self, caller, manual_call_template: CallTemplate) -> None:
402
+ """Deregister a TCP provider (no-op)."""
403
+ if not isinstance(manual_call_template, TCPProvider):
404
+ raise ValueError("TCPTransport can only be used with TCPProvider")
405
+ self._log_info(f"Deregistering TCP provider '{manual_call_template.name}' (no-op)")
406
+
407
+ async def call_tool_streaming(self, caller, tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate):
408
+ async def _generator():
409
+ yield await self.call_tool(caller, tool_name, tool_args, tool_call_template)
410
+ return _generator()
411
+
412
+ async def call_tool(self, caller, tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> Any:
413
+ """Call a TCP tool."""
414
+ if not isinstance(tool_call_template, TCPProvider):
415
+ raise ValueError("TCPTransport can only be used with TCPProvider")
416
+
417
+ self._log_info(f"Calling TCP tool '{tool_name}' on provider '{tool_call_template.name}'")
418
+
419
+ try:
420
+ tool_call_message = self._format_tool_call_message(tool_args, tool_call_template)
421
+
422
+ response = await self._send_tcp_message(
423
+ tool_call_template.host,
424
+ tool_call_template.port,
425
+ tool_call_message,
426
+ tool_call_template,
427
+ tool_call_template.timeout / 1000.0,
428
+ tool_call_template.response_byte_format
429
+ )
430
+ return response
431
+
432
+ except Exception as e:
433
+ self._log_error(f"Error calling TCP tool '{tool_name}': {e}")
434
+ raise
@@ -0,0 +1,56 @@
1
+ from utcp.data.call_template import CallTemplate
2
+ from typing import Optional, Literal
3
+ from pydantic import Field
4
+ from utcp.interfaces.serializer import Serializer
5
+ from utcp.exceptions import UtcpSerializerValidationError
6
+ import traceback
7
+
8
+ class UDPProvider(CallTemplate):
9
+ """Provider configuration for UDP (User Datagram Protocol) socket tools.
10
+
11
+ Enables communication with UDP servers using the connectionless UDP protocol.
12
+ Supports flexible request formatting, response decoding, and multi-datagram
13
+ response handling.
14
+
15
+ Request Data Handling:
16
+ - 'json' format: Arguments formatted as JSON object
17
+ - 'text' format: Template-based with UTCP_ARG_argname_UTCP_ARG placeholders
18
+
19
+ Response Data Handling:
20
+ - If response_byte_format is None: Returns raw bytes
21
+ - If response_byte_format is encoding string: Decodes bytes to text
22
+
23
+ Attributes:
24
+ call_template_type: Always "udp" for UDP providers.
25
+ host: The hostname or IP address of the UDP server.
26
+ port: The port number of the UDP server.
27
+ number_of_response_datagrams: Expected number of response datagrams (0 for no response).
28
+ request_data_format: Format for request data ('json' or 'text').
29
+ request_data_template: Template string for 'text' format with placeholders.
30
+ response_byte_format: Encoding for response decoding (None for raw bytes).
31
+ timeout: Request timeout in milliseconds.
32
+ auth: Always None - UDP providers don't support authentication.
33
+ """
34
+
35
+ call_template_type: Literal["udp"] = "udp"
36
+ host: str
37
+ port: int
38
+ number_of_response_datagrams: int = 1
39
+ request_data_format: Literal["json", "text"] = "json"
40
+ request_data_template: Optional[str] = None
41
+ response_byte_format: Optional[str] = Field(default="utf-8", description="Encoding to decode response bytes. If None, returns raw bytes.")
42
+ timeout: int = 30000
43
+ auth: None = None
44
+
45
+
46
+ class UDPProviderSerializer(Serializer[UDPProvider]):
47
+ def to_dict(self, obj: UDPProvider) -> dict:
48
+ return obj.model_dump()
49
+
50
+ def validate_dict(self, data: dict) -> UDPProvider:
51
+ try:
52
+ return UDPProvider.model_validate(data)
53
+ except Exception as e:
54
+ raise UtcpSerializerValidationError(
55
+ f"Invalid UDPProvider: {e}\n{traceback.format_exc()}"
56
+ )
@@ -0,0 +1,337 @@
1
+ """
2
+ User Datagram Protocol (UDP) transport for UTCP client.
3
+
4
+ This transport communicates with tools over UDP sockets.
5
+ """
6
+ import asyncio
7
+ import json
8
+ import socket
9
+ import traceback
10
+ from typing import Dict, Any, List, Optional, Callable, Union
11
+
12
+ from utcp.interfaces.communication_protocol import CommunicationProtocol
13
+ from utcp_socket.udp_call_template import UDPProvider, UDPProviderSerializer
14
+ from utcp.data.tool import Tool
15
+ from utcp.data.call_template import CallTemplate, CallTemplateSerializer
16
+ from utcp.data.register_manual_response import RegisterManualResult
17
+ from utcp.data.utcp_manual import UtcpManual
18
+ from utcp.exceptions import UtcpSerializerValidationError
19
+ import logging
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ class UDPTransport(CommunicationProtocol):
24
+ """Transport implementation for UDP-based tool providers.
25
+
26
+ This transport communicates with tools over UDP sockets. It supports:
27
+ - Tool discovery via UDP messages
28
+ - Tool execution by sending UDP packets with arguments
29
+ - Multiple response datagrams handling
30
+ - JSON and text-based request formatting
31
+ - Template-based argument substitution
32
+ - Configurable response byte format (text encoding or raw bytes)
33
+ - Stateless operation (no persistent connections)
34
+ """
35
+
36
+ def __init__(self, logger: Optional[Callable[[str], None]] = None):
37
+ """Initialize the UDP transport.
38
+
39
+ Args:
40
+ logger: Optional logger function for debugging
41
+ """
42
+ self._log = logger or (lambda *args, **kwargs: None)
43
+ # UDP is stateless, so no connections to manage
44
+
45
+ def _log_info(self, message: str):
46
+ """Log informational messages."""
47
+ self._log(f"[UDPTransport] {message}")
48
+
49
+ def _log_error(self, message: str):
50
+ """Log error messages."""
51
+ logger.error(f"[UDPTransport Error] {message}")
52
+
53
+ def _format_tool_call_message(
54
+ self,
55
+ tool_args: Dict[str, Any],
56
+ provider: UDPProvider
57
+ ) -> str:
58
+ """Format a tool call message based on provider configuration.
59
+
60
+ Args:
61
+ tool_args: Arguments for the tool call
62
+ provider: The UDPProvider with formatting configuration
63
+
64
+ Returns:
65
+ Formatted message string
66
+ """
67
+ if provider.request_data_format == "json":
68
+ return json.dumps(tool_args)
69
+ elif provider.request_data_format == "text":
70
+ # Use template-based formatting
71
+ if provider.request_data_template is not None and provider.request_data_template != "":
72
+ message = provider.request_data_template
73
+ # Replace placeholders with argument values
74
+ for arg_name, arg_value in tool_args.items():
75
+ placeholder = f"UTCP_ARG_{arg_name}_UTCP_ARG"
76
+ if isinstance(arg_value, str):
77
+ message = message.replace(placeholder, arg_value)
78
+ else:
79
+ message = message.replace(placeholder, json.dumps(arg_value))
80
+ return message
81
+ else:
82
+ # Fallback to simple key=value format
83
+ return " ".join([str(v) for k, v in tool_args.items()])
84
+ else:
85
+ # Default to JSON format
86
+ return json.dumps(tool_args)
87
+
88
+ def _ensure_tool_call_template(self, tool_data: Dict[str, Any], manual_call_template: UDPProvider) -> Dict[str, Any]:
89
+ """Normalize tool definition to include a valid 'tool_call_template'.
90
+
91
+ - If 'tool_call_template' exists, validate it.
92
+ - Else if legacy 'tool_provider' exists, convert using UDPProviderSerializer.
93
+ - Else default to the provided manual_call_template.
94
+ """
95
+ normalized = dict(tool_data)
96
+ try:
97
+ if "tool_call_template" in normalized and normalized["tool_call_template"] is not None:
98
+ # Validate via generic CallTemplate serializer (type-dispatched)
99
+ try:
100
+ ctpl = CallTemplateSerializer().validate_dict(normalized["tool_call_template"]) # type: ignore
101
+ normalized["tool_call_template"] = ctpl
102
+ except (UtcpSerializerValidationError, ValueError) as e:
103
+ # Fallback to manual template if validation fails, but log details
104
+ logger.exception("Failed to validate existing tool_call_template; falling back to manual template")
105
+ normalized["tool_call_template"] = manual_call_template
106
+ elif "tool_provider" in normalized and normalized["tool_provider"] is not None:
107
+ # Convert legacy provider -> call template
108
+ try:
109
+ ctpl = UDPProviderSerializer().validate_dict(normalized["tool_provider"]) # type: ignore
110
+ normalized.pop("tool_provider", None)
111
+ normalized["tool_call_template"] = ctpl
112
+ except UtcpSerializerValidationError as e:
113
+ logger.exception("Failed to convert legacy tool_provider to call template; falling back to manual template")
114
+ normalized.pop("tool_provider", None)
115
+ normalized["tool_call_template"] = manual_call_template
116
+ else:
117
+ normalized["tool_call_template"] = manual_call_template
118
+ except Exception:
119
+ # Any unexpected error during normalization should be logged
120
+ logger.exception("Unexpected error normalizing tool definition; falling back to manual template")
121
+ normalized["tool_call_template"] = manual_call_template
122
+ return normalized
123
+
124
+ async def _send_udp_message(
125
+ self,
126
+ host: str,
127
+ port: int,
128
+ message: str,
129
+ timeout: float = 30.0,
130
+ num_response_datagrams: int = 1,
131
+ response_encoding: Optional[str] = "utf-8"
132
+ ) -> Union[str, bytes]:
133
+ """Send a UDP message and wait for response(s).
134
+
135
+ Args:
136
+ host: Host to send message to
137
+ port: Port to send message to
138
+ message: Message to send
139
+ timeout: Timeout in seconds
140
+ num_response_datagrams: Number of response datagrams to receive
141
+ response_encoding: Encoding to decode response bytes. If None, returns raw bytes.
142
+
143
+ Returns:
144
+ Response message (concatenated if multiple datagrams) or raw bytes if encoding is None
145
+ """
146
+ if num_response_datagrams == 0:
147
+ # No response expected - just send and return
148
+ await self._send_udp_no_response(host, port, message)
149
+ return b"" if response_encoding is None else ""
150
+
151
+ # Use simple socket approach with executor for Windows compatibility
152
+ loop = asyncio.get_event_loop()
153
+
154
+ def _send_and_receive():
155
+ """Blocking function to send UDP message and receive responses."""
156
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
157
+ try:
158
+ # Resolve host to IP for comparison
159
+ try:
160
+ resolved_host_ip = socket.gethostbyname(host)
161
+ except socket.gaierror:
162
+ resolved_host_ip = host # Fallback to original if resolution fails
163
+
164
+ # Send message
165
+ message_bytes = message.encode('utf-8')
166
+ sock.sendto(message_bytes, (host, port))
167
+
168
+ # Collect responses
169
+ response_bytes_list = []
170
+
171
+ for i in range(max(1, num_response_datagrams)):
172
+ try:
173
+ # Use shorter timeout for subsequent datagrams
174
+ current_timeout = timeout if i == 0 else 1.0
175
+
176
+ # Set socket timeout
177
+ sock.settimeout(current_timeout)
178
+
179
+ # Receive response
180
+ data, addr = sock.recvfrom(65535)
181
+
182
+ # Verify it's from the expected host (compare with resolved IP)
183
+ if addr[0] == host or addr[0] == resolved_host_ip:
184
+ response_bytes_list.append(data)
185
+ else:
186
+ # Got response from wrong host, don't count it
187
+ continue
188
+
189
+ except socket.timeout:
190
+ if i == 0:
191
+ # First datagram timed out
192
+ raise TimeoutError(f"UDP request timed out after {timeout} seconds")
193
+ else:
194
+ # Subsequent datagrams timed out, but we have some data
195
+ break
196
+
197
+ return response_bytes_list
198
+
199
+ finally:
200
+ sock.close()
201
+
202
+ try:
203
+ # Run blocking socket operations in executor
204
+ response_bytes_list = await loop.run_in_executor(None, _send_and_receive)
205
+
206
+ # Concatenate response bytes
207
+ combined_bytes = b''.join(response_bytes_list)
208
+
209
+ # Return based on encoding preference
210
+ if response_encoding is None:
211
+ return combined_bytes
212
+ else:
213
+ try:
214
+ return combined_bytes.decode(response_encoding)
215
+ except UnicodeDecodeError as e:
216
+ self._log_error(f"Failed to decode response with encoding '{response_encoding}': {e}")
217
+ # Return raw bytes as fallback
218
+ return combined_bytes
219
+
220
+ except TimeoutError as e:
221
+ self._log_error(traceback.format_exc())
222
+ raise asyncio.TimeoutError(traceback.format_exc())
223
+ except Exception as e:
224
+ self._log_error(f"Error sending UDP message: {traceback.format_exc()}")
225
+ raise
226
+
227
+ async def _send_udp_no_response(self, host: str, port: int, message: str) -> None:
228
+ """Send a UDP message without expecting a response."""
229
+ def _send_only():
230
+ """Blocking function to send UDP message only."""
231
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
232
+ try:
233
+ message_bytes = message.encode('utf-8')
234
+ sock.sendto(message_bytes, (host, port))
235
+ finally:
236
+ sock.close()
237
+
238
+ try:
239
+ loop = asyncio.get_event_loop()
240
+ await loop.run_in_executor(None, _send_only)
241
+ except Exception as e:
242
+ self._log_error(f"Error sending UDP message (no response): {traceback.format_exc()}")
243
+ raise
244
+
245
+ async def register_manual(self, caller, manual_call_template: CallTemplate) -> RegisterManualResult:
246
+ """Register a UDP manual and discover its tools."""
247
+ if not isinstance(manual_call_template, UDPProvider):
248
+ raise ValueError("UDPTransport can only be used with UDPProvider")
249
+
250
+ self._log_info(f"Registering UDP provider '{manual_call_template.name}' at {manual_call_template.host}:{manual_call_template.port}")
251
+
252
+ try:
253
+ discovery_message = json.dumps({"type": "utcp"})
254
+ response = await self._send_udp_message(
255
+ manual_call_template.host,
256
+ manual_call_template.port,
257
+ discovery_message,
258
+ manual_call_template.timeout / 1000.0,
259
+ manual_call_template.number_of_response_datagrams,
260
+ manual_call_template.response_byte_format
261
+ )
262
+ try:
263
+ response_str = response.decode('utf-8') if isinstance(response, bytes) else response
264
+ response_data = json.loads(response_str)
265
+ tools: List[Tool] = []
266
+ if isinstance(response_data, dict) and 'tools' in response_data:
267
+ tools_data = response_data['tools']
268
+ for tool_data in tools_data:
269
+ try:
270
+ normalized = self._ensure_tool_call_template(tool_data, manual_call_template)
271
+ tool = Tool(**normalized)
272
+ tools.append(tool)
273
+ except Exception:
274
+ self._log_error(f"Invalid tool definition in UDP provider '{manual_call_template.name}': {traceback.format_exc()}")
275
+ continue
276
+ self._log_info(f"Discovered {len(tools)} tools from UDP provider '{manual_call_template.name}'")
277
+ else:
278
+ self._log_info(f"No tools found in UDP provider '{manual_call_template.name}' response")
279
+ manual = UtcpManual(utcp_version="1.0", manual_version="1.0", tools=tools)
280
+ return RegisterManualResult(
281
+ manual_call_template=manual_call_template,
282
+ manual=manual,
283
+ success=True,
284
+ errors=[]
285
+ )
286
+ except json.JSONDecodeError as e:
287
+ self._log_error(f"Invalid JSON response from UDP provider '{manual_call_template.name}': {traceback.format_exc()}")
288
+ manual = UtcpManual(utcp_version="1.0", manual_version="1.0", tools=[])
289
+ return RegisterManualResult(
290
+ manual_call_template=manual_call_template,
291
+ manual=manual,
292
+ success=False,
293
+ errors=[str(e)]
294
+ )
295
+ except Exception as e:
296
+ self._log_error(f"Error registering UDP provider '{manual_call_template.name}': {traceback.format_exc()}")
297
+ manual = UtcpManual(utcp_version="1.0", manual_version="1.0", tools=[])
298
+ return RegisterManualResult(
299
+ manual_call_template=manual_call_template,
300
+ manual=manual,
301
+ success=False,
302
+ errors=[str(e)]
303
+ )
304
+
305
+ async def deregister_manual(self, caller, manual_call_template: CallTemplate) -> None:
306
+ if not isinstance(manual_call_template, UDPProvider):
307
+ raise ValueError("UDPTransport can only be used with UDPProvider")
308
+ self._log_info(f"Deregistering UDP provider '{manual_call_template.name}' (no-op)")
309
+
310
+ async def call_tool(self, caller, tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> Any:
311
+ if not isinstance(tool_call_template, UDPProvider):
312
+ raise ValueError("UDPTransport can only be used with UDPProvider")
313
+ self._log_info(f"Calling UDP tool '{tool_name}' on provider '{tool_call_template.name}'")
314
+ try:
315
+ tool_call_message = self._format_tool_call_message(tool_args, tool_call_template)
316
+ response = await self._send_udp_message(
317
+ tool_call_template.host,
318
+ tool_call_template.port,
319
+ tool_call_message,
320
+ tool_call_template.timeout / 1000.0,
321
+ tool_call_template.number_of_response_datagrams,
322
+ tool_call_template.response_byte_format
323
+ )
324
+ return response
325
+ except Exception as e:
326
+ self._log_error(f"Error calling UDP tool '{tool_name}': {traceback.format_exc()}")
327
+ raise
328
+
329
+ # Copilot AI (5 days ago):
330
+ # The call_tool_streaming method wraps a generator function but doesn't use the async def syntax for the method itself.
331
+ # While this works, it's inconsistent with the other implementation in tcp_communication_protocol.py (lines 384-387) which properly uses async def with an inner generator.
332
+ # For consistency and clarity, this should also use async def directly:
333
+ #
334
+ # async def call_tool_streaming(self, caller, tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate):
335
+ # yield await self.call_tool(caller, tool_name, tool_args, tool_call_template)
336
+ async def call_tool_streaming(self, caller, tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate):
337
+ yield await self.call_tool(caller, tool_name, tool_args, tool_call_template)
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: utcp-socket
3
+ Version: 1.0.2
4
+ Summary: UTCP communication protocol plugin for TCP and UDP protocols. (Work in progress)
5
+ Author: UTCP Contributors
6
+ License-Expression: MPL-2.0
7
+ Project-URL: Homepage, https://utcp.io
8
+ Project-URL: Source, https://github.com/universal-tool-calling-protocol/python-utcp
9
+ Project-URL: Issues, https://github.com/universal-tool-calling-protocol/python-utcp/issues
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: pydantic>=2.0
17
+ Requires-Dist: utcp>=1.0
18
+ Provides-Extra: dev
19
+ Requires-Dist: build; extra == "dev"
20
+ Requires-Dist: pytest; extra == "dev"
21
+ Requires-Dist: pytest-asyncio; extra == "dev"
22
+ Requires-Dist: pytest-cov; extra == "dev"
23
+ Requires-Dist: coverage; extra == "dev"
24
+ Requires-Dist: twine; extra == "dev"
25
+
26
+ # UTCP Socket Plugin (UDP/TCP)
27
+
28
+ This plugin adds UDP and TCP communication protocols to UTCP 1.0.
29
+
30
+ ## Running Tests
31
+
32
+ Prerequisites:
33
+ - Python 3.10+
34
+ - `pip`
35
+ - (Optional) a virtual environment
36
+
37
+ 1) Install core and the socket plugin in editable mode with dev extras:
38
+
39
+ ```bash
40
+ pip install -e "./core[dev]"
41
+ pip install -e ./plugins/communication_protocols/socket[dev]
42
+ ```
43
+
44
+ 2) Run the socket plugin tests:
45
+
46
+ ```bash
47
+ python -m pytest plugins/communication_protocols/socket/tests -v
48
+ ```
49
+
50
+ 3) Run a single test or filter by keyword:
51
+
52
+ ```bash
53
+ # One file
54
+ python -m pytest plugins/communication_protocols/socket/tests/test_tcp_communication_protocol.py -v
55
+
56
+ # Filter by keyword (e.g., delimiter framing)
57
+ python -m pytest plugins/communication_protocols/socket/tests -k delimiter -q
58
+ ```
59
+
60
+ 4) Optional end-to-end sanity check (mock UDP/TCP servers):
61
+
62
+ ```bash
63
+ python scripts/socket_sanity.py
64
+ ```
65
+
66
+ Notes:
67
+ - On Windows, your firewall may prompt the first time tests open UDP/TCP sockets; allow access or run as admin if needed.
68
+ - Tests use `pytest-asyncio`. The dev extras installed above provide required dependencies.
69
+ - Streaming is single-chunk by design, consistent with HTTP/Text transports. Multi-chunk streaming can be added later behind provider configuration.
@@ -0,0 +1,10 @@
1
+ utcp_socket/__init__.py,sha256=KEFMWm3BXEiCtw60ICqwMvzzNMsSlLbc1BxtvSnFsiI,742
2
+ utcp_socket/tcp_call_template.py,sha256=8DdoLO3eIskWdIqF0Y-zhaWoD1uPFRN2VZq6h88bdlc,4600
3
+ utcp_socket/tcp_communication_protocol.py,sha256=jcg6WwSQGnOaMAZuIS3_17UqvgCLGC31bNMWUeP_s_c,20481
4
+ utcp_socket/udp_call_template.py,sha256=ibXMAGb9qTeLkqhmTi3PDrnqxwB5bTH-l-Ut43_6j_A,2419
5
+ utcp_socket/udp_communication_protocol.py,sha256=j2IuqMsX8TrJzySyp1FtAozA5aCUTv8VbL7cFrlfowQ,16592
6
+ utcp_socket-1.0.2.dist-info/METADATA,sha256=N4DAeOgjJn8hv369NGdJo7Cx3w4Jty3u5WB6OapKs2I,2290
7
+ utcp_socket-1.0.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
+ utcp_socket-1.0.2.dist-info/entry_points.txt,sha256=Y2Q2PwwL-B6ODwaWEohMtrpipKvRBg3AYgE-s6Toalk,45
9
+ utcp_socket-1.0.2.dist-info/top_level.txt,sha256=Tys91lBOENWnB_K59d1xKKwJgaB5UZRx8BITQ5szn4I,12
10
+ utcp_socket-1.0.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [utcp.plugins]
2
+ socket = utcp_socket:register
@@ -0,0 +1 @@
1
+ utcp_socket