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

AgentConfig.max_turns and RunOptions.max_turns are carried through the API but are not passed to the Strands Agent in _make_agent(). Treat them as documentation of intent rather than an enforced ceiling.

usage keys observed in logs: inputTokens, outputTokens, cacheWriteInputTokens, cacheReadInputTokens.

utils/enums.py — AgentModelIds

Member Model id

CLAUDE_OPUS_4_6

us.anthropic.claude-opus-4-6-v1

CLAUDE_SONNET_4

us.anthropic.claude-sonnet-4-20250514-v1:0

CLAUDE_HAIKU_4_5

us.anthropic.claude-haiku-4-5-20251001-v1:0

CLAUDE_HAIKU_3_5

us.anthropic.claude-3-5-haiku-20241022-v1:0

CLAUDE_HAIKU_3

us.anthropic.claude-3-haiku-20240307-v1:0

NOVA_MICRO

us.amazon.nova-micro-v1:0

NOVA_LITE

us.amazon.nova-lite-v1:0

NOVA_2_LITE

us.amazon.nova-2-lite-v1:0

Do not strip the us. prefix. Newer models are only available through cross-region inference profiles, which the prefix selects. Older models (3.5 and below) also support direct on-demand invocation with a bare anthropic.* id, but the enum uses the prefixed form throughout for consistency.

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

add_numbers

Demonstration @tool

create_hubspot_mcp(*, client_id, client_secret, refresh_token)

HubSpot Remote MCP client with 429 retry

create_tavily_mcp(*, api_key)

Tavily Remote MCP client — web search and extraction

create_agentcore_browser(region=None, session_timeout=3600, identifier=None)

Managed cloud Chrome; requires the [browser] extras

Endpoints (constants.py): https://mcp.hubspot.com/ and https://mcp.tavily.com/mcp/.

HubSpot MCP transport

Two behaviours worth knowing:

  • _RetryOn429Transport wraps the httpx async transport and retries HTTP 429 up to 4 times with exponential backoff (1 s base, capped at 30 s per sleep), honouring Retry-After when 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

ClaudeAgentService, both MCP factories, AgentConfig, AgentModelIds

email-agent

ClaudeAgentService, AgentConfig, AgentModelIds (Nova)

agentcore runtime

ClaudeAgentService, BaseAgentService, create_hubspot_mcp, add_numbers, all types

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.