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.
Files changed (26) hide show
  1. {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/PKG-INFO +7 -1
  2. {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/README.md +4 -0
  3. pattern_agentic_messaging-1.3.0/docs/message-types.md +61 -0
  4. {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/pyproject.toml +2 -1
  5. {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/__init__.py +9 -0
  6. {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/app.py +45 -10
  7. pattern_agentic_messaging-1.3.0/src/pattern_agentic_messaging/audit.py +109 -0
  8. {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/config.py +4 -0
  9. pattern_agentic_messaging-1.3.0/src/pattern_agentic_messaging/message_types.py +51 -0
  10. {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/pool.py +3 -0
  11. {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/session.py +26 -1
  12. {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/uv.lock +20 -2
  13. {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/.claude/settings.local.json +0 -0
  14. {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/.gitignore +0 -0
  15. {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/CHANGELOG.md +0 -0
  16. {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/LICENSE.md +0 -0
  17. {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/a2a.py +0 -0
  18. {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/auth.py +0 -0
  19. {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/exceptions.py +0 -0
  20. {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/messages.py +0 -0
  21. {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/messaging.py +0 -0
  22. {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/session_token.py +0 -0
  23. {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/types.py +0 -0
  24. {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/src/pattern_agentic_messaging/utils.py +0 -0
  25. {pattern_agentic_messaging-1.2.0 → pattern_agentic_messaging-1.3.0}/tests/test_config.py +0 -0
  26. {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.2.0
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
+
@@ -152,3 +152,7 @@ async with PASlimApp(config) as app:
152
152
  await session.send({"result": result})
153
153
  ```
154
154
 
155
+ ## Message Types
156
+
157
+ All messages carry a `metadata.__pa_type` discriminator. See [docs/message-types.md](docs/message-types.md).
158
+
@@ -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.2.0"
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({"system_error": "invalid_session_token", "detail": str(e)})
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({"system_error": "session_token_verification_failed", "detail": str(e)})
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 self._app.create_session_and_wait_async(
518
- self._session_config(SessionType.POINT_TO_POINT), peer
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.1.0"
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 = [{ name = "slim-bindings", specifier = ">=1.3.0" }]
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 = [