diff --git a/agent_assembly/exceptions/__init__.py b/agent_assembly/exceptions/__init__.py index 45b764c..585dc89 100644 --- a/agent_assembly/exceptions/__init__.py +++ b/agent_assembly/exceptions/__init__.py @@ -12,6 +12,7 @@ "ToolExecutionBlockedError", "MCPToolBlockedError", "PolicyViolationError", + "OpTerminatedError", ] @@ -60,3 +61,17 @@ def __init__( class PolicyViolationError(ToolExecutionBlockedError): """Exception raised when policy blocks tool execution.""" + + +class OpTerminatedError(AssemblyError): + """Raised when the gateway terminates an in-flight op (AAASM-1422 PR-E). + + Carries the originating `op_id` so callers can correlate the failure + against the operation they were awaiting. Surfaced by + `OpControlSubscriber.await_op` when an `OP_CONTROL_SIGNAL_TERMINATE` + arrives for the awaited op. + """ + + def __init__(self, message: str, *, op_id: str) -> None: + super().__init__(message) + self.op_id = op_id diff --git a/agent_assembly/op_control.py b/agent_assembly/op_control.py new file mode 100644 index 0000000..4b8e9b3 --- /dev/null +++ b/agent_assembly/op_control.py @@ -0,0 +1,216 @@ +"""Gateway → SDK op-control consumer (AAASM-1422 PR-E / AAASM-1654). + +Subscribes to ``PolicyService.OpControlStream`` and exposes a per-``op_id`` +cooperative-pause / fast-fail-terminate state machine through ``await_op``. + +The subscriber runs on a daemon background thread that reads the gRPC stream +and dispatches each ``OpControlMessage`` to a per-op state slot. Application +code awaits the slot via :meth:`OpControlSubscriber.await_op`: + +* ``OP_CONTROL_SIGNAL_PAUSE`` → ``await_op`` blocks until ``RESUME`` arrives. +* ``OP_CONTROL_SIGNAL_RESUME`` → ``await_op`` returns immediately. +* ``OP_CONTROL_SIGNAL_TERMINATE`` → ``await_op`` raises + :class:`agent_assembly.exceptions.OpTerminatedError`. + +If a signal arrives for an ``op_id`` no one is currently awaiting, it's +buffered into the per-op slot so the next ``await_op`` call sees it. + +Out of scope for PR-E (deferred): + - Reconnection / heartbeat on stream close (caller observes via + ``stream_alive`` and re-instantiates if desired). + - Auto-wiring into ``init_assembly`` / adapter ``check_action`` hooks + (separate sub-task once the adapter surface is stable). +""" + +from __future__ import annotations + +import threading +from collections.abc import Iterator +from dataclasses import dataclass, field +from typing import Protocol + +import grpc + +from agent_assembly.exceptions import OpTerminatedError +from agent_assembly.proto import common_pb2, policy_pb2, policy_pb2_grpc + +__all__ = ["OpControlSubscriber", "OpControlState"] + + +class _OpControlStub(Protocol): + """Structural type for the gRPC stub method this module needs. + + Lets tests inject a hand-rolled stub without standing up a gRPC server. + """ + + def OpControlStream( # noqa: N802 — gRPC method name + self, + request: policy_pb2.OpControlSubscribeRequest, + ) -> Iterator[policy_pb2.OpControlMessage]: ... + + +@dataclass +class OpControlState: + """Per-op state slot used by the cooperative-pause machine. + + Each ``op_id`` the subscriber observes gets one slot. ``await_op`` blocks + on ``event`` whenever ``paused`` is set; on terminate the slot's + ``terminated`` flag is latched and subsequent ``await_op`` calls raise + immediately without blocking. + """ + + event: threading.Event = field(default_factory=threading.Event) + paused: bool = False + terminated: bool = False + + +class OpControlSubscriber: + """Subscribe to OpControlStream and serve per-op pause/terminate signals. + + Construct via :meth:`connect`, never directly — the constructor takes a + pre-wired stub so tests can mock the gRPC layer without touching the + network. + + Thread-safe: ``await_op`` may be called from any thread; the underlying + state is guarded by an internal ``threading.Lock``. + """ + + def __init__(self, stub: _OpControlStub, agent_id: common_pb2.AgentId) -> None: + self._stub = stub + self._agent_id = agent_id + self._lock = threading.Lock() + self._ops: dict[str, OpControlState] = {} + self._stream_alive = threading.Event() + self._stream_alive.set() + self._reader: threading.Thread | None = None + self._call: grpc.RpcContext | None = None + + @classmethod + def connect( + cls, + gateway_url: str, + *, + org_id: str, + team_id: str, + agent_id: str, + channel_factory: grpc.Channel | None = None, + ) -> OpControlSubscriber: + """Open the gRPC channel + subscription stream and start the reader. + + ``gateway_url`` is the ``host:port`` of the gateway's gRPC endpoint + (no scheme; gRPC uses its own). When ``channel_factory`` is supplied + (tests), it's used instead of opening a fresh insecure channel. + """ + channel = channel_factory or grpc.insecure_channel(gateway_url) + stub = policy_pb2_grpc.PolicyServiceStub(channel) # type: ignore[no-untyped-call] + proto_agent_id = common_pb2.AgentId(org_id=org_id, team_id=team_id, agent_id=agent_id) + subscriber = cls(stub, proto_agent_id) + subscriber._start() + return subscriber + + def _start(self) -> None: + """Open the stream + spawn the reader thread. + + Separated from ``connect`` so tests can construct a subscriber with + a hand-rolled stub and call ``_start`` themselves. + """ + request = policy_pb2.OpControlSubscribeRequest(agent_id=self._agent_id) + self._call = self._stub.OpControlStream(request) + self._reader = threading.Thread( + target=self._reader_loop, + name=f"aa-op-control-{self._agent_id.agent_id}", + daemon=True, + ) + self._reader.start() + + def _reader_loop(self) -> None: + """Drain the stream and dispatch each message to the matching op slot.""" + try: + for message in self._call: # type: ignore[union-attr] + self._dispatch(message) + except grpc.RpcError: + # Stream closed (server shutdown, network drop, etc.) — fall through + # to mark the stream dead so await_op can detect it. + pass + finally: + self._stream_alive.clear() + # Wake any currently-blocked awaiters so they can re-check state. + with self._lock: + for state in self._ops.values(): + state.event.set() + + def _dispatch(self, message: policy_pb2.OpControlMessage) -> None: + """Apply one inbound signal to the per-op state slot.""" + with self._lock: + state = self._ops.setdefault(message.op_id, OpControlState()) + signal = message.signal + if signal == policy_pb2.OP_CONTROL_SIGNAL_PAUSE: + state.paused = True + state.event.clear() + elif signal == policy_pb2.OP_CONTROL_SIGNAL_RESUME: + state.paused = False + state.event.set() + elif signal == policy_pb2.OP_CONTROL_SIGNAL_TERMINATE: + state.terminated = True + state.event.set() + + def await_op(self, op_id: str, *, timeout: float | None = None) -> None: + """Block until ``op_id`` is runnable, or raise on terminate. + + Returns immediately when the op is not currently paused. When paused, + blocks on the per-op event up to ``timeout`` seconds. Raises + :class:`OpTerminatedError` if the op has been (or becomes) terminated. + + A timeout returns normally — the caller can inspect ``is_paused`` or + retry. This matches the cooperative-pause expectation in the + architecture doc (the SDK yields, it doesn't deadline-enforce). + """ + with self._lock: + state = self._ops.setdefault(op_id, OpControlState()) + if state.terminated: + raise OpTerminatedError( + f"op {op_id} was terminated by the gateway", + op_id=op_id, + ) + if not state.paused: + return + event = state.event + + # Drop the lock while we wait so the reader thread can update state. + event.wait(timeout=timeout) + + with self._lock: + if state.terminated: + raise OpTerminatedError( + f"op {op_id} was terminated by the gateway", + op_id=op_id, + ) + + def is_paused(self, op_id: str) -> bool: + """Return True iff the gateway has the op currently paused.""" + with self._lock: + state = self._ops.get(op_id) + return state.paused if state else False + + def is_terminated(self, op_id: str) -> bool: + """Return True iff the gateway has terminated the op.""" + with self._lock: + state = self._ops.get(op_id) + return state.terminated if state else False + + def stream_alive(self) -> bool: + """Return False once the underlying gRPC stream has closed.""" + return self._stream_alive.is_set() + + def close(self) -> None: + """Cancel the stream and join the reader thread.""" + if self._call is not None: + self._call.cancel() + if self._reader is not None: + self._reader.join(timeout=2.0) + + def __enter__(self) -> OpControlSubscriber: + return self + + def __exit__(self, exc_type: object, exc: object, tb: object) -> None: + self.close() diff --git a/agent_assembly/proto/__init__.py b/agent_assembly/proto/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent_assembly/proto/common_pb2.py b/agent_assembly/proto/common_pb2.py new file mode 100644 index 0000000..fea2612 --- /dev/null +++ b/agent_assembly/proto/common_pb2.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: common.proto +# Protobuf Python Version: 6.31.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 31, + 1, + '', + 'common.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0c\x63ommon.proto\x12\x12\x61ssembly.common.v1\"<\n\x07\x41gentId\x12\x0e\n\x06org_id\x18\x01 \x01(\t\x12\x0f\n\x07team_id\x18\x02 \x01(\t\x12\x10\n\x08\x61gent_id\x18\x03 \x01(\t\"\x1c\n\tTimestamp\x12\x0f\n\x07unix_ms\x18\x01 \x01(\x03*R\n\x08\x44\x65\x63ision\x12\x18\n\x14\x44\x45\x43ISION_UNSPECIFIED\x10\x00\x12\t\n\x05\x41LLOW\x10\x01\x12\x08\n\x04\x44\x45NY\x10\x02\x12\x0b\n\x07PENDING\x10\x03\x12\n\n\x06REDACT\x10\x04*\x8a\x01\n\nActionType\x12\x16\n\x12\x41\x43TION_UNSPECIFIED\x10\x00\x12\x0c\n\x08LLM_CALL\x10\x01\x12\r\n\tTOOL_CALL\x10\x02\x12\x12\n\x0e\x46ILE_OPERATION\x10\x03\x12\x10\n\x0cNETWORK_CALL\x10\x04\x12\x10\n\x0cPROCESS_EXEC\x10\x05\x12\x0f\n\x0b\x41GENT_SPAWN\x10\x06*M\n\x08RiskTier\x12\x14\n\x10RISK_UNSPECIFIED\x10\x00\x12\x07\n\x03LOW\x10\x01\x12\n\n\x06MEDIUM\x10\x02\x12\x08\n\x04HIGH\x10\x03\x12\x0c\n\x08\x43RITICAL\x10\x04\x62\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'common_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_DECISION']._serialized_start=128 + _globals['_DECISION']._serialized_end=210 + _globals['_ACTIONTYPE']._serialized_start=213 + _globals['_ACTIONTYPE']._serialized_end=351 + _globals['_RISKTIER']._serialized_start=353 + _globals['_RISKTIER']._serialized_end=430 + _globals['_AGENTID']._serialized_start=36 + _globals['_AGENTID']._serialized_end=96 + _globals['_TIMESTAMP']._serialized_start=98 + _globals['_TIMESTAMP']._serialized_end=126 +# @@protoc_insertion_point(module_scope) diff --git a/agent_assembly/proto/common_pb2.pyi b/agent_assembly/proto/common_pb2.pyi new file mode 100644 index 0000000..5cb4322 --- /dev/null +++ b/agent_assembly/proto/common_pb2.pyi @@ -0,0 +1,65 @@ +from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from typing import ClassVar as _ClassVar, Optional as _Optional + +DESCRIPTOR: _descriptor.FileDescriptor + +class Decision(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + DECISION_UNSPECIFIED: _ClassVar[Decision] + ALLOW: _ClassVar[Decision] + DENY: _ClassVar[Decision] + PENDING: _ClassVar[Decision] + REDACT: _ClassVar[Decision] + +class ActionType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + ACTION_UNSPECIFIED: _ClassVar[ActionType] + LLM_CALL: _ClassVar[ActionType] + TOOL_CALL: _ClassVar[ActionType] + FILE_OPERATION: _ClassVar[ActionType] + NETWORK_CALL: _ClassVar[ActionType] + PROCESS_EXEC: _ClassVar[ActionType] + AGENT_SPAWN: _ClassVar[ActionType] + +class RiskTier(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + RISK_UNSPECIFIED: _ClassVar[RiskTier] + LOW: _ClassVar[RiskTier] + MEDIUM: _ClassVar[RiskTier] + HIGH: _ClassVar[RiskTier] + CRITICAL: _ClassVar[RiskTier] +DECISION_UNSPECIFIED: Decision +ALLOW: Decision +DENY: Decision +PENDING: Decision +REDACT: Decision +ACTION_UNSPECIFIED: ActionType +LLM_CALL: ActionType +TOOL_CALL: ActionType +FILE_OPERATION: ActionType +NETWORK_CALL: ActionType +PROCESS_EXEC: ActionType +AGENT_SPAWN: ActionType +RISK_UNSPECIFIED: RiskTier +LOW: RiskTier +MEDIUM: RiskTier +HIGH: RiskTier +CRITICAL: RiskTier + +class AgentId(_message.Message): + __slots__ = ("org_id", "team_id", "agent_id") + ORG_ID_FIELD_NUMBER: _ClassVar[int] + TEAM_ID_FIELD_NUMBER: _ClassVar[int] + AGENT_ID_FIELD_NUMBER: _ClassVar[int] + org_id: str + team_id: str + agent_id: str + def __init__(self, org_id: _Optional[str] = ..., team_id: _Optional[str] = ..., agent_id: _Optional[str] = ...) -> None: ... + +class Timestamp(_message.Message): + __slots__ = ("unix_ms",) + UNIX_MS_FIELD_NUMBER: _ClassVar[int] + unix_ms: int + def __init__(self, unix_ms: _Optional[int] = ...) -> None: ... diff --git a/agent_assembly/proto/common_pb2_grpc.py b/agent_assembly/proto/common_pb2_grpc.py new file mode 100644 index 0000000..864b60c --- /dev/null +++ b/agent_assembly/proto/common_pb2_grpc.py @@ -0,0 +1,24 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc +import warnings + + +GRPC_GENERATED_VERSION = '1.80.0' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + ' but the generated code in common_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) diff --git a/agent_assembly/proto/policy_pb2.py b/agent_assembly/proto/policy_pb2.py new file mode 100644 index 0000000..15d8c61 --- /dev/null +++ b/agent_assembly/proto/policy_pb2.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: policy.proto +# Protobuf Python Version: 6.31.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 31, + 1, + '', + 'policy.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from . import common_pb2 as common__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0cpolicy.proto\x12\x12\x61ssembly.policy.v1\x1a\x0c\x63ommon.proto\"\xe9\x01\n\x12\x43heckActionRequest\x12-\n\x08\x61gent_id\x18\x01 \x01(\x0b\x32\x1b.assembly.common.v1.AgentId\x12\x18\n\x10\x63redential_token\x18\x02 \x01(\t\x12\x10\n\x08trace_id\x18\x03 \x01(\t\x12\x0f\n\x07span_id\x18\x04 \x01(\t\x12\x33\n\x0b\x61\x63tion_type\x18\x05 \x01(\x0e\x32\x1e.assembly.common.v1.ActionType\x12\x32\n\x07\x63ontext\x18\x06 \x01(\x0b\x32!.assembly.policy.v1.ActionContext\"\xc1\x02\n\rActionContext\x12\x36\n\x08llm_call\x18\x01 \x01(\x0b\x32\".assembly.policy.v1.LLMCallContextH\x00\x12\x38\n\ttool_call\x18\x02 \x01(\x0b\x32#.assembly.policy.v1.ToolCallContextH\x00\x12\x34\n\x07\x66ile_op\x18\x03 \x01(\x0b\x32!.assembly.policy.v1.FileOpContextH\x00\x12>\n\x0cnetwork_call\x18\x04 \x01(\x0b\x32&.assembly.policy.v1.NetworkCallContextH\x00\x12>\n\x0cprocess_exec\x18\x05 \x01(\x0b\x32&.assembly.policy.v1.ProcessExecContextH\x00\x42\x08\n\x06\x61\x63tion\"L\n\x0eLLMCallContext\x12\r\n\x05model\x18\x01 \x01(\t\x12\x15\n\rprompt_tokens\x18\x02 \x01(\x05\x12\x14\n\x0c\x63ontains_pii\x18\x03 \x01(\x08\"`\n\x0fToolCallContext\x12\x11\n\ttool_name\x18\x01 \x01(\t\x12\x13\n\x0btool_source\x18\x02 \x01(\t\x12\x11\n\targs_json\x18\x03 \x01(\x0c\x12\x12\n\ntarget_url\x18\x04 \x01(\t\"K\n\rFileOpContext\x12\x11\n\toperation\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x01(\t\x12\x19\n\x11is_sensitive_path\x18\x03 \x01(\x08\"X\n\x12NetworkCallContext\x12\x0c\n\x04host\x18\x01 \x01(\t\x12\x0c\n\x04port\x18\x02 \x01(\x05\x12\x10\n\x08protocol\x18\x03 \x01(\t\x12\x14\n\x0cin_allowlist\x18\x04 \x01(\x08\"3\n\x12ProcessExecContext\x12\x0f\n\x07\x63ommand\x18\x01 \x01(\t\x12\x0c\n\x04\x61rgs\x18\x02 \x03(\t\"\xd4\x01\n\x13\x43heckActionResponse\x12.\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32\x1c.assembly.common.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0bpolicy_rule\x18\x03 \x01(\t\x12\x13\n\x0b\x61pproval_id\x18\x04 \x01(\t\x12\x36\n\x06redact\x18\x05 \x01(\x0b\x32&.assembly.policy.v1.RedactInstructions\x12\x1b\n\x13\x64\x65\x63ision_latency_us\x18\x06 \x01(\x03\"C\n\x12RedactInstructions\x12-\n\x05rules\x18\x01 \x03(\x0b\x32\x1e.assembly.policy.v1.RedactRule\"5\n\nRedactRule\x12\x12\n\nfield_path\x18\x01 \x01(\t\x12\x13\n\x0breplacement\x18\x02 \x01(\t\"M\n\x11\x42\x61tchCheckRequest\x12\x38\n\x08requests\x18\x01 \x03(\x0b\x32&.assembly.policy.v1.CheckActionRequest\"P\n\x12\x42\x61tchCheckResponse\x12:\n\tresponses\x18\x01 \x03(\x0b\x32\'.assembly.policy.v1.CheckActionResponse\"J\n\x19OpControlSubscribeRequest\x12-\n\x08\x61gent_id\x18\x01 \x01(\x0b\x32\x1b.assembly.common.v1.AgentId\"h\n\x10OpControlMessage\x12\r\n\x05op_id\x18\x01 \x01(\t\x12\x33\n\x06signal\x18\x02 \x01(\x0e\x32#.assembly.policy.v1.OpControlSignal\x12\x10\n\x08sequence\x18\x03 \x01(\x04*\x90\x01\n\x0fOpControlSignal\x12!\n\x1dOP_CONTROL_SIGNAL_UNSPECIFIED\x10\x00\x12\x1b\n\x17OP_CONTROL_SIGNAL_PAUSE\x10\x01\x12\x1c\n\x18OP_CONTROL_SIGNAL_RESUME\x10\x02\x12\x1f\n\x1bOP_CONTROL_SIGNAL_TERMINATE\x10\x03\x32\xb6\x02\n\rPolicyService\x12^\n\x0b\x43heckAction\x12&.assembly.policy.v1.CheckActionRequest\x1a\'.assembly.policy.v1.CheckActionResponse\x12[\n\nBatchCheck\x12%.assembly.policy.v1.BatchCheckRequest\x1a&.assembly.policy.v1.BatchCheckResponse\x12h\n\x0fOpControlStream\x12-.assembly.policy.v1.OpControlSubscribeRequest\x1a$.assembly.policy.v1.OpControlMessage0\x01\x62\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'policy_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_OPCONTROLSIGNAL']._serialized_start=1689 + _globals['_OPCONTROLSIGNAL']._serialized_end=1833 + _globals['_CHECKACTIONREQUEST']._serialized_start=51 + _globals['_CHECKACTIONREQUEST']._serialized_end=284 + _globals['_ACTIONCONTEXT']._serialized_start=287 + _globals['_ACTIONCONTEXT']._serialized_end=608 + _globals['_LLMCALLCONTEXT']._serialized_start=610 + _globals['_LLMCALLCONTEXT']._serialized_end=686 + _globals['_TOOLCALLCONTEXT']._serialized_start=688 + _globals['_TOOLCALLCONTEXT']._serialized_end=784 + _globals['_FILEOPCONTEXT']._serialized_start=786 + _globals['_FILEOPCONTEXT']._serialized_end=861 + _globals['_NETWORKCALLCONTEXT']._serialized_start=863 + _globals['_NETWORKCALLCONTEXT']._serialized_end=951 + _globals['_PROCESSEXECCONTEXT']._serialized_start=953 + _globals['_PROCESSEXECCONTEXT']._serialized_end=1004 + _globals['_CHECKACTIONRESPONSE']._serialized_start=1007 + _globals['_CHECKACTIONRESPONSE']._serialized_end=1219 + _globals['_REDACTINSTRUCTIONS']._serialized_start=1221 + _globals['_REDACTINSTRUCTIONS']._serialized_end=1288 + _globals['_REDACTRULE']._serialized_start=1290 + _globals['_REDACTRULE']._serialized_end=1343 + _globals['_BATCHCHECKREQUEST']._serialized_start=1345 + _globals['_BATCHCHECKREQUEST']._serialized_end=1422 + _globals['_BATCHCHECKRESPONSE']._serialized_start=1424 + _globals['_BATCHCHECKRESPONSE']._serialized_end=1504 + _globals['_OPCONTROLSUBSCRIBEREQUEST']._serialized_start=1506 + _globals['_OPCONTROLSUBSCRIBEREQUEST']._serialized_end=1580 + _globals['_OPCONTROLMESSAGE']._serialized_start=1582 + _globals['_OPCONTROLMESSAGE']._serialized_end=1686 + _globals['_POLICYSERVICE']._serialized_start=1836 + _globals['_POLICYSERVICE']._serialized_end=2146 +# @@protoc_insertion_point(module_scope) diff --git a/agent_assembly/proto/policy_pb2.pyi b/agent_assembly/proto/policy_pb2.pyi new file mode 100644 index 0000000..f86b649 --- /dev/null +++ b/agent_assembly/proto/policy_pb2.pyi @@ -0,0 +1,160 @@ +import common_pb2 as _common_pb2 +from google.protobuf.internal import containers as _containers +from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from collections.abc import Iterable as _Iterable, Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class OpControlSignal(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + OP_CONTROL_SIGNAL_UNSPECIFIED: _ClassVar[OpControlSignal] + OP_CONTROL_SIGNAL_PAUSE: _ClassVar[OpControlSignal] + OP_CONTROL_SIGNAL_RESUME: _ClassVar[OpControlSignal] + OP_CONTROL_SIGNAL_TERMINATE: _ClassVar[OpControlSignal] +OP_CONTROL_SIGNAL_UNSPECIFIED: OpControlSignal +OP_CONTROL_SIGNAL_PAUSE: OpControlSignal +OP_CONTROL_SIGNAL_RESUME: OpControlSignal +OP_CONTROL_SIGNAL_TERMINATE: OpControlSignal + +class CheckActionRequest(_message.Message): + __slots__ = ("agent_id", "credential_token", "trace_id", "span_id", "action_type", "context") + AGENT_ID_FIELD_NUMBER: _ClassVar[int] + CREDENTIAL_TOKEN_FIELD_NUMBER: _ClassVar[int] + TRACE_ID_FIELD_NUMBER: _ClassVar[int] + SPAN_ID_FIELD_NUMBER: _ClassVar[int] + ACTION_TYPE_FIELD_NUMBER: _ClassVar[int] + CONTEXT_FIELD_NUMBER: _ClassVar[int] + agent_id: _common_pb2.AgentId + credential_token: str + trace_id: str + span_id: str + action_type: _common_pb2.ActionType + context: ActionContext + def __init__(self, agent_id: _Optional[_Union[_common_pb2.AgentId, _Mapping]] = ..., credential_token: _Optional[str] = ..., trace_id: _Optional[str] = ..., span_id: _Optional[str] = ..., action_type: _Optional[_Union[_common_pb2.ActionType, str]] = ..., context: _Optional[_Union[ActionContext, _Mapping]] = ...) -> None: ... + +class ActionContext(_message.Message): + __slots__ = ("llm_call", "tool_call", "file_op", "network_call", "process_exec") + LLM_CALL_FIELD_NUMBER: _ClassVar[int] + TOOL_CALL_FIELD_NUMBER: _ClassVar[int] + FILE_OP_FIELD_NUMBER: _ClassVar[int] + NETWORK_CALL_FIELD_NUMBER: _ClassVar[int] + PROCESS_EXEC_FIELD_NUMBER: _ClassVar[int] + llm_call: LLMCallContext + tool_call: ToolCallContext + file_op: FileOpContext + network_call: NetworkCallContext + process_exec: ProcessExecContext + def __init__(self, llm_call: _Optional[_Union[LLMCallContext, _Mapping]] = ..., tool_call: _Optional[_Union[ToolCallContext, _Mapping]] = ..., file_op: _Optional[_Union[FileOpContext, _Mapping]] = ..., network_call: _Optional[_Union[NetworkCallContext, _Mapping]] = ..., process_exec: _Optional[_Union[ProcessExecContext, _Mapping]] = ...) -> None: ... + +class LLMCallContext(_message.Message): + __slots__ = ("model", "prompt_tokens", "contains_pii") + MODEL_FIELD_NUMBER: _ClassVar[int] + PROMPT_TOKENS_FIELD_NUMBER: _ClassVar[int] + CONTAINS_PII_FIELD_NUMBER: _ClassVar[int] + model: str + prompt_tokens: int + contains_pii: bool + def __init__(self, model: _Optional[str] = ..., prompt_tokens: _Optional[int] = ..., contains_pii: bool = ...) -> None: ... + +class ToolCallContext(_message.Message): + __slots__ = ("tool_name", "tool_source", "args_json", "target_url") + TOOL_NAME_FIELD_NUMBER: _ClassVar[int] + TOOL_SOURCE_FIELD_NUMBER: _ClassVar[int] + ARGS_JSON_FIELD_NUMBER: _ClassVar[int] + TARGET_URL_FIELD_NUMBER: _ClassVar[int] + tool_name: str + tool_source: str + args_json: bytes + target_url: str + def __init__(self, tool_name: _Optional[str] = ..., tool_source: _Optional[str] = ..., args_json: _Optional[bytes] = ..., target_url: _Optional[str] = ...) -> None: ... + +class FileOpContext(_message.Message): + __slots__ = ("operation", "path", "is_sensitive_path") + OPERATION_FIELD_NUMBER: _ClassVar[int] + PATH_FIELD_NUMBER: _ClassVar[int] + IS_SENSITIVE_PATH_FIELD_NUMBER: _ClassVar[int] + operation: str + path: str + is_sensitive_path: bool + def __init__(self, operation: _Optional[str] = ..., path: _Optional[str] = ..., is_sensitive_path: bool = ...) -> None: ... + +class NetworkCallContext(_message.Message): + __slots__ = ("host", "port", "protocol", "in_allowlist") + HOST_FIELD_NUMBER: _ClassVar[int] + PORT_FIELD_NUMBER: _ClassVar[int] + PROTOCOL_FIELD_NUMBER: _ClassVar[int] + IN_ALLOWLIST_FIELD_NUMBER: _ClassVar[int] + host: str + port: int + protocol: str + in_allowlist: bool + def __init__(self, host: _Optional[str] = ..., port: _Optional[int] = ..., protocol: _Optional[str] = ..., in_allowlist: bool = ...) -> None: ... + +class ProcessExecContext(_message.Message): + __slots__ = ("command", "args") + COMMAND_FIELD_NUMBER: _ClassVar[int] + ARGS_FIELD_NUMBER: _ClassVar[int] + command: str + args: _containers.RepeatedScalarFieldContainer[str] + def __init__(self, command: _Optional[str] = ..., args: _Optional[_Iterable[str]] = ...) -> None: ... + +class CheckActionResponse(_message.Message): + __slots__ = ("decision", "reason", "policy_rule", "approval_id", "redact", "decision_latency_us") + DECISION_FIELD_NUMBER: _ClassVar[int] + REASON_FIELD_NUMBER: _ClassVar[int] + POLICY_RULE_FIELD_NUMBER: _ClassVar[int] + APPROVAL_ID_FIELD_NUMBER: _ClassVar[int] + REDACT_FIELD_NUMBER: _ClassVar[int] + DECISION_LATENCY_US_FIELD_NUMBER: _ClassVar[int] + decision: _common_pb2.Decision + reason: str + policy_rule: str + approval_id: str + redact: RedactInstructions + decision_latency_us: int + def __init__(self, decision: _Optional[_Union[_common_pb2.Decision, str]] = ..., reason: _Optional[str] = ..., policy_rule: _Optional[str] = ..., approval_id: _Optional[str] = ..., redact: _Optional[_Union[RedactInstructions, _Mapping]] = ..., decision_latency_us: _Optional[int] = ...) -> None: ... + +class RedactInstructions(_message.Message): + __slots__ = ("rules",) + RULES_FIELD_NUMBER: _ClassVar[int] + rules: _containers.RepeatedCompositeFieldContainer[RedactRule] + def __init__(self, rules: _Optional[_Iterable[_Union[RedactRule, _Mapping]]] = ...) -> None: ... + +class RedactRule(_message.Message): + __slots__ = ("field_path", "replacement") + FIELD_PATH_FIELD_NUMBER: _ClassVar[int] + REPLACEMENT_FIELD_NUMBER: _ClassVar[int] + field_path: str + replacement: str + def __init__(self, field_path: _Optional[str] = ..., replacement: _Optional[str] = ...) -> None: ... + +class BatchCheckRequest(_message.Message): + __slots__ = ("requests",) + REQUESTS_FIELD_NUMBER: _ClassVar[int] + requests: _containers.RepeatedCompositeFieldContainer[CheckActionRequest] + def __init__(self, requests: _Optional[_Iterable[_Union[CheckActionRequest, _Mapping]]] = ...) -> None: ... + +class BatchCheckResponse(_message.Message): + __slots__ = ("responses",) + RESPONSES_FIELD_NUMBER: _ClassVar[int] + responses: _containers.RepeatedCompositeFieldContainer[CheckActionResponse] + def __init__(self, responses: _Optional[_Iterable[_Union[CheckActionResponse, _Mapping]]] = ...) -> None: ... + +class OpControlSubscribeRequest(_message.Message): + __slots__ = ("agent_id",) + AGENT_ID_FIELD_NUMBER: _ClassVar[int] + agent_id: _common_pb2.AgentId + def __init__(self, agent_id: _Optional[_Union[_common_pb2.AgentId, _Mapping]] = ...) -> None: ... + +class OpControlMessage(_message.Message): + __slots__ = ("op_id", "signal", "sequence") + OP_ID_FIELD_NUMBER: _ClassVar[int] + SIGNAL_FIELD_NUMBER: _ClassVar[int] + SEQUENCE_FIELD_NUMBER: _ClassVar[int] + op_id: str + signal: OpControlSignal + sequence: int + def __init__(self, op_id: _Optional[str] = ..., signal: _Optional[_Union[OpControlSignal, str]] = ..., sequence: _Optional[int] = ...) -> None: ... diff --git a/agent_assembly/proto/policy_pb2_grpc.py b/agent_assembly/proto/policy_pb2_grpc.py new file mode 100644 index 0000000..f387c03 --- /dev/null +++ b/agent_assembly/proto/policy_pb2_grpc.py @@ -0,0 +1,208 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc +import warnings + +from . import policy_pb2 as policy__pb2 + +GRPC_GENERATED_VERSION = '1.80.0' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + ' but the generated code in policy_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) + + +class PolicyServiceStub(object): + """PolicyService handles path ② (runtime → gateway) — the hot path for every + agent action. Target latency: < 5 ms p99. + + Consumed by: aa-runtime/src/gateway_client.rs (client side) + aa-gateway/src/policy_engine.rs (server side) + """ + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.CheckAction = channel.unary_unary( + '/assembly.policy.v1.PolicyService/CheckAction', + request_serializer=policy__pb2.CheckActionRequest.SerializeToString, + response_deserializer=policy__pb2.CheckActionResponse.FromString, + _registered_method=True) + self.BatchCheck = channel.unary_unary( + '/assembly.policy.v1.PolicyService/BatchCheck', + request_serializer=policy__pb2.BatchCheckRequest.SerializeToString, + response_deserializer=policy__pb2.BatchCheckResponse.FromString, + _registered_method=True) + self.OpControlStream = channel.unary_stream( + '/assembly.policy.v1.PolicyService/OpControlStream', + request_serializer=policy__pb2.OpControlSubscribeRequest.SerializeToString, + response_deserializer=policy__pb2.OpControlMessage.FromString, + _registered_method=True) + + +class PolicyServiceServicer(object): + """PolicyService handles path ② (runtime → gateway) — the hot path for every + agent action. Target latency: < 5 ms p99. + + Consumed by: aa-runtime/src/gateway_client.rs (client side) + aa-gateway/src/policy_engine.rs (server side) + """ + + def CheckAction(self, request, context): + """CheckAction evaluates a single action before the agent dispatches it. + Called on every intercepted action — must be as fast as possible. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def BatchCheck(self, request, context): + """BatchCheck pre-warms the policy cache for a set of anticipated actions. + Called at agent startup or when entering a new task context. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def OpControlStream(self, request, context): + """OpControlStream is the gateway → SDK push channel for op-lifecycle + signals (pause / resume / terminate). The SDK opens the stream once + per agent process at startup; the gateway pushes one OpControlMessage + per OpsRegistry transition matching the subscriber's agent_id. See + AAASM-1653 (PR-D of AAASM-1422); SDK-side consumers ship in PR-E + (Python), PR-F (Node), and PR-G (Go). + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_PolicyServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'CheckAction': grpc.unary_unary_rpc_method_handler( + servicer.CheckAction, + request_deserializer=policy__pb2.CheckActionRequest.FromString, + response_serializer=policy__pb2.CheckActionResponse.SerializeToString, + ), + 'BatchCheck': grpc.unary_unary_rpc_method_handler( + servicer.BatchCheck, + request_deserializer=policy__pb2.BatchCheckRequest.FromString, + response_serializer=policy__pb2.BatchCheckResponse.SerializeToString, + ), + 'OpControlStream': grpc.unary_stream_rpc_method_handler( + servicer.OpControlStream, + request_deserializer=policy__pb2.OpControlSubscribeRequest.FromString, + response_serializer=policy__pb2.OpControlMessage.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'assembly.policy.v1.PolicyService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('assembly.policy.v1.PolicyService', rpc_method_handlers) + + + # This class is part of an EXPERIMENTAL API. +class PolicyService(object): + """PolicyService handles path ② (runtime → gateway) — the hot path for every + agent action. Target latency: < 5 ms p99. + + Consumed by: aa-runtime/src/gateway_client.rs (client side) + aa-gateway/src/policy_engine.rs (server side) + """ + + @staticmethod + def CheckAction(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/assembly.policy.v1.PolicyService/CheckAction', + policy__pb2.CheckActionRequest.SerializeToString, + policy__pb2.CheckActionResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def BatchCheck(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/assembly.policy.v1.PolicyService/BatchCheck', + policy__pb2.BatchCheckRequest.SerializeToString, + policy__pb2.BatchCheckResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def OpControlStream(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_stream( + request, + target, + '/assembly.policy.v1.PolicyService/OpControlStream', + policy__pb2.OpControlSubscribeRequest.SerializeToString, + policy__pb2.OpControlMessage.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/pyproject.toml b/pyproject.toml index 0b08c2b..18a250b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,10 @@ dependencies = [ "pydantic>=2.0.0,<3.0.0", "httpx>=0.27.0,<1.0.0", "typing-extensions>=4.0.0", + # AAASM-1654 (PR-E): OpControlSubscriber consumes PolicyService.OpControlStream + # via gRPC. Held to grpcio's modern stable line. + "grpcio>=1.66,<2", + "protobuf>=5,<7", ] [project.scripts] @@ -46,6 +50,10 @@ dev = [ "python-dotenv>=1.0.1,<2", "ruff>=0.1.0", "pytest-benchmark>=4.0.0,<6", + # AAASM-1654 (PR-E): grpcio-tools provides protoc + Python plugin used by + # scripts/gen_proto.py to regenerate agent_assembly/proto/*_pb2*.py from + # the sibling agent-assembly/proto/ checkout. + "grpcio-tools>=1.66,<2", ] pre-commit-ci = [ "pre-commit>=3.5.0,<5", diff --git a/scripts/gen_proto.py b/scripts/gen_proto.py new file mode 100644 index 0000000..1564a49 --- /dev/null +++ b/scripts/gen_proto.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Regenerate Python proto stubs from the sibling agent-assembly checkout. + +AAASM-1654 (PR-E of AAASM-1422). Generates only the protos this SDK +actually consumes today (policy + common). The output lives under +``agent_assembly/proto/`` and is committed to the repo so users don't +need ``grpcio-tools`` at runtime. + +Usage:: + + .venv/bin/python scripts/gen_proto.py + # or with a non-default sibling location: + AA_PROTO_DIR=/some/other/agent-assembly/proto .venv/bin/python scripts/gen_proto.py + +A drift check that runs this script in CI and asserts no diff is left +as a follow-up hygiene sub-task. +""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys +from pathlib import Path + +# Per memory `project_sibling_repo_ci_pattern`: cross-repo deps use +# sibling-checkout via env vars. Default mirrors the workspace layout +# ($REPO_PARENT/agent-assembly/proto). +DEFAULT_PROTO_DIR = Path(__file__).resolve().parent.parent.parent / "agent-assembly" / "proto" + +# Only generate the protos the SDK actually consumes. Keeping the set +# tight keeps the committed-stubs diff readable and avoids accidentally +# coupling the SDK to RPCs it doesn't use. +PROTO_FILES = ["common.proto", "policy.proto"] + +OUTPUT_DIR = Path(__file__).resolve().parent.parent / "agent_assembly" / "proto" + + +def main() -> int: + proto_dir = Path(os.environ.get("AA_PROTO_DIR", DEFAULT_PROTO_DIR)).resolve() + if not proto_dir.is_dir(): + print(f"error: proto dir {proto_dir} does not exist", file=sys.stderr) + print("Set AA_PROTO_DIR to the agent-assembly/proto location.", file=sys.stderr) + return 1 + + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + (OUTPUT_DIR / "__init__.py").touch(exist_ok=True) + + cmd = [ + sys.executable, + "-m", + "grpc_tools.protoc", + f"--proto_path={proto_dir}", + f"--python_out={OUTPUT_DIR}", + f"--pyi_out={OUTPUT_DIR}", + f"--grpc_python_out={OUTPUT_DIR}", + *[str(proto_dir / name) for name in PROTO_FILES], + ] + print(f"running: {' '.join(cmd)}") + rc = subprocess.run(cmd, check=False).returncode + if rc != 0: + return rc + + # grpcio-tools emits sibling-relative imports (`import common_pb2`) which + # break under Python's normal package import mechanics. Rewrite them to + # explicit relative imports (`from . import common_pb2`) so the + # generated stubs work from `agent_assembly.proto`. Pattern is conservative + # — only the top-level `import xxx_pb2(_grpc)?` lines are rewritten. + pattern = re.compile(r"^import (\w+_pb2(?:_grpc)?) as (\w+)$", re.MULTILINE) + for py in OUTPUT_DIR.glob("*_pb2*.py"): + text = py.read_text() + new_text = pattern.sub(r"from . import \1 as \2", text) + if new_text != text: + py.write_text(new_text) + print(f" rewrote sibling imports in {py.name}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/unit/test_op_control.py b/test/unit/test_op_control.py new file mode 100644 index 0000000..b92bcc4 --- /dev/null +++ b/test/unit/test_op_control.py @@ -0,0 +1,222 @@ +"""Unit tests for the OpControlSubscriber (AAASM-1422 PR-E / AAASM-1654). + +The subscriber owns a background thread that reads from the gRPC stream and +dispatches signals to a per-op state machine. We exercise it by injecting a +hand-rolled stub whose ``OpControlStream`` returns a controllable iterator — +no gRPC server stood up. +""" + +from __future__ import annotations + +import threading +import time +from collections.abc import Iterator +from queue import Queue + +import pytest + +from agent_assembly.exceptions import OpTerminatedError +from agent_assembly.op_control import OpControlSubscriber +from agent_assembly.proto import common_pb2, policy_pb2 + + +class _QueueStream: + """An iterator backed by a thread-safe queue so the test can push messages. + + Mirrors what `grpc.UnaryStreamMultiCallable.__call__` returns — an + iterator that blocks on `next()` until a message is available, raises + `StopIteration` when closed. + """ + + def __init__(self) -> None: + self._q: Queue[policy_pb2.OpControlMessage | None] = Queue() + self.cancelled = False + + def __iter__(self) -> Iterator[policy_pb2.OpControlMessage]: + return self + + def __next__(self) -> policy_pb2.OpControlMessage: + item = self._q.get() + if item is None: + raise StopIteration + return item + + def push(self, message: policy_pb2.OpControlMessage) -> None: + self._q.put(message) + + def end(self) -> None: + self._q.put(None) + + def cancel(self) -> None: + self.cancelled = True + self.end() + + +class _FakeStub: + def __init__(self, stream: _QueueStream) -> None: + self.stream = stream + self.last_request: policy_pb2.OpControlSubscribeRequest | None = None + + def OpControlStream( # noqa: N802 — gRPC method name + self, + request: policy_pb2.OpControlSubscribeRequest, + ) -> _QueueStream: + self.last_request = request + return self.stream + + +def _agent(name: str = "agent-7") -> common_pb2.AgentId: + return common_pb2.AgentId(org_id="org", team_id="team", agent_id=name) + + +def _msg(op_id: str, signal: int, sequence: int = 0) -> policy_pb2.OpControlMessage: + return policy_pb2.OpControlMessage(op_id=op_id, signal=signal, sequence=sequence) + + +@pytest.fixture +def subscriber(): + stream = _QueueStream() + stub = _FakeStub(stream) + sub = OpControlSubscriber(stub, _agent()) + sub._start() + try: + yield sub, stream, stub + finally: + stream.end() + sub.close() + + +def test_await_op_returns_immediately_for_unknown_op(subscriber): + sub, _, _ = subscriber + # No signal ever arrived for this op_id; await_op should be a no-op. + sub.await_op("never-seen", timeout=0.1) + + +def test_pause_blocks_until_resume(subscriber): + sub, stream, _ = subscriber + stream.push(_msg("op-1", policy_pb2.OP_CONTROL_SIGNAL_PAUSE)) + # Give the reader a moment to dispatch the pause. + for _ in range(50): + if sub.is_paused("op-1"): + break + time.sleep(0.01) + assert sub.is_paused("op-1") + + # Start await_op in a thread; verify it blocks. + done = threading.Event() + + def waiter() -> None: + sub.await_op("op-1", timeout=2.0) + done.set() + + t = threading.Thread(target=waiter) + t.start() + assert not done.wait(timeout=0.1), "await_op must block while paused" + + # Resume — the waiter should unblock. + stream.push(_msg("op-1", policy_pb2.OP_CONTROL_SIGNAL_RESUME, sequence=1)) + assert done.wait(timeout=2.0), "await_op did not unblock after resume" + t.join(timeout=1.0) + assert not sub.is_paused("op-1") + + +def test_terminate_raises_op_terminated_error(subscriber): + sub, stream, _ = subscriber + stream.push(_msg("op-2", policy_pb2.OP_CONTROL_SIGNAL_TERMINATE)) + for _ in range(50): + if sub.is_terminated("op-2"): + break + time.sleep(0.01) + assert sub.is_terminated("op-2") + + with pytest.raises(OpTerminatedError) as exc_info: + sub.await_op("op-2", timeout=1.0) + assert exc_info.value.op_id == "op-2" + + +def test_terminate_unblocks_waiter_and_raises(subscriber): + sub, stream, _ = subscriber + stream.push(_msg("op-3", policy_pb2.OP_CONTROL_SIGNAL_PAUSE)) + for _ in range(50): + if sub.is_paused("op-3"): + break + time.sleep(0.01) + + captured: list[BaseException] = [] + done = threading.Event() + + def waiter() -> None: + try: + sub.await_op("op-3", timeout=2.0) + except BaseException as exc: # noqa: BLE001 — we want to capture exactly what was raised + captured.append(exc) + done.set() + + t = threading.Thread(target=waiter) + t.start() + assert not done.wait(timeout=0.1) + + stream.push(_msg("op-3", policy_pb2.OP_CONTROL_SIGNAL_TERMINATE, sequence=1)) + assert done.wait(timeout=2.0) + t.join(timeout=1.0) + assert captured and isinstance(captured[0], OpTerminatedError) + assert captured[0].op_id == "op-3" + + +def test_signal_for_unknown_op_is_buffered_until_first_await(subscriber): + sub, stream, _ = subscriber + # Pause arrives before anyone is awaiting — must still be remembered. + stream.push(_msg("op-4", policy_pb2.OP_CONTROL_SIGNAL_PAUSE)) + for _ in range(50): + if sub.is_paused("op-4"): + break + time.sleep(0.01) + + # await_op should now block on the buffered pause. + done = threading.Event() + + def waiter() -> None: + sub.await_op("op-4", timeout=2.0) + done.set() + + t = threading.Thread(target=waiter) + t.start() + assert not done.wait(timeout=0.1) + + stream.push(_msg("op-4", policy_pb2.OP_CONTROL_SIGNAL_RESUME, sequence=1)) + assert done.wait(timeout=2.0) + t.join(timeout=1.0) + + +def test_subscribe_request_carries_composite_agent_id(subscriber): + _, _, stub = subscriber + assert stub.last_request is not None + assert stub.last_request.agent_id.org_id == "org" + assert stub.last_request.agent_id.team_id == "team" + assert stub.last_request.agent_id.agent_id == "agent-7" + + +def test_close_marks_stream_dead_and_wakes_waiters(subscriber): + sub, stream, _ = subscriber + stream.push(_msg("op-5", policy_pb2.OP_CONTROL_SIGNAL_PAUSE)) + for _ in range(50): + if sub.is_paused("op-5"): + break + time.sleep(0.01) + + done = threading.Event() + + def waiter() -> None: + sub.await_op("op-5", timeout=2.0) + done.set() + + t = threading.Thread(target=waiter) + t.start() + assert not done.wait(timeout=0.1) + + # Closing the stream should wake the waiter (without raising — close is + # a normal lifecycle event, not a terminate). + stream.end() + assert done.wait(timeout=2.0) + t.join(timeout=1.0) + assert not sub.stream_alive() diff --git a/uv.lock b/uv.lock index cca5cf2..30158ac 100644 --- a/uv.lock +++ b/uv.lock @@ -7,7 +7,9 @@ name = "agent-assembly" version = "0.0.0" source = { editable = "." } dependencies = [ + { name = "grpcio" }, { name = "httpx" }, + { name = "protobuf" }, { name = "pydantic" }, { name = "typing-extensions" }, ] @@ -15,6 +17,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "coverage" }, + { name = "grpcio-tools" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-benchmark" }, @@ -41,7 +44,9 @@ pre-commit-ci = [ [package.metadata] requires-dist = [ + { name = "grpcio", specifier = ">=1.66,<2" }, { name = "httpx", specifier = ">=0.27.0,<1.0.0" }, + { name = "protobuf", specifier = ">=5,<7" }, { name = "pydantic", specifier = ">=2.0.0,<3.0.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, ] @@ -49,6 +54,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "coverage", specifier = "~=7.10" }, + { name = "grpcio-tools", specifier = ">=1.66,<2" }, { name = "pytest", specifier = ">=8.1.1,<10" }, { name = "pytest-asyncio", specifier = ">=0.23.0,<2" }, { name = "pytest-benchmark", specifier = ">=4.0.0,<6" }, @@ -400,6 +406,90 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, ] +[[package]] +name = "grpcio" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" }, + { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" }, + { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" }, + { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" }, + { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, + { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, + { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, + { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, +] + +[[package]] +name = "grpcio-tools" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "protobuf" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/c8/1223f29c84a143ae9a56c084fc96894de0ba84b6e8d60a26241abd81d278/grpcio_tools-1.80.0.tar.gz", hash = "sha256:26052b19c6ce0dcf52d1024496aea3e2bdfa864159f06dc7b97b22d041a94b26", size = 6133212, upload-time = "2026-03-30T08:52:39.077Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/b9/65929df8c9614792db900a8e45d4997fadbd1734c827da3f0eb1f2fe4866/grpcio_tools-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:d19d5a8244311947b96f749c417b32d144641c6953f1164824579e1f0a51d040", size = 2550856, upload-time = "2026-03-30T08:50:57.3Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/af1557544d68d1aeca9d9ea53ed16524022d521fec6ba334ab3530e9c1a6/grpcio_tools-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fb599a3dc89ed1bb24489a2724b2f6dd4cddbbf0f7bdd69c073477bab0dc7554", size = 5710883, upload-time = "2026-03-30T08:51:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/cc/48/aa9b4f7519ca972bc40d315d5c28f05ca28fa08de13d4e8b69f551b798ab/grpcio_tools-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:623ee31fc2ff7df9a987b4f3d139c30af17ce46a861ae0e25fb8c112daa32dd8", size = 2598004, upload-time = "2026-03-30T08:51:02.102Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b8/b01371c119924b3beca1fe3f047b1bc2cdc66b3d37f0f3acc9d10c567a43/grpcio_tools-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b46570a68378539ee2b75a5a43202561f8d753c832798b1047099e3c551cf5d6", size = 2909568, upload-time = "2026-03-30T08:51:04.159Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7c/1108f7bdb58475a7e701ec89b55eb494538b6e76acd211ba0d4cc5fd28e8/grpcio_tools-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51caf99c28999e7e0f97e9cea190c1405b7681a57bb2e0631205accd92b43fa4", size = 2660938, upload-time = "2026-03-30T08:51:06.126Z" }, + { url = "https://files.pythonhosted.org/packages/67/59/d1c0063d4cd3b85363c7044ff3e5159d6d5df96e2692a9a5312d9c8cb290/grpcio_tools-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cdaa1c9aa8d3a87891a96700cadd29beec214711d6522818d207277f6452567c", size = 3113814, upload-time = "2026-03-30T08:51:08.834Z" }, + { url = "https://files.pythonhosted.org/packages/76/21/18d34a4efe524c903cf66b0cfa5260d81f277b6ae668b647edf795df9ce5/grpcio_tools-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3399b5fd7b59bcffd59c6b9975a969d9f37a3c87f3e3d63c3a09c147907acb0d", size = 3662793, upload-time = "2026-03-30T08:51:11.094Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/cf2d9295a6bd593244ea703858f8fc2efd315046ca3ef7c6f9ebc5b810fa/grpcio_tools-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9c6abc08d3485b2aac99bb58afcd31dc6cd4316ce36cf263ff09cb6df15f287f", size = 3329149, upload-time = "2026-03-30T08:51:13.066Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1d/fc34b32167966df20d69429b71dfca83c48434b047a5ac4fd6cd91ca4eed/grpcio_tools-1.80.0-cp312-cp312-win32.whl", hash = "sha256:18c51e07652ac7386fcdbd11866f8d55a795de073337c12447b5805575339f74", size = 997519, upload-time = "2026-03-30T08:51:14.87Z" }, + { url = "https://files.pythonhosted.org/packages/91/98/6d6563cdf51085b75f8ec24605c6f2ce84197571878ca8ab4af949c6be2d/grpcio_tools-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:ac6fdd42d5bb18f0d903a067e2825be172deff70cf197164b6f65676cb506c9b", size = 1162407, upload-time = "2026-03-30T08:51:16.793Z" }, + { url = "https://files.pythonhosted.org/packages/44/d9/f7887a4805939e9a85d03744b66fc02575dc1df3c3e8b4d9ec000ee7a33d/grpcio_tools-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e7046837859bbfd10b01786056145480155c16b222c9e209215b68d3be13060e", size = 2550319, upload-time = "2026-03-30T08:51:19.117Z" }, + { url = "https://files.pythonhosted.org/packages/57/5a/c8a05b32bd7203f1b9f4c0151090a2d6179d6c97692d32f2066dc29c67a6/grpcio_tools-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a447f28958a8fe84ff0d9d3d9473868feb27ee4a9c9c805e66f5b670121cec59", size = 5709681, upload-time = "2026-03-30T08:51:21.991Z" }, + { url = "https://files.pythonhosted.org/packages/82/6b/794350ed645c12c310008f97068f6a6fd927150b0d0d08aad1d909e880b1/grpcio_tools-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:75f00450e08fe648ad8a1eeb25bc52219679d54cdd02f04dfdddc747309d83f6", size = 2596820, upload-time = "2026-03-30T08:51:24.323Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b2/b39e7b79f7c878135e0784a53cd7260ee77260c8c7f2c9e46bca8e05d017/grpcio_tools-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3db830eaff1f2c2797328f2fa86c9dcdbd7d81af573a68db81e27afa2182a611", size = 2909193, upload-time = "2026-03-30T08:51:27.025Z" }, + { url = "https://files.pythonhosted.org/packages/10/f3/abe089b058f87f9910c9a458409505cbeb0b3e1c2d993a79721d02ee6a32/grpcio_tools-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7982b5fe42f012686b667dda12916884de95c4b1c65ff64371fb7232a1474b23", size = 2660197, upload-time = "2026-03-30T08:51:29.392Z" }, + { url = "https://files.pythonhosted.org/packages/09/c3/3f7806ad8b731d8a89fe3c6ed496473abd1ef4c9c42c9e9a8836ce96e377/grpcio_tools-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6451b3f4eb52d12c7f32d04bf8e0185f80521f3f088ad04b8d222b3a4819c71e", size = 3113144, upload-time = "2026-03-30T08:51:31.671Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f5/415ef205e0b7e75d2a2005df6120145c4f02fda28d7b3715b55d924fe1a4/grpcio_tools-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:258bc30654a9a2236be4ca8e2ad443e2ac6db7c8cc20454d34cce60265922726", size = 3661897, upload-time = "2026-03-30T08:51:34.849Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d3/2ad54764c2a9547080dd8518f4a4dc7899c7e6e747a1b1de542ce6a12066/grpcio_tools-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:865a2b8e6334c838976ab02a322cbd55c863d2eaf3c1e1a0255883c63996772a", size = 3328786, upload-time = "2026-03-30T08:51:37.265Z" }, + { url = "https://files.pythonhosted.org/packages/eb/63/23ab7db01f9630ab4f3742a2fc9fbff38b0cfc30c976114f913950664a75/grpcio_tools-1.80.0-cp313-cp313-win32.whl", hash = "sha256:f760ac1722f33e774814c37b6aa0444143f612e85088ead7447a0e9cd306a1f1", size = 997087, upload-time = "2026-03-30T08:51:39.137Z" }, + { url = "https://files.pythonhosted.org/packages/9b/af/b1c1c4423fb49cb7c8e9d2c02196b038c44160b7028b425466743c6c81fa/grpcio_tools-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:7843b9ac6ff8ca508424d0dd968bd9a1a4559967e4a290f26be5bd6f04af2234", size = 1162167, upload-time = "2026-03-30T08:51:41.498Z" }, + { url = "https://files.pythonhosted.org/packages/0e/44/7beeee2348f9f412804f5bf80b7d13b81d522bf926a338ae3da46b2213b7/grpcio_tools-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:12f950470449dbeec78317dbc090add7a00eb6ca812af7b0538ab7441e0a42c3", size = 2550303, upload-time = "2026-03-30T08:51:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/2d/aa/f77dd85409a1855f8c6319ffc69d81e8c3ffe122ee3a7136653e1991d8b6/grpcio_tools-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d3f9a376a29c9adf62bb56f7ff5bc81eb4abeaf53d1e7dde5015564832901a51", size = 5709778, upload-time = "2026-03-30T08:51:47.112Z" }, + { url = "https://files.pythonhosted.org/packages/9c/7c/ab7af4883ebdfdc228b853de89fed409703955e8d47285b321a5794856bd/grpcio_tools-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ba1ffbf2cff71533615e2c5a138ed5569611eec9ae7f9c67b8898e127b54ac0", size = 2597928, upload-time = "2026-03-30T08:51:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/22/e8/4381a963d472e3ab6690ba067ed2b1f1abf8518b10f402678bd2dcb79a54/grpcio_tools-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:13f60f8d9397c514c6745a967d22b5c8c698347e88deebca1ff2e1b94555e450", size = 2909333, upload-time = "2026-03-30T08:51:52.124Z" }, + { url = "https://files.pythonhosted.org/packages/94/cb/356b5fdf79dd99455b425fb16302fe60995554ceb721afbf3cf770a19208/grpcio_tools-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:88d77bad5dd3cd5e6f952c4ecdd0ee33e0c02ecfc2e4b0cbee3391ac19e0a431", size = 2660217, upload-time = "2026-03-30T08:51:55.066Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d7/1752018cc2c36b2c5612051379e2e5f59f2dbe612de23e817d2f066a9487/grpcio_tools-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:017945c3e98a4ed1c4e21399781b4137fc08dfc1f802c8ace2e64ef52d32b142", size = 3113896, upload-time = "2026-03-30T08:51:57.3Z" }, + { url = "https://files.pythonhosted.org/packages/cc/17/695bbe454f70df35c03e22b48c5314683b913d3e6ed35ec90d065418c1ab/grpcio_tools-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a33e265d4db803495007a6c623eafb0f6b9bb123ff4a0af89e44567dad809b88", size = 3661950, upload-time = "2026-03-30T08:51:59.867Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d0/533d87629ec823c02c9169ee20228f734c264b209dcdf55268b5a14cde0a/grpcio_tools-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c129da370c5f85f569be2e545317dda786a60dd51d7deea29b03b0c05f6aac3", size = 3328755, upload-time = "2026-03-30T08:52:02.942Z" }, + { url = "https://files.pythonhosted.org/packages/08/a1/504d7838770c73a9761e8a8ff4869dba1146b44f297ff0ac6641481942d3/grpcio_tools-1.80.0-cp314-cp314-win32.whl", hash = "sha256:25742de5958ae4325249a37e724e7c0e5120f8e302a24a977ebd1737b48a5e97", size = 1019620, upload-time = "2026-03-30T08:52:05.342Z" }, + { url = "https://files.pythonhosted.org/packages/f3/75/8b7cd281c5cdfb4ca2c308f7e9b2799bab2be6e7a9e9212ea5a82e2aecd4/grpcio_tools-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:bbf8eeef78fda1966f732f79c1c802fadd5cfd203d845d2af4d314d18569069c", size = 1194210, upload-time = "2026-03-30T08:52:08.105Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -846,6 +936,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, ] +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + [[package]] name = "py-cpuinfo" version = "9.0.0" @@ -1160,6 +1265,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, ] +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + [[package]] name = "six" version = "1.17.0"