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
2 changes: 1 addition & 1 deletion .python-version
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
3.13
3.10
11 changes: 9 additions & 2 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,12 +23,18 @@ pip install llpsdk
```python
import asyncio, os
import llpsdk as llp
from my_agent import create_agent

# called when starting a conversation with a test agent
def on_start():
# create an instance of your agent
return create_agent()

# Define a callback handler for processing messages
async def on_message(annotater, msg):
async def on_message(agent, annotater, msg):
# Process the prompt with your agent.
# Replace this with your own processing logic.
response = msg.prompt
response = await agent.ainvoke(msg.prompt)

# You must return a response
return msg.reply(response)
Expand All@@ -41,6 +47,7 @@ async def main():
)

# Register your message handler
client.on_start(on_start)
client.on_message(on_message)

try:
Expand Down
3 changes: 2 additions & 1 deletion examples/.env
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
LLP_URL="ws://localhost:4000/agent/websocket"
LLP_URL="wss://llphq.com/agent/websocket"
LLP_API_KEY=
OLLAMA_API_KEY=
101 changes: 101 additions & 0 deletions examples/langchain_agent.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
"""Agent example demonstrating basic LLP SDK usage with langchain."""
import asyncio
import llpsdk as llp
from llpsdk.langchain import LLPAnnotationMiddleware
import os
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_ollama import ChatOllama
from dotenv import load_dotenv

@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
city_lower = city.lower()
weather_data = {
"new york": "Partly cloudy, 45°F, wind 12 mph NW",
"los angeles": "Sunny, 72°F, wind 5 mph SW",
"chicago": "Overcast, 30°F, wind 20 mph N, chance of snow",
"miami": "Humid and warm, 82°F, wind 8 mph SE",
"seattle": "Rainy, 48°F, wind 10 mph W",
"denver": "Clear skies, 38°F, wind 15 mph NE",
"san francisco": "Foggy, 58°F, wind 14 mph W",
"austin": "Sunny, 68°F, wind 7 mph S",
"boston": "Cold and windy, 35°F, wind 22 mph NW",
"london": "Drizzle, 50°F, wind 11 mph SW",
}
return weather_data.get(city_lower, f"No weather data available for {city}")

async def main() -> None:
"""Run a simple agent that connects, sends presence, and sends a message."""
load_dotenv()
platform_url = os.getenv("LLP_URL")
api_key = os.getenv("LLP_API_KEY")
ollama_key = os.getenv("OLLAMA_API_KEY")

if platform_url is None:
raise Exception("LLP_URL env var is not defined")

if api_key is None:
raise Exception("LLP_API_KEY env var is not defined")

if ollama_key is None:
raise Exception("OLLAMA_API_KEY env var is not defined")

cfg = llp.Config(platform_url=platform_url)
cfg.platform_url = platform_url
client = llp.Client("simple-agent", api_key, cfg)

# Set up handlers
def on_start():
model = ChatOllama(
model="gpt-oss:120b",
base_url="https://ollama.com",
api_key=ollama_key,
)
return create_agent(
model=model,
tools=[get_weather],
middleware=[LLPAnnotationMiddleware()],
system_prompt="You are a helpful meteorologist that gives succinct responses regarding the weather for various American cities."
)

async def on_message(agent, annotater: llp.Annotater, msg: llp.TextMessage) -> llp.TextMessage:
if agent is not None:
result = await agent.ainvoke(
{
"messages": [{"role": "user", "content": msg.prompt}],
"message": msg,
"annotater": annotater,
}
)
reply = result["messages"][-1].content
return msg.reply(reply)
else:
return msg.reply("I'm a helpful meteorologist!")

# Register handlers
client.on_start(on_start)
client.on_message(on_message)

try:
# Connect and authenticate
print("Connecting to server...")
await client.connect()
print(f"Connected!! Session ID: {client.session_id}")

# Keep running
print("Agent running. Press Ctrl+C to exit...")
await asyncio.Event().wait()

except KeyboardInterrupt:
print("\nShutting down...")
except Exception as e:
print(f"Error: {e}")
finally:
await client.close()
print("Disconnected.")


if __name__ == "__main__":
asyncio.run(main())
3 changes: 2 additions & 1 deletion examples/simple_agent.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
"""Agent example demonstrating basic LLP SDK usage."""
import asyncio
from datetime import timedelta
from typing import Any
import llpsdk as llp
import os
from dotenv import load_dotenv
Expand All@@ -23,7 +24,7 @@ async def main() -> None:
client = llp.Client("simple-agent", api_key, cfg)

# Set up handlers
async def on_message(annotater: llp.Annotater, msg: llp.TextMessage) -> llp.TextMessage:
async def on_message(_agent: Any, annotater: llp.Annotater, msg: llp.TextMessage) -> llp.TextMessage:
tc = msg.tool_call("get_weather", '{"city":"Seattle"}', "rainy", timedelta(seconds=1))
await annotater.annotate_tool_call(tc)
return msg.reply("this is my response")
Expand Down
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ license = "MIT"
authors = [
{ name = "Large Language Platform Inc.", email = "founders@llphq.com" }
]
requires-python = ">=3.8"
requires-python = ">=3.8.1, <4.0"
dependencies = [
"websockets>=12.0",
]
Expand All@@ -25,6 +25,7 @@ classifiers = [
[project.optional-dependencies]
examples = [
"dotenv>=0.9.9",
"langchain-ollama>=0.1.3"
]

[build-system]
Expand All@@ -38,7 +39,7 @@ python_functions = ["test_*"]
asyncio_mode = "auto"

[tool.mypy]
python_version = "3.9"
python_version = "3.10"
strict = true
warn_return_any = true
warn_unused_configs = true
Expand All@@ -63,6 +64,7 @@ core-metadata-version = "2.3"
[dependency-groups]
dev = [
"black>=24.8.0",
"langchain>=0.2.17",
"mypy>=1.14.1",
"pytest>=8.3.5",
"pytest-asyncio>=0.24.0",
Expand Down
38 changes: 25 additions & 13 deletions src/llpsdk/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@
NotAuthenticatedError,
PlatformError,
)
from .handler import HandlerRegistry, MessageHandler, PresenceHandler
from .handler import HandlerRegistry, MessageHandler, StartHandler
from .message import (
AuthenticatedResponse,
AuthenticateMessage,
Expand DownExpand Up@@ -55,6 +55,7 @@ def __init__(self, name: str, api_key: str, config: Optional[Config] = None) ->
self._status_lock = asyncio.Lock()
self._presence = PresenceStatus.unavailable
self._presence_lock = asyncio.Lock()
self._agents: Dict[str, Any] = dict()

# Message handling
self._handlers = HandlerRegistry()
Expand DownExpand Up@@ -218,22 +219,24 @@ def presence(self) -> PresenceStatus:
"""Get the current presence status."""
return self._presence

# Event handlers (fluent API)
# Event handlers

def on_presence(self, handler: PresenceHandler) -> None:
def on_start(self, handler: StartHandler) -> None:
"""
Set the presence event handler.
Set the start event handler. This is an initializer function for when
a test agent comes online.

Args:
handler: Callable to handle presence updates
handler: Callable for initializing agents that come online.

Raises:
LLPClientError: If client is already connected

Returns:
Self for fluent chaining
"""
if self._status >= ConnectionStatus.CONNECTING.value:
raise LLPClientError("on_presence can not be called once client is connected.")
raise LLPClientError("on_start can not be called once client is connected.")

self._handlers.set_presence(handler)
self._handlers.set_start(handler)

def on_message(self, handler: MessageHandler) -> None:
"""
Expand All@@ -242,8 +245,8 @@ def on_message(self, handler: MessageHandler) -> None:
Args:
handler: Callable to handle incoming messages

Returns:
Self for fluent chaining
Raises:
LLPClientError: If client is already connected
"""
if self._status >= ConnectionStatus.CONNECTING.value:
raise LLPClientError("on_message can not be called once client is connected.")
Expand DownExpand Up@@ -397,12 +400,21 @@ async def _handle_message(self, msg_dict: Dict[str, Any]) -> None:

if msg_type == "presence":
update = PresenceMessage.decode(msg_dict)
await self._handlers.call_presence(update)
if update.status == PresenceStatus.available:
agent = self._handlers.call_start()
if agent is not None:
self._agents[update.sender] = agent

if update.status == PresenceStatus.unavailable:
# TODO: invoke a "stop" handler for uninitializing
del self._agents[update.sender]

return

if msg_type == "message":
tm = TextMessage.decode(msg_dict)
reply = await self._handlers.call_message(self, tm)
agent = self._agents.get(tm.sender)
reply = await self._handlers.call_message(agent, self, tm)
if reply is not None:
await self._send_async_message(reply)
return
Expand Down
40 changes: 20 additions & 20 deletions src/llpsdk/handler.py
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
"""Event handler registry for the LLP client."""

import asyncio
from typing import Awaitable, Callable, Optional, Union
from typing import Awaitable, Callable, Optional, Any

from typing import Protocol, runtime_checkable

from .message import PresenceMessage, TextMessage
from .message import TextMessage
from .tool_call import ToolCall


Expand All@@ -17,41 +16,42 @@ async def annotate_tool_call(self, tool_call: ToolCall) -> None: ...


# Handler type signatures (supports both sync and async)
PresenceHandler = Union[
Callable[[PresenceMessage], None], Callable[[PresenceMessage], Awaitable[None]]
]
MessageHandler = Callable[["Annotater", TextMessage], Awaitable[TextMessage]]
MessageHandler = Callable[[Any | None, "Annotater", TextMessage], Awaitable[TextMessage]]
StartHandler = Callable[[], Any]


class HandlerRegistry:
"""Registry for event handlers (message and presence only)."""

def __init__(self) -> None:
"""Initialize the handler registry."""
self._on_presence: Optional[PresenceHandler] = None
self._on_message: Optional[MessageHandler] = None
self._on_start: Optional[StartHandler] = None

def set_presence(self, handler: PresenceHandler) -> None:
"""Set the presence event handler."""
self._on_presence = handler
def set_start(self, handler: StartHandler) -> None:
"""Set the start event handler."""
self._on_start = handler

def set_message(self, handler: MessageHandler) -> None:
"""Set the message event handler."""
self._on_message = handler

async def call_presence(self, update: PresenceMessage) -> None:
"""Call the presence handler if set."""
if self._on_presence is not None:
if asyncio.iscoroutinefunction(self._on_presence):
await self._on_presence(update)
else:
self._on_presence(update)
def call_start(self) -> Any | None:
"""
Call the start handler if set.

Returns: If set, returns initial state (e.g. langchain agent instance).
"""
if self._on_start is not None:
return self._on_start()
else:
return None

async def call_message(
self, annotater: Annotater, message: TextMessage
self, agent: Any | None, annotater: Annotater, message: TextMessage
) -> Optional[TextMessage]:
"""Call the message handler if set, passing annotater for tool call telemetry."""
if self._on_message is not None:
result = await self._on_message(annotater, message)
result = await self._on_message(agent, annotater, message)
return result
return None
7 changes: 7 additions & 0 deletions src/llpsdk/langchain/__init__.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
from .middleware import LLPState, LLPAnnotationMiddleware

__all__ = [
"LLPState",
"LLPAnnotationMiddleware",
]
__version__ = "0.1.0"
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
2 changes: 1 addition & 1 deletion .python-version
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
3.13
3.10
11 changes: 9 additions & 2 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,12 +23,18 @@ pip install llpsdk
```python
import asyncio, os
import llpsdk as llp
from my_agent import create_agent

