diff --git a/README.md b/README.md index 84f21fcb5..091d86f63 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,9 @@ 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. +A Container build in VPC mode also needs `--vpc-id `: the export layers +the agent onto the harness image with a generated Dockerfile, and the CodeBuild +project that builds it cannot infer the VPC from subnets alone. Global flags (declared at the root, available on every command): 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/main.py b/src/assets/templates/strands-http-python/main.py index 69dca7e8e..ed5b0b792 100644 --- a/src/assets/templates/strands-http-python/main.py +++ b/src/assets/templates/strands-http-python/main.py @@ -17,23 +17,21 @@ {{/if}} {{/if}} import asyncio +{{#if timeoutSeconds}} +import threading +{{/if}} {{#if hasShell}} import subprocess {{/if}} {{#if hasFileOperations}} import os {{/if}} -{{#if hasExecutionLimits}} -from strands.tools.executors import SequentialToolExecutor -from strands.types.exceptions import EventLoopException -from hooks.execution_limits import ExecutionLimitExceeded, ExecutionLimitsHook -{{/if}} {{#if hasConfigBundle}} from strands.hooks import HookProvider, HookRegistry, BeforeInvocationEvent, BeforeToolCallEvent {{/if}} {{#if truncationStrategy}} {{#if (eq truncationStrategy "sliding_window")}} -from strands.agent.conversation_manager.sliding_window_conversation_manager import SlidingWindowConversationManager +from strands.agent.conversation_manager import SlidingWindowConversationManager {{/if}} {{#if (eq truncationStrategy "summarization")}} from strands.agent.conversation_manager.summarizing_conversation_manager import SummarizingConversationManager @@ -413,18 +411,7 @@ def get_or_create_agent(session_id, user_id{{#if hasSkillsFetcher}}, skill_plugi {{#if hasSkillsFetcher}} plugins=skill_plugins or None, {{/if}} - {{#if hasExecutionLimits}} - tool_executor=SequentialToolExecutor(), - callback_handler=None, - {{/if}} hooks=[ - {{#if hasExecutionLimits}} - ExecutionLimitsHook( - {{#if maxIterations}}max_iterations={{maxIterations}},{{/if}} - {{#if maxTokens}}max_tokens={{maxTokens}},{{/if}} - {{#if timeoutSeconds}}timeout_seconds={{timeoutSeconds}},{{/if}} - ), - {{/if}} {{#if hasConfigBundle}} ConfigBundleHook(), {{/if}} @@ -457,18 +444,7 @@ def get_or_create_agent(session_id{{#if hasSkillsFetcher}}, skill_plugins=None{{ {{#if hasSkillsFetcher}} plugins=skill_plugins or None, {{/if}} - {{#if hasExecutionLimits}} - tool_executor=SequentialToolExecutor(), - callback_handler=None, - {{/if}} hooks=[ - {{#if hasExecutionLimits}} - ExecutionLimitsHook( - {{#if maxIterations}}max_iterations={{maxIterations}},{{/if}} - {{#if maxTokens}}max_tokens={{maxTokens}},{{/if}} - {{#if timeoutSeconds}}timeout_seconds={{timeoutSeconds}},{{/if}} - ), - {{/if}} {{#if hasConfigBundle}} ConfigBundleHook(), {{/if}} @@ -639,24 +615,36 @@ async def invoke(payload, context): {{/if}} {{#if hasExecutionLimits}} - timeout_seconds = {{#if timeoutSeconds}}{{timeoutSeconds}}{{else}}None{{/if}} + 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 timeout_seconds is not None: + {{#if timeoutSeconds}} + if cancel_signal is not None: async def _timeout_watchdog(): nonlocal timeout_fired - await asyncio.sleep(timeout_seconds) + await asyncio.sleep({{timeoutSeconds}}) timeout_fired = True - agent.cancel() + 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") @@ -674,11 +662,14 @@ async def _timeout_watchdog(): if timeout_fired: yield {"event": {"messageStop": {"stopReason": "timeout_exceeded"}}} - except EventLoopException as e: - if isinstance(e.original_exception, ExecutionLimitExceeded): - yield {"event": {"messageStop": {"stopReason": str(e.original_exception)}}} - return - raise + {{#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() diff --git a/src/assets/templates/strands-http-python/mcp_client/client.py b/src/assets/templates/strands-http-python/mcp_client/client.py index 4de07e43a..9cf57422d 100644 --- a/src/assets/templates/strands-http-python/mcp_client/client.py +++ b/src/assets/templates/strands-http-python/mcp_client/client.py @@ -69,21 +69,24 @@ def get_all_gateway_mcp_clients() -> list[MCPClient]: {{#if headerCredentials}} {{#each headerCredentials}} @requires_api_key(provider_name="{{credentialName}}") -def _get_{{snakeCase ../name}}_{{snakeCase headerKey}}_key(api_key: str) -> str: +def _get_{{pythonName}}_key(api_key: str) -> str: """Fetch {{headerKey}} credential for {{../name}} from AgentCore Identity.""" return api_key {{/each}} {{/if}} -def get_{{snakeCase name}}_mcp_client() -> MCPClient | None: +def get_{{pythonName}}_mcp_client() -> MCPClient | None: """Returns an MCP Client for the {{name}} remote MCP server.""" url = {{safeJson url}} {{#if headerCredentials}} - if os.getenv("LOCAL_DEV") == "1": - headers = { {{#each headerCredentials}}{{safeJson headerKey}}: os.environ.get("{{envVarName}}", ""){{#unless @last}}, {{/unless}}{{/each}} } - else: - headers = { {{#each headerCredentials}}{{safeJson headerKey}}: _get_{{snakeCase ../name}}_{{snakeCase headerKey}}_key(){{#unless @last}}, {{/unless}}{{/each}} } - return MCPClient(lambda: streamablehttp_client(url, headers=headers)) + 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}} @@ -91,7 +94,7 @@ def get_{{snakeCase name}}_mcp_client() -> MCPClient | None: {{/each}} def get_all_remote_mcp_clients() -> list[MCPClient]: """Returns all configured remote MCP clients.""" - clients = [{{#each remoteMcpTools}}get_{{snakeCase name}}_mcp_client(){{#unless @last}}, {{/unless}}{{/each}}] + clients = [{{#each remoteMcpTools}}get_{{pythonName}}_mcp_client(){{#unless @last}}, {{/unless}}{{/each}}] return [c for c in clients if c is not None] {{/if}} {{#unless (or hasGateway remoteMcpTools)}} diff --git a/src/assets/templates/strands-http-python/memory/session.py b/src/assets/templates/strands-http-python/memory/session.py index 20e105674..38bcf49f9 100644 --- a/src/assets/templates/strands-http-python/memory/session.py +++ b/src/assets/templates/strands-http-python/memory/session.py @@ -20,16 +20,16 @@ def get_memory_session_manager( {{#if memoryStrategies.length}} retrieval_config = { {{#if (includes memoryStrategies "SEMANTIC")}} - f"/users/{actor_id}/facts": RetrievalConfig(top_k=3, relevance_score=0.5), + 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=3, relevance_score=0.5), + 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=5, relevance_score=0.5), + 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=3, relevance_score=0.5), + f"/summaries/{actor_id}": RetrievalConfig(top_k={{#if memoryRetrievalTopK}}{{memoryRetrievalTopK}}{{else}}3{{/if}}, relevance_score={{#if memoryRetrievalRelevanceScore}}{{memoryRetrievalRelevanceScore}}{{else}}0.5{{/if}}), {{/if}} } {{/if}} diff --git a/src/assets/templates/strands-http-python/model/load.py b/src/assets/templates/strands-http-python/model/load.py index 05da58b20..d54edd29e 100644 --- a/src/assets/templates/strands-http-python/model/load.py +++ b/src/assets/templates/strands-http-python/model/load.py @@ -1,6 +1,9 @@ {{#if (eq modelProvider "Bedrock")}} {{#if bedrockMantle}} import os +{{#if modelAdditionalParams}} +import json +{{/if}} from aws_bedrock_token_generator import provide_token {{#if (eq mantleApiFormat "chat_completions")}} @@ -34,7 +37,7 @@ def load_model(): {{/if}} client_args = {"api_key": token, "base_url": base_url} - params = {} + params = {{#if modelAdditionalParams}}json.loads({{pyJsonStr modelAdditionalParams}}){{else}}{}{{/if}} {{#if modelMaxTokens}} {{#if (eq mantleApiFormat "chat_completions")}} params["max_completion_tokens"] = {{modelMaxTokens}} @@ -60,12 +63,22 @@ def load_model(): {{/if}} {{/if}} {{else}} +{{#if modelAdditionalParams}} +import json +{{/if}} 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}}) + 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 modelAdditionalParams}}additional_request_fields=json.loads({{pyJsonStr modelAdditionalParams}}), + {{/if}} + ) {{/if}} {{/if}} {{#if (eq modelProvider "Anthropic")}} @@ -109,8 +122,15 @@ def load_model() -> AnthropicModel: {{/if}} {{#if (eq modelProvider "OpenAI")}} import os +{{#if modelAdditionalParams}} +import json +{{/if}} +{{#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}}" @@ -138,15 +158,29 @@ def _get_api_key() -> str: return _agentcore_identity_api_key_provider() -def load_model() -> OpenAIModel: +def load_model(): """Get authenticated OpenAI model client.""" - return OpenAIModel( + params = {{#if modelAdditionalParams}}json.loads({{pyJsonStr modelAdditionalParams}}){{else}}{}{{/if}} + {{#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 +{{#if modelAdditionalParams}} +import json +{{/if}} from strands.models.gemini import GeminiModel from bedrock_agentcore.identity.auth import requires_api_key @@ -178,14 +212,28 @@ def _get_api_key() -> str: def load_model() -> GeminiModel: """Get authenticated Gemini model client.""" + params = {{#if modelAdditionalParams}}json.loads({{pyJsonStr modelAdditionalParams}}){{else}}{}{{/if}} + {{#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}} +{{#if modelAdditionalParams}} import json {{/if}} @@ -230,7 +278,16 @@ def load_model() -> LiteLLMModel: {{#if litellmApiBase}} client_args["api_base"] = {{safeJson litellmApiBase}} {{/if}} - params = {{#if litellmAdditionalParams}}json.loads({{pyJsonStr litellmAdditionalParams}}){{else}}{}{{/if}} + params = {{#if modelAdditionalParams}}json.loads({{pyJsonStr modelAdditionalParams}}){{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}}", diff --git a/src/assets/templates/strands-http-python/pyproject.toml b/src/assets/templates/strands-http-python/pyproject.toml index 26d4055ea..1a89b5846 100644 --- a/src/assets/templates/strands-http-python/pyproject.toml +++ b/src/assets/templates/strands-http-python/pyproject.toml @@ -9,17 +9,12 @@ description = "AgentCore Runtime Application using Strands SDK" readme = "README.md" requires-python = ">=3.10" dependencies = [ - {{#if (eq modelProvider "Anthropic")}}"anthropic ~= 0.30.0", - {{/if}}"aws-opentelemetry-distro ~= 0.17.0", + "aws-opentelemetry-distro ~= 0.17.0", "bedrock-agentcore ~= 1.9.1", "botocore[crt] ~= 1.43.0", - {{#if (eq modelProvider "Gemini")}}"google-genai ~= 1.0.0", - {{/if}}"mcp ~= 1.24.0", - {{#if (eq modelProvider "OpenAI")}}"openai ~= 1.0.0", - {{/if}}{{#if (eq modelProvider "LiteLLM")}}"litellm ~= 1.0.0", - {{/if}}{{#if bedrockMantle}}"openai ~= 1.0.0", - "aws-bedrock-token-generator ~= 1.0.0", - {{/if}}"strands-agents ~= 1.15.0", + "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", {{#if (or hasBrowser hasCodeInterpreter)}}"strands-agents-tools ~= 0.1.0", {{/if}}{{#if hasBrowser}}"nest-asyncio ~= 1.5.0", "playwright ~= 1.42.0", diff --git a/src/core/project/manager.export.test.ts b/src/core/project/manager.export.test.ts index 02b0de805..f0f3e98af 100644 --- a/src/core/project/manager.export.test.ts +++ b/src/core/project/manager.export.test.ts @@ -82,18 +82,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 () => { @@ -133,6 +139,135 @@ describe("FsProjectManager.exportHarness rendered tree", () => { expect(result.notes).toEqual([]); }); + 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: [{ path: "/opt/skills" }], + truncation: { + strategy: "sliding_window", + config: { slidingWindow: { messagesCount: 12 } }, + }, + }); + + const result = await drain(subject.exportHarness(project, exportInput({ build: "Container" }))); + + 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("renders the template Dockerfile for a plain Container export", async () => { const { manager: subject } = manager(); const project = await projectWithHarness(subject); @@ -196,12 +331,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 45b99d4d6..f260a419b 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -694,6 +694,8 @@ export class FsProjectManager implements ProjectManager { systemPrompt, projectSpec, build: input.build, + vpcId: input.vpcId, + sourceNotes: input.prefetched?.notes, harnessDockerfileExists: spec.dockerfile !== undefined && harnessDir !== undefined && @@ -710,7 +712,6 @@ export class FsProjectManager implements ProjectManager { 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") @@ -1086,7 +1087,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..4257cb3b5 100644 --- a/src/core/project/templates/export.test.ts +++ b/src/core/project/templates/export.test.ts @@ -3,6 +3,7 @@ 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, @@ -17,6 +18,7 @@ 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, @@ -83,7 +85,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 +122,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 +133,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 +164,7 @@ describe("mapHarnessToExportPlan model mapping", () => { }); expect(result.context.modelProvider).toBe("Gemini"); + expect(result.context.strandsExtras).toBe("gemini"); expect(result.credentials).toEqual([]); }); @@ -163,14 +175,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.modelAdditionalParams).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 +226,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 +262,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", () => { @@ -316,7 +372,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 +416,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" } }), @@ -534,6 +617,23 @@ describe("mapHarnessToExportPlan build types and Dockerfiles", () => { ).toThrow(InputValidationError); }); + test("rejects a VPC container export with no vpcId and accepts one supplied by the caller", () => { + const vpcContainerHarness = harness({ + containerUri: "111122223333.dkr.ecr.us-east-1.amazonaws.com/base-image:latest", + networkMode: "VPC", + networkConfig: { subnets: ["subnet-12345678"], securityGroups: ["sg-12345678"] }, + }); + + expect(() => plan({ spec: vpcContainerHarness })).toThrow(InputValidationError); + + const result = plan({ spec: vpcContainerHarness, vpcId: "vpc-12345678" }); + expect(result.runtime.networkConfig).toEqual({ + subnets: ["subnet-12345678"], + securityGroups: ["sg-12345678"], + vpcId: "vpc-12345678", + }); + }); + test("copies a custom harness Dockerfile with a build-layer note when it exists", () => { const result = plan({ spec: harness({ dockerfile: "Dockerfile" }), @@ -612,15 +712,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 5cc289010..e1b0e3e58 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 { HarnessMemoryRef, + HarnessMemoryRetrievalConfig, HarnessSkill, HarnessSkillGitSource, HarnessSkillPathSource, @@ -47,6 +49,10 @@ export interface HarnessExportInput { projectSpec: ProjectSpec; /** Build override from --build; when absent the harness spec decides. */ build?: BuildType; + /** VPC id from --vpc-id, for Container builds in VPC mode (see mapHarnessToExportPlan). */ + vpcId?: string; + /** Notes collected while converting a service response into a local harness spec. */ + sourceNotes?: ExportNote[]; /** * Whether the harness directory holds the Dockerfile that `spec.dockerfile` * names (local harnesses only; the caller checks the filesystem). @@ -79,8 +85,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 +102,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"; @@ -119,7 +125,7 @@ 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 = {}; @@ -136,6 +142,21 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport ); } + // A Container build is produced by CodeBuild, whose CreateProject API needs an explicit vpcId + // and cannot infer one from subnets. Neither source of a harness carries one: the service's + // VpcConfig has no vpcId field, and a local containerUri harness is never built (so its schema + // rightly does not demand one). Export is what turns it into a build, so export must ask. + const networkConfig = + spec.networkMode === "VPC" && spec.networkConfig + ? { ...spec.networkConfig, ...(input.vpcId !== undefined && { vpcId: input.vpcId }) } + : undefined; + if (buildType === "Container" && networkConfig && networkConfig.vpcId === undefined) { + throw new InputValidationError( + `Harness "${spec.name}" runs in a VPC and exports as a Container build, which CodeBuild ` + + `cannot perform without an explicit VPC id. Re-export with --vpc-id .`, + ); + } + const allowedToolPatterns = spec.allowedTools ?? ["*"]; if (!(allowedToolPatterns.length === 1 && allowedToolPatterns[0] === "*")) { notes.push({ @@ -198,6 +219,12 @@ 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. @@ -247,7 +274,7 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport ...(buildType === "Container" && { dockerfile: "Dockerfile" }), ...(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,7 +296,6 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport envEntries, policyFiles, hasMemory: memory.provider !== undefined, - hasExecutionLimits, buildType, dockerfilePlan, notes, @@ -310,10 +336,13 @@ function resolveModel( const model = spec.model; const context: Record = { modelId: model.modelId, + modelApiFormat: model.apiFormat, + modelAdditionalParams: model.additionalParams, // 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 +352,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 +384,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,10 +398,8 @@ 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; - } if (model.apiKeyArn) { attachIdentityProvider( context, @@ -440,6 +469,7 @@ function attachIdentityProvider( interface MemoryResolution { provider?: { name: string; envVarName: string; strategies: string[] }; actorId?: string; + retrievalConfig?: HarnessMemoryRetrievalConfig; } function resolveMemory( @@ -474,6 +504,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 +522,7 @@ function resolveMemory( strategies: entry.strategies.map(({ type }) => type), }, actorId: memory.actorId, + retrievalConfig: memory.retrievalConfig, }; } @@ -511,8 +552,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 +604,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 +636,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 +703,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 // ============================================================================ diff --git a/src/core/project/templates/runtime.ts b/src/core/project/templates/runtime.ts index 10ab9cd7d..3a42bca9c 100644 --- a/src/core/project/templates/runtime.ts +++ b/src/core/project/templates/runtime.ts @@ -184,10 +184,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..47d455bbe 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,77 @@ 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); + } + + test("preserves service VPC configuration without additional lookups", async () => { + const subject = testExportCommand(); + const projectRoot = await inProjectWithHarness(subject); + setVpcContainerHarness(subject); + + await subject.run(["--arn", HARNESS_ARN, "--vpc-id", "vpc-0123456789abcdef0"]); + + // The vpcId comes from the flag, never from an extra AWS call: the harness API's VpcConfig + // has no vpcId field, so getHarness must remain the only request. + 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("Container"); + expect(runtime.networkConfig).toEqual({ + subnets: ["subnet-0123456789abcdef0"], + securityGroups: ["sg-0123456789abcdef0"], + vpcId: "vpc-0123456789abcdef0", + }); + }); + + // Export turns a containerUri harness into a Dockerfile build so the agent code can be layered + // in, which makes CodeBuild's vpcId mandatory where the source harness never needed one. Fail + // here rather than writing a project that dies at `project build`. + test("requires --vpc-id for a container build in VPC mode", async () => { + const subject = testExportCommand(); + const projectRoot = await inProjectWithHarness(subject); + setVpcContainerHarness(subject); + const specBefore = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).text(); + + await expect(subject.run(["--arn", HARNESS_ARN])).rejects.toThrow(/without an explicit VPC id/); + + // The point is failing before anything is written, so moving the throw later must break this. + expect(await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).text()).toBe( + specBefore, + ); + expect(existsSync(join(projectRoot, "app", "remote_containerAgent"))).toBe(false); + }); + test("validates the project before fetching from the service", async () => { const subject = testExportCommand(); await inTempDirectory(); // not a project @@ -264,5 +335,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 388053239..7e6ce0806 100644 --- a/src/handlers/project/export/harness.ts +++ b/src/handlers/project/export/harness.ts @@ -3,7 +3,11 @@ 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, + BuildTypeSchema, + NetworkConfigSchema, +} from "../../../projectSchemas/runtime"; import { formatExportNotes } from "../../../core/project/templates/export"; import { coreOptsFromCtx } from "../../utils"; import type { ExportHarnessInput } from "../types"; @@ -31,6 +35,11 @@ export const createExportHarnessHandler = (config: ExportProjectResourceConfig) "build type for the exported agent: CodeZip or Container", BuildTypeSchema.optional(), ), + flag( + "vpc-id", + "VPC id for a Container build in VPC mode (CodeBuild cannot infer it from subnets)", + NetworkConfigSchema.shape.vpcId, + ), ], handle: async (ctx, flags) => { if (!!flags.name === !!flags.arn) { @@ -48,25 +57,27 @@ 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, + vpcId: flags["vpc-id"], }; } else { input = { harnessName: flags.name!, targetAgentName: resolveTargetAgentName(flags["target-agent-name"], flags.name!), build: flags.build, + vpcId: flags["vpc-id"], }; } diff --git a/src/handlers/project/export/serviceHarness.test.ts b/src/handlers/project/export/serviceHarness.test.ts index dedaae749..fb8121f9c 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,8 @@ describe("mapServiceHarnessToSpec", () => { expect(spec.executionRoleArn).toBeUndefined(); }); - test("maps every skill source variant and drops unknown members", () => { - const { spec } = mapServiceHarnessToSpec( + test("maps every skill source variant and notes unknown members", () => { + const { spec, notes } = mapServiceHarnessToSpec( serviceHarness({ skills: [ { path: "local_skill" }, @@ -122,6 +139,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 +240,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..e8f95a871 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,7 +44,9 @@ export function regionFromHarnessArn(arn: string): string | undefined { export function mapServiceHarnessToSpec(harness: Harness): { spec: HarnessSpec; systemPrompt?: string; + notes: ExportNote[]; } { + const notes: ExportNote[] = []; const joinedPrompt = (harness.systemPrompt ?? []) .map((block) => ("text" in block ? block.text : undefined)) .filter((text): text is string => typeof text === "string" && text.length > 0) @@ -45,7 +55,7 @@ export function mapServiceHarnessToSpec(harness: Harness): { 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 +63,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 +86,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 +99,7 @@ function mapModel(model: Harness["model"]): Record { temperature: c.temperature, topP: c.topP, maxTokens: c.maxTokens, + additionalParams: mapAdditionalParams("bedrock", c.additionalParams, notes), }); } if (model?.openAiModelConfig) { @@ -99,6 +112,7 @@ function mapModel(model: Harness["model"]): Record { temperature: c.temperature, topP: c.topP, maxTokens: c.maxTokens, + additionalParams: mapAdditionalParams("open_ai", c.additionalParams, notes), }); } if (model?.geminiModelConfig) { @@ -111,6 +125,7 @@ function mapModel(model: Harness["model"]): Record { topP: c.topP, topK: c.topK, maxTokens: c.maxTokens, + additionalParams: mapAdditionalParams("gemini", c.additionalParams, notes), }); } if (model?.liteLlmModelConfig) { @@ -131,8 +146,29 @@ 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 mapAdditionalParams(provider: string, value: unknown, notes: ExportNote[]): unknown { + if (value === undefined) return undefined; + if (provider === "lite_llm") return value; + 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 +184,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 +200,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 +224,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 +240,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 +301,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 +317,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 4e142deb5..50c40f1f8 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -250,11 +250,14 @@ 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; + /** VPC id for a Container build in VPC mode; CodeBuild cannot infer one from subnets. */ + vpcId?: string; }; /** 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);