diff --git a/src/config/navigation.yml b/src/config/navigation.yml index 0485cac61..d528e2079 100644 --- a/src/config/navigation.yml +++ b/src/config/navigation.yml @@ -119,6 +119,7 @@ sidebar: - docs/user-guide/concepts/bidirectional-streaming/interruption - docs/user-guide/concepts/bidirectional-streaming/hooks - docs/user-guide/concepts/bidirectional-streaming/session-management + - docs/user-guide/concepts/local-development - label: Experimental items: - docs/user-guide/concepts/experimental/agent-config diff --git a/src/content/docs/user-guide/concepts/local-development.mdx b/src/content/docs/user-guide/concepts/local-development.mdx new file mode 100644 index 000000000..c1a256082 --- /dev/null +++ b/src/content/docs/user-guide/concepts/local-development.mdx @@ -0,0 +1,401 @@ +--- +title: Local Development & Mock Mode +sidebar: + label: "Local Development" +--- + +When developing agents with the Strands Agents SDK, you don't always need real API credentials. Mock model providers let you build, iterate, and test agent logic locally — without calling Bedrock, OpenAI, or any other remote service. + +This guide covers how to create mock models for local development and testing, simulate common scenarios like tool calls and errors, and optionally use a local LLM backend for more realistic testing. + +## Creating a Mock Model + +A mock model implements the same `Model` interface as any real provider but returns pre-configured responses instead of calling an API. This is the simplest way to develop and test agent logic offline. + + + + +```python +from typing import Any, AsyncIterable, Optional +from typing_extensions import Unpack + +from strands.models import Model +from strands.types.content import Messages +from strands.types.streaming import StreamEvent +from strands.types.tools import ToolSpec + + +class MockModel(Model): + """A simple mock model for local development and testing.""" + + class ModelConfig: + model_id: str = "mock-model" + + def __init__(self, responses: list[str] | None = None) -> None: + self.responses = responses or ["Hello! I am a mock response."] + self.call_index = 0 + self.config = {"model_id": "mock-model"} + + def update_config(self, **kwargs: Any) -> None: + self.config.update(kwargs) + + def get_config(self) -> dict: + return self.config + + async def stream( + self, + messages: Messages, + tool_specs: Optional[list[ToolSpec]] = None, + system_prompt: Optional[str] = None, + **kwargs: Any, + ) -> AsyncIterable[StreamEvent]: + text = self.responses[self.call_index % len(self.responses)] + self.call_index += 1 + + yield {"messageStart": {"role": "assistant"}} + yield {"contentBlockStart": {"start": {}}} + yield {"contentBlockDelta": {"delta": {"text": text}}} + yield {"contentBlockStop": {}} + yield {"messageStop": {"stopReason": "end_turn"}} +``` + + + +```typescript +--8<-- "user-guide/concepts/local-development.ts:basic_mock_model" +``` + + + +### Using the Mock Model + +Pass the mock model to an agent just like any real provider: + + + + +```python +from strands import Agent + +mock_model = MockModel(responses=["The capital of France is Paris."]) +agent = Agent(model=mock_model) + +response = agent("What is the capital of France?") +print(response) +``` + + + +```typescript +--8<-- "user-guide/concepts/local-development.ts:use_mock_model" +``` + + + +## Environment Variable Swap Pattern + +A common pattern is to swap between mock and real models using an environment variable. This lets you develop locally without changing any application code — just set `MOCK_MODE=true`: + + + + +```python +import os +from strands import Agent +from strands.models.bedrock import BedrockModel + + +def create_model(): + if os.environ.get("MOCK_MODE") == "true": + return MockModel(responses=["This is a mock response for local development."]) + return BedrockModel(model_id="anthropic.claude-sonnet-4-20250514") + + +model = create_model() +agent = Agent(model=model) +``` + +Run locally with mock mode: + +```bash +MOCK_MODE=true python my_agent.py +``` + + + +```typescript +--8<-- "user-guide/concepts/local-development.ts:env_swap_pattern" +``` + +Run locally with mock mode: + +```bash +MOCK_MODE=true npx tsx my_agent.ts +``` + + + +## Simulating Tool Calls + +To test agent tool-use loops, your mock model needs to return tool use events on the first call and text events after the tool result is returned: + + + + +```python +class MockToolModel(Model): + """A mock model that simulates tool calls.""" + + def __init__(self) -> None: + self.config = {"model_id": "mock-tool-model"} + + def update_config(self, **kwargs: Any) -> None: + self.config.update(kwargs) + + def get_config(self) -> dict: + return self.config + + async def stream( + self, + messages: Messages, + tool_specs: Optional[list[ToolSpec]] = None, + system_prompt: Optional[str] = None, + **kwargs: Any, + ) -> AsyncIterable[StreamEvent]: + # Check if the last message contains a tool result + last_message = messages[-1] if messages else None + has_tool_result = last_message and any( + "toolResult" in block for block in last_message.get("content", []) + ) + + yield {"messageStart": {"role": "assistant"}} + + if has_tool_result: + # Respond with final text after tool execution + yield {"contentBlockStart": {"start": {}}} + yield { + "contentBlockDelta": { + "delta": {"text": "The weather in Seattle is 72°F and sunny."} + } + } + yield {"contentBlockStop": {}} + yield {"messageStop": {"stopReason": "end_turn"}} + else: + # Simulate a tool call + yield { + "contentBlockStart": { + "start": { + "toolUseId": "tool_001", + "name": "get_weather", + } + } + } + yield { + "contentBlockDelta": { + "delta": {"toolUse": {"input": '{"location": "Seattle"}'}} + } + } + yield {"contentBlockStop": {}} + yield {"messageStop": {"stopReason": "tool_use"}} +``` + + + +```typescript +--8<-- "user-guide/concepts/local-development.ts:mock_tool_calls" +``` + + + +## Simulating Errors + +Testing error handling is critical. Create a mock model that throws errors to verify your agent handles failures gracefully: + + + + +```python +class MockErrorModel(Model): + """A mock model that simulates errors for testing error handling.""" + + def __init__(self, should_fail: bool = True) -> None: + self.should_fail = should_fail + self.config = {"model_id": "mock-error-model"} + + def update_config(self, **kwargs: Any) -> None: + self.config.update(kwargs) + + def get_config(self) -> dict: + return self.config + + async def stream( + self, + messages: Messages, + tool_specs: Optional[list[ToolSpec]] = None, + system_prompt: Optional[str] = None, + **kwargs: Any, + ) -> AsyncIterable[StreamEvent]: + if self.should_fail: + raise RuntimeError("Simulated API failure: rate limit exceeded") + + yield {"messageStart": {"role": "assistant"}} + yield {"contentBlockStart": {"start": {}}} + yield {"contentBlockDelta": {"delta": {"text": "Success!"}}} + yield {"contentBlockStop": {}} + yield {"messageStop": {"stopReason": "end_turn"}} +``` + + + +```typescript +--8<-- "user-guide/concepts/local-development.ts:mock_errors" +``` + + + +## Simulating Multi-Turn Conversations + +For testing multi-turn conversation flows, create a mock model with scripted responses for each turn: + + + + +```python +class MockMultiTurnModel(Model): + """A mock model with pre-scripted multi-turn responses.""" + + def __init__(self, turns: list[str]) -> None: + self.turns = turns + self.turn_index = 0 + self.config = {"model_id": "mock-multi-turn-model"} + + def update_config(self, **kwargs: Any) -> None: + self.config.update(kwargs) + + def get_config(self) -> dict: + return self.config + + async def stream( + self, + messages: Messages, + tool_specs: Optional[list[ToolSpec]] = None, + system_prompt: Optional[str] = None, + **kwargs: Any, + ) -> AsyncIterable[StreamEvent]: + if self.turn_index >= len(self.turns): + raise RuntimeError("All scripted turns have been consumed") + + text = self.turns[self.turn_index] + self.turn_index += 1 + + yield {"messageStart": {"role": "assistant"}} + yield {"contentBlockStart": {"start": {}}} + yield {"contentBlockDelta": {"delta": {"text": text}}} + yield {"contentBlockStop": {}} + yield {"messageStop": {"stopReason": "end_turn"}} + + +# Usage +multi_turn_model = MockMultiTurnModel(turns=[ + "Hi! How can I help you?", + "Sure, I can help with that.", + "Here is the result you asked for.", +]) +agent = Agent(model=multi_turn_model) + +agent("Hello") +agent("Can you help me?") +agent("Show me the result") +``` + + + +```typescript +--8<-- "user-guide/concepts/local-development.ts:mock_multi_turn" +``` + + + +## Using a Local LLM Backend + +For more realistic local development, you can run a local LLM instead of using mock responses. Strands Agents has built-in support for popular local LLM servers. + +### Ollama + +[Ollama](https://ollama.ai) lets you run open-source models locally. Strands provides a native `OllamaModel` provider: + + + + +```bash +# Install Ollama and pull a model +brew install ollama # macOS +ollama pull llama3.2 + +# Install the Strands Ollama extra +pip install 'strands-agents[ollama]' +``` + +```python +from strands import Agent +from strands.models.ollama import OllamaModel + +model = OllamaModel( + model_id="llama3.2", + host="http://localhost:11434", +) + +agent = Agent(model=model) +response = agent("Explain quantum computing in simple terms") +``` + +For complete Ollama setup and configuration, see the [Ollama model provider](./model-providers/ollama.md) documentation. + + + +Ollama support in the TypeScript SDK is not yet available as a built-in provider. You can use Ollama through the [Custom Model Provider](./model-providers/custom_model_provider.md) interface, or use the `MOCK_MODE` pattern above for local development. + + + +### llama.cpp + +[llama.cpp](https://github.com/ggerganov/llama.cpp) provides a lightweight C++ inference engine. Strands supports it natively: + + + + +```bash +# Install the Strands llama.cpp extra +pip install 'strands-agents[llamacpp]' +``` + +```python +from strands import Agent +from strands.models.llamacpp import LlamaCppModel + +model = LlamaCppModel( + host="http://localhost:8080", + model_id="local-model", +) + +agent = Agent(model=model) +response = agent("What are the benefits of local LLM development?") +``` + +For complete llama.cpp setup instructions, see the [llama.cpp model provider](./model-providers/llamacpp.md) documentation. + + + +llama.cpp support in the TypeScript SDK is not yet available as a built-in provider. You can use llama.cpp through the [Custom Model Provider](./model-providers/custom_model_provider.md) interface to connect to the llama.cpp HTTP server. + + + +## Choosing Your Approach + +| Approach | Best for | Trade-offs | +|---|---|---| +| **Mock model** | Unit tests, CI/CD, offline development | Fast and deterministic, but no real LLM behavior | +| **Env var swap** | Switching between local and production | Zero code changes, easy to integrate | +| **Ollama / llama.cpp** | Integration testing, realistic local dev | Real LLM responses, but requires local setup and GPU | + +For most development workflows, start with a mock model for fast iteration and testing, then validate with a local LLM or real API before deploying. diff --git a/src/content/docs/user-guide/concepts/local-development.ts b/src/content/docs/user-guide/concepts/local-development.ts new file mode 100644 index 000000000..ce3584696 --- /dev/null +++ b/src/content/docs/user-guide/concepts/local-development.ts @@ -0,0 +1,245 @@ +/** + * TypeScript examples for the local development & mock mode guide. + * Demonstrates how to build a MockModel for testing without real API credentials. + */ + +import { Agent } from '@strands-agents/sdk' +import type { + Model, + BaseModelConfig, + Message, + ModelStreamEvent, + StreamOptions, + StopReason, +} from '@strands-agents/sdk' + +// --8<-- [start:basic_mock_model] +import { Model as BaseModel } from '@strands-agents/sdk' +import type { + BaseModelConfig as Config, + Message as Msg, + ModelStreamEvent as StreamEvent, + StreamOptions as Opts, +} from '@strands-agents/sdk' + +/** + * A simple mock model provider for local development and testing. + * Returns pre-configured responses without making any API calls. + */ +class MockModel extends BaseModel { + private responses: string[] + private callIndex = 0 + private config: Config + + constructor(responses: string[] = ['Hello! I am a mock response.']) { + super() + this.responses = responses + this.config = { modelId: 'mock-model' } + } + + updateConfig(config: Config): void { + this.config = { ...this.config, ...config } + } + + getConfig(): Config { + return this.config + } + + async *stream(_messages: Msg[], _options?: Opts): AsyncGenerator { + // Cycle through responses, wrapping around if exhausted + const text = this.responses[this.callIndex % this.responses.length]! + this.callIndex++ + + // Yield the standard stream event sequence + yield { type: 'modelMessageStartEvent', role: 'assistant' } + yield { type: 'modelContentBlockStartEvent' } + yield { + type: 'modelContentBlockDeltaEvent', + delta: { type: 'textDelta', text }, + } + yield { type: 'modelContentBlockStopEvent' } + yield { type: 'modelMessageStopEvent', stopReason: 'endTurn' } + } +} +// --8<-- [end:basic_mock_model] + +// --8<-- [start:use_mock_model] +const mockModel = new MockModel(['The capital of France is Paris.']) +const agent = new Agent({ model: mockModel }) + +const response = await agent.invoke('What is the capital of France?') +console.log(response) +// --8<-- [end:use_mock_model] + +// --8<-- [start:env_swap_pattern] +import { BedrockModel } from '@strands-agents/sdk' + +function createModel() { + if (process.env.MOCK_MODE === 'true') { + return new MockModel(['This is a mock response for local development.']) + } + return new BedrockModel({ + modelId: 'anthropic.claude-sonnet-4-20250514', + }) +} + +const model = createModel() +const agentWithSwap = new Agent({ model }) +// --8<-- [end:env_swap_pattern] + +// --8<-- [start:mock_tool_calls] +/** + * A mock model that simulates tool calls. + * Useful for testing agent tool-use loops without real API credentials. + */ +class MockToolModel extends BaseModel { + private config: Config + + constructor() { + super() + this.config = { modelId: 'mock-tool-model' } + } + + updateConfig(config: Config): void { + this.config = { ...this.config, ...config } + } + + getConfig(): Config { + return this.config + } + + async *stream(messages: Msg[], _options?: Opts): AsyncGenerator { + // Check if the last message contains a tool result — if so, respond with text + const lastMessage = messages[messages.length - 1] + const hasToolResult = lastMessage?.content.some( + (block) => block.type === 'toolResultBlock' + ) + + yield { type: 'modelMessageStartEvent', role: 'assistant' } + + if (hasToolResult) { + // Second turn: respond with final text after tool execution + yield { type: 'modelContentBlockStartEvent' } + yield { + type: 'modelContentBlockDeltaEvent', + delta: { type: 'textDelta', text: 'The weather in Seattle is 72°F and sunny.' }, + } + yield { type: 'modelContentBlockStopEvent' } + yield { type: 'modelMessageStopEvent', stopReason: 'endTurn' } + } else { + // First turn: simulate a tool call + yield { + type: 'modelContentBlockStartEvent', + start: { + type: 'toolUseStart', + name: 'get_weather', + toolUseId: 'tool_001', + }, + } + yield { + type: 'modelContentBlockDeltaEvent', + delta: { + type: 'toolUseInputDelta', + input: JSON.stringify({ location: 'Seattle' }), + }, + } + yield { type: 'modelContentBlockStopEvent' } + yield { type: 'modelMessageStopEvent', stopReason: 'toolUse' } + } + } +} +// --8<-- [end:mock_tool_calls] + +// --8<-- [start:mock_errors] +/** + * A mock model that simulates errors for testing error handling. + */ +class MockErrorModel extends BaseModel { + private shouldFail: boolean + private config: Config + + constructor(shouldFail = true) { + super() + this.shouldFail = shouldFail + this.config = { modelId: 'mock-error-model' } + } + + updateConfig(config: Config): void { + this.config = { ...this.config, ...config } + } + + getConfig(): Config { + return this.config + } + + async *stream(_messages: Msg[], _options?: Opts): AsyncGenerator { + if (this.shouldFail) { + throw new Error('Simulated API failure: rate limit exceeded') + } + + yield { type: 'modelMessageStartEvent', role: 'assistant' } + yield { type: 'modelContentBlockStartEvent' } + yield { + type: 'modelContentBlockDeltaEvent', + delta: { type: 'textDelta', text: 'Success!' }, + } + yield { type: 'modelContentBlockStopEvent' } + yield { type: 'modelMessageStopEvent', stopReason: 'endTurn' } + } +} +// --8<-- [end:mock_errors] + +// --8<-- [start:mock_multi_turn] +/** + * A mock model with pre-scripted multi-turn conversation responses. + */ +class MockMultiTurnModel extends BaseModel { + private turns: string[] + private turnIndex = 0 + private config: Config + + constructor(turns: string[]) { + super() + this.turns = turns + this.config = { modelId: 'mock-multi-turn-model' } + } + + updateConfig(config: Config): void { + this.config = { ...this.config, ...config } + } + + getConfig(): Config { + return this.config + } + + async *stream(_messages: Msg[], _options?: Opts): AsyncGenerator { + if (this.turnIndex >= this.turns.length) { + throw new Error('All scripted turns have been consumed') + } + + const text = this.turns[this.turnIndex]! + this.turnIndex++ + + yield { type: 'modelMessageStartEvent', role: 'assistant' } + yield { type: 'modelContentBlockStartEvent' } + yield { + type: 'modelContentBlockDeltaEvent', + delta: { type: 'textDelta', text }, + } + yield { type: 'modelContentBlockStopEvent' } + yield { type: 'modelMessageStopEvent', stopReason: 'endTurn' } + } +} + +// Usage +const multiTurnModel = new MockMultiTurnModel([ + 'Hi! How can I help you?', + 'Sure, I can help with that.', + 'Here is the result you asked for.', +]) +const multiTurnAgent = new Agent({ model: multiTurnModel }) + +await multiTurnAgent.invoke('Hello') +await multiTurnAgent.invoke('Can you help me?') +await multiTurnAgent.invoke('Show me the result') +// --8<-- [end:mock_multi_turn]