diff --git a/src/assets/templates/agent-python-strands/README.md b/src/assets/templates/agent-python-strands/README.md index e3edfd5ae..eebff51c3 100644 --- a/src/assets/templates/agent-python-strands/README.md +++ b/src/assets/templates/agent-python-strands/README.md @@ -9,9 +9,9 @@ commands like `deploy`, `dev`, and `invoke` rely on the configuration stored her ## Agent Root The main entrypoint to your app is defined in `main.py`. Using the AgentCore SDK `@app.entrypoint` decorator, this -file defines a Starlette ASGI app with the chosen Agent framework SDK running within. +file defines a Starlette ASGI app with the Strands Agent SDK running within. -`model/load.py` instantiates your chosen model provider. +`model/load.py` instantiates the chosen model provider. ## Input Validation diff --git a/src/assets/templates/agent-python-strands/main.py b/src/assets/templates/agent-python-strands/main.py index 69dca7e8e..7988bae03 100644 --- a/src/assets/templates/agent-python-strands/main.py +++ b/src/assets/templates/agent-python-strands/main.py @@ -1,441 +1,54 @@ from typing import Any from collections import OrderedDict -{{#if inlineFunctionTools}} -import json -from strands.tools.tools import PythonAgentTool -from strands.types.tools import ToolResult, ToolUse -{{/if}} from strands import Agent, tool -{{#if hasSkillsFetcher}} -from strands import AgentSkills -{{#if hasFetchedSkills}} -from skills.fetcher import resolve_s3_skills, resolve_git_skills -{{/if}} -{{#if (some gitSkills "credentialArn")}} -from bedrock_agentcore.services.identity import IdentityClient -{{/if}} -{{/if}} -import asyncio -{{#if hasShell}} -import subprocess -{{/if}} -{{#if hasFileOperations}} -import os -{{/if}} -{{#if hasExecutionLimits}} -from strands.tools.executors import SequentialToolExecutor -from strands.types.exceptions import EventLoopException -from hooks.execution_limits import ExecutionLimitExceeded, ExecutionLimitsHook -{{/if}} -{{#if hasConfigBundle}} -from strands.hooks import HookProvider, HookRegistry, BeforeInvocationEvent, BeforeToolCallEvent -{{/if}} -{{#if truncationStrategy}} -{{#if (eq truncationStrategy "sliding_window")}} -from strands.agent.conversation_manager.sliding_window_conversation_manager import SlidingWindowConversationManager -{{/if}} -{{#if (eq truncationStrategy "summarization")}} -from strands.agent.conversation_manager.summarizing_conversation_manager import SummarizingConversationManager -{{/if}} -{{else}} from strands.agent.conversation_manager.null_conversation_manager import NullConversationManager -{{/if}} -{{#if hasConfigBundle}} -from bedrock_agentcore.runtime.context import BedrockAgentCoreContext -{{/if}} -{{#if hasBrowser}} -from strands_tools.browser import AgentCoreBrowser -{{/if}} -{{#if hasCodeInterpreter}} -from strands_tools.code_interpreter import AgentCoreCodeInterpreter -{{/if}} from bedrock_agentcore.runtime import BedrockAgentCoreApp from model.load import load_model -{{#if hasGateway}} -from mcp_client.client import get_all_gateway_mcp_clients -{{/if}} -{{#if remoteMcpTools}} -from mcp_client.client import get_all_remote_mcp_clients -{{/if}} -{{#unless (or hasGateway remoteMcpTools)}} -{{#unless isExportHarness}} -from mcp_client.client import get_streamable_http_mcp_client -{{/unless}} -{{/unless}} {{#if hasMemory}} from memory.session import get_memory_session_manager {{/if}} -{{#unless hasFileOperations}} -{{#if (or needsOs browserIdentifierEnvVar codeInterpreterIdentifierEnvVar (some gitSkills "credentialArn"))}} -import os -{{/if}} -{{/unless}} -{{#if hasPayment}} -from capabilities.payments.payments import create_payments_plugin, PAYMENT_SYSTEM_PROMPT -{{/if}} app = BedrockAgentCoreApp() log = app.logger -{{#if (or hasGateway remoteMcpTools)}} -# Define MCP clients for all configured MCP servers (gateways and/or remote MCP) -mcp_clients = [] -{{#if hasGateway}} -mcp_clients += get_all_gateway_mcp_clients() -{{/if}} -{{#if remoteMcpTools}} -mcp_clients += get_all_remote_mcp_clients() -{{/if}} -{{else}} -{{#unless isExportHarness}} -# Define a Streamable HTTP MCP Client -mcp_clients = [get_streamable_http_mcp_client()] -{{/unless}} -{{/if}} - -{{#if systemPromptText}} -DEFAULT_SYSTEM_PROMPT = """{{escapePyStr systemPromptText}}""" -{{else}} -DEFAULT_SYSTEM_PROMPT = """ -You are a helpful assistant. Use tools when appropriate. -{{#if needsOs}}{{#unless isExportHarness}} -You have access to the following mounted filesystems. Use file_read, file_write, and list_files with full absolute paths: -{{#if sessionStorageMountPath}}- {{sessionStorageMountPath}}: ephemeral session storage (lost when session ends) -{{/if}}{{#each efsMounts}}- {{mountPath}}: EFS persistent storage (persists across sessions and agent restarts) -{{/each}}{{#each s3Mounts}}- {{mountPath}}: S3 Files persistent storage (durable, backed by S3) -{{/each}}{{/unless}}{{/if}} -""" -{{/if}} - -{{#if hasConfigBundle}} -DEFAULT_TOOL_DESC = "Return the sum of two numbers" -{{/if}} +DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant. Use tools when appropriate." # Define a collection of tools used by the model tools = [] -{{#if inlineFunctionTools}} -# Inline function tools — stop the agent loop so the tool call streams back to the caller -def _make_inline_tool(name: str, spec: dict) -> PythonAgentTool: - def _handler(tool: ToolUse, **kwargs: Any) -> ToolResult: - kwargs.get("request_state", {})["stop_event_loop"] = True - return {"toolUseId": tool["toolUseId"], "status": "success", "content": [{"text": " "}]} - _handler.__name__ = name - return PythonAgentTool(tool_name=name, tool_spec=spec, tool_func=_handler) -{{#each inlineFunctionTools}} -_INLINE_SPEC_{{snakeCase name}} = { - "name": "{{name}}", - "description": {{safeJson description}}, - "inputSchema": {"json": json.loads({{pyJsonStr inputSchema}}) }, -} -tools.append(_make_inline_tool("{{name}}", _INLINE_SPEC_{{snakeCase name}})) -{{/each}} - -_INLINE_FUNCTION_NAMES = { {{#each inlineFunctionTools}}"{{name}}"{{#unless @last}}, {{/unless}}{{/each}} } - -{{else}} -_INLINE_FUNCTION_NAMES = set() - -{{#unless isExportHarness}} # Define a simple function tool -{{#if hasConfigBundle}} -@tool(description=DEFAULT_TOOL_DESC) -{{else}} @tool -{{/if}} def add_numbers(a: int, b: int) -> int: """Return the sum of two numbers""" - return a+b -tools.append(add_numbers) + return a + b -{{/unless}} -{{/if}} -{{#if hasBrowser}} -{{#if browserIdentifierEnvVar}} -_browser_id = os.getenv("{{browserIdentifierEnvVar}}") -tools.append(AgentCoreBrowser(**({"identifier": _browser_id} if _browser_id else {})).browser) -{{else}} -tools.append(AgentCoreBrowser().browser) -{{/if}} -{{/if}} -{{#if hasCodeInterpreter}} -{{#if codeInterpreterIdentifierEnvVar}} -_code_interpreter_id = os.getenv("{{codeInterpreterIdentifierEnvVar}}") -tools.append(AgentCoreCodeInterpreter(**({"identifier": _code_interpreter_id} if _code_interpreter_id else {})).code_interpreter) -{{else}} -tools.append(AgentCoreCodeInterpreter().code_interpreter) -{{/if}} -{{/if}} -{{#if hasShell}} -@tool -def shell(command: str, timeout: int = 300) -> dict: - """Execute a bash command and return the results. - - Args: - command: The bash command to execute - timeout: Timeout in seconds (default: 300) - - Returns: - Dict with stdout, stderr, and exit_code - """ - result = subprocess.run( - command, shell=True, capture_output=True, text=True, timeout=timeout - ) - return {"stdout": result.stdout, "stderr": result.stderr, "exit_code": result.returncode} - -tools.append(shell) -{{/if}} -{{#if hasFileOperations}} -@tool -def file_operations( - command: str, - path: str, - old_str: str = None, - new_str: str = None, - file_text: str = None, - insert_line: int = None, - view_range: list = None, -) -> str: - """Text editor tool for viewing and modifying files. - - Args: - command: The command to execute ("view", "str_replace", "create", "insert") - path: Path to the file or directory - old_str: Text to replace (for str_replace command) - new_str: Replacement text (for str_replace and insert commands) - file_text: Content for new file (for create command) - insert_line: Line number to insert after (for insert command) - view_range: [start_line, end_line] for viewing specific lines (for view command) - - Returns: - Result of the operation - """ - try: - if command == "view": - if not os.path.exists(path): - return f"Error: Path '{path}' does not exist" - if os.path.isdir(path): - return "\n".join(os.listdir(path)) - with open(path) as f: - lines = f.read().splitlines() - if view_range: - start, end = view_range - start_idx = max(0, start - 1) - end_idx = len(lines) if end == -1 else min(len(lines), end) - lines = lines[start_idx:end_idx] - start_num = start_idx + 1 - else: - start_num = 1 - return "\n".join(f"{start_num + i}: {line}" for i, line in enumerate(lines)) - elif command == "str_replace": - if old_str is None or new_str is None: - return "Error: str_replace requires both old_str and new_str parameters" - if not os.path.exists(path): - return f"Error: File '{path}' does not exist" - content = open(path).read() - if old_str not in content: - return "Error: Text not found in file" - count = content.count(old_str) - if count > 1: - return f"Error: Text appears {count} times in file. Please be more specific." - open(path, "w").write(content.replace(old_str, new_str, 1)) - return f"Successfully replaced text in '{path}'" - elif command == "create": - if file_text is None: - return "Error: create requires file_text parameter" - os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) - open(path, "w").write(file_text) - return f"Successfully created file '{path}'" - elif command == "insert": - if new_str is None or insert_line is None: - return "Error: insert requires both new_str and insert_line parameters" - if not os.path.exists(path): - return f"Error: File '{path}' does not exist" - lines = open(path).read().splitlines(True) - if insert_line == 0: - lines.insert(0, new_str + "\n") - elif insert_line >= len(lines): - lines.append(new_str + "\n") - else: - lines.insert(insert_line, new_str + "\n") - open(path, "w").write("".join(lines)) - return f"Successfully inserted text in '{path}' at line {insert_line + 1}" - else: - return f"Error: Unknown command '{command}'" - except Exception as e: - return f"Error: {e}" - -tools.append(file_operations) -{{/if}} -{{#if needsOs}}{{#unless isExportHarness}} -_MOUNT_PATHS = [ - {{#if sessionStorageMountPath}}"{{sessionStorageMountPath}}",{{/if}} - {{#each efsMounts}}"{{mountPath}}",{{/each}} - {{#each s3Mounts}}"{{mountPath}}",{{/each}} -] - -def _safe_resolve(path: str) -> str: - resolved = os.path.realpath(path) - if not any(resolved == os.path.realpath(m) or resolved.startswith(os.path.realpath(m) + os.sep) for m in _MOUNT_PATHS): - raise ValueError(f"Path '{path}' is not within any configured mount ({', '.join(_MOUNT_PATHS)})") - return resolved - -@tool -def file_read(path: str) -> str: - """Read a file from a mounted filesystem. Use the absolute path (e.g. /mnt/tools/data.txt).""" - try: - full_path = _safe_resolve(path) - with open(full_path) as f: - return f.read() - except ValueError as e: - return str(e) - except OSError as e: - return f"Error reading '{path}': {e.strerror}" -@tool -def file_write(path: str, content: str) -> str: - """Write a file to a mounted filesystem. Use the absolute path (e.g. /mnt/tools/data.txt).""" - try: - full_path = _safe_resolve(path) - parent = os.path.dirname(full_path) - if parent: - os.makedirs(parent, exist_ok=True) - with open(full_path, "w") as f: - f.write(content) - return f"Written to {path}" - except ValueError as e: - return str(e) - except OSError as e: - return f"Error writing '{path}': {e.strerror}" - -@tool -def list_files(path: str) -> str: - """List files in a mounted filesystem directory. Use the absolute path (e.g. /mnt/tools).""" - try: - full_path = _safe_resolve(path) - entries = os.listdir(full_path) - return "\n".join(entries) if entries else "(empty directory)" - except ValueError as e: - return str(e) - except OSError as e: - return f"Error listing '{path}': {e.strerror}" - -tools.extend([file_read, file_write, list_files]) -{{/unless}}{{/if}} - -{{#if (or hasGateway remoteMcpTools)}} -# Add MCP clients to tools -for mcp_client in mcp_clients: - if mcp_client: - tools.append(mcp_client) -{{else}} -{{#unless isExportHarness}} -# Add MCP client to tools if available -for mcp_client in mcp_clients: - if mcp_client: - tools.append(mcp_client) -{{/unless}} -{{/if}} - -{{#if hasConfigBundle}} - -class ConfigBundleHook(HookProvider): - """Injects config bundle values (system prompt, tool descriptions) before each invocation. - - BedrockAgentCoreContext.get_config_bundle() fetches the component configuration - for the current runtime ARN from the config bundle service. The SDK caches the - result and refreshes on bundle version changes. - """ - - def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None: - registry.add_callback(BeforeInvocationEvent, self._inject_system_prompt) - registry.add_callback(BeforeToolCallEvent, self._override_tool_desc) - - def _inject_system_prompt(self, event: BeforeInvocationEvent) -> None: - config = BedrockAgentCoreContext.get_config_bundle() - prompt = config.get("systemPrompt", DEFAULT_SYSTEM_PROMPT) - - if prompt != event.agent.system_prompt: - event.agent.system_prompt = prompt - - def _override_tool_desc(self, event: BeforeToolCallEvent) -> None: - config = BedrockAgentCoreContext.get_config_bundle() - tool_descs = config.get("toolDescriptions", {}) - - tool_name = event.tool_use["name"] - override = tool_descs.get(tool_name) - if override and event.selected_tool: - spec = event.selected_tool.tool_spec - if spec and "description" in spec: - spec["description"] = override +tools.append(add_numbers) -{{/if}} def _make_conversation_manager(): -{{#if truncationStrategy}} -{{#if (eq truncationStrategy "sliding_window")}} -{{#if truncationConfig}} - return SlidingWindowConversationManager(**{{safeJson truncationConfig}}, per_turn=True) -{{else}} - return SlidingWindowConversationManager(per_turn=True) -{{/if}} -{{else}} -{{#if truncationConfig}} - return SummarizingConversationManager(**{{safeJson truncationConfig}}) -{{else}} - return SummarizingConversationManager() -{{/if}} -{{/if}} -{{else}} return NullConversationManager() -{{/if}} + {{#if hasMemory}} -{{#unless hasPayment}} def agent_factory(): cache = {} - def get_or_create_agent(session_id, user_id{{#if hasSkillsFetcher}}, skill_plugins=None{{/if}}): - {{#if actorId}} - _actor_id = "{{actorId}}" - {{else}} - _actor_id = user_id - {{/if}} - key = f"{session_id}/{_actor_id}" + def get_or_create_agent(session_id, user_id): + key = f"{session_id}/{user_id}" if key not in cache: cache[key] = Agent( model=load_model(), - session_manager=get_memory_session_manager(session_id, _actor_id), + session_manager=get_memory_session_manager(session_id, user_id), conversation_manager=_make_conversation_manager(), system_prompt=DEFAULT_SYSTEM_PROMPT, tools=tools, - {{#if hasSkillsFetcher}} - plugins=skill_plugins or None, - {{/if}} - {{#if hasExecutionLimits}} - tool_executor=SequentialToolExecutor(), - callback_handler=None, - {{/if}} - hooks=[ - {{#if hasExecutionLimits}} - ExecutionLimitsHook( - {{#if maxIterations}}max_iterations={{maxIterations}},{{/if}} - {{#if maxTokens}}max_tokens={{maxTokens}},{{/if}} - {{#if timeoutSeconds}}timeout_seconds={{timeoutSeconds}},{{/if}} - ), - {{/if}} - {{#if hasConfigBundle}} - ConfigBundleHook(), - {{/if}} - ], ) return cache[key] return get_or_create_agent get_or_create_agent = agent_factory() -{{/unless}} {{else}} -{{#unless hasPayment}} # Reuses one Agent per session_id so each session keeps its own in-process # conversation history (best-effort; resets on cold start). The cache is bounded # to 128 sessions with LRU eviction (least-recently-used is dropped and its @@ -443,7 +56,7 @@ def get_or_create_agent(session_id, user_id{{#if hasSkillsFetcher}}, skill_plugi # between them or grow without limit. For durable history, attach a session manager. def agent_factory(): cache = OrderedDict() - def get_or_create_agent(session_id{{#if hasSkillsFetcher}}, skill_plugins=None{{/if}}): + def get_or_create_agent(session_id): if session_id in cache: cache.move_to_end(session_id) return cache[session_id] @@ -454,30 +67,10 @@ def get_or_create_agent(session_id{{#if hasSkillsFetcher}}, skill_plugins=None{{ system_prompt=DEFAULT_SYSTEM_PROMPT, tools=tools, conversation_manager=_make_conversation_manager(), - {{#if hasSkillsFetcher}} - plugins=skill_plugins or None, - {{/if}} - {{#if hasExecutionLimits}} - tool_executor=SequentialToolExecutor(), - callback_handler=None, - {{/if}} - hooks=[ - {{#if hasExecutionLimits}} - ExecutionLimitsHook( - {{#if maxIterations}}max_iterations={{maxIterations}},{{/if}} - {{#if maxTokens}}max_tokens={{maxTokens}},{{/if}} - {{#if timeoutSeconds}}timeout_seconds={{timeoutSeconds}},{{/if}} - ), - {{/if}} - {{#if hasConfigBundle}} - ConfigBundleHook(), - {{/if}} - ], ) return cache[session_id] return get_or_create_agent get_or_create_agent = agent_factory() -{{/unless}} {{/if}} @@ -507,207 +100,38 @@ def strip_trailing_tool_use(messages: Any) -> list[dict]: def _extract_prompt(payload: dict): - """Accept validated harness messages, tool results, or a plain prompt string.""" + """Accept a caller-supplied message history or a plain prompt string.""" if not isinstance(payload, dict): raise ValueError("payload must be a JSON object") if "messages" in payload: return strip_trailing_tool_use(payload["messages"]) - if "tool_results" in payload: - tool_results = payload["tool_results"] - if not isinstance(tool_results, list) or not all( - isinstance(tool_result, dict) and isinstance(tool_result.get("toolUseId"), str) - for tool_result in tool_results - ): - raise ValueError("tool_results must contain objects with a toolUseId string") - return [{"role": "user", "content": [{"toolResult": { - "toolUseId": tr["toolUseId"], - "status": tr.get("status", "success"), - "content": tr.get("content", []), - }} for tr in tool_results]}] prompt = payload.get("prompt", "") if not isinstance(prompt, str): raise ValueError("prompt must be a string") return prompt -def _has_inline_function_call(messages) -> bool: - """Return True if messages contains an assistant toolUse for an inline function tool.""" - if not _INLINE_FUNCTION_NAMES or not isinstance(messages, list): - return False - for msg in messages: - if msg.get("role") == "assistant": - for block in msg.get("content", []): - if isinstance(block, dict) and block.get("toolUse", {}).get("name") in _INLINE_FUNCTION_NAMES: - return True - return False - - -def _is_inline_function_call(event: dict) -> bool: - """Check if a contentBlockStart event is for an inline function tool.""" - if not _INLINE_FUNCTION_NAMES: - return False - cbs = event.get("contentBlockStart", {}) - start = cbs.get("start", {}) - tool_use = start.get("toolUse") if isinstance(start, dict) else None - return tool_use is not None and tool_use.get("name") in _INLINE_FUNCTION_NAMES - - - @app.entrypoint async def invoke(payload, context): log.info("Invoking Agent.....") -{{#if hasPayment}} - user_id = payload.get("user_id") or getattr(context, "user_id", "default-user") - instrument_id = payload.get("payment_instrument_id") - session_id = payload.get("payment_session_id") - payments_plugin = create_payments_plugin(user_id, instrument_id, session_id) - plugins = [payments_plugin] if payments_plugin else [] -{{/if}} -{{#if hasSkillsFetcher}} - skill_paths = [{{#each pathSkills}}{{safeJson this}}{{#unless @last}}, {{/unless}}{{/each}}] - {{#if s3Skills}} - s3_skill_sources = [{{#each s3Skills}}{{safeJson this}}{{#unless @last}}, {{/unless}}{{/each}}] - skill_paths.extend(await asyncio.to_thread(resolve_s3_skills, s3_skill_sources, None)) - {{/if}} - {{#if gitSkills}} - git_skill_sources = [ - {{#each gitSkills}} - dict(url={{safeJson this.url}}{{#if this.path}}, path={{safeJson this.path}}{{/if}}{{#if this.credentialArn}}, credentialArn={{safeJson this.credentialArn}}{{#if this.username}}, username={{safeJson this.username}}{{/if}}{{/if}}), - {{/each}} - ] - {{#if (some gitSkills "credentialArn")}} - _git_identity_client = IdentityClient(os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION", "us-east-1"))) + session_id = getattr(context, "session_id", "default-session") + {{#if hasMemory}} + user_id = getattr(context, "user_id", "default-user") + agent = get_or_create_agent(session_id, user_id) {{else}} - _git_identity_client = None - {{/if}} - skill_paths.extend(await asyncio.to_thread(resolve_git_skills, git_skill_sources, _git_identity_client)) + agent = get_or_create_agent(session_id) {{/if}} - _skill_plugins = [AgentSkills(skills=skill_paths)] if skill_paths else [] -{{/if}} - -{{#if hasMemory}} -{{#if hasPayment}} - mem_session_id = getattr(context, 'session_id', 'default-session') - {{#if actorId}} - mem_user_id = "{{actorId}}" - {{else}} - mem_user_id = getattr(context, 'user_id', 'default-user') - {{/if}} - agent = Agent( - model=load_model(), - session_manager=get_memory_session_manager(mem_session_id, mem_user_id), - system_prompt=DEFAULT_SYSTEM_PROMPT + PAYMENT_SYSTEM_PROMPT, - tools=tools, - plugins=plugins{{#if hasSkillsFetcher}} + _skill_plugins{{/if}},{{#if hasConfigBundle}} - hooks=[ConfigBundleHook()],{{/if}} - ) -{{else}} - session_id = getattr(context, 'session_id', 'default-session') - {{#if actorId}} - user_id = "{{actorId}}" - {{else}} - user_id = getattr(context, 'user_id', 'default-user') - {{/if}} - agent = get_or_create_agent(session_id, user_id{{#if hasSkillsFetcher}}, _skill_plugins{{/if}}) -{{/if}} -{{else}} -{{#if hasPayment}} - agent = Agent( - model=load_model(), - system_prompt=DEFAULT_SYSTEM_PROMPT + PAYMENT_SYSTEM_PROMPT, - tools=tools, - plugins=plugins{{#if hasSkillsFetcher}} + _skill_plugins{{/if}},{{#if hasConfigBundle}} - hooks=[ConfigBundleHook()],{{/if}} - ) -{{else}} - session_id = getattr(context, 'session_id', 'default-session') - agent = get_or_create_agent(session_id{{#if hasSkillsFetcher}}, _skill_plugins{{/if}}) -{{/if}} -{{/if}} prompt = _extract_prompt(payload) - {{#if inlineFunctionTools}} - # If Turn 2 carries the harness-style assistant(toolUse)+user(toolResult) pair, - # strip the placeholder turn Strands stored during Turn 1 so the real toolResult - # is injected cleanly — same protocol as the harness runtime. - if _has_inline_function_call(prompt): - msgs = agent.messages - if len(msgs) >= 2 and any("toolResult" in b for b in msgs[-1].get("content", [])): - del msgs[-2:] - {{/if}} - - {{#if hasExecutionLimits}} - timeout_seconds = {{#if timeoutSeconds}}{{timeoutSeconds}}{{else}}None{{/if}} - timeout_fired = False - watchdog_task = None - if timeout_seconds is not None: - async def _timeout_watchdog(): - nonlocal timeout_fired - await asyncio.sleep(timeout_seconds) - timeout_fired = True - agent.cancel() - watchdog_task = asyncio.create_task(_timeout_watchdog()) - - try: - {{#if inlineFunctionTools}} - hit_inline_function = False - {{/if}} - async for event in agent.stream_async( - prompt, - ): - if not isinstance(event, dict) or "event" not in event: - continue - cbs = event["event"].get("contentBlockStart") - if cbs is not None and not cbs.get("start"): - continue - {{#if inlineFunctionTools}} - if not hit_inline_function: - hit_inline_function = _is_inline_function_call(event["event"]) - {{/if}} - yield event - {{#if inlineFunctionTools}} - if hit_inline_function and "messageStop" in event["event"]: - return - {{/if}} - - if timeout_fired: - yield {"event": {"messageStop": {"stopReason": "timeout_exceeded"}}} - except EventLoopException as e: - if isinstance(e.original_exception, ExecutionLimitExceeded): - yield {"event": {"messageStop": {"stopReason": str(e.original_exception)}}} - return - raise - finally: - if watchdog_task is not None: - watchdog_task.cancel() - try: - await watchdog_task - except asyncio.CancelledError: - pass - {{else}} - {{#if inlineFunctionTools}} - hit_inline_function = False - {{/if}} - async for event in agent.stream_async( - prompt, - ): + async for event in agent.stream_async(prompt): if not isinstance(event, dict) or "event" not in event: continue cbs = event["event"].get("contentBlockStart") if cbs is not None and not cbs.get("start"): continue - {{#if inlineFunctionTools}} - if not hit_inline_function: - hit_inline_function = _is_inline_function_call(event["event"]) - {{/if}} yield event - {{#if inlineFunctionTools}} - if hit_inline_function and "messageStop" in event["event"]: - return - {{/if}} - {{/if}} if __name__ == "__main__": diff --git a/src/assets/templates/agent-python-strands/mcp_client/__init__.py b/src/assets/templates/agent-python-strands/mcp_client/__init__.py deleted file mode 100644 index 0e632e10c..000000000 --- a/src/assets/templates/agent-python-strands/mcp_client/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Package marker diff --git a/src/assets/templates/agent-python-strands/mcp_client/client.py b/src/assets/templates/agent-python-strands/mcp_client/client.py deleted file mode 100644 index 4de07e43a..000000000 --- a/src/assets/templates/agent-python-strands/mcp_client/client.py +++ /dev/null @@ -1,116 +0,0 @@ -import os -import logging -from mcp.client.streamable_http import streamablehttp_client -from strands.tools.mcp.mcp_client import MCPClient - -logger = logging.getLogger(__name__) - -{{#if hasGateway}} -{{#if (includes gatewayAuthTypes "AWS_IAM")}} -from mcp_proxy_for_aws.client import aws_iam_streamablehttp_client -{{/if}} -{{#if (includes gatewayAuthTypes "CUSTOM_JWT")}} -from bedrock_agentcore.identity import requires_access_token -{{/if}} - -{{#each gatewayProviders}} -{{#if (eq authType "CUSTOM_JWT")}} -@requires_access_token( - provider_name="{{credentialProviderName}}", - scopes=[{{#if scopes}}"{{scopes}}"{{/if}}], - auth_flow="{{#if authFlow}}{{authFlow}}{{else}}M2M{{/if}}", -{{#if customParameters}} - custom_parameters={{safeJson customParameters}}, -{{/if}} -) -def _get_bearer_token_{{snakeCase name}}(*, access_token: str): - """Obtain OAuth access token via AgentCore Identity for {{name}}.""" - return access_token - -{{/if}} -{{/each}} -{{#each gatewayProviders}} -def get_{{snakeCase name}}_mcp_client() -> MCPClient | None: - """Returns an MCP Client connected to the {{name}} gateway.""" - {{#if hardcodedUrl}} - url = {{safeJson hardcodedUrl}} - {{else}} - url = os.environ.get("{{envVarName}}") - if not url: - logger.warning("{{envVarName}} not set — {{name}} gateway tools unavailable") - return None - {{/if}} - {{#if (eq authType "AWS_IAM")}} - return MCPClient(lambda: aws_iam_streamablehttp_client(url, aws_service="bedrock-agentcore", aws_region=os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION"))), prefix="{{snakeCase name}}") - {{else if (eq authType "CUSTOM_JWT")}} - token = _get_bearer_token_{{snakeCase name}}() - headers = {"Authorization": f"Bearer {token}"} if token else {} - return MCPClient(lambda: streamablehttp_client(url, headers=headers), prefix="{{snakeCase name}}") - {{else}} - return MCPClient(lambda: streamablehttp_client(url), prefix="{{snakeCase name}}") - {{/if}} - -{{/each}} -def get_all_gateway_mcp_clients() -> list[MCPClient]: - """Returns MCP clients for all configured gateways.""" - clients = [] - {{#each gatewayProviders}} - client = get_{{snakeCase name}}_mcp_client() - if client: - clients.append(client) - {{/each}} - return clients -{{/if}} -{{#if remoteMcpTools}} -{{#if (some remoteMcpTools "headerCredentials")}} -from bedrock_agentcore.identity.auth import requires_api_key -{{/if}} -{{#each remoteMcpTools}} -{{#if headerCredentials}} -{{#each headerCredentials}} -@requires_api_key(provider_name="{{credentialName}}") -def _get_{{snakeCase ../name}}_{{snakeCase headerKey}}_key(api_key: str) -> str: - """Fetch {{headerKey}} credential for {{../name}} from AgentCore Identity.""" - return api_key - -{{/each}} -{{/if}} -def get_{{snakeCase name}}_mcp_client() -> MCPClient | None: - """Returns an MCP Client for the {{name}} remote MCP server.""" - url = {{safeJson url}} - {{#if headerCredentials}} - if os.getenv("LOCAL_DEV") == "1": - headers = { {{#each headerCredentials}}{{safeJson headerKey}}: os.environ.get("{{envVarName}}", ""){{#unless @last}}, {{/unless}}{{/each}} } - else: - headers = { {{#each headerCredentials}}{{safeJson headerKey}}: _get_{{snakeCase ../name}}_{{snakeCase headerKey}}_key(){{#unless @last}}, {{/unless}}{{/each}} } - return MCPClient(lambda: streamablehttp_client(url, headers=headers)) - {{else}} - return MCPClient(lambda: streamablehttp_client(url)) - {{/if}} - -{{/each}} -def get_all_remote_mcp_clients() -> list[MCPClient]: - """Returns all configured remote MCP clients.""" - clients = [{{#each remoteMcpTools}}get_{{snakeCase name}}_mcp_client(){{#unless @last}}, {{/unless}}{{/each}}] - return [c for c in clients if c is not None] -{{/if}} -{{#unless (or hasGateway remoteMcpTools)}} -{{#if isVpc}} -# VPC mode: external MCP endpoints are not reachable without a NAT gateway. -# Add an AgentCore Gateway with `agentcore add gateway`, or configure your own endpoint below. - -def get_streamable_http_mcp_client() -> MCPClient | None: - """No MCP server configured. Add a gateway with `agentcore add gateway`.""" - return None -{{else}} -{{#unless isExportHarness}} -# ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication -EXAMPLE_MCP_ENDPOINT = "https://mcp.exa.ai/mcp" - -def get_streamable_http_mcp_client() -> MCPClient: - """Returns an MCP Client compatible with Strands""" - # to use an MCP server that supports bearer authentication, add headers={"Authorization": f"Bearer {access_token}"} - return MCPClient(lambda: streamablehttp_client(EXAMPLE_MCP_ENDPOINT)) -{{/unless}} -{{/if}} -{{/unless}} diff --git a/src/assets/templates/agent-python-strands/model/load.py b/src/assets/templates/agent-python-strands/model/load.py index 0b3b23eac..e2cd5cd6b 100644 --- a/src/assets/templates/agent-python-strands/model/load.py +++ b/src/assets/templates/agent-python-strands/model/load.py @@ -1,72 +1,10 @@ {{#if (eq modelProvider "Bedrock")}} -{{#if bedrockMantle}} -import os - -from aws_bedrock_token_generator import provide_token -{{#if (eq mantleApiFormat "chat_completions")}} -from strands.models.openai import OpenAIModel -{{else}} -{{#if mantleProprietary}} -from strands.models.openai_responses import OpenAIResponsesModel -{{else}} -from model.mantle_compat import MantleCompatResponsesModel -{{/if}} -{{/if}} - -MODEL_ID = "{{modelId}}" - - -def load_model(): - """ - Get a Bedrock Mantle model client. These OpenAI-compatible models (e.g. openai.gpt-5.5, - openai.gpt-oss-120b) are served via the Bedrock Mantle endpoint, NOT the Converse API — so they - are invoked through an OpenAI-style client authenticated with a short-lived Bedrock bearer token. - Region is read from AWS_REGION (set by the AgentCore runtime). - """ - region = os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION", "us-east-1")) - token = provide_token(region=region) - {{#if mantleProprietary}} - # Proprietary OpenAI models only work on the /openai/v1 Mantle path. - base_url = f"https://bedrock-mantle.{region}.api.aws/openai/v1" - {{else}} - # Open-source OpenAI models (gpt-oss-*) only work on the /v1 Mantle path. - base_url = f"https://bedrock-mantle.{region}.api.aws/v1" - {{/if}} - client_args = {"api_key": token, "base_url": base_url} - - params = {} - {{#if modelMaxTokens}} - {{#if (eq mantleApiFormat "chat_completions")}} - params["max_completion_tokens"] = {{modelMaxTokens}} - {{else}} - params["max_output_tokens"] = {{modelMaxTokens}} - {{/if}} - {{/if}} - {{#if modelTemperature}} - params["temperature"] = {{modelTemperature}} - {{/if}} - {{#if modelTopP}} - params["top_p"] = {{modelTopP}} - {{/if}} - {{#if (eq mantleApiFormat "chat_completions")}} - return OpenAIModel(client_args=client_args, model_id=MODEL_ID, params=params) - {{else}} - # Responses API: Mantle does not persist responses, so disable server-side storage. - params["store"] = False - {{#if mantleProprietary}} - return OpenAIResponsesModel(client_args=client_args, model_id=MODEL_ID, params=params) - {{else}} - return MantleCompatResponsesModel(client_args=client_args, model_id=MODEL_ID, params=params) - {{/if}} - {{/if}} -{{else}} from strands.models.bedrock import BedrockModel def load_model() -> BedrockModel: """Get Bedrock model client using IAM credentials.""" - return BedrockModel(model_id="{{#if modelId}}{{modelId}}{{else}}global.anthropic.claude-sonnet-4-5-20250929-v1:0{{/if}}"{{#if modelMaxTokens}}, max_tokens={{modelMaxTokens}}{{/if}}) -{{/if}} + return BedrockModel(model_id="global.anthropic.claude-sonnet-4-5-20250929-v1:0") {{/if}} {{#if (eq modelProvider "Anthropic")}} import os @@ -142,7 +80,7 @@ def load_model() -> OpenAIModel: """Get authenticated OpenAI model client.""" return OpenAIModel( client_args={"api_key": _get_api_key()}, - model_id="{{#if modelId}}{{modelId}}{{else}}gpt-4.1{{/if}}", + model_id="gpt-4.1", ) {{/if}} {{#if (eq modelProvider "Gemini")}} @@ -180,15 +118,14 @@ def load_model() -> GeminiModel: """Get authenticated Gemini model client.""" return GeminiModel( client_args={"api_key": _get_api_key()}, - model_id="{{#if modelId}}{{modelId}}{{else}}gemini-2.5-flash{{/if}}", + model_id="gemini-2.5-flash", ) {{/if}} {{#if (eq modelProvider "LiteLLM")}} +{{#if identityProviders.[0].name}} import os -{{#if litellmAdditionalParams}} -import json -{{/if}} +{{/if}} from strands.models.litellm import LiteLLMModel {{#if identityProviders.[0].name}} from bedrock_agentcore.identity.auth import requires_api_key @@ -219,21 +156,14 @@ def _get_api_key() -> str: {{/if}} - - def load_model() -> LiteLLMModel: """Get a LiteLLM model client (proxies to the provider encoded in model_id).""" client_args = {} {{#if identityProviders.[0].name}} client_args["api_key"] = _get_api_key() {{/if}} - {{#if litellmApiBase}} - client_args["api_base"] = {{safeJson litellmApiBase}} - {{/if}} - params = {{#if litellmAdditionalParams}}json.loads({{pyJsonStr litellmAdditionalParams}}){{else}}{}{{/if}} return LiteLLMModel( client_args=client_args, - model_id="{{#if modelId}}{{modelId}}{{else}}bedrock/us.anthropic.claude-sonnet-4-5-20250514-v1:0{{/if}}", - params=params, + model_id="bedrock/us.anthropic.claude-sonnet-4-5-20250514-v1:0", ) {{/if}} diff --git a/src/assets/templates/agent-python-strands/model/mantle_compat.py b/src/assets/templates/agent-python-strands/model/mantle_compat.py deleted file mode 100644 index 4607a3517..000000000 --- a/src/assets/templates/agent-python-strands/model/mantle_compat.py +++ /dev/null @@ -1,21 +0,0 @@ -from strands.models.openai_responses import OpenAIResponsesModel - - -class MantleCompatResponsesModel(OpenAIResponsesModel): - """Workaround for Bedrock Mantle rejecting output_text in EasyInputMessage content arrays. - - Mantle's Pydantic validation only accepts content as a plain string for assistant messages, while - real OpenAI accepts both formats. Flatten assistant content arrays to strings so multi-turn works. - Used for open-source OpenAI models (gpt-oss-*) on the /v1 Mantle path; proprietary models use the - plain OpenAIResponsesModel on /openai/v1. - """ - - @classmethod - def _format_request_messages(cls, messages): - formatted = super()._format_request_messages(messages) - for msg in formatted: - if msg.get("role") == "assistant" and isinstance(msg.get("content"), list): - msg["content"] = "".join( - part.get("text", "") for part in msg["content"] if part.get("type") == "output_text" - ) - return formatted diff --git a/src/assets/templates/agent-python-strands/pyproject.toml b/src/assets/templates/agent-python-strands/pyproject.toml index 88eadd517..e21492965 100644 --- a/src/assets/templates/agent-python-strands/pyproject.toml +++ b/src/assets/templates/agent-python-strands/pyproject.toml @@ -12,20 +12,12 @@ dependencies = [ "aws-opentelemetry-distro ~= 0.18.0", "bedrock-agentcore ~= 1.9.1", "botocore[crt] ~= 1.43.0", - "mcp ~= 1.24.0", - {{#if bedrockMantle}}"openai ~= 1.0.0", - "aws-bedrock-token-generator ~= 1.0.0", - {{/if}}{{#if (eq modelProvider "Anthropic")}}"strands-agents[anthropic] ~= 1.15.0", + {{#if (eq modelProvider "Anthropic")}}"strands-agents[anthropic] ~= 1.15.0", {{else}}{{#if (eq modelProvider "OpenAI")}}"strands-agents[openai] ~= 1.15.0", {{else}}{{#if (eq modelProvider "Gemini")}}"strands-agents[gemini] ~= 1.15.0", {{else}}{{#if (eq modelProvider "LiteLLM")}}"strands-agents[litellm] ~= 1.15.0", {{else}}"strands-agents ~= 1.15.0", {{/if}}{{/if}}{{/if}}{{/if}} - {{#if (or hasBrowser hasCodeInterpreter)}}"strands-agents-tools ~= 0.1.0", - {{/if}}{{#if hasBrowser}}"nest-asyncio ~= 1.5.0", - "playwright ~= 1.42.0", - {{/if}}{{#if hasGateway}}{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws ~= 1.1.0", - {{/if}}{{/if}} ] [tool.hatch.build.targets.wheel] diff --git a/src/assets/templates/agent-python-strands/skills/fetcher.py b/src/assets/templates/agent-python-strands/skills/fetcher.py deleted file mode 100644 index 2f82cd6c2..000000000 --- a/src/assets/templates/agent-python-strands/skills/fetcher.py +++ /dev/null @@ -1,279 +0,0 @@ -"""Skill fetcher — downloads s3/git skills to local filesystem on first use. - -Resolved paths are passed to AgentSkills(skills=...) in main.py. -Cache directory: /.agents/skills/ — an absolute path under the system temp -directory (honors $TMPDIR, defaults to /tmp). The runtime working directory (e.g. -/var/task in a CodeZip runtime) is read-only, so the cache must live somewhere -guaranteed-writable. -""" - -import base64 -import hashlib -import json -import logging -import os -import shutil -import subprocess -import tempfile -from pathlib import Path -from typing import Optional - -logger = logging.getLogger(__name__) - -_SKILLS_BASE = Path(tempfile.gettempdir()) / ".agents" / "skills" -_GIT_TIMEOUT = 60 -_S3_MAX_SIZE_BYTES = 1 * 1024 * 1024 * 1024 # 1 GB - - -def _stable_hash(value: str) -> str: - return hashlib.sha256(value.encode()).hexdigest()[:12] - - -def _cleanup(path: Path) -> None: - """Remove a partially-created skill directory so retries don't see stale state.""" - shutil.rmtree(path, ignore_errors=True) - - -def _read_map(type_dir: Path) -> dict: - map_file = type_dir / ".map.json" - return json.loads(map_file.read_text()) if map_file.exists() else {} - - -def _write_map(type_dir: Path, mapping: dict) -> None: - type_dir.mkdir(parents=True, exist_ok=True) - (type_dir / ".map.json").write_text(json.dumps(mapping)) - - -def _resolve_cached(type_dir: Path, source_hash: str) -> Optional[str]: - """Return the cached skill directory for a source hash, or None if not on disk.""" - mapping = _read_map(type_dir) - dir_name = mapping.get(source_hash) - if dir_name and (type_dir / dir_name).exists(): - return str(type_dir / dir_name) - return None - - -def _read_skill_name(skill_dir: Path) -> str: - """Extract the skill name from SKILL.md YAML frontmatter.""" - content = (skill_dir / "SKILL.md").read_text() - if not content.startswith("---"): - raise ValueError(f"SKILL.md in {skill_dir} has no YAML frontmatter (must start with ---)") - parts = content.split("---", 2) - if len(parts) < 3: - raise ValueError(f"SKILL.md in {skill_dir} has malformed frontmatter (missing closing ---)") - for line in parts[1].strip().splitlines(): - if line.startswith("name:"): - name = line[len("name:"):].strip().strip("\"'") - if name: - return name - raise ValueError(f"SKILL.md in {skill_dir} is missing a 'name' field in frontmatter") - - -def _pick_dir_name(type_dir: Path, name: str, source_hash: str) -> str: - """Pick a unique directory name, appending a hash suffix on collision.""" - if not (type_dir / name).exists(): - return name - return f"{name}-{source_hash[:8]}" - - -def _rename_and_cache_skill(type_dir: Path, temp_dir: Path, source_hash: str, skill_root: Path, - source_label: str = "") -> Path: - """Validate SKILL.md, rename the temp dir to the skill's declared name, and update the map. - - Raises ValueError if SKILL.md is missing or has invalid frontmatter. - """ - if not (skill_root / "SKILL.md").exists(): - _cleanup(temp_dir) - hint = f" (source: {source_label})" if source_label else "" - raise ValueError(f"No SKILL.md found in fetched skill{hint}") - - name = _read_skill_name(skill_root) - dir_name = _pick_dir_name(type_dir, name, source_hash) - final_dir = type_dir / dir_name - if final_dir != temp_dir: - temp_dir.rename(final_dir) - - mapping = _read_map(type_dir) - mapping[source_hash] = dir_name - _write_map(type_dir, mapping) - return final_dir - - -def _fetch_s3_skill(source: str, s3_client=None) -> Path: - """Download an s3:// skill prefix and return the local directory.""" - uri = source if source.endswith("/") else source + "/" - source_hash = _stable_hash(uri) - type_dir = _SKILLS_BASE / "s3" - - cached = _resolve_cached(type_dir, source_hash) - if cached: - return Path(cached) - - import boto3 - client = s3_client or boto3.client("s3") - bucket, _, prefix = uri[len("s3://"):].partition("/") - if not bucket: - raise ValueError(f"Invalid S3 URI (no bucket): {uri}") - - temp_dir = type_dir / source_hash - _cleanup(temp_dir) - temp_dir.mkdir(parents=True, exist_ok=True) - temp_root = temp_dir.resolve() - - paginator = client.get_paginator("list_objects_v2") - total = 0 - for page in paginator.paginate(Bucket=bucket, Prefix=prefix): - for obj in page.get("Contents", []): - total += obj["Size"] - if total > _S3_MAX_SIZE_BYTES: - _cleanup(temp_dir) - raise ValueError(f"S3 skill {uri} exceeds 1 GB size limit") - rel = obj["Key"][len(prefix):].lstrip("/") - if not rel: - continue - dest = (temp_dir / rel).resolve() - if dest != temp_root and not str(dest).startswith(str(temp_root) + os.sep): - _cleanup(temp_dir) - raise ValueError(f"Path traversal detected in S3 key: {obj['Key']}") - dest.parent.mkdir(parents=True, exist_ok=True) - client.download_file(bucket, obj["Key"], str(dest)) - - if total == 0: - _cleanup(temp_dir) - raise ValueError(f"No files found at S3 URI: {uri}") - - return _rename_and_cache_skill(type_dir, temp_dir, source_hash, temp_dir, source_label=uri) - - -def _resolve_credential_arn(credential_arn: str, identity_client) -> str: - """Resolve a Token Vault API-key credential ARN to its secret value via AgentCore Identity. - - ARN format: arn:

