Skip to content
Open
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
3 changes: 2 additions & 1 deletion packages/sdk-py/src/agent_relay/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# ── Primary API: Direct spawn/message (matches TypeScript SDK) ────────────────

from .relay import AgentRelay, Agent, AgentSpawner, HumanHandle, Message, SpawnOptions
from .relay import AgentRelay, Agent, AgentSpawner, HumanHandle, Message, SpawnOptions, SystemHandle
_has_communicate = False
try:
from .communicate import Relay, RelayConfig, on_relay
Expand Down Expand Up @@ -78,6 +78,7 @@
"Agent",
"AgentSpawner",
"HumanHandle",
"SystemHandle",
"Message",
"SpawnOptions",
*(["Relay", "RelayConfig", "on_relay"] if _has_communicate else []),
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk-py/src/agent_relay/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from .protocol import (
BrokerEvent,
MessageInjectionMode,
ParticipantKind,
)

# ── Errors ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -469,13 +470,15 @@ async def send_message(
to: str,
text: str,
from_: Optional[str] = None,
from_kind: Optional[ParticipantKind] = None,
thread_id: Optional[str] = None,
priority: Optional[int] = None,
data: Optional[dict[str, Any]] = None,
mode: Optional[MessageInjectionMode] = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {"to": to, "text": text}
if from_ is not None: payload["from"] = from_
if from_kind is not None: payload["senderKind"] = from_kind
if thread_id is not None: payload["threadId"] = thread_id
if priority is not None: payload["priority"] = priority
if data is not None: payload["data"] = data
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk-py/src/agent_relay/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
AgentRuntime = Literal["pty", "headless"]
HeadlessProvider = Literal["claude", "opencode"]
MessageInjectionMode = Literal["wait", "steer"]
ParticipantKind = Literal["agent", "human", "system"]
SenderKind = Literal["agent", "human", "system", "unknown"]

# BrokerEvent is a dict with a 'kind' field discriminator.
# Event kinds: agent_spawned, agent_released, agent_exit, agent_exited,
Expand Down
40 changes: 32 additions & 8 deletions packages/sdk-py/src/agent_relay/relay.py
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Python SDK Agent.send_message does not pass from_kind to the broker client

In the Python SDK, Agent.send_message (packages/sdk-py/src/agent_relay/relay.py:204-212) does not pass from_kind to client.send_message(), even though the local Message object is constructed with from_kind="agent" on line 219. This is inconsistent with: (1) the TypeScript SDK's Agent.sendMessage which correctly passes fromKind: 'agent' at packages/sdk/src/relay.ts:1543, (2) the Python SDK's own _ParticipantHandle.send_message which correctly passes from_kind=self._kind at packages/sdk-py/src/agent_relay/relay.py:280. As a result, agent-originated messages from the Python SDK will not carry senderKind in the HTTP payload to the broker, meaning the broker cannot distinguish agent senders from unknown senders. The test at packages/sdk-py/tests/test_send_message_mode.py:89-93 expects from_kind="agent" to be passed and would fail.

(Refers to lines 204-213)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@
import os
import secrets
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, Optional
from typing import Any, Awaitable, Callable, Literal, Optional

from .client import AgentRelayClient
from .protocol import AgentRuntime, BrokerEvent, MessageInjectionMode
from .protocol import AgentRuntime, BrokerEvent, MessageInjectionMode, SenderKind

# ── Public types ──────────────────────────────────────────────────────────────

