@aws/agentcore 1.0.0-preview.13 → 1.0.0-preview.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -17
- package/dist/agent-inspector/index.css +1 -1
- package/dist/agent-inspector/index.js +90 -85
- package/dist/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +966 -106
- package/dist/assets/agents/AGENTS.md +14 -0
- package/dist/assets/cdk/bin/cdk.ts +42 -20
- package/dist/assets/cdk/lib/cdk-stack.ts +19 -14
- package/dist/assets/cdk/test/cdk.test.ts +2 -1
- package/dist/assets/container/python/Dockerfile +4 -0
- package/dist/assets/python/a2a/strands/capabilities/memory/session.py +4 -1
- package/dist/assets/python/agui/strands/capabilities/memory/session.py +4 -1
- package/dist/assets/python/http/googleadk/base/mcp_client/client.py +7 -7
- package/dist/assets/python/http/googleadk/base/pyproject.toml +1 -1
- package/dist/assets/python/http/langchain_langgraph/base/mcp_client/client.py +10 -8
- package/dist/assets/python/http/langchain_langgraph/base/pyproject.toml +1 -1
- package/dist/assets/python/http/openaiagents/base/main.py +19 -9
- package/dist/assets/python/http/openaiagents/base/mcp_client/client.py +4 -4
- package/dist/assets/python/http/openaiagents/base/pyproject.toml +1 -1
- package/dist/assets/python/http/strands/base/main.py +438 -27
- package/dist/assets/python/http/strands/base/mcp_client/client.py +45 -5
- package/dist/assets/python/http/strands/base/model/load.py +3 -3
- package/dist/assets/python/http/strands/base/pyproject.toml +5 -2
- package/dist/assets/python/http/strands/base/skills/fetcher.py +279 -0
- package/dist/assets/python/http/strands/capabilities/execution-limits/hooks/execution_limits.py +54 -0
- package/dist/assets/python/http/strands/capabilities/memory/session.py +4 -1
- package/dist/cli/index.mjs +1274 -1088
- package/dist/lib/errors/types.d.ts +18 -0
- package/dist/lib/errors/types.d.ts.map +1 -1
- package/dist/lib/errors/types.js +28 -1
- package/dist/lib/errors/types.js.map +1 -1
- package/dist/lib/schemas/io/config-io.d.ts.map +1 -1
- package/dist/lib/schemas/io/config-io.js +0 -2
- package/dist/lib/schemas/io/config-io.js.map +1 -1
- package/dist/schema/constants.d.ts +20 -0
- package/dist/schema/constants.d.ts.map +1 -1
- package/dist/schema/constants.js +25 -1
- package/dist/schema/constants.js.map +1 -1
- package/dist/schema/schemas/agent-env.d.ts +35 -0
- package/dist/schema/schemas/agent-env.d.ts.map +1 -1
- package/dist/schema/schemas/agentcore-project.d.ts +184 -25
- package/dist/schema/schemas/agentcore-project.d.ts.map +1 -1
- package/dist/schema/schemas/agentcore-project.js +81 -31
- package/dist/schema/schemas/agentcore-project.js.map +1 -1
- package/dist/schema/schemas/auth.d.ts +175 -0
- package/dist/schema/schemas/auth.d.ts.map +1 -1
- package/dist/schema/schemas/auth.js +126 -3
- package/dist/schema/schemas/auth.js.map +1 -1
- package/dist/schema/schemas/deployed-state.d.ts +140 -46
- package/dist/schema/schemas/deployed-state.d.ts.map +1 -1
- package/dist/schema/schemas/deployed-state.js +39 -14
- package/dist/schema/schemas/deployed-state.js.map +1 -1
- package/dist/schema/schemas/mcp-defs.d.ts +2 -1
- package/dist/schema/schemas/mcp-defs.d.ts.map +1 -1
- package/dist/schema/schemas/mcp-defs.js +3 -2
- package/dist/schema/schemas/mcp-defs.js.map +1 -1
- package/dist/schema/schemas/mcp.d.ts +186 -0
- package/dist/schema/schemas/mcp.d.ts.map +1 -1
- package/dist/schema/schemas/mcp.js +479 -4
- package/dist/schema/schemas/mcp.js.map +1 -1
- package/dist/schema/schemas/primitives/ab-test.d.ts +0 -6
- package/dist/schema/schemas/primitives/ab-test.d.ts.map +1 -1
- package/dist/schema/schemas/primitives/ab-test.js +0 -2
- package/dist/schema/schemas/primitives/ab-test.js.map +1 -1
- package/dist/schema/schemas/primitives/harness.d.ts +209 -11
- package/dist/schema/schemas/primitives/harness.d.ts.map +1 -1
- package/dist/schema/schemas/primitives/harness.js +268 -21
- package/dist/schema/schemas/primitives/harness.js.map +1 -1
- package/dist/schema/schemas/primitives/index.d.ts +6 -8
- package/dist/schema/schemas/primitives/index.d.ts.map +1 -1
- package/dist/schema/schemas/primitives/index.js +6 -5
- package/dist/schema/schemas/primitives/index.js.map +1 -1
- package/dist/schema/schemas/primitives/knowledge-base.d.ts +104 -0
- package/dist/schema/schemas/primitives/knowledge-base.d.ts.map +1 -0
- package/dist/schema/schemas/primitives/knowledge-base.js +99 -0
- package/dist/schema/schemas/primitives/knowledge-base.js.map +1 -0
- package/dist/schema/schemas/primitives/online-eval-config.d.ts +20 -2
- package/dist/schema/schemas/primitives/online-eval-config.d.ts.map +1 -1
- package/dist/schema/schemas/primitives/online-eval-config.js +43 -5
- package/dist/schema/schemas/primitives/online-eval-config.js.map +1 -1
- package/dist/schema/schemas/primitives/policy.d.ts +26 -0
- package/dist/schema/schemas/primitives/policy.d.ts.map +1 -1
- package/dist/schema/schemas/primitives/policy.js +5 -1
- package/dist/schema/schemas/primitives/policy.js.map +1 -1
- package/npm-shrinkwrap.json +195 -330
- package/package.json +10 -8
- package/scripts/bundle.mjs +51 -0
- package/scripts/run-e2e-local.sh +40 -8
- package/dist/schema/schemas/primitives/http-gateway.d.ts +0 -21
- package/dist/schema/schemas/primitives/http-gateway.d.ts.map +0 -1
- package/dist/schema/schemas/primitives/http-gateway.js +0 -34
- package/dist/schema/schemas/primitives/http-gateway.js.map +0 -1
|
@@ -1,23 +1,75 @@
|
|
|
1
1
|
from typing import Any
|
|
2
|
+
{{#if inlineFunctionTools}}
|
|
3
|
+
import json
|
|
2
4
|
|
|
5
|
+
from strands.tools.tools import PythonAgentTool
|
|
6
|
+
from strands.types.tools import ToolResult, ToolUse
|
|
7
|
+
{{/if}}
|
|
3
8
|
from strands import Agent, tool
|
|
9
|
+
{{#if hasSkillsFetcher}}
|
|
10
|
+
from strands import AgentSkills
|
|
11
|
+
{{#if hasFetchedSkills}}
|
|
12
|
+
from skills.fetcher import resolve_s3_skills, resolve_git_skills
|
|
13
|
+
{{/if}}
|
|
14
|
+
{{#if (some gitSkills "credentialArn")}}
|
|
15
|
+
from bedrock_agentcore.services.identity import IdentityClient
|
|
16
|
+
{{/if}}
|
|
17
|
+
{{/if}}
|
|
18
|
+
import asyncio
|
|
19
|
+
{{#if hasShell}}
|
|
20
|
+
import subprocess
|
|
21
|
+
{{/if}}
|
|
22
|
+
{{#if hasFileOperations}}
|
|
23
|
+
import os
|
|
24
|
+
{{/if}}
|
|
25
|
+
{{#if hasExecutionLimits}}
|
|
26
|
+
from strands.tools.executors import SequentialToolExecutor
|
|
27
|
+
from strands.types.exceptions import EventLoopException
|
|
28
|
+
from hooks.execution_limits import ExecutionLimitExceeded, ExecutionLimitsHook
|
|
29
|
+
{{/if}}
|
|
4
30
|
{{#if hasConfigBundle}}
|
|
5
31
|
from strands.hooks import HookProvider, HookRegistry, BeforeInvocationEvent, BeforeToolCallEvent
|
|
32
|
+
{{/if}}
|
|
33
|
+
{{#if truncationStrategy}}
|
|
34
|
+
{{#if (eq truncationStrategy "sliding_window")}}
|
|
35
|
+
from strands.agent.conversation_manager.sliding_window_conversation_manager import SlidingWindowConversationManager
|
|
36
|
+
{{/if}}
|
|
37
|
+
{{#if (eq truncationStrategy "summarization")}}
|
|
38
|
+
from strands.agent.conversation_manager.summarizing_conversation_manager import SummarizingConversationManager
|
|
39
|
+
{{/if}}
|
|
40
|
+
{{else}}
|
|
41
|
+
from strands.agent.conversation_manager.null_conversation_manager import NullConversationManager
|
|
42
|
+
{{/if}}
|
|
43
|
+
{{#if hasConfigBundle}}
|
|
6
44
|
from bedrock_agentcore.runtime.context import BedrockAgentCoreContext
|
|
7
45
|
{{/if}}
|
|
46
|
+
{{#if hasBrowser}}
|
|
47
|
+
from strands_tools.browser import AgentCoreBrowser
|
|
48
|
+
{{/if}}
|
|
49
|
+
{{#if hasCodeInterpreter}}
|
|
50
|
+
from strands_tools.code_interpreter import AgentCoreCodeInterpreter
|
|
51
|
+
{{/if}}
|
|
8
52
|
from bedrock_agentcore.runtime import BedrockAgentCoreApp
|
|
9
53
|
from model.load import load_model
|
|
10
54
|
{{#if hasGateway}}
|
|
11
55
|
from mcp_client.client import get_all_gateway_mcp_clients
|
|
12
|
-
{{else}}
|
|
13
|
-
from mcp_client.client import get_streamable_http_mcp_client
|
|
14
56
|
{{/if}}
|
|
57
|
+
{{#if remoteMcpTools}}
|
|
58
|
+
from mcp_client.client import get_all_remote_mcp_clients
|
|
59
|
+
{{/if}}
|
|
60
|
+
{{#unless (or hasGateway remoteMcpTools)}}
|
|
61
|
+
{{#unless isExportHarness}}
|
|
62
|
+
from mcp_client.client import get_streamable_http_mcp_client
|
|
63
|
+
{{/unless}}
|
|
64
|
+
{{/unless}}
|
|
15
65
|
{{#if hasMemory}}
|
|
16
66
|
from memory.session import get_memory_session_manager
|
|
17
67
|
{{/if}}
|
|
18
|
-
{{#
|
|
68
|
+
{{#unless hasFileOperations}}
|
|
69
|
+
{{#if (or needsOs (some gitSkills "credentialArn"))}}
|
|
19
70
|
import os
|
|
20
71
|
{{/if}}
|
|
72
|
+
{{/unless}}
|
|
21
73
|
{{#if hasPayment}}
|
|
22
74
|
from capabilities.payments.payments import create_payments_plugin, PAYMENT_SYSTEM_PROMPT
|
|
23
75
|
{{/if}}
|
|
@@ -25,22 +77,35 @@ from capabilities.payments.payments import create_payments_plugin, PAYMENT_SYSTE
|
|
|
25
77
|
app = BedrockAgentCoreApp()
|
|
26
78
|
log = app.logger
|
|
27
79
|
|
|
28
|
-
#
|
|
80
|
+
{{#if (or hasGateway remoteMcpTools)}}
|
|
81
|
+
# Define MCP clients for all configured MCP servers (gateways and/or remote MCP)
|
|
82
|
+
mcp_clients = []
|
|
29
83
|
{{#if hasGateway}}
|
|
30
|
-
mcp_clients
|
|
84
|
+
mcp_clients += get_all_gateway_mcp_clients()
|
|
85
|
+
{{/if}}
|
|
86
|
+
{{#if remoteMcpTools}}
|
|
87
|
+
mcp_clients += get_all_remote_mcp_clients()
|
|
88
|
+
{{/if}}
|
|
31
89
|
{{else}}
|
|
90
|
+
{{#unless isExportHarness}}
|
|
91
|
+
# Define a Streamable HTTP MCP Client
|
|
32
92
|
mcp_clients = [get_streamable_http_mcp_client()]
|
|
93
|
+
{{/unless}}
|
|
33
94
|
{{/if}}
|
|
34
95
|
|
|
96
|
+
{{#if systemPromptText}}
|
|
97
|
+
DEFAULT_SYSTEM_PROMPT = """{{escapePyStr systemPromptText}}"""
|
|
98
|
+
{{else}}
|
|
35
99
|
DEFAULT_SYSTEM_PROMPT = """
|
|
36
100
|
You are a helpful assistant. Use tools when appropriate.
|
|
37
|
-
{{#if needsOs}}
|
|
101
|
+
{{#if needsOs}}{{#unless isExportHarness}}
|
|
38
102
|
You have access to the following mounted filesystems. Use file_read, file_write, and list_files with full absolute paths:
|
|
39
103
|
{{#if sessionStorageMountPath}}- {{sessionStorageMountPath}}: ephemeral session storage (lost when session ends)
|
|
40
104
|
{{/if}}{{#each efsMounts}}- {{mountPath}}: EFS persistent storage (persists across sessions and agent restarts)
|
|
41
105
|
{{/each}}{{#each s3Mounts}}- {{mountPath}}: S3 Files persistent storage (durable, backed by S3)
|
|
42
|
-
{{/each}}{{/if}}
|
|
106
|
+
{{/each}}{{/unless}}{{/if}}
|
|
43
107
|
"""
|
|
108
|
+
{{/if}}
|
|
44
109
|
|
|
45
110
|
{{#if hasConfigBundle}}
|
|
46
111
|
DEFAULT_TOOL_DESC = "Return the sum of two numbers"
|
|
@@ -49,6 +114,30 @@ DEFAULT_TOOL_DESC = "Return the sum of two numbers"
|
|
|
49
114
|
# Define a collection of tools used by the model
|
|
50
115
|
tools = []
|
|
51
116
|
|
|
117
|
+
{{#if inlineFunctionTools}}
|
|
118
|
+
# Inline function tools — stop the agent loop so the tool call streams back to the caller
|
|
119
|
+
def _make_inline_tool(name: str, spec: dict) -> PythonAgentTool:
|
|
120
|
+
def _handler(tool: ToolUse, **kwargs: Any) -> ToolResult:
|
|
121
|
+
kwargs.get("request_state", {})["stop_event_loop"] = True
|
|
122
|
+
return {"toolUseId": tool["toolUseId"], "status": "success", "content": [{"text": " "}]}
|
|
123
|
+
_handler.__name__ = name
|
|
124
|
+
return PythonAgentTool(tool_name=name, tool_spec=spec, tool_func=_handler)
|
|
125
|
+
|
|
126
|
+
{{#each inlineFunctionTools}}
|
|
127
|
+
_INLINE_SPEC_{{snakeCase name}} = {
|
|
128
|
+
"name": "{{name}}",
|
|
129
|
+
"description": {{safeJson description}},
|
|
130
|
+
"inputSchema": {"json": json.loads({{pyJsonStr inputSchema}}) },
|
|
131
|
+
}
|
|
132
|
+
tools.append(_make_inline_tool("{{name}}", _INLINE_SPEC_{{snakeCase name}}))
|
|
133
|
+
{{/each}}
|
|
134
|
+
|
|
135
|
+
_INLINE_FUNCTION_NAMES = { {{#each inlineFunctionTools}}"{{name}}"{{#unless @last}}, {{/unless}}{{/each}} }
|
|
136
|
+
|
|
137
|
+
{{else}}
|
|
138
|
+
_INLINE_FUNCTION_NAMES = set()
|
|
139
|
+
|
|
140
|
+
{{#unless isExportHarness}}
|
|
52
141
|
# Define a simple function tool
|
|
53
142
|
{{#if hasConfigBundle}}
|
|
54
143
|
@tool(description=DEFAULT_TOOL_DESC)
|
|
@@ -60,7 +149,116 @@ def add_numbers(a: int, b: int) -> int:
|
|
|
60
149
|
return a+b
|
|
61
150
|
tools.append(add_numbers)
|
|
62
151
|
|
|
63
|
-
{{
|
|
152
|
+
{{/unless}}
|
|
153
|
+
{{/if}}
|
|
154
|
+
{{#if hasBrowser}}
|
|
155
|
+
tools.append(AgentCoreBrowser({{#if browserIdentifier}}identifier="{{browserIdentifier}}"{{/if}}).browser)
|
|
156
|
+
{{/if}}
|
|
157
|
+
{{#if hasCodeInterpreter}}
|
|
158
|
+
tools.append(AgentCoreCodeInterpreter({{#if codeInterpreterIdentifier}}identifier="{{codeInterpreterIdentifier}}"{{/if}}).code_interpreter)
|
|
159
|
+
{{/if}}
|
|
160
|
+
{{#if hasShell}}
|
|
161
|
+
@tool
|
|
162
|
+
def shell(command: str, timeout: int = 300) -> dict:
|
|
163
|
+
"""Execute a bash command and return the results.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
command: The bash command to execute
|
|
167
|
+
timeout: Timeout in seconds (default: 300)
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Dict with stdout, stderr, and exit_code
|
|
171
|
+
"""
|
|
172
|
+
result = subprocess.run(
|
|
173
|
+
command, shell=True, capture_output=True, text=True, timeout=timeout
|
|
174
|
+
)
|
|
175
|
+
return {"stdout": result.stdout, "stderr": result.stderr, "exit_code": result.returncode}
|
|
176
|
+
|
|
177
|
+
tools.append(shell)
|
|
178
|
+
{{/if}}
|
|
179
|
+
{{#if hasFileOperations}}
|
|
180
|
+
@tool
|
|
181
|
+
def file_operations(
|
|
182
|
+
command: str,
|
|
183
|
+
path: str,
|
|
184
|
+
old_str: str = None,
|
|
185
|
+
new_str: str = None,
|
|
186
|
+
file_text: str = None,
|
|
187
|
+
insert_line: int = None,
|
|
188
|
+
view_range: list = None,
|
|
189
|
+
) -> str:
|
|
190
|
+
"""Text editor tool for viewing and modifying files.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
command: The command to execute ("view", "str_replace", "create", "insert")
|
|
194
|
+
path: Path to the file or directory
|
|
195
|
+
old_str: Text to replace (for str_replace command)
|
|
196
|
+
new_str: Replacement text (for str_replace and insert commands)
|
|
197
|
+
file_text: Content for new file (for create command)
|
|
198
|
+
insert_line: Line number to insert after (for insert command)
|
|
199
|
+
view_range: [start_line, end_line] for viewing specific lines (for view command)
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Result of the operation
|
|
203
|
+
"""
|
|
204
|
+
try:
|
|
205
|
+
if command == "view":
|
|
206
|
+
if not os.path.exists(path):
|
|
207
|
+
return f"Error: Path '{path}' does not exist"
|
|
208
|
+
if os.path.isdir(path):
|
|
209
|
+
return "\n".join(os.listdir(path))
|
|
210
|
+
with open(path) as f:
|
|
211
|
+
lines = f.read().splitlines()
|
|
212
|
+
if view_range:
|
|
213
|
+
start, end = view_range
|
|
214
|
+
start_idx = max(0, start - 1)
|
|
215
|
+
end_idx = len(lines) if end == -1 else min(len(lines), end)
|
|
216
|
+
lines = lines[start_idx:end_idx]
|
|
217
|
+
start_num = start_idx + 1
|
|
218
|
+
else:
|
|
219
|
+
start_num = 1
|
|
220
|
+
return "\n".join(f"{start_num + i}: {line}" for i, line in enumerate(lines))
|
|
221
|
+
elif command == "str_replace":
|
|
222
|
+
if old_str is None or new_str is None:
|
|
223
|
+
return "Error: str_replace requires both old_str and new_str parameters"
|
|
224
|
+
if not os.path.exists(path):
|
|
225
|
+
return f"Error: File '{path}' does not exist"
|
|
226
|
+
content = open(path).read()
|
|
227
|
+
if old_str not in content:
|
|
228
|
+
return "Error: Text not found in file"
|
|
229
|
+
count = content.count(old_str)
|
|
230
|
+
if count > 1:
|
|
231
|
+
return f"Error: Text appears {count} times in file. Please be more specific."
|
|
232
|
+
open(path, "w").write(content.replace(old_str, new_str, 1))
|
|
233
|
+
return f"Successfully replaced text in '{path}'"
|
|
234
|
+
elif command == "create":
|
|
235
|
+
if file_text is None:
|
|
236
|
+
return "Error: create requires file_text parameter"
|
|
237
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
238
|
+
open(path, "w").write(file_text)
|
|
239
|
+
return f"Successfully created file '{path}'"
|
|
240
|
+
elif command == "insert":
|
|
241
|
+
if new_str is None or insert_line is None:
|
|
242
|
+
return "Error: insert requires both new_str and insert_line parameters"
|
|
243
|
+
if not os.path.exists(path):
|
|
244
|
+
return f"Error: File '{path}' does not exist"
|
|
245
|
+
lines = open(path).read().splitlines(True)
|
|
246
|
+
if insert_line == 0:
|
|
247
|
+
lines.insert(0, new_str + "\n")
|
|
248
|
+
elif insert_line >= len(lines):
|
|
249
|
+
lines.append(new_str + "\n")
|
|
250
|
+
else:
|
|
251
|
+
lines.insert(insert_line, new_str + "\n")
|
|
252
|
+
open(path, "w").write("".join(lines))
|
|
253
|
+
return f"Successfully inserted text in '{path}' at line {insert_line + 1}"
|
|
254
|
+
else:
|
|
255
|
+
return f"Error: Unknown command '{command}'"
|
|
256
|
+
except Exception as e:
|
|
257
|
+
return f"Error: {e}"
|
|
258
|
+
|
|
259
|
+
tools.append(file_operations)
|
|
260
|
+
{{/if}}
|
|
261
|
+
{{#if needsOs}}{{#unless isExportHarness}}
|
|
64
262
|
_MOUNT_PATHS = [
|
|
65
263
|
{{#if sessionStorageMountPath}}"{{sessionStorageMountPath}}",{{/if}}
|
|
66
264
|
{{#each efsMounts}}"{{mountPath}}",{{/each}}
|
|
@@ -114,12 +312,21 @@ def list_files(path: str) -> str:
|
|
|
114
312
|
return f"Error listing '{path}': {e.strerror}"
|
|
115
313
|
|
|
116
314
|
tools.extend([file_read, file_write, list_files])
|
|
117
|
-
{{/if}}
|
|
315
|
+
{{/unless}}{{/if}}
|
|
118
316
|
|
|
317
|
+
{{#if (or hasGateway remoteMcpTools)}}
|
|
318
|
+
# Add MCP clients to tools
|
|
319
|
+
for mcp_client in mcp_clients:
|
|
320
|
+
if mcp_client:
|
|
321
|
+
tools.append(mcp_client)
|
|
322
|
+
{{else}}
|
|
323
|
+
{{#unless isExportHarness}}
|
|
119
324
|
# Add MCP client to tools if available
|
|
120
325
|
for mcp_client in mcp_clients:
|
|
121
326
|
if mcp_client:
|
|
122
327
|
tools.append(mcp_client)
|
|
328
|
+
{{/unless}}
|
|
329
|
+
{{/if}}
|
|
123
330
|
|
|
124
331
|
{{#if hasConfigBundle}}
|
|
125
332
|
|
|
@@ -155,19 +362,62 @@ class ConfigBundleHook(HookProvider):
|
|
|
155
362
|
|
|
156
363
|
{{/if}}
|
|
157
364
|
|
|
365
|
+
def _make_conversation_manager():
|
|
366
|
+
{{#if truncationStrategy}}
|
|
367
|
+
{{#if (eq truncationStrategy "sliding_window")}}
|
|
368
|
+
{{#if truncationConfig}}
|
|
369
|
+
return SlidingWindowConversationManager(**{{safeJson truncationConfig}}, per_turn=True)
|
|
370
|
+
{{else}}
|
|
371
|
+
return SlidingWindowConversationManager(per_turn=True)
|
|
372
|
+
{{/if}}
|
|
373
|
+
{{else}}
|
|
374
|
+
{{#if truncationConfig}}
|
|
375
|
+
return SummarizingConversationManager(**{{safeJson truncationConfig}})
|
|
376
|
+
{{else}}
|
|
377
|
+
return SummarizingConversationManager()
|
|
378
|
+
{{/if}}
|
|
379
|
+
{{/if}}
|
|
380
|
+
{{else}}
|
|
381
|
+
return NullConversationManager()
|
|
382
|
+
{{/if}}
|
|
383
|
+
|
|
158
384
|
{{#if hasMemory}}
|
|
159
385
|
{{#unless hasPayment}}
|
|
160
386
|
def agent_factory():
|
|
161
387
|
cache = {}
|
|
162
|
-
def get_or_create_agent(session_id, user_id):
|
|
163
|
-
|
|
388
|
+
def get_or_create_agent(session_id, user_id{{#if hasSkillsFetcher}}, skill_plugins=None{{/if}}):
|
|
389
|
+
{{#if actorId}}
|
|
390
|
+
_actor_id = "{{actorId}}"
|
|
391
|
+
{{else}}
|
|
392
|
+
_actor_id = user_id
|
|
393
|
+
{{/if}}
|
|
394
|
+
key = f"{session_id}/{_actor_id}"
|
|
164
395
|
if key not in cache:
|
|
165
396
|
cache[key] = Agent(
|
|
166
397
|
model=load_model(),
|
|
167
|
-
session_manager=get_memory_session_manager(session_id,
|
|
398
|
+
session_manager=get_memory_session_manager(session_id, _actor_id),
|
|
399
|
+
conversation_manager=_make_conversation_manager(),
|
|
168
400
|
system_prompt=DEFAULT_SYSTEM_PROMPT,
|
|
169
|
-
tools=tools
|
|
170
|
-
|
|
401
|
+
tools=tools,
|
|
402
|
+
{{#if hasSkillsFetcher}}
|
|
403
|
+
plugins=skill_plugins or None,
|
|
404
|
+
{{/if}}
|
|
405
|
+
{{#if hasExecutionLimits}}
|
|
406
|
+
tool_executor=SequentialToolExecutor(),
|
|
407
|
+
callback_handler=None,
|
|
408
|
+
{{/if}}
|
|
409
|
+
hooks=[
|
|
410
|
+
{{#if hasExecutionLimits}}
|
|
411
|
+
ExecutionLimitsHook(
|
|
412
|
+
{{#if maxIterations}}max_iterations={{maxIterations}},{{/if}}
|
|
413
|
+
{{#if maxTokens}}max_tokens={{maxTokens}},{{/if}}
|
|
414
|
+
{{#if timeoutSeconds}}timeout_seconds={{timeoutSeconds}},{{/if}}
|
|
415
|
+
),
|
|
416
|
+
{{/if}}
|
|
417
|
+
{{#if hasConfigBundle}}
|
|
418
|
+
ConfigBundleHook(),
|
|
419
|
+
{{/if}}
|
|
420
|
+
],
|
|
171
421
|
)
|
|
172
422
|
return cache[key]
|
|
173
423
|
return get_or_create_agent
|
|
@@ -175,24 +425,45 @@ get_or_create_agent = agent_factory()
|
|
|
175
425
|
{{/unless}}
|
|
176
426
|
{{else}}
|
|
177
427
|
{{#if hasConfigBundle}}
|
|
178
|
-
def create_agent():
|
|
428
|
+
def create_agent({{#if hasSkillsFetcher}}skill_plugins=None{{/if}}):
|
|
179
429
|
return Agent(
|
|
180
430
|
model=load_model(),
|
|
181
431
|
system_prompt=DEFAULT_SYSTEM_PROMPT,
|
|
182
432
|
tools=tools,
|
|
433
|
+
conversation_manager=_make_conversation_manager(),
|
|
434
|
+
{{#if hasSkillsFetcher}}
|
|
435
|
+
plugins=skill_plugins or None,
|
|
436
|
+
{{/if}}
|
|
183
437
|
hooks=[ConfigBundleHook()],
|
|
184
438
|
)
|
|
185
439
|
{{else}}
|
|
186
440
|
{{#unless hasPayment}}
|
|
187
441
|
_agent = None
|
|
188
442
|
|
|
189
|
-
def get_or_create_agent():
|
|
443
|
+
def get_or_create_agent({{#if hasSkillsFetcher}}skill_plugins=None{{/if}}):
|
|
190
444
|
global _agent
|
|
191
445
|
if _agent is None:
|
|
192
446
|
_agent = Agent(
|
|
193
447
|
model=load_model(),
|
|
194
448
|
system_prompt=DEFAULT_SYSTEM_PROMPT,
|
|
195
449
|
tools=tools,
|
|
450
|
+
conversation_manager=_make_conversation_manager(),
|
|
451
|
+
{{#if hasSkillsFetcher}}
|
|
452
|
+
plugins=skill_plugins or None,
|
|
453
|
+
{{/if}}
|
|
454
|
+
{{#if hasExecutionLimits}}
|
|
455
|
+
tool_executor=SequentialToolExecutor(),
|
|
456
|
+
callback_handler=None,
|
|
457
|
+
{{/if}}
|
|
458
|
+
hooks=[
|
|
459
|
+
{{#if hasExecutionLimits}}
|
|
460
|
+
ExecutionLimitsHook(
|
|
461
|
+
{{#if maxIterations}}max_iterations={{maxIterations}},{{/if}}
|
|
462
|
+
{{#if maxTokens}}max_tokens={{maxTokens}},{{/if}}
|
|
463
|
+
{{#if timeoutSeconds}}timeout_seconds={{timeoutSeconds}},{{/if}}
|
|
464
|
+
),
|
|
465
|
+
{{/if}}
|
|
466
|
+
],
|
|
196
467
|
)
|
|
197
468
|
return _agent
|
|
198
469
|
{{/unless}}
|
|
@@ -200,6 +471,42 @@ def get_or_create_agent():
|
|
|
200
471
|
{{/if}}
|
|
201
472
|
|
|
202
473
|
|
|
474
|
+
def _extract_prompt(payload: dict):
|
|
475
|
+
"""Accept harness-style messages[], tool_results[], or plain prompt string payloads."""
|
|
476
|
+
if "messages" in payload:
|
|
477
|
+
return payload["messages"]
|
|
478
|
+
if "tool_results" in payload:
|
|
479
|
+
return [{"role": "user", "content": [{"toolResult": {
|
|
480
|
+
"toolUseId": tr["toolUseId"],
|
|
481
|
+
"status": tr.get("status", "success"),
|
|
482
|
+
"content": tr.get("content", []),
|
|
483
|
+
}} for tr in payload["tool_results"]]}]
|
|
484
|
+
return payload.get("prompt", "")
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _has_inline_function_call(messages) -> bool:
|
|
488
|
+
"""Return True if messages contains an assistant toolUse for an inline function tool."""
|
|
489
|
+
if not _INLINE_FUNCTION_NAMES or not isinstance(messages, list):
|
|
490
|
+
return False
|
|
491
|
+
for msg in messages:
|
|
492
|
+
if msg.get("role") == "assistant":
|
|
493
|
+
for block in msg.get("content", []):
|
|
494
|
+
if isinstance(block, dict) and block.get("toolUse", {}).get("name") in _INLINE_FUNCTION_NAMES:
|
|
495
|
+
return True
|
|
496
|
+
return False
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _is_inline_function_call(event: dict) -> bool:
|
|
500
|
+
"""Check if a contentBlockStart event is for an inline function tool."""
|
|
501
|
+
if not _INLINE_FUNCTION_NAMES:
|
|
502
|
+
return False
|
|
503
|
+
cbs = event.get("contentBlockStart", {})
|
|
504
|
+
start = cbs.get("start", {})
|
|
505
|
+
tool_use = start.get("toolUse") if isinstance(start, dict) else None
|
|
506
|
+
return tool_use is not None and tool_use.get("name") in _INLINE_FUNCTION_NAMES
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
|
|
203
510
|
@app.entrypoint
|
|
204
511
|
async def invoke(payload, context):
|
|
205
512
|
log.info("Invoking Agent.....")
|
|
@@ -211,23 +518,52 @@ async def invoke(payload, context):
|
|
|
211
518
|
payments_plugin = create_payments_plugin(user_id, instrument_id, session_id)
|
|
212
519
|
plugins = [payments_plugin] if payments_plugin else []
|
|
213
520
|
{{/if}}
|
|
521
|
+
{{#if hasSkillsFetcher}}
|
|
522
|
+
skill_paths = [{{#each pathSkills}}{{safeJson this}}{{#unless @last}}, {{/unless}}{{/each}}]
|
|
523
|
+
{{#if s3Skills}}
|
|
524
|
+
s3_skill_sources = [{{#each s3Skills}}{{safeJson this}}{{#unless @last}}, {{/unless}}{{/each}}]
|
|
525
|
+
skill_paths.extend(await asyncio.to_thread(resolve_s3_skills, s3_skill_sources, None))
|
|
526
|
+
{{/if}}
|
|
527
|
+
{{#if gitSkills}}
|
|
528
|
+
git_skill_sources = [
|
|
529
|
+
{{#each gitSkills}}
|
|
530
|
+
dict(url={{safeJson this.url}}{{#if this.path}}, path={{safeJson this.path}}{{/if}}{{#if this.credentialArn}}, credentialArn={{safeJson this.credentialArn}}{{#if this.username}}, username={{safeJson this.username}}{{/if}}{{/if}}),
|
|
531
|
+
{{/each}}
|
|
532
|
+
]
|
|
533
|
+
{{#if (some gitSkills "credentialArn")}}
|
|
534
|
+
_git_identity_client = IdentityClient(os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION", "us-east-1")))
|
|
535
|
+
{{else}}
|
|
536
|
+
_git_identity_client = None
|
|
537
|
+
{{/if}}
|
|
538
|
+
skill_paths.extend(await asyncio.to_thread(resolve_git_skills, git_skill_sources, _git_identity_client))
|
|
539
|
+
{{/if}}
|
|
540
|
+
_skill_plugins = [AgentSkills(skills=skill_paths)] if skill_paths else []
|
|
541
|
+
{{/if}}
|
|
214
542
|
|
|
215
543
|
{{#if hasMemory}}
|
|
216
544
|
{{#if hasPayment}}
|
|
217
545
|
mem_session_id = getattr(context, 'session_id', 'default-session')
|
|
546
|
+
{{#if actorId}}
|
|
547
|
+
mem_user_id = "{{actorId}}"
|
|
548
|
+
{{else}}
|
|
218
549
|
mem_user_id = getattr(context, 'user_id', 'default-user')
|
|
550
|
+
{{/if}}
|
|
219
551
|
agent = Agent(
|
|
220
552
|
model=load_model(),
|
|
221
553
|
session_manager=get_memory_session_manager(mem_session_id, mem_user_id),
|
|
222
554
|
system_prompt=DEFAULT_SYSTEM_PROMPT + PAYMENT_SYSTEM_PROMPT,
|
|
223
555
|
tools=tools,
|
|
224
|
-
plugins=plugins,{{#if hasConfigBundle}}
|
|
556
|
+
plugins=plugins{{#if hasSkillsFetcher}} + _skill_plugins{{/if}},{{#if hasConfigBundle}}
|
|
225
557
|
hooks=[ConfigBundleHook()],{{/if}}
|
|
226
558
|
)
|
|
227
559
|
{{else}}
|
|
228
560
|
session_id = getattr(context, 'session_id', 'default-session')
|
|
561
|
+
{{#if actorId}}
|
|
562
|
+
user_id = "{{actorId}}"
|
|
563
|
+
{{else}}
|
|
229
564
|
user_id = getattr(context, 'user_id', 'default-user')
|
|
230
|
-
|
|
565
|
+
{{/if}}
|
|
566
|
+
agent = get_or_create_agent(session_id, user_id{{#if hasSkillsFetcher}}, _skill_plugins{{/if}})
|
|
231
567
|
{{/if}}
|
|
232
568
|
{{else}}
|
|
233
569
|
{{#if hasPayment}}
|
|
@@ -235,25 +571,100 @@ async def invoke(payload, context):
|
|
|
235
571
|
model=load_model(),
|
|
236
572
|
system_prompt=DEFAULT_SYSTEM_PROMPT + PAYMENT_SYSTEM_PROMPT,
|
|
237
573
|
tools=tools,
|
|
238
|
-
plugins=plugins,{{#if hasConfigBundle}}
|
|
574
|
+
plugins=plugins{{#if hasSkillsFetcher}} + _skill_plugins{{/if}},{{#if hasConfigBundle}}
|
|
239
575
|
hooks=[ConfigBundleHook()],{{/if}}
|
|
240
576
|
)
|
|
241
577
|
{{else}}
|
|
242
578
|
{{#if hasConfigBundle}}
|
|
243
|
-
agent = create_agent()
|
|
579
|
+
agent = create_agent({{#if hasSkillsFetcher}}_skill_plugins{{/if}})
|
|
244
580
|
{{else}}
|
|
245
|
-
agent = get_or_create_agent()
|
|
581
|
+
agent = get_or_create_agent({{#if hasSkillsFetcher}}_skill_plugins{{/if}})
|
|
246
582
|
{{/if}}
|
|
247
583
|
{{/if}}
|
|
248
584
|
{{/if}}
|
|
249
585
|
|
|
250
|
-
|
|
251
|
-
|
|
586
|
+
prompt = _extract_prompt(payload)
|
|
587
|
+
|
|
588
|
+
{{#if inlineFunctionTools}}
|
|
589
|
+
# If Turn 2 carries the harness-style assistant(toolUse)+user(toolResult) pair,
|
|
590
|
+
# strip the placeholder turn Strands stored during Turn 1 so the real toolResult
|
|
591
|
+
# is injected cleanly — same protocol as the harness runtime.
|
|
592
|
+
if _has_inline_function_call(prompt):
|
|
593
|
+
msgs = agent.messages
|
|
594
|
+
if len(msgs) >= 2 and any("toolResult" in b for b in msgs[-1].get("content", [])):
|
|
595
|
+
del msgs[-2:]
|
|
596
|
+
{{/if}}
|
|
597
|
+
|
|
598
|
+
{{#if hasExecutionLimits}}
|
|
599
|
+
timeout_seconds = {{#if timeoutSeconds}}{{timeoutSeconds}}{{else}}None{{/if}}
|
|
600
|
+
timeout_fired = False
|
|
601
|
+
watchdog_task = None
|
|
602
|
+
if timeout_seconds is not None:
|
|
603
|
+
async def _timeout_watchdog():
|
|
604
|
+
nonlocal timeout_fired
|
|
605
|
+
await asyncio.sleep(timeout_seconds)
|
|
606
|
+
timeout_fired = True
|
|
607
|
+
agent.cancel()
|
|
608
|
+
watchdog_task = asyncio.create_task(_timeout_watchdog())
|
|
252
609
|
|
|
253
|
-
|
|
254
|
-
#
|
|
255
|
-
|
|
256
|
-
|
|
610
|
+
try:
|
|
611
|
+
{{#if inlineFunctionTools}}
|
|
612
|
+
hit_inline_function = False
|
|
613
|
+
{{/if}}
|
|
614
|
+
async for event in agent.stream_async(
|
|
615
|
+
prompt,
|
|
616
|
+
):
|
|
617
|
+
if not isinstance(event, dict) or "event" not in event:
|
|
618
|
+
continue
|
|
619
|
+
cbs = event["event"].get("contentBlockStart")
|
|
620
|
+
if cbs is not None and not cbs.get("start"):
|
|
621
|
+
continue
|
|
622
|
+
{{#if inlineFunctionTools}}
|
|
623
|
+
if not hit_inline_function:
|
|
624
|
+
hit_inline_function = _is_inline_function_call(event["event"])
|
|
625
|
+
{{/if}}
|
|
626
|
+
yield event
|
|
627
|
+
{{#if inlineFunctionTools}}
|
|
628
|
+
if hit_inline_function and "messageStop" in event["event"]:
|
|
629
|
+
return
|
|
630
|
+
{{/if}}
|
|
631
|
+
|
|
632
|
+
if timeout_fired:
|
|
633
|
+
yield {"event": {"messageStop": {"stopReason": "timeout_exceeded"}}}
|
|
634
|
+
except EventLoopException as e:
|
|
635
|
+
if isinstance(e.original_exception, ExecutionLimitExceeded):
|
|
636
|
+
yield {"event": {"messageStop": {"stopReason": str(e.original_exception)}}}
|
|
637
|
+
return
|
|
638
|
+
raise
|
|
639
|
+
finally:
|
|
640
|
+
if watchdog_task is not None:
|
|
641
|
+
watchdog_task.cancel()
|
|
642
|
+
try:
|
|
643
|
+
await watchdog_task
|
|
644
|
+
except asyncio.CancelledError:
|
|
645
|
+
pass
|
|
646
|
+
{{else}}
|
|
647
|
+
{{#if inlineFunctionTools}}
|
|
648
|
+
hit_inline_function = False
|
|
649
|
+
{{/if}}
|
|
650
|
+
async for event in agent.stream_async(
|
|
651
|
+
prompt,
|
|
652
|
+
):
|
|
653
|
+
if not isinstance(event, dict) or "event" not in event:
|
|
654
|
+
continue
|
|
655
|
+
cbs = event["event"].get("contentBlockStart")
|
|
656
|
+
if cbs is not None and not cbs.get("start"):
|
|
657
|
+
continue
|
|
658
|
+
{{#if inlineFunctionTools}}
|
|
659
|
+
if not hit_inline_function:
|
|
660
|
+
hit_inline_function = _is_inline_function_call(event["event"])
|
|
661
|
+
{{/if}}
|
|
662
|
+
yield event
|
|
663
|
+
{{#if inlineFunctionTools}}
|
|
664
|
+
if hit_inline_function and "messageStop" in event["event"]:
|
|
665
|
+
return
|
|
666
|
+
{{/if}}
|
|
667
|
+
{{/if}}
|
|
257
668
|
|
|
258
669
|
|
|
259
670
|
if __name__ == "__main__":
|
|
@@ -29,18 +29,22 @@ def _get_bearer_token_{{snakeCase name}}(*, access_token: str):
|
|
|
29
29
|
{{#each gatewayProviders}}
|
|
30
30
|
def get_{{snakeCase name}}_mcp_client() -> MCPClient | None:
|
|
31
31
|
"""Returns an MCP Client connected to the {{name}} gateway."""
|
|
32
|
+
{{#if hardcodedUrl}}
|
|
33
|
+
url = {{safeJson hardcodedUrl}}
|
|
34
|
+
{{else}}
|
|
32
35
|
url = os.environ.get("{{envVarName}}")
|
|
33
36
|
if not url:
|
|
34
37
|
logger.warning("{{envVarName}} not set — {{name}} gateway tools unavailable")
|
|
35
38
|
return None
|
|
39
|
+
{{/if}}
|
|
36
40
|
{{#if (eq authType "AWS_IAM")}}
|
|
37
|
-
return MCPClient(lambda: aws_iam_streamablehttp_client(url, aws_service="bedrock-agentcore", aws_region=os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION"))))
|
|
41
|
+
return MCPClient(lambda: aws_iam_streamablehttp_client(url, aws_service="bedrock-agentcore", aws_region=os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION"))), prefix="{{snakeCase name}}")
|
|
38
42
|
{{else if (eq authType "CUSTOM_JWT")}}
|
|
39
43
|
token = _get_bearer_token_{{snakeCase name}}()
|
|
40
44
|
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
|
41
|
-
return MCPClient(lambda: streamablehttp_client(url, headers=headers))
|
|
45
|
+
return MCPClient(lambda: streamablehttp_client(url, headers=headers), prefix="{{snakeCase name}}")
|
|
42
46
|
{{else}}
|
|
43
|
-
return MCPClient(lambda: streamablehttp_client(url))
|
|
47
|
+
return MCPClient(lambda: streamablehttp_client(url), prefix="{{snakeCase name}}")
|
|
44
48
|
{{/if}}
|
|
45
49
|
|
|
46
50
|
{{/each}}
|
|
@@ -53,7 +57,41 @@ def get_all_gateway_mcp_clients() -> list[MCPClient]:
|
|
|
53
57
|
clients.append(client)
|
|
54
58
|
{{/each}}
|
|
55
59
|
return clients
|
|
56
|
-
{{
|
|
60
|
+
{{/if}}
|
|
61
|
+
{{#if remoteMcpTools}}
|
|
62
|
+
{{#if (some remoteMcpTools "headerCredentials")}}
|
|
63
|
+
from bedrock_agentcore.identity.auth import requires_api_key
|
|
64
|
+
{{/if}}
|
|
65
|
+
{{#each remoteMcpTools}}
|
|
66
|
+
{{#if headerCredentials}}
|
|
67
|
+
{{#each headerCredentials}}
|
|
68
|
+
@requires_api_key(provider_name="{{credentialName}}")
|
|
69
|
+
def _get_{{snakeCase ../name}}_{{snakeCase headerKey}}_key(api_key: str) -> str:
|
|
70
|
+
"""Fetch {{headerKey}} credential for {{../name}} from AgentCore Identity."""
|
|
71
|
+
return api_key
|
|
72
|
+
|
|
73
|
+
{{/each}}
|
|
74
|
+
{{/if}}
|
|
75
|
+
def get_{{snakeCase name}}_mcp_client() -> MCPClient | None:
|
|
76
|
+
"""Returns an MCP Client for the {{name}} remote MCP server."""
|
|
77
|
+
url = {{safeJson url}}
|
|
78
|
+
{{#if headerCredentials}}
|
|
79
|
+
if os.getenv("LOCAL_DEV") == "1":
|
|
80
|
+
headers = { {{#each headerCredentials}}{{safeJson headerKey}}: os.environ.get("{{envVarName}}", ""){{#unless @last}}, {{/unless}}{{/each}} }
|
|
81
|
+
else:
|
|
82
|
+
headers = { {{#each headerCredentials}}{{safeJson headerKey}}: _get_{{snakeCase ../name}}_{{snakeCase headerKey}}_key(){{#unless @last}}, {{/unless}}{{/each}} }
|
|
83
|
+
return MCPClient(lambda: streamablehttp_client(url, headers=headers))
|
|
84
|
+
{{else}}
|
|
85
|
+
return MCPClient(lambda: streamablehttp_client(url))
|
|
86
|
+
{{/if}}
|
|
87
|
+
|
|
88
|
+
{{/each}}
|
|
89
|
+
def get_all_remote_mcp_clients() -> list[MCPClient]:
|
|
90
|
+
"""Returns all configured remote MCP clients."""
|
|
91
|
+
clients = [{{#each remoteMcpTools}}get_{{snakeCase name}}_mcp_client(){{#unless @last}}, {{/unless}}{{/each}}]
|
|
92
|
+
return [c for c in clients if c is not None]
|
|
93
|
+
{{/if}}
|
|
94
|
+
{{#unless (or hasGateway remoteMcpTools)}}
|
|
57
95
|
{{#if isVpc}}
|
|
58
96
|
# VPC mode: external MCP endpoints are not reachable without a NAT gateway.
|
|
59
97
|
# Add an AgentCore Gateway with `agentcore add gateway`, or configure your own endpoint below.
|
|
@@ -62,6 +100,7 @@ def get_streamable_http_mcp_client() -> MCPClient | None:
|
|
|
62
100
|
"""No MCP server configured. Add a gateway with `agentcore add gateway`."""
|
|
63
101
|
return None
|
|
64
102
|
{{else}}
|
|
103
|
+
{{#unless isExportHarness}}
|
|
65
104
|
# ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication
|
|
66
105
|
EXAMPLE_MCP_ENDPOINT = "https://mcp.exa.ai/mcp"
|
|
67
106
|
|
|
@@ -69,5 +108,6 @@ def get_streamable_http_mcp_client() -> MCPClient:
|
|
|
69
108
|
"""Returns an MCP Client compatible with Strands"""
|
|
70
109
|
# to use an MCP server that supports bearer authentication, add headers={"Authorization": f"Bearer {access_token}"}
|
|
71
110
|
return MCPClient(lambda: streamablehttp_client(EXAMPLE_MCP_ENDPOINT))
|
|
111
|
+
{{/unless}}
|
|
72
112
|
{{/if}}
|
|
73
|
-
{{/
|
|
113
|
+
{{/unless}}
|