morpheus-llm
Import prefix morpheus_llm. Layer MorpheusLlmLayer. Also an editable path dependency of
the AgentCore runtime.
Source: libs/morpheus-llm/morpheus_llm/.
Every Bedrock call and every Strands Agent construction in the platform goes through this
library, so model choice, prompt caching, memory wiring, and MCP transport behaviour are
changeable in one place.
Lazy imports
morpheus_llm/init.py uses PEP 562 getattr, so import morpheus_llm.utils does
not pull in Bedrock, Strands, or bedrock_agentcore:
def __getattr__(name: str):
if name == "ClaudeAgentService":
from morpheus_llm.providers.claude_service import ClaudeAgentService
return ClaudeAgentService
…
Consumers add a second layer of laziness, deferring imports inside @lru_cache functions so
a module stays importable without the layer installed. See
Testing Lambdas.
providers/base_service.py — BaseAgentService
The provider-agnostic base: tool resolution, AgentCore Memory session management, and the
public run / new_session API. A concrete provider implements one method.
class BaseAgentService(ABC):
def __init__(self, config: AgentConfig,
tools: list[Any] | Callable[[], list[Any]],
memory_id: str | None = None) -> None:
self._memory_id = memory_id or os.environ.get("MEMORY_ID", "")
def _resolve_tools(self) -> list[Any]:
return self._tools() if callable(self._tools) else self._tools
def _make_session_manager(self, options) -> AgentCoreMemorySessionManager | None: ...
@abstractmethod
def _make_agent(self, options: RunOptions | None = None) -> Any: ...
def run(self, prompt: str, options=None) -> AgentResult: ...
def new_session(self, options=None) -> Any: ...
run() builds a fresh agent, calls it once, and wraps the result:
return AgentResult(
output=str(result),
stop_reason="end_turn",
structured_output=getattr(result, "structured_output", None),
)
stop_reason is hardcoded to "end_turn" — the underlying max_turns state is not
currently surfaced.
new_session() returns a fresh callable agent for a multi-step workflow. Both return a
fresh agent every time, because service instances are container-lifetime singletons and a
shared agent would leak conversation history between requests.
Memory wiring: AgentCore Memory.
providers/claude_service.py — ClaudeAgentService
Claude on Bedrock through Strands. Only responsibility: build the Agent with a
BedrockModel.
def _make_agent(self, options: RunOptions | None = None) -> Agent:
cache_prompt, cache_tools = _bedrock_prompt_tool_cache_flags(self._config.model_id)
model = BedrockModel(
model_id=self._config.model_id,
max_tokens=self._config.max_tokens,
cache_prompt=cache_prompt,
cache_tools=cache_tools,
)
system_prompt = (options.system_prompt
if options and options.system_prompt
else self._config.system_prompt)
return Agent(
model=model,
system_prompt=system_prompt,
tools=self._resolve_tools(),
session_manager=self._make_session_manager(options) if options else None,
structured_output_model=self._config.structured_output_model,
)
Authentication is the ambient IAM identity — the Lambda execution role or the runtime role. No API key.
Prompt and tool caching
def _bedrock_prompt_tool_cache_flags(model_id: str) -> tuple[str | None, str | None]:
m = model_id.lower()
if "anthropic" in m or "claude" in m:
return "default", "default"
return None, None
Bedrock cachePoint markers are Anthropic-only. Passing them to Amazon Nova produces
ValidationException: extraneous key [cachePoint] is not permitted in
toolConfig/tools. The flags are therefore derived from the model id, which is why the
Nova-based email categorisation agent works without any special handling at the call site.
Caching matters most for the enrichment agent, where a long system prompt and two MCP tool schemas are re-sent on every turn.
Structured output
Set AgentConfig.structured_output_model to a Pydantic class and Strands adds a dedicated
output tool, so the model returns validated structured data rather than text to parse.
config = AgentConfig(
model_id=AgentModelIds.CLAUDE_HAIKU_4_5,
system_prompt="…",
max_tokens=2048,
max_turns=6,
structured_output_model=SellerEnrichmentResult,
)
result = agent.run(prompt)
data: SellerEnrichmentResult = result.structured_output # a validated instance
Never json.loads(result.output). If structured_output is None, the model failed to
call the output tool — treat that as an error, as run_reply_categorisation() does by
raising RuntimeError.
Used by all three Lambda agents. The AgentCore runtime’s BaseWorkflow uses a weaker
prompt-level mechanism instead — see Workflows.
types/init.py
@dataclass
class AgentConfig:
model_id: str
system_prompt: str
max_tokens: int
max_turns: int
structured_output_model: type | None = None
@dataclass
class AgentResult:
output: str
stop_reason: str = "end_turn"
usage: dict[str, int] | None = None
structured_output: Any = None
@dataclass
class RunOptions:
system_prompt: str | None = None
max_turns: int | None = None
metadata: dict[str, Any] | None = None
session_id: str | None = None # required for memory
actor_id: str | None = None # memory namespace key
|
|
usage keys observed in logs: inputTokens, outputTokens, cacheWriteInputTokens,
cacheReadInputTokens.
utils/enums.py — AgentModelIds
| Member | Model id |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Do not strip the |
In use today: CLAUDE_SONNET_4 (runtime dialler agent), CLAUDE_HAIKU_4_5 (seller
enrichment), CLAUDE_HAIKU_3 (payout-pain scoring), NOVA_LITE (email reply
categorisation).
tools/
| Export | Purpose |
|---|---|
|
Demonstration |
|
HubSpot Remote MCP client with 429 retry |
|
Tavily Remote MCP client — web search and extraction |
|
Managed cloud Chrome; requires the |
Endpoints (constants.py): https://mcp.hubspot.com/ and https://mcp.tavily.com/mcp/.
HubSpot MCP transport
Two behaviours worth knowing:
-
_RetryOn429Transportwraps thehttpxasync transport and retries HTTP 429 up to 4 times with exponential backoff (1 s base, capped at 30 s per sleep), honouringRetry-Afterwhen present. -
terminate_on_close=False, because HubSpot’s MCP server does not implement the DELETE session-termination endpoint and returns 405 if it is called.
The access token is obtained by refresh_hubspot_access_token() and cached with
@lru_cache(maxsize=4) keyed on the credential triple, so the OAuth round-trip happens once
per container. Use _get_access_token.cache_clear() in tests.
Factories, not objects
Every MCP tool is a factory. MCPClient holds an open HTTP session and cannot be reused
across agent invocations, so tool sets containing one must be supplied as a lambda:
tools=lambda: [create_tavily_mcp(api_key=secrets.tavily_api_key),
create_hubspot_mcp(client_id=…, client_secret=…, refresh_token=…)]
Details: Tools.
utils/hubspot.py
Re-exports pyb_hubspot.auth.refresh_hubspot_access_token. Token refresh is a
HubSpot-specific credential concern needed by both this layer and the CRM sync Lambda, so
pyb-hubspot owns the implementation and this module keeps existing importers working.
Consumers
| Consumer | Uses |
|---|---|
enrichment-scorer |
|
email-agent |
|
agentcore runtime |
|
Extras
morpheus-llm[browser] adds strands-agents-tools, playwright, and nest-asyncio for
create_agentcore_browser(). The AgentCore runtime depends on the extras variant; the
Lambda layer does not.
|
The split is a packaging constraint, not a preference. Playwright ships browser binaries that blow past the 250 MB unzipped limit a zip-based layer shares with the function and every other layer — see Lambda layers. The AgentCore runtime is a container image, so it has room. A Lambda that genuinely needs browser automation would have to be repackaged as a container image rather than gaining a sixth layer. Nothing needs that today. |