Skip to content

Commit bf24fca

Browse files
siwachabhiAbhimanyu Siwach
andauthored
feat: Improve multi-agent entrypoint handling (#270)
* Improve entrypoint handling for multi-agent * Add unit tests * Fix integ test and improve ecr name handling * Fix integ test assertion --------- Signed-off-by: Abhimanyu Siwach <128322948+siwachabhi@users.noreply.github.com> Co-authored-by: Abhimanyu Siwach <siwabhi@amazon.com>
1 parent 500d4f4 commit bf24fca

File tree

20 files changed

+1114
-278
lines changed

20 files changed

+1114
-278
lines changed

documentation/docs/examples/agentcore-quickstart-example.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ pip install --no-cache-dir "bedrock-agentcore-starter-toolkit>=0.1.21" strands-a
342342
1. Clean up resources in the incorrect region:
343343
```bash
344344
agentcore destroy
345-
345+
346346
# This removes:
347347
# - Runtime endpoint and agent
348348
# - Memory resources (STM + LTM)

src/bedrock_agentcore_starter_toolkit/cli/common.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def _handle_error(message: str, exception: Optional[Exception] = None) -> NoRetu
2020

2121
def _handle_warn(message: str) -> None:
2222
"""Handle errors with consistent formatting and exit."""
23-
console.print(f"⚠️ {message}", new_line_start=True, style="bold yellow underline")
23+
console.print(f"⚠️ {message}", new_line_start=True, style="yellow")
2424

2525

2626
def _print_success(message: str) -> None:

src/bedrock_agentcore_starter_toolkit/cli/runtime/commands.py

Lines changed: 146 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,16 @@
1515
from ...operations.runtime import (
1616
configure_bedrock_agentcore,
1717
destroy_bedrock_agentcore,
18+
detect_entrypoint,
19+
detect_requirements,
20+
get_relative_path,
1821
get_status,
22+
infer_agent_name,
1923
invoke_bedrock_agentcore,
2024
launch_bedrock_agentcore,
2125
validate_agent_name,
2226
)
2327
from ...utils.runtime.config import load_config
24-
from ...utils.runtime.entrypoint import parse_entrypoint
2528
from ...utils.runtime.logs import get_agent_log_paths, get_aws_tail_commands, get_genai_observability_url
2629
from ..common import _handle_error, _print_success, console
2730
from .configuration_manager import ConfigurationManager
@@ -47,22 +50,50 @@ def _show_configuration_not_found_panel():
4750

4851

4952
def _validate_requirements_file(file_path: str) -> str:
50-
"""Validate requirements file and return the path."""
53+
"""Validate requirements file and return the absolute path."""
5154
from ...utils.runtime.entrypoint import validate_requirements_file
5255

5356
try:
5457
deps = validate_requirements_file(Path.cwd(), file_path)
55-
_print_success(f"Using requirements file: [dim]{deps.resolved_path}[/dim]")
56-
return file_path
58+
rel_path = get_relative_path(Path(deps.resolved_path))
59+
_print_success(f"Using requirements file: [dim]{rel_path}[/dim]")
60+
# Return absolute path for consistency with entrypoint handling
61+
return str(Path(deps.resolved_path).resolve())
5762
except (FileNotFoundError, ValueError) as e:
5863
_handle_error(str(e), e)
5964

6065

61-
def _prompt_for_requirements_file(prompt_text: str, default: str = "") -> Optional[str]:
62-
"""Prompt user for requirements file path with validation."""
63-
response = prompt(prompt_text, completer=PathCompleter(), default=default)
66+
def _prompt_for_requirements_file(prompt_text: str, source_path: str, default: str = "") -> Optional[str]:
67+
"""Prompt user for requirements file path with validation.
68+
69+
Args:
70+
prompt_text: Prompt message to display
71+
source_path: Source directory path for validation
72+
default: Default path to pre-populate
73+
"""
74+
# Pre-populate with relative source directory path if no default provided
75+
if not default:
76+
rel_source = get_relative_path(Path(source_path))
77+
default = f"{rel_source}/"
78+
79+
# Use PathCompleter without filter - allow navigation anywhere
80+
response = prompt(prompt_text, completer=PathCompleter(), complete_while_typing=True, default=default)
6481

6582
if response.strip():
83+
# Validate file exists and is in source directory
84+
req_file = Path(response.strip()).resolve()
85+
source_dir = Path(source_path).resolve()
86+
87+
# Check if requirements file is within source directory
88+
try:
89+
if not req_file.is_relative_to(source_dir):
90+
rel_source = get_relative_path(source_dir)
91+
console.print(f"[red]Error: Requirements file must be in source directory: {rel_source}[/red]")
92+
return _prompt_for_requirements_file(prompt_text, source_path, default)
93+
except (ValueError, AttributeError):
94+
# is_relative_to not available or other error - skip validation
95+
pass
96+
6697
return _validate_requirements_file(response.strip())
6798

6899
return None
@@ -76,56 +107,75 @@ def _handle_requirements_file_display(
76107
Args:
77108
requirements_file: Explicit requirements file path
78109
non_interactive: Whether to skip interactive prompts
79-
source_path: Optional source code directory (checks here first, then falls back to project root)
110+
source_path: Optional source code directory
80111
"""
81-
from ...utils.runtime.entrypoint import detect_dependencies
82-
83112
if requirements_file:
84113
# User provided file - validate and show confirmation
85114
return _validate_requirements_file(requirements_file)
86115

87-
# Detect dependencies:
88-
# - If source_path provided: check source_path only
89-
# - Otherwise: check project root (Path.cwd())
90-
if source_path:
91-
source_dir = Path(source_path)
92-
deps = detect_dependencies(source_dir)
93-
else:
94-
# No source_path, check project root
95-
deps = detect_dependencies(Path.cwd())
116+
# Use operations layer for detection - source_path is always provided
117+
deps = detect_requirements(Path(source_path))
96118

97119
if non_interactive:
98120
# Auto-detection for non-interactive mode
99121
if deps.found:
100-
_print_success(f"Using detected file: [dim]{deps.file}[/dim]")
122+
rel_deps_path = get_relative_path(Path(deps.resolved_path))
123+
_print_success(f"Using detected requirements file: [cyan]{rel_deps_path}[/cyan]")
101124
return None # Use detected file
102125
else:
103126
_handle_error("No requirements file specified and none found automatically")
104127

105128
# Auto-detection with interactive prompt
106129
if deps.found:
107-
console.print(f"\n🔍 [cyan]Detected dependency file:[/cyan] [bold]{deps.file}[/bold]")
130+
rel_deps_path = get_relative_path(Path(deps.resolved_path))
131+
132+
console.print(f"\n🔍 [cyan]Detected dependency file:[/cyan] [bold]{rel_deps_path}[/bold]")
108133
console.print("[dim]Press Enter to use this file, or type a different path (use Tab for autocomplete):[/dim]")
109134

110-
result = _prompt_for_requirements_file("Path or Press Enter to use detected dependency file: ", default="")
135+
result = _prompt_for_requirements_file(
136+
"Path or Press Enter to use detected dependency file: ", source_path=source_path, default=rel_deps_path
137+
)
111138

112139
if result is None:
113140
# Use detected file
114-
_print_success(f"Using detected file: [dim]{deps.file}[/dim]")
141+
_print_success(f"Using detected requirements file: [cyan]{rel_deps_path}[/cyan]")
115142

116143
return result
117144
else:
118145
console.print("\n[yellow]⚠️ No dependency file found (requirements.txt or pyproject.toml)[/yellow]")
119146
console.print("[dim]Enter path to requirements file (use Tab for autocomplete), or press Enter to skip:[/dim]")
120147

121-
result = _prompt_for_requirements_file("Path: ")
148+
result = _prompt_for_requirements_file("Path: ", source_path=source_path)
122149

123150
if result is None:
124151
_handle_error("No requirements file specified and none found automatically")
125152

126153
return result
127154

128155

156+
def _detect_entrypoint_in_source(source_path: str, non_interactive: bool = False) -> str:
157+
"""Detect entrypoint file in source directory with CLI display."""
158+
source_dir = Path(source_path)
159+
160+
# Use operations layer for detection
161+
detected = detect_entrypoint(source_dir)
162+
163+
if not detected:
164+
# No fallback prompt - fail with clear error message
165+
rel_source = get_relative_path(source_dir)
166+
_handle_error(
167+
f"No entrypoint file found in {rel_source}\n"
168+
f"Expected one of: main.py, agent.py, app.py, __main__.py\n"
169+
f"Please specify full file path (e.g., {rel_source}/your_agent.py)"
170+
)
171+
172+
# Show detection and confirm
173+
rel_entrypoint = get_relative_path(detected)
174+
175+
_print_success(f"Using entrypoint file: [cyan]{rel_entrypoint}[/cyan]")
176+
return str(detected)
177+
178+
129179
# Define options at module level to avoid B008
130180
ENV_OPTION = typer.Option(None, "--env", "-env", help="Environment variables for local mode (format: KEY=VALUE)")
131181

@@ -179,7 +229,12 @@ def set_default(name: str = typer.Argument(...)):
179229
@configure_app.callback(invoke_without_command=True)
180230
def configure(
181231
ctx: typer.Context,
182-
entrypoint: Optional[str] = typer.Option(None, "--entrypoint", "-e", help="Python file with BedrockAgentCoreApp"),
232+
entrypoint: Optional[str] = typer.Option(
233+
None,
234+
"--entrypoint",
235+
"-e",
236+
help="Entry point: file path (e.g., agent.py) or directory path (auto-detects main.py, agent.py, app.py)",
237+
),
183238
agent_name: Optional[str] = typer.Option(None, "--name", "-n"),
184239
execution_role: Optional[str] = typer.Option(None, "--execution-role", "-er"),
185240
code_build_execution_role: Optional[str] = typer.Option(None, "--code-build-execution-role", "-cber"),
@@ -206,35 +261,75 @@ def configure(
206261
non_interactive: bool = typer.Option(
207262
False, "--non-interactive", "-ni", help="Skip prompts; use defaults unless overridden"
208263
),
209-
source_path: Optional[str] = typer.Option(None, "--source-path", "-sp", help="Path to agent source code directory"),
210264
):
211-
"""Configure a Bedrock AgentCore agent. The agent name defaults to your Python file name."""
265+
"""Configure a Bedrock AgentCore agent interactively or with parameters.
266+
267+
Examples:
268+
agentcore configure # Fully interactive (current directory)
269+
agentcore configure --entrypoint writer/ # Directory (auto-detect entrypoint)
270+
agentcore configure --entrypoint agent.py # File (use as entrypoint)
271+
"""
212272
if ctx.invoked_subcommand is not None:
213273
return
214274

215-
if not entrypoint:
216-
_handle_error("--entrypoint is required")
217-
218275
if protocol and protocol.upper() not in ["HTTP", "MCP", "A2A"]:
219276
_handle_error("Error: --protocol must be either HTTP or MCP or A2A")
220277

221278
console.print("[cyan]Configuring Bedrock AgentCore...[/cyan]")
222-
try:
223-
_, file_name = parse_entrypoint(entrypoint)
224-
agent_name = agent_name or file_name
225-
226-
valid, error = validate_agent_name(agent_name)
227-
if not valid:
228-
_handle_error(error)
229-
230-
console.print(f"[dim]Agent name: {agent_name}[/dim]")
231-
except ValueError as e:
232-
_handle_error(f"Error: {e}", e)
233279

234-
# Create configuration manager for clean, elegant prompting
280+
# Create configuration manager early for consistent prompting
235281
config_path = Path.cwd() / ".bedrock_agentcore.yaml"
236282
config_manager = ConfigurationManager(config_path, non_interactive)
237283

284+
# Interactive entrypoint selection
285+
if not entrypoint:
286+
if non_interactive:
287+
entrypoint_input = "."
288+
else:
289+
console.print("\n📂 [cyan]Entrypoint Selection[/cyan]")
290+
console.print("[dim]Specify the entry point (use Tab for autocomplete):[/dim]")
291+
console.print("[dim] • File path: weather/agent.py[/dim]")
292+
console.print("[dim] • Directory: weather/ (auto-detects main.py, agent.py, app.py)[/dim]")
293+
console.print("[dim] • Current directory: press Enter[/dim]")
294+
295+
entrypoint_input = (
296+
prompt("Entrypoint: ", completer=PathCompleter(), complete_while_typing=True, default="").strip() or "."
297+
)
298+
else:
299+
entrypoint_input = entrypoint
300+
301+
# Resolve the entrypoint_input (handles both file and directory)
302+
entrypoint_path = Path(entrypoint_input).resolve()
303+
304+
if entrypoint_path.is_file():
305+
# It's a file - use directly as entrypoint
306+
entrypoint = str(entrypoint_path)
307+
source_path = str(entrypoint_path.parent)
308+
if not non_interactive:
309+
rel_path = get_relative_path(entrypoint_path)
310+
_print_success(f"Using file: {rel_path}")
311+
elif entrypoint_path.is_dir():
312+
# It's a directory - detect entrypoint within it
313+
source_path = str(entrypoint_path)
314+
entrypoint = _detect_entrypoint_in_source(source_path, non_interactive)
315+
else:
316+
_handle_error(f"Path not found: {entrypoint_input}")
317+
318+
# Process agent name
319+
entrypoint_path = Path(entrypoint)
320+
321+
# Infer agent name from full entrypoint path (e.g., agents/writer/main.py -> agents_writer_main)
322+
if not agent_name:
323+
suggested_name = infer_agent_name(entrypoint_path)
324+
agent_name = config_manager.prompt_agent_name(suggested_name)
325+
326+
valid, error = validate_agent_name(agent_name)
327+
if not valid:
328+
_handle_error(error)
329+
330+
# Handle dependency file selection with simplified logic
331+
final_requirements_file = _handle_requirements_file_display(requirements_file, non_interactive, source_path)
332+
238333
# Interactive prompts for missing values - clean and elegant
239334
if not execution_role:
240335
execution_role = config_manager.prompt_execution_role()
@@ -253,9 +348,6 @@ def configure(
253348
auto_create_ecr = False
254349
_print_success(f"Using existing ECR repository: [dim]{ecr_repository}[/dim]")
255350

256-
# Handle dependency file selection with simplified logic
257-
final_requirements_file = _handle_requirements_file_display(requirements_file, non_interactive, source_path)
258-
259351
# Handle OAuth authorization configuration
260352
oauth_config = None
261353
if authorizer_config:
@@ -318,26 +410,26 @@ def configure(
318410
headers = request_header_config.get("requestHeaderAllowlist", [])
319411
headers_info = f"Request Headers Allowlist: [dim]{len(headers)} headers configured[/dim]\n"
320412

413+
execution_role_display = "Auto-create" if not result.execution_role else result.execution_role
321414
memory_info = "Short-term memory (30-day retention)"
322415
if disable_memory:
323416
memory_info = "Disabled"
324417

325418
console.print(
326419
Panel(
327-
f"[green]Configuration Complete[/green]\n\n"
328-
f"[bold]Agent Details:[/bold]\n"
420+
f"[bold]Agent Details[/bold]\n"
329421
f"Agent Name: [cyan]{agent_name}[/cyan]\n"
330422
f"Runtime: [cyan]{result.runtime}[/cyan]\n"
331423
f"Region: [cyan]{result.region}[/cyan]\n"
332-
f"Account: [dim]{result.account_id}[/dim]\n\n"
333-
f"[bold]Configuration:[/bold]\n"
334-
f"Execution Role: [dim]{result.execution_role}[/dim]\n"
335-
f"ECR Repository: [dim]"
424+
f"Account: [cyan]{result.account_id}[/cyan]\n\n"
425+
f"[bold]Configuration[/bold]\n"
426+
f"Execution Role: [cyan]{execution_role_display}[/cyan]\n"
427+
f"ECR Repository: [cyan]"
336428
f"{'Auto-create' if result.auto_create_ecr else result.ecr_repository or 'N/A'}"
337-
f"[/dim]\n"
338-
f"Authorization: [dim]{auth_info}[/dim]\n\n"
429+
f"[/cyan]\n"
430+
f"Authorization: [cyan]{auth_info}[/cyan]\n\n"
339431
f"{headers_info}\n"
340-
f"Memory: [dim]{memory_info}[/dim]\n\n"
432+
f"Memory: [cyan]{memory_info}[/cyan]\n\n"
341433
f"📄 Config saved to: [dim]{result.config_path}[/dim]\n\n"
342434
f"[bold]Next Steps:[/bold]\n"
343435
f" [cyan]agentcore launch[/cyan]",
@@ -501,7 +593,6 @@ def launch(
501593
region = agent_config.aws.region if agent_config else "us-east-1"
502594

503595
deploy_panel = (
504-
f"✅ [green]CodeBuild Deployment Successful![/green]\n\n"
505596
f"[bold]Agent Details:[/bold]\n"
506597
f"Agent Name: [cyan]{agent_name}[/cyan]\n"
507598
f"Agent ARN: [cyan]{result.agent_arn}[/cyan]\n"
@@ -541,15 +632,12 @@ def launch(
541632

542633
if local_build:
543634
title = "Local Build Success"
544-
deployment_type = "✅ [green]Local Build Deployment Successful![/green]"
545635
icon = "🔧"
546636
else:
547637
title = "Deployment Success"
548-
deployment_type = "✅ [green]Deployment Successful![/green]"
549638
icon = "🚀"
550639

551640
deploy_panel = (
552-
f"{deployment_type}\n\n"
553641
f"[bold]Agent Details:[/bold]\n"
554642
f"Agent Name: [cyan]{agent_name}[/cyan]\n"
555643
f"Agent ARN: [cyan]{result.agent_arn}[/cyan]\n"

src/bedrock_agentcore_starter_toolkit/cli/runtime/configuration_manager.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,29 @@ def __init__(self, config_path: Path, non_interactive: bool = False, region: Opt
2525
self.non_interactive = non_interactive
2626
self.region = region
2727

28+
def prompt_agent_name(self, suggested_name: str) -> str:
29+
"""Prompt for agent name with a suggested default.
30+
31+
Args:
32+
suggested_name: The suggested agent name based on entrypoint path
33+
34+
Returns:
35+
The selected or entered agent name
36+
"""
37+
if self.non_interactive:
38+
_print_success(f"Agent name (inferred): {suggested_name}")
39+
return suggested_name
40+
41+
console.print(f"\n🏷️ [cyan]Inferred agent name[/cyan]: {suggested_name}")
42+
console.print("[dim]Press Enter to use this name, or type a different one (alphanumeric without '-')[/dim]")
43+
agent_name = _prompt_with_default("Agent name", suggested_name)
44+
45+
if not agent_name:
46+
agent_name = suggested_name
47+
48+
_print_success(f"Using agent name: [cyan]{agent_name}[/cyan]")
49+
return agent_name
50+
2851
def prompt_execution_role(self) -> Optional[str]:
2952
"""Prompt for execution role. Returns role name/ARN or None for auto-creation."""
3053
if self.non_interactive:

0 commit comments

Comments
 (0)