crowddrop-pubsub-sdk 0.2.0__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.
- crowddrop_pubsub_sdk-0.2.0.dist-info/METADATA +181 -0
- crowddrop_pubsub_sdk-0.2.0.dist-info/RECORD +13 -0
- crowddrop_pubsub_sdk-0.2.0.dist-info/WHEEL +5 -0
- crowddrop_pubsub_sdk-0.2.0.dist-info/top_level.txt +1 -0
- pubsub_sdk/__init__.py +31 -0
- pubsub_sdk/chat.py +118 -0
- pubsub_sdk/client.py +381 -0
- pubsub_sdk/config.py +43 -0
- pubsub_sdk/events.py +47 -0
- pubsub_sdk/langchain_callback.py +227 -0
- pubsub_sdk/publisher.py +61 -0
- pubsub_sdk/py.typed +0 -0
- pubsub_sdk/subscriber.py +33 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: crowddrop-pubsub-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Framework-agnostic Google Cloud Pub/Sub step-event publisher/subscriber for embodied AI agents.
|
|
5
|
+
Project-URL: Repository, https://github.com/crowddrop-ai/crowddrop_ai_agents
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: google-cloud-pubsub<3.0.0,>=2.21.0
|
|
9
|
+
Requires-Dist: google-api-core<3.0.0,>=2.17.1
|
|
10
|
+
Provides-Extra: langchain
|
|
11
|
+
Requires-Dist: langchain-core>=0.3.0; extra == "langchain"
|
|
12
|
+
|
|
13
|
+
# pubsub_sdk
|
|
14
|
+
|
|
15
|
+
A small, framework-agnostic wrapper around Google Cloud Pub/Sub for publishing and
|
|
16
|
+
consuming structured step/progress events, and for direct agent-to-agent
|
|
17
|
+
instruction/reply exchanges (chat). It has no dependency on LangChain, on any
|
|
18
|
+
specific agent framework, or on any particular storage backend — any Python project
|
|
19
|
+
(an agent, a backend service, a future embodied-AI-agent codebase) can import it
|
|
20
|
+
directly.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install ./pubsub_sdk # core publisher/subscriber only
|
|
26
|
+
pip install ./pubsub_sdk[langchain] # + the optional LangChain callback adapter
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Published to public PyPI as `crowddrop-pubsub-sdk` (the import name stays
|
|
30
|
+
`pubsub_sdk` either way):
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install crowddrop-pubsub-sdk
|
|
34
|
+
pip install crowddrop-pubsub-sdk[langchain]
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Configuration (environment variables)
|
|
38
|
+
|
|
39
|
+
| Variable | Default | Purpose |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| `GCP_PROJECT_ID` | `test` | GCP project the client talks to. |
|
|
42
|
+
| `PUBSUB_SDK_TOPIC_ID` | `agent-step-events` | Topic events are published to. |
|
|
43
|
+
| `PUBSUB_SDK_SUBSCRIPTION_ID` | `agent-step-events-firestore-persister` | Subscription a consumer pulls from. |
|
|
44
|
+
| `PUBSUB_SDK_CHAT_TOPIC_ID` | `agent-chat-messages` | Fixed topic for agent-to-agent chat (see below). |
|
|
45
|
+
| `PUBSUB_SDK_ENABLED` | `true` | Kill switch — set to `false` to disable publishing/subscribing entirely. |
|
|
46
|
+
| `PUBSUB_EMULATOR_HOST` | unset | Standard Google client env var; set to point at a local Pub/Sub emulator instead of real GCP. |
|
|
47
|
+
|
|
48
|
+
Credentials are picked up automatically via `GOOGLE_APPLICATION_CREDENTIALS`, exactly
|
|
49
|
+
like every other `google-cloud-*` client — no SDK-specific credential handling.
|
|
50
|
+
|
|
51
|
+
## Publishing (any consumer)
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from pubsub_sdk import StepEventPublisher
|
|
55
|
+
|
|
56
|
+
publisher = StepEventPublisher()
|
|
57
|
+
publisher.publish_step(
|
|
58
|
+
activity_id="task-123",
|
|
59
|
+
agent_id="MissionCommander",
|
|
60
|
+
event_type="tool_start",
|
|
61
|
+
message="Invoking tool: delegate_to_agent",
|
|
62
|
+
status="in_progress",
|
|
63
|
+
metadata={"tool_name": "delegate_to_agent"},
|
|
64
|
+
)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`publish_step()` never raises — if Pub/Sub is unavailable it logs the failure and
|
|
68
|
+
returns `False`. Check `publisher.is_available()` if you need to know the state
|
|
69
|
+
up front.
|
|
70
|
+
|
|
71
|
+
## Subscribing (any consumer)
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from pubsub_sdk import StepEventSubscriber, StepEvent
|
|
75
|
+
|
|
76
|
+
def handle(event: StepEvent) -> None:
|
|
77
|
+
... # e.g. persist it somewhere
|
|
78
|
+
|
|
79
|
+
subscriber = StepEventSubscriber()
|
|
80
|
+
subscriber.pull_forever(handle) # blocks; acks on success, nacks (redelivers) on exception
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Agent-to-agent chat (ChatMessage)
|
|
84
|
+
|
|
85
|
+
A direct instruction/reply exchange between two agent instances (e.g. a
|
|
86
|
+
coordinator persona delegating to a field agent), as an alternative transport to
|
|
87
|
+
a live MCP/SSE connection. Both directions of one exchange ride the same fixed
|
|
88
|
+
topic (`agent-chat-messages`) and are distinguished by `role`
|
|
89
|
+
(`ChatMessageRole.INSTRUCTION` / `.REPLY`), correlated by `message_id`.
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from pubsub_sdk import ChatMessage, ChatMessageRole, get_default_chat_publisher, ChatMessageSubscriber
|
|
93
|
+
|
|
94
|
+
# Sending side: publish an instruction addressed to another agent instance's
|
|
95
|
+
# routing identity (its session_id - never a shared persona name; two ephemeral
|
|
96
|
+
# instances of the same persona would otherwise collide on the same subscription).
|
|
97
|
+
publisher = get_default_chat_publisher()
|
|
98
|
+
publisher.publish(ChatMessage(
|
|
99
|
+
message_id="m1", from_agent="MissionCommander__task-42", to_agent="PreciseHumanoid",
|
|
100
|
+
role=ChatMessageRole.INSTRUCTION, content="what is your current battery level?",
|
|
101
|
+
activity_id="task-42",
|
|
102
|
+
))
|
|
103
|
+
|
|
104
|
+
# Receiving side: each participating instance self-provisions its own filtered
|
|
105
|
+
# subscription (against real GCP, not just the emulator - a per-instance
|
|
106
|
+
# subscription's identity only exists at runtime and can't be pre-provisioned).
|
|
107
|
+
subscriber = ChatMessageSubscriber(routing_identity="PreciseHumanoid")
|
|
108
|
+
subscriber.pull_forever(lambda msg: ...) # blocks; ack on success, nack (redeliver) on exception
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`GenericSubscriber.pull_forever` also accepts a `stop()` call from another thread
|
|
112
|
+
to cancel an in-progress streaming pull cleanly.
|
|
113
|
+
|
|
114
|
+
**Security note:** `agent-chat-messages` is one shared topic with no
|
|
115
|
+
per-agent access control beyond GCP IAM on the topic/subscription resources
|
|
116
|
+
themselves - any credential with publish/subscribe rights can spoof
|
|
117
|
+
`from_agent` or read another agent's traffic via an unfiltered subscription.
|
|
118
|
+
Fine within one trust domain (this app's own fleet today); see
|
|
119
|
+
`docs/pubsub_chat_access_control.md` before granting a genuinely independent/
|
|
120
|
+
external party a credential onto this topic.
|
|
121
|
+
|
|
122
|
+
## LangChain integration (optional extra)
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from pubsub_sdk.langchain_callback import StepEventCallbackHandler
|
|
126
|
+
|
|
127
|
+
callback = StepEventCallbackHandler(activity_id="task-123", agent_id="MissionCommander")
|
|
128
|
+
callback.log_mission_start()
|
|
129
|
+
# pass `callback` into a LangChain AgentExecutor's `config={"callbacks": [callback]}` —
|
|
130
|
+
# every LLM/tool/chain/agent lifecycle event then publishes automatically.
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`langchain_callback` is the only module in this package that imports `langchain` —
|
|
134
|
+
a consumer who only needs `StepEventPublisher`/`StepEventSubscriber` never pulls
|
|
135
|
+
that dependency in.
|
|
136
|
+
|
|
137
|
+
## Releasing (publishing a new version to PyPI)
|
|
138
|
+
|
|
139
|
+
Releases are tag-triggered via `.github/workflows/publish-pubsub-sdk.yml`,
|
|
140
|
+
using PyPI's **Trusted Publishing** (OIDC) — no API token is stored as a
|
|
141
|
+
GitHub secret.
|
|
142
|
+
|
|
143
|
+
1. Bump `version` in `pubsub_sdk/pyproject.toml`.
|
|
144
|
+
2. Commit that change (on a branch, via the normal PR flow).
|
|
145
|
+
3. Once merged, tag the merge commit and push the tag:
|
|
146
|
+
```bash
|
|
147
|
+
git tag pubsub-sdk-v<version> # e.g. pubsub-sdk-v0.2.1
|
|
148
|
+
git push origin pubsub-sdk-v<version>
|
|
149
|
+
```
|
|
150
|
+
The tag push is what fires the workflow — it builds `pubsub_sdk/` and
|
|
151
|
+
uploads it to [pypi.org/project/crowddrop-pubsub-sdk](https://pypi.org/project/crowddrop-pubsub-sdk/).
|
|
152
|
+
No other trigger publishes this package.
|
|
153
|
+
|
|
154
|
+
**One-time setup, not yet done as of this writing — needed before the first
|
|
155
|
+
tag push, and again only if this ever moves to a different PyPI
|
|
156
|
+
account/org:**
|
|
157
|
+
- Register a **pending publisher** for `crowddrop-pubsub-sdk` at
|
|
158
|
+
https://pypi.org/manage/account/publishing/ — this can be done before the
|
|
159
|
+
PyPI project exists, so it covers the *first-ever* release too, not just
|
|
160
|
+
subsequent ones. Fill in: PyPI project name `crowddrop-pubsub-sdk`, repo
|
|
161
|
+
owner `crowddrop-ai`, repo name `crowddrop_ai_agents`, workflow filename
|
|
162
|
+
`publish-pubsub-sdk.yml`, environment name `pypi`. Requires a PyPI account
|
|
163
|
+
with 2FA enabled — no API token to generate or store.
|
|
164
|
+
- The `pypi` GitHub Environment referenced by the workflow is created
|
|
165
|
+
automatically the first time the workflow runs against it; create it
|
|
166
|
+
manually in this repo's Settings → Environments beforehand only if you
|
|
167
|
+
want a required-reviewer protection rule (so a tag push pauses for human
|
|
168
|
+
approval before it actually publishes).
|
|
169
|
+
- `../scripts/publish_python_packages.sh` (manual `build` + `twine upload`)
|
|
170
|
+
is kept as a fallback/local-dry-run tool only — with a pending publisher
|
|
171
|
+
registered, it's no longer needed even for the first release.
|
|
172
|
+
|
|
173
|
+
Versioning is manual — nothing cross-checks the tag against
|
|
174
|
+
`pyproject.toml`'s `version`. Bump the file first, commit, *then* tag that
|
|
175
|
+
exact commit; tagging a commit whose `pyproject.toml` still has an
|
|
176
|
+
already-published version will fail the upload (PyPI rejects re-uploading an
|
|
177
|
+
existing version).
|
|
178
|
+
|
|
179
|
+
Note: `crowddrop-sdk`'s `cloud-brain` extra depends on `crowddrop-pubsub-sdk`
|
|
180
|
+
(see `../crowddrop_sdk/pyproject.toml`) — release this package first if both
|
|
181
|
+
need a coordinated update.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
pubsub_sdk/__init__.py,sha256=-_qVzXE2Y8_s0-U64P4oZoOYo2bpP5C9YWPA7XzlTlQ,959
|
|
2
|
+
pubsub_sdk/chat.py,sha256=n4lsSeYSr1Vp10Yq-pQ2NLCFgo9XIVFBfmjf7RNX2bk,4748
|
|
3
|
+
pubsub_sdk/client.py,sha256=FrmSH3xVLepsUVgXFR3lnygj1bSCVHOd5dwOXPLHeY0,18502
|
|
4
|
+
pubsub_sdk/config.py,sha256=iCatyWtrkeW5tEx2nQ4mbMIrYVt2OCh9zHOE9S8atpA,1464
|
|
5
|
+
pubsub_sdk/events.py,sha256=GGo9SglVNwY1qxY79FzuAyFIlaJtLV7X3GbOGdE5nXM,1740
|
|
6
|
+
pubsub_sdk/langchain_callback.py,sha256=CyyEbIJeaune5MCes8ZNcTxGRNkfb_KdgZyXA-p6hG0,9887
|
|
7
|
+
pubsub_sdk/publisher.py,sha256=xOjXiFMOV8LkptL2aKZvvQvgnrLlMEBeKy5pTig0iNg,2338
|
|
8
|
+
pubsub_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
pubsub_sdk/subscriber.py,sha256=cuERyg4u1k7dFbZrZuMpTYoO-TGAhyfpfm1sX_l7VxM,1346
|
|
10
|
+
crowddrop_pubsub_sdk-0.2.0.dist-info/METADATA,sha256=aONfOS8RBZa81gkgaIxl6Vm-xHY-afD5HhkeEDVZbf4,8055
|
|
11
|
+
crowddrop_pubsub_sdk-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
12
|
+
crowddrop_pubsub_sdk-0.2.0.dist-info/top_level.txt,sha256=gzpn2GhMuyKPgkd2n25810spt0QEjFBFiClNvH5d60I,11
|
|
13
|
+
crowddrop_pubsub_sdk-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pubsub_sdk
|
pubsub_sdk/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Framework-agnostic Google Cloud Pub/Sub step-event publisher/subscriber.
|
|
2
|
+
|
|
3
|
+
The LangChain adapter (StepEventCallbackHandler) is intentionally NOT re-exported
|
|
4
|
+
here — importing it requires the optional `langchain` extra, and this top-level
|
|
5
|
+
package must stay importable without that dependency. Import it explicitly from
|
|
6
|
+
`pubsub_sdk.langchain_callback` when you need it.
|
|
7
|
+
"""
|
|
8
|
+
from .chat import (
|
|
9
|
+
ChatMessage,
|
|
10
|
+
ChatMessagePublisher,
|
|
11
|
+
ChatMessageRole,
|
|
12
|
+
ChatMessageSubscriber,
|
|
13
|
+
get_default_chat_publisher,
|
|
14
|
+
)
|
|
15
|
+
from .config import PubSubConfig
|
|
16
|
+
from .events import StepEvent
|
|
17
|
+
from .publisher import StepEventPublisher, get_default_publisher
|
|
18
|
+
from .subscriber import StepEventSubscriber
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"PubSubConfig",
|
|
22
|
+
"StepEvent",
|
|
23
|
+
"StepEventPublisher",
|
|
24
|
+
"StepEventSubscriber",
|
|
25
|
+
"get_default_publisher",
|
|
26
|
+
"ChatMessage",
|
|
27
|
+
"ChatMessageRole",
|
|
28
|
+
"ChatMessagePublisher",
|
|
29
|
+
"ChatMessageSubscriber",
|
|
30
|
+
"get_default_chat_publisher",
|
|
31
|
+
]
|
pubsub_sdk/chat.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""ChatMessage: the wire schema for a direct instruction/reply exchange between
|
|
2
|
+
two agent instances (e.g. MissionCommander delegating to a field agent) over
|
|
3
|
+
Pub/Sub, as an alternative transport to a live MCP/SSE connection.
|
|
4
|
+
|
|
5
|
+
Both directions of one exchange ride the same fixed topic
|
|
6
|
+
(PubSubConfig.chat_topic_id, default "agent-chat-messages") and are
|
|
7
|
+
distinguished by `role` (ChatMessageRole.INSTRUCTION / .REPLY), correlated by
|
|
8
|
+
`message_id`. Routing to the right recipient is a Pub/Sub subscription filter
|
|
9
|
+
on the `to_agent` attribute - each participating agent INSTANCE self-provisions
|
|
10
|
+
its own filtered subscription, keyed by its session_id (never its persona-wide
|
|
11
|
+
agent_id - see ChatMessageSubscriber).
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import asdict, dataclass
|
|
16
|
+
from typing import Any, Dict, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
from .client import GenericPublisher, GenericSubscriber
|
|
19
|
+
from .config import PubSubConfig
|
|
20
|
+
|
|
21
|
+
# Two ephemeral instances of the same persona (e.g. two in-flight
|
|
22
|
+
# MissionCommander missions) can run concurrently, each spawned with its own
|
|
23
|
+
# unique session_id - subscriptions live for as long as any single instance
|
|
24
|
+
# does, so this is a generous safety-net TTL, not a tuning knob for latency.
|
|
25
|
+
_CHAT_SUBSCRIPTION_TTL_SECONDS = 172800 # 48h
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ChatMessageRole:
|
|
29
|
+
INSTRUCTION = "instruction"
|
|
30
|
+
REPLY = "reply"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ChatMessage:
|
|
35
|
+
message_id: str
|
|
36
|
+
from_agent: str
|
|
37
|
+
to_agent: str
|
|
38
|
+
role: str
|
|
39
|
+
content: str
|
|
40
|
+
activity_id: Optional[str] = None
|
|
41
|
+
|
|
42
|
+
def to_pubsub_message(self) -> Tuple[bytes, Dict[str, str]]:
|
|
43
|
+
body = asdict(self)
|
|
44
|
+
data = json.dumps(body).encode("utf-8")
|
|
45
|
+
attributes = {
|
|
46
|
+
"message_id": self.message_id,
|
|
47
|
+
"from_agent": self.from_agent,
|
|
48
|
+
"to_agent": self.to_agent,
|
|
49
|
+
"role": self.role,
|
|
50
|
+
}
|
|
51
|
+
return data, attributes
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_pubsub_message(cls, data: bytes, attributes: Optional[Dict[str, str]] = None) -> "ChatMessage":
|
|
55
|
+
body = json.loads(data.decode("utf-8"))
|
|
56
|
+
return cls(
|
|
57
|
+
message_id=body["message_id"],
|
|
58
|
+
from_agent=body["from_agent"],
|
|
59
|
+
to_agent=body["to_agent"],
|
|
60
|
+
role=body["role"],
|
|
61
|
+
content=body["content"],
|
|
62
|
+
activity_id=body.get("activity_id"),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ChatMessagePublisher(GenericPublisher[ChatMessage]):
|
|
67
|
+
def __init__(self, config: Optional[PubSubConfig] = None):
|
|
68
|
+
cfg = config or PubSubConfig.from_env()
|
|
69
|
+
super().__init__(config=cfg, topic_id=cfg.chat_topic_id, label="ChatMessagePublisher")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_default_chat_publisher: Optional[ChatMessagePublisher] = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_default_chat_publisher(config: Optional[PubSubConfig] = None) -> ChatMessagePublisher:
|
|
76
|
+
"""Process-wide singleton, mirroring get_default_publisher(): one long-lived
|
|
77
|
+
Pub/Sub client (and its gRPC channel) per process, shared by every attached
|
|
78
|
+
chat channel, instead of one per agent instance with nothing to close it."""
|
|
79
|
+
global _default_chat_publisher
|
|
80
|
+
if _default_chat_publisher is None:
|
|
81
|
+
_default_chat_publisher = ChatMessagePublisher(config=config)
|
|
82
|
+
return _default_chat_publisher
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _sanitize_subscription_id(routing_identity: str) -> str:
|
|
86
|
+
"""Pub/Sub subscription IDs allow only [A-Za-z0-9-_.~+%]; session_id can
|
|
87
|
+
contain a platform task_id of unknown format, so sanitize rather than
|
|
88
|
+
assume it's already safe."""
|
|
89
|
+
return re.sub(r"[^A-Za-z0-9\-_.~+%]", "-", routing_identity)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ChatMessageSubscriber(GenericSubscriber[ChatMessage]):
|
|
93
|
+
"""One per attached agent INSTANCE (never shared across instances, unlike
|
|
94
|
+
ChatMessagePublisher) - filtered on `to_agent = routing_identity`, where
|
|
95
|
+
routing_identity is that instance's session_id. Self-provisions against
|
|
96
|
+
real GCP (not just the emulator), since a per-instance subscription's
|
|
97
|
+
identity only exists at runtime and can't be pre-provisioned by infra the
|
|
98
|
+
way agent-step-events' fixed subscription is."""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
routing_identity: str,
|
|
103
|
+
config: Optional[PubSubConfig] = None,
|
|
104
|
+
expiration_ttl_seconds: int = _CHAT_SUBSCRIPTION_TTL_SECONDS,
|
|
105
|
+
):
|
|
106
|
+
cfg = config or PubSubConfig.from_env()
|
|
107
|
+
self.routing_identity = routing_identity
|
|
108
|
+
subscription_id = f"agent-chat-{_sanitize_subscription_id(routing_identity)}"
|
|
109
|
+
super().__init__(
|
|
110
|
+
config=cfg,
|
|
111
|
+
topic_id=cfg.chat_topic_id,
|
|
112
|
+
subscription_id=subscription_id,
|
|
113
|
+
message_cls=ChatMessage,
|
|
114
|
+
label=f"ChatMessageSubscriber[{routing_identity}]",
|
|
115
|
+
filter_expr=f'attributes.to_agent = "{routing_identity}"',
|
|
116
|
+
expiration_ttl_seconds=expiration_ttl_seconds,
|
|
117
|
+
always_self_provision=True,
|
|
118
|
+
)
|
pubsub_sdk/client.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""Generic Pub/Sub publish/subscribe core, extracted from the original
|
|
2
|
+
StepEventPublisher/StepEventSubscriber so a second message type (chat, see
|
|
3
|
+
chat.py) doesn't duplicate the same emulator-retry/error-logging/ack-nack
|
|
4
|
+
machinery a second time. StepEventPublisher/StepEventSubscriber become thin
|
|
5
|
+
subclasses with zero API change - this refactor is behavior-preserving by
|
|
6
|
+
construction (see test_publisher.py/test_subscriber.py, unmodified).
|
|
7
|
+
|
|
8
|
+
Follows this org's existing degrade-gracefully-and-log-loudly philosophy (see
|
|
9
|
+
ActivityLoggingService): never raises past its own boundary, always logs
|
|
10
|
+
failures with context server-side, and reports its own availability via
|
|
11
|
+
is_available().
|
|
12
|
+
|
|
13
|
+
The real google-cloud-pubsub client's publish()/subscribe() calls never raise
|
|
14
|
+
NotFound synchronously - they always return a future immediately and defer
|
|
15
|
+
the RPC's own errors to it (confirmed against a real Pub/Sub emulator, not
|
|
16
|
+
just mocks). Every NotFound-driven retry below reacts to the future
|
|
17
|
+
resolving, not to publish()/subscribe() itself raising.
|
|
18
|
+
"""
|
|
19
|
+
import logging
|
|
20
|
+
import threading
|
|
21
|
+
from typing import Any, Callable, Dict, Generic, Optional, Protocol, Tuple, Type, TypeVar
|
|
22
|
+
|
|
23
|
+
from google.api_core.exceptions import AlreadyExists, NotFound
|
|
24
|
+
from google.cloud import pubsub_v1
|
|
25
|
+
|
|
26
|
+
from .config import PubSubConfig
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PubsubMessage(Protocol):
|
|
32
|
+
"""Structural contract any message type (StepEvent, ChatMessage, ...) must
|
|
33
|
+
satisfy to ride on GenericPublisher/GenericSubscriber."""
|
|
34
|
+
|
|
35
|
+
def to_pubsub_message(self) -> Tuple[bytes, Dict[str, str]]: ...
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_pubsub_message(cls, data: bytes, attributes: Optional[Dict[str, str]] = None) -> "PubsubMessage": ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
T = TypeVar("T", bound=PubsubMessage)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class GenericPublisher(Generic[T]):
|
|
45
|
+
"""Framework-agnostic publish core. A subclass fixes the topic_id and
|
|
46
|
+
message type; everything else (client construction, degrade-gracefully
|
|
47
|
+
behavior, emulator-only topic auto-create, async error logging) lives
|
|
48
|
+
here once."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, config: Optional[PubSubConfig], topic_id: str, label: str):
|
|
51
|
+
self.config = config or PubSubConfig.from_env()
|
|
52
|
+
self._topic_id = topic_id
|
|
53
|
+
self._label = label
|
|
54
|
+
self._client: Optional[pubsub_v1.PublisherClient] = None
|
|
55
|
+
self._topic_path: Optional[str] = None
|
|
56
|
+
self._topic_create_attempted = False
|
|
57
|
+
# Guards _topic_create_attempted and _in_flight - publish()'s
|
|
58
|
+
# done-callbacks fire on a background gRPC thread, concurrently with
|
|
59
|
+
# the caller's own thread and with each other. _in_flight counts
|
|
60
|
+
# logical publish attempts still in progress (an original publish
|
|
61
|
+
# plus its one possible auto-create retry count as a single unit,
|
|
62
|
+
# decremented only once we're truly done with it) - _drained is set
|
|
63
|
+
# exactly when that count reaches zero, so close() has one thing to
|
|
64
|
+
# wait on without racing a snapshot of individual futures (an
|
|
65
|
+
# earlier version of this fix took a one-time snapshot of pending
|
|
66
|
+
# futures, which missed a retry future created moments later and
|
|
67
|
+
# tore the client down while the retry was still using it).
|
|
68
|
+
self._lock = threading.Lock()
|
|
69
|
+
self._in_flight = 0
|
|
70
|
+
self._drained = threading.Event()
|
|
71
|
+
self._drained.set()
|
|
72
|
+
|
|
73
|
+
if not self.config.enabled:
|
|
74
|
+
logger.info(f"{label} disabled via PUBSUB_SDK_ENABLED=false.")
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
self._client = pubsub_v1.PublisherClient()
|
|
79
|
+
self._topic_path = self._client.topic_path(self.config.project_id, topic_id)
|
|
80
|
+
logger.info(
|
|
81
|
+
f"{label} initialized (project={self.config.project_id}, "
|
|
82
|
+
f"topic={topic_id}) targeting: {self.config.target_label}"
|
|
83
|
+
)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
self._client = None
|
|
86
|
+
self._topic_path = None
|
|
87
|
+
logger.warning(f"{label} initialization failed: {e}. Publishing will be disabled.")
|
|
88
|
+
|
|
89
|
+
def is_available(self) -> bool:
|
|
90
|
+
return self._client is not None and self._topic_path is not None
|
|
91
|
+
|
|
92
|
+
def publish(self, message: T) -> bool:
|
|
93
|
+
"""Fire-and-forget: returns True as soon as the message is handed to
|
|
94
|
+
the client for sending, not once delivery is actually confirmed - a
|
|
95
|
+
short-lived process (a CLI script, a one-shot Cloud Function) that
|
|
96
|
+
exits right after calling this can lose the message before it's
|
|
97
|
+
actually sent over the wire. Call close() before such a process
|
|
98
|
+
exits; it blocks until every in-flight publish from this instance is
|
|
99
|
+
confirmed sent (or fails)."""
|
|
100
|
+
if not self.is_available():
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
data, attributes = message.to_pubsub_message()
|
|
104
|
+
with self._lock:
|
|
105
|
+
self._in_flight += 1
|
|
106
|
+
self._drained.clear()
|
|
107
|
+
|
|
108
|
+
future = self._publish_once(data, attributes)
|
|
109
|
+
if future is None:
|
|
110
|
+
self._mark_publish_done()
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
future.add_done_callback(lambda fut: self._on_publish_done(fut, data, attributes, is_retry=False))
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
def _publish_once(self, data: bytes, attributes: Dict[str, str]):
|
|
117
|
+
try:
|
|
118
|
+
return self._client.publish(self._topic_path, data, **attributes)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.error(f"{self._label}.publish: publish call failed: {e}")
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
def _mark_publish_done(self) -> None:
|
|
124
|
+
"""Decrements _in_flight for one logical publish attempt (an
|
|
125
|
+
original publish plus its one possible auto-create retry share a
|
|
126
|
+
single count, so this fires exactly once per publish() call, however
|
|
127
|
+
many underlying Pub/Sub RPCs it took)."""
|
|
128
|
+
with self._lock:
|
|
129
|
+
self._in_flight = max(self._in_flight - 1, 0)
|
|
130
|
+
if self._in_flight == 0:
|
|
131
|
+
self._drained.set()
|
|
132
|
+
|
|
133
|
+
def _on_publish_done(self, fut: Any, data: bytes, attributes: Dict[str, str], is_retry: bool) -> None:
|
|
134
|
+
exc = fut.exception()
|
|
135
|
+
if exc is None:
|
|
136
|
+
self._mark_publish_done()
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
if not is_retry and isinstance(exc, NotFound) and self._should_attempt_topic_autocreate():
|
|
140
|
+
logger.info(f"{self._label}.publish: topic not found, auto-creating and retrying (emulator only).")
|
|
141
|
+
if self._ensure_topic_exists():
|
|
142
|
+
retry_future = self._publish_once(data, attributes)
|
|
143
|
+
if retry_future is not None:
|
|
144
|
+
# Still in flight under the same count this original
|
|
145
|
+
# future held - do not mark done until the retry itself
|
|
146
|
+
# resolves, so close() never sees "nothing pending"
|
|
147
|
+
# while a retry is actually in progress using self._client.
|
|
148
|
+
retry_future.add_done_callback(
|
|
149
|
+
lambda f: self._on_publish_done(f, data, attributes, is_retry=True)
|
|
150
|
+
)
|
|
151
|
+
return
|
|
152
|
+
logger.error(f"{self._label}.publish: retry after topic auto-create failed: {exc}")
|
|
153
|
+
self._mark_publish_done()
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
logger.error(f"{self._label}.publish: async publish failed: {exc}")
|
|
157
|
+
self._mark_publish_done()
|
|
158
|
+
|
|
159
|
+
def _should_attempt_topic_autocreate(self) -> bool:
|
|
160
|
+
"""Memoized: only ever attempt auto-create once per process, not once
|
|
161
|
+
per publish() call - otherwise a misconfigured emulator target pays
|
|
162
|
+
two RPCs (failed publish + failed create_topic) on every single
|
|
163
|
+
message forever. Never true against real GCP - production topics are
|
|
164
|
+
provisioned by infra ahead of time."""
|
|
165
|
+
if not self.config.emulator_host:
|
|
166
|
+
return False
|
|
167
|
+
with self._lock:
|
|
168
|
+
if self._topic_create_attempted:
|
|
169
|
+
return False
|
|
170
|
+
self._topic_create_attempted = True
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
def _ensure_topic_exists(self) -> bool:
|
|
174
|
+
"""Emulator-only convenience: auto-create the topic if it's missing.
|
|
175
|
+
Only ever called after _should_attempt_topic_autocreate() has already
|
|
176
|
+
confirmed we're targeting the emulator. Tolerates AlreadyExists so a
|
|
177
|
+
second concurrent publish racing this same auto-create doesn't log a
|
|
178
|
+
false failure for a topic that in fact now exists."""
|
|
179
|
+
try:
|
|
180
|
+
self._client.create_topic(name=self._topic_path)
|
|
181
|
+
logger.info(f"Auto-created emulator topic: {self._topic_path}")
|
|
182
|
+
return True
|
|
183
|
+
except AlreadyExists:
|
|
184
|
+
logger.info(f"Emulator topic already exists (reused): {self._topic_path}")
|
|
185
|
+
return True
|
|
186
|
+
except Exception as e:
|
|
187
|
+
logger.error(f"Failed to auto-create emulator topic {self._topic_path}: {e}")
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
def close(self, timeout: float = 10.0) -> None:
|
|
191
|
+
"""Blocks until every in-flight publish from this instance is
|
|
192
|
+
confirmed sent or fails (up to timeout), then shuts the client down.
|
|
193
|
+
Call this before a short-lived process exits - see publish()'s own
|
|
194
|
+
docstring for why. If timeout elapses with publishes still
|
|
195
|
+
in-flight, logs a warning and tears the client down anyway rather
|
|
196
|
+
than blocking forever - a since-abandoned in-flight retry attempting
|
|
197
|
+
to use self._client afterward fails cleanly (caught and logged by
|
|
198
|
+
_publish_once/_ensure_topic_exists) rather than raising out of a
|
|
199
|
+
background thread."""
|
|
200
|
+
if self._client is not None:
|
|
201
|
+
if not self._drained.wait(timeout=timeout):
|
|
202
|
+
with self._lock:
|
|
203
|
+
remaining = self._in_flight
|
|
204
|
+
logger.warning(f"{self._label}.close: timed out with {remaining} publish(es) still in flight.")
|
|
205
|
+
try:
|
|
206
|
+
# PublisherClient has no close() - stop() is its equivalent
|
|
207
|
+
# (asynchronously flushes outstanding messages and prevents
|
|
208
|
+
# further publish() calls; the wait above is what actually
|
|
209
|
+
# guarantees delivery, stop() alone does not block for it).
|
|
210
|
+
self._client.stop()
|
|
211
|
+
except Exception as e:
|
|
212
|
+
logger.warning(f"{self._label}.close: error stopping client: {e}")
|
|
213
|
+
self._client = None
|
|
214
|
+
self._topic_path = None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class GenericSubscriber(Generic[T]):
|
|
218
|
+
"""Framework-agnostic pull-consumer core. A subclass fixes the topic/
|
|
219
|
+
subscription ids and message type; `always_self_provision` controls
|
|
220
|
+
whether the subscription is created eagerly (and against real GCP, not
|
|
221
|
+
just the emulator) - StepEventSubscriber keeps today's emulator-only,
|
|
222
|
+
lazy-on-NotFound behavior (always_self_provision=False); a per-instance
|
|
223
|
+
subscriber whose identity only exists at runtime (e.g. chat) opts in."""
|
|
224
|
+
|
|
225
|
+
def __init__(
|
|
226
|
+
self,
|
|
227
|
+
config: Optional[PubSubConfig],
|
|
228
|
+
topic_id: str,
|
|
229
|
+
subscription_id: str,
|
|
230
|
+
message_cls: Type[T],
|
|
231
|
+
label: str,
|
|
232
|
+
filter_expr: Optional[str] = None,
|
|
233
|
+
expiration_ttl_seconds: Optional[int] = None,
|
|
234
|
+
always_self_provision: bool = False,
|
|
235
|
+
):
|
|
236
|
+
self.config = config or PubSubConfig.from_env()
|
|
237
|
+
self._topic_id = topic_id
|
|
238
|
+
self._subscription_id = subscription_id
|
|
239
|
+
self._message_cls = message_cls
|
|
240
|
+
self._label = label
|
|
241
|
+
self._filter_expr = filter_expr
|
|
242
|
+
self._expiration_ttl_seconds = expiration_ttl_seconds
|
|
243
|
+
self._always_self_provision = always_self_provision
|
|
244
|
+
self._client: Optional[pubsub_v1.SubscriberClient] = None
|
|
245
|
+
self._subscription_path: Optional[str] = None
|
|
246
|
+
self._topic_path: Optional[str] = None
|
|
247
|
+
self._subscription_confirmed = False
|
|
248
|
+
self._active_future = None # the StreamingPullFuture from subscribe(), for stop()
|
|
249
|
+
|
|
250
|
+
if not self.config.enabled:
|
|
251
|
+
logger.info(f"{label} disabled via PUBSUB_SDK_ENABLED=false.")
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
self._client = pubsub_v1.SubscriberClient()
|
|
256
|
+
self._subscription_path = self._client.subscription_path(self.config.project_id, subscription_id)
|
|
257
|
+
self._topic_path = self._client.topic_path(self.config.project_id, topic_id)
|
|
258
|
+
logger.info(
|
|
259
|
+
f"{label} initialized (project={self.config.project_id}, "
|
|
260
|
+
f"subscription={subscription_id}) targeting: {self.config.target_label}"
|
|
261
|
+
)
|
|
262
|
+
except Exception as e:
|
|
263
|
+
self._client = None
|
|
264
|
+
self._subscription_path = None
|
|
265
|
+
logger.warning(f"{label} initialization failed: {e}. Subscribing will be disabled.")
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
if always_self_provision:
|
|
269
|
+
self._subscription_confirmed = self._ensure_subscription_exists()
|
|
270
|
+
|
|
271
|
+
def is_available(self) -> bool:
|
|
272
|
+
base_available = self._client is not None and self._subscription_path is not None
|
|
273
|
+
if self._always_self_provision:
|
|
274
|
+
return base_available and self._subscription_confirmed
|
|
275
|
+
return base_available
|
|
276
|
+
|
|
277
|
+
def _ensure_subscription_exists(self) -> bool:
|
|
278
|
+
"""For StepEventSubscriber (always_self_provision=False): emulator-only
|
|
279
|
+
convenience, never fires against real GCP - production subscriptions are
|
|
280
|
+
provisioned by infra ahead of time. For a subscriber that opted into
|
|
281
|
+
always_self_provision (e.g. chat): fires against real GCP too, since a
|
|
282
|
+
per-instance subscription's identity only exists at runtime and can't be
|
|
283
|
+
pre-provisioned; tolerates AlreadyExists so a restarted process finds its
|
|
284
|
+
own prior subscription instead of erroring."""
|
|
285
|
+
try:
|
|
286
|
+
# create_subscription()'s real signature only accepts name/topic/
|
|
287
|
+
# push_config/ack_deadline_seconds as direct keyword arguments - filter
|
|
288
|
+
# and expiration_policy are Subscription fields only reachable via the
|
|
289
|
+
# request= dict. A bare filter=/expiration_policy= kwarg raises
|
|
290
|
+
# TypeError against the real client; a MagicMock in a unit test
|
|
291
|
+
# wouldn't catch that mismatch, which is exactly how this went
|
|
292
|
+
# unnoticed until a real-GCP run surfaced it.
|
|
293
|
+
request: Dict[str, Any] = {"name": self._subscription_path, "topic": self._topic_path}
|
|
294
|
+
if self._filter_expr:
|
|
295
|
+
request["filter"] = self._filter_expr
|
|
296
|
+
if self._expiration_ttl_seconds:
|
|
297
|
+
request["expiration_policy"] = {"ttl": {"seconds": self._expiration_ttl_seconds}}
|
|
298
|
+
self._client.create_subscription(request=request)
|
|
299
|
+
logger.info(f"Auto-created subscription: {self._subscription_path}")
|
|
300
|
+
return True
|
|
301
|
+
except AlreadyExists:
|
|
302
|
+
logger.info(f"Subscription already exists (reused): {self._subscription_path}")
|
|
303
|
+
return True
|
|
304
|
+
except Exception as e:
|
|
305
|
+
logger.error(f"Failed to auto-create subscription {self._subscription_path}: {e}")
|
|
306
|
+
return False
|
|
307
|
+
|
|
308
|
+
def pull_forever(self, on_event: Callable[[T], None]) -> None:
|
|
309
|
+
"""Blocking pull loop. Acks each message only after on_event() returns without
|
|
310
|
+
raising; an exception from on_event() nacks the message so Pub/Sub redelivers
|
|
311
|
+
it instead of silently dropping a message the consumer failed to process.
|
|
312
|
+
|
|
313
|
+
A missing subscription surfaces only once the StreamingPullFuture
|
|
314
|
+
subscribe() returns actually resolves/raises (via future.result()),
|
|
315
|
+
never synchronously from subscribe() itself - the real client always
|
|
316
|
+
starts the stream and defers the RPC's own errors to that future."""
|
|
317
|
+
if not self.is_available():
|
|
318
|
+
logger.error(f"{self._label}.pull_forever: subscriber unavailable, not starting.")
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
def _callback(message) -> None:
|
|
322
|
+
try:
|
|
323
|
+
event = self._message_cls.from_pubsub_message(message.data, dict(message.attributes))
|
|
324
|
+
on_event(event)
|
|
325
|
+
message.ack()
|
|
326
|
+
except Exception as e:
|
|
327
|
+
logger.error(f"{self._label}.pull_forever: handler failed for message, nacking for redelivery: {e}")
|
|
328
|
+
message.nack()
|
|
329
|
+
|
|
330
|
+
subscription_create_attempted = False
|
|
331
|
+
|
|
332
|
+
while True:
|
|
333
|
+
try:
|
|
334
|
+
future = self._client.subscribe(self._subscription_path, callback=_callback)
|
|
335
|
+
except Exception as e:
|
|
336
|
+
logger.error(f"{self._label}.pull_forever: failed to start streaming pull: {e}")
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
self._active_future = future
|
|
340
|
+
try:
|
|
341
|
+
future.result()
|
|
342
|
+
return # normal completion (e.g. a clean stop() cancelled it)
|
|
343
|
+
except NotFound as e:
|
|
344
|
+
if subscription_create_attempted or not self._should_retry_subscribe():
|
|
345
|
+
logger.error(f"{self._label}.pull_forever: subscription {self._subscription_path} not found: {e}")
|
|
346
|
+
return
|
|
347
|
+
subscription_create_attempted = True
|
|
348
|
+
if not self._ensure_subscription_exists():
|
|
349
|
+
return
|
|
350
|
+
continue # subscribe() again now that the subscription (should) exist
|
|
351
|
+
except Exception as e:
|
|
352
|
+
logger.error(f"{self._label}.pull_forever: streaming pull ended: {e}")
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
def _should_retry_subscribe(self) -> bool:
|
|
356
|
+
"""True if a missing subscription is worth one auto-create-and-retry
|
|
357
|
+
attempt: always for a self-provisioning subscriber (eagerly
|
|
358
|
+
provisioned at construction already - a NotFound here means that
|
|
359
|
+
genuinely failed, or the subscription was deleted out from under a
|
|
360
|
+
long-lived process mid-run), otherwise only against the emulator -
|
|
361
|
+
production subscriptions are provisioned by infra ahead of time."""
|
|
362
|
+
return self._always_self_provision or bool(self.config.emulator_host)
|
|
363
|
+
|
|
364
|
+
def stop(self) -> None:
|
|
365
|
+
"""Cancels an in-progress pull_forever() streaming pull, if any, so its
|
|
366
|
+
background thread's blocking future.result() call returns. Idempotent."""
|
|
367
|
+
if self._active_future is not None:
|
|
368
|
+
try:
|
|
369
|
+
self._active_future.cancel()
|
|
370
|
+
except Exception as e:
|
|
371
|
+
logger.warning(f"{self._label}.stop: error cancelling streaming pull: {e}")
|
|
372
|
+
|
|
373
|
+
def close(self) -> None:
|
|
374
|
+
if self._client is not None:
|
|
375
|
+
try:
|
|
376
|
+
self._client.close()
|
|
377
|
+
except Exception as e:
|
|
378
|
+
logger.warning(f"{self._label}.close: error closing client: {e}")
|
|
379
|
+
self._client = None
|
|
380
|
+
self._subscription_path = None
|
|
381
|
+
self._topic_path = None
|
pubsub_sdk/config.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Environment-driven configuration for pubsub_sdk, following this org's existing
|
|
2
|
+
GCP_PROJECT_ID / *_EMULATOR_HOST conventions (see ActivityLoggingService)."""
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _env_bool(name: str, default: bool) -> bool:
|
|
9
|
+
value = os.environ.get(name)
|
|
10
|
+
if value is None:
|
|
11
|
+
return default
|
|
12
|
+
return value.strip().lower() in ("1", "true", "yes", "on")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class PubSubConfig:
|
|
17
|
+
project_id: str
|
|
18
|
+
topic_id: str
|
|
19
|
+
subscription_id: str
|
|
20
|
+
enabled: bool
|
|
21
|
+
chat_topic_id: str = "agent-chat-messages"
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_env(cls) -> "PubSubConfig":
|
|
25
|
+
return cls(
|
|
26
|
+
project_id=os.environ.get("GCP_PROJECT_ID", "test"),
|
|
27
|
+
topic_id=os.environ.get("PUBSUB_SDK_TOPIC_ID", "agent-step-events"),
|
|
28
|
+
subscription_id=os.environ.get(
|
|
29
|
+
"PUBSUB_SDK_SUBSCRIPTION_ID", "agent-step-events-firestore-persister"
|
|
30
|
+
),
|
|
31
|
+
enabled=_env_bool("PUBSUB_SDK_ENABLED", True),
|
|
32
|
+
chat_topic_id=os.environ.get("PUBSUB_SDK_CHAT_TOPIC_ID", "agent-chat-messages"),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def emulator_host(self) -> Optional[str]:
|
|
37
|
+
return os.environ.get("PUBSUB_EMULATOR_HOST")
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def target_label(self) -> str:
|
|
41
|
+
"""Human-readable label for log lines, mirroring ActivityLoggingService's
|
|
42
|
+
EMULATOR/CLOUD distinction."""
|
|
43
|
+
return "EMULATOR" if self.emulator_host else "CLOUD"
|
pubsub_sdk/events.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""StepEvent: the wire schema published to / consumed from Pub/Sub.
|
|
2
|
+
|
|
3
|
+
Field names (activity_id, agent_id, event_type, message, status, metadata) are
|
|
4
|
+
deliberately identical to ActivityLoggingService.log_event()'s signature in the
|
|
5
|
+
crowddrop_ai_agents fastapi app, so a consumer reading both sinks isn't reconciling
|
|
6
|
+
two different vocabularies for the same concept.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
from dataclasses import asdict, dataclass, field
|
|
10
|
+
from typing import Any, Dict, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class StepEvent:
|
|
15
|
+
activity_id: str
|
|
16
|
+
agent_id: str
|
|
17
|
+
event_type: str
|
|
18
|
+
message: str
|
|
19
|
+
status: str = "in_progress"
|
|
20
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
sequence: Optional[int] = None
|
|
22
|
+
|
|
23
|
+
def to_pubsub_message(self) -> Tuple[bytes, Dict[str, str]]:
|
|
24
|
+
"""JSON body + string attributes, so a subscriber can filter by
|
|
25
|
+
activity_id/agent_id/event_type/status without deserializing the body."""
|
|
26
|
+
body = asdict(self)
|
|
27
|
+
data = json.dumps(body).encode("utf-8")
|
|
28
|
+
attributes = {
|
|
29
|
+
"activity_id": self.activity_id,
|
|
30
|
+
"agent_id": self.agent_id,
|
|
31
|
+
"event_type": self.event_type,
|
|
32
|
+
"status": self.status,
|
|
33
|
+
}
|
|
34
|
+
return data, attributes
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_pubsub_message(cls, data: bytes, attributes: Optional[Dict[str, str]] = None) -> "StepEvent":
|
|
38
|
+
body = json.loads(data.decode("utf-8"))
|
|
39
|
+
return cls(
|
|
40
|
+
activity_id=body["activity_id"],
|
|
41
|
+
agent_id=body["agent_id"],
|
|
42
|
+
event_type=body["event_type"],
|
|
43
|
+
message=body["message"],
|
|
44
|
+
status=body.get("status", "in_progress"),
|
|
45
|
+
metadata=body.get("metadata") or {},
|
|
46
|
+
sequence=body.get("sequence"),
|
|
47
|
+
)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""LangChain callback adapter. The ONLY module in pubsub_sdk that imports langchain —
|
|
2
|
+
a consumer who only wants StepEventPublisher/StepEventSubscriber never pulls this
|
|
3
|
+
dependency in (see the `langchain` optional extra in pyproject.toml).
|
|
4
|
+
|
|
5
|
+
Method names deliberately mirror FirestoreCallbackHandler's own surface
|
|
6
|
+
(log_mission_start/complete/error, log_directive, _log_event) so a caller holding
|
|
7
|
+
either kind of handler can call the same method name on it without isinstance
|
|
8
|
+
branching.
|
|
9
|
+
"""
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
14
|
+
|
|
15
|
+
from .config import PubSubConfig
|
|
16
|
+
from .publisher import StepEventPublisher, get_default_publisher
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _preview(value: Any, limit: int = 500) -> str:
|
|
22
|
+
"""Truncate a value for a log/metadata preview without paying the cost of fully
|
|
23
|
+
materializing a large non-str object first. A plain str is sliced directly (it's
|
|
24
|
+
already in memory, so this is cheap); a container (dict/list/tuple) - which is
|
|
25
|
+
where a large embedded payload such as a base64 image data URI would live - gets
|
|
26
|
+
only a type+length summary instead of a fully-serialized copy, since calling
|
|
27
|
+
str()/repr() on it would render the entire structure just to discard most of it."""
|
|
28
|
+
if isinstance(value, str):
|
|
29
|
+
return value[:limit]
|
|
30
|
+
if isinstance(value, (dict, list, tuple)):
|
|
31
|
+
return f"<{type(value).__name__} len={len(value)}>"
|
|
32
|
+
return str(value)[:limit]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class StepEventCallbackHandler(BaseCallbackHandler):
|
|
36
|
+
"""Publishes every LLM/tool/chain/agent lifecycle event to Pub/Sub automatically —
|
|
37
|
+
no LLM cooperation required. Each event carries a code-level `sequence` ordinal
|
|
38
|
+
(incremented here, never LLM-supplied): MissionCommander's own tool calls don't
|
|
39
|
+
map 1:1 onto its persona's numbered directives (several directives share one tool
|
|
40
|
+
name, others make no tool call at all), so there is no real directive number to
|
|
41
|
+
report automatically. This ordinal is the honest, zero-prose ordering signal
|
|
42
|
+
instead of a fabricated directive number.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
activity_id: str,
|
|
48
|
+
agent_id: str,
|
|
49
|
+
config: Optional[PubSubConfig] = None,
|
|
50
|
+
publisher: Optional[StepEventPublisher] = None,
|
|
51
|
+
):
|
|
52
|
+
super().__init__()
|
|
53
|
+
self.activity_id = activity_id
|
|
54
|
+
self.agent_id = agent_id
|
|
55
|
+
# Reuses a process-wide singleton client (get_default_publisher) rather than
|
|
56
|
+
# constructing a fresh PublisherClient/gRPC channel per mission, which would
|
|
57
|
+
# otherwise leak one per mission over the life of a long-running process.
|
|
58
|
+
self.publisher = publisher or get_default_publisher(config=config)
|
|
59
|
+
self._sequence = 0
|
|
60
|
+
|
|
61
|
+
if self.publisher.is_available():
|
|
62
|
+
logger.info(f"StepEventCallbackHandler ready for activity: {activity_id} (agent: {agent_id})")
|
|
63
|
+
else:
|
|
64
|
+
logger.info(
|
|
65
|
+
f"StepEventCallbackHandler initialized but Pub/Sub unavailable for "
|
|
66
|
+
f"{agent_id} (activity: {activity_id}). Publishing will be disabled."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def _next_sequence(self) -> int:
|
|
70
|
+
self._sequence += 1
|
|
71
|
+
return self._sequence
|
|
72
|
+
|
|
73
|
+
def _log_event(
|
|
74
|
+
self,
|
|
75
|
+
event_type: str,
|
|
76
|
+
message: str,
|
|
77
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
78
|
+
status: str = "in_progress",
|
|
79
|
+
) -> None:
|
|
80
|
+
if not self.publisher.is_available():
|
|
81
|
+
return
|
|
82
|
+
try:
|
|
83
|
+
self.publisher.publish_step(
|
|
84
|
+
activity_id=self.activity_id,
|
|
85
|
+
agent_id=self.agent_id,
|
|
86
|
+
event_type=event_type,
|
|
87
|
+
message=message,
|
|
88
|
+
status=status,
|
|
89
|
+
metadata=metadata,
|
|
90
|
+
sequence=self._next_sequence(),
|
|
91
|
+
)
|
|
92
|
+
except Exception as e:
|
|
93
|
+
# Never let a logging failure break the agent's own execution.
|
|
94
|
+
logger.error(f"StepEventCallbackHandler: failed to publish {event_type} for {self.activity_id}: {e}")
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
# LangChain lifecycle hooks — fire automatically, zero LLM cooperation
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def on_llm_start(self, serialized: Dict[str, Any], prompts: List[str], **kwargs: Any) -> None:
|
|
101
|
+
self._log_event(
|
|
102
|
+
event_type="llm_start",
|
|
103
|
+
message=f"LLM invocation started with {len(prompts)} prompt(s)",
|
|
104
|
+
metadata={"model": serialized.get("name", "unknown"), "prompts_count": len(prompts)},
|
|
105
|
+
status="in_progress",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def on_llm_end(self, response, **kwargs: Any) -> None:
|
|
109
|
+
self._log_event(
|
|
110
|
+
event_type="llm_end",
|
|
111
|
+
message="LLM invocation completed",
|
|
112
|
+
metadata={"generations": len(response.generations) if response.generations else 0},
|
|
113
|
+
status="success",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def on_llm_error(self, error: BaseException, **kwargs: Any) -> None:
|
|
117
|
+
self._log_event(
|
|
118
|
+
event_type="llm_error",
|
|
119
|
+
message=f"LLM error: {str(error)}",
|
|
120
|
+
metadata={"exception_type": type(error).__name__, "exception_details": str(error)},
|
|
121
|
+
status="error",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def on_tool_start(self, serialized: Dict[str, Any], input_str: str, **kwargs: Any) -> None:
|
|
125
|
+
tool_name = serialized.get("name", "unknown_tool")
|
|
126
|
+
self._log_event(
|
|
127
|
+
event_type="tool_start",
|
|
128
|
+
message=f"Invoking tool: {tool_name}",
|
|
129
|
+
metadata={"tool_name": tool_name, "tool_input": _preview(input_str)},
|
|
130
|
+
status="in_progress",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def on_tool_end(self, output: Any, **kwargs: Any) -> None:
|
|
134
|
+
self._log_event(
|
|
135
|
+
event_type="tool_end",
|
|
136
|
+
message="Tool execution completed",
|
|
137
|
+
metadata={"output_preview": _preview(output)},
|
|
138
|
+
status="success",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def on_tool_error(self, error: BaseException, **kwargs: Any) -> None:
|
|
142
|
+
self._log_event(
|
|
143
|
+
event_type="tool_error",
|
|
144
|
+
message=f"Tool error: {str(error)}",
|
|
145
|
+
metadata={"exception_type": type(error).__name__, "exception_details": str(error)},
|
|
146
|
+
status="error",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def on_agent_action(self, action, **kwargs: Any) -> None:
|
|
150
|
+
action_log = action.log[:500] if hasattr(action, "log") else None
|
|
151
|
+
self._log_event(
|
|
152
|
+
event_type="agent_action",
|
|
153
|
+
message=f"Agent decision: {action.tool}",
|
|
154
|
+
metadata={
|
|
155
|
+
"tool": action.tool,
|
|
156
|
+
"tool_input": _preview(action.tool_input),
|
|
157
|
+
"log": action_log,
|
|
158
|
+
},
|
|
159
|
+
status="in_progress",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def on_agent_finish(self, finish, **kwargs: Any) -> None:
|
|
163
|
+
output = finish.return_values.get("output", "") if hasattr(finish, "return_values") else ""
|
|
164
|
+
self._log_event(
|
|
165
|
+
event_type="agent_finish",
|
|
166
|
+
message="Agent execution completed successfully",
|
|
167
|
+
metadata={"output_preview": _preview(output)},
|
|
168
|
+
status="success",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def on_chain_start(self, serialized: Dict[str, Any], inputs: Dict[str, Any], **kwargs: Any) -> None:
|
|
172
|
+
chain_name = serialized.get("name", "unknown_chain") if serialized else "unknown_chain"
|
|
173
|
+
self._log_event(
|
|
174
|
+
event_type="chain_start",
|
|
175
|
+
message=f"Chain started: {chain_name}",
|
|
176
|
+
metadata={"chain_name": chain_name, "inputs": _preview(inputs) if inputs else "no_inputs"},
|
|
177
|
+
status="in_progress",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def on_chain_end(self, outputs: Dict[str, Any], **kwargs: Any) -> None:
|
|
181
|
+
self._log_event(
|
|
182
|
+
event_type="chain_end",
|
|
183
|
+
message="Chain execution completed",
|
|
184
|
+
metadata={"outputs": _preview(outputs)},
|
|
185
|
+
status="success",
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def on_chain_error(self, error: BaseException, **kwargs: Any) -> None:
|
|
189
|
+
self._log_event(
|
|
190
|
+
event_type="chain_error",
|
|
191
|
+
message=f"Chain error: {str(error)}",
|
|
192
|
+
metadata={"exception_type": type(error).__name__, "exception_details": str(error)},
|
|
193
|
+
status="error",
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# ------------------------------------------------------------------
|
|
197
|
+
# Convenience methods — mirror FirestoreCallbackHandler's surface exactly
|
|
198
|
+
# ------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def log_mission_start(self) -> None:
|
|
201
|
+
self._log_event(
|
|
202
|
+
event_type="mission_start",
|
|
203
|
+
message=f"Mission {self.activity_id} started by {self.agent_id}",
|
|
204
|
+
status="in_progress",
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def log_mission_complete(self, summary: str = "Mission completed successfully") -> None:
|
|
208
|
+
self._log_event(event_type="mission_complete", message=summary, status="success")
|
|
209
|
+
|
|
210
|
+
def log_mission_error(self, summary: str = "Mission failed") -> None:
|
|
211
|
+
self._log_event(event_type="mission_error", message=summary, status="error")
|
|
212
|
+
|
|
213
|
+
def log_directive(self, directive_number: int, directive_text: str, status: str = "started") -> None:
|
|
214
|
+
"""Kept for API symmetry with FirestoreCallbackHandler; unused by the automatic
|
|
215
|
+
path today (MissionCommander's directives don't map 1:1 onto discrete tool
|
|
216
|
+
calls, so there is no real directive number for the automatic hooks above to
|
|
217
|
+
report). Available for a future caller that does have a real directive
|
|
218
|
+
number to report."""
|
|
219
|
+
event_type = f"directive_{status}"
|
|
220
|
+
message = f"Directive {directive_number}: {directive_text}"
|
|
221
|
+
log_status = "success" if status == "completed" else "in_progress" if status == "started" else "error"
|
|
222
|
+
self._log_event(
|
|
223
|
+
event_type=event_type,
|
|
224
|
+
message=message,
|
|
225
|
+
metadata={"directive_number": directive_number, "directive_status": status},
|
|
226
|
+
status=log_status,
|
|
227
|
+
)
|
pubsub_sdk/publisher.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""StepEventPublisher: the framework-agnostic core client. Zero LangChain dependency —
|
|
2
|
+
this is the part any future embodied-AI-agent developer imports directly, with no
|
|
3
|
+
tie to any particular agent framework or storage backend.
|
|
4
|
+
|
|
5
|
+
A thin GenericPublisher[StepEvent] subclass (see client.py) - construction,
|
|
6
|
+
degrade-gracefully behavior, and emulator-only topic auto-create all live there
|
|
7
|
+
now; this module keeps the `pubsub_v1` import so existing tests patching
|
|
8
|
+
`pubsub_sdk.publisher.pubsub_v1.PublisherClient` keep resolving correctly even
|
|
9
|
+
though client construction happens in the parent class.
|
|
10
|
+
"""
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any, Dict, Optional
|
|
13
|
+
|
|
14
|
+
from google.cloud import pubsub_v1 # noqa: F401 - kept for existing test patch targets
|
|
15
|
+
|
|
16
|
+
from .client import GenericPublisher
|
|
17
|
+
from .config import PubSubConfig
|
|
18
|
+
from .events import StepEvent
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class StepEventPublisher(GenericPublisher[StepEvent]):
|
|
24
|
+
def __init__(self, config: Optional[PubSubConfig] = None):
|
|
25
|
+
cfg = config or PubSubConfig.from_env()
|
|
26
|
+
super().__init__(config=cfg, topic_id=cfg.topic_id, label="StepEventPublisher")
|
|
27
|
+
|
|
28
|
+
def publish_step(
|
|
29
|
+
self,
|
|
30
|
+
activity_id: str,
|
|
31
|
+
agent_id: str,
|
|
32
|
+
event_type: str,
|
|
33
|
+
message: str,
|
|
34
|
+
status: str = "in_progress",
|
|
35
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
36
|
+
sequence: Optional[int] = None,
|
|
37
|
+
) -> bool:
|
|
38
|
+
event = StepEvent(
|
|
39
|
+
activity_id=activity_id,
|
|
40
|
+
agent_id=agent_id,
|
|
41
|
+
event_type=event_type,
|
|
42
|
+
message=message,
|
|
43
|
+
status=status,
|
|
44
|
+
metadata=metadata or {},
|
|
45
|
+
sequence=sequence,
|
|
46
|
+
)
|
|
47
|
+
return self.publish(event)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_default_publisher: Optional[StepEventPublisher] = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_default_publisher(config: Optional[PubSubConfig] = None) -> StepEventPublisher:
|
|
54
|
+
"""Process-wide singleton, mirroring get_activity_logging_service()'s existing
|
|
55
|
+
pattern in this org's codebase: one long-lived Pub/Sub client (and its gRPC
|
|
56
|
+
channel) per process instead of one per mission/callback instance, which would
|
|
57
|
+
otherwise leak a channel on every mission with nothing to close it."""
|
|
58
|
+
global _default_publisher
|
|
59
|
+
if _default_publisher is None:
|
|
60
|
+
_default_publisher = StepEventPublisher(config=config)
|
|
61
|
+
return _default_publisher
|
pubsub_sdk/py.typed
ADDED
|
File without changes
|
pubsub_sdk/subscriber.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""StepEventSubscriber: the generic pull-consumer primitive. Zero Firestore, zero
|
|
2
|
+
LangChain, zero "logging" concept — it just turns Pub/Sub messages back into
|
|
3
|
+
StepEvents and hands them to a callback. This is what keeps pubsub_sdk reusable
|
|
4
|
+
for purposes beyond logging (agent-to-agent chat, see chat.py).
|
|
5
|
+
|
|
6
|
+
A thin GenericSubscriber[StepEvent] subclass (see client.py) - construction,
|
|
7
|
+
degrade-gracefully behavior, and emulator-only subscription auto-create all
|
|
8
|
+
live there now; this module keeps the `pubsub_v1` import so existing tests
|
|
9
|
+
patching `pubsub_sdk.subscriber.pubsub_v1.SubscriberClient` keep resolving
|
|
10
|
+
correctly even though client construction happens in the parent class.
|
|
11
|
+
"""
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from google.cloud import pubsub_v1 # noqa: F401 - kept for existing test patch targets
|
|
16
|
+
|
|
17
|
+
from .client import GenericSubscriber
|
|
18
|
+
from .config import PubSubConfig
|
|
19
|
+
from .events import StepEvent
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class StepEventSubscriber(GenericSubscriber[StepEvent]):
|
|
25
|
+
def __init__(self, config: Optional[PubSubConfig] = None):
|
|
26
|
+
cfg = config or PubSubConfig.from_env()
|
|
27
|
+
super().__init__(
|
|
28
|
+
config=cfg,
|
|
29
|
+
topic_id=cfg.topic_id,
|
|
30
|
+
subscription_id=cfg.subscription_id,
|
|
31
|
+
message_cls=StepEvent,
|
|
32
|
+
label="StepEventSubscriber",
|
|
33
|
+
)
|