:bedrock-agentcore:::token-vault//apikeycredentialprovider/ - """ - from bedrock_agentcore.runtime.context import BedrockAgentCoreContext # noqa: PLC0415 - - provider_name = credential_arn.rsplit("/", 1)[-1] - if not provider_name: - raise ValueError(f"Invalid credential ARN: {credential_arn}") - workload_token = BedrockAgentCoreContext.get_workload_access_token() - if not workload_token: - raise ValueError("Credential ARN resolution requires a workload access token") - api_key = identity_client.dp_client.get_resource_api_key( - resourceCredentialProviderName=provider_name, - workloadIdentityToken=workload_token, - )["apiKey"] - if not api_key: - raise ValueError(f"Identity returned empty API key for provider: {provider_name}") - return api_key - - -def _build_git_auth_env(credential_arn: Optional[str], username: Optional[str], identity_client=None) -> dict: - """Build GIT_CONFIG_* env vars for HTTP Basic auth using a Token Vault credential ARN. - - Uses env vars instead of -c args to avoid leaking credentials in /proc/*/cmdline, - and so auth propagates to sub-commands (e.g. sparse-checkout triggering a fetch). - """ - if not credential_arn or not identity_client: - return {} - password = _resolve_credential_arn(credential_arn, identity_client) - user = username or "oauth2" - encoded = base64.b64encode(f"{user}:{password}".encode()).decode() - return { - "GIT_CONFIG_COUNT": "1", - "GIT_CONFIG_KEY_0": "http.extraHeader", - "GIT_CONFIG_VALUE_0": f"Authorization: Basic {encoded}", - } - - -def _fetch_git_skill(url: str, skill_path: str = "", credential_arn: Optional[str] = None, - username: Optional[str] = None, identity_client=None) -> Path: - """Shallow-clone a git skill repository and return the local skill directory. - - Returns the directory containing SKILL.md (the subdir itself for sparse checkouts). - """ - if skill_path and (os.path.isabs(skill_path) or ".." in Path(skill_path).parts): - raise ValueError(f"Path traversal detected in skill path: {skill_path}") - - source_hash = _stable_hash(f"{url}:{skill_path}") - type_dir = _SKILLS_BASE / "git" - - cached = _resolve_cached(type_dir, source_hash) - if cached: - return Path(cached) / skill_path if skill_path else Path(cached) - - temp_dir = type_dir / source_hash - _cleanup(temp_dir) - temp_dir.mkdir(parents=True, exist_ok=True) - - extra_env = _build_git_auth_env(credential_arn, username, identity_client) - git_env = {**os.environ, **extra_env} if extra_env else None - - try: - if skill_path: - subprocess.run( - ["git", "clone", "--depth", "1", "--filter=blob:none", "--sparse", url, str(temp_dir)], - check=True, timeout=_GIT_TIMEOUT, capture_output=True, env=git_env, - ) - subprocess.run( - ["git", "sparse-checkout", "set", skill_path], - check=True, timeout=_GIT_TIMEOUT, capture_output=True, cwd=str(temp_dir), env=git_env, - ) - else: - subprocess.run( - ["git", "clone", "--depth", "1", url, str(temp_dir)], - check=True, timeout=_GIT_TIMEOUT, capture_output=True, env=git_env, - ) - except Exception: - _cleanup(temp_dir) - raise - - if skill_path and not (temp_dir / skill_path).exists(): - _cleanup(temp_dir) - raise ValueError(f"Skill path '{skill_path}' not found in repository '{url}'") - - # SKILL.md lives inside the subdir for sparse checkouts. - skill_root = temp_dir / skill_path if skill_path else temp_dir - label = f"{url}:{skill_path}" if skill_path else url - final_dir = _rename_and_cache_skill(type_dir, temp_dir, source_hash, skill_root, source_label=label) - return final_dir / skill_path if skill_path else final_dir - - -def resolve_s3_skills(sources: list, s3_client=None) -> list: - """Resolve s3:// skill URIs to local filesystem paths. - - Any fetch failure raises and fails the invocation — a partial skill set - would silently run the agent without capabilities the harness declared. - """ - paths = [] - for uri in sources: - try: - skill_dir = _fetch_s3_skill(uri, s3_client) - except Exception as e: - raise ValueError(f"Failed to resolve S3 skill '{uri}': {e}") from e - paths.append(str(skill_dir.resolve())) - return paths - - -def resolve_git_skills(sources: list, identity_client=None) -> list: - """Resolve git skill dicts to local filesystem paths. - - Each source is a dict with keys: url (required), path (optional), - credentialArn (optional), username (optional). - - Any fetch failure raises and fails the invocation — a partial skill set - would silently run the agent without capabilities the harness declared. - """ - paths = [] - for source in sources: - try: - skill_dir = _fetch_git_skill( - url=source["url"], - skill_path=source.get("path") or "", - credential_arn=source.get("credentialArn"), - username=source.get("username"), - identity_client=identity_client, - ) - except Exception as e: - raise ValueError(f"Failed to resolve git skill '{source.get('url', source)}': {e}") from e - paths.append(str(skill_dir.resolve())) - return paths diff --git a/src/core/project/__snapshots__/manager.test.ts.snap b/src/core/project/__snapshots__/manager.test.ts.snap index 93418d3d4..46d1f2f1f 100644 --- a/src/core/project/__snapshots__/manager.test.ts.snap +++ b/src/core/project/__snapshots__/manager.test.ts.snap @@ -44,15 +44,11 @@ exports[`FsProjectManager.create snapshots the Strands project manifest and runt "app/agent_python_strands/.gitignore", "app/agent_python_strands/README.md", "app/agent_python_strands/main.py", - "app/agent_python_strands/mcp_client/__init__.py", - "app/agent_python_strands/mcp_client/client.py", "app/agent_python_strands/memory/__init__.py", "app/agent_python_strands/memory/session.py", "app/agent_python_strands/model/__init__.py", "app/agent_python_strands/model/load.py", - "app/agent_python_strands/model/mantle_compat.py", "app/agent_python_strands/pyproject.toml", - "app/agent_python_strands/skills/fetcher.py", ], "memories": [ { diff --git a/src/core/project/templates/runtime.ts b/src/core/project/templates/runtime.ts index 01c5b2ba7..9d4679a72 100644 --- a/src/core/project/templates/runtime.ts +++ b/src/core/project/templates/runtime.ts @@ -140,20 +140,6 @@ const getTemplateResolvers = (assetSource: AssetSource, templateRenderer: Templa return { tree, spec: { runtimes: [buildRuntimeSpec(input)] } }; }, [buildResolverKey("strands", "Python", "HTTP")]: async (input: RuntimeResourceConfig) => { - const filesystemConfigurations = input.filesystemConfigurations ?? []; - const sessionStorageMountPath = filesystemConfigurations.flatMap((configuration) => - "sessionStorage" in configuration ? [configuration.sessionStorage.mountPath] : [], - )[0]; - const efsMounts = filesystemConfigurations.flatMap((configuration) => - "efsAccessPoint" in configuration - ? [{ mountPath: configuration.efsAccessPoint.mountPath }] - : [], - ); - const s3Mounts = filesystemConfigurations.flatMap((configuration) => - "s3FilesAccessPoint" in configuration - ? [{ mountPath: configuration.s3FilesAccessPoint.mountPath }] - : [], - ); const memory = input.scaffoldRuntimeInput.memory; const modelScaffold = resolveModelProviderScaffold(input); const context = { @@ -164,16 +150,6 @@ const getTemplateResolvers = (assetSource: AssetSource, templateRenderer: Templa memoryEnvVarName: memory ? `MEMORY_${memory.name.toUpperCase()}_ID` : undefined, memoryStrategies: memory?.strategies.map(({ type }) => type) ?? [], ...modelScaffold.templateRenderContext, - hasGateway: false, - hasPayment: false, - isVpc: input.networkMode === "VPC", - gatewayProviders: [], - gatewayAuthTypes: [], - sessionStorageMountPath, - efsMounts, - s3Mounts, - needsOs: filesystemConfigurations.length > 0, - hasConfigBundle: false, enableOtel: true, // The strands template's entrypoint is fixed to main.py; the container Dockerfile launches it as the `main` module. entrypoint: "main",