CVAgent — 3-Agent AI Pipeline for Professional Representation

Live demo: https://main.d1d43kyudgb9uj.amplifyapp.com Repo: https://github.com/mirkovicUK/Amazon-Nova-AI-Hackathon-CVAgent-3-agent-pipeline- Category: Agentic AI + Voice AI (Amazon Nova 2 Sonic)


Inspiration

I'm a career changer. I came into tech from a completely different path, and every interview I've had has been marked by anxiety and imposter syndrome. My one-page CV doesn't reflect who I am. Bullet points can't capture years of working within diverse multinational teams, out-of-the-box thinking, or cross-team collaboration. I can list "Python, AWS, Bedrock, Spark" — but my CV can't explain why I architected Nova Sonic to delegate complex JSON writing to a Nova Lite sub-agent, or how I handled stream reconnection edge cases that weren't documented yet.

And I'm not alone. Every career changer, every person whose experience doesn't fit neatly into a template, faces this gap between who they are and what their CV says.

CVAgent is the answer. You attach it as a link in your CV. When an employer clicks it, they talk to your data. It explains why you built things, how you think, how you handle pressure. It works when you sleep. No timezone barriers. No pre-interview anxiety.


What it does

A pipeline of three specialised AI agents, each solving a distinct problem in the journey from empty profile to employer-ready AI representative.

Agent Role Model
Anna Conversational profile builder (text + voice) Nova 2 Lite + Nova 2 Sonic
Hannah Profile expansion & "About Me" generator Nova 2 Lite
CVAgent Public-facing AI representative (text + voice) Nova 2 Lite + Nova 2 Sonic
Anna (collect) → Hannah (expand) → CVAgent (serve)
     │                 │                 │
  DynamoDB         Knowledge Base    Visitors
  (structured)     (vectorized       (text + voice)
                    prose)

Anna replaces form-filling with conversation. She asks questions like a career coach, structures the answers, and writes them to DynamoDB across 9 profile sections. Text or voice — same agent, same memory. The user watches the profile form update live as Anna fills it in, catching any hallucinations in real time.

Hannah reads Anna's structured data, identifies the user's profession, and generates a long-form "About Me" narrative grounded entirely in the profile. No fabrication. Designed for RAG — each section is self-contained prose that chunks and retrieves well. Gives users an out-of-the-box Knowledge Base without writing a single word.