Expand All @@ -34,6 +34,7 @@ class Message:
from_name: str
to: str
text: str
from_kind: Optional[SenderKind] = None
thread_id: Optional[str] = None
data: Optional[dict[str, Any]] = None
mode: Optional[MessageInjectionMode] = None
Expand Down Expand Up @@ -215,6 +216,7 @@ async def send_message(
msg = Message(
event_id=event_id,
from_name=self._name,
from_kind="agent",
to=to,
text=text,
thread_id=thread_id,
Expand All @@ -241,20 +243,25 @@ def unsubscribe() -> None:
return unsubscribe


# ── Human handle ──────────────────────────────────────────────────────────────
# ── Human/system handles ──────────────────────────────────────────────────────


class HumanHandle:
"""A messaging handle for human/system messages."""
class _ParticipantHandle:
"""Shared messaging handle for non-agent participants."""

def __init__(self, name: str, relay: AgentRelay):
def __init__(self, name: str, kind: Literal["human", "system"], relay: AgentRelay):
self._name = name
self._kind = kind
self._relay = relay

@property
def name(self) -> str:
return self._name

@property
def kind(self) -> Literal["human", "system"]:
return self._kind

async def send_message(
self,
*,
Expand All @@ -270,6 +277,7 @@ async def send_message(
to=to,
text=text,
from_=self._name,
from_kind=self._kind,
thread_id=thread_id,
priority=priority,
data=data,
Expand All @@ -280,6 +288,7 @@ async def send_message(
msg = Message(
event_id=event_id,
from_name=self._name,
from_kind=self._kind,
to=to,
text=text,
thread_id=thread_id,
Expand All @@ -292,6 +301,20 @@ async def send_message(
return msg


class HumanHandle(_ParticipantHandle):
"""A messaging handle for human participants."""

def __init__(self, name: str, relay: AgentRelay):
super().__init__(name, "human", relay)


class SystemHandle(_ParticipantHandle):
"""A messaging handle for deterministic system-origin messages."""

def __init__(self, relay: AgentRelay):
super().__init__("system", "system", relay)


# ── Agent spawner ─────────────────────────────────────────────────────────────


Expand Down Expand Up @@ -573,8 +596,8 @@ async def spawn_and_wait(
def human(self, name: str) -> HumanHandle:
return HumanHandle(name, self)

def system(self) -> HumanHandle:
return HumanHandle("system", self)
def system(self) -> SystemHandle:
return SystemHandle(self)

async def broadcast(self, text: str, *, from_name: str = "human:orchestrator") -> Message:
return await self.human(from_name).send_message(to="*", text=text)
Expand Down Expand Up @@ -773,6 +796,7 @@ def on_event(event: BrokerEvent) -> None:
msg = Message(
event_id=event.get("event_id", ""),
from_name=event.get("from", ""),
from_kind=event.get("sender_kind"),
to=event.get("target", ""),
text=event.get("body", ""),
thread_id=event.get("thread_id"),
Expand Down
36 changes: 34 additions & 2 deletions packages/sdk-py/tests/test_send_message_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ async def fake_request_ok(type_: str, payload: dict):
to="Worker",
text="hello",
from_="system",
from_kind="system",
thread_id="thread-1",
priority=5,
data={"k": "v"},
Expand All @@ -38,6 +39,7 @@ async def fake_request_ok(type_: str, payload: dict):
"to": "Worker",
"text": "hello",
"from": "system",
"senderKind": "system",
"thread_id": "thread-1",
"priority": 5,
"data": {"k": "v"},
Expand All @@ -53,14 +55,17 @@ async def test_human_send_message_passes_mode_and_sets_message_mode():
client.send_message = AsyncMock(return_value={"event_id": "evt-2"})
relay._ensure_started = AsyncMock(return_value=client)

human = HumanHandle("system", relay)
human = HumanHandle("operator", relay)
msg = await human.send_message(to="Worker", text="status?", mode="wait")

assert msg.mode == "wait"
assert msg.from_kind == "human"
assert human.kind == "human"
client.send_message.assert_awaited_once_with(
to="Worker",
text="status?",
from_="system",
from_="operator",
from_kind="human",
thread_id=None,
priority=None,
data=None,
Expand All @@ -80,12 +85,39 @@ async def test_agent_send_message_passes_mode_and_sets_message_mode():
msg = await agent.send_message(to="Reviewer", text="ready", mode="steer")

assert msg.mode == "steer"
assert msg.from_kind == "agent"
client.send_message.assert_awaited_with(
to="Reviewer",
text="ready",
from_="Worker",
from_kind="agent",
thread_id=None,
priority=None,
data=None,
mode="steer",
)


@pytest.mark.asyncio
async def test_system_handle_is_distinct_from_human_handle():
relay = AgentRelay()
client = AsyncMock()
client.send_message = AsyncMock(return_value={"event_id": "evt-4"})
relay._ensure_started = AsyncMock(return_value=client)

system = relay.system()
msg = await system.send_message(to="Worker", text="deterministic notice")

assert system.kind == "system"
assert system.name == "system"
assert msg.from_kind == "system"
client.send_message.assert_awaited_once_with(
to="Worker",
text="deterministic notice",
from_="system",
from_kind="system",
thread_id=None,
priority=None,
data=None,
mode=None,
)
22 changes: 22 additions & 0 deletions packages/sdk/src/__tests__/orchestration-upgrades.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -756,9 +756,31 @@ describe('AgentRelay orchestration handles', () => {
to: 'worker-1',
text: 'New task assigned',
from: 'system',
fromKind: 'system',
})
);
expect(system.kind).toBe('system');
expect(message.from).toBe('system');
expect(message.fromKind).toBe('system');
} finally {
await relay.shutdown();
}
});

it('human() and system() stay type-distinct while sharing the send API', async () => {
const { client } = createMockFacadeClient();
vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client);

const relay = new AgentRelay();

try {
const human = relay.human({ name: 'Owner' });
const system = relay.system();

expect(human.kind).toBe('human');
expect(system.kind).toBe('system');
expect(human.name).toBe('Owner');
expect(system.name).toBe('system');
} finally {
await relay.shutdown();
}
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,7 @@ export class AgentRelayClient {
to: input.to,
text: input.text,
from: input.from,
senderKind: input.fromKind,
threadId: input.threadId,
workspaceId: input.workspaceId,
workspaceAlias: input.workspaceAlias,
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export const PROTOCOL_VERSION = 1 as const;

export type AgentRuntime = 'pty' | 'headless';
export type HeadlessProvider = 'claude' | 'opencode';
export type ParticipantKind = 'agent' | 'human' | 'system';
export type SenderKind = ParticipantKind | 'unknown';

export interface RestartPolicy {
enabled?: boolean;
Expand Down Expand Up @@ -62,6 +64,7 @@ export type SdkToBroker =
to: string;
text: string;
from?: string;
from_kind?: ParticipantKind;
thread_id?: string;
workspace_id?: string;
workspace_alias?: string;
Expand Down Expand Up @@ -230,6 +233,7 @@ export type BrokerEvent =
kind: 'relay_inbound';
event_id: string;
from: string;
sender_kind?: SenderKind;
target: string;
body: string;
thread_id?: string;
Expand Down
Loading
Loading