# called when starting a conversation with a test agent
def on_start():
# create an instance of your agent
return create_agent()

# Define a callback handler for processing messages
async def on_message(annotater, msg):
async def on_message(agent, annotater, msg):
# Process the prompt with your agent.
# Replace this with your own processing logic.
response = msg.prompt
response = await agent.ainvoke(msg.prompt)

# You must return a response
return msg.reply(response)
Expand All@@ -41,6 +47,7 @@ async def main():
)

# Register your message handler
client.on_start(on_start)
client.on_message(on_message)

try:
Expand Down
3 changes: 2 additions & 1 deletion examples/.env
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
LLP_URL="ws://localhost:4000/agent/websocket"
LLP_URL="wss://llphq.com/agent/websocket"
LLP_API_KEY=
OLLAMA_API_KEY=
101 changes: 101 additions & 0 deletions examples/langchain_agent.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
"""Agent example demonstrating basic LLP SDK usage with langchain."""
import asyncio
import llpsdk as llp
from llpsdk.langchain import LLPAnnotationMiddleware
import os
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_ollama import ChatOllama
from dotenv import load_dotenv

@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
city_lower = city.lower()
weather_data = {
"new york": "Partly cloudy, 45°F, wind 12 mph NW",
"los angeles": "Sunny, 72°F, wind 5 mph SW",
"chicago": "Overcast, 30°F, wind 20 mph N, chance of snow",
"miami": "Humid and warm, 82°F, wind 8 mph SE",
"seattle": "Rainy, 48°F, wind 10 mph W",
"denver": "Clear skies, 38°F, wind 15 mph NE",
"san francisco": "Foggy, 58°F, wind 14 mph W",
"austin": "Sunny, 68°F, wind 7 mph S",
"boston": "Cold and windy, 35°F, wind 22 mph NW",
"london": "Drizzle, 50°F, wind 11 mph SW",
}
return weather_data.get(city_lower, f"No weather data available for {city}")

async def main() -> None:
"""Run a simple agent that connects, sends presence, and sends a message."""
load_dotenv()
platform_url = os.getenv("LLP_URL")
api_key = os.getenv("LLP_API_KEY")
ollama_key = os.getenv("OLLAMA_API_KEY")

if platform_url is None:
raise Exception("LLP_URL env var is not defined")

if api_key is None:
raise Exception("LLP_API_KEY env var is not defined")

if ollama_key is None:
raise Exception("OLLAMA_API_KEY env var is not defined")

cfg = llp.Config(platform_url=platform_url)
cfg.platform_url = platform_url
client = llp.Client("simple-agent", api_key, cfg)

# Set up handlers
def on_start():
model = ChatOllama(
model="gpt-oss:120b",
base_url="https://ollama.com",
api_key=ollama_key,
)
return create_agent(
model=model,
tools=[get_weather],
middleware=[LLPAnnotationMiddleware()],
system_prompt="You are a helpful meteorologist that gives succinct responses regarding the weather for various American cities."
)

async def on_message(agent, annotater: llp.Annotater, msg: llp.TextMessage) -> llp.TextMessage:
if agent is not None:
result = await agent.ainvoke(
{
"messages": [{"role": "user", "content": msg.prompt}],
"message": msg,
"annotater": annotater,
}
)
reply = result["messages"][-1].content
return msg.reply(reply)
else:
return msg.reply("I'm a helpful meteorologist!")

# Register handlers
client.on_start(on_start)
client.on_message(on_message)

try:
# Connect and authenticate
print("Connecting to server...")
await client.connect()
print(f"Connected!! Session ID: {client.session_id}")

# Keep running
print("Agent running. Press Ctrl+C to exit...")
await asyncio.Event().wait()

except KeyboardInterrupt:
print("\nShutting down...")
except Exception as e:
print(f"Error: {e}")
finally:
await client.close()
print("Disconnected.")


if __name__ == "__main__":
asyncio.run(main())
3 changes: 2 additions & 1 deletion examples/simple_agent.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
"""Agent example demonstrating basic LLP SDK usage."""
import asyncio
from datetime import timedelta
from typing import Any
import llpsdk as llp
import os
from dotenv import load_dotenv
Expand All@@ -23,7 +24,7 @@ async def main() -> None:
client = llp.Client("simple-agent", api_key, cfg)

