From 4c46a2778dc48295983e8f3f70ce9a2c3437cc57 Mon Sep 17 00:00:00 2001 From: Amir Hegazy Date: Wed, 30 Sep 2026 17:17:56 -0400 Subject: [PATCH] feat(export): build exported harness agents with Strands Harness Exported agents now reproduce how the harness runtime builds agents: strands_harness.create_harness() with its defaults off and each capability passed explicitly, instead of a bare Strands Agent with hand-written tools. The construction lives in a generated harness_runtime.py module. - Built-in tools: sandbox-routed shell, Strands Harness read/write/edit, and markdown web_fetch, each gated by allowedTools. - Plugins: todos (todo_write) and a context offloader (retrieve_offloaded_content) that never evicts entries. - Subagent tool: a generalist child that can use the parent's built-in and MCP tools and gets only the plugins it selects. - One invocation budget, passed in the invocation state, is shared by the agent and its subagents; a limit ends the invocation with max_iterations_exceeded or max_output_tokens_exceeded. With the subagent tool enabled, usage is reported once per invocation, including the subagents'; otherwise per model call. - MCP tools are prefixed with their server name so they cannot collide with the built-ins. - Defaults when unset: the Strands Harness contract prompt and summarization truncation; Bedrock Converse models cache prompts and tools unless the model parameters already place a cache point. - Pins strands-agents ~= 1.57.1 and strands-harness == 0.1.2. --- .../export-harness-python/harness_runtime.py | 224 ++++++++++++++ .../templates/export-harness-python/main.py | 278 ++++++------------ .../mcp_client/client.py | 4 +- .../export-harness-python/model/load.py | 7 + .../export-harness-python/pyproject.toml | 3 +- src/core/project/manager.export.test.ts | 226 +++++++++++++- src/core/project/manager.tsx | 11 +- src/core/project/templates/export.test.ts | 75 ++++- src/core/project/templates/export.ts | 105 ++++++- 9 files changed, 698 insertions(+), 235 deletions(-) create mode 100644 src/assets/templates/export-harness-python/harness_runtime.py diff --git a/src/assets/templates/export-harness-python/harness_runtime.py b/src/assets/templates/export-harness-python/harness_runtime.py new file mode 100644 index 0000000000..512d59d137 --- /dev/null +++ b/src/assets/templates/export-harness-python/harness_runtime.py @@ -0,0 +1,224 @@ +"""Builds the agent the way the harness does, and bounds it by the invocation's limits.""" + +{{#if hasContextOffloader}} +import hashlib +import os +import tempfile + +{{/if}} +from strands.hooks import AfterModelCallEvent, BeforeModelCallEvent, HookProvider +{{#if hasContextOffloader}} +from strands.storage import LocalFileStorage +{{/if}} +from strands.tools.executors import SequentialToolExecutor +{{#if hasSubagent}} +from strands.tools.mcp import MCPAgentTool +{{/if}} +{{#if (or maxIterations maxTokens)}} +from strands.types.exceptions import EventLoopException +{{/if}} +{{#if hasContextOffloader}} +from strands.vended_plugins.context_offloader import ContextOffloader +{{/if}} +from strands_harness import create_harness +{{#if hasSubagent}} +from strands_harness.tools import Choice, make_subagent +from strands_harness.tools.subagent import GENERALIST +{{/if}} + +# Tools vended by the enabled built-in plugins. +_PLUGIN_TOOL_NAMES = frozenset({{safeJson pluginToolNames}}) + + +class LimitExceeded(Exception): + """The invocation reached an execution limit; stop_reason is the harness stop reason.""" + + def __init__(self, stop_reason): + super().__init__(stop_reason) + self.stop_reason = stop_reason + + +_USAGE_FIELDS = ("inputTokens", "outputTokens", "totalTokens") +_CACHE_USAGE_FIELDS = ("cacheReadInputTokens", "cacheWriteInputTokens") + + +class InvocationBudget: + """One budget shared by the agent and every subagent it runs within an invocation. + + Each model call reserves an iteration and adds its token usage, so limits and the reported + usage cover the whole delegation tree. + """ + + def __init__(self): + self.iterations = 0 + self.completed_calls = 0 + self.usage = dict.fromkeys(_USAGE_FIELDS + _CACHE_USAGE_FIELDS, 0) + self.latency_ms = 0 + + def before_model_call(self): + self.iterations += 1 + {{#if (or maxIterations maxTokens)}} + # Raised as EventLoopException, which Strands re-raises unchanged; any other exception + # would be logged as a failed event loop cycle. + {{/if}} + {{#if maxIterations}} + if self.iterations > {{maxIterations}}: + raise EventLoopException(LimitExceeded("max_iterations_exceeded")) + {{/if}} + {{#if maxTokens}} + if self.usage["outputTokens"] >= {{maxTokens}}: + raise EventLoopException(LimitExceeded("max_output_tokens_exceeded")) + {{/if}} + + def after_model_call(self, metadata): + usage = metadata.get("usage") + if not isinstance(usage, dict): + return + for field in _USAGE_FIELDS + _CACHE_USAGE_FIELDS: + if isinstance(usage.get(field), (int, float)): + self.usage[field] += int(usage[field]) + latency_ms = (metadata.get("metrics") or {}).get("latencyMs") + if isinstance(latency_ms, (int, float)): + self.latency_ms += int(latency_ms) + self.completed_calls += 1 + + def metadata_event(self): + """The invocation-total usage, reported once in place of per-call metadata.""" + if not self.completed_calls: + return None + usage = {field: self.usage[field] for field in _USAGE_FIELDS} + usage.update({field: self.usage[field] for field in _CACHE_USAGE_FIELDS if self.usage[field]}) + return {"event": {"metadata": {"usage": usage, "metrics": {"latencyMs": self.latency_ms}}}} + + +class _BudgetHook(HookProvider): + """Charge an agent's model calls to the budget in its invocation state. + + A subagent receives a copy of its parent's invocation state, so both charge the same budget. + """ + + def register_hooks(self, registry, **kwargs): + registry.add_callback(BeforeModelCallEvent, self._before) + registry.add_callback(AfterModelCallEvent, self._after) + + def _before(self, event): + budget = event.invocation_state.get("budget") + if budget is not None: + budget.before_model_call() + + def _after(self, event): + budget = event.invocation_state.get("budget") + if budget is not None and event.stop_response is not None: + budget.after_model_call(event.stop_response.message.get("metadata") or {}) +{{#if hasContextOffloader}} + + +_OFFLOAD_ROOT = os.path.join(tempfile.gettempdir(), "agent", "offloaded") + + +def _make_context_offloader(session_id): + """Offload large tool results to disk per session, with a tool to read them back. + + Entries are never evicted: a preview in the conversation must stay retrievable for as long + as the session lasts. A subagent reuses its parent's session storage. + """ + session_key = hashlib.sha256(session_id.encode("utf-8")).hexdigest() + return ContextOffloader( + storage=LocalFileStorage(os.path.join(_OFFLOAD_ROOT, session_key)), + max_result_tokens=1500, + preview_tokens=750, + include_retrieval_tool=True, + evict_after_cycles=None, + ) +{{/if}} + + +def _create_harness_agent(*, plugin_tools, plugins, **kwargs): + """Build a Strands Harness agent with its defaults off; each capability is passed explicitly.""" + return create_harness( + builtin_tools=[], + background_tasks=False, + caching=False, + context_manager=False, + session=False, + skills=False, + memory=False, + builtin_plugins=["todos"] if "todo_write" in plugin_tools else [], + plugins=plugins or None, + tool_executor=SequentialToolExecutor(), + callback_handler=None, + hooks=[_BudgetHook()], + **kwargs, + ) + + +def _plugins(session_id, plugin_tools, skill_plugins): + """The agent's plugins: its skills, plus the context offloader when its tool is enabled.""" + plugins = list(skill_plugins or []) + {{#if hasContextOffloader}} + if "retrieve_offloaded_content" in plugin_tools: + plugins.append(_make_context_offloader(session_id)) + {{/if}} + return plugins +{{#if hasSubagent}} + + +# Built-in tools a subagent may use, alongside the parent's MCP tools. +_SUBAGENT_BUILTIN_TOOL_NAMES = frozenset({{safeJson builtinTools}}) + + +def _add_subagent_tool(parent, *, session_id, skill_plugins, make_conversation_manager): + """Let the agent delegate a focused subtask to a generalist child. + + The child may use the parent's built-in and MCP tools, gets its own instances of the + plugins it selects and shares the parent's skills. It has no session manager and cannot delegate + further. As in the harness runtime, each inherited tool is offered by name. + """ + inheritable = { + name: tool + for name, tool in parent.tool_registry.registry.items() + if name in _SUBAGENT_BUILTIN_TOOL_NAMES or isinstance(tool, MCPAgentTool) + } + + def build_child(spec): + selected = set(spec.tools) if spec.tools is not None else {*inheritable, *_PLUGIN_TOOL_NAMES} + plugin_tools = selected & _PLUGIN_TOOL_NAMES + return _create_harness_agent( + plugin_tools=plugin_tools, + plugins=_plugins(session_id, plugin_tools, skill_plugins), + model=spec.model or parent.model, + instructions=spec.instructions, + tools=[tool for name, tool in inheritable.items() if name in selected], + conversation_manager=make_conversation_manager(), + name="subagent", + ) + + parent.tool_registry.register_tool( + make_subagent( + builder=build_child, + presets={"generalist": GENERALIST}, + inherited_tools=[*inheritable, *sorted(_PLUGIN_TOOL_NAMES)], + context=Choice(["none", "all", "no_tools"]), + ) + ) +{{/if}} + + +def build_session_agent(*, session_id, make_conversation_manager, skill_plugins=None, **kwargs): + """Build the agent that serves a session, with its plugins and subagent tool.""" + agent = _create_harness_agent( + plugin_tools=_PLUGIN_TOOL_NAMES, + plugins=_plugins(session_id, _PLUGIN_TOOL_NAMES, skill_plugins), + conversation_manager=make_conversation_manager(), + **kwargs, + ) + {{#if hasSubagent}} + # Registered after construction so the child can pick from the MCP tools the agent loaded. + _add_subagent_tool( + agent, + session_id=session_id, + skill_plugins=skill_plugins, + make_conversation_manager=make_conversation_manager, + ) + {{/if}} + return agent diff --git a/src/assets/templates/export-harness-python/main.py b/src/assets/templates/export-harness-python/main.py index 9424df446f..05e9572f3d 100644 --- a/src/assets/templates/export-harness-python/main.py +++ b/src/assets/templates/export-harness-python/main.py @@ -6,7 +6,13 @@ from strands.tools.tools import PythonAgentTool from strands.types.tools import ToolResult, ToolUse {{/if}} -from strands import Agent, tool +from strands.types.exceptions import EventLoopException +{{#if (or hasShell hasWebFetch)}} +from strands.vended_tools import {{#if hasShell}}make_shell{{#if hasWebFetch}}, {{/if}}{{/if}}{{#if hasWebFetch}}make_web_fetch{{/if}} +{{/if}} +{{#if harnessFileTools}} +from strands_harness.tools import {{#each harnessFileTools}}{{this}}{{#unless @last}}, {{/unless}}{{/each}} +{{/if}} {{#if hasSkillsFetcher}} from strands import AgentSkills {{#if hasFetchedSkills}} @@ -20,9 +26,6 @@ {{#if timeoutSeconds}} import threading {{/if}} -{{#if hasShell}} -import subprocess -{{/if}} {{#if hasFileOperations}} import os {{/if}} @@ -37,6 +40,7 @@ from strands.agent.conversation_manager.null_conversation_manager import NullConversationManager {{/if}} from bedrock_agentcore.runtime import BedrockAgentCoreApp +from harness_runtime import InvocationBudget, LimitExceeded, build_session_agent from model.load import load_model {{#if remoteMcpTools}} from mcp_client.client import get_all_remote_mcp_clients @@ -74,15 +78,7 @@ {{#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}} -""" +# No system prompt is set, so create_harness() applies the Strands Harness contract prompt. {{/if}} @@ -122,106 +118,17 @@ def add_numbers(a: int, b: int) -> int: {{/unless}} {{/if}} +{{#if builtinTools}} +# Built-in tools selected by allowedTools. {{#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) +tools.append(make_shell()) +{{/if}} +{{#each harnessFileTools}} +tools.append({{this}}) +{{/each}} +{{#if hasWebFetch}} +tools.append(make_web_fetch(mode="markdown")) {{/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 = [ @@ -324,17 +231,18 @@ def get_or_create_agent(session_id, user_id{{#if hasSkillsFetcher}}, skill_plugi {{/if}} key = f"{session_id}/{_actor_id}" if key not in cache: - cache[key] = Agent( + cache[key] = build_session_agent( + session_id=session_id, + make_conversation_manager=_make_conversation_manager, model=load_model(), session_manager=get_memory_session_manager(session_id, _actor_id), - conversation_manager=_make_conversation_manager(), + {{#if systemPromptText}} system_prompt=DEFAULT_SYSTEM_PROMPT, + {{/if}} tools=tools, {{#if hasSkillsFetcher}} - plugins=skill_plugins or None, + skill_plugins=skill_plugins, {{/if}} - hooks=[ - ], ) return cache[key] return get_or_create_agent @@ -353,16 +261,17 @@ def get_or_create_agent(session_id{{#if hasSkillsFetcher}}, skill_plugins=None{{ return cache[session_id] if len(cache) >= 128: cache.popitem(last=False) - cache[session_id] = Agent( + cache[session_id] = build_session_agent( + session_id=session_id, + make_conversation_manager=_make_conversation_manager, model=load_model(), + {{#if systemPromptText}} system_prompt=DEFAULT_SYSTEM_PROMPT, + {{/if}} tools=tools, - conversation_manager=_make_conversation_manager(), {{#if hasSkillsFetcher}} - plugins=skill_plugins or None, + skill_plugins=skill_plugins, {{/if}} - hooks=[ - ], ) return cache[session_id] return get_or_create_agent @@ -493,61 +402,75 @@ async def invoke(payload, context): del msgs[-2:] {{/if}} - {{#if hasExecutionLimits}} - limits = { - {{#if maxIterations}}"turns": {{maxIterations}},{{/if}} - {{#if maxTokens}}"output_tokens": {{maxTokens}},{{/if}} - } or None + budget = InvocationBudget() 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()) + 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 + inline_handoff = 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}}"}}} + try: + async for event in agent.stream_async( + prompt, cancel_signal=cancel_signal, invocation_state={"budget": budget} + ): + {{#if inlineFunctionTools}} + if inline_handoff and "metadata" not in (event.get("event") or {}): + break + {{/if}} + if not isinstance(event, dict) or "event" not in event: + continue + if "metadata" in event["event"]: + {{#if inlineFunctionTools}} + if inline_handoff: + # The loop stops before the model-call hook records this turn's usage. + budget.after_model_call(event["event"]["metadata"]) + {{/if}} + {{#if hasSubagent}} + # Replaced by the invocation total, which includes subagent usage. + {{else}} + yield event + {{/if}} + {{#if inlineFunctionTools}} + if inline_handoff: + break + {{/if}} + 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"]: + # Hand the inline tool call to the caller before the agent runs it. Stop once + # this turn's usage arrives, which precedes the tool running. + inline_handoff = True + {{/if}} + except EventLoopException as e: + if not isinstance(e.original_exception, LimitExceeded): + raise + yield {"event": {"messageStop": {"stopReason": e.original_exception.stop_reason}}} + else: + if timeout_fired: + yield {"event": {"messageStop": {"stopReason": "timeout_exceeded"}}} + {{#if hasSubagent}} + metadata = budget.metadata_event() + if metadata is not None: + yield metadata {{/if}} finally: if watchdog_task is not None: @@ -556,29 +479,6 @@ async def _timeout_watchdog(): 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/client.py b/src/assets/templates/export-harness-python/mcp_client/client.py index 6b600156e1..477a0ae80a 100644 --- a/src/assets/templates/export-harness-python/mcp_client/client.py +++ b/src/assets/templates/export-harness-python/mcp_client/client.py @@ -42,9 +42,9 @@ def transport(): headers = { {{#each headerCredentials}}{{safeJson headerKey}}: _get_{{pythonName}}_key(){{#unless @last}}, {{/unless}}{{/each}} } return streamablehttp_client(url, headers=headers) - return MCPClient(transport{{#if toolPatterns}}, tool_filters=_allowed_tools({{safeJson name}}, {{#each toolPatterns}}{{safeJson this}}{{#unless @last}}, {{/unless}}{{/each}}){{/if}}) + return MCPClient(transport, prefix={{safeJson name}}{{#if toolPatterns}}, tool_filters=_allowed_tools({{safeJson name}}, {{#each toolPatterns}}{{safeJson this}}{{#unless @last}}, {{/unless}}{{/each}}){{/if}}) {{else}} - return MCPClient(lambda: streamablehttp_client(url){{#if toolPatterns}}, tool_filters=_allowed_tools({{safeJson name}}, {{#each toolPatterns}}{{safeJson this}}{{#unless @last}}, {{/unless}}{{/each}}){{/if}}) + return MCPClient(lambda: streamablehttp_client(url), prefix={{safeJson name}}{{#if toolPatterns}}, tool_filters=_allowed_tools({{safeJson name}}, {{#each toolPatterns}}{{safeJson this}}{{#unless @last}}, {{/unless}}{{/each}}){{/if}}) {{/if}} {{/each}} diff --git a/src/assets/templates/export-harness-python/model/load.py b/src/assets/templates/export-harness-python/model/load.py index 22b6ca24bf..15dcc5102f 100644 --- a/src/assets/templates/export-harness-python/model/load.py +++ b/src/assets/templates/export-harness-python/model/load.py @@ -66,12 +66,19 @@ def load_model(): {{#if modelAdditionalParams}} import json {{/if}} +{{#if bedrockPromptCaching}} +from strands.models import CacheConfig +{{/if}} from strands.models.bedrock import BedrockModel def load_model() -> BedrockModel: """Get Bedrock model client using IAM credentials.""" return BedrockModel( +{{#if bedrockPromptCaching}} + # Cache the system prompt and tool definitions automatically. + cache_config=CacheConfig(strategy="auto", system_prompt_ttl=True, tools_ttl=True), +{{/if}} model_id="{{#if modelId}}{{modelId}}{{else}}global.anthropic.claude-sonnet-4-5-20250929-v1:0{{/if}}", {{#if modelMaxTokens}} max_tokens={{modelMaxTokens}}, diff --git a/src/assets/templates/export-harness-python/pyproject.toml b/src/assets/templates/export-harness-python/pyproject.toml index 29262d7157..9b13156966 100644 --- a/src/assets/templates/export-harness-python/pyproject.toml +++ b/src/assets/templates/export-harness-python/pyproject.toml @@ -14,7 +14,8 @@ dependencies = [ "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", + {{/if}}"strands-agents{{#if strandsExtras}}[{{strandsExtras}}]{{/if}} ~= 1.57.1", + "strands-harness == 0.1.2", ] diff --git a/src/core/project/manager.export.test.ts b/src/core/project/manager.export.test.ts index de3f458eca..5e2af05a5d 100644 --- a/src/core/project/manager.export.test.ts +++ b/src/core/project/manager.export.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { spawnSync } from "node:child_process"; import z from "zod"; import { parse, stringify } from "yaml"; import { FsProjectManager } from "./manager"; @@ -86,6 +87,124 @@ function exportInput(overrides: Partial = {}): ExportHarness } describe("FsProjectManager.exportHarness rendered tree", () => { + test("builds the agent with create_harness and the builtin tools and plugins", async () => { + const { manager: subject } = manager(); + const project = await projectWithHarness(subject); + // A service harness that sets no prompt or truncation, so the harness defaults apply. + const spec = HarnessSpecSchema.parse({ + name: "remote", + model: { provider: "bedrock", modelId: "global.anthropic.claude-opus-5-5" }, + }); + + const result = await drain( + subject.exportHarness(project, { prefetched: { spec }, targetAgentName: "remoteAgent" }), + ); + + const main = await Bun.file(join(result.agentPath, "main.py")).text(); + expect(main).toContain("cache[session_id] = build_session_agent("); + expect(main).toContain("from strands_harness.tools import read, write, edit"); + expect(main).toContain('tools.append(make_web_fetch(mode="markdown"))'); + // No prompt set: create_harness applies the contract prompt. + expect(main).not.toContain("system_prompt="); + expect(main).toContain("SummarizingConversationManager(**"); + const runtime = await Bun.file(join(result.agentPath, "harness_runtime.py")).text(); + expect(runtime).toContain("from strands_harness import create_harness"); + expect(runtime).toContain( + '_PLUGIN_TOOL_NAMES = frozenset(["todo_write","retrieve_offloaded_content"])', + ); + expect(runtime).toContain("evict_after_cycles=None"); + expect(runtime).toContain("def _add_subagent_tool("); + const loadModel = await Bun.file(join(result.agentPath, "model", "load.py")).text(); + expect(loadModel).toContain( + 'cache_config=CacheConfig(strategy="auto", system_prompt_ttl=True, tools_ttl=True)', + ); + const pyproject = await Bun.file(join(result.agentPath, "pyproject.toml")).text(); + expect(pyproject).toContain('"strands-agents[web-fetch] ~= 1.57.1"'); + expect(pyproject).toContain('"strands-harness == 0.1.2"'); + }); + + test("renders only the builtins and plugins allowedTools selects", async () => { + const { manager: subject } = manager(); + const project = await projectWithHarness(subject, { allowedTools: ["shell", "read"] }); + + const result = await drain(subject.exportHarness(project, exportInput())); + + const main = await Bun.file(join(result.agentPath, "main.py")).text(); + expect(main).toContain("system_prompt=DEFAULT_SYSTEM_PROMPT,"); + expect(main).toContain("from strands_harness.tools import read\n"); + expect(main).not.toContain("make_web_fetch"); + const runtime = await Bun.file(join(result.agentPath, "harness_runtime.py")).text(); + expect(runtime).toContain("_PLUGIN_TOOL_NAMES = frozenset([])"); + expect(runtime).not.toContain("make_subagent"); + expect(runtime).not.toContain("ContextOffloader"); + const pyproject = await Bun.file(join(result.agentPath, "pyproject.toml")).text(); + expect(pyproject).toContain('"strands-agents ~= 1.57.1"'); + }); + + test("reports usage per model call unless a subagent can add to it", async () => { + const { manager: subject } = manager(); + const render = async (allowedTools: string[]) => { + const project = await projectWithHarness(subject, { allowedTools }); + const result = await drain(subject.exportHarness(project, exportInput())); + return Bun.file(join(result.agentPath, "main.py")).text(); + }; + + const withSubagent = await render(["subagent"]); + expect(withSubagent).toContain("metadata = budget.metadata_event()"); + const without = await render(["shell"]); + expect(without).not.toContain("budget.metadata_event()"); + expect(without).toMatch(/if "metadata" in event\["event"\]:\n\s+yield event/); + }); + + test("renders a subagent with no builtin tools to inherit", async () => { + const { manager: subject } = manager(); + const project = await projectWithHarness(subject, { allowedTools: ["subagent"] }); + + const result = await drain(subject.exportHarness(project, exportInput())); + + const runtime = await Bun.file(join(result.agentPath, "harness_runtime.py")).text(); + expect(runtime).toContain("_SUBAGENT_BUILTIN_TOOL_NAMES = frozenset([])"); + const main = await Bun.file(join(result.agentPath, "main.py")).text(); + expect(main).not.toContain("# Built-in tools"); + expect(main).not.toContain("from strands_harness.tools import"); + }); + + test("prefixes MCP tools with their server name", async () => { + const { manager: subject } = manager(); + const project = await projectWithHarness(subject, { + tools: [ + { + type: "remote_mcp", + name: "exa", + config: { remoteMcp: { url: "https://mcp.exa.ai/mcp" } }, + }, + ], + }); + + const result = await drain(subject.exportHarness(project, exportInput())); + + const client = await Bun.file(join(result.agentPath, "mcp_client", "client.py")).text(); + expect(client).toContain('prefix="exa"'); + }); + + test("counts an inline function turn's usage when handing the call off", async () => { + const { manager: subject } = manager(); + const project = await projectWithHarness(subject, { + tools: [ + { + type: "inline_function", + name: "lookup", + config: { inlineFunction: { description: "d", inputSchema: { type: "object" } } }, + }, + ], + }); + + const result = await drain(subject.exportHarness(project, exportInput())); + + const main = await Bun.file(join(result.agentPath, "main.py")).text(); + expect(main).toContain('budget.after_model_call(event["event"]["metadata"])'); + }); + test("loads only the MCP tools allowedTools selects", async () => { const { manager: subject } = manager(); const project = await projectWithHarness(subject, { @@ -125,7 +244,7 @@ describe("FsProjectManager.exportHarness rendered tree", () => { expect(loadModel).toContain("top_k"); }); - test("renders invocation-scoped native Strands limits without a custom hook", async () => { + test("enforces limits with one budget shared by the agent and its subagents", async () => { const { manager: subject } = manager(); const project = await projectWithHarness(subject, { maxIterations: 3, @@ -136,13 +255,16 @@ describe("FsProjectManager.exportHarness rendered tree", () => { const result = await drain(subject.exportHarness(project, exportInput())); expect(existsSync(join(result.agentPath, "hooks"))).toBe(false); + const runtime = await Bun.file(join(result.agentPath, "harness_runtime.py")).text(); + expect(runtime).toContain("if self.iterations > 3:"); + expect(runtime).toContain('if self.usage["outputTokens"] >= 128:'); + expect(runtime).toContain('LimitExceeded("max_iterations_exceeded")'); + expect(runtime).toContain('LimitExceeded("max_output_tokens_exceeded")'); const main = await Bun.file(join(result.agentPath, "main.py")).text(); - expect(main).toContain('"turns": 3'); - expect(main).toContain('"output_tokens": 128'); + // The budget reaches subagents through the invocation state. + expect(main).toContain('invocation_state={"budget": budget}'); expect(main).toContain("cancel_signal = threading.Event()"); - expect(main).toContain("limits=limits"); - expect(main).not.toContain("ExecutionLimitsHook"); - expect(main).not.toContain("agent.cancel()"); + expect(main).not.toContain("limits="); }); test("leaves hooks/ and memory/ out of a plain export", async () => { @@ -238,7 +360,7 @@ describe("FsProjectManager.exportHarness rendered tree", () => { 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).toContain('"strands-agents[openai,web-fetch] ~= 1.57.1"'); expect(pyproject).not.toContain('"openai ~= 1.0.0"'); }); @@ -265,7 +387,7 @@ describe("FsProjectManager.exportHarness rendered tree", () => { 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"', + '"strands-agents[gemini,web-fetch] ~= 1.57.1"', ); }); @@ -290,7 +412,7 @@ describe("FsProjectManager.exportHarness rendered tree", () => { 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"', + '"strands-agents[litellm,web-fetch] ~= 1.57.1"', ); }); @@ -310,7 +432,7 @@ describe("FsProjectManager.exportHarness rendered tree", () => { 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"', + '"strands-agents[web-fetch] ~= 1.57.1"', ); }); @@ -361,6 +483,23 @@ describe("FsProjectManager.exportHarness rendered tree", () => { }); describe("FsProjectManager.exportHarness side effects", () => { + test("leaves the system prompt to the harness default when the harness sets none", async () => { + const { manager: subject } = manager(); + const project = await projectWithHarness(subject); + const dir = join(project.rootPath, "app", "assistant"); + const configPath = join(dir, "harness.yaml"); + const config = parse(await Bun.file(configPath).text()); + delete config.systemPrompt; + await Bun.write(configPath, stringify(config)); + await rm(join(dir, "system-prompt.md"), { force: true }); + + const result = await drain(subject.exportHarness(project, exportInput())); + + const main = await Bun.file(join(result.agentPath, "main.py")).text(); + expect(main).not.toContain("DEFAULT_SYSTEM_PROMPT"); + expect(main).not.toContain("system_prompt="); + }); + test.each(["inline", "file"] as const)( "exports the %s prompt and inline summary without rewriting YAML", async (source) => { @@ -519,3 +658,70 @@ function failingWriteJson() { }; return { json, failNextWrite: () => (shouldFail = true) }; } + +const hasPython = spawnSync("python3", ["--version"]).status === 0; + +describe("FsProjectManager.exportHarness generated Python", () => { + // Every template branch must render to valid Python; Handlebars cannot check that. + const variants: Record & { withProjectMemory?: boolean }> = { + default: {}, + "no builtins or plugins": { allowedTools: ["exa"] }, + "subagent without builtins": { allowedTools: ["subagent"] }, + "subagent with limits": { maxIterations: 3, maxTokens: 64, timeoutSeconds: 5 }, + "memory and skills": { + memory: { mode: "existing", name: "chat_history" }, + skills: [{ s3Uri: "s3://bucket/skills" }], + withProjectMemory: true, + }, + "inline function and MCP": { + tools: [ + { + type: "inline_function", + name: "lookup", + config: { + inlineFunction: { description: "Look up an order", inputSchema: { type: "object" } }, + }, + }, + { + type: "remote_mcp", + name: "exa", + config: { remoteMcp: { url: "https://mcp.exa.ai/mcp" } }, + }, + ], + }, + }; + + for (const [name, harness] of Object.entries(variants)) { + test.skipIf(!hasPython)(`compiles: ${name}`, async () => { + const { withProjectMemory, ...harnessConfig } = harness; + const { manager: subject } = manager(); + let project = await projectWithHarness(subject, harnessConfig); + if (withProjectMemory) { + 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 files = [ + "main.py", + "harness_runtime.py", + join("model", "load.py"), + join("mcp_client", "client.py"), + ].map((file) => join(result.agentPath, file)); + // A missing context value renders as `undefined`, which compiles but fails at import. + for (const file of files) expect(await Bun.file(file).text()).not.toContain("undefined"); + const compiled = spawnSync("python3", ["-m", "py_compile", ...files], { encoding: "utf-8" }); + expect(compiled.stderr).toBe(""); + expect(compiled.status).toBe(0); + }); + } +}); diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 7543f6e9eb..5a0bbbb34a 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -43,7 +43,6 @@ import { getHarnessTemplateResolver, validateHarnessTemplateSource } from "./tem import { createProjectTree } from "./templates/project"; import { getRuntimeTemplateResolver } from "./templates/runtime"; import { - DEFAULT_EXPORT_SYSTEM_PROMPT, EXPORT_NOTES_FILENAME, buildExportNotesMarkdown, mapHarnessToExportPlan, @@ -915,14 +914,14 @@ export class FsProjectManager implements ProjectManager { // payload (--arn) or from the in-project harness files (--name). let harnessName: string; let spec: z.output; - let systemPrompt: string; + // Undefined when the harness sets no prompt, so the export keeps the harness contract prompt. + let systemPrompt: string | undefined; let harnessDir: string | undefined; if (input.prefetched) { spec = input.prefetched.spec; harnessName = spec.name; const prompt = input.prefetched.systemPrompt?.trim(); - systemPrompt = - prompt && prompt.length > 0 ? prompt : (spec.systemPrompt ?? DEFAULT_EXPORT_SYSTEM_PROMPT); + systemPrompt = prompt && prompt.length > 0 ? prompt : spec.systemPrompt; } else { harnessName = input.harnessName!; const entry = projectSpec.harnesses.find((candidate) => candidate.name === harnessName); @@ -947,7 +946,7 @@ export class FsProjectManager implements ProjectManager { ); } spec = parsed.data; - systemPrompt = spec.systemPrompt ?? DEFAULT_EXPORT_SYSTEM_PROMPT; + systemPrompt = spec.systemPrompt; if (spec.systemPrompt === undefined) { const promptPath = join(harnessDir, "system-prompt.md"); try { @@ -960,7 +959,7 @@ export class FsProjectManager implements ProjectManager { }); } } - if (!systemPrompt.trim()) { + if (systemPrompt !== undefined && !systemPrompt.trim()) { throw new InputValidationError( `System prompt file '${promptPath}' is empty or whitespace-only.`, ); diff --git a/src/core/project/templates/export.test.ts b/src/core/project/templates/export.test.ts index 14f8e841d7..ad3784211e 100644 --- a/src/core/project/templates/export.test.ts +++ b/src/core/project/templates/export.test.ts @@ -130,7 +130,7 @@ describe("mapHarnessToExportPlan model mapping", () => { }); expect(result.context.modelProvider).toBe("OpenAI"); - expect(result.context.strandsExtras).toBe("openai"); + expect(result.context.strandsExtras).toBe("openai,web-fetch"); expect(result.context.modelApiFormat).toBe("responses"); expect(result.context.modelMaxTokens).toBe("768"); expect(result.context.modelTemperature).toBe("0.2"); @@ -161,7 +161,7 @@ describe("mapHarnessToExportPlan model mapping", () => { }); expect(result.context.modelProvider).toBe("Gemini"); - expect(result.context.strandsExtras).toBe("gemini"); + expect(result.context.strandsExtras).toBe("gemini,web-fetch"); expect(result.credentials).toEqual([]); }); @@ -181,7 +181,7 @@ describe("mapHarnessToExportPlan model mapping", () => { }); expect(result.context.modelProvider).toBe("LiteLLM"); - expect(result.context.strandsExtras).toBe("litellm"); + expect(result.context.strandsExtras).toBe("litellm,web-fetch"); expect(result.context.litellmApiBase).toBe("https://litellm.example"); expect(result.context.modelAdditionalParams).toEqual({ max_retries: 2 }); expect(result.context.modelMaxTokens).toBe("300"); @@ -219,6 +219,34 @@ describe("mapHarnessToExportPlan model mapping", () => { }); }); + test("caches Bedrock Converse prompts unless the parameters already place a cache point", () => { + expect(plan({}).context.bedrockPromptCaching).toBe(true); + const mantle = plan({ + spec: harness({ + model: { provider: "bedrock", modelId: "openai.gpt-5.5", apiFormat: "responses" }, + }), + }); + expect(mantle.context.bedrockPromptCaching).toBe(false); + for (const params of [ + { system: [{ cachePoint: { type: "default" } }] }, + { messages: [{ role: "user", content: [{ cachePoint: { type: "default" } }] }] }, + { toolConfig: { tools: [{ cachePoint: { type: "default" } }] } }, + ]) { + expect(plan({ modelAdditionalParams: params }).context.bedrockPromptCaching).toBe(false); + } + // A cachePoint key elsewhere (e.g. a tool schema property) does not count. + expect(plan({ modelAdditionalParams: { cachePoint: true } }).context.bedrockPromptCaching).toBe( + true, + ); + }); + + test("adds the web-fetch extra only when web_fetch is allowed", () => { + expect(plan({}).context.strandsExtras).toBe("web-fetch"); + expect( + plan({ spec: harness({ allowedTools: ["shell"] }) }).context.strandsExtras, + ).toBeUndefined(); + }); + test("warns when a keyless LiteLLM model is not Bedrock-backed", () => { const result = plan({ spec: harness({ model: { provider: "lite_llm", modelId: "openai/gpt-4.1" } }), @@ -377,8 +405,11 @@ describe("mapHarnessToExportPlan tools", () => { test("includes the harness builtins unless allowedTools filters them out", () => { const unrestricted = plan({}); - expect(unrestricted.context.hasShell).toBe(true); - expect(unrestricted.context.hasFileOperations).toBe(true); + expect(unrestricted.context).toMatchObject({ + builtinTools: ["shell", "read", "write", "edit", "web_fetch"], + pluginToolNames: ["todo_write", "retrieve_offloaded_content"], + hasSubagent: true, + }); const restricted = plan({ spec: harness({ @@ -397,8 +428,7 @@ describe("mapHarnessToExportPlan tools", () => { ], }), }); - expect(restricted.context.hasShell).toBe(true); - expect(restricted.context.hasFileOperations).toBe(false); + expect(restricted.context.builtinTools).toEqual(["shell"]); expect(restricted.context.remoteMcpTools).toEqual([ { name: "exa", @@ -413,13 +443,23 @@ describe("mapHarnessToExportPlan tools", () => { describe("mapHarnessToExportPlan allowedTools selection", () => { test("a bare name or glob selects builtins only", () => { - const shellOnly = plan({ spec: harness({ allowedTools: ["shell"] }) }); - expect(shellOnly.context.hasShell).toBe(true); - expect(shellOnly.context.hasFileOperations).toBe(false); + const tools = (allowedTools: string[]) => + plan({ spec: harness({ allowedTools }) }).context.builtinTools; + expect(tools(["shell"])).toEqual(["shell"]); + // file_operations, and globs matching it, still grant read, write, and edit. + expect(tools(["file_*"])).toEqual(["read", "write", "edit"]); + expect(tools(["web_*"])).toEqual(["web_fetch"]); + }); - const fileGlob = plan({ spec: harness({ allowedTools: ["file_*"] }) }); - expect(fileGlob.context.hasShell).toBe(false); - expect(fileGlob.context.hasFileOperations).toBe(true); + test("selects plugins and the subagent by the tool names they vend", () => { + const { context } = plan({ + spec: harness({ + allowedTools: ["todo_write", "retrieve_offloaded_content", "@builtin/subagent"], + }), + }); + expect(context.builtinTools).toEqual([]); + expect(context.pluginToolNames).toEqual(["todo_write", "retrieve_offloaded_content"]); + expect(context.hasSubagent).toBe(true); }); test("keeps an MCP server that @server or * allows and drops it otherwise", () => { @@ -661,6 +701,15 @@ describe("mapHarnessToExportPlan truncation", () => { }); }); + test("summarizes the conversation when the harness sets no truncation", () => { + const result = plan({}); + expect(result.context.truncationStrategy).toBe("summarization"); + expect(result.context.truncationConfig).toEqual({ + summary_ratio: 0.3, + preserve_recent_messages: 10, + }); + }); + test('treats strategy "none" as no conversation manager override', () => { const result = plan({ spec: harness({ truncation: { strategy: "none" } }) }); expect(result.context.truncationStrategy).toBeUndefined(); diff --git a/src/core/project/templates/export.ts b/src/core/project/templates/export.ts index 702fce355c..7fc69f1150 100644 --- a/src/core/project/templates/export.ts +++ b/src/core/project/templates/export.ts @@ -24,7 +24,6 @@ import { resourceNameFromArn } from "../../arn"; type ProjectSpec = z.infer; export const EXPORT_NOTES_FILENAME = "EXPORT_NOTES.md"; -export const DEFAULT_EXPORT_SYSTEM_PROMPT = "You are a helpful assistant."; /** A manual follow-up item recorded while mapping, written to EXPORT_NOTES.md. */ export interface ExportNote { @@ -44,8 +43,11 @@ export interface HarnessExportInput { targetAgentName: string; /** The parsed harness spec (from app//harness.yaml or the service). */ spec: HarnessSpec; - /** The resolved system prompt text (explicit prompt > conventional file > default). */ - systemPrompt: string; + /** + * The resolved system prompt text (explicit prompt > conventional file). Undefined when the + * harness sets none, so the agent keeps the Strands Harness contract prompt default. + */ + systemPrompt?: string; /** The current project spec, for memory lookups and credential dedup. */ projectSpec: ProjectSpec; /** Notes collected while converting a service response into a local harness spec. */ @@ -97,6 +99,9 @@ export const LITELLM_NO_API_KEY_NOTE_CATEGORY = "LiteLLM model may require an AP export const MODEL_API_KEY_NOTE_CATEGORY = "Model API key credential referenced"; export const CONTAINER_IMAGE_NOTE_CATEGORY = "Container image not carried over"; +/** Truncation used when a harness sets none: summarize, keeping recent turns. */ +const DEFAULT_TRUNCATION_CONFIG = { summary_ratio: 0.3, preserve_recent_messages: 10 }; + // ============================================================================ // Public entry point // ============================================================================ @@ -151,6 +156,10 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport envEntries, notes, ); + const builtins = resolveBuiltins(allowedToolPatterns); + const isEnabled = (name: Builtin["name"]) => builtins.some((builtin) => builtin.name === name); + const namesOf = (kind: Builtin["kind"]) => + builtins.filter((builtin) => builtin.kind === kind).map(({ name }) => name); 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; @@ -176,6 +185,10 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport protocol: "HTTP", // Model ...model.context, + // web_fetch needs the SDK's web-fetch extra next to any provider extra. + strandsExtras: isEnabled("web_fetch") + ? [model.context.strandsExtras, "web-fetch"].filter(Boolean).join(",") + : model.context.strandsExtras, // System prompt (written verbatim into main.py) systemPromptText: input.systemPrompt, // Memory @@ -195,8 +208,17 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport // `some` helpers use JS truthiness, where [] is truthy, unlike `{{#if}}`. inlineFunctionTools: undefinedIfEmpty(tools.inlineFunctionTools), remoteMcpTools: undefinedIfEmpty(tools.remoteMcpTools), - hasShell: tools.hasShell, - hasFileOperations: tools.hasFileOperations, + // Built-in tools and plugins. Plain arrays: {{#if}} treats an empty one as false, and + // safeJson renders it as []. + builtinTools: namesOf("tool"), + harnessFileTools: builtins + .filter((builtin) => "alias" in builtin && builtin.alias === "file_operations") + .map(({ name }) => name), + pluginToolNames: namesOf("plugin"), + hasShell: isEnabled("shell"), + hasWebFetch: isEnabled("web_fetch"), + hasContextOffloader: isEnabled("retrieve_offloaded_content"), + hasSubagent: isEnabled("subagent"), // Skills hasSkillsFetcher: skills.hasSkillsFetcher, hasFetchedSkills: skills.hasFetchedSkills, @@ -208,9 +230,7 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport maxTokens: spec.maxTokens, timeoutSeconds: spec.timeoutSeconds, // Conversation truncation - truncationStrategy: - spec.truncation?.strategy === "none" ? undefined : spec.truncation?.strategy, - truncationConfig: resolveTruncationConfig(spec.truncation), + ...resolveTruncationContext(spec.truncation), // Filesystem mounts (informational for the template; tools are harness builtins) sessionStorageMountPath: spec.sessionStoragePath, efsMounts: (spec.efsAccessPoints ?? []).map(({ mountPath }) => ({ mountPath })), @@ -308,6 +328,10 @@ function resolveModel( switch (model.provider) { case "bedrock": { context.modelProvider = "Bedrock"; + // Bedrock Converse models cache the system prompt and tools unless the parameters already + // place a cache point. + context.bedrockPromptCaching = + !isBedrockMantleModel(spec) && !hasBedrockCachePoint(additionalParams); if (isBedrockMantleModel(spec)) { context.bedrockMantle = true; context.strandsExtras = "openai"; @@ -406,6 +430,25 @@ function resolveModel( } } +/** + * Whether Bedrock request parameters already place a cache point where Bedrock honors one: the + * system blocks, message content, or tool list. Other values may legitimately contain a + * "cachePoint" key (a tool schema property), so they are not searched. + */ +function hasBedrockCachePoint(params: Record | undefined): boolean { + if (!params) return false; + const hasCachePoint = (blocks: unknown) => + Array.isArray(blocks) && + blocks.some((block) => typeof block === "object" && block !== null && "cachePoint" in block); + const messages = Array.isArray(params.messages) ? params.messages : []; + const toolConfig = params.toolConfig as { tools?: unknown } | undefined; + return ( + hasCachePoint(params.system) || + messages.some((message) => hasCachePoint((message as { content?: unknown })?.content)) || + hasCachePoint(toolConfig?.tools) + ); +} + /** * Wire a non-Bedrock model's API key through AgentCore Identity: derive the * credential-provider name from the token-vault ARN, reference it from the @@ -542,8 +585,6 @@ interface ToolsResolution { pythonName: string; }[]; }[]; - hasShell: boolean; - hasFileOperations: boolean; } function resolveTools( @@ -557,10 +598,6 @@ function resolveTools( const result: ToolsResolution = { inlineFunctionTools: [], remoteMcpTools: [], - // Builtin tools are always available in the harness runtime; include them - // unless the allowedTools filter excludes them. - hasShell: isBuiltinIncluded("shell", allowedPatterns), - hasFileOperations: isBuiltinIncluded("file_operations", allowedPatterns), }; for (const tool of spec.tools) { @@ -680,6 +717,33 @@ function resolveTools( return result; } +/** + * The harness's built-in tools, the tools its built-in plugins vend, and the subagent tool. Each + * is enabled by an allowedTools selector naming it; `file_operations` still grants the + * read/write/edit tools that replaced it. + */ +const BUILTINS = [ + { name: "shell", kind: "tool" }, + { name: "read", kind: "tool", alias: "file_operations" }, + { name: "write", kind: "tool", alias: "file_operations" }, + { name: "edit", kind: "tool", alias: "file_operations" }, + { name: "web_fetch", kind: "tool" }, + { name: "todo_write", kind: "plugin" }, + { name: "retrieve_offloaded_content", kind: "plugin" }, + { name: "subagent", kind: "subagent" }, +] as const; + +type Builtin = (typeof BUILTINS)[number]; + +/** The builtins the allowedTools filter leaves enabled. */ +function resolveBuiltins(allowedPatterns: string[]): Builtin[] { + return BUILTINS.filter( + (builtin) => + isBuiltinIncluded(builtin.name, allowedPatterns) || + ("alias" in builtin && isBuiltinIncluded(builtin.alias, allowedPatterns)), + ); +} + function undefinedIfEmpty(values: T[]): T[] | undefined { return values.length > 0 ? values : undefined; } @@ -882,6 +946,19 @@ function buildFilesystemConfigurations( // Truncation // ============================================================================ +function resolveTruncationContext(truncation: HarnessTruncationConfig | undefined): { + truncationStrategy?: string; + truncationConfig?: Record; +} { + if (!truncation) { + return { truncationStrategy: "summarization", truncationConfig: DEFAULT_TRUNCATION_CONFIG }; + } + return { + truncationStrategy: truncation.strategy === "none" ? undefined : truncation.strategy, + truncationConfig: resolveTruncationConfig(truncation), + }; +} + function resolveTruncationConfig( truncation: HarnessTruncationConfig | undefined, ): Record | undefined {