agent-feature-builder
Helps a DataStream Corp bootcamp participant extend the Phase 2 AgentCore agent in `phase2/app/agent/agent.py` safely — add a Strands `@tool`, add a specialist sub-agent (agents-as-tools), tweak the o
SKILL.md.claude/skills/agent-feature-builder/SKILL.md
GitHubAgent Feature Builder
The deployed agent is phase2/app/agent/agent.py plus
answers.py, sandbox_tools.py, guardrail.py and prompt_variants.py next to it, zipped by uv run bootcamp.py deploy
together with the linux/arm64 wheels from phase2/app/agent/requirements.txt
and shared/*.py (litellm_gateway.py, sql_guard.py, nws_weather.py). Shape:
invoke()(the async@app.entrypoint) reads the actor from the verified user token (actor_from), thenrun_agent()opens the AgentCore Gateway MCP client with an M2M token, builds the orchestratorAgent(build_orchestrator) and iteratesstream_async:{"delta": text}and{"tool": name}events, then the reply object. With"stream": truein the payload those events go out as server-sent events (invoke --stream); otherwiselast_event()returns only the reply.build_specialists(gateway, results)returns@toolsub-agents. Each finds its tools with the Gateway's semantic search,search_tools(gateway, (need, top_k))(DATA_TOOLS,WEATHER_TOOLS), neverlist_tools_sync():data_agent(the top 3 database tools +ReadOnlyGuardHook) andweather_agent(the top-ranked tool,Weather___get_us_forecastfrom the Gateway's Lambda target, which runsshared/nws_weather.py, +nws_weather.WEATHER_PROMPT: US locations only, a few trimmed forecast lines instead of raw NWS JSON; non-US places get "I can only look up US weather (National Weather Service)."). Each returnsconsult(agent, query, "data"): the answer, or "The data specialist failed: ..." when the specialist raises or answers with nothing (never an empty string), appended toresults.orchestrator_prompt()tells the orchestrator to report a failed specialist and never invent numbers or placeholders such as[count]; keep that line.- The reply is
{"response": final_answer(...), "actor_id": ..., "session_id": ..., "variant": ...}(add reply fields there, inrun_agent): if the model returns empty text, the last specialist answer (or a "Sorry, the model returned an empty answer ..." message) is sent instead. make_model()→ LiteLLM gateway (keyless;guardrail.make_model(...)when guardrails are on);memory_session_manager()→ AgentCore Memory keyed byactor_id+session_id.- All ids come from env vars Terraform sets (
MODEL_ID,GATEWAY_URL,MEMORY_ID, ...). Never hardcode ids, URLs or keys.
Rules that keep the agent deployable
- Prototype in Phase 1 first.
phase1/t7_agents_as_tools.pyhas the same orchestrator shape and runs locally in seconds:uv run bootcamp.py phase1 t7 "...". Port toagent.pyonce it works. - Every sub-agent that gets
gateway_toolskeepshooks=[ReadOnlyGuardHook()]. Stage 7 Cedar also blocks writes at the gateway and the MCP server's database is read-only — defence in depth, not a reason to drop the hook. - Tools fail soft. Catch errors and return an error string so the model
can recover. An exception raised in
invoke()or at import time makes the runtime return HTTP 424. - Docstring = routing. First line says what,
Args:says each input; the orchestrator prompt names the tool and when to use it. - New dependency? Add to
phase2/app/agent/requirements.txt. The build is wheel-only for linux/arm64 + Python 3.13 — pure-Python packages are safest. - Do not edit
shared/litellm_gateway.py,terraform/orbootcamp_cli/. New env vars need Terraform, so ask the instructor instead.
Worked example: an hr_agent specialist + a plain tool
Goal: HR questions go to a dedicated specialist that knows the schema, and the orchestrator can compute tenure without spending a model call on maths.
# phase2/app/agent/agent.py
from datetime import date
HR_PROMPT = (
"You are an HR analyst for DataStream Corp. Use the database tools with SELECT only. "
"Inspect the schema first (SELECT name FROM sqlite_master WHERE type='table'). "
"Answer with numbers and a one-line summary."
)
@tool
def years_since(iso_date: str) -> str:
"""Compute whole years between a past ISO date (YYYY-MM-DD) and today, e.g. an employee's tenure.
Args:
iso_date: The start date, formatted YYYY-MM-DD.
"""
try:
start = date.fromisoformat(iso_date)
today = date.today()
years = today.year - start.year - ((today.month, today.day) < (start.month, start.day))
return f"{years} full year(s) since {iso_date}"
except ValueError as error:
return f"years_since failed: {error}"
def build_specialists(gateway: MCPClient, results: list[str]) -> list:
# ... existing data_agent, weather_agent, consult() and remember() unchanged ...
@tool
def hr_agent(query: str) -> str:
"""Answer HR questions: headcount by department, hiring dates, tenure, salaries, managers.
Args:
query: An HR or people-analytics question.
"""
agent = Agent(
model=make_model(),
system_prompt=f"{HR_PROMPT}\n{guardrail.prompt_hint()}",
tools=[*search_tools(gateway, DATA_TOOLS), years_since],
hooks=[ReadOnlyGuardHook(), *guardrail.hooks()],
callback_handler=None,
)
return consult(agent, query, "hr")
return [data_agent, weather_agent, hr_agent, years_since]
Then teach the orchestrator to route to it in orchestrator_prompt():
"Route HR and people questions (headcount, tenure, salaries) to hr_agent, other database"
"questions to data_agent and weather questions to weather_agent; use years_since for date maths; "
Try it on this machine first (stage 3+; seconds per change), then ship and verify:
uv run bootcamp.py invoke "Who is the longest-serving engineer and how many years have they been here?" --local
uv run bootcamp.py deploy
uv run bootcamp.py test --only 4
uv run bootcamp.py invoke "Who is the longest-serving engineer and how many years have they been here?"
Confirm routing in the trace (stage 5+, spans arrive 2-10 min after the
invoke): uv run bootcamp.py traces --args (or AgentCore console → Observability →
Agent: your agent → Sessions) should show an hr_agent tool span with
DataStreamDatabase___query_db calls inside it.
Optional sandbox tools: Code Interpreter and Browser
Set in .env (stage >= 4):
ENABLE_CODE_INTERPRETER=true # orchestrator tool run_python(code) -> AWS-managed aws.codeinterpreter.v1
ENABLE_BROWSER=true # orchestrator tool browse_web(url) -> AWS-managed aws.browser.v1
Then apply and verify:
uv run bootcamp.py deploy
uv run bootcamp.py test --only 4
- The platform sets
CODE_INTERPRETER_ENABLED/BROWSER_ENABLEDon the runtime and grants the agent role the session APIs;phase2/app/agent/sandbox_tools.pyturns them into tools (optional_tools()) and prompt hints (prompt_hints()). Each call starts a fresh sandbox session and stops it afterwards. - Give a specialist the sandbox too:
tools=[*search_tools(gateway, DATA_TOOLS), run_python](import fromsandbox_tools; keepReadOnlyGuardHook). E.g. ananalysis_agentthat queries rows, then computes statistics inrun_python. browse_webreturns title, final URL and the first 4000 chars of visible text via raw CDP (no Playwright). Need clicks/forms? Add more CDP calls inread_page()(Input.dispatchMouseEvent,Runtime.evaluate) rather than adding Playwright torequirements.txt(large arm64 driver).- Using a tool without its flag → the agent role lacks permission →
AccessDeniedExceptioninAGENT_LOG_GROUP.
uv run bootcamp.py test --only 4 # adds "code interpreter tool" / "browser tool" checks when enabled
uv run bootcamp.py invoke "Open https://example.com and use the code interpreter to count the words in its first paragraph."
Optional: Bedrock Guardrail (prompt injection, PII)
ENABLE_GUARDRAILS=true in .env + deploy (stage >= 4) creates awsworkshop-<you>-guardrail and sets
GUARDRAIL_ID / GUARDRAIL_VERSION on the runtime. phase2/app/agent/guardrail.py then attaches it to every model
call (make_model() returns guardrail.make_model(...), which sends guardrailConfig through LiteLLM; the gateway
refuses a guardrail not tagged with your name), adds ToolOutputGuard (screens tool results with the same
guardrail, a blocked database result row by row so only poisoned rows/values are replaced, and tags them
<untrusted_tool_output>) and one hardening line to the prompts. Keep both in any specialist
you add: hooks=[ReadOnlyGuardHook(), *guardrail.hooks()] and f"{PROMPT}\n{guardrail.prompt_hint()}".
Bedrock checks only the user's text for prompt attacks, never tool results, so a new tool that reads untrusted data
(web pages, tickets, documents) must go through the hook. Emails/phone numbers come back as {EMAIL} / {PHONE}.
Tune it in .env, then deploy: GUARDRAIL_PROMPT_ATTACK_STRENGTH (NONE|LOW|MEDIUM|HIGH, default MEDIUM,
which can block a harmless request naming a tool together with a URL) and GUARDRAIL_PII_TYPES (comma list of
Bedrock PII entity types masked in answers, default EMAIL,PHONE, or none; unknown types and NONE + none are
rejected; the stage-4 guardrail check expects EMAIL).
Prompts
- Orchestrator:
orchestrator_prompt(actor_id, sandbox_tools)— keep the actor name and the trailing{prompt_hints(sandbox_tools)}in it; list every specialist by its exact function name with a "when" clause. - Specialists: one short prompt each; put them in module-level constants.
- Prompt-only change? Still
deploy— the prompt lives in the zip.
Memory (actor / session)
--actor→ the CLI signs in as that Cognito user and sends its access token → the runtime's JWT authorizer validates it →actor_from()reads theusernameclaim. Never take identity from a header or the payload: any caller could then read anyone's memories (stage 4identitycheck). Experiment:TRUST_ACTOR_HEADER=true+deploymakes the runtime forwardX-Amzn-Bedrock-AgentCore-Runtime-Custom-Actor-Idand the agent trust it, i.e. exactly that hole; stage 4 then prints[WARN] stage 4 TRUST_ACTOR_HEADER=true: ...instead of FAIL. Set it back tofalseanddeploy.--session→ conversation (short-term) memory: events per actor + session, reloaded when a session continues.- Long-term records are extracted asynchronously (~1–2 min) by three
strategies, each in its own per-actor namespace:
UserFacts(semantic),UserPreferences(user preference),SessionSummaries(summary, one namespace per session).long_term_retrieval()searches all three before each model call and the hits arrive as<user_context>. MEMORY_RELEVANCE_SCORE = 0.3(facts, summaries) is tuned on purpose; preferences useALWAYS(no threshold) because they apply to every question. If you change either, re-check withtest --only 3(preference challenge) andtest --only 4(fact recall).- Inspect what was stored:
uv run bootcamp.py memory --actor <name>. - Recalled memories are context, not data: the orchestrator prompt forbids
answering company numbers from memory (always
query_db). Keep that rule when you edit the prompt; test data questions with a fresh--actor, since an actor's old answers otherwise tempt the model to skip the tool. MEMORY_EPISODIC=true+deploy(stage >= 3) adds a fourth strategy,Episodes(episodic). Any other new strategy needs Terraform (task3_memory.tf) and a new env var, so ask the instructor; then add an entry inlong_term_retrieval(). Extraction quota is shared, keep it light.
uv run bootcamp.py invoke "Remember: I prefer answers as bullet lists." --actor bob-lee
# wait ~2 min, new session:
uv run bootcamp.py invoke "Summarise Engineering headcount." --actor bob-lee
Custom --session ids must be at least 33 characters.
Switching models
Pick a model from uv run bootcamp.py llm models (prices, class default,
suggestions, quirks), set MODEL_ID=<model> in .env (empty = class
default), then uv run bootcamp.py deploy — only the runtime env changes. To
mix, pass a model per agent: litellm_gateway.make_model("<stronger model>", LITELLM_BASE_URL) for the orchestrator, keep a cheap one for specialists. See
litellm-usage for choosing a model, cost and known model quirks.
Common pitfalls
- Tool added to
build_specialistsbut not returned in the list → never offered. - Specialist built without
ReadOnlyGuardHook→ writes reach the gateway (Tool Execution Deniedat stage 7) and, if they get past Cedar, the database answersError: the database is read-only; .... - Empty answers (
Sorry, the model returned an empty answer ...or "The data specialist failed: ...") mean Bedrock streamed no tokens even after the gateway's retry and the model's fallback (ifllm modelslists one): re-run, or setMODEL_IDto another model fromllm models+deploy. - Testing a prompt change? Use a fresh
--actor: long-term memory of an existing actor can answer correctly and hide a regression. - Before shipping a prompt/model change, run
uv run bootcamp.py eval: it replaysphase2/evals/golden.yaml(11 questions with known answers, the first 10 by default (--limit), fresh actor per item) and fails below an 80% pass rate (--min-pass). Add an item there for every new capability you build, but keep the file small (at most 50 items; a run scores at most 25 answers,--limitx--variants). - Evaluations only through
uv run bootcamp.py eval: AgentCore's built-in judges bill the class AWS account (not LiteLLM), so never call AgentCore evaluation APIs (StartBatchEvaluation,Evaluate,CreateOnlineEvaluationConfig) from boto3 / the AWS CLI / a script, never runevalin a loop, never load-test the agent (online evaluation scores every request). Re-run after a change worth measuring; while iterating prefer--evaluators exact_numbers(no model) orrubric_judge(LiteLLM budget). - Comparing two prompts? Put the change in
phase2/app/agent/prompt_variants.pyas variantB(A = the shipped prompt),deploy, thenuv run bootcamp.py eval --variants A,B --evaluators all: both variants get the same golden questions and evaluators; the table shows pass rate and mean scores per variant and the change vs A. Promote a winner by moving its lines intoorchestrator_prompt. - Domain-specific quality bar? Deterministic checks go in the code-based
evaluator
phase2/app/code_evaluator/handler.py(a Lambda, no judge model;deployat stage 6+, theneval --evaluators exact_numbers). Judgement calls go in a rubric ofphase2/evals/evaluators.yaml, graded by therubric_judgeLambda (phase2/app/rubric_judge/handler.py) with a judge model called through LiteLLM (billed to your key):TF_VAR_judge_rubric=<name>,deploy, theneval --evaluators rubric_judge. Never AgentCore's managed LLM-as-a-judge evaluators: they call Bedrock directly, which participant roles cannot. - Raising in
invoke()or at import time (e.g. reading a missing env var at module level) → HTTP 424; a tool should return an error string instead. CheckAGENT_LOG_GROUP(bootcamp-debugger). - Heavy work at import time slows cold starts; build agents inside
invoke().
Output shape
Give: (1) a diff of phase2/app/agent/agent.py (and requirements.txt if
needed), (2) the deploy → test --only 4 → invoke commands, (3) one
prompt that should trigger the new feature and what to look for in the trace.