# Set up handlers
async def on_message(annotater: llp.Annotater, msg: llp.TextMessage) -> llp.TextMessage:
async def on_message(_agent: Any, annotater: llp.Annotater, msg: llp.TextMessage) -> llp.TextMessage:
tc = msg.tool_call("get_weather", '{"city":"Seattle"}', "rainy", timedelta(seconds=1))
await annotater.annotate_tool_call(tc)
return msg.reply("this is my response")
Expand Down
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ license = "MIT"
authors = [
{ name = "Large Language Platform Inc.", email = "founders@llphq.com" }
]
requires-python = ">=3.8"
requires-python = ">=3.8.1, <4.0"
dependencies = [
"websockets>=12.0",
]
Expand All@@ -25,6 +25,7 @@ classifiers = [
[project.optional-dependencies]
examples = [
"dotenv>=0.9.9",
"langchain-ollama>=0.1.3"
]

[build-system]
Expand All@@ -38,7 +39,7 @@ python_functions = ["test_*"]
asyncio_mode = "auto"

[tool.mypy]
python_version = "3.9"
python_version = "3.10"
strict = true
warn_return_any = true
warn_unused_configs = true
Expand All@@ -63,6 +64,7 @@ core-metadata-version = "2.3"
[dependency-groups]
dev = [
"black>=24.8.0",
"langchain>=0.2.17",
"mypy>=1.14.1",
"pytest>=8.3.5",
"pytest-asyncio>=0.24.0",
Expand Down
38 changes: 25 additions & 13 deletions src/llpsdk/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@
NotAuthenticatedError,
PlatformError,
)
from .handler import HandlerRegistry, MessageHandler, PresenceHandler
from .handler import HandlerRegistry, MessageHandler, StartHandler
from .message import (
AuthenticatedResponse,
AuthenticateMessage,
Expand DownExpand Up@@ -55,6 +55,7 @@ def __init__(self, name: str, api_key: str, config: Optional[Config] = None) ->
self._status_lock = asyncio.Lock()
self._presence = PresenceStatus.unavailable
self._presence_lock = asyncio.Lock()
self._agents: Dict[str, Any] = dict()

# Message handling
self._handlers = HandlerRegistry()
Expand DownExpand Up@@ -218,22 +219,24 @@ def presence(self) -> PresenceStatus:
"""Get the current presence status."""
return self._presence

# Event handlers (fluent API)
# Event handlers

def on_presence(self, handler: PresenceHandler) -> None:
def on_start(self, handler: StartHandler) -> None:
"""
Set the presence event handler.
Set the start event handler. This is an initializer function for when
a test agent comes online.

Args:
handler: Callable to handle presence updates
handler: Callable for initializing agents that come online.

Raises:
LLPClientError: If client is already connected

Returns:
Self for fluent chaining
"""
if self._status >= ConnectionStatus.CONNECTING.value:
raise LLPClientError("on_presence can not be called once client is connected.")
raise LLPClientError("on_start can not be called once client is connected.")

self._handlers.set_presence(handler)
self._handlers.set_start(handler)

def on_message(self, handler: MessageHandler) -> None:
"""
Expand All@@ -242,8 +245,8 @@ def on_message(self, handler: MessageHandler) -> None:
Args:
handler: Callable to handle incoming messages

Returns:
Self for fluent chaining
Raises:
LLPClientError: If client is already connected
"""
if self._status >= ConnectionStatus.CONNECTING.value:
raise LLPClientError("on_message can not be called once client is connected.")
Expand DownExpand Up@@ -397,12 +400,21 @@ async def _handle_message(self, msg_dict: Dict[str, Any]) -> None:

if msg_type == "presence":
update = PresenceMessage.decode(msg_dict)
await self._handlers.call_presence(update)
if update.status == PresenceStatus.available:
agent = self._handlers.call_start()
if agent is not None:
self._agents[update.sender] = agent

if update.status == PresenceStatus.unavailable:
# TODO: invoke a "stop" handler for uninitializing
del self._agents[update.sender]

return

if msg_type == "message":
tm = TextMessage.decode(msg_dict)
reply = await self._handlers.call_message(self, tm)
agent = self._agents.get(tm.sender)
reply = await self._handlers.call_message(agent, self, tm)
if reply is not None:
await self._send_async_message(reply)
return
Expand Down
40 changes: 20 additions & 20 deletions src/llpsdk/handler.py
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
"""Event handler registry for the LLP client."""

import asyncio
from typing import Awaitable, Callable, Optional, Union
from typing import Awaitable, Callable, Optional, Any

from typing import Protocol, runtime_checkable

from .message import PresenceMessage, TextMessage
from .message import TextMessage
from .tool_call import ToolCall


Expand All@@ -17,41 +16,42 @@ async def annotate_tool_call(self, tool_call: ToolCall) -> None: ...


# Handler type signatures (supports both sync and async)
PresenceHandler = Union[
Callable[[PresenceMessage], None], Callable[[PresenceMessage], Awaitable[None]]
]
MessageHandler = Callable[["Annotater", TextMessage], Awaitable[TextMessage]]
MessageHandler = Callable[[Any | None, "Annotater", TextMessage], Awaitable[TextMessage]]
StartHandler = Callable[[], Any]


class HandlerRegistry:
"""Registry for event handlers (message and presence only)."""

def __init__(self) -> None:
"""Initialize the handler registry."""
self._on_presence: Optional[PresenceHandler] = None
self._on_message: Optional[MessageHandler] = None
self._on_start: Optional[StartHandler] = None

def set_presence(self, handler: PresenceHandler) -> None:
"""Set the presence event handler."""
self._on_presence = handler
def set_start(self, handler: StartHandler) -> None:
"""Set the start event handler."""
self._on_start = handler

def set_message(self, handler: MessageHandler) -> None:
"""Set the message event handler."""
self._on_message = handler

async def call_presence(self, update: PresenceMessage) -> None:
"""Call the presence handler if set."""
if self._on_presence is not None:
if asyncio.iscoroutinefunction(self._on_presence):
await self._on_presence(update)
else:
self._on_presence(update)
def call_start(self) -> Any | None:
"""
Call the start handler if set.

Returns: If set, returns initial state (e.g. langchain agent instance).
"""
if self._on_start is not None:
return self._on_start()
else:
return None

async def call_message(
self, annotater: Annotater, message: TextMessage
self, agent: Any | None, annotater: Annotater, message: TextMessage
) -> Optional[TextMessage]:
"""Call the message handler if set, passing annotater for tool call telemetry."""
if self._on_message is not None:
result = await self._on_message(annotater, message)
result = await self._on_message(agent, annotater, message)
return result
return None
7 changes: 7 additions & 0 deletions src/llpsdk/langchain/__init__.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
from .middleware import LLPState, LLPAnnotationMiddleware

__all__ = [
"LLPState",
"LLPAnnotationMiddleware",
]
__version__ = "0.1.0"
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
2 changes: 1 addition & 1 deletion .python-version
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
3.13
3.10
11 changes: 9 additions & 2 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,12 +23,18 @@ pip install llpsdk
```python
import asyncio, os
import llpsdk as llp
from my_agent import create_agent

# called when starting a conversation with a test agent
def on_start():
# create an instance of your agent
return create_agent()

# Define a callback handler for processing messages
async def on_message(annotater, msg):
async def on_message(agent, annotater, msg):
# Process the prompt with your agent.
# Replace this with your own processing logic.
response = msg.prompt
response = await agent.ainvoke(msg.prompt)

# You must return a response
return msg.reply(response)
Expand All@@ -41,6 +47,7 @@ async def main():
)

# Register your message handler
client.on_start(on_start)
client.on_message(on_message)

try:
Expand Down
3 changes: 2 additions & 1 deletion examples/.env
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
LLP_URL="ws://localhost:4000/agent/websocket"
LLP_URL="wss://llphq.com/agent/websocket"
LLP_API_KEY=
OLLAMA_API_KEY=
101 changes: 101 additions & 0 deletions examples/langchain_agent.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
"""Agent example demonstrating basic LLP SDK usage with langchain."""
import asyncio
import llpsdk as llp
from llpsdk.langchain import LLPAnnotationMiddleware
import os
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_ollama import ChatOllama
from dotenv import load_dotenv

@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
city_lower = city.lower()
weather_data = {
"new york": "Partly cloudy, 45°F, wind 12 mph NW",
"los angeles": "Sunny, 72°F, wind 5 mph SW",
"chicago": "Overcast, 30°F, wind 20 mph N, chance of snow",
"miami": "Humid and warm, 82°F, wind 8 mph SE",
"seattle": "Rainy, 48°F, wind 10 mph W",
"denver": "Clear skies, 38°F, wind 15 mph NE",
"san francisco": "Foggy, 58°F, wind 14 mph W",
"austin": "Sunny, 68°F, wind 7 mph S",
"boston": "Cold and windy, 35°F, wind 22 mph NW",
"london": "Drizzle, 50°F, wind 11 mph SW",
}
return weather_data.get(city_lower, f"No weather data available for {city}")

async def main() -> None:
"""Run a simple agent that connects, sends presence, and sends a message."""
load_dotenv()
platform_url = os.getenv("LLP_URL")
api_key = os.getenv("LLP_API_KEY")
ollama_key = os.getenv("OLLAMA_API_KEY")

if platform_url is None:
raise Exception("LLP_URL env var is not defined")

if api_key is None:
raise Exception("LLP_API_KEY env var is not defined")

if ollama_key is None:
raise Exception("OLLAMA_API_KEY env var is not defined")

cfg = llp.Config(platform_url=platform_url)
cfg.platform_url = platform_url
client = llp.Client("simple-agent", api_key, cfg)

# Set up handlers
def on_start():
model = ChatOllama(
model="gpt-oss:120b",
base_url="https://ollama.com",
api_key=ollama_key,
)
return create_agent(
model=model,
tools=[get_weather],
middleware=[LLPAnnotationMiddleware()],
system_prompt="You are a helpful meteorologist that gives succinct responses regarding the weather for various American cities."
)

async def on_message(agent, annotater: llp.Annotater, msg: llp.TextMessage) -> llp.TextMessage:
if agent is not None:
result = await agent.ainvoke(
{
"messages": [{"role": "user", "content": msg.prompt}],
"message": msg,
"annotater": annotater,
}
)
reply = result["messages"][-1].content
return msg.reply(reply)
else:
return msg.reply("I'm a helpful meteorologist!")

# Register handlers
client.on_start(on_start)
client.on_message(on_message)

try:
# Connect and authenticate
print("Connecting to server...")
await client.connect()
print(f"Connected!! Session ID: {client.session_id}")

# Keep running
print("Agent running. Press Ctrl+C to exit...")
await asyncio.Event().wait()

except KeyboardInterrupt:
print("\nShutting down...")
except Exception as e:
print(f"Error: {e}")
finally:
await client.close()
print("Disconnected.")


if __name__ == "__main__":
asyncio.run(main())
3 changes: 2 additions & 1 deletion examples/simple_agent.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
"""Agent example demonstrating basic LLP SDK usage."""
import asyncio
from datetime import timedelta
from typing import Any
import llpsdk as llp
import os
from dotenv import load_dotenv
Expand All@@ -23,7 +24,7 @@ async def main() -> None:
client = llp.Client("simple-agent", api_key, cfg)

# Set up handlers
async def on_message(annotater: llp.Annotater, msg: llp.TextMessage) -> llp.TextMessage:
async def on_message(_agent: Any, annotater: llp.Annotater, msg: llp.TextMessage) -> llp.TextMessage:
tc = msg.tool_call("get_weather", '{"city":"Seattle"}', "rainy", timedelta(seconds=1))
await annotater.annotate_tool_call(tc)
return msg.reply("this is my response")
Expand Down
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ license = "MIT"
authors = [
{ name = "Large Language Platform Inc.", email = "founders@llphq.com" }
]
requires-python = ">=3.8"
requires-python = ">=3.8.1, <4.0"
dependencies = [
"websockets>=12.0",
]
Expand All@@ -25,6 +25,7 @@ classifiers = [
[project.optional-dependencies]
examples = [
"dotenv>=0.9.9",
"langchain-ollama>=0.1.3"
]

[build-system]
Expand All@@ -38,7 +39,7 @@ python_functions = ["test_*"]
asyncio_mode = "auto"

[tool.mypy]
python_version = "3.9"
python_version = "3.10"
strict = true
warn_return_any = true
warn_unused_configs = true
Expand All@@ -63,6 +64,7 @@ core-metadata-version = "2.3"
[dependency-groups]
dev = [
"black>=24.8.0",
"langchain>=0.2.17",
"mypy>=1.14.1",
"pytest>=8.3.5",
"pytest-asyncio>=0.24.0",
Expand Down
38 changes: 25 additions & 13 deletions src/llpsdk/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@
NotAuthenticatedError,
PlatformError,
)
from .handler import HandlerRegistry, MessageHandler, PresenceHandler
from .handler import HandlerRegistry, MessageHandler, StartHandler
from .message import (
AuthenticatedResponse,
AuthenticateMessage,
Expand DownExpand Up@@ -55,6 +55,7 @@ def __init__(self, name: str, api_key: str, config: Optional[Config] = None) ->
self._status_lock = asyncio.Lock()
self._presence = PresenceStatus.unavailable
self._presence_lock = asyncio.Lock()
self._agents: Dict[str, Any] = dict()

# Message handling
self._handlers = HandlerRegistry()
Expand DownExpand Up@@ -218,22 +219,24 @@ def presence(self) -> PresenceStatus:
"""Get the current presence status."""
return self._presence

# Event handlers (fluent API)
# Event handlers

def on_presence(self, handler: PresenceHandler) -> None:
def on_start(self, handler: StartHandler) -> None:
"""
Set the presence event handler.
Set the start event handler. This is an initializer function for when
a test agent comes online.

Args:
handler: Callable to handle presence updates
handler: Callable for initializing agents that come online.

Raises:
LLPClientError: If client is already connected

Returns:
Self for fluent chaining
"""
if self._status >= ConnectionStatus.CONNECTING.value:
raise LLPClientError("on_presence can not be called once client is connected.")
raise LLPClientError("on_start can not be called once client is connected.")

self._handlers.set_presence(handler)
self._handlers.set_start(handler)

def on_message(self, handler: MessageHandler) -> None:
"""
Expand All@@ -242,8 +245,8 @@ def on_message(self, handler: MessageHandler) -> None:
Args:
handler: Callable to handle incoming messages

Returns:
Self for fluent chaining
Raises:
LLPClientError: If client is already connected
"""
if self._status >= ConnectionStatus.CONNECTING.value:
raise LLPClientError("on_message can not be called once client is connected.")
Expand DownExpand Up@@ -397,12 +400,21 @@ async def _handle_message(self, msg_dict: Dict[str, Any]) -> None:

if msg_type == "presence":
update = PresenceMessage.decode(msg_dict)
await self._handlers.call_presence(update)
if update.status == PresenceStatus.available:
agent = self._handlers.call_start()
if agent is not None:
self._agents[update.sender] = agent

if update.status == PresenceStatus.unavailable:
# TODO: invoke a "stop" handler for uninitializing
del self._agents[update.sender]

return

if msg_type == "message":
tm = TextMessage.decode(msg_dict)
reply = await self._handlers.call_message(self, tm)
agent = self._agents.get(tm.sender)
reply = await self._handlers.call_message(agent, self, tm)
if reply is not None:
await self._send_async_message(reply)
return
Expand Down
40 changes: 20 additions & 20 deletions src/llpsdk/handler.py
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
"""Event handler registry for the LLP client."""

import asyncio
from typing import Awaitable, Callable, Optional, Union
from typing import Awaitable, Callable, Optional, Any

from typing import Protocol, runtime_checkable

from .message import PresenceMessage, TextMessage
from .message import TextMessage
from .tool_call import ToolCall


Expand All@@ -17,41 +16,42 @@ async def annotate_tool_call(self, tool_call: ToolCall) -> None: ...


# Handler type signatures (supports both sync and async)
PresenceHandler = Union[
Callable[[PresenceMessage], None], Callable[[PresenceMessage], Awaitable[None]]
]
MessageHandler = Callable[["Annotater", TextMessage], Awaitable[TextMessage]]
MessageHandler = Callable[[Any | None, "Annotater", TextMessage], Awaitable[TextMessage]]
StartHandler = Callable[[], Any]


class HandlerRegistry:
"""Registry for event handlers (message and presence only)."""

def __init__(self) -> None:
"""Initialize the handler registry."""
self._on_presence: Optional[PresenceHandler] = None
self._on_message: Optional[MessageHandler] = None
self._on_start: Optional[StartHandler] = None

def set_presence(self, handler: PresenceHandler) -> None:
"""Set the presence event handler."""
self._on_presence = handler
def set_start(self, handler: StartHandler) -> None:
"""Set the start event handler."""
self._on_start = handler

def set_message(self, handler: MessageHandler) -> None:
"""Set the message event handler."""
self._on_message = handler

async def call_presence(self, update: PresenceMessage) -> None:
"""Call the presence handler if set."""
if self._on_presence is not None:
if asyncio.iscoroutinefunction(self._on_presence):
await self._on_presence(update)
else:
self._on_presence(update)
def call_start(self) -> Any | None:
"""
Call the start handler if set.

Returns: If set, returns initial state (e.g. langchain agent instance).
"""
if self._on_start is not None:
return self._on_start()
else:
return None

async def call_message(
self, annotater: Annotater, message: TextMessage
self, agent: Any | None, annotater: Annotater, message: TextMessage
) -> Optional[TextMessage]:
"""Call the message handler if set, passing annotater for tool call telemetry."""
if self._on_message is not None:
result = await self._on_message(annotater, message)
result = await self._on_message(agent, annotater, message)
return result
return None
7 changes: 7 additions & 0 deletions src/llpsdk/langchain/__init__.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
from .middleware import LLPState, LLPAnnotationMiddleware

__all__ = [
"LLPState",
"LLPAnnotationMiddleware",
]
__version__ = "0.1.0"
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
2 changes: 1 addition & 1 deletion .python-version
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
3.13
3.10
11 changes: 9 additions & 2 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,12 +23,18 @@ pip install llpsdk
```python
import asyncio, os
import llpsdk as llp
from my_agent import create_agent

# called when starting a conversation with a test agent
def on_start():
# create an instance of your agent
return create_agent()

# Define a callback handler for processing messages
async def on_message(annotater, msg):
async def on_message(agent, annotater, msg):
# Process the prompt with your agent.
# Replace this with your own processing logic.
response = msg.prompt
response = await agent.ainvoke(msg.prompt)

# You must return a response
return msg.reply(response)
Expand All@@ -41,6 +47,7 @@ async def main():
)

# Register your message handler
client.on_start(on_start)
client.on_message(on_message)

try:
Expand Down
3 changes: 2 additions & 1 deletion examples/.env
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
LLP_URL="ws://localhost:4000/agent/websocket"
LLP_URL="wss://llphq.com/agent/websocket"
LLP_API_KEY=
OLLAMA_API_KEY=
101 changes: 101 additions & 0 deletions examples/langchain_agent.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
"""Agent example demonstrating basic LLP SDK usage with langchain."""
import asyncio
import llpsdk as llp
from llpsdk.langchain import LLPAnnotationMiddleware
import os
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_ollama import ChatOllama
from dotenv import load_dotenv

@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
city_lower = city.lower()
weather_data = {
"new york": "Partly cloudy, 45°F, wind 12 mph NW",
"los angeles": "Sunny, 72°F, wind 5 mph SW",
"chicago": "Overcast, 30°F, wind 20 mph N, chance of snow",
"miami": "Humid and warm, 82°F, wind 8 mph SE",
"seattle": "Rainy, 48°F, wind 10 mph W",
"denver": "Clear skies, 38°F, wind 15 mph NE",
"san francisco": "Foggy, 58°F, wind 14 mph W",
"austin": "Sunny, 68°F, wind 7 mph S",
"boston": "Cold and windy, 35°F, wind 22 mph NW",
"london": "Drizzle, 50°F, wind 11 mph SW",
}
return weather_data.get(city_lower, f"No weather data available for {city}")

async def main() -> None:
"""Run a simple agent that connects, sends presence, and sends a message."""
load_dotenv()
platform_url = os.getenv("LLP_URL")
api_key = os.getenv("LLP_API_KEY")
ollama_key = os.getenv("OLLAMA_API_KEY")

if platform_url is None:
raise Exception("LLP_URL env var is not defined")

if api_key is None:
raise Exception("LLP_API_KEY env var is not defined")

if ollama_key is None:
raise Exception("OLLAMA_API_KEY env var is not defined")

cfg = llp.Config(platform_url=platform_url)
cfg.platform_url = platform_url
client = llp.Client("simple-agent", api_key, cfg)

# Set up handlers
def on_start():
model = ChatOllama(
model="gpt-oss:120b",
base_url="https://ollama.com",
api_key=ollama_key,
)
return create_agent(
model=model,
tools=[get_weather],
middleware=[LLPAnnotationMiddleware()],
system_prompt="You are a helpful meteorologist that gives succinct responses regarding the weather for various American cities."
)

async def on_message(agent, annotater: llp.Annotater, msg: llp.TextMessage) -> llp.TextMessage:
if agent is not None:
result = await agent.ainvoke(
{
"messages": [{"role": "user", "content": msg.prompt}],
"message": msg,
"annotater": annotater,
}
)
reply = result["messages"][-1].content
return msg.reply(reply)
else:
return msg.reply("I'm a helpful meteorologist!")

# Register handlers
client.on_start(on_start)
client.on_message(on_message)

try:
# Connect and authenticate
print("Connecting to server...")
await client.connect()
print(f"Connected!! Session ID: {client.session_id}")

# Keep running
print("Agent running. Press Ctrl+C to exit...")
await asyncio.Event().wait()

except KeyboardInterrupt:
print("\nShutting down...")
except Exception as e:
print(f"Error: {e}")
finally:
await client.close()
print("Disconnected.")


if __name__ == "__main__":
asyncio.run(main())
3 changes: 2 additions & 1 deletion examples/simple_agent.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
"""Agent example demonstrating basic LLP SDK usage."""
import asyncio
from datetime import timedelta
from typing import Any
import llpsdk as llp
import os
from dotenv import load_dotenv
Expand All@@ -23,7 +24,7 @@ async def main() -> None:
client = llp.Client("simple-agent", api_key, cfg)

# Set up handlers
async def on_message(annotater: llp.Annotater, msg: llp.TextMessage) -> llp.TextMessage:
async def on_message(_agent: Any, annotater: llp.Annotater, msg: llp.TextMessage) -> llp.TextMessage:
tc = msg.tool_call("get_weather", '{"city":"Seattle"}', "rainy", timedelta(seconds=1))
await annotater.annotate_tool_call(tc)
return msg.reply("this is my response")
Expand Down
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ license = "MIT"
authors = [
{ name = "Large Language Platform Inc.", email = "founders@llphq.com" }
]
requires-python = ">=3.8"
requires-python = ">=3.8.1, <4.0"
dependencies = [
"websockets>=12.0",
]
Expand All@@ -25,6 +25,7 @@ classifiers = [
[project.optional-dependencies]
examples = [
"dotenv>=0.9.9",
"langchain-ollama>=0.1.3"
]

[build-system]
Expand All@@ -38,7 +39,7 @@ python_functions = ["test_*"]
asyncio_mode = "auto"

[tool.mypy]
python_version = "3.9"
python_version = "3.10"
strict = true
warn_return_any = true
warn_unused_configs = true
Expand All@@ -63,6 +64,7 @@ core-metadata-version = "2.3"
[dependency-groups]
dev = [
"black>=24.8.0",
"langchain>=0.2.17",
"mypy>=1.14.1",
"pytest>=8.3.5",
"pytest-asyncio>=0.24.0",
Expand Down
38 changes: 25 additions & 13 deletions src/llpsdk/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@
NotAuthenticatedError,
PlatformError,
)
from .handler import HandlerRegistry, MessageHandler, PresenceHandler
from .handler import HandlerRegistry, MessageHandler, StartHandler
from .message import (
AuthenticatedResponse,
AuthenticateMessage,
Expand DownExpand Up@@ -55,6 +55,7 @@ def __init__(self, name: str, api_key: str, config: Optional[Config] = None) ->
self._status_lock = asyncio.Lock()
self._presence = PresenceStatus.unavailable
self._presence_lock = asyncio.Lock()
self._agents: Dict[str, Any] = dict()

# Message handling
self._handlers = HandlerRegistry()
Expand DownExpand Up@@ -218,22 +219,24 @@ def presence(self) -> PresenceStatus:
"""Get the current presence status."""
return self._presence

# Event handlers (fluent API)
# Event handlers

def on_presence(self, handler: PresenceHandler) -> None:
def on_start(self, handler: StartHandler) -> None:
"""
Set the presence event handler.
Set the start event handler. This is an initializer function for when
a test agent comes online.

Args:
handler: Callable to handle presence updates
handler: Callable for initializing agents that come online.

Raises:
LLPClientError: If client is already connected

Returns:
Self for fluent chaining
"""
if self._status >= ConnectionStatus.CONNECTING.value:
raise LLPClientError("on_presence can not be called once client is connected.")
raise LLPClientError("on_start can not be called once client is connected.")

self._handlers.set_presence(handler)
self._handlers.set_start(handler)

def on_message(self, handler: MessageHandler) -> None:
"""
Expand All@@ -242,8 +245,8 @@ def on_message(self, handler: MessageHandler) -> None:
Args:
handler: Callable to handle incoming messages

Returns:
Self for fluent chaining
Raises:
LLPClientError: If client is already connected
"""
if self._status >= ConnectionStatus.CONNECTING.value:
raise LLPClientError("on_message can not be called once client is connected.")
Expand DownExpand Up@@ -397,12 +400,21 @@ async def _handle_message(self, msg_dict: Dict[str, Any]) -> None:

if msg_type == "presence":
update = PresenceMessage.decode(msg_dict)
await self._handlers.call_presence(update)
if update.status == PresenceStatus.available:
agent = self._handlers.call_start()
if agent is not None:
self._agents[update.sender] = agent

if update.status == PresenceStatus.unavailable:
# TODO: invoke a "stop" handler for uninitializing
del self._agents[update.sender]

return

if msg_type == "message":
tm = TextMessage.decode(msg_dict)
reply = await self._handlers.call_message(self, tm)
agent = self._agents.get(tm.sender)
reply = await self._handlers.call_message(agent, self, tm)
if reply is not None:
await self._send_async_message(reply)
return
Expand Down
40 changes: 20 additions & 20 deletions src/llpsdk/handler.py
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
"""Event handler registry for the LLP client."""

import asyncio
from typing import Awaitable, Callable, Optional, Union
from typing import Awaitable, Callable, Optional, Any

from typing import Protocol, runtime_checkable

from .message import PresenceMessage, TextMessage
from .message import TextMessage
from .tool_call import ToolCall


Expand All@@ -17,41 +16,42 @@ async def annotate_tool_call(self, tool_call: ToolCall) -> None: ...


# Handler type signatures (supports both sync and async)
PresenceHandler = Union[
Callable[[PresenceMessage], None], Callable[[PresenceMessage], Awaitable[None]]
]
MessageHandler = Callable[["Annotater", TextMessage], Awaitable[TextMessage]]
MessageHandler = Callable[[Any | None, "Annotater", TextMessage], Awaitable[TextMessage]]
StartHandler = Callable[[], Any]


class HandlerRegistry:
"""Registry for event handlers (message and presence only)."""

def __init__(self) -> None:
"""Initialize the handler registry."""
self._on_presence: Optional[PresenceHandler] = None
self._on_message: Optional[MessageHandler] = None
self._on_start: Optional[StartHandler] = None

def set_presence(self, handler: PresenceHandler) -> None:
"""Set the presence event handler."""
self._on_presence = handler
def set_start(self, handler: StartHandler) -> None:
"""Set the start event handler."""
self._on_start = handler

def set_message(self, handler: MessageHandler) -> None:
"""Set the message event handler."""
self._on_message = handler

async def call_presence(self, update: PresenceMessage) -> None:
"""Call the presence handler if set."""
if self._on_presence is not None:
if asyncio.iscoroutinefunction(self._on_presence):
await self._on_presence(update)
else:
self._on_presence(update)
def call_start(self) -> Any | None:
"""
Call the start handler if set.

Returns: If set, returns initial state (e.g. langchain agent instance).
"""
if self._on_start is not None:
return self._on_start()
else:
return None

async def call_message(
self, annotater: Annotater, message: TextMessage
self, agent: Any | None, annotater: Annotater, message: TextMessage
) -> Optional[TextMessage]:
"""Call the message handler if set, passing annotater for tool call telemetry."""
if self._on_message is not None:
result = await self._on_message(annotater, message)
result = await self._on_message(agent, annotater, message)
return result
return None
7 changes: 7 additions & 0 deletions src/llpsdk/langchain/__init__.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
from .middleware import LLPState, LLPAnnotationMiddleware

__all__ = [
"LLPState",
"LLPAnnotationMiddleware",
]
__version__ = "0.1.0"
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
2 changes: 1 addition & 1 deletion .python-version
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
3.13
3.10
11 changes: 9 additions & 2 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,12 +23,18 @@ pip install llpsdk
```python
import asyncio, os
import llpsdk as llp
from my_agent import create_agent

# called when starting a conversation with a test agent
def on_start():
# create an instance of your agent
return create_agent()

# Define a callback handler for processing messages
async def on_message(annotater, msg):
async def on_message(agent, annotater, msg):
# Process the prompt with your agent.
# Replace this with your own processing logic.
response = msg.prompt
response = await agent.ainvoke(msg.prompt)

# You must return a response
return msg.reply(response)
Expand All@@ -41,6 +47,7 @@ async def main():
)

# Register your message handler
client.on_start(on_start)
client.on_message(on_message)

try:
Expand Down
3 changes: 2 additions & 1 deletion examples/.env
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
LLP_URL="ws://localhost:4000/agent/websocket"
LLP_URL="wss://llphq.com/agent/websocket"
LLP_API_KEY=
OLLAMA_API_KEY=
101 changes: 101 additions & 0 deletions examples/langchain_agent.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
"""Agent example demonstrating basic LLP SDK usage with langchain."""
import asyncio
import llpsdk as llp
from llpsdk.langchain import LLPAnnotationMiddleware
import os
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_ollama import ChatOllama
from dotenv import load_dotenv

@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
city_lower = city.lower()
weather_data = {
"new york": "Partly cloudy, 45°F, wind 12 mph NW",
"los angeles": "Sunny, 72°F, wind 5 mph SW",
"chicago": "Overcast, 30°F, wind 20 mph N, chance of snow",
"miami": "Humid and warm, 82°F, wind 8 mph SE",
"seattle": "Rainy, 48°F, wind 10 mph W",
"denver": "Clear skies, 38°F, wind 15 mph NE",
"san francisco": "Foggy, 58°F, wind 14 mph W",
"austin": "Sunny, 68°F, wind 7 mph S",
"boston": "Cold and windy, 35°F, wind 22 mph NW",
"london": "Drizzle, 50°F, wind 11 mph SW",
}
return weather_data.get(city_lower, f"No weather data available for {city}")

async def main() -> None:
"""Run a simple agent that connects, sends presence, and sends a message."""
load_dotenv()
platform_url = os.getenv("LLP_URL")
api_key = os.getenv("LLP_API_KEY")
ollama_key = os.getenv("OLLAMA_API_KEY")

if platform_url is None:
raise Exception("LLP_URL env var is not defined")

if api_key is None:
raise Exception("LLP_API_KEY env var is not defined")

if ollama_key is None:
raise Exception("OLLAMA_API_KEY env var is not defined")

cfg = llp.Config(platform_url=platform_url)
cfg.platform_url = platform_url
client = llp.Client("simple-agent", api_key, cfg)

# Set up handlers
def on_start():
model = ChatOllama(
model="gpt-oss:120b",
base_url="https://ollama.com",
api_key=ollama_key,
)
return create_agent(
model=model,
tools=[get_weather],
middleware=[LLPAnnotationMiddleware()],
system_prompt="You are a helpful meteorologist that gives succinct responses regarding the weather for various American cities."
)

async def on_message(agent, annotater: llp.Annotater, msg: llp.TextMessage) -> llp.TextMessage:
if agent is not None:
result = await agent.ainvoke(
{
"messages": [{"role": "user", "content": msg.prompt}],
"message": msg,
"annotater": annotater,
}
)
reply = result["messages"][-1].content
return msg.reply(reply)
else:
return msg.reply("I'm a helpful meteorologist!")

# Register handlers
client.on_start(on_start)
client.on_message(on_message)

try:
# Connect and authenticate
print("Connecting to server...")
await client.connect()
print(f"Connected!! Session ID: {client.session_id}")

# Keep running
print("Agent running. Press Ctrl+C to exit...")
await asyncio.Event().wait()

except KeyboardInterrupt:
print("\nShutting down...")
except Exception as e:
print(f"Error: {e}")
finally:
await client.close()
print("Disconnected.")


if __name__ == "__main__":
asyncio.run(main())
3 changes: 2 additions & 1 deletion examples/simple_agent.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
"""Agent example demonstrating basic LLP SDK usage."""
import asyncio
from datetime import timedelta
from typing import Any
import llpsdk as llp
import os
from dotenv import load_dotenv
Expand All@@ -23,7 +24,7 @@ async def main() -> None:
client = llp.Client("simple-agent", api_key, cfg)

# Set up handlers
async def on_message(annotater: llp.Annotater, msg: llp.TextMessage) -> llp.TextMessage:
async def on_message(_agent: Any, annotater: llp.Annotater, msg: llp.TextMessage) -> llp.TextMessage:
tc = msg.tool_call("get_weather", '{"city":"Seattle"}', "rainy", timedelta(seconds=1))
await annotater.annotate_tool_call(tc)
return msg.reply("this is my response")
Expand Down
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ license = "MIT"
authors = [
{ name = "Large Language Platform Inc.", email = "founders@llphq.com" }
]
requires-python = ">=3.8"
requires-python = ">=3.8.1, <4.0"
dependencies = [
"websockets>=12.0",
]
Expand All@@ -25,6 +25,7 @@ classifiers = [
[project.optional-dependencies]
examples = [
"dotenv>=0.9.9",
"langchain-ollama>=0.1.3"
]

[build-system]
Expand All@@ -38,7 +39,7 @@ python_functions = ["test_*"]
asyncio_mode = "auto"

[tool.mypy]
python_version = "3.9"
python_version = "3.10"
strict = true
warn_return_any = true
warn_unused_configs = true
Expand All@@ -63,6 +64,7 @@ core-metadata-version = "2.3"
[dependency-groups]
dev = [
"black>=24.8.0",
"langchain>=0.2.17",
"mypy>=1.14.1",
"pytest>=8.3.5",
"pytest-asyncio>=0.24.0",
Expand Down
38 changes: 25 additions & 13 deletions src/llpsdk/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@
NotAuthenticatedError,
PlatformError,
)
from .handler import HandlerRegistry, MessageHandler, PresenceHandler
from .handler import HandlerRegistry, MessageHandler, StartHandler
from .message import (
AuthenticatedResponse,
AuthenticateMessage,
Expand DownExpand Up@@ -55,6 +55,7 @@ def __init__(self, name: str, api_key: str, config: Optional[Config] = None) ->
self._status_lock = asyncio.Lock()
self._presence = PresenceStatus.unavailable
self._presence_lock = asyncio.Lock()
self._agents: Dict[str, Any] = dict()

# Message handling
self._handlers = HandlerRegistry()
Expand DownExpand Up@@ -218,22 +219,24 @@ def presence(self) -> PresenceStatus:
"""Get the current presence status."""
return self._presence

# Event handlers (fluent API)
# Event handlers

def on_presence(self, handler: PresenceHandler) -> None:
def on_start(self, handler: StartHandler) -> None:
"""
Set the presence event handler.
Set the start event handler. This is an initializer function for when
a test agent comes online.

Args:
handler: Callable to handle presence updates
handler: Callable for initializing agents that come online.

Raises:
LLPClientError: If client is already connected

Returns:
Self for fluent chaining
"""
if self._status >= ConnectionStatus.CONNECTING.value:
raise LLPClientError("on_presence can not be called once client is connected.")
raise LLPClientError("on_start can not be called once client is connected.")

self._handlers.set_presence(handler)
self._handlers.set_start(handler)

def on_message(self, handler: MessageHandler) -> None:
"""
Expand All@@ -242,8 +245,8 @@ def on_message(self, handler: MessageHandler) -> None:
Args:
handler: Callable to handle incoming messages

Returns:
Self for fluent chaining
Raises:
LLPClientError: If client is already connected
"""
if self._status >= ConnectionStatus.CONNECTING.value:
raise LLPClientError("on_message can not be called once client is connected.")
Expand DownExpand Up@@ -397,12 +400,21 @@ async def _handle_message(self, msg_dict: Dict[str, Any]) -> None:

if msg_type == "presence":
update = PresenceMessage.decode(msg_dict)
await self._handlers.call_presence(update)
if update.status == PresenceStatus.available:
agent = self._handlers.call_start()
if agent is not None:
self._agents[update.sender] = agent

if update.status == PresenceStatus.unavailable:
# TODO: invoke a "stop" handler for uninitializing
del self._agents[update.sender]

return

if msg_type == "message":
tm = TextMessage.decode(msg_dict)
reply = await self._handlers.call_message(self, tm)
agent = self._agents.get(tm.sender)
reply = await self._handlers.call_message(agent, self, tm)
if reply is not None:
await self._send_async_message(reply)
return
Expand Down
40 changes: 20 additions & 20 deletions src/llpsdk/handler.py
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
"""Event handler registry for the LLP client."""

import asyncio
from typing import Awaitable, Callable, Optional, Union
from typing import Awaitable, Callable, Optional, Any

from typing import Protocol, runtime_checkable

from .message import PresenceMessage, TextMessage
from .message import TextMessage
from .tool_call import ToolCall


Expand All@@ -17,41 +16,42 @@ async def annotate_tool_call(self, tool_call: ToolCall) -> None: ...


# Handler type signatures (supports both sync and async)
PresenceHandler = Union[
Callable[[PresenceMessage], None], Callable[[PresenceMessage], Awaitable[None]]
]
MessageHandler = Callable[["Annotater", TextMessage], Awaitable[TextMessage]]
MessageHandler = Callable[[Any | None, "Annotater", TextMessage], Awaitable[TextMessage]]
StartHandler = Callable[[], Any]


class HandlerRegistry:
"""Registry for event handlers (message and presence only)."""

def __init__(self) -> None:
"""Initialize the handler registry."""
self._on_presence: Optional[PresenceHandler] = None
self._on_message: Optional[MessageHandler] = None
self._on_start: Optional[StartHandler] = None

def set_presence(self, handler: PresenceHandler) -> None:
"""Set the presence event handler."""
self._on_presence = handler
def set_start(self, handler: StartHandler) -> None:
"""Set the start event handler."""
self._on_start = handler

def set_message(self, handler: MessageHandler) -> None:
"""Set the message event handler."""
self._on_message = handler

async def call_presence(self, update: PresenceMessage) -> None:
"""Call the presence handler if set."""
if self._on_presence is not None:
if asyncio.iscoroutinefunction(self._on_presence):
await self._on_presence(update)
else:
self._on_presence(update)
def call_start(self) -> Any | None:
"""
Call the start handler if set.

Returns: If set, returns initial state (e.g. langchain agent instance).
"""
if self._on_start is not None:
return self._on_start()
else:
return None

async def call_message(
self, annotater: Annotater, message: TextMessage
self, agent: Any | None, annotater: Annotater, message: TextMessage
) -> Optional[TextMessage]:
"""Call the message handler if set, passing annotater for tool call telemetry."""
if self._on_message is not None:
result = await self._on_message(annotater, message)
result = await self._on_message(agent, annotater, message)
return result
return None
7 changes: 7 additions & 0 deletions src/llpsdk/langchain/__init__.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
from .middleware import LLPState, LLPAnnotationMiddleware

__all__ = [
"LLPState",
"LLPAnnotationMiddleware",
]
__version__ = "0.1.0"
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
2 changes: 1 addition & 1 deletion .python-version
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
3.13
3.10
11 changes: 9 additions & 2 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,12 +23,18 @@ pip install llpsdk
```python
import asyncio, os
import llpsdk as llp
from my_agent import create_agent

# called when starting a conversation with a test agent
def on_start():
# create an instance of your agent
return create_agent()

# Define a callback handler for processing messages
async def on_message(annotater, msg):
async def on_message(agent, annotater, msg):
# Process the prompt with your agent.
# Replace this with your own processing logic.
response = msg.prompt
response = await agent.ainvoke(msg.prompt)

# You must return a response
return msg.reply(response)
Expand All@@ -41,6 +47,7 @@ async def main():
)

# Register your message handler
client.on_start(on_start)
client.on_message(on_message)

try:
Expand Down
3 changes: 2 additions & 1 deletion examples/.env
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
LLP_URL="ws://localhost:4000/agent/websocket"
LLP_URL="wss://llphq.com/agent/websocket"
LLP_API_KEY=
OLLAMA_API_KEY=
101 changes: 101 additions & 0 deletions examples/langchain_agent.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
"""Agent example demonstrating basic LLP SDK usage with langchain."""
import asyncio
import llpsdk as llp
from llpsdk.langchain import LLPAnnotationMiddleware
import os
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_ollama import ChatOllama
from dotenv import load_dotenv

@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
city_lower = city.lower()
weather_data = {
"new york": "Partly cloudy, 45°F, wind 12 mph NW",
"los angeles": "Sunny, 72°F, wind 5 mph SW",
"chicago": "Overcast, 30°F, wind 20 mph N, chance of snow",
"miami": "Humid and warm, 82°F, wind 8 mph SE",
"seattle": "Rainy, 48°F, wind 10 mph W",
"denver": "Clear skies, 38°F, wind 15 mph NE",
"san francisco": "Foggy, 58°F, wind 14 mph W",
"austin": "Sunny, 68°F, wind 7 mph S",
"boston": "Cold and windy, 35°F, wind 22 mph NW",
"london": "Drizzle, 50°F, wind 11 mph SW",
}
return weather_data.get(city_lower, f"No weather data available for {city}")

async def main() -> None:
"""Run a simple agent that connects, sends presence, and sends a message."""
load_dotenv()
platform_url = os.getenv("LLP_URL")
api_key = os.getenv("LLP_API_KEY")
ollama_key = os.getenv("OLLAMA_API_KEY")

if platform_url is None:
raise Exception("LLP_URL env var is not defined")

if api_key is None:
raise Exception("LLP_API_KEY env var is not defined")

if ollama_key is None:
raise Exception("OLLAMA_API_KEY env var is not defined")

cfg = llp.Config(platform_url=platform_url)
cfg.platform_url = platform_url
client = llp.Client("simple-agent", api_key, cfg)

# Set up handlers
def on_start():
model = ChatOllama(
model="gpt-oss:120b",
base_url="https://ollama.com",
api_key=ollama_key,
)
return create_agent(
model=model,
tools=[get_weather],
middleware=[LLPAnnotationMiddleware()],
system_prompt="You are a helpful meteorologist that gives succinct responses regarding the weather for various American cities."
)

async def on_message(agent, annotater: llp.Annotater, msg: llp.TextMessage) -> llp.TextMessage:
if agent is not None:
result = await agent.ainvoke(
{
"messages": [{"role": "user", "content": msg.prompt}],
"message": msg,
"annotater": annotater,
}
)
reply = result["messages"][-1].content
return msg.reply(reply)
else:
return msg.reply("I'm a helpful meteorologist!")

# Register handlers
client.on_start(on_start)
client.on_message(on_message)

try:
# Connect and authenticate
print("Connecting to server...")
await client.connect()
print(f"Connected!! Session ID: {client.session_id}")

# Keep running
print("Agent running. Press Ctrl+C to exit...")
await asyncio.Event().wait()

except KeyboardInterrupt:
print("\nShutting down...")
except Exception as e:
print(f"Error: {e}")
finally:
await client.close()
print("Disconnected.")


if __name__ == "__main__":
asyncio.run(main())
3 changes: 2 additions & 1 deletion examples/simple_agent.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
"""Agent example demonstrating basic LLP SDK usage."""
import asyncio
from datetime import timedelta
from typing import Any
import llpsdk as llp
import os
from dotenv import load_dotenv
Expand All@@ -23,7 +24,7 @@ async def main() -> None:
client = llp.Client("simple-agent", api_key, cfg)

# Set up handlers
async def on_message(annotater: llp.Annotater, msg: llp.TextMessage) -> llp.TextMessage:
async def on_message(_agent: Any, annotater: llp.Annotater, msg: llp.TextMessage) -> llp.TextMessage:
tc = msg.tool_call("get_weather", '{"city":"Seattle"}', "rainy", timedelta(seconds=1))
await annotater.annotate_tool_call(tc)
return msg.reply("this is my response")
Expand Down
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ license = "MIT"
authors = [
{ name = "Large Language Platform Inc.", email = "founders@llphq.com" }
]
requires-python = ">=3.8"
requires-python = ">=3.8.1, <4.0"
dependencies = [
"websockets>=12.0",
]
Expand All@@ -25,6 +25,7 @@ classifiers = [
[project.optional-dependencies]
examples = [
"dotenv>=0.9.9",
"langchain-ollama>=0.1.3"
]

[build-system]
Expand All@@ -38,7 +39,7 @@ python_functions = ["test_*"]
asyncio_mode = "auto"

[tool.mypy]
python_version = "3.9"
python_version = "3.10"
strict = true
warn_return_any = true
warn_unused_configs = true
Expand All@@ -63,6 +64,7 @@ core-metadata-version = "2.3"
[dependency-groups]
dev = [
"black>=24.8.0",
"langchain>=0.2.17",
"mypy>=1.14.1",
"pytest>=8.3.5",
"pytest-asyncio>=0.24.0",
Expand Down
38 changes: 25 additions & 13 deletions src/llpsdk/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@
NotAuthenticatedError,
PlatformError,
)
from .handler import HandlerRegistry, MessageHandler, PresenceHandler
from .handler import HandlerRegistry, MessageHandler, StartHandler
from .message import (
AuthenticatedResponse,
AuthenticateMessage,
Expand DownExpand Up@@ -55,6 +55,7 @@ def __init__(self, name: str, api_key: str, config: Optional[Config] = None) ->
self._status_lock = asyncio.Lock()
self._presence = PresenceStatus.unavailable
self._presence_lock = asyncio.Lock()
self._agents: Dict[str, Any] = dict()

# Message handling
self._handlers = HandlerRegistry()
Expand DownExpand Up@@ -218,22 +219,24 @@ def presence(self) -> PresenceStatus:
"""Get the current presence status."""
return self._presence

# Event handlers (fluent API)
# Event handlers

def on_presence(self, handler: PresenceHandler) -> None:
def on_start(self, handler: StartHandler) -> None:
"""
Set the presence event handler.
Set the start event handler. This is an initializer function for when
a test agent comes online.

Args:
handler: Callable to handle presence updates
handler: Callable for initializing agents that come online.

Raises:
LLPClientError: If client is already connected

Returns:
Self for fluent chaining
"""
if self._status >= ConnectionStatus.CONNECTING.value:
raise LLPClientError("on_presence can not be called once client is connected.")
raise LLPClientError("on_start can not be called once client is connected.")

self._handlers.set_presence(handler)
self._handlers.set_start(handler)

def on_message(self, handler: MessageHandler) -> None:
"""
Expand All@@ -242,8 +245,8 @@ def on_message(self, handler: MessageHandler) -> None:
Args:
handler: Callable to handle incoming messages

Returns:
Self for fluent chaining
Raises:
LLPClientError: If client is already connected
"""
if self._status >= ConnectionStatus.CONNECTING.value:
raise LLPClientError("on_message can not be called once client is connected.")
Expand DownExpand Up@@ -397,12 +400,21 @@ async def _handle_message(self, msg_dict: Dict[str, Any]) -> None:

if msg_type == "presence":
update = PresenceMessage.decode(msg_dict)
await self._handlers.call_presence(update)
if update.status == PresenceStatus.available:
agent = self._handlers.call_start()
if agent is not None:
self._agents[update.sender] = agent

if update.status == PresenceStatus.unavailable:
# TODO: invoke a "stop" handler for uninitializing
del self._agents[update.sender]

return

if msg_type == "message":
tm = TextMessage.decode(msg_dict)
reply = await self._handlers.call_message(self, tm)
agent = self._agents.get(tm.sender)
reply = await self._handlers.call_message(agent, self, tm)
if reply is not None:
await self._send_async_message(reply)
return
Expand Down
40 changes: 20 additions & 20 deletions src/llpsdk/handler.py
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
"""Event handler registry for the LLP client."""

import asyncio
from typing import Awaitable, Callable, Optional, Union
from typing import Awaitable, Callable, Optional, Any

from typing import Protocol, runtime_checkable

from .message import PresenceMessage, TextMessage
from .message import TextMessage
from .tool_call import ToolCall


Expand All@@ -17,41 +16,42 @@ async def annotate_tool_call(self, tool_call: ToolCall) -> None: ...


# Handler type signatures (supports both sync and async)
PresenceHandler = Union[
Callable[[PresenceMessage], None], Callable[[PresenceMessage], Awaitable[None]]
]
MessageHandler = Callable[["Annotater", TextMessage], Awaitable[TextMessage]]
MessageHandler = Callable[[Any | None, "Annotater", TextMessage], Awaitable[TextMessage]]
StartHandler = Callable[[], Any]


class HandlerRegistry:
"""Registry for event handlers (message and presence only)."""

def __init__(self) -> None:
"""Initialize the handler registry."""
self._on_presence: Optional[PresenceHandler] = None
self._on_message: Optional[MessageHandler] = None
self._on_start: Optional[StartHandler] = None

def set_presence(self, handler: PresenceHandler) -> None:
"""Set the presence event handler."""
self._on_presence = handler
def set_start(self, handler: StartHandler) -> None:
"""Set the start event handler."""
self._on_start = handler

def set_message(self, handler: MessageHandler) -> None:
"""Set the message event handler."""
self._on_message = handler

async def call_presence(self, update: PresenceMessage) -> None:
"""Call the presence handler if set."""
if self._on_presence is not None:
if asyncio.iscoroutinefunction(self._on_presence):
await self._on_presence(update)
else:
self._on_presence(update)
def call_start(self) -> Any | None:
"""
Call the start handler if set.

Returns: If set, returns initial state (e.g. langchain agent instance).
"""
if self._on_start is not None:
return self._on_start()
else:
return None

async def call_message(
self, annotater: Annotater, message: TextMessage
self, agent: Any | None, annotater: Annotater, message: TextMessage
) -> Optional[TextMessage]:
"""Call the message handler if set, passing annotater for tool call telemetry."""
if self._on_message is not None:
result = await self._on_message(annotater, message)
result = await self._on_message(agent, annotater, message)
return result
return None
7 changes: 7 additions & 0 deletions src/llpsdk/langchain/__init__.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
from .middleware import LLPState, LLPAnnotationMiddleware

__all__ = [
"LLPState",
"LLPAnnotationMiddleware",
]
__version__ = "0.1.0"
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
2 changes: 1 addition & 1 deletion .python-version
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
3.13
3.10
11 changes: 9 additions & 2 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,12 +23,18 @@ pip install llpsdk
```python
import asyncio, os
import llpsdk as llp
from my_agent import create_agent

# called when starting a conversation with a test agent
def on_start():
# create an instance of your agent
return create_agent()

# Define a callback handler for processing messages
async def on_message(annotater, msg):
async def on_message(agent, annotater, msg):
# Process the prompt with your agent.
# Replace this with your own processing logic.
response = msg.prompt
response = await agent.ainvoke(msg.prompt)

# You must return a response
return msg.reply(response)
Expand All@@ -41,6 +47,7 @@ async def main():
)

# Register your message handler
client.on_start(on_start)
client.on_message(on_message)

try:
Expand Down
3 changes: 2 additions & 1 deletion examples/.env
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
LLP_URL="ws://localhost:4000/agent/websocket"
LLP_URL="wss://llphq.com/agent/websocket"
LLP_API_KEY=
OLLAMA_API_KEY=
101 changes: 101 additions & 0 deletions examples/langchain_agent.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
"""Agent example demonstrating basic LLP SDK usage with langchain."""
import asyncio
import llpsdk as llp
from llpsdk.langchain import LLPAnnotationMiddleware
import os
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_ollama import ChatOllama
from dotenv import load_dotenv

@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
city_lower = city.lower()
weather_data = {
"new york": "Partly cloudy, 45°F, wind 12 mph NW",
"los angeles": "Sunny, 72°F, wind 5 mph SW",
"chicago": "Overcast, 30°F, wind 20 mph N, chance of snow",
"miami": "Humid and warm, 82°F, wind 8 mph SE",
"seattle": "Rainy, 48°F, wind 10 mph W",
"denver": "Clear skies, 38°F, wind 15 mph NE",
"san francisco": "Foggy, 58°F, wind 14 mph W",
"austin": "Sunny, 68°F, wind 7 mph S",
"boston": "Cold and windy, 35°F, wind 22 mph NW",
"london": "Drizzle, 50°F, wind 11 mph SW",
}
return weather_data.get(city_lower, f"No weather data available for {city}")

async def main() -> None:
"""Run a simple agent that connects, sends presence, and sends a message."""
load_dotenv()
platform_url = os.getenv("LLP_URL")
api_key = os.getenv("LLP_API_KEY")
ollama_key = os.getenv("OLLAMA_API_KEY")

if platform_url is None:
raise Exception("LLP_URL env var is not defined")

if api_key is None:
raise Exception("LLP_API_KEY env var is not defined")

if ollama_key is None:
raise Exception("OLLAMA_API_KEY env var is not defined")

cfg = llp.Config(platform_url=platform_url)
cfg.platform_url = platform_url
client = llp.Client("simple-agent", api_key, cfg)

# Set up handlers
def on_start():
model = ChatOllama(
model="gpt-oss:120b",
base_url="https://ollama.com",
api_key=ollama_key,
)
return create_agent(
model=model,
tools=[get_weather],
middleware=[LLPAnnotationMiddleware()],
system_prompt="You are a helpful meteorologist that gives succinct responses regarding the weather for various American cities."
)

async def on_message(agent, annotater: llp.Annotater, msg: llp.TextMessage) -> llp.TextMessage:
if agent is not None:
result = await agent.ainvoke(
{
"messages": [{"role": "user", "content": msg.prompt}],
"message": msg,
"annotater": annotater,
}
)
reply = result["messages"][-1].content
return msg.reply(reply)
else:
return msg.reply("I'm a helpful meteorologist!")

# Register handlers
client.on_start(on_start)
client.on_message(on_message)

try:
# Connect and authenticate
print("Connecting to server...")
await client.connect()
print(f"Connected!! Session ID: {client.session_id}")

# Keep running
print("Agent running. Press Ctrl+C to exit...")
await asyncio.Event().wait()

except KeyboardInterrupt:
print("\nShutting down...")
except Exception as e:
print(f"Error: {e}")
finally:
await client.close()
print("Disconnected.")


if __name__ == "__main__":
asyncio.run(main())
3 changes: 2 additions & 1 deletion examples/simple_agent.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
"""Agent example demonstrating basic LLP SDK usage."""
import asyncio
from datetime import timedelta
from typing import Any
import llpsdk as llp
import os
from dotenv import load_dotenv
Expand All@@ -23,7 +24,7 @@ async def main() -> None:
client = llp.Client("simple-agent", api_key, cfg)

# Set up handlers
async def on_message(annotater: llp.Annotater, msg: llp.TextMessage) -> llp.TextMessage:
async def on_message(_agent: Any, annotater: llp.Annotater, msg: llp.TextMessage) -> llp.TextMessage:
tc = msg.tool_call("get_weather", '{"city":"Seattle"}', "rainy", timedelta(seconds=1))
await annotater.annotate_tool_call(tc)
return msg.reply("this is my response")
Expand Down
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ license = "MIT"
authors = [
{ name = "Large Language Platform Inc.", email = "founders@llphq.com" }
]
requires-python = ">=3.8"
requires-python = ">=3.8.1, <4.0"
dependencies = [
"websockets>=12.0",
]
Expand All@@ -25,6 +25,7 @@ classifiers = [
[project.optional-dependencies]
examples = [
"dotenv>=0.9.9",
"langchain-ollama>=0.1.3"
]

[build-system]
Expand All@@ -38,7 +39,7 @@ python_functions = ["test_*"]
asyncio_mode = "auto"

[tool.mypy]
python_version = "3.9"
python_version = "3.10"
strict = true
warn_return_any = true
warn_unused_configs = true
Expand All@@ -63,6 +64,7 @@ core-metadata-version = "2.3"
[dependency-groups]
dev = [
"black>=24.8.0",
"langchain>=0.2.17",
"mypy>=1.14.1",
"pytest>=8.3.5",
"pytest-asyncio>=0.24.0",
Expand Down
38 changes: 25 additions & 13 deletions src/llpsdk/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@
NotAuthenticatedError,
PlatformError,
)
from .handler import HandlerRegistry, MessageHandler, PresenceHandler
from .handler import HandlerRegistry, MessageHandler, StartHandler
from .message import (
AuthenticatedResponse,
AuthenticateMessage,
Expand DownExpand Up@@ -55,6 +55,7 @@ def __init__(self, name: str, api_key: str, config: Optional[Config] = None) ->
self._status_lock = asyncio.Lock()
self._presence = PresenceStatus.unavailable
self._presence_lock = asyncio.Lock()
self._agents: Dict[str, Any] = dict()

# Message handling
self._handlers = HandlerRegistry()
Expand DownExpand Up@@ -218,22 +219,24 @@ def presence(self) -> PresenceStatus:
"""Get the current presence status."""
return self._presence

# Event handlers (fluent API)
# Event handlers

def on_presence(self, handler: PresenceHandler) -> None:
def on_start(self, handler: StartHandler) -> None:
"""
Set the presence event handler.
Set the start event handler. This is an initializer function for when
a test agent comes online.

Args:
handler: Callable to handle presence updates
handler: Callable for initializing agents that come online.

Raises:
LLPClientError: If client is already connected

Returns:
Self for fluent chaining
"""
if self._status >= ConnectionStatus.CONNECTING.value:
raise LLPClientError("on_presence can not be called once client is connected.")
raise LLPClientError("on_start can not be called once client is connected.")

self._handlers.set_presence(handler)
self._handlers.set_start(handler)

def on_message(self, handler: MessageHandler) -> None:
"""
Expand All@@ -242,8 +245,8 @@ def on_message(self, handler: MessageHandler) -> None:
Args:
handler: Callable to handle incoming messages

Returns:
Self for fluent chaining
Raises:
LLPClientError: If client is already connected
"""
if self._status >= ConnectionStatus.CONNECTING.value:
raise LLPClientError("on_message can not be called once client is connected.")
Expand DownExpand Up@@ -397,12 +400,21 @@ async def _handle_message(self, msg_dict: Dict[str, Any]) -> None:

if msg_type == "presence":
update = PresenceMessage.decode(msg_dict)
await self._handlers.call_presence(update)
if update.status == PresenceStatus.available:
agent = self._handlers.call_start()
if agent is not None:
self._agents[update.sender] = agent

if update.status == PresenceStatus.unavailable:
# TODO: invoke a "stop" handler for uninitializing
del self._agents[update.sender]

return

if msg_type == "message":
tm = TextMessage.decode(msg_dict)
reply = await self._handlers.call_message(self, tm)
agent = self._agents.get(tm.sender)
reply = await self._handlers.call_message(agent, self, tm)
if reply is not None:
await self._send_async_message(reply)
return
Expand Down
40 changes: 20 additions & 20 deletions src/llpsdk/handler.py
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
"""Event handler registry for the LLP client."""

import asyncio
from typing import Awaitable, Callable, Optional, Union
from typing import Awaitable, Callable, Optional, Any

from typing import Protocol, runtime_checkable

from .message import PresenceMessage, TextMessage
from .message import TextMessage
from .tool_call import ToolCall


Expand All@@ -17,41 +16,42 @@ async def annotate_tool_call(self, tool_call: ToolCall) -> None: ...


# Handler type signatures (supports both sync and async)
PresenceHandler = Union[
Callable[[PresenceMessage], None], Callable[[PresenceMessage], Awaitable[None]]
]
MessageHandler = Callable[["Annotater", TextMessage], Awaitable[TextMessage]]
MessageHandler = Callable[[Any | None, "Annotater", TextMessage], Awaitable[TextMessage]]
StartHandler = Callable[[], Any]


class HandlerRegistry:
"""Registry for event handlers (message and presence only)."""

def __init__(self) -> None:
"""Initialize the handler registry."""
self._on_presence: Optional[PresenceHandler] = None
self._on_message: Optional[MessageHandler] = None
self._on_start: Optional[StartHandler] = None

def set_presence(self, handler: PresenceHandler) -> None:
"""Set the presence event handler."""
self._on_presence = handler
def set_start(self, handler: StartHandler) -> None:
"""Set the start event handler."""
self._on_start = handler

def set_message(self, handler: MessageHandler) -> None:
"""Set the message event handler."""
self._on_message = handler

async def call_presence(self, update: PresenceMessage) -> None:
"""Call the presence handler if set."""
if self._on_presence is not None:
if asyncio.iscoroutinefunction(self._on_presence):
await self._on_presence(update)
else:
self._on_presence(update)
def call_start(self) -> Any | None:
"""
Call the start handler if set.

Returns: If set, returns initial state (e.g. langchain agent instance).
"""
if self._on_start is not None:
return self._on_start()
else:
return None

async def call_message(
self, annotater: Annotater, message: TextMessage
self, agent: Any | None, annotater: Annotater, message: TextMessage
) -> Optional[TextMessage]:
"""Call the message handler if set, passing annotater for tool call telemetry."""
if self._on_message is not None:
result = await self._on_message(annotater, message)
result = await self._on_message(agent, annotater, message)
return result
return None
7 changes: 7 additions & 0 deletions src/llpsdk/langchain/__init__.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
from .middleware import LLPState, LLPAnnotationMiddleware

__all__ = [
"LLPState",
"LLPAnnotationMiddleware",
]
__version__ = "0.1.0"
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
2 changes: 1 addition & 1 deletion .python-version
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
3.13
3.10
11 changes: 9 additions & 2 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,12 +23,18 @@ pip install llpsdk
```python
import asyncio, os
import llpsdk as llp
from my_agent import create_agent

# called when starting a conversation with a test agent
def on_start():
# create an instance of your agent
return create_agent()

# Define a callback handler for processing messages
async def on_message(annotater, msg):
async def on_message(agent, annotater, msg):
# Process the prompt with your agent.
# Replace this with your own processing logic.
response = msg.prompt
response = await agent.ainvoke(msg.prompt)

# You must return a response
return msg.reply(response)
Expand All@@ -41,6 +47,7 @@ async def main():
)

# Register your message handler
client.on_start(on_start)
client.on_message(on_message)

try:
Expand Down
3 changes: 2 additions & 1 deletion examples/.env
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
LLP_URL="ws://localhost:4000/agent/websocket"
LLP_URL="wss://llphq.com/agent/websocket"
LLP_API_KEY=
OLLAMA_API_KEY=
101 changes: 101 additions & 0 deletions examples/langchain_agent.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
"""Agent example demonstrating basic LLP SDK usage with langchain."""
import asyncio
import llpsdk as llp
from llpsdk.langchain import LLPAnnotationMiddleware
import os
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_ollama import ChatOllama
from dotenv import load_dotenv

@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
city_lower = city.lower()
weather_data = {
"new york": "Partly cloudy, 45°F, wind 12 mph NW",
"los angeles": "Sunny, 72°F, wind 5 mph SW",
"chicago": "Overcast, 30°F, wind 20 mph N, chance of snow",
"miami": "Humid and warm, 82°F, wind 8 mph SE",
"seattle": "Rainy, 48°F, wind 10 mph W",
"denver": "Clear skies, 38°F, wind 15 mph NE",
"san francisco": "Foggy, 58°F, wind 14 mph W",
"austin": "Sunny, 68°F, wind 7 mph S",
"boston": "Cold and windy, 35°F, wind 22 mph NW",
"london": "Drizzle, 50°F, wind 11 mph SW",
}
return weather_data.get(city_lower, f"No weather data available for {city}")

async def main() -> None:
"""Run a simple agent that connects, sends presence, and sends a message."""
load_dotenv()
platform_url = os.getenv("LLP_URL")
api_key = os.getenv("LLP_API_KEY")
ollama_key = os.getenv("OLLAMA_API_KEY")

if platform_url is None:
raise Exception("LLP_URL env var is not defined")

if api_key is None:
raise Exception("LLP_API_KEY env var is not defined")

if ollama_key is None:
raise Exception("OLLAMA_API_KEY env var is not defined")

cfg = llp.Config(platform_url=platform_url)
cfg.platform_url = platform_url
client = llp.Client("simple-agent", api_key, cfg)

# Set up handlers
def on_start():
model = ChatOllama(
model="gpt-oss:120b",
base_url="https://ollama.com",
api_key=ollama_key,
)
return create_agent(
model=model,
tools=[get_weather],
middleware=[LLPAnnotationMiddleware()],
system_prompt="You are a helpful meteorologist that gives succinct responses regarding the weather for various American cities."
)

async def on_message(agent, annotater: llp.Annotater, msg: llp.TextMessage) -> llp.TextMessage:
if agent is not None:
result = await agent.ainvoke(
{
"messages": [{"role": "user", "content": msg.prompt}],
"message": msg,
"annotater": annotater,
}
)
reply = result["messages"][-1].content
return msg.reply(reply)
else:
return msg.reply("I'm a helpful meteorologist!")

# Register handlers
client.on_start(on_start)
client.on_message(on_message)

try:
# Connect and authenticate
print("Connecting to server...")
await client.connect()
print(f"Connected!! Session ID: {client.session_id}")

# Keep running
print("Agent running. Press Ctrl+C to exit...")
await asyncio.Event().wait()

except KeyboardInterrupt:
print("\nShutting down...")
except Exception as e:
print(f"Error: {e}")
finally:
await client.close()
print("Disconnected.")


if __name__ == "__main__":
asyncio.run(main())
3 changes: 2 additions & 1 deletion examples/simple_agent.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
"""Agent example demonstrating basic LLP SDK usage."""
import asyncio
from datetime import timedelta
from typing import Any
import llpsdk as llp
import os
from dotenv import load_dotenv
Expand All@@ -23,7 +24,7 @@ async def main() -> None:
client = llp.Client("simple-agent", api_key, cfg)

# Set up handlers
async def on_message(annotater: llp.Annotater, msg: llp.TextMessage) -> llp.TextMessage:
async def on_message(_agent: Any, annotater: llp.Annotater, msg: llp.TextMessage) -> llp.TextMessage:
tc = msg.tool_call("get_weather", '{"city":"Seattle"}', "rainy", timedelta(seconds=1))
await annotater.annotate_tool_call(tc)
return msg.reply("this is my response")
Expand Down
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ license = "MIT"
authors = [
{ name = "Large Language Platform Inc.", email = "founders@llphq.com" }
]
requires-python = ">=3.8"
requires-python = ">=3.8.1, <4.0"
dependencies = [
"websockets>=12.0",
]
Expand All@@ -25,6 +25,7 @@ classifiers = [
[project.optional-dependencies]
examples = [
"dotenv>=0.9.9",
"langchain-ollama>=0.1.3"
]

[build-system]
Expand All@@ -38,7 +39,7 @@ python_functions = ["test_*"]
asyncio_mode = "auto"

[tool.mypy]
python_version = "3.9"
python_version = "3.10"
strict = true
warn_return_any = true
warn_unused_configs = true
Expand All@@ -63,6 +64,7 @@ core-metadata-version = "2.3"
[dependency-groups]
dev = [
"black>=24.8.0",
"langchain>=0.2.17",
"mypy>=1.14.1",
"pytest>=8.3.5",
"pytest-asyncio>=0.24.0",
Expand Down
38 changes: 25 additions & 13 deletions src/llpsdk/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@
NotAuthenticatedError,
PlatformError,
)
from .handler import HandlerRegistry, MessageHandler, PresenceHandler
from .handler import HandlerRegistry, MessageHandler, StartHandler
from .message import (
AuthenticatedResponse,
AuthenticateMessage,
Expand DownExpand Up@@ -55,6 +55,7 @@ def __init__(self, name: str, api_key: str, config: Optional[Config] = None) ->
self._status_lock = asyncio.Lock()
self._presence = PresenceStatus.unavailable
self._presence_lock = asyncio.Lock()
self._agents: Dict[str, Any] = dict()

# Message handling
self._handlers = HandlerRegistry()
Expand DownExpand Up@@ -218,22 +219,24 @@ def presence(self) -> PresenceStatus:
"""Get the current presence status."""
return self._presence

# Event handlers (fluent API)
# Event handlers

def on_presence(self, handler: PresenceHandler) -> None:
def on_start(self, handler: StartHandler) -> None:
"""
Set the presence event handler.
Set the start event handler. This is an initializer function for when
a test agent comes online.

Args:
handler: Callable to handle presence updates
handler: Callable for initializing agents that come online.

Raises:
LLPClientError: If client is already connected

Returns:
Self for fluent chaining
"""
if self._status >= ConnectionStatus.CONNECTING.value:
raise LLPClientError("on_presence can not be called once client is connected.")
raise LLPClientError("on_start can not be called once client is connected.")

self._handlers.set_presence(handler)
self._handlers.set_start(handler)

def on_message(self, handler: MessageHandler) -> None:
"""
Expand All@@ -242,8 +245,8 @@ def on_message(self, handler: MessageHandler) -> None:
Args:
handler: Callable to handle incoming messages

Returns:
Self for fluent chaining
Raises:
LLPClientError: If client is already connected
"""
if self._status >= ConnectionStatus.CONNECTING.value:
raise LLPClientError("on_message can not be called once client is connected.")
Expand DownExpand Up@@ -397,12 +400,21 @@ async def _handle_message(self, msg_dict: Dict[str, Any]) -> None:

if msg_type == "presence":
update = PresenceMessage.decode(msg_dict)
await self._handlers.call_presence(update)
if update.status == PresenceStatus.available:
agent = self._handlers.call_start()
if agent is not None:
self._agents[update.sender] = agent

if update.status == PresenceStatus.unavailable:
# TODO: invoke a "stop" handler for uninitializing
del self._agents[update.sender]

return

if msg_type == "message":
tm = TextMessage.decode(msg_dict)
reply = await self._handlers.call_message(self, tm)
agent = self._agents.get(tm.sender)
reply = await self._handlers.call_message(agent, self, tm)
if reply is not None:
await self._send_async_message(reply)
return
Expand Down
40 changes: 20 additions & 20 deletions src/llpsdk/handler.py
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
"""Event handler registry for the LLP client."""

import asyncio
from typing import Awaitable, Callable, Optional, Union
from typing import Awaitable, Callable, Optional, Any

from typing import Protocol, runtime_checkable

from .message import PresenceMessage, TextMessage
from .message import TextMessage
from .tool_call import ToolCall


Expand All@@ -17,41 +16,42 @@ async def annotate_tool_call(self, tool_call: ToolCall) -> None: ...


# Handler type signatures (supports both sync and async)
PresenceHandler = Union[
Callable[[PresenceMessage], None], Callable[[PresenceMessage], Awaitable[None]]
]
MessageHandler = Callable[["Annotater", TextMessage], Awaitable[TextMessage]]
MessageHandler = Callable[[Any | None, "Annotater", TextMessage], Awaitable[TextMessage]]
StartHandler = Callable[[], Any]


class HandlerRegistry:
"""Registry for event handlers (message and presence only)."""

def __init__(self) -> None:
"""Initialize the handler registry."""
self._on_presence: Optional[PresenceHandler] = None
self._on_message: Optional[MessageHandler] = None
self._on_start: Optional[StartHandler] = None

def set_presence(self, handler: PresenceHandler) -> None:
"""Set the presence event handler."""
self._on_presence = handler
def set_start(self, handler: StartHandler) -> None:
"""Set the start event handler."""
self._on_start = handler

def set_message(self, handler: MessageHandler) -> None:
"""Set the message event handler."""
self._on_message = handler

async def call_presence(self, update: PresenceMessage) -> None:
"""Call the presence handler if set."""
if self._on_presence is not None:
if asyncio.iscoroutinefunction(self._on_presence):
await self._on_presence(update)
else:
self._on_presence(update)
def call_start(self) -> Any | None:
"""
Call the start handler if set.

Returns: If set, returns initial state (e.g. langchain agent instance).
"""
if self._on_start is not None:
return self._on_start()
else:
return None

async def call_message(
self, annotater: Annotater, message: TextMessage
self, agent: Any | None, annotater: Annotater, message: TextMessage
) -> Optional[TextMessage]:
"""Call the message handler if set, passing annotater for tool call telemetry."""
if self._on_message is not None:
result = await self._on_message(annotater, message)
result = await self._on_message(agent, annotater, message)
return result
return None
7 changes: 7 additions & 0 deletions src/llpsdk/langchain/__init__.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
from .middleware import LLPState, LLPAnnotationMiddleware

__all__ = [
"LLPState",
"LLPAnnotationMiddleware",
]
__version__ = "0.1.0"
Loading