From bf9e00e0e4e6aed6c0265168d86e33107fb9d638 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Wed, 26 Aug 2026 01:27:23 +0000 Subject: [PATCH 01/23] feat(templates): add support for dynamic templates --- .../templates/strands-http-python/README.md | 46 ++ .../strands-http-python/gitignore.template | 41 + .../templates/strands-http-python/main.py | 714 ++++++++++++++++++ .../mcp_client/__init__.py | 1 + .../strands-http-python/mcp_client/client.py | 116 +++ .../strands-http-python/model/__init__.py | 1 + .../strands-http-python/model/load.py | 239 ++++++ .../model/mantle_compat.py | 21 + .../strands-http-python/pyproject.toml | 31 + .../strands-http-python/skills/fetcher.py | 279 +++++++ src/core/project/manager.tsx | 137 ++-- src/core/project/templates.ts | 134 ---- .../project/{ => templates}/fsTree.test.ts | 4 +- src/core/project/{ => templates}/fsTree.ts | 12 +- src/core/project/templates/harness.ts | 31 + src/core/project/templates/project.ts | 71 ++ src/core/project/templates/renderer.ts | 39 + src/core/project/templates/runtime.ts | 129 ++++ src/core/project/templates/types.ts | 28 + .../project/add/runtime/index.test.ts | 93 ++- src/handlers/project/add/runtime/types.ts | 1 + src/handlers/project/project.test.ts | 44 ++ src/handlers/project/types.ts | 10 +- 23 files changed, 2029 insertions(+), 193 deletions(-) create mode 100644 src/assets/templates/strands-http-python/README.md create mode 100644 src/assets/templates/strands-http-python/gitignore.template create mode 100644 src/assets/templates/strands-http-python/main.py create mode 100644 src/assets/templates/strands-http-python/mcp_client/__init__.py create mode 100644 src/assets/templates/strands-http-python/mcp_client/client.py create mode 100644 src/assets/templates/strands-http-python/model/__init__.py create mode 100644 src/assets/templates/strands-http-python/model/load.py create mode 100644 src/assets/templates/strands-http-python/model/mantle_compat.py create mode 100644 src/assets/templates/strands-http-python/pyproject.toml create mode 100644 src/assets/templates/strands-http-python/skills/fetcher.py delete mode 100644 src/core/project/templates.ts rename src/core/project/{ => templates}/fsTree.test.ts (96%) rename src/core/project/{ => templates}/fsTree.ts (87%) create mode 100644 src/core/project/templates/harness.ts create mode 100644 src/core/project/templates/project.ts create mode 100644 src/core/project/templates/renderer.ts create mode 100644 src/core/project/templates/runtime.ts create mode 100644 src/core/project/templates/types.ts diff --git a/src/assets/templates/strands-http-python/README.md b/src/assets/templates/strands-http-python/README.md new file mode 100644 index 000000000..1985f1eae --- /dev/null +++ b/src/assets/templates/strands-http-python/README.md @@ -0,0 +1,46 @@ +This is a project generated by the AgentCore CLI! + +# Layout + +The generated application code lives at the agent root directory. At the root, there is a `.gitignore` file, an +`agentcore/` folder which represents the configurations and state associated with this project. Other `agentcore` +commands like `deploy`, `dev`, and `invoke` rely on the configuration stored here. + +## 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. + +`model/load.py` instantiates your chosen model provider. + +## Input Validation + +Validate invocation input before forwarding it to Strands. Keep plain prompts typed as strings. If the app accepts a +caller-supplied message history, retain `strip_trailing_tool_use()`, which normalizes the history tail before +invoking the agent. + +## Environment Variables + +| Variable | Required | Description | +| --- | --- | --- | +{{#if hasIdentity}}| `{{identityProviders.[0].envVarName}}` | Yes | {{modelProvider}} API key (local) or Identity provider name (deployed) | +{{/if}}| `LOCAL_DEV` | No | Set to `1` to use `.env.local` instead of AgentCore Identity | + +# Developing locally + +If installation was successful, a virtual environment is already created with dependencies installed. + +Activate the environment with `source .venv/bin/activate` on macOS/Linux, `.venv\Scripts\activate.bat` in Windows +Command Prompt, or `.\.venv\Scripts\activate.ps1` in Windows PowerShell. + +`agentcore dev` will start a local server on 0.0.0.0:8080. + +In a new terminal, you can invoke that server with: + +`agentcore invoke --dev "What can you do"` + +# Deployment + +After providing credentials, `agentcore deploy` will deploy your project into Amazon Bedrock AgentCore. + +Use `agentcore invoke` to invoke your deployed agent. diff --git a/src/assets/templates/strands-http-python/gitignore.template b/src/assets/templates/strands-http-python/gitignore.template new file mode 100644 index 000000000..f36f968a0 --- /dev/null +++ b/src/assets/templates/strands-http-python/gitignore.template @@ -0,0 +1,41 @@ +# Environment variables +.env + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/src/assets/templates/strands-http-python/main.py b/src/assets/templates/strands-http-python/main.py new file mode 100644 index 000000000..69dca7e8e --- /dev/null +++ b/src/assets/templates/strands-http-python/main.py @@ -0,0 +1,714 @@ +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}} + +# 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) + +{{/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 + +{{/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}" + if key not in cache: + cache[key] = Agent( + model=load_model(), + session_manager=get_memory_session_manager(session_id, _actor_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 +# history reset) so a single process serving many sessions cannot leak history +# 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}}): + if session_id in cache: + cache.move_to_end(session_id) + return cache[session_id] + if len(cache) >= 128: + cache.popitem(last=False) + cache[session_id] = Agent( + model=load_model(), + 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}} + + +def strip_trailing_tool_use(messages: Any) -> list[dict]: + """Strip toolUse blocks from the tail until the last message has none.""" + if not isinstance(messages, list): + raise ValueError("messages must be a list") + + messages = list(messages) + while messages: + last = messages[-1] + if not isinstance(last, dict): + raise ValueError("each message must be an object") + original_content = last.get("content", []) + if not isinstance(original_content, list) or not all(isinstance(block, dict) for block in original_content): + raise ValueError("each message content value must be a list of content blocks") + + content = [block for block in original_content if "toolUse" not in block] + if len(content) == len(original_content): + break + if content: + messages[-1] = {**last, "content": content} + break + messages.pop() + + return messages + + +def _extract_prompt(payload: dict): + """Accept validated harness messages, tool results, 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"))) + {{else}} + _git_identity_client = None + {{/if}} + skill_paths.extend(await asyncio.to_thread(resolve_git_skills, git_skill_sources, _git_identity_client)) + {{/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, + ): + 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__": + app.run() diff --git a/src/assets/templates/strands-http-python/mcp_client/__init__.py b/src/assets/templates/strands-http-python/mcp_client/__init__.py new file mode 100644 index 000000000..0e632e10c --- /dev/null +++ b/src/assets/templates/strands-http-python/mcp_client/__init__.py @@ -0,0 +1 @@ +# Package marker diff --git a/src/assets/templates/strands-http-python/mcp_client/client.py b/src/assets/templates/strands-http-python/mcp_client/client.py new file mode 100644 index 000000000..4de07e43a --- /dev/null +++ b/src/assets/templates/strands-http-python/mcp_client/client.py @@ -0,0 +1,116 @@ +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/strands-http-python/model/__init__.py b/src/assets/templates/strands-http-python/model/__init__.py new file mode 100644 index 000000000..0e632e10c --- /dev/null +++ b/src/assets/templates/strands-http-python/model/__init__.py @@ -0,0 +1 @@ +# Package marker diff --git a/src/assets/templates/strands-http-python/model/load.py b/src/assets/templates/strands-http-python/model/load.py new file mode 100644 index 000000000..0b3b23eac --- /dev/null +++ b/src/assets/templates/strands-http-python/model/load.py @@ -0,0 +1,239 @@ +{{#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}} +{{/if}} +{{#if (eq modelProvider "Anthropic")}} +import os + +from strands.models.anthropic import AnthropicModel +from bedrock_agentcore.identity.auth import requires_api_key + +IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}" +IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}" + + +@requires_api_key(provider_name=IDENTITY_PROVIDER_NAME) +def _agentcore_identity_api_key_provider(api_key: str) -> str: + """Fetch API key from AgentCore Identity.""" + return api_key + + +def _get_api_key() -> str: + """ + Uses AgentCore Identity for API key management in deployed environments. + For local development, run via 'agentcore dev' which loads agentcore/.env. + """ + if os.getenv("LOCAL_DEV") == "1": + api_key = os.getenv(IDENTITY_ENV_VAR) + if not api_key: + raise RuntimeError( + f"{IDENTITY_ENV_VAR} not found. Add {IDENTITY_ENV_VAR}=your-key to .env.local" + ) + return api_key + return _agentcore_identity_api_key_provider() + + +def load_model() -> AnthropicModel: + """Get authenticated Anthropic model client.""" + return AnthropicModel( + client_args={"api_key": _get_api_key()}, + model_id="claude-sonnet-4-5-20250929", + max_tokens=5000, + ) +{{/if}} +{{#if (eq modelProvider "OpenAI")}} +import os + +from strands.models.openai import OpenAIModel +from bedrock_agentcore.identity.auth import requires_api_key + +IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}" +IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}" + + +@requires_api_key(provider_name=IDENTITY_PROVIDER_NAME) +def _agentcore_identity_api_key_provider(api_key: str) -> str: + """Fetch API key from AgentCore Identity.""" + return api_key + + +def _get_api_key() -> str: + """ + Uses AgentCore Identity for API key management in deployed environments. + For local development, run via 'agentcore dev' which loads agentcore/.env. + """ + if os.getenv("LOCAL_DEV") == "1": + api_key = os.getenv(IDENTITY_ENV_VAR) + if not api_key: + raise RuntimeError( + f"{IDENTITY_ENV_VAR} not found. Add {IDENTITY_ENV_VAR}=your-key to .env.local" + ) + return api_key + return _agentcore_identity_api_key_provider() + + +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}}", + ) +{{/if}} +{{#if (eq modelProvider "Gemini")}} +import os + +from strands.models.gemini import GeminiModel +from bedrock_agentcore.identity.auth import requires_api_key + +IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}" +IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}" + + +@requires_api_key(provider_name=IDENTITY_PROVIDER_NAME) +def _agentcore_identity_api_key_provider(api_key: str) -> str: + """Fetch API key from AgentCore Identity.""" + return api_key + + +def _get_api_key() -> str: + """ + Uses AgentCore Identity for API key management in deployed environments. + For local development, run via 'agentcore dev' which loads agentcore/.env. + """ + if os.getenv("LOCAL_DEV") == "1": + api_key = os.getenv(IDENTITY_ENV_VAR) + if not api_key: + raise RuntimeError( + f"{IDENTITY_ENV_VAR} not found. Add {IDENTITY_ENV_VAR}=your-key to .env.local" + ) + return api_key + return _agentcore_identity_api_key_provider() + + +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}}", + ) +{{/if}} +{{#if (eq modelProvider "LiteLLM")}} +import os +{{#if litellmAdditionalParams}} +import json +{{/if}} + +from strands.models.litellm import LiteLLMModel +{{#if identityProviders.[0].name}} +from bedrock_agentcore.identity.auth import requires_api_key + +IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}" +IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}" + + +@requires_api_key(provider_name=IDENTITY_PROVIDER_NAME) +def _agentcore_identity_api_key_provider(api_key: str) -> str: + """Fetch API key from AgentCore Identity.""" + return api_key + + +def _get_api_key() -> str: + """ + Uses AgentCore Identity for API key management in deployed environments. + For local development, run via 'agentcore dev' which loads agentcore/.env. + """ + if os.getenv("LOCAL_DEV") == "1": + api_key = os.getenv(IDENTITY_ENV_VAR) + if not api_key: + raise RuntimeError( + f"{IDENTITY_ENV_VAR} not found. Add {IDENTITY_ENV_VAR}=your-key to .env.local" + ) + return api_key + return _agentcore_identity_api_key_provider() +{{/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, + ) +{{/if}} diff --git a/src/assets/templates/strands-http-python/model/mantle_compat.py b/src/assets/templates/strands-http-python/model/mantle_compat.py new file mode 100644 index 000000000..4607a3517 --- /dev/null +++ b/src/assets/templates/strands-http-python/model/mantle_compat.py @@ -0,0 +1,21 @@ +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/strands-http-python/pyproject.toml b/src/assets/templates/strands-http-python/pyproject.toml new file mode 100644 index 000000000..26d4055ea --- /dev/null +++ b/src/assets/templates/strands-http-python/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["hatchling ~= 1.27.0"] +build-backend = "hatchling.build" + +[project] +name = "{{ name }}" +version = "0.1.0" +description = "AgentCore Runtime Application using Strands SDK" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + {{#if (eq modelProvider "Anthropic")}}"anthropic ~= 0.30.0", + {{/if}}"aws-opentelemetry-distro ~= 0.17.0", + "bedrock-agentcore ~= 1.9.1", + "botocore[crt] ~= 1.43.0", + {{#if (eq modelProvider "Gemini")}}"google-genai ~= 1.0.0", + {{/if}}"mcp ~= 1.24.0", + {{#if (eq modelProvider "OpenAI")}}"openai ~= 1.0.0", + {{/if}}{{#if (eq modelProvider "LiteLLM")}}"litellm ~= 1.0.0", + {{/if}}{{#if bedrockMantle}}"openai ~= 1.0.0", + "aws-bedrock-token-generator ~= 1.0.0", + {{/if}}"strands-agents ~= 1.15.0", + {{#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] +packages = ["."] diff --git a/src/assets/templates/strands-http-python/skills/fetcher.py b/src/assets/templates/strands-http-python/skills/fetcher.py new file mode 100644 index 000000000..2f82cd6c2 --- /dev/null +++ b/src/assets/templates/strands-http-python/skills/fetcher.py @@ -0,0 +1,279 @@ +"""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/manager.tsx b/src/core/project/manager.tsx index 83cbff2c0..7caeca463 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -23,8 +23,14 @@ import { } from "../../io"; import { defaultSource, type AssetSource } from "./source"; import { ENV_LOCAL_RELATIVE_PATH, EnvLocalFile } from "./envLocal"; -import { createHarnessTreeFromSpec, createProjectTree } from "./templates"; +import { createHarnessTreeFromSpec } from "./templates/harness"; +import { createProjectTree } from "./templates/project"; +import { getRuntimeTemplateResolver } from "./templates/runtime"; import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project"; +import { ConfigBundleSchema } from "../../projectSchemas/config-bundle"; +import { CredentialSchema } from "../../projectSchemas/credential"; +import { MemorySchema } from "../../projectSchemas/memory"; +import { OnlineEvalConfigSchema } from "../../projectSchemas/online-eval-config"; import { enclosingProjectRoot } from "./fsUtils"; import { AgentCoreCLIError, @@ -37,16 +43,20 @@ import z from "zod"; import { CdkBackend } from "./backends/cdk"; import type { ProjectBackend } from "./backends/types"; import { AwsDeploymentTargetsSchema } from "../../projectSchemas/aws-targets"; +import type { RuntimeResourceConfig } from "../../handlers/project/add/runtime/types"; +import type { TemplateRenderer } from "./templates/types"; +import { HandlebarsTemplateRenderer } from "./templates/renderer"; const TARGETS_EXAMPLE = '[{ "name": "default", "account": "111122223333", "region": "us-east-1" }]'; type ProjectManagerConfig = { logger: Logger; - source?: AssetSource; // Bun executable or dist/assets depending on runtime - runner?: ProcessRunner; // injectable so tests never spawn real processes - checkTool?: typeof requireTool; // injectable so tests don't depend on the host's PATH - json?: ReadWriteJson; // injectable so tests read fixtures instead of disk + source?: AssetSource; + runner?: ProcessRunner; + checkTool?: typeof requireTool; + json?: ReadWriteJson; backends?: Partial>; + templateRenderer?: TemplateRenderer; }; /** @@ -54,7 +64,8 @@ type ProjectManagerConfig = { */ export class FsProjectManager implements ProjectManager { private readonly logger: Logger; - private readonly source: AssetSource; + private readonly assetSource: AssetSource; + private readonly templateRenderer: TemplateRenderer; private readonly runner: ProcessRunner; private readonly checkTool: typeof requireTool; private readonly json: ReadWriteJson; @@ -62,7 +73,7 @@ export class FsProjectManager implements ProjectManager { constructor(config: ProjectManagerConfig) { this.logger = config.logger; - this.source = config.source ?? defaultSource(); + this.assetSource = config.source ?? defaultSource(); this.runner = config.runner ?? runProcess; this.checkTool = config.checkTool ?? requireTool; this.json = config.json ?? new FsReadWriteJson({ logger: config.logger }); @@ -74,6 +85,7 @@ export class FsProjectManager implements ProjectManager { json: config.json, }), }; + this.templateRenderer = config.templateRenderer ?? new HandlebarsTemplateRenderer(); } public async resolve(input: ResolveProjectInput): Promise { @@ -102,8 +114,12 @@ export class FsProjectManager implements ProjectManager { const destination = join(process.cwd(), input.name); yield { message: "Creating project tree" }; - const tree = await createProjectTree(input.name, scaffoldRuntimeInput, this.source); - await tree.write(destination); + const projectTemplate = await createProjectTree( + { templateRenderer: this.templateRenderer, assetSource: this.assetSource }, + { projectName: input.name }, + { runtime: scaffoldRuntimeInput }, + ); + await projectTemplate.write(destination); // A failed step leaves the scaffolded files in place; the error tells the // user how to rerun the step by hand. @@ -145,66 +161,67 @@ export class FsProjectManager implements ProjectManager { project: Project, input: AddResourceInput, ): AsyncGenerator { - const { resourceType, resourceConfig } = input; const agentCoreSpecPath = this.getProjectSpecPath(project); - const projectSpecKey = toProjectSpecKey(resourceType); + const projectSpecKey = toProjectSpecKey(input.resourceType); yield { message: `Reading project spec file at '${agentCoreSpecPath}'` }; - const existingProjectSpec = await this.json.read(agentCoreSpecPath, ProjectSpecSchema); + const projectSpec = await this.json.read(agentCoreSpecPath, ProjectSpecSchema); - const existingResources = existingProjectSpec[projectSpecKey]; - if (resourceType === "gateway-target") { + const existingResources = projectSpec[projectSpecKey]; + if (input.resourceType === "gateway-target") { // Current L3 outputs are keyed only by Target name, so names must remain // project-unique until those outputs include the parent Gateway. - const gateway = existingProjectSpec.agentCoreGateways.find((candidate) => - candidate.targets.some((target) => target.name === resourceConfig.name), + const gateway = projectSpec.agentCoreGateways.find((candidate) => + candidate.targets.some((target) => target.name === input.resourceConfig.name), ); if (gateway) { throw new InputValidationError( - `a gateway target with name '${resourceConfig.name}' already exists in gateway '${gateway.name}'`, + `a gateway target with name '${input.resourceConfig.name}' already exists in gateway '${gateway.name}'`, ); } if ( - existingProjectSpec.unassignedTargets?.some((target) => target.name === resourceConfig.name) + projectSpec.unassignedTargets?.some((target) => target.name === input.resourceConfig.name) ) { throw new InputValidationError( - `an unassigned gateway target with name '${resourceConfig.name}' already exists`, + `an unassigned gateway target with name '${input.resourceConfig.name}' already exists`, ); } - } else if (existingResources.find((resource) => resource.name === resourceConfig.name)) { + } else if (existingResources.find((resource) => resource.name === input.resourceConfig.name)) { throw new InputValidationError( - `a ${resourceType} with name '${resourceConfig.name}' already exists`, + `a ${input.resourceType} with name '${input.resourceConfig.name}' already exists`, ); } - // Widened: arms push their own shapes; the whole-spec safeParse below validates. - const newResources: unknown[] = [...existingResources]; const scaffoldedPaths: string[] = []; - // Non-file work that a failed spec write must also reverse. let envFile: EnvLocalFile | undefined; switch (input.resourceType) { case "harness": { yield { message: `Scaffolding harness in project` }; - const outputPath = join(project.rootPath, "app", resourceConfig.name); + const outputPath = join(project.rootPath, "app", input.resourceConfig.name); scaffoldedPaths.push(outputPath); const harnessPath = await this.scaffoldHarness(outputPath, input.resourceConfig); - newResources.push({ + projectSpec.harnesses.push({ name: input.resourceConfig.name, path: relative(project.rootPath, harnessPath), }); break; } case "runtime": { - throw new NotImplementedError( - "runtime case not yet implemented in FsProjectManager.addResource", - ); + yield { message: "Scaffolding runtime in project" }; + const outputPath = join(project.rootPath, "app"); + scaffoldedPaths.push(join(outputPath, input.resourceConfig.name)); + + const spec = await this.scaffoldRuntimeResources(outputPath, input.resourceConfig); + if (spec.runtimes) projectSpec.runtimes.push(...spec.runtimes); + if (spec.memories) projectSpec.memories.push(...spec.memories); + if (spec.credentials) projectSpec.credentials.push(...spec.credentials); + break; } case "credential": { - // No file scaffolding; the secret placeholder is staged into .env.local - // and reversed with the spec write if that commit fails. - newResources.push(input.resourceConfig); + const credential = parseResource(CredentialSchema, input.resourceConfig); + projectSpec.credentials.push(credential); if (input.envEntries?.length) { envFile = new EnvLocalFile(project.rootPath); yield { message: `Updating secrets file at '${envFile.path}'` }; @@ -217,15 +234,26 @@ export class FsProjectManager implements ProjectManager { } break; } - case "config-bundle": + case "config-bundle": { + projectSpec.configBundles.push(parseResource(ConfigBundleSchema, input.resourceConfig)); + break; + } case "online-eval": - case "online-insight": - case "memory": + case "online-insight": { + projectSpec.onlineEvalConfigs.push( + parseResource(OnlineEvalConfigSchema, input.resourceConfig), + ); + break; + } + case "memory": { + projectSpec.memories.push(parseResource(MemorySchema, input.resourceConfig)); + break; + } case "gateway": - newResources.push(resourceConfig); + projectSpec.agentCoreGateways.push(input.resourceConfig); break; case "gateway-target": { - const gatewayIndex = existingProjectSpec.agentCoreGateways.findIndex( + const gatewayIndex = projectSpec.agentCoreGateways.findIndex( (gateway) => gateway.name === input.gatewayName, ); if (gatewayIndex < 0) { @@ -233,11 +261,7 @@ export class FsProjectManager implements ProjectManager { `gateway '${input.gatewayName}' does not exist in this project; check agentCoreGateways in agentcore.json`, ); } - const gateway = existingProjectSpec.agentCoreGateways[gatewayIndex]!; - newResources[gatewayIndex] = { - ...gateway, - targets: [...gateway.targets, resourceConfig], - }; + projectSpec.agentCoreGateways[gatewayIndex]!.targets.push(input.resourceConfig); break; } default: { @@ -248,13 +272,9 @@ export class FsProjectManager implements ProjectManager { yield { message: `Updating project spec file at '${agentCoreSpecPath}'` }; - const newSpec = { ...existingProjectSpec, [projectSpecKey]: newResources }; - - // Validate and write inside the same boundary so a rejected spec rolls back - // staged side effects (.env.local, scaffolded files) rather than leaving them. let newProjectSpec: z.infer; try { - const newSpecParseResult = ProjectSpecSchema.safeParse(newSpec); + const newSpecParseResult = ProjectSpecSchema.safeParse(projectSpec); if (!newSpecParseResult.success) throw new InputValidationError(z.prettifyError(newSpecParseResult.error), { cause: newSpecParseResult.error, @@ -359,6 +379,19 @@ export class FsProjectManager implements ProjectManager { return outputPath; } + private async scaffoldRuntimeResources(outputPath: string, input: RuntimeResourceConfig) { + const resolver = getRuntimeTemplateResolver( + { assetSource: this.assetSource, templateRenderer: this.templateRenderer }, + input, + ); + if (!resolver) + throw new InputValidationError(`unable to find template that matches given parameters`); + + const result = await resolver.resolve(input); + await result.tree.write(outputPath); + return result.spec; + } + public async *build(project: Project): AsyncGenerator { yield* this.backendFor(project).build(project); } @@ -437,3 +470,13 @@ function toProjectSpecKey(resourceType: ProjectResource) { return "agentCoreGateways"; } } + +function parseResource( + schema: TSchema, + input: z.input, +): z.output { + const result = schema.safeParse(input); + if (!result.success) + throw new InputValidationError(z.prettifyError(result.error), { cause: result.error }); + return result.data; +} diff --git a/src/core/project/templates.ts b/src/core/project/templates.ts deleted file mode 100644 index 8630a1d99..000000000 --- a/src/core/project/templates.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { ZodError, z } from "zod"; -import { HarnessSpecSchema } from "../../projectSchemas/harness"; -import { FsTreeNode } from "./fsTree"; -import type { AssetSource } from "./source"; -import { InputValidationError } from "../../errors/errors"; -import { - RUNTIME_TEMPLATE_SHORTCUTS, - type ScaffoldRuntimeInput, -} from "../../handlers/project/types"; - -type TemplateSpec = { - runtimes?: unknown[]; - memories?: unknown[]; - harnesses?: unknown[]; -}; - -/** - * A project template pairs the agent code scaffolded under app/ with the resource - * sections it registers in agentcore.json. Adding a template is one entry here plus its assets. - */ -type Template = { - /** Asset directory relative to the asset root, expanded into the app directory. */ - assetDir: string; - /** Resource sections this template contributes to agentcore.json. */ - spec: TemplateSpec; -}; - -const TEMPLATES: Record = { - [buildRuntimeTemplateKey(RUNTIME_TEMPLATE_SHORTCUTS["hello-world-python"])]: { - assetDir: "templates/hello-world-python", - spec: { - runtimes: [ - { - name: "hello_world", - build: "CodeZip", - entrypoint: "main.py", - codeLocation: "app/hello_world", - // Required for CodeZip builds: the CDK construct library rejects a - // CodeZip runtime with no runtimeVersion, and it is what selects the - // packager. Container builds take their version from the image. - runtimeVersion: "PYTHON_3_14", - }, - ], - }, - }, - [buildRuntimeTemplateKey(RUNTIME_TEMPLATE_SHORTCUTS["hello-world-python-container"])]: { - assetDir: "templates/hello-world-python-container", - spec: { - runtimes: [ - { - name: "hello_world", - build: "Container", - entrypoint: "main.py", - codeLocation: "app/hello_world", - dockerfile: "Dockerfile", - }, - ], - }, - }, -}; - -function buildRuntimeTemplateKey(input: ScaffoldRuntimeInput): string { - return `runtime_${input.build}_${input.framework}_${input.language}_${input.memory}_${input.modelProvider}`; -} - -function resolveTemplate(input: ScaffoldRuntimeInput): Template | undefined { - return TEMPLATES[buildRuntimeTemplateKey(input)]; -} - -/** Serializes a value as pretty-printed JSON with a trailing newline. */ -const json = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`; - -/** - * Builds the agentcore.json spec by adding the template's resource sections to the shared base. - * The base fields and template sections never overlap so this is a plain spread. - */ -function agentcoreSpec(name: string, template: Template): unknown { - return { - name, - version: 1, - managedBy: "CDK", - ...template.spec, - }; -} - -export async function createProjectTree( - name: string, - input: ScaffoldRuntimeInput, - src: AssetSource, -): Promise { - const template = resolveTemplate(input); - if (!template) - throw new InputValidationError(`unable to find template that matches given parameters`); - return FsTreeNode.createDirectory(".", [ - FsTreeNode.createFile(".gitignore", () => src.read("templates/shared/gitignore.template")), - FsTreeNode.createDirectory("agentcore", [ - await FsTreeNode.fromAssetSource(src, "cdk"), - FsTreeNode.createFile("agentcore.json", async () => json(agentcoreSpec(name, template))), - FsTreeNode.createFile("aws-targets.json", async () => json([])), - FsTreeNode.createFile(".env.local", () => src.read("templates/shared/env.local.template")), - ]), - FsTreeNode.createDirectory("app", [ - // TODO: replace this hardcoded "hello_world" with the runtime name once templates are more flexible. - await FsTreeNode.fromAssetSource(src, template.assetDir, "hello_world"), - ]), - ]); -} - -const DEFAULT_HARNESS_SYSTEM_PROMPT = "You are a helpful assistant"; - -export async function createHarnessTreeFromSpec( - spec: z.input, -): Promise { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { systemPrompt, ...rest } = spec; - // strip system prompt such that markdown file is source of truth. - const parsed = parseHarnessSpec(rest); - return FsTreeNode.createDirectory(".", [ - FsTreeNode.createFile("harness.json", async () => json(parsed)), - FsTreeNode.createFile( - "system-prompt.md", - async () => spec.systemPrompt ?? DEFAULT_HARNESS_SYSTEM_PROMPT, - ), - ]); -} - -function parseHarnessSpec(spec: z.input) { - try { - return HarnessSpecSchema.parse(spec); - } catch (err) { - if (err instanceof ZodError) throw new InputValidationError(z.prettifyError(err)); - throw err; - } -} diff --git a/src/core/project/fsTree.test.ts b/src/core/project/templates/fsTree.test.ts similarity index 96% rename from src/core/project/fsTree.test.ts rename to src/core/project/templates/fsTree.test.ts index f33e5f9fb..bdf4e0503 100644 --- a/src/core/project/fsTree.test.ts +++ b/src/core/project/templates/fsTree.test.ts @@ -2,8 +2,8 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdtemp, readFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { ProjectStateError } from "../../errors/errors"; -import type { AssetSource } from "./source"; +import { ProjectStateError } from "../../../errors/errors"; +import type { AssetSource } from "../source"; import { FsTreeNode } from "./fsTree"; const tempDirectories: string[] = []; diff --git a/src/core/project/fsTree.ts b/src/core/project/templates/fsTree.ts similarity index 87% rename from src/core/project/fsTree.ts rename to src/core/project/templates/fsTree.ts index 9c1b41dc4..095f190b1 100644 --- a/src/core/project/fsTree.ts +++ b/src/core/project/templates/fsTree.ts @@ -1,9 +1,9 @@ import { existsSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import type { AssetSource } from "./source"; -import { AgentCoreCLIError, ERROR_SOURCE } from "../../errors"; -import { ProjectStateError } from "../../errors/errors"; +import type { AssetSource } from "../source"; +import { AgentCoreCLIError, ERROR_SOURCE } from "../../../errors"; +import { ProjectStateError } from "../../../errors/errors"; /** * FsTreeNode represents a tree of directories and files. @@ -65,6 +65,7 @@ export class FsTreeNode { src: AssetSource, assetDir: string, rootDirName?: string, + transform?: (content: string) => string, ): Promise { const paths = await src.list(assetDir); const root = FsTreeNode.createDirectory(rootDirName ?? assetDir, []); @@ -82,7 +83,10 @@ export class FsTreeNode { segments.forEach((segment, index) => { if (index === segments.length - 1) { parent.children.push( - FsTreeNode.createFile(renderName(segment), () => src.read(assetPath)), + FsTreeNode.createFile(renderName(segment), async () => { + const raw = await src.read(assetPath); + return transform ? transform(raw) : raw; + }), ); return; } diff --git a/src/core/project/templates/harness.ts b/src/core/project/templates/harness.ts new file mode 100644 index 000000000..eb0284f99 --- /dev/null +++ b/src/core/project/templates/harness.ts @@ -0,0 +1,31 @@ +import { ZodError, z } from "zod"; +import { HarnessSpecSchema } from "../../../projectSchemas/harness"; +import { FsTreeNode } from "./fsTree"; +import { InputValidationError } from "../../../errors/errors"; + +const DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant"; +const json = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`; + +export async function createHarnessTreeFromSpec( + spec: z.input, +): Promise { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { systemPrompt, ...rest } = spec; + const parsed = parseHarnessSpec(rest); + return FsTreeNode.createDirectory(".", [ + FsTreeNode.createFile("harness.json", async () => json(parsed)), + FsTreeNode.createFile( + "system-prompt.md", + async () => spec.systemPrompt ?? DEFAULT_SYSTEM_PROMPT, + ), + ]); +} + +function parseHarnessSpec(spec: z.input) { + try { + return HarnessSpecSchema.parse(spec); + } catch (err) { + if (err instanceof ZodError) throw new InputValidationError(z.prettifyError(err)); + throw err; + } +} diff --git a/src/core/project/templates/project.ts b/src/core/project/templates/project.ts new file mode 100644 index 000000000..bfee91cfb --- /dev/null +++ b/src/core/project/templates/project.ts @@ -0,0 +1,71 @@ +import { FsTreeNode } from "./fsTree"; +import type { AssetSource } from "../source"; +import type { ScaffoldRuntimeInput } from "../../../handlers/project/types"; +import type { RuntimeResourceConfig } from "../../../handlers/project/add/runtime/types"; +import { InputValidationError } from "../../../errors/errors"; +import { getRuntimeTemplateResolver } from "./runtime"; +import type { SpecEntries, Template, TemplateRenderer } from "./types"; + +type CreateProjectConfig = { + assetSource: AssetSource; + templateRenderer: TemplateRenderer; +}; +/** Scaffold a project from scratch, with optional support for rendering a runtime with the project. **/ +export async function createProjectTree( + config: CreateProjectConfig, + input: { projectName: string }, + options?: { runtime?: ScaffoldRuntimeInput }, +): Promise { + const templates: Template[] = []; + if (options?.runtime) { + const runtimeConfig: RuntimeResourceConfig = { + name: options.runtime.runtimeName, + scaffoldRuntimeInput: options.runtime, + }; + + const resolver = getRuntimeTemplateResolver(config, runtimeConfig); + if (!resolver) + throw new InputValidationError(`unable to find template that matches given parameters`); + + templates.push(await resolver.resolve(runtimeConfig)); + } + + return FsTreeNode.createDirectory(".", [ + FsTreeNode.createFile(".gitignore", () => + config.assetSource.read("templates/shared/gitignore.template"), + ), + FsTreeNode.createDirectory("agentcore", [ + await FsTreeNode.fromAssetSource(config.assetSource, "cdk"), + FsTreeNode.createFile("agentcore.json", async () => + json({ + name: input.projectName, + version: 1, + managedBy: "CDK", + ...mergeSpecEntries(templates.map(({ spec }) => spec)), + }), + ), + FsTreeNode.createFile("aws-targets.json", async () => json([])), + FsTreeNode.createFile(".env.local", () => + config.assetSource.read("templates/shared/env.local.template"), + ), + ]), + FsTreeNode.createDirectory( + "app", + templates.map((t) => t.tree), + ), + ]); +} + +const json = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`; + +function mergeSpecEntries(entries: SpecEntries[]): SpecEntries { + const runtimes = entries.flatMap(({ runtimes }) => runtimes ?? []); + const credentials = entries.flatMap(({ credentials }) => credentials ?? []); + const memories = entries.flatMap(({ memories }) => memories ?? []); + + return { + ...(runtimes.length > 0 && { runtimes }), + ...(credentials.length > 0 && { credentials }), + ...(memories.length > 0 && { memories }), + }; +} diff --git a/src/core/project/templates/renderer.ts b/src/core/project/templates/renderer.ts new file mode 100644 index 000000000..286faa937 --- /dev/null +++ b/src/core/project/templates/renderer.ts @@ -0,0 +1,39 @@ +import Handlebars from "handlebars"; +import type { TemplateRenderer } from "./types"; + +/** An implementation of {@link TemplateRenderer} that leverages handlebars to substitute placeholders in the given string **/ +export class HandlebarsTemplateRenderer implements TemplateRenderer { + private readonly hbs: typeof Handlebars; + + constructor() { + this.hbs = Handlebars.create(); + this.hbs.registerHelper("eq", (a: unknown, b: unknown) => a === b); + this.hbs.registerHelper( + "includes", + (arr: unknown[], val: unknown) => Array.isArray(arr) && arr.includes(val), + ); + this.hbs.registerHelper( + "some", + (arr: unknown[], key: string) => + Array.isArray(arr) && + arr.some( + (value) => + value !== null && + typeof value === "object" && + key in value && + Boolean((value as Record)[key]), + ), + ); + this.hbs.registerHelper("or", (...args: unknown[]) => { + for (let i = 0; i < args.length - 1; i++) if (args[i]) return true; + return false; + }); + this.hbs.registerHelper("snakeCase", (str: string) => + str.replace(/[^a-zA-Z0-9]/g, "_").toLowerCase(), + ); + } + + render(template: string, context: Record): string { + return this.hbs.compile(template, { noEscape: true })(context); + } +} diff --git a/src/core/project/templates/runtime.ts b/src/core/project/templates/runtime.ts new file mode 100644 index 000000000..a5f1759a1 --- /dev/null +++ b/src/core/project/templates/runtime.ts @@ -0,0 +1,129 @@ +import { FsTreeNode } from "./fsTree"; +import type { AssetSource } from "../source"; +import type { RuntimeResourceConfig } from "../../../handlers/project/add/runtime/types"; +import type { ProjectRuntime } from "../../../projectSchemas/runtime"; +import type { TemplateRenderer, TemplateResolver } from "./types"; +import type { ScaffoldRuntimeInput } from "../../../handlers/project/types"; +import { InputValidationError } from "../../../errors"; + +function buildRuntimeSpec(input: RuntimeResourceConfig): ProjectRuntime { + const { scaffoldRuntimeInput, name, ...infra } = input; + return { + name, + build: scaffoldRuntimeInput.build, + entrypoint: "main.py", + codeLocation: `app/${name}` as ProjectRuntime["codeLocation"], + ...(scaffoldRuntimeInput.build === "CodeZip" && { runtimeVersion: "PYTHON_3_14" as const }), + ...(scaffoldRuntimeInput.build === "Container" && { dockerfile: "Dockerfile" }), + ...(infra.description && { description: infra.description }), + ...(infra.executionRoleArn && { executionRoleArn: infra.executionRoleArn }), + ...(infra.additionalPolicies && { additionalPolicies: infra.additionalPolicies }), + ...(infra.envVars && { envVars: infra.envVars }), + ...(infra.networkMode && { networkMode: infra.networkMode }), + ...(infra.networkConfig && { networkConfig: infra.networkConfig }), + ...(infra.authorizerType && { authorizerType: infra.authorizerType }), + ...(infra.authorizerConfiguration && { + authorizerConfiguration: infra.authorizerConfiguration, + }), + ...(infra.protocol && { protocol: infra.protocol }), + ...(infra.requestHeaderAllowlist && { requestHeaderAllowlist: infra.requestHeaderAllowlist }), + ...(infra.lifecycleConfiguration && { lifecycleConfiguration: infra.lifecycleConfiguration }), + ...(infra.filesystemConfigurations && { + filesystemConfigurations: infra.filesystemConfigurations, + }), + ...(infra.tags && { tags: infra.tags }), + ...(infra.runtimeVersion && { runtimeVersion: infra.runtimeVersion }), + }; +} + +function buildResolverKey( + framework: ScaffoldRuntimeInput["framework"], + language: ScaffoldRuntimeInput["language"], +): `${ScaffoldRuntimeInput["framework"]}/${ScaffoldRuntimeInput["language"]}` { + return `${framework}/${language}`; +} + +const getTemplateResolvers = (assetSource: AssetSource, templateRenderer: TemplateRenderer) => ({ + "none/Python": async (input: RuntimeResourceConfig) => { + const tree = await FsTreeNode.fromAssetSource( + assetSource, + input.scaffoldRuntimeInput.build === "Container" + ? "templates/hello-world-python-container" + : "templates/hello-world-python", + input.name, + ); + return { tree, spec: { runtimes: [buildRuntimeSpec(input)] } }; + }, + "strands/Python": async (input: RuntimeResourceConfig) => { + if (input.protocol !== undefined && input.protocol !== "HTTP") + throw new InputValidationError("the strands-python template only supports HTTP"); + + 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 context = { + name: input.name, + projectName: input.name, + Name: input.name, + sdkFramework: "Strands", + targetLanguage: "Python", + modelProvider: input.scaffoldRuntimeInput.modelProvider, + hasMemory: input.scaffoldRuntimeInput.memory !== "none", + hasIdentity: false, + hasGateway: false, + hasPayment: false, + isVpc: input.networkMode === "VPC", + buildType: input.scaffoldRuntimeInput.build, + memoryProviders: [], + identityProviders: [], + gatewayProviders: [], + gatewayAuthTypes: [], + protocol: "HTTP", + sessionStorageMountPath, + efsMounts, + s3Mounts, + needsOs: filesystemConfigurations.length > 0, + enableOtel: true, + hasConfigBundle: false, + }; + const tree = await FsTreeNode.fromAssetSource( + assetSource, + "templates/strands-http-python", + input.name, + (raw) => templateRenderer.render(raw, context), + ); + return { + tree, + spec: { runtimes: [{ ...buildRuntimeSpec(input), protocol: "HTTP" as const }] }, + }; + }, +}); + +type GetRuntimeTemplateResolverConfig = { + assetSource: AssetSource; + templateRenderer: TemplateRenderer; +}; + +/** Given the parameters for rendering, load {@link TemplateResolver} that resolves to the correct template **/ +export function getRuntimeTemplateResolver( + config: GetRuntimeTemplateResolverConfig, + input: RuntimeResourceConfig, +): TemplateResolver | undefined { + const { framework, language } = input.scaffoldRuntimeInput; + const key = buildResolverKey(framework, language); + + const resolve = getTemplateResolvers(config.assetSource, config.templateRenderer)[key]; + if (!resolve) return undefined; + return { resolve }; +} diff --git a/src/core/project/templates/types.ts b/src/core/project/templates/types.ts new file mode 100644 index 000000000..05af26c78 --- /dev/null +++ b/src/core/project/templates/types.ts @@ -0,0 +1,28 @@ +import type { FsTreeNode } from "./fsTree"; +import type { ProjectRuntime } from "../../../projectSchemas/runtime"; +import type { MemorySchema } from "../../../projectSchemas/memory"; +import type { CredentialSchema } from "../../../projectSchemas/credential"; +import type z from "zod"; + +/** AgentCore Project Spec Entries that referenced as part of a {@link Template} **/ +export type SpecEntries = { + runtimes?: ProjectRuntime[]; + credentials?: z.infer[]; + memories?: z.infer[]; +}; + +/** A group of files and resources that can be rendered into a project **/ +export type Template = { + tree: FsTreeNode; + spec: SpecEntries; +}; + +/** A standard interface for resolving templates from a given input of paramters **/ +export interface TemplateResolver { + resolve(input: T): Promise