pattern-agentic-messaging 1.2.0__tar.gz → 1.3.0__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.
- {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/PKG-INFO +7 -1
- {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/README.md +4 -0
- pattern_agentic_messaging-1.3.0/docs/message-types.md +61 -0
- {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/pyproject.toml +2 -1
- {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/__init__.py +9 -0
- {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/app.py +45 -10
- pattern_agentic_messaging-1.3.0/src/pattern_agentic_messaging/audit.py +109 -0
- {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/config.py +4 -0
- pattern_agentic_messaging-1.3.0/src/pattern_agentic_messaging/message_types.py +51 -0
- {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/pool.py +3 -0
- {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/session.py +26 -1
- {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/uv.lock +20 -2
- {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/.claude/settings.local.json +0 -0
- {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/.gitignore +0 -0
- {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/CHANGELOG.md +0 -0
- {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/LICENSE.md +0 -0
- {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/a2a.py +0 -0
- {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/auth.py +0 -0
- {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/exceptions.py +0 -0
- {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/messages.py +0 -0
- {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/messaging.py +0 -0
- {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/session_token.py +0 -0
- {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/types.py +0 -0
- {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/utils.py +0 -0
- {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/tests/test_config.py +0 -0
- {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/tests/test_messages.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pattern_agentic_messaging
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: SLIM-powered messaging
|
|
5
5
|
Author-email: Amos Joshua <amos@patternagentic.ai>
|
|
6
6
|
License-File: LICENSE.md
|
|
@@ -9,6 +9,8 @@ Classifier: Operating System :: OS Independent
|
|
|
9
9
|
Classifier: Programming Language :: Python :: 3
|
|
10
10
|
Requires-Python: >=3.11
|
|
11
11
|
Requires-Dist: slim-bindings>=1.3.0
|
|
12
|
+
Provides-Extra: audit
|
|
13
|
+
Requires-Dist: nats-py>=2.14.0; extra == 'audit'
|
|
12
14
|
Description-Content-Type: text/markdown
|
|
13
15
|
|
|
14
16
|
# Pattern Agentic Messaging
|
|
@@ -165,3 +167,7 @@ async with PASlimApp(config) as app:
|
|
|
165
167
|
await session.send({"result": result})
|
|
166
168
|
```
|
|
167
169
|
|
|
170
|
+
## Message Types
|
|
171
|
+
|
|
172
|
+
All messages carry a `metadata.__pa_type` discriminator. See [docs/message-types.md](docs/message-types.md).
|
|
173
|
+
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Message Types
|
|
2
|
+
|
|
3
|
+
All messages carry a `metadata.__pa_type` discriminator field that identifies the payload structure.
|
|
4
|
+
|
|
5
|
+
## `a2a.message`
|
|
6
|
+
|
|
7
|
+
Standard A2A `Message`. Used for all conversation messages between users and agents.
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"role": "ROLE_USER",
|
|
12
|
+
"parts": [{"text": "hello", "mediaType": "text/plain"}],
|
|
13
|
+
"contextId": "session-uuid",
|
|
14
|
+
"messageId": "msg-uuid",
|
|
15
|
+
"metadata": {"__pa_type": "a2a.message"}
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Parsed with `pattern_agentic_messaging.a2a.Message`.
|
|
20
|
+
|
|
21
|
+
## `a2a.task_status`
|
|
22
|
+
|
|
23
|
+
A2A `TaskStatusUpdateEvent`. Used for task state transitions (working, completed, failed).
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"taskId": "task-uuid",
|
|
28
|
+
"contextId": "session-uuid",
|
|
29
|
+
"status": {
|
|
30
|
+
"state": "TASK_STATE_FAILED",
|
|
31
|
+
"message": {"role": "ROLE_AGENT", "parts": [{"text": "error details"}]}
|
|
32
|
+
},
|
|
33
|
+
"metadata": {"__pa_type": "a2a.task_status"}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## `system_error`
|
|
38
|
+
|
|
39
|
+
Framework-level error. The message was rejected before reaching the agent (e.g. invalid session token, unauthorized).
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"error": "invalid_session_token",
|
|
44
|
+
"detail": "Missing x-pa-session-token in message metadata",
|
|
45
|
+
"metadata": {"__pa_type": "system_error"}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Parsed with `pattern_agentic_messaging.PASystemError`.
|
|
50
|
+
|
|
51
|
+
## Utilities
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from pattern_agentic_messaging import (
|
|
55
|
+
PAType, # Constants: A2A_MESSAGE, A2A_TASK_STATUS, SYSTEM_ERROR
|
|
56
|
+
PA_TYPE_KEY, # "__pa_type"
|
|
57
|
+
tag_a2a_message, # Adds __pa_type to an A2A message dict
|
|
58
|
+
get_pa_type, # Reads __pa_type from a payload
|
|
59
|
+
PASystemError, # Pydantic model for system errors
|
|
60
|
+
)
|
|
61
|
+
```
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pattern_agentic_messaging"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.3.0"
|
|
8
8
|
description = "SLIM-powered messaging"
|
|
9
9
|
authors = [
|
|
10
10
|
{ name="Amos Joshua", email="amos@patternagentic.ai" }
|
|
@@ -21,6 +21,7 @@ dependencies = [
|
|
|
21
21
|
]
|
|
22
22
|
|
|
23
23
|
[project.optional-dependencies]
|
|
24
|
+
audit = ["nats-py>=2.14.0"]
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
[tool.hatch.build.targets.wheel]
|
|
@@ -14,6 +14,8 @@ from .exceptions import (
|
|
|
14
14
|
from slim_bindings import MessageContext
|
|
15
15
|
from .auth import JWTClaims
|
|
16
16
|
from .session_token import PatternAgentSessionToken
|
|
17
|
+
from .audit import AuditPublisher
|
|
18
|
+
from .message_types import PAType, PASystemError, PA_TYPE_KEY, tag_a2a_message, tag_a2a_task_status, get_pa_type
|
|
17
19
|
|
|
18
20
|
__all__ = [
|
|
19
21
|
"PASlimConfig",
|
|
@@ -34,4 +36,11 @@ __all__ = [
|
|
|
34
36
|
"SerializationError",
|
|
35
37
|
"SessionClosedError",
|
|
36
38
|
"PatternAgentSessionToken",
|
|
39
|
+
"AuditPublisher",
|
|
40
|
+
"PAType",
|
|
41
|
+
"PASystemError",
|
|
42
|
+
"PA_TYPE_KEY",
|
|
43
|
+
"tag_a2a_message",
|
|
44
|
+
"tag_a2a_task_status",
|
|
45
|
+
"get_pa_type",
|
|
37
46
|
]
|
|
@@ -24,6 +24,7 @@ from .config import PASlimConfig
|
|
|
24
24
|
from .session import PASlimSession, PASlimP2PSession, PASlimGroupSession
|
|
25
25
|
from .auth import create_none_auth, create_shared_secret_auth, create_jwt_auth, JWTClaims
|
|
26
26
|
from .session_token import PatternAgentSessionToken
|
|
27
|
+
from .message_types import PASystemError
|
|
27
28
|
from .types import MessagePayload
|
|
28
29
|
from .exceptions import AuthenticationError
|
|
29
30
|
from .utils import parse_name
|
|
@@ -111,14 +112,14 @@ async def _call_handler(handler, session, msg, msg_ctx, injection, session_token
|
|
|
111
112
|
token = PatternAgentSessionToken.from_metadata(msg_ctx.metadata)
|
|
112
113
|
except Exception as e:
|
|
113
114
|
logger.warning(f"Session token extraction failed: {e}")
|
|
114
|
-
await session.send(
|
|
115
|
+
await session.send(PASystemError(error="invalid_session_token", detail=str(e)).to_payload())
|
|
115
116
|
return
|
|
116
117
|
if session_token_verifier:
|
|
117
118
|
try:
|
|
118
119
|
await session_token_verifier(token.raw_token)
|
|
119
120
|
except Exception as e:
|
|
120
121
|
logger.warning(f"Session token verification failed: {e}")
|
|
121
|
-
await session.send(
|
|
122
|
+
await session.send(PASystemError(error="session_token_verification_failed", detail=str(e)).to_payload())
|
|
122
123
|
return
|
|
123
124
|
kwargs[injection['session_token_param']] = token
|
|
124
125
|
await handler(session, msg, **kwargs)
|
|
@@ -136,6 +137,7 @@ class PASlimApp:
|
|
|
136
137
|
self._init_handlers = []
|
|
137
138
|
self._running = True
|
|
138
139
|
self.session_token_verifier = None
|
|
140
|
+
self._audit_publisher = None
|
|
139
141
|
|
|
140
142
|
async def __aenter__(self):
|
|
141
143
|
auth_type = self.config.auth_type
|
|
@@ -189,22 +191,47 @@ class PASlimApp:
|
|
|
189
191
|
tls=tls,
|
|
190
192
|
headers=self.config.custom_headers,
|
|
191
193
|
)
|
|
194
|
+
logger.info(f"SLIM __aenter__: connecting to endpoint={self.config.endpoint}")
|
|
192
195
|
conn_id = await service.connect_async(client_config)
|
|
196
|
+
logger.info(f"SLIM __aenter__: connected conn_id={conn_id}")
|
|
193
197
|
|
|
194
198
|
local_name = parse_name(self.config.local_name)
|
|
195
199
|
if auth_type == "shared_secret":
|
|
196
200
|
app = service.create_app_with_secret(local_name, self.config.auth_secret)
|
|
197
201
|
else:
|
|
198
202
|
app = service.create_app(local_name, auth_provider, auth_verifier)
|
|
203
|
+
logger.info(f"SLIM __aenter__: app created name={self.config.local_name}")
|
|
199
204
|
|
|
200
205
|
await app.subscribe_async(local_name, conn_id)
|
|
206
|
+
logger.info(f"SLIM __aenter__: subscribed name={self.config.local_name}")
|
|
201
207
|
|
|
202
208
|
self._service = service
|
|
203
209
|
self._app = app
|
|
204
210
|
self._conn_id = conn_id
|
|
211
|
+
|
|
212
|
+
if self.config.audit_nats_url:
|
|
213
|
+
try:
|
|
214
|
+
from .audit import AuditPublisher
|
|
215
|
+
self._audit_publisher = AuditPublisher(
|
|
216
|
+
self.config.audit_nats_url,
|
|
217
|
+
subject_prefix=self.config.audit_nats_subject_prefix,
|
|
218
|
+
creds_file=self.config.audit_nats_creds_file,
|
|
219
|
+
)
|
|
220
|
+
await self._audit_publisher.connect()
|
|
221
|
+
except Exception as e:
|
|
222
|
+
logger.warning(f"Audit publisher init failed (audit disabled): {e}")
|
|
223
|
+
self._audit_publisher = None
|
|
224
|
+
|
|
205
225
|
return self
|
|
206
226
|
|
|
207
227
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
228
|
+
if self._audit_publisher:
|
|
229
|
+
try:
|
|
230
|
+
await self._audit_publisher.close()
|
|
231
|
+
except Exception:
|
|
232
|
+
pass
|
|
233
|
+
self._audit_publisher = None
|
|
234
|
+
|
|
208
235
|
if self._service:
|
|
209
236
|
try:
|
|
210
237
|
self._service.disconnect(self._conn_id)
|
|
@@ -502,22 +529,30 @@ class PASlimApp:
|
|
|
502
529
|
metadata={},
|
|
503
530
|
)
|
|
504
531
|
|
|
505
|
-
async def connect(self, peer_name: str) -> PASlimP2PSession:
|
|
532
|
+
async def connect(self, peer_name: str, timeout: Optional[float] = None) -> PASlimP2PSession:
|
|
506
533
|
"""
|
|
507
534
|
Connect to a peer (P2P Active mode).
|
|
508
535
|
|
|
509
536
|
Args:
|
|
510
537
|
peer_name: Peer identifier (e.g., "org/namespace/app")
|
|
538
|
+
timeout: Connection timeout in seconds (default: config.connect_timeout_sec)
|
|
511
539
|
|
|
512
540
|
Returns:
|
|
513
541
|
PASlimP2PSession for communicating with the peer
|
|
542
|
+
|
|
543
|
+
Raises:
|
|
544
|
+
asyncio.TimeoutError: If the peer doesn't respond within the timeout
|
|
514
545
|
"""
|
|
546
|
+
connect_timeout = timeout or self.config.connect_timeout_sec
|
|
515
547
|
peer = parse_name(peer_name)
|
|
516
548
|
await self._app.set_route_async(peer, self._conn_id)
|
|
517
|
-
session = await
|
|
518
|
-
self.
|
|
549
|
+
session = await asyncio.wait_for(
|
|
550
|
+
self._app.create_session_and_wait_async(
|
|
551
|
+
self._session_config(SessionType.POINT_TO_POINT), peer
|
|
552
|
+
),
|
|
553
|
+
timeout=connect_timeout,
|
|
519
554
|
)
|
|
520
|
-
return PASlimP2PSession(session)
|
|
555
|
+
return PASlimP2PSession(session, audit_publisher=self._audit_publisher, local_name=self.config.local_name, peer_name=peer_name)
|
|
521
556
|
|
|
522
557
|
async def accept(self) -> PASlimP2PSession:
|
|
523
558
|
"""
|
|
@@ -527,7 +562,7 @@ class PASlimApp:
|
|
|
527
562
|
PASlimP2PSession for the incoming connection
|
|
528
563
|
"""
|
|
529
564
|
session = await self._app.listen_for_session_async(None)
|
|
530
|
-
return PASlimP2PSession(session)
|
|
565
|
+
return PASlimP2PSession(session, audit_publisher=self._audit_publisher, local_name=self.config.local_name)
|
|
531
566
|
|
|
532
567
|
async def create_channel(self, channel_name: str, invites: list[str] = None) -> PASlimGroupSession:
|
|
533
568
|
"""
|
|
@@ -547,7 +582,7 @@ class PASlimApp:
|
|
|
547
582
|
slim_session = await self._app.create_session_and_wait_async(
|
|
548
583
|
self._session_config(SessionType.GROUP), channel
|
|
549
584
|
)
|
|
550
|
-
session = PASlimGroupSession(slim_session)
|
|
585
|
+
session = PASlimGroupSession(slim_session, audit_publisher=self._audit_publisher, local_name=self.config.local_name, peer_name=channel_name)
|
|
551
586
|
|
|
552
587
|
for invite in invites:
|
|
553
588
|
participant = parse_name(invite)
|
|
@@ -564,7 +599,7 @@ class PASlimApp:
|
|
|
564
599
|
PASlimGroupSession for the channel
|
|
565
600
|
"""
|
|
566
601
|
session = await self._app.listen_for_session_async(None)
|
|
567
|
-
return PASlimGroupSession(session)
|
|
602
|
+
return PASlimGroupSession(session, audit_publisher=self._audit_publisher, local_name=self.config.local_name)
|
|
568
603
|
|
|
569
604
|
async def listen(self) -> AsyncIterator[PASlimP2PSession]:
|
|
570
605
|
"""
|
|
@@ -575,7 +610,7 @@ class PASlimApp:
|
|
|
575
610
|
"""
|
|
576
611
|
while True:
|
|
577
612
|
session = await self._app.listen_for_session_async(None)
|
|
578
|
-
yield PASlimP2PSession(session)
|
|
613
|
+
yield PASlimP2PSession(session, audit_publisher=self._audit_publisher, local_name=self.config.local_name)
|
|
579
614
|
|
|
580
615
|
async def messages(self) -> AsyncIterator[tuple[PASlimSession, MessageContext, MessagePayload]]:
|
|
581
616
|
"""
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuditPublisher:
|
|
11
|
+
def __init__(self, nats_url: str, subject_prefix: str = "pa.audit.messages", creds_file: Optional[str] = None):
|
|
12
|
+
self._nats_url = nats_url
|
|
13
|
+
self._subject_prefix = subject_prefix
|
|
14
|
+
self._creds_file = creds_file
|
|
15
|
+
self._nc = None
|
|
16
|
+
self._js = None
|
|
17
|
+
self._connect_lock = asyncio.Lock()
|
|
18
|
+
|
|
19
|
+
async def connect(self):
|
|
20
|
+
try:
|
|
21
|
+
from nats.aio.client import Client as NATS
|
|
22
|
+
except ImportError:
|
|
23
|
+
raise ImportError("nats-py is required for audit publishing. Install with: pip install pattern-agentic-messaging[audit]")
|
|
24
|
+
|
|
25
|
+
async with self._connect_lock:
|
|
26
|
+
if self._nc and self._nc.is_connected:
|
|
27
|
+
return
|
|
28
|
+
self._nc = NATS()
|
|
29
|
+
connect_opts = {"servers": [self._nats_url]}
|
|
30
|
+
if self._creds_file:
|
|
31
|
+
connect_opts["user_credentials"] = self._creds_file
|
|
32
|
+
await self._nc.connect(**connect_opts)
|
|
33
|
+
self._js = self._nc.jetstream()
|
|
34
|
+
logger.info(f"Audit publisher connected to {self._nats_url}")
|
|
35
|
+
|
|
36
|
+
async def close(self):
|
|
37
|
+
if self._nc and self._nc.is_connected:
|
|
38
|
+
await self._nc.drain()
|
|
39
|
+
await self._nc.close()
|
|
40
|
+
self._nc = None
|
|
41
|
+
self._js = None
|
|
42
|
+
logger.info("Audit publisher closed")
|
|
43
|
+
|
|
44
|
+
async def publish(
|
|
45
|
+
self,
|
|
46
|
+
payload,
|
|
47
|
+
*,
|
|
48
|
+
sender: str,
|
|
49
|
+
recipient: str,
|
|
50
|
+
tenant_id: Optional[str] = None,
|
|
51
|
+
session_id: Optional[str] = None,
|
|
52
|
+
user_id: Optional[str] = None,
|
|
53
|
+
task_id: Optional[str] = None,
|
|
54
|
+
metadata: Optional[dict] = None,
|
|
55
|
+
):
|
|
56
|
+
if not self._nc or not self._nc.is_connected:
|
|
57
|
+
try:
|
|
58
|
+
await self.connect()
|
|
59
|
+
except Exception:
|
|
60
|
+
logger.warning("Audit publish skipped: NATS not connected", exc_info=True)
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
if isinstance(payload, bytes):
|
|
64
|
+
try:
|
|
65
|
+
message = json.loads(payload)
|
|
66
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
67
|
+
message = {"raw": payload.hex()}
|
|
68
|
+
elif isinstance(payload, str):
|
|
69
|
+
message = {"text": payload}
|
|
70
|
+
elif hasattr(payload, "model_dump"):
|
|
71
|
+
message = payload.model_dump(by_alias=True, exclude_none=True)
|
|
72
|
+
elif isinstance(payload, dict):
|
|
73
|
+
message = payload
|
|
74
|
+
else:
|
|
75
|
+
message = {"data": str(payload)}
|
|
76
|
+
|
|
77
|
+
envelope = {
|
|
78
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
79
|
+
"sender": sender,
|
|
80
|
+
"recipient": recipient,
|
|
81
|
+
"direction": "outgoing",
|
|
82
|
+
"message": message,
|
|
83
|
+
}
|
|
84
|
+
if tenant_id:
|
|
85
|
+
envelope["tenant_id"] = tenant_id
|
|
86
|
+
if session_id:
|
|
87
|
+
envelope["session_id"] = session_id
|
|
88
|
+
if user_id:
|
|
89
|
+
envelope["user_id"] = user_id
|
|
90
|
+
if task_id:
|
|
91
|
+
envelope["task_id"] = task_id
|
|
92
|
+
if metadata:
|
|
93
|
+
envelope["metadata"] = metadata
|
|
94
|
+
|
|
95
|
+
subject_parts = [self._subject_prefix]
|
|
96
|
+
if tenant_id:
|
|
97
|
+
subject_parts.append(tenant_id)
|
|
98
|
+
if session_id:
|
|
99
|
+
subject_parts.append(session_id)
|
|
100
|
+
subject = ".".join(subject_parts)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
data = json.dumps(envelope).encode()
|
|
104
|
+
if self._js:
|
|
105
|
+
await self._js.publish(subject, data)
|
|
106
|
+
else:
|
|
107
|
+
await self._nc.publish(subject, data)
|
|
108
|
+
except Exception:
|
|
109
|
+
logger.warning(f"Audit publish failed for {subject}", exc_info=True)
|
|
@@ -23,9 +23,13 @@ class PASlimConfig:
|
|
|
23
23
|
jwt_token_duration: timedelta = field(default_factory=lambda: _DEFAULT_TOKEN_DURATION)
|
|
24
24
|
max_retries: int = 5
|
|
25
25
|
timeout: timedelta = field(default_factory=lambda: timedelta(seconds=5))
|
|
26
|
+
connect_timeout_sec: float = 30.0
|
|
26
27
|
mls_enabled: bool = True
|
|
27
28
|
message_discriminator: Optional[str] = None
|
|
28
29
|
custom_headers: Optional[dict[str, str]] = None
|
|
30
|
+
audit_nats_url: Optional[str] = None
|
|
31
|
+
audit_nats_subject_prefix: str = "pa.audit.messages"
|
|
32
|
+
audit_nats_creds_file: Optional[str] = None
|
|
29
33
|
|
|
30
34
|
def with_no_auth(self) -> PASlimConfig:
|
|
31
35
|
self.auth_type = "none"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from typing import Any, Optional
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
|
|
4
|
+
PA_TYPE_KEY = "__pa_type"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PAType:
|
|
8
|
+
A2A_MESSAGE = "a2a.message"
|
|
9
|
+
A2A_TASK_STATUS = "a2a.task_status"
|
|
10
|
+
A2A_TASK_ARTIFACT = "a2a.task_artifact"
|
|
11
|
+
SYSTEM_ERROR = "system_error"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PASystemError(BaseModel):
|
|
15
|
+
error: str
|
|
16
|
+
detail: str
|
|
17
|
+
metadata: dict[str, Any] = {}
|
|
18
|
+
|
|
19
|
+
def to_payload(self) -> dict[str, Any]:
|
|
20
|
+
return {
|
|
21
|
+
"error": self.error,
|
|
22
|
+
"detail": self.detail,
|
|
23
|
+
"metadata": {**self.metadata, PA_TYPE_KEY: PAType.SYSTEM_ERROR},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_payload(cls, payload: dict[str, Any]) -> "PASystemError":
|
|
28
|
+
return cls(
|
|
29
|
+
error=payload.get("error", "unknown"),
|
|
30
|
+
detail=payload.get("detail", ""),
|
|
31
|
+
metadata={k: v for k, v in payload.get("metadata", {}).items() if k != PA_TYPE_KEY},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def tag_a2a_message(payload: dict[str, Any]) -> dict[str, Any]:
|
|
36
|
+
meta = payload.get("metadata") or {}
|
|
37
|
+
meta[PA_TYPE_KEY] = PAType.A2A_MESSAGE
|
|
38
|
+
payload["metadata"] = meta
|
|
39
|
+
return payload
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def tag_a2a_task_status(payload: dict[str, Any]) -> dict[str, Any]:
|
|
43
|
+
meta = payload.get("metadata") or {}
|
|
44
|
+
meta[PA_TYPE_KEY] = PAType.A2A_TASK_STATUS
|
|
45
|
+
payload["metadata"] = meta
|
|
46
|
+
return payload
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_pa_type(payload: dict[str, Any]) -> Optional[str]:
|
|
50
|
+
meta = payload.get("metadata") or {}
|
|
51
|
+
return meta.get(PA_TYPE_KEY)
|
|
@@ -53,11 +53,14 @@ class SlimConnectionPool:
|
|
|
53
53
|
|
|
54
54
|
async def acquire(self, key: str, local_name: str) -> PASlimApp:
|
|
55
55
|
lock = await self._get_lock(key)
|
|
56
|
+
logger.info(f"SLIM pool acquire: waiting for lock key={key}")
|
|
56
57
|
|
|
57
58
|
async with lock:
|
|
59
|
+
logger.info(f"SLIM pool acquire: lock acquired key={key}")
|
|
58
60
|
if key not in self._apps:
|
|
59
61
|
config = replace(self._template, local_name=local_name)
|
|
60
62
|
app = PASlimApp(config)
|
|
63
|
+
logger.info(f"SLIM pool: connecting app key={key} name={local_name} endpoint={config.endpoint}")
|
|
61
64
|
await app.__aenter__()
|
|
62
65
|
self._apps[key] = app
|
|
63
66
|
self._refcounts[key] = 0
|
|
@@ -6,12 +6,14 @@ from datetime import timedelta
|
|
|
6
6
|
from .types import MessagePayload
|
|
7
7
|
from .messages import encode_message, decode_message
|
|
8
8
|
from .exceptions import SessionClosedError, TimeoutError as PATimeoutError
|
|
9
|
+
from .session_token import SESSION_TOKEN_METADATA_KEY
|
|
10
|
+
from .message_types import tag_a2a_message
|
|
9
11
|
from .utils import parse_name
|
|
10
12
|
|
|
11
13
|
logger = logging.getLogger(__name__)
|
|
12
14
|
|
|
13
15
|
class PASlimSession:
|
|
14
|
-
def __init__(self, slim_session):
|
|
16
|
+
def __init__(self, slim_session, *, audit_publisher=None, local_name: str = "", peer_name: str = ""):
|
|
15
17
|
self._session = slim_session
|
|
16
18
|
self._session_id = str(uuid.uuid4())
|
|
17
19
|
self.context: Dict[str, Any] = {}
|
|
@@ -21,6 +23,9 @@ class PASlimSession:
|
|
|
21
23
|
self._pending_requests: dict[str, asyncio.Future] = {}
|
|
22
24
|
self._closed = False
|
|
23
25
|
self._outgoing_metadata: dict[str, str] = {}
|
|
26
|
+
self._audit_publisher = audit_publisher
|
|
27
|
+
self._local_name = local_name
|
|
28
|
+
self._peer_name = peer_name
|
|
24
29
|
|
|
25
30
|
@property
|
|
26
31
|
def session_id(self) -> str:
|
|
@@ -34,6 +39,10 @@ class PASlimSession:
|
|
|
34
39
|
payload = received.payload
|
|
35
40
|
decoded = decode_message(payload)
|
|
36
41
|
|
|
42
|
+
incoming_metadata = getattr(msg_ctx, 'metadata', None) or {}
|
|
43
|
+
if SESSION_TOKEN_METADATA_KEY in incoming_metadata:
|
|
44
|
+
self._outgoing_metadata[SESSION_TOKEN_METADATA_KEY] = incoming_metadata[SESSION_TOKEN_METADATA_KEY]
|
|
45
|
+
|
|
37
46
|
if isinstance(decoded, dict) and "_request_id" in decoded:
|
|
38
47
|
request_id = decoded["_request_id"]
|
|
39
48
|
if request_id in self._pending_requests:
|
|
@@ -93,6 +102,22 @@ class PASlimSession:
|
|
|
93
102
|
data = encode_message(payload)
|
|
94
103
|
merged = {**self._outgoing_metadata, **(metadata or {})}
|
|
95
104
|
await self._session.publish_and_wait_async(data, None, merged or None)
|
|
105
|
+
if self._audit_publisher:
|
|
106
|
+
try:
|
|
107
|
+
from .session_token import PatternAgentSessionToken
|
|
108
|
+
token = PatternAgentSessionToken.from_metadata(merged) if merged else None
|
|
109
|
+
audit_payload = tag_a2a_message(dict(payload)) if isinstance(payload, dict) else payload
|
|
110
|
+
await self._audit_publisher.publish(
|
|
111
|
+
audit_payload,
|
|
112
|
+
sender=self._local_name,
|
|
113
|
+
recipient=self._peer_name,
|
|
114
|
+
tenant_id=token.tenant_id if token else None,
|
|
115
|
+
session_id=token.session_id if token else None,
|
|
116
|
+
user_id=token.user_id if token else None,
|
|
117
|
+
task_id=payload.get("taskId") if isinstance(payload, dict) else None,
|
|
118
|
+
)
|
|
119
|
+
except Exception:
|
|
120
|
+
logger.debug("Audit publish failed", exc_info=True)
|
|
96
121
|
|
|
97
122
|
def on_message(self, callback: Callable[[Any], None]):
|
|
98
123
|
self._callbacks.append(callback)
|
|
@@ -20,6 +20,15 @@ wheels = [
|
|
|
20
20
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
|
21
21
|
]
|
|
22
22
|
|
|
23
|
+
[[package]]
|
|
24
|
+
name = "nats-py"
|
|
25
|
+
version = "2.14.0"
|
|
26
|
+
source = { registry = "https://pypi.org/simple" }
|
|
27
|
+
sdist = { url = "https://files.pythonhosted.org/packages/c3/f8/b956c4621ba88748ed707c52e69f95b7a50c8914e750edca59a5bef84a76/nats_py-2.14.0.tar.gz", hash = "sha256:4ed02cb8e3b55c68074a063aa2687087115d805d1513297da90cb2068fb07bed", size = 120751, upload-time = "2026-02-23T22:44:58.988Z" }
|
|
28
|
+
wheels = [
|
|
29
|
+
{ url = "https://files.pythonhosted.org/packages/f9/39/0e87753df1072254bac190b33ed34b264f28f6aa9bea0f01b7e818071756/nats_py-2.14.0-py3-none-any.whl", hash = "sha256:4116f5d2233ce16e63c3d5538fa40a5e207f75fcf42a741773929ddf1e29d19d", size = 82259, upload-time = "2026-02-23T22:45:00.152Z" },
|
|
30
|
+
]
|
|
31
|
+
|
|
23
32
|
[[package]]
|
|
24
33
|
name = "packaging"
|
|
25
34
|
version = "26.0"
|
|
@@ -31,12 +40,17 @@ wheels = [
|
|
|
31
40
|
|
|
32
41
|
[[package]]
|
|
33
42
|
name = "pattern-agentic-messaging"
|
|
34
|
-
version = "1.
|
|
43
|
+
version = "1.2.0"
|
|
35
44
|
source = { editable = "." }
|
|
36
45
|
dependencies = [
|
|
37
46
|
{ name = "slim-bindings" },
|
|
38
47
|
]
|
|
39
48
|
|
|
49
|
+
[package.optional-dependencies]
|
|
50
|
+
audit = [
|
|
51
|
+
{ name = "nats-py" },
|
|
52
|
+
]
|
|
53
|
+
|
|
40
54
|
[package.dev-dependencies]
|
|
41
55
|
dev = [
|
|
42
56
|
{ name = "pytest" },
|
|
@@ -44,7 +58,11 @@ dev = [
|
|
|
44
58
|
]
|
|
45
59
|
|
|
46
60
|
[package.metadata]
|
|
47
|
-
requires-dist = [
|
|
61
|
+
requires-dist = [
|
|
62
|
+
{ name = "nats-py", marker = "extra == 'audit'", specifier = ">=2.9.0" },
|
|
63
|
+
{ name = "slim-bindings", specifier = ">=1.3.0" },
|
|
64
|
+
]
|
|
65
|
+
provides-extras = ["audit"]
|
|
48
66
|
|
|
49
67
|
[package.metadata.requires-dev]
|
|
50
68
|
dev = [
|
{pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/.claude/settings.local.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|