CVAgent is the public face. Read-only. Answers employer questions from both DynamoDB (structured facts) and a Bedrock Knowledge Base (Hannah's narrative + user-uploaded docs). Supports text and voice. Visitors can also send a contact email directly to the profile owner.

The Knowledge Base runs on Amazon Titan Text Embed v2, semantic chunking (max 200 tokens), Aurora PostgreSQL with pgvector, and a Lambda-triggered ingestion pipeline from S3.


How we built it

Stack: Strands Agents SDK, AWS Bedrock AgentCore, Amazon Nova 2 Lite + Nova 2 Sonic, DynamoDB, Bedrock Knowledge Base, Aurora PostgreSQL (pgvector), S3, EventBridge, SES, API Gateway WebSockets, AWS SAM, Cognito.

Built iteratively within the hackathon timeframe — Anna first, Hannah second, CVAgent last. CVAgent has the cleanest structure (modular, well-separated concerns). Anna carries the marks of being first and is a candidate for restructuring. Honest about this in the repo.

The Delegation Pattern (Anna Voice)

Nova Sonic excels at natural conversation but struggles with complex structured JSON — nested objects with dates, UUIDs, arrays. Direct tool calls from Sonic for profile writes were unreliable.

Solution: two-agent delegation. Sonic gets only 2 tools: get_user_profile and delegate_to_text_agent. For ALL writes, Sonic passes a natural language instruction to a Nova 2 Lite sub-agent that produces reliable structured JSON. The delegation agent is created once per WebSocket session — not per call — to avoid per-call latency and STM corruption from concurrent agents writing back to the same session history. One persistent agent + one asyncio.Lock = sequential reads and writes, consistent history.

CVAgent doesn't need this. Its tools are read-only plus one simple contact email write. Sonic handles them directly.

Human-in-the-Loop

The handoff from Anna to Hannah is intentional and manual — the user presses Generate when they're satisfied with what Anna collected. After Hannah generates, the user reviews and edits before anything enters the Knowledge Base. By the time CVAgent serves data to employers, it has passed through human hands at every stage.


Challenges we ran into

The $37 Tool Loop

Anna's update_skills tool has a hard cap of 50 skills per user. A user already had 8 stored. Anna attempted to write 50 new skills — rejected (8 + 50 > 50). The tool returned an error with a do_not_retry flag.

Anna ignored it.

The LLM reinterpreted the rejection as a solvable problem. Tried again with a different 50. Rejected. Tried again. The classic failure mode: the model treats a hard constraint as a soft one and keeps attempting variations.

The frontend had already closed the tab. The WebSocket was gone. But Anna had no way to know — the agent was running inside Strands' internal tool-calling loop, deep in async for event in agent.stream_async(...). The WebSocket disconnect happened on the outside. The loop on the inside had no signal to stop.

Each iteration: one LLM call, one DynamoDB round trip, a growing context window. Three conditions aligned: the LLM ignored do_not_retry, no circuit breaker existed, and the WebSocket disconnect didn't propagate inward.

Fix: ToolLoopGuard — a Strands HookProvider that intercepts every tool call at the framework level before it executes:

class ToolLoopGuard(HookProvider):
    def __init__(self, max_calls: int = 5):
        self._max_calls = max_calls
        self._call_counts: dict[str, int] = {}
        self._lock = Lock()

    def register_hooks(self, registry: HookRegistry, **kwargs) -> None:
        registry.add_callback(BeforeInvocationEvent, self._reset)
        registry.add_callback(BeforeToolCallEvent, self._check_limit)

BeforeInvocationEvent resets the counter at the start of every new user message. BeforeToolCallEvent cancels the call if the limit is exceeded and injects the cancellation as the tool result. The LLM reads it and responds to the user instead of looping. The key difference from do_not_retry in the tool return value: the hook stops the call before the LLM ever gets to decide whether to retry.

Sonic Forgets Who It Is

Nova Sonic has an 8-minute session window. On expiry, the Strands SDK reconnects automatically. The new stream starts with squashed history — tool calls stripped, only text turns remain. With no tool calls in history, Sonic answers from memory instead of calling tools. Hallucination.

Strands documents reconnection hooks (BidiBeforeConnectionRestartEvent) from strands.experimental.bidi.hooks.events. In Strands v1.29.0 — the latest release at time of development — this import crashes. The module doesn't exist in the package.

Workaround: Handle reconnection directly in the event loop via raw bidi_connection_restart / bidi_connection_start event types.

Two-layer injection: on bidi_connection_start, inject a system instruction forcing get_user_profile. But if Sonic is mid-voice-stream during reconnection, it prioritises audio and ignores it. So on bidi_connection_restart, arm a countdown — after 2 complete is_final transcript events (Sonic has settled), fire the injection again.

This is Anna-specific — CVAgent doesn't have this problem because it has no delegation agent.

Anna's voice path works like this: Sonic receives a user utterance, decides a profile write is needed, and calls delegate_to_text_agent. That tool spins up a Nova 2 Lite sub-agent which may make multiple tool calls of its own (read profile, validate, write, confirm). This takes time. Meanwhile, the 8-minute Sonic stream expires mid-delegation. The SDK tears down the old stream and starts a new one. The delegation agent eventually finishes and returns its result — but that result carries a toolInvocationId that belongs to the dead stream. Sending it to the new stream causes a ValidationException crash.

The fix is a generation counter shared between the event loop and the delegation tool:

stream_counter = {"value": 0}  # mutable dict, not int — same object reference shared everywhere

It's a dict (not a plain int) so that the same object can be passed into invocation_state and mutated in place — both the event loop and the tool hold a reference to the same dict.

The event loop increments it on every reconnect:

elif event_type == "bidi_connection_restart":
    stream_counter["value"] += 1

The delegation tool snapshots the counter before calling the sub-agent:

counter_before = stream_counter["value"]
result = await delegation_agent.invoke_async(instruction)

After the sub-agent returns, it checks whether the stream is still the same one that started the call:

if stream_counter["value"] != counter_before:
    raise asyncio.CancelledError("stream reconnected during delegation — dropping stale result")

asyncio.CancelledError is a BaseException, not an Exception. The Strands SDK's internal _run_tool has except Exception — so CancelledError bypasses it entirely. The stale result is never sent to the new stream, the crash never happens, and Sonic continues on the new stream as if the delegation never completed.


Accomplishments that we're proud of

  • A fully working end-to-end pipeline: Anna collects, Hannah expands, CVAgent serves — live and accessible at the demo link
  • Nova Sonic voice working reliably across 8-minute reconnection boundaries with no user-visible disruption
  • The delegation pattern solving a real Nova Sonic limitation in a clean, latency-aware way
  • Shared STM across text and voice modes — switch from typing to talking and the agent remembers everything
  • ToolLoopGuard as a reusable, framework-level safety mechanism that works regardless of what the tool returns
  • Hannah's profession-specific generation with four employment pillars producing RAG-ready narratives out of the box
  • Human-in-the-loop design that keeps the user as supervisor at every stage without making the experience feel manual

What we learned

LLMs treat hard constraints as soft ones. A do_not_retry flag in a tool return value is a suggestion. The model will find creative ways around it. Circuit breakers need to live at the framework level, not in the tool response.

WebSocket disconnects don't propagate into async agent loops. The outside world closing a connection doesn't stop an agent that's already running inside stream_async. You need explicit mechanisms — rate limits, watchdog tasks, framework-level hooks — to bound runaway execution.

Nova Sonic is a voice model first. It prioritises audio flow over instruction processing. Injecting tool-awareness instructions immediately after reconnection doesn't work reliably — you have to wait for the model to settle into the new stream before it's receptive.

Strands v1.29.0 ships documented APIs that don't exist yet. The reconnection hook imports are in the docs but not in the package. When building on a fast-moving SDK, pin your version and read the source, not just the docs.

Sonic can't reliably output complex structured JSON. It's a speech-to-speech model optimised for conversation. Asking it to produce nested JSON with UUIDs and arrays is asking it to do something it wasn't designed for. The delegation pattern — offloading writes to a Nova 2 Lite text sub-agent — is the right architectural response.

STM corruption is subtle and hard to debug. Multiple agents sharing the same session and writing back concurrently produces out-of-order turns and conversations starting with assistant messages, which crashes Strands. One persistent agent + one lock is the only safe pattern.


What's next for CVAgent

  • Anna restructure — refactor Anna's monolithic app.py into the same modular folder structure CVAgent uses
  • KB ingestion notification — currently there's no signal when the 2-minute ingestion sync completes; architecting a WebSocket push or polling endpoint is the next infrastructure piece
  • Multi-language support — the pipeline is language-agnostic at the model level; the system prompts need localisation
  • Interview prep mode — a private CVAgent mode where the profile owner can practice answering questions about their own data before interviews
  • Analytics — what questions are employers asking most? Which profile sections get retrieved most often? This data would help users know where to invest in enriching their Knowledge Base
  • Strands hooks migration — when strands.experimental.bidi.hooks.events is actually available in a release, replace the raw event type workaround with the proper hook API
  • Financing — actively competing in AWS-native AI hackathons to gain funding, exposure, and AWS credits to scale the infrastructure
  • Early adopters — once the MVP is polished, reach out to YouTubers and content creators covering Amazon Nova and AWS AI to get CVAgent in front of the right early audience
  • User base — build from that first wave of AWS-native AI enthusiasts who understand the stack, can give meaningful feedback, and are exactly the kind of users who'd benefit from a tool like this

Built With

Share this project:

Updates