Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,20 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install
run: |
python -m pip install --upgrade pip build
pip install -e .
pip install -e ".[test]"
- name: Lint
run: ruff check helloagent tests
- name: Test
run: pytest
- name: Import smoke
run: |
python -c "from helloagent import Agent, UserClient, IncomingMessage, AuthFailedError, Tool, ToolRegistry; print('imports ok')"
Expand Down
16 changes: 11 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,18 @@ jobs:
name: Build distributions
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install build tooling
run: python -m pip install --upgrade build
python-version: "3.14"
- name: Install tooling
run: |
python -m pip install --upgrade pip build
pip install -e ".[test]"
- name: Lint
run: ruff check helloagent tests
- name: Test
run: pytest
- name: Build sdist and wheel
run: python -m build
- uses: actions/upload-artifact@v4
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ await client.send("alice/jarvis", "what's on my calendar today?")

## What you get

- **`Agent`** — long-lived WebSocket connection authenticated with an `ha_*` token. Auto-reconnects with exponential backoff. Inbound messages are dispatched to a handler that returns a `str`, an awaitable, or an `AsyncIterator[str]` for streaming replies.
- **`Agent`** — long-lived WebSocket connection authenticated with an `ha_*` token. Auto-reconnects with exponential backoff. Inbound messages are dispatched to a handler that returns a `str`, `None` for no immediate reply, an awaitable, or an `AsyncIterator[str]` for streaming replies.
- **`UserClient`** — same transport, `ROLE_USER`. For user-facing surfaces.
- **`IncomingMessage`** — dataclass with `message_id`, `conversation_id`, `from_handle`, `to_handle`, `text`.
- **`AuthFailedError`** — raised when the relay rejects auth (`auth_response.ok=false`). Treat as terminal: re-pair, don't retry.
Expand Down
13 changes: 9 additions & 4 deletions helloagent/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ class IncomingMessage:
text: str


Handler = Callable[[IncomingMessage], Union[str, Awaitable[str], AsyncIterator[str]]]
HandlerResult = Union[str, None, Awaitable[Optional[str]], AsyncIterator[str]]
Handler = Callable[[IncomingMessage], HandlerResult]


class AuthFailedError(Exception):
Expand Down Expand Up @@ -76,7 +77,8 @@ async def _connect_once(self):
)
await self.ws.send(auth.SerializeToString())
raw = await self.ws.recv()
env = pb.Envelope(); env.ParseFromString(raw)
env = pb.Envelope()
env.ParseFromString(raw)
if not env.HasField("auth_response") or not env.auth_response.ok:
raise AuthFailedError(str(env))
if env.auth_response.handle:
Expand All @@ -88,7 +90,8 @@ async def _send(self, env: pb.Envelope):

async def _recv(self) -> pb.Envelope:
raw = await self.ws.recv()
env = pb.Envelope(); env.ParseFromString(raw)
env = pb.Envelope()
env.ParseFromString(raw)
return env


Expand Down Expand Up @@ -189,7 +192,9 @@ async def _handle(self, env: pb.Envelope):
await self._send_chunk(incoming, env.message_id, "", True)
else:
text = await result if inspect.isawaitable(result) else result
await self._send_chunk(incoming, env.message_id, text or "", True)
if text is None:
return
await self._send_chunk(incoming, env.message_id, text, True)

async def _send_chunk(self, incoming: IncomingMessage, ref_id: str, body: str, final: bool):
sess = self._session_for(incoming.from_handle)
Expand Down
18 changes: 15 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,22 @@ build-backend = "setuptools.build_meta"

