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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion sdks/python/agenta/sdk/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,15 @@
to_messages,
)
from .errors import ToolResolutionError, UnsupportedHarnessError
from .interfaces import Backend, Environment, Harness, Sandbox, Session
from .interfaces import (
Backend,
Environment,
Harness,
NoopSessionStore,
Sandbox,
Session,
SessionStore,
)
from .mcp import (
MCPConfigurationError,
MCPError,
Expand Down Expand Up @@ -157,6 +165,8 @@
"Backend",
"Sandbox",
"Session",
"SessionStore",
"NoopSessionStore",
"Environment",
"Harness",
# Errors
Expand Down
25 changes: 20 additions & 5 deletions sdks/python/agenta/sdk/agents/adapters/vercel/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@
from agenta.sdk.contexts.tracing import tracing_context_manager
from agenta.sdk.models.workflows import (
LoadSessionRequest,
LoadSessionResponse,
WorkflowBatchResponse,
WorkflowInvokeRequest,
WorkflowRequestData,
WorkflowStreamingResponse,
)

from .messages import vercel_ui_messages_to_messages
from ...interfaces import NoopSessionStore, SessionStore
from .messages import message_to_vercel_ui_message, vercel_ui_messages_to_messages

# An opaque, project-scoped session id (RFC §4.1): bounded length, restricted charset.
_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
Expand Down Expand Up @@ -122,12 +124,24 @@ async def messages_endpoint(req: Request, request: WorkflowInvokeRequest):
return messages_endpoint


def make_load_session_endpoint():
"""Build the v1 ``POST /load-session`` stub endpoint."""
def make_load_session_endpoint(
*,
session_store: Optional[SessionStore] = None,
):
"""Build the v1 ``POST /load-session`` endpoint over the session-store port."""
store = session_store or NoopSessionStore()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The or NoopSessionStore() default is what keeps the response identical to the old hardcoded stub when no adapter is wired. Worth confirming this is the only place the default is chosen.


async def load_session_endpoint(req: Request, request: LoadSessionRequest):
messages = await store.load(request.session_id)
response = LoadSessionResponse(
session_id=request.session_id,
messages=[
message_to_vercel_ui_message(message, message_id=f"msg-{idx}")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Loaded neutral messages convert back to Vercel UIMessage here, the inverse of the inbound vercel_ui_messages_to_messages on /messages. Confirm the two directions stay symmetric so a saved turn round-trips.

for idx, message in enumerate(messages, start=1)
],
)
return JSONResponse(
content={"session_id": request.session_id, "messages": []},
content=response.model_dump(mode="json"),
)

return load_session_endpoint
Expand All @@ -146,6 +160,7 @@ def register_agent_message_routes(
make_not_acceptable_response: Callable[[str, Any], Response],
make_stream_response: Callable[[WorkflowStreamingResponse, str], Response],
handle_failure: Callable[[Exception], Any],
session_store: Optional[SessionStore] = None,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The msg-{idx} ids restart at 1 per response, so they are not stable across load-session calls. Confirm no client treats these ids as durable.

) -> None:
"""Register ``/messages`` and ``/load-session`` on a FastAPI app/router target."""
target.add_api_route(
Expand All @@ -165,6 +180,6 @@ def register_agent_message_routes(
)
target.add_api_route(
prefix + "/load-session",
make_load_session_endpoint(),
make_load_session_endpoint(session_store=session_store),
methods=["POST"],
)
38 changes: 38 additions & 0 deletions sdks/python/agenta/sdk/agents/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,44 @@ async def destroy(self) -> None:
return None


class SessionStore(ABC):
"""Durable conversation history behind the agent session id.

The cold runtime still receives the full message history on every turn. This port is the
place a platform-backed or file-backed store attaches when the server owns that history.
"""

@abstractmethod
async def load(self, session_id: str) -> Sequence[Message]:
"""Return the neutral message history for ``session_id``."""

@abstractmethod
async def save_turn(
self,
session_id: str,
*,
messages: Sequence[Message],
result: Optional[AgentResult] = None,
) -> None:
"""Persist one completed cold turn."""

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is the contract being frozen ahead of any real store. Confirm save_turn taking messages plus an optional AgentResult fits a file-backed or platform-backed adapter without a later signature break.



class NoopSessionStore(SessionStore):
"""Session store adapter used until server-owned history persistence lands."""

async def load(self, session_id: str) -> Sequence[Message]:
return ()

async def save_turn(
self,
session_id: str,
*,
messages: Sequence[Message],
result: Optional[AgentResult] = None,
) -> None:
return None


# ---------------------------------------------------------------------------
# Backend (the engine)
# ---------------------------------------------------------------------------
Expand Down
30 changes: 30 additions & 0 deletions sdks/python/oss/tests/pytest/utils/test_messages_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,22 @@
without ``ag.init()``.
"""

import json
from unittest.mock import MagicMock, patch

import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient

from agenta.sdk.agents import Message
from agenta.sdk.agents.adapters.vercel.routing import (
inject_stream_session_id,
make_load_session_endpoint,
resolve_session_id,
)
from agenta.sdk.decorators.routing import route
from agenta.sdk.models.workflows import (
LoadSessionRequest,
WorkflowBatchResponse,
WorkflowServiceStatus,
WorkflowStreamingResponse,
Expand Down Expand Up @@ -224,3 +228,29 @@ def test_load_session_returns_stub_history(client):
res = client.post("/load-session", json={"session_id": "sess_abc"})
assert res.status_code == 200
assert res.json() == {"session_id": "sess_abc", "messages": []}


@pytest.mark.asyncio
async def test_load_session_uses_session_store_port():
class _Store:
async def load(self, session_id):
assert session_id == "sess_abc"
return [Message(role="user", content="hello")]

async def save_turn(self, session_id, *, messages, result=None):
raise AssertionError("load-session should only load")

endpoint = make_load_session_endpoint(session_store=_Store())
response = await endpoint(None, LoadSessionRequest(session_id="sess_abc"))

assert response.status_code == 200
assert json.loads(response.body) == {
"session_id": "sess_abc",
"messages": [
{
"id": "msg-1",
"role": "user",
"parts": [{"type": "text", "text": "hello"}],
}
],
}
Loading