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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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
+ )
@@ -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
@@ -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
+ )