diff --git a/README.md b/README.md index 84f21fcb5..acc74ba06 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,11 @@ new runtime in `agentcore.json` (the harness entry stays), and writes an mapped mechanically. Pass `--name ` for an in-project harness or `--arn ` to fetch a deployed one (the fetch uses the region embedded in the ARN); `--target-agent-name` overrides the default -`Agent`, and `--build CodeZip|Container` overrides the build type. +`Agent`. The exported agent is always a `CodeZip` runtime: it +declares its own dependencies, so it needs no image build. If the harness used a +pre-built container image or a custom Dockerfile, that is reported in +`EXPORT_NOTES.md` rather than rebuilt. Path-based skills are not supported, +since the exported agent has no container filesystem to read them from. Global flags (declared at the root, available on every command): diff --git a/src/assets/templates/export-harness-python/README.md b/src/assets/templates/export-harness-python/README.md new file mode 100644 index 000000000..5714aafbf --- /dev/null +++ b/src/assets/templates/export-harness-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 project dev` will start a local server on 0.0.0.0:8080. + +# Deployment + +After providing credentials, `agentcore project deploy` will deploy your project into Amazon Bedrock AgentCore. + +Invoke the deployed Runtime with its native payload: + +```bash +agentcore project invoke runtime --payload '{"prompt":"Hello!"}' +``` diff --git a/src/assets/templates/export-harness-python/gitignore.template b/src/assets/templates/export-harness-python/gitignore.template new file mode 100644 index 000000000..f36f968a0 --- /dev/null +++ b/src/assets/templates/export-harness-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/export-harness-python/main.py b/src/assets/templates/export-harness-python/main.py new file mode 100644 index 000000000..9424df446 --- /dev/null +++ b/src/assets/templates/export-harness-python/main.py @@ -0,0 +1,584 @@ +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 timeoutSeconds}} +import threading +{{/if}} +{{#if hasShell}} +import subprocess +{{/if}} +{{#if hasFileOperations}} +import os +{{/if}} +{{#if truncationStrategy}} +{{#if (eq truncationStrategy "sliding_window")}} +from strands.agent.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}} +from bedrock_agentcore.runtime import BedrockAgentCoreApp +from model.load import load_model +{{#if remoteMcpTools}} +from mcp_client.client import get_all_remote_mcp_clients +{{/if}} +{{#unless 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 (some gitSkills "credentialArn"))}} +import os +{{/if}} +{{/unless}} + +app = BedrockAgentCoreApp() +log = app.logger + +{{#if remoteMcpTools}} +# Define MCP clients for all configured MCP servers (gateways and/or remote MCP) +mcp_clients = [] +{{#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}} + + +# 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 +@tool +def add_numbers(a: int, b: int) -> int: + """Return the sum of two numbers""" + return a+b +tools.append(add_numbers) + +{{/unless}} +{{/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 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}} + + +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}} +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}} + hooks=[ + ], + ) + return cache[key] + return get_or_create_agent +get_or_create_agent = agent_factory() +{{else}} +# 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}} + hooks=[ + ], + ) + return cache[session_id] + return get_or_create_agent +get_or_create_agent = agent_factory() +{{/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 hasSkillsFetcher}} + skill_paths = [] + {{#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}} + 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}}) +{{else}} + session_id = getattr(context, 'session_id', 'default-session') + agent = get_or_create_agent(session_id{{#if hasSkillsFetcher}}, _skill_plugins{{/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}} + limits = { + {{#if maxIterations}}"turns": {{maxIterations}},{{/if}} + {{#if maxTokens}}"output_tokens": {{maxTokens}},{{/if}} + } or None + cancel_signal = {{#if timeoutSeconds}}threading.Event(){{else}}None{{/if}} + timeout_fired = False + watchdog_task = None + {{#if timeoutSeconds}} + if cancel_signal is not None: + async def _timeout_watchdog(): + nonlocal timeout_fired + await asyncio.sleep({{timeoutSeconds}}) + timeout_fired = True + cancel_signal.set() + watchdog_task = asyncio.create_task(_timeout_watchdog()) + {{/if}} + + try: + stop_reason = None + {{#if inlineFunctionTools}} + hit_inline_function = False + {{/if}} + async for event in agent.stream_async( + prompt, + limits=limits, + cancel_signal=cancel_signal, + ): + if isinstance(event, dict) and "result" in event: + stop_reason = getattr(event["result"], "stop_reason", None) + continue + 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"}}} + {{#if maxIterations}} + elif stop_reason == "limit_turns": + yield {"event": {"messageStop": {"stopReason": "Max iterations exceeded: {{maxIterations}}"}}} + {{/if}} + {{#if maxTokens}} + elif stop_reason == "limit_output_tokens": + yield {"event": {"messageStop": {"stopReason": "Max output tokens exceeded: {{maxTokens}}"}}} + {{/if}} + 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/export-harness-python/mcp_client/__init__.py b/src/assets/templates/export-harness-python/mcp_client/__init__.py new file mode 100644 index 000000000..0e632e10c --- /dev/null +++ b/src/assets/templates/export-harness-python/mcp_client/__init__.py @@ -0,0 +1 @@ +# Package marker diff --git a/src/assets/templates/export-harness-python/mcp_client/client.py b/src/assets/templates/export-harness-python/mcp_client/client.py new file mode 100644 index 000000000..ec98d6762 --- /dev/null +++ b/src/assets/templates/export-harness-python/mcp_client/client.py @@ -0,0 +1,63 @@ +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 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_{{pythonName}}_key(api_key: str) -> str: + """Fetch {{headerKey}} credential for {{../name}} from AgentCore Identity.""" + return api_key + +{{/each}} +{{/if}} +def get_{{pythonName}}_mcp_client() -> MCPClient | None: + """Returns an MCP Client for the {{name}} remote MCP server.""" + url = {{safeJson url}} + {{#if headerCredentials}} + def transport(): + 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_{{pythonName}}_key(){{#unless @last}}, {{/unless}}{{/each}} } + return streamablehttp_client(url, headers=headers) + + return MCPClient(transport) + {{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_{{pythonName}}_mcp_client(){{#unless @last}}, {{/unless}}{{/each}}] + return [c for c in clients if c is not None] +{{/if}} +{{#unless 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/export-harness-python/memory/__init__.py b/src/assets/templates/export-harness-python/memory/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/assets/templates/export-harness-python/memory/session.py b/src/assets/templates/export-harness-python/memory/session.py new file mode 100644 index 000000000..38bcf49f9 --- /dev/null +++ b/src/assets/templates/export-harness-python/memory/session.py @@ -0,0 +1,47 @@ +import os +import uuid +from typing import Optional + +from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig{{#if memoryStrategies.length}}, RetrievalConfig{{/if}} +from bedrock_agentcore.memory.integrations.strands.session_manager import AgentCoreMemorySessionManager + +MEMORY_ID = os.getenv("{{memoryEnvVarName}}") +REGION = os.getenv("AWS_REGION") + + +def get_memory_session_manager( + session_id: Optional[str], actor_id: str +) -> Optional[AgentCoreMemorySessionManager]: + if not MEMORY_ID: + return None + + session_id = session_id or uuid.uuid4().hex + +{{#if memoryStrategies.length}} + retrieval_config = { +{{#if (includes memoryStrategies "SEMANTIC")}} + f"/users/{actor_id}/facts": RetrievalConfig(top_k={{#if memoryRetrievalTopK}}{{memoryRetrievalTopK}}{{else}}3{{/if}}, relevance_score={{#if memoryRetrievalRelevanceScore}}{{memoryRetrievalRelevanceScore}}{{else}}0.5{{/if}}), +{{/if}} +{{#if (includes memoryStrategies "USER_PREFERENCE")}} + f"/users/{actor_id}/preferences": RetrievalConfig(top_k={{#if memoryRetrievalTopK}}{{memoryRetrievalTopK}}{{else}}3{{/if}}, relevance_score={{#if memoryRetrievalRelevanceScore}}{{memoryRetrievalRelevanceScore}}{{else}}0.5{{/if}}), +{{/if}} +{{#if (includes memoryStrategies "EPISODIC")}} + f"/episodes/{actor_id}/{session_id}": RetrievalConfig(top_k={{#if memoryRetrievalTopK}}{{memoryRetrievalTopK}}{{else}}5{{/if}}, relevance_score={{#if memoryRetrievalRelevanceScore}}{{memoryRetrievalRelevanceScore}}{{else}}0.5{{/if}}), +{{/if}} +{{#if (includes memoryStrategies "SUMMARIZATION")}} + f"/summaries/{actor_id}": RetrievalConfig(top_k={{#if memoryRetrievalTopK}}{{memoryRetrievalTopK}}{{else}}3{{/if}}, relevance_score={{#if memoryRetrievalRelevanceScore}}{{memoryRetrievalRelevanceScore}}{{else}}0.5{{/if}}), +{{/if}} + } +{{/if}} + + return AgentCoreMemorySessionManager( + AgentCoreMemoryConfig( + memory_id=MEMORY_ID, + session_id=session_id, + actor_id=actor_id, +{{#if memoryStrategies.length}} + retrieval_config=retrieval_config, +{{/if}} + ), + REGION, + ) diff --git a/src/assets/templates/export-harness-python/model/__init__.py b/src/assets/templates/export-harness-python/model/__init__.py new file mode 100644 index 000000000..0e632e10c --- /dev/null +++ b/src/assets/templates/export-harness-python/model/__init__.py @@ -0,0 +1 @@ +# Package marker diff --git a/src/assets/templates/export-harness-python/model/load.py b/src/assets/templates/export-harness-python/model/load.py new file mode 100644 index 000000000..1cbcc4de9 --- /dev/null +++ b/src/assets/templates/export-harness-python/model/load.py @@ -0,0 +1,249 @@ +{{#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 modelTemperature}} + temperature={{modelTemperature}}, +{{/if}} +{{#if modelTopP}} + top_p={{modelTopP}}, +{{/if}} + ) +{{/if}} +{{/if}} +{{#if (eq modelProvider "OpenAI")}} +import os + +{{#if (eq modelApiFormat "responses")}} +from strands.models.openai_responses import OpenAIResponsesModel +{{else}} +from strands.models.openai import OpenAIModel +{{/if}} +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(): + """Get authenticated OpenAI model client.""" + params = {} + {{#if modelMaxTokens}} + params["{{#if (eq modelApiFormat "responses")}}max_output_tokens{{else}}max_completion_tokens{{/if}}"] = {{modelMaxTokens}} + {{/if}} + {{#if modelTemperature}} + params["temperature"] = {{modelTemperature}} + {{/if}} + {{#if modelTopP}} + params["top_p"] = {{modelTopP}} + {{/if}} + return {{#if (eq modelApiFormat "responses")}}OpenAIResponsesModel{{else}}OpenAIModel{{/if}}( + client_args={"api_key": _get_api_key()}, + model_id="{{#if modelId}}{{modelId}}{{else}}gpt-4.1{{/if}}", + params=params, + ) +{{/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.""" + params = {} + {{#if modelMaxTokens}} + params["max_output_tokens"] = {{modelMaxTokens}} + {{/if}} + {{#if modelTemperature}} + params["temperature"] = {{modelTemperature}} + {{/if}} + {{#if modelTopP}} + params["top_p"] = {{modelTopP}} + {{/if}} + {{#if modelTopK}} + params["top_k"] = {{modelTopK}} + {{/if}} + return GeminiModel( + client_args={"api_key": _get_api_key()}, + model_id="{{#if modelId}}{{modelId}}{{else}}gemini-2.5-flash{{/if}}", + params=params, + ) +{{/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}} + {{#if modelMaxTokens}} + params["max_tokens"] = {{modelMaxTokens}} + {{/if}} + {{#if modelTemperature}} + params["temperature"] = {{modelTemperature}} + {{/if}} + {{#if modelTopP}} + params["top_p"] = {{modelTopP}} + {{/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/export-harness-python/model/mantle_compat.py b/src/assets/templates/export-harness-python/model/mantle_compat.py new file mode 100644 index 000000000..4607a3517 --- /dev/null +++ b/src/assets/templates/export-harness-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/export-harness-python/pyproject.toml b/src/assets/templates/export-harness-python/pyproject.toml new file mode 100644 index 000000000..29262d715 --- /dev/null +++ b/src/assets/templates/export-harness-python/pyproject.toml @@ -0,0 +1,22 @@ +[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 = [ + "aws-opentelemetry-distro ~= 0.18.0", + "bedrock-agentcore ~= 1.9.1", + "botocore[crt] ~= 1.43.0", + "mcp >= 1.23.0, < 2.0.0", + {{#if bedrockMantle}}"aws-bedrock-token-generator >= 1.1.0, < 2.0.0", + {{/if}}"strands-agents{{#if strandsExtras}}[{{strandsExtras}}]{{/if}} ~= 1.54.0", + +] + +[tool.hatch.build.targets.wheel] +packages = ["."] diff --git a/src/assets/templates/export-harness-python/skills/fetcher.py b/src/assets/templates/export-harness-python/skills/fetcher.py new file mode 100644 index 000000000..2f82cd6c2 --- /dev/null +++ b/src/assets/templates/export-harness-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/assets/templates/strands-http-python/hooks/execution_limits.py b/src/assets/templates/strands-http-python/hooks/execution_limits.py deleted file mode 100644 index 057f348d8..000000000 --- a/src/assets/templates/strands-http-python/hooks/execution_limits.py +++ /dev/null @@ -1,54 +0,0 @@ -import time -from typing import Optional - -from strands.hooks import BeforeModelCallEvent -from strands.hooks.registry import HookProvider, HookRegistry -from strands.types.exceptions import EventLoopException - - -class ExecutionLimitExceeded(Exception): - def __init__(self, message: str) -> None: - super().__init__(message) - - -class ExecutionLimitsHook(HookProvider): - def __init__( - self, - max_iterations: Optional[int] = None, - max_tokens: Optional[int] = None, - timeout_seconds: Optional[float] = None, - ) -> None: - self._max_iterations = max_iterations - self._max_tokens = max_tokens - self._timeout_seconds = timeout_seconds - self._iteration_count = 0 - self._start_time = time.monotonic() - - def register_hooks(self, registry: HookRegistry, **kwargs) -> None: - registry.add_callback(BeforeModelCallEvent, self._check_limits) - - def _check_limits(self, event: BeforeModelCallEvent) -> None: - self._iteration_count += 1 - - if self._max_iterations is not None and self._iteration_count > self._max_iterations: - raise EventLoopException( - ExecutionLimitExceeded(f"Max iterations exceeded: {self._max_iterations}") - ) - - if self._timeout_seconds is not None: - elapsed = time.monotonic() - self._start_time - if elapsed > self._timeout_seconds: - raise EventLoopException( - ExecutionLimitExceeded( - f"Timeout exceeded: {self._timeout_seconds}s (elapsed {elapsed:.1f}s)" - ) - ) - - if self._max_tokens is not None: - used = event.agent.event_loop_metrics.accumulated_usage.get("outputTokens", 0) - if used >= self._max_tokens: - raise EventLoopException( - ExecutionLimitExceeded( - f"Max output tokens exceeded: {used}/{self._max_tokens}" - ) - ) diff --git a/src/assets/templates/strands-http-python/model/load.py b/src/assets/templates/strands-http-python/model/load.py index 05da58b20..0b3b23eac 100644 --- a/src/assets/templates/strands-http-python/model/load.py +++ b/src/assets/templates/strands-http-python/model/load.py @@ -65,7 +65,7 @@ def load_model(): 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 modelTemperature}}, temperature={{modelTemperature}}{{/if}}{{#if modelTopP}}, top_p={{modelTopP}}{{/if}}) + 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")}} diff --git a/src/core/project/manager.export.test.ts b/src/core/project/manager.export.test.ts index 20f2772d0..1c54253cd 100644 --- a/src/core/project/manager.export.test.ts +++ b/src/core/project/manager.export.test.ts @@ -83,18 +83,24 @@ function exportInput(overrides: Partial = {}): ExportHarness } describe("FsProjectManager.exportHarness rendered tree", () => { - test("includes hooks/ only when the harness sets execution limits", async () => { + test("renders invocation-scoped native Strands limits without a custom hook", async () => { const { manager: subject } = manager(); - const project = await projectWithHarness(subject, { maxIterations: 3 }); + const project = await projectWithHarness(subject, { + maxIterations: 3, + maxTokens: 128, + timeoutSeconds: 5, + }); const result = await drain(subject.exportHarness(project, exportInput())); - expect(existsSync(join(result.agentPath, "hooks", "execution_limits.py"))).toBe(true); + expect(existsSync(join(result.agentPath, "hooks"))).toBe(false); const main = await Bun.file(join(result.agentPath, "main.py")).text(); - expect(main).toContain( - "from hooks.execution_limits import ExecutionLimitExceeded, ExecutionLimitsHook", - ); - expect(main).toContain("max_iterations=3,"); + expect(main).toContain('"turns": 3'); + expect(main).toContain('"output_tokens": 128'); + expect(main).toContain("cancel_signal = threading.Event()"); + expect(main).toContain("limits=limits"); + expect(main).not.toContain("ExecutionLimitsHook"); + expect(main).not.toContain("agent.cancel()"); }); test("leaves hooks/ and memory/ out of a plain export", async () => { @@ -134,21 +140,151 @@ describe("FsProjectManager.exportHarness rendered tree", () => { expect(result.notes).toEqual([]); }); - test("renders the template Dockerfile for a plain Container export", async () => { + test("renders memory retrieval tuning and notes messagesCount", async () => { + const { manager: subject } = manager(); + let project = await projectWithHarness(subject, { + memory: { + mode: "existing", + name: "chat_history", + messagesCount: 12, + retrievalConfig: { topK: 8, relevanceScore: 0.7 }, + }, + }); + project = await drain( + subject.addResource(project, { + resourceType: "memory", + resourceConfig: { + name: "chat_history", + eventExpiryDuration: 30, + strategies: [{ type: "SEMANTIC" }], + }, + }), + ); + + const result = await drain(subject.exportHarness(project, exportInput())); + + const session = await Bun.file(join(result.agentPath, "memory", "session.py")).text(); + expect(session).toContain("RetrievalConfig(top_k=8, relevance_score=0.7)"); + expect(result.notes.map((note) => note.category)).toContain( + "Memory messagesCount is not directly portable to Strands", + ); + }); + + test("renders OpenAI Responses settings with compatible Strands extras", async () => { + const { manager: subject } = manager(); + const project = await projectWithHarness(subject, { + model: { + provider: "open_ai", + modelId: "gpt-4.1", + apiKeyArn: + "arn:aws:bedrock-agentcore:us-east-1:111122223333:token-vault/default/apikeycredentialprovider/OpenAiKey", + apiFormat: "responses", + maxTokens: 512, + temperature: 0.2, + topP: 0.8, + }, + }); + + const result = await drain(subject.exportHarness(project, exportInput())); + + const loadModel = await Bun.file(join(result.agentPath, "model", "load.py")).text(); + expect(loadModel).toContain("from strands.models.openai_responses import OpenAIResponsesModel"); + expect(loadModel).toContain('params["max_output_tokens"] = 512'); + expect(loadModel).toContain('params["temperature"] = 0.2'); + expect(loadModel).toContain('params["top_p"] = 0.8'); + const pyproject = await Bun.file(join(result.agentPath, "pyproject.toml")).text(); + expect(pyproject).toContain('"strands-agents[openai] ~= 1.54.0"'); + expect(pyproject).not.toContain('"openai ~= 1.0.0"'); + }); + + test("renders Gemini sampling settings with the Gemini extra", async () => { + const { manager: subject } = manager(); + const project = await projectWithHarness(subject, { + model: { + provider: "gemini", + modelId: "gemini-2.5-flash", + apiKeyArn: + "arn:aws:bedrock-agentcore:us-east-1:111122223333:token-vault/default/apikeycredentialprovider/GeminiKey", + maxTokens: 400, + temperature: 0.3, + topP: 0.9, + topK: 20, + }, + }); + + const result = await drain(subject.exportHarness(project, exportInput())); + + const loadModel = await Bun.file(join(result.agentPath, "model", "load.py")).text(); + expect(loadModel).toContain('params["max_output_tokens"] = 400'); + expect(loadModel).toContain('params["temperature"] = 0.3'); + expect(loadModel).toContain('params["top_p"] = 0.9'); + expect(loadModel).toContain('params["top_k"] = 20'); + expect(await Bun.file(join(result.agentPath, "pyproject.toml")).text()).toContain( + '"strands-agents[gemini] ~= 1.54.0"', + ); + }); + + test("renders LiteLLM settings with the LiteLLM extra", async () => { + const { manager: subject } = manager(); + const project = await projectWithHarness(subject, { + model: { + provider: "lite_llm", + modelId: "bedrock/us.amazon.nova-lite-v1:0", + maxTokens: 300, + temperature: 0.1, + topP: 0.7, + additionalParams: { max_retries: 2 }, + }, + }); + + const result = await drain(subject.exportHarness(project, exportInput())); + + const loadModel = await Bun.file(join(result.agentPath, "model", "load.py")).text(); + expect(loadModel).toContain('params["max_tokens"] = 300'); + expect(loadModel).toContain('params["temperature"] = 0.1'); + expect(loadModel).toContain('params["top_p"] = 0.7'); + expect(loadModel).toContain('json.loads("{\\"max_retries\\":2}")'); + expect(await Bun.file(join(result.agentPath, "pyproject.toml")).text()).toContain( + '"strands-agents[litellm] ~= 1.54.0"', + ); + }); + + test("renders released skills and sliding-window APIs", async () => { + const { manager: subject } = manager(); + const project = await projectWithHarness(subject, { + skills: [{ s3Uri: "s3://skills-bucket/team/" }], + truncation: { + strategy: "sliding_window", + config: { slidingWindow: { messagesCount: 12 } }, + }, + }); + + const result = await drain(subject.exportHarness(project, exportInput())); + + const main = await Bun.file(join(result.agentPath, "main.py")).text(); + expect(main).toContain("from strands import AgentSkills"); + expect(main).toContain('SlidingWindowConversationManager(**{"window_size":12}, per_turn=True)'); + expect(await Bun.file(join(result.agentPath, "pyproject.toml")).text()).toContain( + '"strands-agents ~= 1.54.0"', + ); + }); + + test("emits a CodeZip runtime with no container files", async () => { const { manager: subject } = manager(); const project = await projectWithHarness(subject); - const result = await drain(subject.exportHarness(project, exportInput({ build: "Container" }))); + const result = await drain(subject.exportHarness(project, exportInput())); - expect(await Bun.file(join(result.agentPath, "Dockerfile")).text()).toContain("uv sync"); - expect(existsSync(join(result.agentPath, ".dockerignore"))).toBe(true); + expect(existsSync(join(result.agentPath, "Dockerfile"))).toBe(false); + expect(existsSync(join(result.agentPath, ".dockerignore"))).toBe(false); const spec = await Bun.file(join(project.rootPath, "agentcore", "agentcore.json")).json(); const runtime = spec.runtimes.find((r: { name: string }) => r.name === "assistantAgent"); - expect(runtime.build).toBe("Container"); - expect(runtime.dockerfile).toBe("Dockerfile"); + expect(runtime.build).toBe("CodeZip"); + expect(runtime.runtimeVersion).toBe("PYTHON_3_14"); + expect(runtime.dockerfile).toBeUndefined(); }); - test("writes a FROM stub for a containerUri harness", async () => { + test("exports a containerUri harness as CodeZip and reports the dropped image", async () => { const { manager: subject } = manager(); const project = await projectWithHarness(subject, { containerUri: "111122223333.dkr.ecr.us-east-1.amazonaws.com/base-image:latest", @@ -156,12 +292,11 @@ describe("FsProjectManager.exportHarness rendered tree", () => { const result = await drain(subject.exportHarness(project, exportInput())); - expect(await Bun.file(join(result.agentPath, "Dockerfile")).text()).toContain( - "FROM 111122223333.dkr.ecr.us-east-1.amazonaws.com/base-image:latest", - ); - expect(result.notes.map((note) => note.category)).toEqual([ - "containerUri: verify Python in base image", - ]); + expect(existsSync(join(result.agentPath, "Dockerfile"))).toBe(false); + expect(result.notes.map((note) => note.category)).toEqual(["Container image not carried over"]); + const spec = await Bun.file(join(project.rootPath, "agentcore", "agentcore.json")).json(); + const runtime = spec.runtimes.find((r: { name: string }) => r.name === "assistantAgent"); + expect(runtime.build).toBe("CodeZip"); }); test("writes generated IAM policy files next to the code", async () => { @@ -197,12 +332,20 @@ describe("FsProjectManager.exportHarness side effects", () => { await drain(subject.exportHarness(project, exportInput())); - const envLocal = await Bun.file(join(project.rootPath, "agentcore", ".env.local")).text(); - expect(envLocal).toContain("AGENTCORE_CREDENTIAL_ORDERSMCPINTERNALXAPIKEY='s3cret'"); const spec = await Bun.file(join(project.rootPath, "agentcore", "agentcore.json")).json(); - expect(spec.credentials).toEqual([ - { authorizerType: "ApiKeyCredentialProvider", name: "ordersMcpinternalXApiKey" }, - ]); + const credential = spec.credentials[0]; + expect(credential.authorizerType).toBe("ApiKeyCredentialProvider"); + expect(credential.name).toMatch(/^ordersMcpinternalX-Api-Key-[a-f0-9]{10}$/); + const envLocal = await Bun.file(join(project.rootPath, "agentcore", ".env.local")).text(); + expect(envLocal).toContain( + `AGENTCORE_CREDENTIAL_${credential.name.replace(/-/g, "_").toUpperCase()}='s3cret'`, + ); + const mcpClient = await Bun.file( + join(project.rootPath, "app", "assistantAgent", "mcp_client", "client.py"), + ).text(); + expect(mcpClient).toMatch( + /def transport\(\):[\s\S]*headers = \{ "X-Api-Key": _get_[a-z0-9_]+_key\(\) \}[\s\S]*return streamablehttp_client/, + ); }); test("exports a prefetched (service) harness without touching harness files", async () => { diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 6f10ec39a..c502cbdae 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { copyFile, readFile, rm, writeFile } from "node:fs/promises"; +import { readFile, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import type { AddResourceInput, @@ -37,7 +37,6 @@ import { getRuntimeTemplateResolver } from "./templates/runtime"; import { DEFAULT_EXPORT_SYSTEM_PROMPT, EXPORT_NOTES_FILENAME, - buildDockerfileStub, buildExportNotesMarkdown, mapHarnessToExportPlan, } from "./templates/export"; @@ -726,31 +725,17 @@ export class FsProjectManager implements ProjectManager { spec, systemPrompt, projectSpec, - build: input.build, - harnessDockerfileExists: - spec.dockerfile !== undefined && - harnessDir !== undefined && - existsSync(join(harnessDir, spec.dockerfile)), + sourceNotes: input.prefetched?.notes, }); - const isContainer = plan.buildType === "Container"; yield { type: "step", message: `Rendering agent code at 'app/${targetAgentName}'` }; const tree = await FsTreeNode.fromAssetSource( { assetSource: this.assetSource }, - { assetDir: "templates/strands-http-python" }, + { assetDir: "templates/export-harness-python" }, { rootDirName: targetAgentName, transformContent: (raw) => this.templateRenderer.render(raw, plan.context), - filter: (name, isDir) => { - if (isDir && name === "memory") return plan.hasMemory; - if (isDir && name === "hooks") return plan.hasExecutionLimits; - // The template's own Dockerfile is used only for a plain Container - // export; containerUri/custom-Dockerfile harnesses replace it below. - if (name === "Dockerfile") - return isContainer && plan.dockerfilePlan.source === "template"; - if (name === ".dockerignore") return isContainer; - return true; - }, + filter: (name, isDir) => (isDir && name === "memory" ? plan.hasMemory : true), }, ); @@ -769,14 +754,6 @@ export class FsProjectManager implements ProjectManager { await tree.write(join(project.rootPath, "app")); // Post-render files the template cannot express. - if (plan.dockerfilePlan.source === "stub") { - await writeFile( - join(agentDir, "Dockerfile"), - buildDockerfileStub(plan.dockerfilePlan.containerUri), - ); - } else if (plan.dockerfilePlan.source === "harnessCopy") { - await copyFile(join(harnessDir!, spec.dockerfile!), join(agentDir, "Dockerfile")); - } for (const [fileName, policyDoc] of Object.entries(plan.policyFiles)) { await writeFile(join(agentDir, fileName), `${JSON.stringify(policyDoc, null, 2)}\n`); } @@ -1147,7 +1124,7 @@ function toProjectSpecKey(resourceType: ProjectResource) { async function readStrandsVersion(agentDir: string): Promise { try { const pyproject = await readFile(join(agentDir, "pyproject.toml"), "utf-8"); - const match = /strands-agents\s*([~><=]+\s*[\d.]+)/.exec(pyproject); + const match = /strands-agents(?:\[[^\]]+\])?\s*([~><=]+\s*[\d.]+)/.exec(pyproject); return match ? `strands-agents ${match[1]}` : "strands-agents (version unknown)"; } catch { return "strands-agents (version unknown)"; diff --git a/src/core/project/templates/export.test.ts b/src/core/project/templates/export.test.ts index 543a7f141..a431dde6b 100644 --- a/src/core/project/templates/export.test.ts +++ b/src/core/project/templates/export.test.ts @@ -3,13 +3,13 @@ import z from "zod"; import { InputValidationError } from "../../../errors/errors"; import { HarnessSpecSchema, type HarnessSpec } from "../../../projectSchemas/harness"; import { ProjectSpecSchema } from "../../../projectSchemas/project"; +import { credentialEnvVarName } from "../../../projectSchemas/credential"; import { ALLOWED_TOOLS_NOTE_CATEGORY, AWS_SKILLS_NOTE_CATEGORY, BROWSER_TOOL_NOTE_CATEGORY, CODE_INTERPRETER_TOOL_NOTE_CATEGORY, - CONTAINER_URI_NOTE_CATEGORY, - CUSTOM_DOCKERFILE_NOTE_CATEGORY, + CONTAINER_IMAGE_NOTE_CATEGORY, GATEWAY_TOOL_NOTE_CATEGORY, GIT_SKILLS_AUTH_NOTE_CATEGORY, LITELLM_NO_API_KEY_NOTE_CATEGORY, @@ -17,10 +17,9 @@ import { MCP_HEADER_CREDS_NOTE_CATEGORY, MEMORY_ARN_NOTE_CATEGORY, MEMORY_MANAGED_NOTE_CATEGORY, + MEMORY_MESSAGES_COUNT_NOTE_CATEGORY, MEMORY_NAME_NOT_FOUND_NOTE_CATEGORY, - MISSING_DOCKERFILE_NOTE_CATEGORY, MODEL_API_KEY_NOTE_CATEGORY, - PATH_SKILLS_NOTE_CATEGORY, buildExportNotesMarkdown, formatExportNotes, mapHarnessToExportPlan, @@ -83,7 +82,7 @@ describe("mapHarnessToExportPlan model mapping", () => { expect(result.context.modelTopP).toBe("0.9"); expect(result.context.modelMaxTokens).toBe("512"); expect(result.context.bedrockMantle).toBeUndefined(); - expect(result.hasExecutionLimits).toBe(true); + expect(result.context.hasExecutionLimits).toBe(true); expect(result.context.maxIterations).toBe(5); expect(result.context.maxTokens).toBe(2048); expect(result.context.timeoutSeconds).toBe(60); @@ -120,6 +119,10 @@ describe("mapHarnessToExportPlan model mapping", () => { model: { provider: "open_ai", modelId: "gpt-4.1", + apiFormat: "responses", + maxTokens: 768, + temperature: 0.2, + topP: 0.8, apiKeyArn: "arn:aws:bedrock-agentcore:us-east-1:111122223333:token-vault/default/apikeycredentialprovider/MyOpenAiKey", }, @@ -127,6 +130,11 @@ describe("mapHarnessToExportPlan model mapping", () => { }); expect(result.context.modelProvider).toBe("OpenAI"); + expect(result.context.strandsExtras).toBe("openai"); + expect(result.context.modelApiFormat).toBe("responses"); + expect(result.context.modelMaxTokens).toBe("768"); + expect(result.context.modelTemperature).toBe("0.2"); + expect(result.context.modelTopP).toBe("0.8"); expect(result.context.hasIdentity).toBe(true); expect(result.context.identityProviders).toEqual([ { name: "MyOpenAiKey", envVarName: "AGENTCORE_CREDENTIAL_MYOPENAIKEY" }, @@ -153,6 +161,7 @@ describe("mapHarnessToExportPlan model mapping", () => { }); expect(result.context.modelProvider).toBe("Gemini"); + expect(result.context.strandsExtras).toBe("gemini"); expect(result.credentials).toEqual([]); }); @@ -163,14 +172,21 @@ describe("mapHarnessToExportPlan model mapping", () => { provider: "lite_llm", modelId: "bedrock/us.amazon.nova-lite-v1:0", apiBase: "https://litellm.example", + maxTokens: 300, + temperature: 0.1, + topP: 0.7, additionalParams: { max_retries: 2 }, }, }), }); expect(result.context.modelProvider).toBe("LiteLLM"); + expect(result.context.strandsExtras).toBe("litellm"); expect(result.context.litellmApiBase).toBe("https://litellm.example"); expect(result.context.litellmAdditionalParams).toEqual({ max_retries: 2 }); + expect(result.context.modelMaxTokens).toBe("300"); + expect(result.context.modelTemperature).toBe("0.1"); + expect(result.context.modelTopP).toBe("0.7"); expect(result.notes).toEqual([]); }); @@ -207,7 +223,12 @@ describe("mapHarnessToExportPlan tools", () => { }); expect(result.context.remoteMcpTools).toEqual([ - { name: "exa", url: "https://mcp.exa.ai/mcp", headerCredentials: undefined }, + { + name: "exa", + pythonName: expect.stringMatching(/^exa_[a-f0-9]{10}$/), + url: "https://mcp.exa.ai/mcp", + headerCredentials: undefined, + }, ]); expect(result.context.inlineFunctionTools).toEqual([ { @@ -238,26 +259,58 @@ describe("mapHarnessToExportPlan tools", () => { }); const tools = result.context.remoteMcpTools as { - headerCredentials?: { headerKey: string; credentialName: string; envVarName: string }[]; + headerCredentials?: { + headerKey: string; + credentialName: string; + envVarName: string; + pythonName: string; + }[]; }[]; - expect(tools[0]!.headerCredentials).toEqual([ - { - headerKey: "X-Api-Key", - credentialName: "ordersMcpinternalXApiKey", - envVarName: "AGENTCORE_CREDENTIAL_ORDERSMCPINTERNALXAPIKEY", - }, - ]); + const header = tools[0]!.headerCredentials![0]!; + expect(header.headerKey).toBe("X-Api-Key"); + expect(header.credentialName).toMatch(/^ordersMcpinternalX-Api-Key-[a-f0-9]{10}$/); + expect(header.envVarName).toBe(credentialEnvVarName(header.credentialName)); + expect(header.pythonName).toMatch(/^internal_x_api_key_[a-f0-9]{10}$/); expect(result.credentials).toEqual([ - { authorizerType: "ApiKeyCredentialProvider", name: "ordersMcpinternalXApiKey" }, + { authorizerType: "ApiKeyCredentialProvider", name: header.credentialName }, ]); expect(result.envEntries).toEqual([ { - key: "AGENTCORE_CREDENTIAL_ORDERSMCPINTERNALXAPIKEY", + key: header.envVarName, value: "s3cret", comment: '"X-Api-Key" header for MCP tool "internal" (exported from harness "assistant")', }, ]); expect(categories(result)).toEqual([MCP_HEADER_CREDS_NOTE_CATEGORY]); + expect(result.notes[0]!.message).toContain("exists in AgentCore Identity"); + }); + + test("keeps normalized header names distinct", () => { + const result = plan({ + spec: harness({ + tools: [ + { + type: "remote_mcp", + name: "internal", + config: { + remoteMcp: { + url: "https://mcp.internal.example", + headers: { "X-Api-Key": "first", X_Api_Key: "second" }, + }, + }, + }, + ], + }), + }); + + const names = result.credentials.map((credential) => credential.name); + expect(names).toHaveLength(2); + expect(new Set(names).size).toBe(2); + expect(new Set(result.envEntries.map((entry) => entry.key)).size).toBe(2); + const tools = result.context.remoteMcpTools as { + headerCredentials: { pythonName: string }[]; + }[]; + expect(new Set(tools[0]!.headerCredentials.map(({ pythonName }) => pythonName)).size).toBe(2); }); test("emits a follow-up note for each unmappable tool type instead of code", () => { @@ -279,9 +332,11 @@ describe("mapHarnessToExportPlan tools", () => { }), }); - expect(result.context.hasBrowser).toBe(false); - expect(result.context.hasCodeInterpreter).toBe(false); - expect(result.context.hasGateway).toBe(false); + // The render context carries nothing for these tools at all, so the template has no + // branch to render them from — the notes below are the whole output. + expect(result.context.hasBrowser).toBeUndefined(); + expect(result.context.hasCodeInterpreter).toBeUndefined(); + expect(result.context.hasGateway).toBeUndefined(); expect(result.context.remoteMcpTools).toBeUndefined(); expect(categories(result)).toEqual([ GATEWAY_TOOL_NOTE_CATEGORY, @@ -316,7 +371,12 @@ describe("mapHarnessToExportPlan tools", () => { expect(restricted.context.hasShell).toBe(true); expect(restricted.context.hasFileOperations).toBe(false); expect(restricted.context.remoteMcpTools).toEqual([ - { name: "exa", url: "https://mcp.exa.ai/mcp", headerCredentials: undefined }, + { + name: "exa", + pythonName: expect.stringMatching(/^exa_[a-f0-9]{10}$/), + url: "https://mcp.exa.ai/mcp", + headerCredentials: undefined, + }, ]); expect(categories(restricted)).toEqual([ALLOWED_TOOLS_NOTE_CATEGORY]); }); @@ -355,6 +415,28 @@ describe("mapHarnessToExportPlan memory", () => { expect(result.notes).toEqual([]); }); + test("preserves retrieval tuning and notes an unmappable messagesCount", () => { + const result = plan({ + spec: harness({ + memory: { + mode: "existing", + name: "chat_history", + messagesCount: 12, + retrievalConfig: { topK: 7, relevanceScore: 0 }, + }, + }), + projectSpec: projectSpec({ + memories: [ + { name: "chat_history", eventExpiryDuration: 30, strategies: [{ type: "SEMANTIC" }] }, + ], + }), + }); + + expect(result.context.memoryRetrievalTopK).toBe("7"); + expect(result.context.memoryRetrievalRelevanceScore).toBe("0"); + expect(categories(result)).toEqual([MEMORY_MESSAGES_COUNT_NOTE_CATEGORY]); + }); + test("notes a by-name memory that is not in the project", () => { const result = plan({ spec: harness({ memory: { mode: "existing", name: "missing" } }), @@ -388,12 +470,11 @@ describe("mapHarnessToExportPlan memory", () => { }); describe("mapHarnessToExportPlan skills", () => { - test("maps path, s3, and git skills and generates the S3 read policy", () => { + test("maps s3 and git skills and generates the S3 read policy", () => { const result = plan({ spec: harness({ build: undefined, skills: [ - { path: "local_skill" }, { s3Uri: "s3://skills-bucket/team/" }, { gitUrl: "https://github.com/example/skills.git", path: "subdir" }, ], @@ -402,7 +483,6 @@ describe("mapHarnessToExportPlan skills", () => { expect(result.context.hasSkillsFetcher).toBe(true); expect(result.context.hasFetchedSkills).toBe(true); - expect(result.context.pathSkills).toEqual(["local_skill"]); expect(result.context.s3Skills).toEqual(["s3://skills-bucket/team/"]); expect(result.context.gitSkills).toEqual([ { url: "https://github.com/example/skills.git", path: "subdir" }, @@ -419,8 +499,7 @@ describe("mapHarnessToExportPlan skills", () => { ], }); expect(result.runtime.additionalPolicies).toEqual(["s3-skills-policy.json"]); - // CodeZip path skills need the container filesystem — flagged for follow-up. - expect(categories(result)).toEqual([PATH_SKILLS_NOTE_CATEGORY]); + expect(result.notes).toEqual([]); }); test("notes a malformed s3 URI instead of generating IAM for it", () => { @@ -492,66 +571,50 @@ describe("mapHarnessToExportPlan truncation", () => { }); }); -describe("mapHarnessToExportPlan build types and Dockerfiles", () => { - test("defaults to CodeZip with the PYTHON_3_14 runtime", () => { +describe("mapHarnessToExportPlan always exports a CodeZip runtime", () => { + const CONTAINER_URI = "111122223333.dkr.ecr.us-east-1.amazonaws.com/base-image:latest"; + + test("emits CodeZip with the PYTHON_3_14 runtime and no Dockerfile", () => { const result = plan({}); - expect(result.buildType).toBe("CodeZip"); - expect(result.dockerfilePlan).toEqual({ source: "none" }); + expect(result.runtime.build).toBe("CodeZip"); expect(result.runtime.runtimeVersion).toBe("PYTHON_3_14"); expect(result.runtime.dockerfile).toBeUndefined(); }); - test("a plain --build Container uses the template Dockerfile", () => { - const result = plan({ build: "Container" }); - expect(result.buildType).toBe("Container"); - expect(result.dockerfilePlan).toEqual({ source: "template" }); - expect(result.runtime.dockerfile).toBe("Dockerfile"); - expect(result.runtime.runtimeVersion).toBeUndefined(); + test("a containerUri harness still exports as CodeZip, with a note that the image was dropped", () => { + const result = plan({ spec: harness({ containerUri: CONTAINER_URI }) }); + expect(result.runtime.build).toBe("CodeZip"); + expect(result.runtime.dockerfile).toBeUndefined(); + expect(categories(result)).toEqual([CONTAINER_IMAGE_NOTE_CATEGORY]); + expect(result.notes[0]?.message).toContain(CONTAINER_URI); + }); + + test("a custom-Dockerfile harness also exports as CodeZip with the same note", () => { + const result = plan({ spec: harness({ dockerfile: "Dockerfile" }) }); + expect(result.runtime.build).toBe("CodeZip"); + expect(result.runtime.dockerfile).toBeUndefined(); + expect(categories(result)).toEqual([CONTAINER_IMAGE_NOTE_CATEGORY]); }); - test("a containerUri harness gets a FROM-stub Dockerfile and a verify note", () => { + test("a VPC harness keeps its subnets and security groups and needs no vpcId", () => { const result = plan({ spec: harness({ - containerUri: "111122223333.dkr.ecr.us-east-1.amazonaws.com/base-image:latest", + containerUri: CONTAINER_URI, + networkMode: "VPC", + networkConfig: { subnets: ["subnet-12345678"], securityGroups: ["sg-12345678"] }, }), }); - expect(result.buildType).toBe("Container"); - expect(result.dockerfilePlan).toEqual({ - source: "stub", - containerUri: "111122223333.dkr.ecr.us-east-1.amazonaws.com/base-image:latest", - }); - expect(categories(result)).toEqual([CONTAINER_URI_NOTE_CATEGORY]); - }); - - test("rejects forcing CodeZip onto a containerUri harness", () => { - expect(() => - plan({ - build: "CodeZip", - spec: harness({ - containerUri: "111122223333.dkr.ecr.us-east-1.amazonaws.com/base-image:latest", - }), - }), - ).toThrow(InputValidationError); - }); - - test("copies a custom harness Dockerfile with a build-layer note when it exists", () => { - const result = plan({ - spec: harness({ dockerfile: "Dockerfile" }), - harnessDockerfileExists: true, + expect(result.runtime.build).toBe("CodeZip"); + expect(result.runtime.networkConfig).toEqual({ + subnets: ["subnet-12345678"], + securityGroups: ["sg-12345678"], }); - expect(result.dockerfilePlan).toEqual({ source: "harnessCopy" }); - expect(categories(result)).toEqual([CUSTOM_DOCKERFILE_NOTE_CATEGORY]); }); - test("notes a declared-but-missing harness Dockerfile", () => { - const result = plan({ - spec: harness({ dockerfile: "Dockerfile" }), - harnessDockerfileExists: false, - }); - expect(result.dockerfilePlan).toEqual({ source: "none" }); - expect(categories(result)).toEqual([MISSING_DOCKERFILE_NOTE_CATEGORY]); - // The runtime entry still expects the Dockerfile the user will create. - expect(result.runtime.dockerfile).toBe("Dockerfile"); + test("rejects path-based skills, which have no container filesystem to read from", () => { + expect(() => plan({ spec: harness({ skills: [{ path: "/opt/skills/research" }] }) })).toThrow( + InputValidationError, + ); }); }); @@ -612,15 +675,21 @@ describe("mapHarnessToExportPlan runtime spec entry", () => { }); describe("export notes rendering", () => { + test("keeps notes collected while mapping a service harness", () => { + const sourceNote = { category: "Service field", message: "Review it." }; + const result = plan({ sourceNotes: [sourceNote] }); + expect(result.notes).toContainEqual(sourceNote); + }); + test("buildExportNotesMarkdown lists each note under its category", () => { const markdown = buildExportNotesMarkdown( [{ category: "A category", message: "Do the thing." }], "assistant", "assistantAgent", - "strands-agents ~= 1.15.0", + "strands-agents ~= 1.54.0", ); expect(markdown).toContain("# Export Notes — assistant → assistantAgent"); - expect(markdown).toContain("Strands version: strands-agents ~= 1.15.0"); + expect(markdown).toContain("Strands version: strands-agents ~= 1.54.0"); expect(markdown).toContain("## Items requiring manual follow-up"); expect(markdown).toContain("### A category"); expect(markdown).toContain("Do the thing."); diff --git a/src/core/project/templates/export.ts b/src/core/project/templates/export.ts index f83d89302..fd882b04a 100644 --- a/src/core/project/templates/export.ts +++ b/src/core/project/templates/export.ts @@ -1,7 +1,9 @@ +import { createHash } from "node:crypto"; import type { z } from "zod"; -import type { BuildType, ProjectRuntime } from "../../../projectSchemas/runtime"; +import type { ProjectRuntime } from "../../../projectSchemas/runtime"; import type { HarnessMemoryRef, + HarnessMemoryRetrievalConfig, HarnessSkill, HarnessSkillGitSource, HarnessSkillPathSource, @@ -45,26 +47,10 @@ export interface HarnessExportInput { systemPrompt: string; /** The current project spec, for memory lookups and credential dedup. */ projectSpec: ProjectSpec; - /** Build override from --build; when absent the harness spec decides. */ - build?: BuildType; - /** - * Whether the harness directory holds the Dockerfile that `spec.dockerfile` - * names (local harnesses only; the caller checks the filesystem). - */ - harnessDockerfileExists?: boolean; + /** Notes collected while converting a service response into a local harness spec. */ + sourceNotes?: ExportNote[]; } -/** How the exported agent's Dockerfile is produced (Container builds only). */ -export type DockerfilePlan = - /** Render the stock template Dockerfile (plain --build Container). */ - | { source: "template" } - /** Write a FROM- stub extending the harness's prebuilt image. */ - | { source: "stub"; containerUri: string } - /** Copy the harness's own Dockerfile from the harness directory. */ - | { source: "harnessCopy" } - /** CodeZip — no Dockerfile at all. */ - | { source: "none" }; - /** The pure mapping result; the project manager executes it against the filesystem. */ export interface HarnessExportPlan { /** Handlebars context for rendering the strands-http-python template. */ @@ -79,10 +65,6 @@ export interface HarnessExportPlan { policyFiles: Record; /** Whether the render includes the memory/ module. */ hasMemory: boolean; - /** Whether the render includes hooks/execution_limits.py. */ - hasExecutionLimits: boolean; - buildType: BuildType; - dockerfilePlan: DockerfilePlan; notes: ExportNote[]; } @@ -98,6 +80,8 @@ export const CODE_INTERPRETER_TOOL_NOTE_CATEGORY = export const MEMORY_ARN_NOTE_CATEGORY = "External memory reference not exported"; export const MEMORY_MANAGED_NOTE_CATEGORY = "Managed harness memory not exported"; export const MEMORY_NAME_NOT_FOUND_NOTE_CATEGORY = "Memory reference could not be resolved"; +export const MEMORY_MESSAGES_COUNT_NOTE_CATEGORY = + "Memory messagesCount is not directly portable to Strands"; export const PATH_SKILLS_NOTE_CATEGORY = "path skills require container filesystem"; export const GIT_SKILLS_CONTAINER_NOTE_CATEGORY = "git skills require git in container image"; export const GIT_SKILLS_AUTH_NOTE_CATEGORY = "git skill credential provider referenced"; @@ -108,10 +92,7 @@ export const MALFORMED_S3_SKILL_NOTE_CATEGORY = export const MCP_HEADER_CREDS_NOTE_CATEGORY = "MCP tool header credentials"; export const LITELLM_NO_API_KEY_NOTE_CATEGORY = "LiteLLM model may require an API key"; export const MODEL_API_KEY_NOTE_CATEGORY = "Model API key credential referenced"; -export const CONTAINER_URI_NOTE_CATEGORY = "containerUri: verify Python in base image"; -export const CUSTOM_DOCKERFILE_NOTE_CATEGORY = - "Custom harness Dockerfile needs the agent build layer"; -export const MISSING_DOCKERFILE_NOTE_CATEGORY = "Dockerfile not found — create it before deploying"; +export const CONTAINER_IMAGE_NOTE_CATEGORY = "Container image not carried over"; // ============================================================================ // Public entry point @@ -119,23 +100,33 @@ export const MISSING_DOCKERFILE_NOTE_CATEGORY = "Dockerfile not found — create export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExportPlan { const { spec, targetAgentName, projectSpec } = input; - const notes: ExportNote[] = []; + const notes: ExportNote[] = [...(input.sourceNotes ?? [])]; const credentials: Credential[] = []; const envEntries: EnvLocalEntry[] = []; const policyFiles: Record = {}; const additionalPolicies: string[] = []; - const buildType = resolveBuildType(spec, input.build); - if (buildType === "CodeZip" && (spec.containerUri || spec.dockerfile)) { + // Export always emits a CodeZip runtime. The generated agent is a self-contained Strands + // application whose dependencies come from its own pyproject.toml, so it needs no image build + // and never reaches CodeBuild. A source image or Dockerfile is reported rather than rebuilt. + if (spec.containerUri || spec.dockerfile) { const what = spec.containerUri - ? `containerUri (${spec.containerUri})` - : `dockerfile (${spec.dockerfile})`; - throw new InputValidationError( - `Harness "${spec.name}" uses ${what}, which requires a Container build. ` + - `Re-export with --build Container.`, - ); + ? `a pre-built container image (${spec.containerUri})` + : `a custom Dockerfile (${spec.dockerfile})`; + notes.push({ + category: CONTAINER_IMAGE_NOTE_CATEGORY, + message: + `The harness used ${what} as its execution environment. The exported agent does not ` + + `rebuild it: the generated Strands application declares its own dependencies and runs ` + + `on the managed Python runtime. If that image supplied anything the agent needs at ` + + `runtime — system packages, certificates, or files read from disk — add it to the ` + + `generated project yourself.`, + }); } + const networkConfig = + spec.networkMode === "VPC" && spec.networkConfig ? spec.networkConfig : undefined; + const allowedToolPatterns = spec.allowedTools ?? ["*"]; if (!(allowedToolPatterns.length === 1 && allowedToolPatterns[0] === "*")) { notes.push({ @@ -157,7 +148,7 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport envEntries, notes, ); - const skills = resolveSkills(spec, buildType, targetAgentName, credentials, notes); + const skills = resolveSkills(spec, credentials, notes); for (const [file, doc] of Object.entries(skills.policyFiles)) policyFiles[file] = doc; if (model.policyFile) policyFiles[model.policyFile.name] = model.policyFile.doc; additionalPolicies.push(...Object.keys(policyFiles)); @@ -167,14 +158,6 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport spec.maxTokens !== undefined || spec.timeoutSeconds !== undefined; - const dockerfilePlan = resolveDockerfilePlan( - spec, - buildType, - targetAgentName, - input.harnessDockerfileExists ?? false, - notes, - ); - const filesystemConfigurations = buildFilesystemConfigurations(spec); const envVars = Object.entries(spec.environmentVariables ?? {}).map(([name, value]) => ({ name, @@ -186,8 +169,6 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport isExportHarness: true, entrypoint: "main", enableOtel: true, - hasConfigBundle: false, - hasPayment: false, isVpc: spec.networkMode === "VPC", protocol: "HTTP", // Model @@ -198,24 +179,24 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport hasMemory: memory.provider !== undefined, memoryEnvVarName: memory.provider?.envVarName, memoryStrategies: memory.provider?.strategies ?? [], + memoryRetrievalTopK: + memory.retrievalConfig?.topK !== undefined ? String(memory.retrievalConfig.topK) : undefined, + memoryRetrievalRelevanceScore: + memory.retrievalConfig?.relevanceScore !== undefined + ? String(memory.retrievalConfig.relevanceScore) + : undefined, actorId: memory.actorId, // Gateways are never exported as code (see resolveTools); the template still // needs the keys so its conditionals resolve. - hasGateway: false, - gatewayProviders: [], - gatewayAuthTypes: [], // Tools. Empty collections become undefined: the template's custom `or`/ // `some` helpers use JS truthiness, where [] is truthy, unlike `{{#if}}`. inlineFunctionTools: undefinedIfEmpty(tools.inlineFunctionTools), remoteMcpTools: undefinedIfEmpty(tools.remoteMcpTools), hasShell: tools.hasShell, hasFileOperations: tools.hasFileOperations, - hasBrowser: false, - hasCodeInterpreter: false, // Skills hasSkillsFetcher: skills.hasSkillsFetcher, hasFetchedSkills: skills.hasFetchedSkills, - pathSkills: skills.pathSkills, s3Skills: undefinedIfEmpty(skills.s3Skills), gitSkills: undefinedIfEmpty(skills.gitSkills), // Execution limits (numbers are schema-validated >= 1, so plain #if works) @@ -239,15 +220,14 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport const runtime: ProjectRuntime = { name: targetAgentName, - build: buildType, + build: "CodeZip", entrypoint: "main.py", codeLocation: `app/${targetAgentName}` as ProjectRuntime["codeLocation"], protocol: "HTTP", - ...(buildType === "CodeZip" && { runtimeVersion: "PYTHON_3_14" as const }), - ...(buildType === "Container" && { dockerfile: "Dockerfile" }), + runtimeVersion: "PYTHON_3_14", ...(envVars.length > 0 && { envVars }), ...(spec.networkMode && { networkMode: spec.networkMode }), - ...(spec.networkMode === "VPC" && spec.networkConfig && { networkConfig: spec.networkConfig }), + ...(networkConfig && { networkConfig }), ...(spec.authorizerType && { authorizerType: spec.authorizerType }), ...(spec.authorizerConfiguration && { authorizerConfiguration: spec.authorizerConfiguration, @@ -269,9 +249,6 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport envEntries, policyFiles, hasMemory: memory.provider !== undefined, - hasExecutionLimits, - buildType, - dockerfilePlan, notes, }; } @@ -310,10 +287,12 @@ function resolveModel( const model = spec.model; const context: Record = { modelId: model.modelId, + modelApiFormat: model.apiFormat, // Stringified so a legal 0 (temperature/topP) stays truthy for {{#if}}. modelMaxTokens: model.maxTokens !== undefined ? String(model.maxTokens) : undefined, modelTemperature: model.temperature !== undefined ? String(model.temperature) : undefined, modelTopP: model.topP !== undefined ? String(model.topP) : undefined, + modelTopK: model.topK !== undefined ? String(model.topK) : undefined, hasIdentity: false, identityProviders: [] as { name: string; envVarName: string }[], }; @@ -323,6 +302,7 @@ function resolveModel( context.modelProvider = "Bedrock"; if (isBedrockMantleModel(spec)) { context.bedrockMantle = true; + context.strandsExtras = "openai"; context.mantleApiFormat = model.apiFormat; context.mantleProprietary = isProprietaryOpenAiModel(model.modelId); // Mantle is invoked via the bedrock-mantle service, not bedrock:InvokeModel, @@ -354,6 +334,7 @@ function resolveModel( case "open_ai": case "gemini": { context.modelProvider = model.provider === "open_ai" ? "OpenAI" : "Gemini"; + context.strandsExtras = model.provider === "open_ai" ? "openai" : "gemini"; // The schema guarantees apiKeyArn for these providers. attachIdentityProvider( context, @@ -367,6 +348,7 @@ function resolveModel( } case "lite_llm": { context.modelProvider = "LiteLLM"; + context.strandsExtras = "litellm"; if (model.apiBase) context.litellmApiBase = model.apiBase; if (model.additionalParams && Object.keys(model.additionalParams).length > 0) { context.litellmAdditionalParams = model.additionalParams; @@ -440,6 +422,7 @@ function attachIdentityProvider( interface MemoryResolution { provider?: { name: string; envVarName: string; strategies: string[] }; actorId?: string; + retrievalConfig?: HarnessMemoryRetrievalConfig; } function resolveMemory( @@ -474,6 +457,16 @@ function resolveMemory( }); return { actorId: memory.actorId }; } + if (memory.messagesCount !== undefined) { + notes.push({ + category: MEMORY_MESSAGES_COUNT_NOTE_CATEGORY, + message: + `The harness restored at most ${memory.messagesCount} short-term memory messages. ` + + "AgentCoreMemorySessionManager restores the available session history and does not expose " + + "an equivalent message-count setting; use conversation truncation or customize " + + "memory/session.py if the exact restore limit is required.", + }); + } return { provider: { name: entry.name, @@ -482,6 +475,7 @@ function resolveMemory( strategies: entry.strategies.map(({ type }) => type), }, actorId: memory.actorId, + retrievalConfig: memory.retrievalConfig, }; } @@ -511,8 +505,14 @@ interface ToolsResolution { }[]; remoteMcpTools: { name: string; + pythonName: string; url: string; - headerCredentials?: { headerKey: string; credentialName: string; envVarName: string }[]; + headerCredentials?: { + headerKey: string; + credentialName: string; + envVarName: string; + pythonName: string; + }[]; }[]; hasShell: boolean; hasFileOperations: boolean; @@ -557,13 +557,18 @@ function resolveTools( if (!cfg) break; const headerKeys = Object.keys(cfg.headers ?? {}); let headerCredentials: ToolsResolution["remoteMcpTools"][number]["headerCredentials"]; + const toolPythonName = stablePythonIdentifier(tool.name); if (headerKeys.length > 0) { headerCredentials = []; - const toolPrefix = tool.name.replace(/[^A-Za-z0-9]/g, ""); for (const headerKey of headerKeys) { - const credentialName = `${projectSpec.name}Mcp${toolPrefix}${headerKey.replace(/[^A-Za-z0-9]/g, "")}`; + const credentialName = remoteMcpCredentialName(projectSpec.name, tool.name, headerKey); const envVarName = credentialEnvVarName(credentialName); - headerCredentials.push({ headerKey, credentialName, envVarName }); + headerCredentials.push({ + headerKey, + credentialName, + envVarName, + pythonName: stablePythonIdentifier(`${tool.name}-${headerKey}`), + }); if ( !projectSpec.credentials.some((c) => c.name === credentialName) && !credentials.some((c) => c.name === credentialName) @@ -584,14 +589,20 @@ function resolveTools( message: `MCP tool "${tool.name}" sends request headers whose values are managed via ` + `AgentCore Identity. Credential entries were added to agentcore.json and the header ` + - `values written to agentcore/.env.local; they are provisioned on ` + - `\`agentcore project deploy\`.\n\n` + + `values written to agentcore/.env.local. Ensure each named API-key credential provider ` + + `exists in AgentCore Identity before invoking the exported runtime; deployment wires ` + + `the provider references and runtime permissions.\n\n` + headerCredentials .map((h) => ` ${h.credentialName} (env var: ${h.envVarName})`) .join("\n"), }); } - result.remoteMcpTools.push({ name: tool.name, url: cfg.url, headerCredentials }); + result.remoteMcpTools.push({ + name: tool.name, + pythonName: toolPythonName, + url: cfg.url, + headerCredentials, + }); break; } case "agentcore_gateway": { @@ -645,6 +656,25 @@ function configOf(tool: HarnessTool, key: string): unknown { return (tool.config as Record)[key]; } +function stablePythonIdentifier(value: string): string { + const readable = + value + .replace(/[^a-zA-Z0-9]/g, "_") + .toLowerCase() + .slice(0, 48) || "value"; + return `${readable}_${shortHash(value)}`; +} + +function remoteMcpCredentialName(projectName: string, toolName: string, headerKey: string): string { + const readable = `${projectName}Mcp${toolName}${headerKey}`.replace(/[^a-zA-Z0-9_-]/g, ""); + const suffix = `-${shortHash(`${toolName}\0${headerKey}`)}`; + return `${readable.slice(0, 128 - suffix.length)}${suffix}`; +} + +function shortHash(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 10); +} + // ============================================================================ // Skills // ============================================================================ @@ -676,8 +706,6 @@ function isAwsSkill(skill: HarnessSkill): skill is HarnessSkillAwsSkillsSource { function resolveSkills( spec: HarnessSpec, - buildType: BuildType, - targetAgentName: string, credentials: Credential[], notes: ExportNote[], ): SkillsResolution { @@ -687,25 +715,14 @@ function resolveSkills( const awsSkills = spec.skills.filter(isAwsSkill); const policyFiles: Record = {}; - if (pathSkills.length > 0 && buildType === "CodeZip") { - notes.push({ - category: PATH_SKILLS_NOTE_CATEGORY, - message: - `The following skill paths must exist on the container filesystem at runtime: ` + - `${pathSkills.join(", ")}. For CodeZip builds, path skills are not supported — switch to ` + - `a Container build and COPY the skill directory into app/${targetAgentName}/, or use ` + - `s3/git skill variants.`, - }); - } - - if (gitSkillSources.length > 0 && buildType === "Container") { - notes.push({ - category: GIT_SKILLS_CONTAINER_NOTE_CATEGORY, - message: - "The agent clones git skill repositories at runtime using `git`. The default Container " + - "base image does not include git. Add it to your Dockerfile before deploying:\n\n" + - " RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*", - }); + // A path skill is a directory on the harness image's filesystem. The exported agent runs on the + // managed runtime with no such image, so the files would simply be absent at invocation. + if (pathSkills.length > 0) { + throw new InputValidationError( + `Harness "${spec.name}" uses path-based skills (${pathSkills.join(", ")}), which export ` + + `does not support: the exported agent has no container filesystem to read them from. ` + + `Republish those skills from s3 or git, then export again.`, + ); } // The agent fetches S3 skills with boto3 at runtime, so the runtime execution @@ -814,101 +831,6 @@ export function parseS3SkillArns( return { bucket, bucketArn, objectArn }; } -// ============================================================================ -// Build type + Dockerfile -// ============================================================================ - -function resolveBuildType(spec: HarnessSpec, override?: BuildType): BuildType { - if (override) return override; - if (spec.containerUri || spec.dockerfile) return "Container"; - return "CodeZip"; -} - -function resolveDockerfilePlan( - spec: HarnessSpec, - buildType: BuildType, - targetAgentName: string, - harnessDockerfileExists: boolean, - notes: ExportNote[], -): DockerfilePlan { - if (buildType !== "Container") return { source: "none" }; - if (spec.containerUri) { - notes.push({ - category: CONTAINER_URI_NOTE_CATEGORY, - message: - `The harness used a pre-built container image as its execution environment ` + - `(${spec.containerUri}). The generated Dockerfile extends that image directly ` + - `(FROM ) and layers the Strands agent code on top. If your base image does ` + - `not include Python 3.12+ or uv, add an install step before the \`uv sync\` steps. If ` + - `the base image is a private ECR repository, also grant the CodeBuild project that ` + - `builds this agent permission to pull it.`, - }); - return { source: "stub", containerUri: spec.containerUri }; - } - if (spec.dockerfile) { - if (!harnessDockerfileExists) { - notes.push({ - category: MISSING_DOCKERFILE_NOTE_CATEGORY, - message: - `The harness declares a custom Dockerfile, but no Dockerfile was found in its ` + - `directory, so nothing was copied. Create app/${targetAgentName}/Dockerfile ` + - `(including the Strands agent build layer) before \`agentcore project deploy\`.`, - }); - return { source: "none" }; - } - notes.push({ - category: CUSTOM_DOCKERFILE_NOTE_CATEGORY, - message: - `The harness used a custom Dockerfile that describes its execution environment. It has ` + - `been copied to app/${targetAgentName}/Dockerfile unchanged, but the exported agent will ` + - `NOT run as-is: a harness Dockerfile has no dependency install, code copy, or startup ` + - `command (the harness runtime supplied those). Append the Strands agent build layer ` + - `before \`agentcore project deploy\` (adjust if your base image is not Python 3.12+/uv):\n\n` + - ` WORKDIR /app\n` + - ` RUN pip install --no-cache-dir uv\n` + - ` COPY pyproject.toml uv.lock ./\n` + - ` RUN uv sync --frozen --no-dev --no-install-project\n` + - ` COPY . .\n` + - ` RUN uv sync --frozen --no-dev\n` + - ` EXPOSE 8080\n` + - ` CMD ["opentelemetry-instrument", "python", "-m", "main"]`, - }); - return { source: "harnessCopy" }; - } - return { source: "template" }; -} - -/** Dockerfile stub for a containerUri harness: extend the image, layer the agent on top. */ -export function buildDockerfileStub(containerUri: string): string { - return [ - `# Base image from the source harness: ${containerUri}`, - "# The generated Strands agent is layered on top. If the base image does not", - "# include Python 3.12+ or uv, add install steps before the COPY/RUN below.", - `FROM ${containerUri}`, - "", - "RUN pip install --no-cache-dir uv", - "", - "WORKDIR /app", - "", - "ENV UV_SYSTEM_PYTHON=1 \\", - " UV_COMPILE_BYTECODE=1 \\", - " UV_NO_PROGRESS=1 \\", - " PYTHONUNBUFFERED=1 \\", - ' PATH="/app/.venv/bin:$PATH"', - "", - "COPY pyproject.toml uv.lock ./", - "RUN uv sync --frozen --no-dev --no-install-project", - "", - "COPY . .", - "RUN uv sync --frozen --no-dev", - "", - "EXPOSE 8080 8000 9000", - "", - 'CMD ["opentelemetry-instrument", "python", "-m", "main"]', - "", - ].join("\n"); -} - // ============================================================================ // Filesystem mounts // ============================================================================ diff --git a/src/core/project/templates/runtime.ts b/src/core/project/templates/runtime.ts index ed9ad0d8f..7855c697d 100644 --- a/src/core/project/templates/runtime.ts +++ b/src/core/project/templates/runtime.ts @@ -169,10 +169,6 @@ const getTemplateResolvers = (assetSource: AssetSource, templateRenderer: Templa transformContent: (raw) => templateRenderer.render(raw, context), filter: (name, isDir) => { if (isDir && name === "memory") return memory !== undefined; - // hooks/ carries the execution-limits capability, which only - // `project export harness` renders (harnesses can cap - // iterations/tokens/time; scaffolded runtimes cannot). - if (isDir && name === "hooks") return false; if (name === "Dockerfile" || name === ".dockerignore") return isContainer; return true; }, diff --git a/src/handlers/project/export/harness.test.ts b/src/handlers/project/export/harness.test.ts index a34e1e4ee..c3e2f9000 100644 --- a/src/handlers/project/export/harness.test.ts +++ b/src/handlers/project/export/harness.test.ts @@ -106,9 +106,9 @@ describe("project export harness handler", () => { expect(await Bun.file(join(agentDir, "main.py")).text()).toContain( 'DEFAULT_SYSTEM_PROMPT = """You are a terse assistant."""', ); - expect(await Bun.file(join(agentDir, "model", "load.py")).text()).toContain( - 'BedrockModel(model_id="us.amazon.nova-lite-v1:0", max_tokens=256)', - ); + const loadModel = await Bun.file(join(agentDir, "model", "load.py")).text(); + expect(loadModel).toContain('model_id="us.amazon.nova-lite-v1:0"'); + expect(loadModel).toContain("max_tokens=256"); expect(await Bun.file(join(agentDir, "EXPORT_NOTES.md")).text()).toContain( "# Export Notes — exportme → exportmeAgent", ); @@ -248,6 +248,60 @@ describe("project export harness handler", () => { expect(existsSync(join(projectRoot, "app", "remote_harnessAgent", "main.py"))).toBe(true); }); + /** A container harness in VPC mode, whose service VpcConfig carries no vpcId (the API has none). */ + function setVpcContainerHarness(subject: ReturnType) { + subject.core.harness.setGetResponse({ + harness: { + harnessName: "remote_container", + model: { bedrockModelConfig: { modelId: "us.amazon.nova-lite-v1:0" } }, + environmentArtifact: { + containerConfiguration: { + containerUri: "111122223333.dkr.ecr.us-west-2.amazonaws.com/base:latest", + }, + }, + environment: { + agentCoreRuntimeEnvironment: { + networkConfiguration: { + networkMode: "VPC", + networkModeConfig: { + subnets: ["subnet-0123456789abcdef0"], + securityGroups: ["sg-0123456789abcdef0"], + }, + }, + }, + }, + }, + } as never); + } + + // A container harness in a VPC exports as CodeZip: no image build, so no CodeBuild and no vpcId + // to supply. The service's subnets and security groups still carry over verbatim. + test("exports a VPC container harness as CodeZip without additional lookups", async () => { + const subject = testExportCommand(); + const projectRoot = await inProjectWithHarness(subject); + setVpcContainerHarness(subject); + + await subject.run(["--arn", HARNESS_ARN]); + + expect(subject.core.harness.calls).toEqual([ + { + method: "getHarness", + args: ["h-abc123", expect.objectContaining({ region: "us-west-2" })], + }, + ]); + const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + const runtime = spec.runtimes.find( + (candidate: { name: string }) => candidate.name === "remote_containerAgent", + ); + expect(runtime.build).toBe("CodeZip"); + expect(runtime.dockerfile).toBeUndefined(); + expect(runtime.networkConfig).toEqual({ + subnets: ["subnet-0123456789abcdef0"], + securityGroups: ["sg-0123456789abcdef0"], + }); + expect(existsSync(join(projectRoot, "app", "remote_containerAgent", "Dockerfile"))).toBe(false); + }); + test("validates the project before fetching from the service", async () => { const subject = testExportCommand(); await inTempDirectory(); // not a project @@ -264,5 +318,10 @@ describe("project export harness handler", () => { /not a valid harness ARN/, ); expect(subject.core.harness.calls).toEqual([]); + + await expect( + subject.run(["--arn", "arn:aws:lambda:us-west-2:111122223333:harness/h-abc123"]), + ).rejects.toThrow(/not a valid harness ARN/); + expect(subject.core.harness.calls).toEqual([]); }); }); diff --git a/src/handlers/project/export/harness.ts b/src/handlers/project/export/harness.ts index 9a2403b30..882c550df 100644 --- a/src/handlers/project/export/harness.ts +++ b/src/handlers/project/export/harness.ts @@ -3,7 +3,7 @@ import { InputValidationError } from "../../../errors"; import { createHandler, flag, ProjectKey } from "../../../router"; import { JsonRendererKey } from "../../../tui"; import { JsonKey } from "../../keys"; -import { AgentNameSchema, BuildTypeSchema } from "../../../projectSchemas/runtime"; +import { AgentNameSchema } from "../../../projectSchemas/runtime"; import { formatExportNotes } from "../../../core/project/templates/export"; import { coreOptsFromCtx } from "../../utils"; import type { ExportHarnessInput } from "../types"; @@ -26,11 +26,6 @@ export const createExportHarnessHandler = (config: ExportProjectResourceConfig) "the name of the generated runtime agent (default: Agent)", z.string().optional(), ), - flag( - "build", - "build type for the exported agent: CodeZip or Container", - BuildTypeSchema.optional(), - ), ], handle: async (ctx, flags) => { if (!!flags.name === !!flags.arn) { @@ -48,25 +43,23 @@ export const createExportHarnessHandler = (config: ExportProjectResourceConfig) if (flags.arn) { config.io.stderr.write(`Fetching harness from the service\n`); const harnessId = harnessIdFromArn(flags.arn); - // The ARN names the region the harness lives in; fall back to the CLI's - // resolved region only when the ARN carries none. + // The ARN names the region the harness lives in and takes precedence over + // the CLI's resolved region, so service fetches never drift to ambient config. const coreOpts = coreOptsFromCtx(ctx); - const region = regionFromHarnessArn(flags.arn) ?? coreOpts.region; + const region = regionFromHarnessArn(flags.arn); const response = await config.core.harness.getHarness(harnessId, { ...coreOpts, region }); if (!response.harness) { throw new InputValidationError(`the service returned no harness for "${flags.arn}"`); } - const { spec, systemPrompt } = mapServiceHarnessToSpec(response.harness); + const { spec, systemPrompt, notes } = mapServiceHarnessToSpec(response.harness); input = { - prefetched: { spec, systemPrompt }, + prefetched: { spec, systemPrompt, notes }, targetAgentName: resolveTargetAgentName(flags["target-agent-name"], spec.name), - build: flags.build, }; } else { input = { harnessName: flags.name!, targetAgentName: resolveTargetAgentName(flags["target-agent-name"], flags.name!), - build: flags.build, }; } diff --git a/src/handlers/project/export/serviceHarness.test.ts b/src/handlers/project/export/serviceHarness.test.ts index dedaae749..5f2e0d1f4 100644 --- a/src/handlers/project/export/serviceHarness.test.ts +++ b/src/handlers/project/export/serviceHarness.test.ts @@ -1,7 +1,13 @@ import { describe, expect, test } from "bun:test"; import type { Harness } from "@aws-sdk/client-bedrock-agentcore-control"; import { InputValidationError, MalformedServiceResponseError } from "../../../errors"; -import { harnessIdFromArn, mapServiceHarnessToSpec, regionFromHarnessArn } from "./serviceHarness"; +import { + MEMORY_TUNING_NOTE_CATEGORY, + SERVICE_FIELD_OMITTED_NOTE_CATEGORY, + harnessIdFromArn, + mapServiceHarnessToSpec, + regionFromHarnessArn, +} from "./serviceHarness"; const ARN = "arn:aws:bedrock-agentcore:us-west-2:111122223333:harness/h-abc123"; @@ -37,9 +43,20 @@ describe("harness ARN helpers", () => { expect(regionFromHarnessArn(ARN)).toBe("us-west-2"); }); - test("rejects a malformed harness ARN and tolerates a missing region", () => { + test("accepts other AWS partitions and rejects malformed or wrong-service ARNs", () => { + const chinaArn = "arn:aws-cn:bedrock-agentcore:cn-north-1:111122223333:harness/h-abc123"; + expect(harnessIdFromArn(chinaArn)).toBe("h-abc123"); + expect(regionFromHarnessArn(chinaArn)).toBe("cn-north-1"); expect(() => harnessIdFromArn("arn:aws:foo:bar")).toThrow(InputValidationError); - expect(regionFromHarnessArn("not-an-arn")).toBeUndefined(); + expect(() => + harnessIdFromArn("arn:aws:lambda:us-east-1:111122223333:harness/h-abc123"), + ).toThrow(InputValidationError); + expect(() => + harnessIdFromArn("arn:aws:bedrock-agentcore::111122223333:harness/h-abc123"), + ).toThrow(InputValidationError); + expect(() => + harnessIdFromArn("arn:aws:bedrock-agentcore:us-west-2:12345:harness/h-abc123"), + ).toThrow(InputValidationError); }); }); @@ -85,8 +102,28 @@ describe("mapServiceHarnessToSpec", () => { expect(spec.executionRoleArn).toBeUndefined(); }); - test("maps every skill source variant and drops unknown members", () => { - const { spec } = mapServiceHarnessToSpec( + test("notes unknown system prompt blocks while preserving recognized text", () => { + const { systemPrompt, notes } = mapServiceHarnessToSpec( + serviceHarness({ + systemPrompt: [ + { text: "Be terse." }, + { $unknown: ["futurePrompt", {}] }, + ] as Harness["systemPrompt"], + }), + ); + + expect(systemPrompt).toBe("Be terse."); + expect(notes).toEqual([ + { + category: SERVICE_FIELD_OMITTED_NOTE_CATEGORY, + message: + 'A system prompt block of type "futurePrompt" was omitted because its service payload was unknown or incomplete.', + }, + ]); + }); + + test("maps every skill source variant and notes unknown members", () => { + const { spec, notes } = mapServiceHarnessToSpec( serviceHarness({ skills: [ { path: "local_skill" }, @@ -122,6 +159,7 @@ describe("mapServiceHarnessToSpec", () => { }, { awsSkills: { paths: ["aws/foo"] } }, ]); + expect(notes.map((note) => note.category)).toEqual([SERVICE_FIELD_OMITTED_NOTE_CATEGORY]); }); test("maps tools by passing their config through", () => { @@ -222,6 +260,65 @@ describe("mapServiceHarnessToSpec", () => { ]); }); + test("notes incomplete filesystem members instead of silently dropping them", () => { + const { spec, notes } = mapServiceHarnessToSpec( + serviceHarness({ + environment: { + agentCoreRuntimeEnvironment: { + filesystemConfigurations: [ + { efsAccessPoint: { mountPath: "/mnt/incomplete" } }, + { $unknown: ["futureFilesystem", {}] }, + ], + }, + }, + } as Partial), + ); + + expect(spec.efsAccessPoints).toBeUndefined(); + expect(notes.map((note) => note.category)).toEqual([ + SERVICE_FIELD_OMITTED_NOTE_CATEGORY, + SERVICE_FIELD_OMITTED_NOTE_CATEGORY, + ]); + }); + + // The pinned CDK only maps additionalParams for lite_llm, so carrying it on another provider + // would produce a harness.json that fails at synth. The lite_llm keep-path is already asserted + // by "maps openai and litellm model configs" above. + test("notes additionalParams the CDK cannot map", () => { + const { spec, notes } = mapServiceHarnessToSpec( + serviceHarness({ + model: { + bedrockModelConfig: { + modelId: "us.amazon.nova-lite-v1:0", + additionalParams: { custom_parameter: true }, + }, + }, + } as Partial), + ); + + expect(spec.model.additionalParams).toBeUndefined(); + expect(notes.map((note) => note.category)).toEqual([SERVICE_FIELD_OMITTED_NOTE_CATEGORY]); + }); + + test("notes external-memory tuning that cannot be wired automatically", () => { + const { spec, notes } = mapServiceHarnessToSpec( + serviceHarness({ + memory: { + agentCoreMemoryConfiguration: { + arn: "arn:aws:bedrock-agentcore:us-west-2:111122223333:memory/m-1", + messagesCount: 12, + retrievalConfig: { + "/users/{actorId}/facts": { topK: 8, relevanceScore: 0.7 }, + }, + }, + }, + } as Partial), + ); + + expect(spec.memory).toMatchObject({ mode: "existing", messagesCount: 12 }); + expect(notes.map((note) => note.category)).toEqual([MEMORY_TUNING_NOTE_CATEGORY]); + }); + test("rejects a VPC harness without explicit subnets/security groups before anything is written", () => { expect(() => mapServiceHarnessToSpec( diff --git a/src/handlers/project/export/serviceHarness.ts b/src/handlers/project/export/serviceHarness.ts index 1aeabab20..79a333d79 100644 --- a/src/handlers/project/export/serviceHarness.ts +++ b/src/handlers/project/export/serviceHarness.ts @@ -5,26 +5,34 @@ import type { import z from "zod"; import { InputValidationError, MalformedServiceResponseError } from "../../../errors"; import { HarnessSpecSchema, type HarnessSpec } from "../../../projectSchemas/harness"; +import type { ExportNote } from "../../../core/project/templates/export"; -/** Extract the harness id from a harness ARN (`.../harness/` -> ``). */ -export function harnessIdFromArn(arn: string): string { - const match = /:harness\/([^/]+)$/.exec(arn); - if (!match?.[1]) { +export const SERVICE_FIELD_OMITTED_NOTE_CATEGORY = "Service harness field not exported"; +export const MEMORY_TUNING_NOTE_CATEGORY = "Harness memory tuning requires manual follow-up"; + +function parseHarnessArn(arn: string): { region: string; harnessId: string } { + const match = /^arn:[^:]+:bedrock-agentcore:([a-z0-9-]+):(\d{12}):harness\/([^/]+)$/.exec(arn); + if (!match?.[1] || !match[2] || !match[3]) { throw new InputValidationError( - `"${arn}" is not a valid harness ARN (expected ...:harness/)`, + `"${arn}" is not a valid harness ARN ` + + "(expected arn::bedrock-agentcore:::harness/)", ); } - return match[1]; + return { region: match[1], harnessId: match[3] }; +} + +/** Extract the harness id from a validated harness ARN. */ +export function harnessIdFromArn(arn: string): string { + return parseHarnessArn(arn).harnessId; } /** - * The region embedded in a harness ARN (`arn::bedrock-agentcore::...`), - * or undefined when the ARN carries none. The harness lives in this region, so - * it takes precedence over the CLI's resolved region for the export fetch. + * The region embedded in a harness ARN (`arn::bedrock-agentcore::...`). + * The harness lives in this region, so it takes precedence over the CLI's resolved + * region for the export fetch. */ -export function regionFromHarnessArn(arn: string): string | undefined { - const match = /^arn:[^:]+:bedrock-agentcore:([a-z0-9-]+):/.exec(arn); - return match?.[1] || undefined; +export function regionFromHarnessArn(arn: string): string { + return parseHarnessArn(arn).region; } /** @@ -36,16 +44,29 @@ export function regionFromHarnessArn(arn: string): string | undefined { export function mapServiceHarnessToSpec(harness: Harness): { spec: HarnessSpec; systemPrompt?: string; + notes: ExportNote[]; } { - const joinedPrompt = (harness.systemPrompt ?? []) + const notes: ExportNote[] = []; + const promptBlocks = harness.systemPrompt ?? []; + const joinedPrompt = promptBlocks .map((block) => ("text" in block ? block.text : undefined)) .filter((text): text is string => typeof text === "string" && text.length > 0) .join("\n"); const systemPrompt = joinedPrompt.length > 0 ? joinedPrompt : undefined; + for (const block of promptBlocks) { + if ("text" in block && typeof block.text === "string" && block.text.length > 0) continue; + const unknown = unknownMemberName(block); + notes.push({ + category: SERVICE_FIELD_OMITTED_NOTE_CATEGORY, + message: + `A system prompt block${unknown ? ` of type "${unknown}"` : ""} was omitted because ` + + "its service payload was unknown or incomplete.", + }); + } const candidate = clean({ name: harness.harnessName, - model: mapModel(harness.model), + model: mapModel(harness.model, notes), tools: (harness.tools ?? []).map((tool) => clean({ type: tool.type, @@ -53,18 +74,20 @@ export function mapServiceHarnessToSpec(harness: Harness): { config: tool.config, }), ), - skills: (harness.skills ?? []).map(mapSkill).filter((skill) => skill !== undefined), + skills: (harness.skills ?? []) + .map((skill) => mapSkill(skill, notes)) + .filter((skill) => skill !== undefined), allowedTools: harness.allowedTools, - memory: mapMemory(harness.memory), + memory: mapMemory(harness.memory, notes), maxIterations: harness.maxIterations ?? undefined, maxTokens: harness.maxTokens ?? undefined, timeoutSeconds: harness.timeoutSeconds ?? undefined, truncation: harness.truncation, - containerUri: harness.environmentArtifact?.containerConfiguration?.containerUri, + containerUri: mapContainerUri(harness.environmentArtifact, notes), environmentVariables: harness.environmentVariables, // The harness's executionRoleArn is deliberately NOT carried: the exported // agent is a new runtime that gets its own CDK-managed execution role. - ...mapRuntimeEnvironment(harness), + ...mapRuntimeEnvironment(harness, notes), }); const parsed = HarnessSpecSchema.safeParse(candidate); @@ -74,10 +97,10 @@ export function mapServiceHarnessToSpec(harness: Harness): { { cause: parsed.error }, ); } - return { spec: parsed.data, systemPrompt }; + return { spec: parsed.data, systemPrompt, notes }; } -function mapModel(model: Harness["model"]): Record { +function mapModel(model: Harness["model"], notes: ExportNote[]): Record { if (model?.bedrockModelConfig) { const c = model.bedrockModelConfig; return clean({ @@ -87,6 +110,7 @@ function mapModel(model: Harness["model"]): Record { temperature: c.temperature, topP: c.topP, maxTokens: c.maxTokens, + additionalParams: omitUnsupportedAdditionalParams("bedrock", c.additionalParams, notes), }); } if (model?.openAiModelConfig) { @@ -99,6 +123,7 @@ function mapModel(model: Harness["model"]): Record { temperature: c.temperature, topP: c.topP, maxTokens: c.maxTokens, + additionalParams: omitUnsupportedAdditionalParams("open_ai", c.additionalParams, notes), }); } if (model?.geminiModelConfig) { @@ -111,6 +136,7 @@ function mapModel(model: Harness["model"]): Record { topP: c.topP, topK: c.topK, maxTokens: c.maxTokens, + additionalParams: omitUnsupportedAdditionalParams("gemini", c.additionalParams, notes), }); } if (model?.liteLlmModelConfig) { @@ -131,8 +157,32 @@ function mapModel(model: Harness["model"]): Record { ); } -/** Service skill union -> the flat local skill shape; unknown members are dropped. */ -function mapSkill(skill: ApiHarnessSkill): Record | undefined { +/** + * Only lite_llm carries additionalParams through to CFN — the CDK's harness schema rejects the + * field on every other provider, so mapping it verbatim would produce a spec that fails at synth. + * Drop it with a note instead of writing an undeployable harness. + */ +function omitUnsupportedAdditionalParams( + provider: "bedrock" | "open_ai" | "gemini", + value: unknown, + notes: ExportNote[], +): undefined { + if (value === undefined) return undefined; + notes.push({ + category: SERVICE_FIELD_OMITTED_NOTE_CATEGORY, + message: + `The harness model's additionalParams were omitted because they are only supported for ` + + `the "lite_llm" provider (this harness uses "${provider}"). Set the equivalent options ` + + `directly in the generated model/load.py if the exported agent needs them.`, + }); + return undefined; +} + +/** Service skill union -> the flat local skill shape. */ +function mapSkill( + skill: ApiHarnessSkill, + notes: ExportNote[], +): Record | undefined { if ("path" in skill && skill.path) return { path: skill.path }; if ("s3" in skill && skill.s3?.uri) return { s3Uri: skill.s3.uri }; if ("git" in skill && skill.git?.url) { @@ -148,6 +198,13 @@ function mapSkill(skill: ApiHarnessSkill): Record | undefined { if ("awsSkills" in skill && skill.awsSkills) { return { awsSkills: clean({ paths: skill.awsSkills.paths }) }; } + const unknown = unknownMemberName(skill); + notes.push({ + category: SERVICE_FIELD_OMITTED_NOTE_CATEGORY, + message: + `A harness skill${unknown ? ` of type "${unknown}"` : ""} was omitted because ` + + "its service payload was unknown or incomplete.", + }); return undefined; } @@ -157,10 +214,22 @@ function mapSkill(skill: ApiHarnessSkill): Record | undefined { * bring-your-own memory; managed-without-ARN keeps the `managed` marker so the * export mapper can emit its follow-up note. */ -function mapMemory(memory: Harness["memory"]): Record | undefined { +function mapMemory( + memory: Harness["memory"], + notes: ExportNote[], +): Record | undefined { if (!memory) return undefined; if ("agentCoreMemoryConfiguration" in memory && memory.agentCoreMemoryConfiguration?.arn) { - const { arn, actorId, messagesCount } = memory.agentCoreMemoryConfiguration; + const { arn, actorId, messagesCount, retrievalConfig } = memory.agentCoreMemoryConfiguration; + if (messagesCount !== undefined || retrievalConfig !== undefined) { + notes.push({ + category: MEMORY_TUNING_NOTE_CATEGORY, + message: + `The service harness configured external memory${messagesCount !== undefined ? ` messagesCount=${messagesCount}` : ""}` + + `${retrievalConfig !== undefined ? " with per-namespace retrieval tuning" : ""}. ` + + "The exported runtime cannot apply those settings until the external memory is wired manually.", + }); + } return clean({ mode: "existing", arn, actorId, messagesCount }); } if ("managedMemoryConfiguration" in memory && memory.managedMemoryConfiguration) { @@ -169,6 +238,13 @@ function mapMemory(memory: Harness["memory"]): Record | undefin return { mode: "managed" }; } if ("disabled" in memory && memory.disabled) return { mode: "disabled" }; + const unknown = unknownMemberName(memory); + notes.push({ + category: SERVICE_FIELD_OMITTED_NOTE_CATEGORY, + message: + `The harness memory configuration${unknown ? ` of type "${unknown}"` : ""} was omitted because ` + + "the service payload was unknown or incomplete.", + }); return undefined; } @@ -178,11 +254,18 @@ function mapMemory(memory: Harness["memory"]): Record | undefin * cannot be expressed locally; fail here — before anything is written — with a * clear message instead of a downstream schema error. */ -function mapRuntimeEnvironment(harness: Harness): Record { - const env = - harness.environment && "agentCoreRuntimeEnvironment" in harness.environment - ? harness.environment.agentCoreRuntimeEnvironment - : undefined; +function mapRuntimeEnvironment(harness: Harness, notes: ExportNote[]): Record { + if (harness.environment && !("agentCoreRuntimeEnvironment" in harness.environment)) { + const unknown = unknownMemberName(harness.environment); + notes.push({ + category: SERVICE_FIELD_OMITTED_NOTE_CATEGORY, + message: + `The harness environment${unknown ? ` of type "${unknown}"` : ""} was omitted because ` + + "the service payload is not an AgentCore Runtime environment.", + }); + return {}; + } + const env = harness.environment?.agentCoreRuntimeEnvironment; if (!env) return {}; const out: Record = {}; @@ -232,6 +315,14 @@ function mapRuntimeEnvironment(harness: Harness): Record { accessPointArn: fs.s3FilesAccessPoint.accessPointArn, mountPath: fs.s3FilesAccessPoint.mountPath, }); + } else { + const unknown = unknownMemberName(fs); + notes.push({ + category: SERVICE_FIELD_OMITTED_NOTE_CATEGORY, + message: + `A filesystem configuration${unknown ? ` of type "${unknown}"` : ""} was omitted because ` + + "its service payload was unknown or incomplete.", + }); } } if (efs.length) out.efsAccessPoints = efs; @@ -240,6 +331,30 @@ function mapRuntimeEnvironment(harness: Harness): Record { return out; } +function mapContainerUri( + artifact: Harness["environmentArtifact"], + notes: ExportNote[], +): string | undefined { + if (!artifact) return undefined; + if ("containerConfiguration" in artifact) { + return artifact.containerConfiguration?.containerUri; + } + const unknown = unknownMemberName(artifact); + notes.push({ + category: SERVICE_FIELD_OMITTED_NOTE_CATEGORY, + message: + `The harness environment artifact${unknown ? ` of type "${unknown}"` : ""} was omitted because ` + + "the service payload is not a container configuration.", + }); + return undefined; +} + +function unknownMemberName(value: unknown): string | undefined { + if (!value || typeof value !== "object" || !("$unknown" in value)) return undefined; + const unknown = (value as { $unknown?: unknown }).$unknown; + return Array.isArray(unknown) && typeof unknown[0] === "string" ? unknown[0] : undefined; +} + /** Drop undefined-valued keys so optional fields stay omitted. */ function clean>(obj: T): T { return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as T; diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 59f97a578..1b6afed07 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -1,5 +1,4 @@ import { HarnessSpecSchema } from "../../projectSchemas/harness"; -import type { BuildType } from "../../projectSchemas/runtime"; import type { ExportNote } from "../../core/project/templates/export"; import type { CredentialSchema } from "../../projectSchemas/credential"; import type { PaymentConnectorSchema, PaymentManagerSchema } from "../../projectSchemas/payment"; @@ -278,11 +277,10 @@ export type ExportHarnessInput = { prefetched?: { spec: z.output; systemPrompt?: string; + notes?: ExportNote[]; }; /** Name of the runtime agent to generate. */ targetAgentName: string; - /** Build override; when absent the harness spec decides (CodeZip unless it demands Container). */ - build?: BuildType; }; /** Result of {@link ProjectManager.exportHarness}. */ diff --git a/src/projectSchemas/harness.test.ts b/src/projectSchemas/harness.test.ts index 66b7050f7..b830337aa 100644 --- a/src/projectSchemas/harness.test.ts +++ b/src/projectSchemas/harness.test.ts @@ -41,6 +41,30 @@ describe("harness custom validation", () => { }).success, ).toBe(false); }); + // The pinned @aws/agentcore-cdk rejects additionalParams on every provider but lite_llm, and + // re-parses harness.json at synth — so accepting it here would defer the failure to + // `project build` instead of surfacing it at authoring time. + it("accepts additional parameters only for the lite_llm provider", () => { + expect( + HarnessModelSchema.safeParse({ + provider: "lite_llm", + modelId: "bedrock/model", + additionalParams: { custom_parameter: true }, + }).success, + ).toBe(true); + for (const model of [ + { provider: "bedrock", modelId: "model" }, + { provider: "open_ai", modelId: "gpt", apiKeyArn: "arn:key" }, + { provider: "gemini", modelId: "gemini", apiKeyArn: "arn:key" }, + ]) { + expect( + HarnessModelSchema.safeParse({ + ...model, + additionalParams: { custom_parameter: true }, + }).success, + ).toBe(false); + } + }); it("validates provider-specific API formats through the shared helper", () => { expect(validateApiFormat("responses", "open_ai")).toEqual({ valid: true }); expect(validateApiFormat("converse_stream", "open_ai").valid).toBe(false); diff --git a/src/projectSchemas/runtime.test.ts b/src/projectSchemas/runtime.test.ts index d752c2f4d..967991cb1 100644 --- a/src/projectSchemas/runtime.test.ts +++ b/src/projectSchemas/runtime.test.ts @@ -144,9 +144,28 @@ describe("runtime custom validation", () => { ); const vpc = { networkMode: "VPC" as const, - networkConfig: { ...networkConfig, securityGroups }, + networkConfig: { ...networkConfig, securityGroups, vpcId: "vpc-0123456789abcdef0" }, }; - expect(ProjectRuntimeSchema.safeParse({ ...containerAgent, ...vpc }).success).toBe(false); + const capped = ProjectRuntimeSchema.safeParse({ ...containerAgent, ...vpc }); + expect(capped.success).toBe(false); + expect(capped.error?.issues[0]?.path).toEqual(["networkConfig", "securityGroups"]); expect(ProjectRuntimeSchema.safeParse({ ...codeZipAgent, ...vpc }).success).toBe(true); }); + it("requires a VPC ID for container builds in VPC mode only", () => { + const vpc = { networkMode: "VPC" as const, networkConfig }; + const missing = ProjectRuntimeSchema.safeParse({ ...containerAgent, ...vpc }); + expect(missing.success).toBe(false); + expect(missing.error?.issues[0]?.path).toEqual(["networkConfig", "vpcId"]); + expect( + ProjectRuntimeSchema.safeParse({ + ...containerAgent, + networkMode: "VPC", + networkConfig: { ...networkConfig, vpcId: "vpc-0123456789abcdef0" }, + }).success, + ).toBe(true); + + // CodeZip never reaches CodeBuild, so it needs no VPC ID. + expect(ProjectRuntimeSchema.safeParse({ ...codeZipAgent, ...vpc }).success).toBe(true); + expect(ProjectRuntimeSchema.safeParse(containerAgent).success).toBe(true); + }); }); diff --git a/src/projectSchemas/runtime.ts b/src/projectSchemas/runtime.ts index 87f8836ec..d5bd8e2e5 100644 --- a/src/projectSchemas/runtime.ts +++ b/src/projectSchemas/runtime.ts @@ -290,6 +290,16 @@ export const ProjectRuntimeSchema = z path: ["networkConfig", "securityGroups"], }); } + // Mirrors the CDK, which feeds networkConfig.vpcId to the CodeBuild project's + // VpcConfig. Only `build: "Container"` reaches that path, so CodeZip is exempt. + if (data.networkMode === "VPC" && data.build === "Container" && !data.networkConfig?.vpcId) { + ctx.addIssue({ + code: "custom", + message: + "networkConfig.vpcId is required for Container builds in VPC mode (CodeBuild cannot infer the VPC from subnets)", + path: ["networkConfig", "vpcId"], + }); + } if ( data.authorizerType === "CUSTOM_JWT" && !data.authorizerConfiguration?.customJwtAuthorizer