[project]
name = "helloagentai"
version = "0.1.0"
version = "0.1.1"
description = "HelloAgent Python SDK — relay client + channel-link helpers. Install as `helloagentai`, import as `helloagent`."
readme = "README.md"
license = {text = "MIT"}
license = "MIT"
requires-python = ">=3.10"
authors = [{name = "HelloAgent"}]
keywords = ["helloagent", "agent", "websocket", "relay", "messaging", "sdk", "ai-agent"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Software Development :: Libraries :: Python Modules",
]
dependencies = [
Expand All @@ -27,6 +28,14 @@ dependencies = [
"cryptography>=42.0",
]

[project.optional-dependencies]
test = [
"pytest>=8",
"pytest-asyncio>=0.24",
"pytest-cov>=5",
"ruff>=0.8",
]

[project.urls]
Homepage = "https://github.com/helloagentai/helloagent-sdk-python"
Documentation = "https://github.com/helloagentai/helloagent-sdk-python#readme"
Expand All @@ -38,3 +47,6 @@ include = ["helloagent*"]

[tool.setuptools.package-data]
helloagent = ["v1/*.py"]

[tool.pytest.ini_options]
asyncio_mode = "strict"
138 changes: 138 additions & 0 deletions tests/test_agent_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import pytest

from helloagent import Agent
from helloagent.v1 import protocol_pb2 as pb


def _incoming_env() -> pb.Envelope:
return pb.Envelope(
message_id="in_1",
ts_unix_ms=123,
send_message=pb.SendMessage(
conversation_id="conv_1",
from_handle="alice",
to_handle="alice/jarvis",
text="hello",
),
)


def _capture_agent() -> tuple[Agent, list[pb.Envelope]]:
agent = Agent("ha_test")
agent.handle = "alice/jarvis"
sent = []

async def fake_send(env):
sent.append(env)

agent._send = fake_send
return agent, sent


@pytest.mark.asyncio
async def test_none_handler_result_acks_without_reply_chunk():
agent, sent = _capture_agent()

@agent.on_message
async def handler(msg):
return None

await agent._handle(_incoming_env())

assert [env.WhichOneof("payload") for env in sent] == ["ack"]


@pytest.mark.asyncio
async def test_string_handler_result_sends_final_reply_chunk():
agent, sent = _capture_agent()

@agent.on_message
async def handler(msg):
return "hi"

await agent._handle(_incoming_env())

assert [env.WhichOneof("payload") for env in sent] == ["ack", "stream_chunk"]
chunk = sent[1].stream_chunk
assert chunk.body == "hi"
assert chunk.is_final is True


@pytest.mark.asyncio
async def test_sync_none_handler_result_acks_without_reply_chunk():
agent, sent = _capture_agent()

@agent.on_message
def handler(msg):
return None

await agent._handle(_incoming_env())

assert [env.WhichOneof("payload") for env in sent] == ["ack"]


@pytest.mark.asyncio
async def test_missing_handler_only_acks():
agent, sent = _capture_agent()

await agent._handle(_incoming_env())

assert [env.WhichOneof("payload") for env in sent] == ["ack"]


@pytest.mark.asyncio
async def test_async_generator_handler_streams_chunks_then_final_empty_chunk():
agent, sent = _capture_agent()

@agent.on_message
async def handler(msg):
yield "one"
yield "two"

await agent._handle(_incoming_env())

assert [env.WhichOneof("payload") for env in sent] == [
"ack",
"stream_chunk",
"stream_chunk",
"stream_chunk",
]
chunks = [env.stream_chunk for env in sent[1:]]
assert [(chunk.body, chunk.is_final) for chunk in chunks] == [
("one", False),
("two", False),
("", True),
]


@pytest.mark.asyncio
async def test_agent_send_builds_send_message_envelope():
class FakeWS:
def __init__(self):
self.payloads = []

async def send(self, payload):
self.payloads.append(payload)

agent = Agent("ha_test")
agent.handle = "alice/jarvis"
agent.ws = FakeWS()

message_id = await agent.send("bob", "hello", conversation_id="conv_1")

assert message_id
env = pb.Envelope()
env.ParseFromString(agent.ws.payloads[0])
assert env.message_id == message_id
assert env.send_message.conversation_id == "conv_1"
assert env.send_message.from_handle == "alice/jarvis"
assert env.send_message.to_handle == "bob"
assert env.send_message.text == "hello"


@pytest.mark.asyncio
async def test_agent_send_requires_connection():
agent = Agent("ha_test")

with pytest.raises(RuntimeError, match="agent not connected"):
await agent.send("bob", "hello")
Loading