diff --git a/lambda-durable-agentcore-springai-sam-java/README.md b/lambda-durable-agentcore-springai-sam-java/README.md new file mode 100644 index 000000000..c1471c6b1 --- /dev/null +++ b/lambda-durable-agentcore-springai-sam-java/README.md @@ -0,0 +1,296 @@ +# Human-in-the-loop AI review with AWS Lambda durable functions and Amazon Bedrock AgentCore + +This pattern shows how an AWS Lambda durable function written in Java can orchestrate a Spring AI agent hosted on Amazon Bedrock AgentCore Runtime, and pause partway through to wait for a person to approve or reject the agent's work. + +The durable function asks the agent to draft a summary of a document, then suspends. It resumes only when a human sends a decision, and asks the agent for a final version if the review was approved. While suspended the function consumes no compute and can wait for days. + +Learn more about this pattern at Serverless Land Patterns: << Add the live URL here >> + +Important: this application uses various AWS services and there are costs associated with these services after the Free Tier usage - please see the [AWS Pricing page](https://aws.amazon.com/pricing/) for details. You are responsible for any AWS costs incurred. No warranty is implied in this example. + +## Requirements + +* [Create an AWS account](https://portal.aws.amazon.com/gp/aws/developer/registration/index.html) if you do not already have one and log in. The IAM user that you use must have sufficient permissions to make necessary AWS service calls and manage AWS resources. +* [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) installed and configured +* [Git Installed](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +* [AWS Serverless Application Model](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) (AWS SAM) installed, **version 1.161.0 or later** +* [Java 21](https://docs.aws.amazon.com/corretto/latest/corretto-21-ug/downloads-list.html) and [Apache Maven](https://maven.apache.org/install.html) 3.9 or later +* [Docker](https://docs.docker.com/get-docker/), [Finch](https://runfinch.com/), nerdctl or Podman, to build the agent container image +* An Amazon Bedrock model available in the target Region. The default is `us.anthropic.claude-sonnet-4-5-20250929-v1:0`. Check availability with `aws bedrock get-foundation-model-availability --model-id --region `; if it reports anything other than `AUTHORIZED`, enable it under **Model access** in the Amazon Bedrock console. + +## Deployment Instructions + +1. Create a new directory, navigate to that directory in a terminal and clone the GitHub repository: + + ```bash + git clone https://github.com/aws-samples/serverless-patterns + ``` + +1. Change directory to the pattern directory: + + ```bash + cd serverless-patterns/lambda-durable-agentcore-springai-sam-java + ``` + +1. Build and push the agent container image. AgentCore Runtime can host plain source code only for Python and Node runtimes, so the Java agent is delivered as an ARM64 container image: + + ```bash + ./scripts/build-agent-image.sh + ``` + + The script prints the image URI it pushed. Copy it - you need it in the next step. + + > The image must exist in Amazon ECR before the stack is deployed, because `AWS::BedrockAgentCore::Runtime` refers to it directly. `sam build` cannot produce it: SAM's `Metadata: Dockerfile` support applies only to `PackageType: Image` Lambda functions and builds for the host architecture. + +1. Build the durable function and deploy: + + ```bash + sam build + sam deploy --guided + ``` + + Accept the defaults, and paste the image URI from the previous step when prompted for `AgentImageUri`. Answer `y` when asked to allow SAM CLI to create IAM roles. + +1. Note the `WorkflowFunctionName` output - the testing commands below use it. + +## How it works + +``` +aws lambda invoke ──► ReviewWorkflowFunction (durable) + │ + │ step "analyze-document" + │ └──► AgentCore Runtime + │ (Spring AI agent, ARM64 container) + │ + │ waitForCallback "await-human-review" + │ + ⋮ ~~ suspended: no compute billed ~~ + │ +aws lambda send-durable- ─────┤ resumes with the decision +execution-callback-success │ + │ step "finalize-document" (only if approved) + │ + ▼ + returns ReviewResult +``` + +The workflow is [`DocumentReviewWorkflow.java`](orchestrator/src/main/java/com/example/durableagent/DocumentReviewWorkflow.java). In Java the durable programming model is based on inheritance rather than annotations: the handler extends `DurableHandler` and receives a `DurableContext`. + +### Steps + +Every durable operation in Java takes an explicit name and a result type token: + +```java +String draft = ctx.step("analyze-document", String.class, + stepCtx -> agent.invoke(documentId, "analyze", request.documentText(), null, null)); +``` + +The name is how replay matches a new invocation to previously checkpointed state, so names must be stable across deployments. Once a step has completed its result is recorded, and later invocations reuse that value instead of running the body again - which is what stops an expensive agent call from being repeated. + +### Waiting for a human + +`waitForCallback` creates a callback, runs a submitter that hands the callback ID to the outside world, and then suspends the execution: + +```java +Decision decision = ctx.waitForCallback("await-human-review", Decision.class, + (callbackId, stepCtx) -> announceReviewRequest(callbackId, request, draft), + WaitForCallbackConfig.builder() + .callbackConfig(CallbackConfig.builder() + .timeout(Duration.ofHours(24)) + .build()) + .build()); +``` + +Note that `WaitForCallbackConfig` nests a `CallbackConfig` rather than taking a timeout directly. Because the submitter runs as a step, the SDK retries it if it fails. + +To keep the pattern focused, the submitter simply writes the callback ID and the agent's draft to the function log, and you resume the workflow with the AWS CLI. A production workflow would notify the reviewer out of band - email, chat, a ticket. + +If you add a notification with an approval link, do not let the link itself record the decision. Mail clients and security scanners prefetch URLs, so a `GET` that decides will be actioned by a scanner rather than by your approver. Render a confirmation page on `GET` and act only on the `POST` it submits. + +#### Approve and reject are both callback successes + +Both decisions are delivered with `SendDurableExecutionCallbackSuccess`. The success/failure axis of the callback API describes whether a decision was *obtained*, not whether the answer was favourable - a reviewer who rejects a document has successfully decided. `SendDurableExecutionCallbackFailure` is for the case where no decision can be produced at all, such as an abandoned review, and surfaces in the workflow as a thrown exception rather than a value. + +The workflow keeps those two ideas in separate fields, so "the reviewer said no" is never confused with "nobody answered": + +| Field | Values | Meaning | +|---|---|---| +| `outcome` | `DECIDED`, `EXPIRED` | Was a decision obtained at all? | +| `decision` | `APPROVED`, `REJECTED`, null | What the reviewer chose. Null when `outcome` is `EXPIRED`. | + +Carrying the verdict as data also keeps replay deterministic: the workflow branches on the checkpointed callback result, so every replay takes the same path. + +### Where the state lives + +There is no table to provision. Lambda checkpoints every durable operation - step results, the pending callback, and the handler's return value - into storage the service manages, kept for `DurableConfig.RetentionPeriodInDays` after the execution ends. `get-durable-execution` and `get-durable-execution-history` read it back. + +### The agent + +[`ReviewAgentController`](agent/src/main/java/com/example/agent/ReviewAgentController.java) is an ordinary Spring `@RestController`. The [`spring-ai-agentcore-runtime-starter`](https://github.com/spring-ai-community/spring-ai-agentcore) auto-configures the `POST /invocations` and `GET /ping` endpoints that AgentCore requires, so the agent is a single annotated method: + +```java +@AgentCoreInvocation +public String handleInvocation(AgentRequest request, AgentCoreContext agentCoreContext) { ... } +``` + +Returning a `String` rather than a `Flux` produces a single non-streaming response, which is what the calling step expects. One runtime serves both steps: the request carries a `mode` of `analyze` or `finalize` and the controller selects the system prompt. + +Both agent calls reuse one `runtimeSessionId`, so the agent still has the analyze turn in context when it writes the final version. AgentCore requires session IDs of at least 33 characters; `AgentClient.sessionIdFor` prefixes the document ID and right-pads it to that length, keeping the document ID legible at the front for anyone reading AgentCore's logs. It has to be a pure function of the document ID - a fresh `UUID` would hand the agent a different session on every replay, losing the earlier turn. + +Note that the AgentCore starter is a community project under `org.springaicommunity`, published to Maven Central and Apache-2.0 licensed - not an official Spring AI or AWS module - and it requires Spring Boot 4.1 or later. To avoid the dependency, write the two endpoints yourself as plain Spring Web handlers: the contract is a `POST` that takes and returns JSON, and a `GET` that returns `{"status":"Healthy"}`, on port 8080. + +### Timeouts + +`DurableConfig.ExecutionTimeout` is 7 days and the callback timeout is 24 hours. The execution timeout must always exceed the longest callback timeout, otherwise the execution would expire while still waiting for a decision. If nobody responds in 24 hours the callback raises `CallbackTimeoutException`, which the workflow catches and reports as `outcome: EXPIRED`. + +## Testing + +### Run the unit tests + +The durable execution SDK ships an in-memory test runner, so all three outcomes can be verified with no AWS account: + +```bash +cd orchestrator && mvn test +``` + +These drive the workflow through approve, reject and timeout, and assert that the agent's analyze step runs exactly once even though the handler body executes again after the callback resumes it - that is, that replay really does skip completed work. + +### Start a review + +Use the `WorkflowFunctionName` from the stack outputs. `--durable-execution-name` names the execution so you can find it again; `--invocation-type Event` starts it asynchronously so the CLI returns immediately. + +```bash +aws lambda invoke \ + --function-name :live \ + --region \ + --invocation-type Event \ + --durable-execution-name review-001 \ + --payload fileb://events/submit-document.json \ + response.json +``` + +### Find the callback ID + +Give the workflow a moment first. The agent call takes roughly 10-15 seconds, and the callback only exists once `analyze-document` has finished - so this query returns nothing if you run it immediately. Wait about 30 seconds, and retry if the output is empty. + +Run these in the same shell, since `$ARN` is reused by the commands below. + +```bash +ARN=$(aws lambda list-durable-executions-by-function \ + --function-name \ + --region \ + --durable-execution-name review-001 \ + --query 'DurableExecutions[0].DurableExecutionArn' --output text) + +aws lambda get-durable-execution-history \ + --durable-execution-arn "$ARN" \ + --region \ + --query 'Events[?EventType==`CallbackStarted`].CallbackStartedDetails.CallbackId' \ + --output text +``` + +To confirm the workflow is suspended and waiting rather than still working, check its status - `RUNNING` with `analyze-document` already succeeded means it is parked at the callback: + +```bash +aws lambda get-durable-execution --durable-execution-arn "$ARN" \ + --region --query 'Status' --output text +``` + +The agent's draft is also printed to the function log, along with ready-to-paste approve and reject commands: + +```bash +sam logs --stack-name --region --tail +``` + +### Approve + +Write whatever comments the draft actually warrants - they are passed to the agent for the finalize step, so this is where you see the agent do something with your input. The draft ends with a list of concerns for the reviewer to check; answering some of those is the most interesting thing to put here, because you can then watch those answers appear in the final summary and the questions themselves disappear. + +```bash +aws lambda send-durable-execution-callback-success \ + --region \ + --callback-id \ + --cli-binary-format raw-in-base64-out \ + --result '{"decision":"approved","comments":""}' +``` + +Comments are optional - `"comments":""` works, and the finalize step then just polishes the draft rather than incorporating anything. + +### Reject + +Reject is also a callback *success* - the decision is data, not a failure. Again, the comments are yours to write; they are recorded on the result, though the agent is not called again because the finalize step is skipped: + +```bash +aws lambda send-durable-execution-callback-success \ + --region \ + --callback-id \ + --cli-binary-format raw-in-base64-out \ + --result '{"decision":"rejected","comments":""}' +``` + +To abandon a review instead of deciding it, send a failure: + +```bash +aws lambda send-durable-execution-callback-failure \ + --region \ + --callback-id \ + --error ErrorType=ReviewAbandoned,ErrorMessage="No reviewer available" +``` + +### Read the result + +```bash +aws lambda get-durable-execution --durable-execution-arn "$ARN" \ + --region --query '[Status,Result]' --output text +``` + +An approved review returns `outcome: DECIDED`, `decision: APPROVED` and a `finalSummary`. A rejected one returns `decision: REJECTED` with no `finalSummary`, because the finalize step is skipped. A review nobody answered returns `outcome: EXPIRED` with a null `decision`. Confirm the branching in the history - `finalize-document` appears only on approval: + +```bash +aws lambda get-durable-execution-history --durable-execution-arn "$ARN" \ + --region --query 'Events[?EventType==`StepStarted`].Name' --output text +``` + +Run a few reviews with different `--durable-execution-name` values and decide them differently to see both paths. + +### Test the agent on its own + +The agent is a normal Spring Boot application, so you can exercise the AgentCore container contract locally before building an image: + +```bash +cd agent && mvn package +java -jar target/document-review-agent.jar +``` + +```bash +curl localhost:8080/ping +# {"time_of_last_update":...,"status":"Healthy"} + +curl -X POST localhost:8080/invocations \ + -H 'Content-Type: application/json' \ + -H 'X-Amzn-Bedrock-AgentCore-Runtime-Session-Id: local-test-session-0000000000000000' \ + -d '{"mode":"analyze","document":"Q3 budget: 4 more engineers, 620k USD."}' +``` + +This needs AWS credentials in the shell, because the agent calls Amazon Bedrock. + +## Cleanup + +1. Delete the stack: + + ```bash + sam delete + ``` + +1. Delete the agent images and repository, which the build script created outside the stack: + + ```bash + aws ecr delete-repository --repository-name document-review-agent \ + --region --force + ``` + +---- + +Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 diff --git a/lambda-durable-agentcore-springai-sam-java/agent/Dockerfile b/lambda-durable-agentcore-springai-sam-java/agent/Dockerfile new file mode 100644 index 000000000..80e9fc79c --- /dev/null +++ b/lambda-durable-agentcore-springai-sam-java/agent/Dockerfile @@ -0,0 +1,13 @@ +# AgentCore Runtime requires an ARM64 container listening on port 8080. +FROM --platform=linux/arm64 amazoncorretto:21-alpine3.23 + +RUN addgroup -S agent && adduser -S agent -G agent + +WORKDIR /app +COPY target/document-review-agent.jar app.jar +RUN chown agent:agent /app/app.jar + +USER agent +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/lambda-durable-agentcore-springai-sam-java/agent/pom.xml b/lambda-durable-agentcore-springai-sam-java/agent/pom.xml new file mode 100644 index 000000000..7dfc675c3 --- /dev/null +++ b/lambda-durable-agentcore-springai-sam-java/agent/pom.xml @@ -0,0 +1,87 @@ + + + 4.0.0 + + + + org.springframework.boot + spring-boot-starter-parent + 4.1.0 + + + + com.example + document-review-agent + 1.0 + Document Review Agent + Spring AI agent hosted on Amazon Bedrock AgentCore Runtime + + + 21 + 2.0.0 + 2.1.0 + + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + org.springaicommunity + spring-ai-agentcore-bom + ${spring-ai-agentcore.version} + pom + import + + + + + + + + org.springaicommunity + spring-ai-agentcore-runtime-starter + + + + + org.springframework.ai + spring-ai-starter-model-bedrock-converse + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + document-review-agent + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/lambda-durable-agentcore-springai-sam-java/agent/src/main/java/com/example/agent/AgentApplication.java b/lambda-durable-agentcore-springai-sam-java/agent/src/main/java/com/example/agent/AgentApplication.java new file mode 100644 index 000000000..3c76e8ffb --- /dev/null +++ b/lambda-durable-agentcore-springai-sam-java/agent/src/main/java/com/example/agent/AgentApplication.java @@ -0,0 +1,22 @@ +/*! Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT-0 + */ +package com.example.agent; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Spring Boot entry point for the document review agent. + * + *

The {@code spring-ai-agentcore-runtime-starter} on the classpath auto-configures the + * two endpoints Amazon Bedrock AgentCore Runtime requires - {@code POST /invocations} and + * {@code GET /ping} - so this application only needs to supply the agent logic itself. + */ +@SpringBootApplication +public class AgentApplication { + + public static void main(String[] args) { + SpringApplication.run(AgentApplication.class, args); + } +} diff --git a/lambda-durable-agentcore-springai-sam-java/agent/src/main/java/com/example/agent/AgentRequest.java b/lambda-durable-agentcore-springai-sam-java/agent/src/main/java/com/example/agent/AgentRequest.java new file mode 100644 index 000000000..5497a12d7 --- /dev/null +++ b/lambda-durable-agentcore-springai-sam-java/agent/src/main/java/com/example/agent/AgentRequest.java @@ -0,0 +1,27 @@ +/*! Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT-0 + */ +package com.example.agent; + +/** + * Payload sent by the durable function to {@code POST /invocations}. + * + *

A single agent runtime serves both workflow steps. {@code mode} selects which system + * prompt to apply: + * + *

    + *
  • {@code analyze} - summarise {@code document} and produce a draft for review. + *
  • {@code finalize} - polish {@code draft}, taking {@code reviewerComments} into account. + *
+ * + * @param mode either {@code analyze} or {@code finalize} + * @param document the original document text (used by {@code analyze}) + * @param draft the approved draft (used by {@code finalize}) + * @param reviewerComments free-text comments captured at the approval gate; may be null + */ +public record AgentRequest( + String mode, + String document, + String draft, + String reviewerComments) { +} diff --git a/lambda-durable-agentcore-springai-sam-java/agent/src/main/java/com/example/agent/ReviewAgentController.java b/lambda-durable-agentcore-springai-sam-java/agent/src/main/java/com/example/agent/ReviewAgentController.java new file mode 100644 index 000000000..288600887 --- /dev/null +++ b/lambda-durable-agentcore-springai-sam-java/agent/src/main/java/com/example/agent/ReviewAgentController.java @@ -0,0 +1,83 @@ +/*! Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT-0 + */ +package com.example.agent; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springaicommunity.agentcore.annotation.AgentCoreInvocation; +import org.springaicommunity.agentcore.context.AgentCoreContext; +import org.springaicommunity.agentcore.context.AgentCoreHeaders; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.web.bind.annotation.RestController; + +/** + * The document review agent. + * + *

The method annotated with {@link AgentCoreInvocation} is wired to {@code POST + * /invocations} by the AgentCore runtime starter. Returning a plain {@code String} (rather + * than a {@code Flux}) yields a single non-streaming JSON response, which is what the + * calling durable function step expects. + */ +@RestController +public class ReviewAgentController { + + private static final Logger logger = LoggerFactory.getLogger(ReviewAgentController.class); + + private static final String ANALYZE_PROMPT = """ + You are a document review assistant. Read the document supplied by the user and + produce a concise draft summary for a human reviewer. + + Your response must contain: + 1. A one-paragraph summary of the document's purpose. + 2. Three to five key points as a bullet list. + 3. Any concerns, ambiguities, or missing information a reviewer should check. + + Be factual. Do not invent details that are not present in the document. + """; + + private static final String FINALIZE_PROMPT = """ + You are a document review assistant. A human reviewer has approved the draft + supplied by the user. Produce the final, polished version of the summary. + + Apply any reviewer comments that are provided. Tighten the wording, remove + reviewer-only scaffolding such as open questions, and return prose suitable for + publication. Do not introduce new factual claims. + """; + + private final ChatClient chatClient; + + public ReviewAgentController(ChatClient.Builder chatClientBuilder) { + this.chatClient = chatClientBuilder.build(); + } + + @AgentCoreInvocation + public String handleInvocation(AgentRequest request, AgentCoreContext agentCoreContext) { + String sessionId = agentCoreContext.getHeader(AgentCoreHeaders.SESSION_ID); + String mode = request.mode() == null ? "analyze" : request.mode(); + logger.info("Handling '{}' invocation for session {}", mode, sessionId); + + return switch (mode) { + case "analyze" -> chatClient.prompt() + .system(ANALYZE_PROMPT) + .user("Review the following document:\n\n" + request.document()) + .call() + .content(); + case "finalize" -> chatClient.prompt() + .system(FINALIZE_PROMPT) + .user(buildFinalizeMessage(request)) + .call() + .content(); + default -> throw new IllegalArgumentException( + "Unsupported mode '" + mode + "'. Expected 'analyze' or 'finalize'."); + }; + } + + private static String buildFinalizeMessage(AgentRequest request) { + StringBuilder message = new StringBuilder("Approved draft:\n\n").append(request.draft()); + if (request.reviewerComments() != null && !request.reviewerComments().isBlank()) { + message.append("\n\nReviewer comments:\n").append(request.reviewerComments()); + } + return message.toString(); + } +} diff --git a/lambda-durable-agentcore-springai-sam-java/agent/src/main/resources/application.properties b/lambda-durable-agentcore-springai-sam-java/agent/src/main/resources/application.properties new file mode 100644 index 000000000..274131da3 --- /dev/null +++ b/lambda-durable-agentcore-springai-sam-java/agent/src/main/resources/application.properties @@ -0,0 +1,17 @@ +spring.application.name=document-review-agent + +# AgentCore Runtime requires the container to listen on 0.0.0.0:8080 +server.port=8080 +server.address=0.0.0.0 + +# Region and model are injected as environment variables by the AgentCore runtime +# (see EnvironmentVariables on AWS::BedrockAgentCore::Runtime in template.yaml). +spring.ai.bedrock.aws.region=${AWS_REGION:us-east-1} +spring.ai.bedrock.converse.chat.options.model=${BEDROCK_MODEL_ID:us.anthropic.claude-sonnet-4-5-20250929-v1:0} + +# The agent summarises documents; keep the response focused and deterministic. +spring.ai.bedrock.converse.chat.options.temperature=0.2 +spring.ai.bedrock.converse.chat.options.max-tokens=2048 + +logging.level.root=INFO +logging.level.com.example.agent=DEBUG diff --git a/lambda-durable-agentcore-springai-sam-java/events/submit-document.json b/lambda-durable-agentcore-springai-sam-java/events/submit-document.json new file mode 100644 index 000000000..5ae4487b6 --- /dev/null +++ b/lambda-durable-agentcore-springai-sam-java/events/submit-document.json @@ -0,0 +1,5 @@ +{ + "documentId": "doc-2026-001", + "title": "Q3 Engineering Budget Proposal", + "documentText": "Q3 Engineering Budget Proposal\n\nWe propose increasing engineering headcount by 4 full-time equivalents in Q3, at a total cost of 620,000 USD. Two positions are backend engineers for the payments platform, one is a site reliability engineer, and one is a QA automation engineer. The increase is funded by reallocating the unspent Q2 contractor budget of 400,000 USD, with the remaining 220,000 USD drawn from the annual contingency reserve.\n\nThe payments platform has been operating with a two-person on-call rotation since January, which the engineering manager has flagged as a retention risk. The SRE hire is intended to bring the rotation to four people.\n\nApproval is required by August 15 so that recruiting can begin before the Q3 hiring freeze." +} diff --git a/lambda-durable-agentcore-springai-sam-java/example-pattern.json b/lambda-durable-agentcore-springai-sam-java/example-pattern.json new file mode 100644 index 000000000..429ef1346 --- /dev/null +++ b/lambda-durable-agentcore-springai-sam-java/example-pattern.json @@ -0,0 +1,74 @@ +{ + "title": "Human-in-the-loop AI review with Lambda durable functions", + "description": "Orchestrate a Spring AI agent on Bedrock AgentCore from a Java Lambda durable function, pausing for human approval.", + "language": "Java", + "level": "300", + "framework": "AWS SAM", + "introBox": { + "headline": "How it works", + "text": [ + "This pattern shows how to pause a long-running Java workflow for human approval of an AI agent's work. An AWS Lambda durable function orchestrates a Spring AI agent hosted on Amazon Bedrock AgentCore Runtime, suspending mid-workflow until a person approves or rejects the agent's draft.", + "The workflow runs three durable operations. First, a step invokes the Spring AI agent on AgentCore, which reads a document and drafts a summary highlighting key points and open concerns. Second, waitForCallback creates a callback and suspends the execution. At this point the function stops running entirely: no compute is billed while it waits, and it can stay suspended for up to the configured seven-day execution timeout. Third, once a decision arrives through SendDurableExecutionCallbackSuccess, Lambda starts a fresh invocation, replays the handler from the top while skipping the already-completed step, and resumes at the callback with the reviewer's decision. Approved documents go back to the agent for a final polish, reusing the same AgentCore session so the agent still has the earlier turn in context. Rejected ones skip that step entirely.", + "There is no database in this pattern, and that is the point: Lambda checkpoints step results, the pending callback and the handler's return value into storage the service manages, so the workflow's state is readable with get-durable-execution and get-durable-execution-history rather than from a table you provision. Submission and approval are both driven from the AWS CLI, which keeps the example focused on the durable execution model itself.", + "The agent is an ordinary Spring Boot application. The community spring-ai-agentcore-runtime-starter auto-configures the POST /invocations and GET /ping endpoints that AgentCore Runtime requires, so the agent code is a single method annotated with @AgentCoreInvocation that calls Amazon Bedrock through Spring AI's Converse integration. Because AgentCore hosts source code directly only for Python and Node, the Java agent ships as an ARM64 container image built by an included script and passed to the stack as a parameter.", + "Together these pieces show the durability model that makes agentic workflows practical: expensive, slow agent calls are checkpointed so they are never repeated on failure or replay, and the workflow can wait days for a human without holding an execution open or paying for idle time. The pattern deploys one Lambda durable function and one AgentCore runtime." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/lambda-durable-agentcore-springai-sam-java", + "templateURL": "serverless-patterns/lambda-durable-agentcore-springai-sam-java", + "projectFolder": "lambda-durable-agentcore-springai-sam-java", + "templateFile": "template.yaml" + } + }, + "resources": { + "bullets": [ + { + "text": "AWS Lambda durable functions", + "link": "https://docs.aws.amazon.com/lambda/latest/dg/durable-functions.html" + }, + { + "text": "AWS Durable Execution SDK for Java", + "link": "https://docs.aws.amazon.com/durable-execution/sdk-reference/languages/java/" + }, + { + "text": "Host an agent with Amazon Bedrock AgentCore Runtime", + "link": "https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/agents-tools-runtime.html" + }, + { + "text": "Callback operations for human-in-the-loop workflows", + "link": "https://docs.aws.amazon.com/durable-execution/sdk-reference/operations/callback/" + }, + { + "text": "Spring AI reference documentation", + "link": "https://docs.spring.io/spring-ai/reference/" + } + ] + }, + "deploy": { + "text": [ + "./scripts/build-agent-image.sh", + "sam build && sam deploy --guided" + ] + }, + "testing": { + "text": [ + "See the GitHub repo for detailed testing instructions." + ] + }, + "cleanup": { + "text": [ + "Delete the stack: sam delete." + ] + }, + "authors": [ + { + "name": "Paras Jain", + "image": "https://avatars.githubusercontent.com/u/583119?v=4", + "bio": "Paras is a Technical Account Manager with AWS based out of Herndon, Virginia, USA. He is a member of Serverless Technical Field community", + "linkedin": "parasjain01", + "twitter": "parasjain01" + } + ] +} diff --git a/lambda-durable-agentcore-springai-sam-java/orchestrator/pom.xml b/lambda-durable-agentcore-springai-sam-java/orchestrator/pom.xml new file mode 100644 index 000000000..8f79c8c16 --- /dev/null +++ b/lambda-durable-agentcore-springai-sam-java/orchestrator/pom.xml @@ -0,0 +1,119 @@ + + + 4.0.0 + + com.example + document-review-orchestrator + 1.0 + jar + Document Review Orchestrator + + + 21 + 21 + UTF-8 + 2.1.0 + 2.50.1 + 2.26.1 + + + + + + software.amazon.awssdk + bom + ${aws.sdk.version} + pom + import + + + + + + + + software.amazon.lambda.durable + aws-durable-execution-sdk-java + ${durable.sdk.version} + + + com.amazonaws + aws-lambda-java-core + 1.4.0 + + + + software.amazon.awssdk + bedrockagentcore + + + + + org.apache.logging.log4j + log4j-slf4j2-impl + ${log4j.version} + + + org.apache.logging.log4j + log4j-core + ${log4j.version} + + + + org.apache.logging.log4j + log4j-layout-template-json + ${log4j.version} + + + + software.amazon.lambda.durable + aws-durable-execution-sdk-java-testing + ${durable.sdk.version} + test + + + org.junit.jupiter + junit-jupiter + 5.11.4 + test + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.2 + + + + org.apache.logging.log4j + log4j-transform-maven-shade-plugin-extensions + 0.2.0 + + + + false + + + + + + + package + + shade + + + + + + + diff --git a/lambda-durable-agentcore-springai-sam-java/orchestrator/src/main/java/com/example/durableagent/AgentClient.java b/lambda-durable-agentcore-springai-sam-java/orchestrator/src/main/java/com/example/durableagent/AgentClient.java new file mode 100644 index 000000000..39217670e --- /dev/null +++ b/lambda-durable-agentcore-springai-sam-java/orchestrator/src/main/java/com/example/durableagent/AgentClient.java @@ -0,0 +1,109 @@ +/*! Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT-0 + */ +package com.example.durableagent; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.bedrockagentcore.BedrockAgentCoreClient; +import software.amazon.awssdk.services.bedrockagentcore.model.InvokeAgentRuntimeRequest; +import software.amazon.awssdk.services.bedrockagentcore.model.InvokeAgentRuntimeResponse; + +/** + * Thin wrapper over {@code InvokeAgentRuntime} for the Spring AI agent hosted on AgentCore. + * + *

Two details are easy to get wrong: + * + *

    + *
  • {@code InvokeAgentRuntimeResponse} exposes no {@code payload()} accessor - the response + * body is streamed. {@code invokeAgentRuntimeAsBytes} buffers it so the result can be read + * as a string. + *
  • {@code runtimeSessionId} must be at least 33 characters, and is derived deterministically + * from the document ID by {@link #sessionIdFor} - never randomly - so that a replay reuses + * the same session and the agent sees one continuous conversation across the approval gate. + *
+ */ +public class AgentClient { + + /** AgentCore requires a session ID of at least this many characters. */ + private static final int MIN_SESSION_ID_LENGTH = 33; + + /** Marks the session as belonging to this workflow, and lengthens short document IDs. */ + private static final String SESSION_ID_PREFIX = "doc-review-"; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final BedrockAgentCoreClient client; + private final String agentRuntimeArn; + + public AgentClient(BedrockAgentCoreClient client, String agentRuntimeArn) { + this.client = client; + this.agentRuntimeArn = agentRuntimeArn; + } + + /** + * Invokes the agent and returns its response text. + * + * @param documentId identifies the review; both turns share the session derived from it + * @param mode {@code analyze} or {@code finalize} + * @param document document text for {@code analyze}; may be null for {@code finalize} + * @param draft approved draft for {@code finalize}; may be null for {@code analyze} + * @param comments reviewer comments to fold into the final summary; may be null + */ + public String invoke(String documentId, String mode, String document, String draft, String comments) { + if (client == null) { + throw new IllegalStateException("No AgentCore client configured"); + } + ObjectNode payload = MAPPER.createObjectNode(); + payload.put("mode", mode); + if (document != null) { + payload.put("document", document); + } + if (draft != null) { + payload.put("draft", draft); + } + if (comments != null) { + payload.put("reviewerComments", comments); + } + + String body; + try { + body = MAPPER.writeValueAsString(payload); + } catch (Exception e) { + throw new IllegalStateException("Unable to serialize agent request", e); + } + + ResponseBytes response = client.invokeAgentRuntimeAsBytes( + InvokeAgentRuntimeRequest.builder() + .agentRuntimeArn(agentRuntimeArn) + .runtimeSessionId(sessionIdFor(documentId)) + .contentType("application/json") + .accept("application/json") + .payload(SdkBytes.fromUtf8String(body)) + .build()); + + return response.asUtf8String(); + } + + /** + * Builds the AgentCore session ID for a document. + * + *

AgentCore requires {@code runtimeSessionId} to be at least + * {@value #MIN_SESSION_ID_LENGTH} characters. Document IDs are often shorter than that, so the + * ID is prefixed and then right-padded to the minimum. The document ID stays at the front, + * which keeps AgentCore's logs readable. + * + *

This must be a pure function of the document ID. Using a random value - a fresh + * {@code UUID}, say - would hand the agent a different session on every replay, so the + * finalize turn would lose the context of the analyze turn. + */ + static String sessionIdFor(String documentId) { + StringBuilder id = new StringBuilder(SESSION_ID_PREFIX).append(documentId); + while (id.length() < MIN_SESSION_ID_LENGTH) { + id.append('0'); + } + return id.toString(); + } +} diff --git a/lambda-durable-agentcore-springai-sam-java/orchestrator/src/main/java/com/example/durableagent/DocumentReviewWorkflow.java b/lambda-durable-agentcore-springai-sam-java/orchestrator/src/main/java/com/example/durableagent/DocumentReviewWorkflow.java new file mode 100644 index 000000000..abb210fff --- /dev/null +++ b/lambda-durable-agentcore-springai-sam-java/orchestrator/src/main/java/com/example/durableagent/DocumentReviewWorkflow.java @@ -0,0 +1,154 @@ +/*! Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT-0 + */ +package com.example.durableagent; + +import java.time.Duration; + +import com.example.durableagent.model.Decision; +import com.example.durableagent.model.ReviewRequest; +import com.example.durableagent.model.ReviewResult; +import software.amazon.awssdk.services.bedrockagentcore.BedrockAgentCoreClient; +import software.amazon.lambda.durable.DurableContext; +import software.amazon.lambda.durable.DurableHandler; +import software.amazon.lambda.durable.config.CallbackConfig; +import software.amazon.lambda.durable.config.WaitForCallbackConfig; +import software.amazon.lambda.durable.exception.CallbackTimeoutException; + +/** + * Orchestrates a document review across an AI agent and a human approver. + * + *

+ *   step            analyze-document    -> Spring AI agent on AgentCore drafts a summary
+ *   waitForCallback await-human-review  -> execution suspends until a human decides
+ *   step            finalize-document   -> agent polishes the approved draft
+ * 
+ * + *

While suspended at the callback the function consumes no compute, and it can stay that way + * for as long as {@code DurableConfig.ExecutionTimeout} allows. When the decision arrives, Lambda + * starts a fresh invocation and replays this method from the top, skipping the operations that + * already completed - so the agent is not asked to analyze the document a second time. + * + *

Where state lives

+ * + * Nowhere in this pattern. Lambda checkpoints each durable operation - step results, the pending + * callback, and this method's return value - into storage the service manages, retained for + * {@code DurableConfig.RetentionPeriodInDays}. Read it back with + * {@code aws lambda get-durable-execution} and {@code ... get-durable-execution-history}; there is + * no table to provision. + * + *

Determinism

+ * + * Both agent calls happen inside steps, so replay reuses the checkpointed text rather than + * re-invoking the model. Steps return their values instead of mutating captured state, because on + * replay a step body does not execute and any such mutation would silently be lost. + */ +public class DocumentReviewWorkflow extends DurableHandler { + + /** + * How long a review may sit awaiting a human. Must stay below the function's + * {@code DurableConfig.ExecutionTimeout}, which is set to 7 days in template.yaml. + */ + static final Duration APPROVAL_TIMEOUT = Duration.ofHours(24); + + private final AgentClient agent; + + /** Constructor used by the Lambda runtime. */ + public DocumentReviewWorkflow() { + this(new AgentClient(SharedClients.AGENT_CORE, System.getenv("AGENT_RUNTIME_ARN"))); + } + + /** Constructor used by tests, which supply a stand-in agent. */ + DocumentReviewWorkflow(AgentClient agent) { + this.agent = agent; + } + + @Override + public ReviewResult handleRequest(ReviewRequest request, DurableContext ctx) { + String documentId = request.documentId(); + ctx.getLogger().info("Starting review of document " + documentId); + + // Step 1 - the agent drafts a summary for the reviewer. + String draft = ctx.step("analyze-document", String.class, + stepCtx -> agent.invoke(documentId, "analyze", request.documentText(), null, null)); + + // Step 2 - suspend until a human decides. The submitter runs as a step, so the SDK retries + // it if it fails; here it simply publishes the callback ID for the reviewer to pick up. + Decision decision; + try { + decision = ctx.waitForCallback("await-human-review", Decision.class, + (callbackId, stepCtx) -> announceReviewRequest(callbackId, request, draft), + WaitForCallbackConfig.builder() + .callbackConfig(CallbackConfig.builder() + .timeout(APPROVAL_TIMEOUT) + .build()) + .build()); + } catch (CallbackTimeoutException e) { + ctx.getLogger().warn("No decision within " + APPROVAL_TIMEOUT + " for " + documentId); + return ReviewResult.expired(documentId, draft); + } + + if (!decision.isApproved()) { + ctx.getLogger().info("Document " + documentId + " was rejected"); + // A rejection is a completed review, not a failure - see Decision's javadoc. + return ReviewResult.rejected(documentId, draft, decision.comments()); + } + + // Step 3 - the agent produces the final copy, reusing the same AgentCore session so it + // still has the analyze turn in context. + String finalSummary = ctx.step("finalize-document", String.class, + stepCtx -> agent.invoke(documentId, "finalize", null, draft, decision.comments())); + + ctx.getLogger().info("Review of document " + documentId + " complete"); + return ReviewResult.approved(documentId, draft, finalSummary, decision.comments()); + } + + /** + * Makes the pending review visible to a human. + * + *

This pattern keeps the notification channel deliberately minimal: the callback ID and the + * agent's draft go to the function's log, and the reviewer resumes the workflow with the AWS + * CLI. The callback ID is also recorded by the service itself, on the {@code CallbackStarted} + * event in the execution history, so nothing here is load-bearing. + * + *

A production workflow would notify the reviewer out of band - email, chat, a ticket. If + * you send a link, make sure opening it does not record the decision: mail clients and + * security scanners prefetch URLs, and a link that decides on {@code GET} will be actioned by + * a scanner rather than by your approver. Render a confirmation page on {@code GET} and act + * only on the {@code POST} it submits. + */ + private static void announceReviewRequest(String callbackId, ReviewRequest request, String draft) { + System.out.printf(""" + + ========================================================================= + REVIEW REQUIRED: %s (%s) + + --- AGENT DRAFT --- + %s + ------------------- + + Approve: + aws lambda send-durable-execution-callback-success \\ + --callback-id %s \\ + --cli-binary-format raw-in-base64-out \\ + --result '{"decision":"approved","comments":"Looks good"}' + + Reject: + aws lambda send-durable-execution-callback-success \\ + --callback-id %s \\ + --cli-binary-format raw-in-base64-out \\ + --result '{"decision":"rejected","comments":"Needs a cost breakdown"}' + + Expires in %d hours. + ========================================================================= + + """, + request.title(), request.documentId(), draft, + callbackId, callbackId, APPROVAL_TIMEOUT.toHours()); + } + + /** Holder so the AgentCore client is created once per environment, and never in unit tests. */ + private static final class SharedClients { + static final BedrockAgentCoreClient AGENT_CORE = BedrockAgentCoreClient.create(); + } +} diff --git a/lambda-durable-agentcore-springai-sam-java/orchestrator/src/main/java/com/example/durableagent/model/Decision.java b/lambda-durable-agentcore-springai-sam-java/orchestrator/src/main/java/com/example/durableagent/model/Decision.java new file mode 100644 index 000000000..fed25f905 --- /dev/null +++ b/lambda-durable-agentcore-springai-sam-java/orchestrator/src/main/java/com/example/durableagent/model/Decision.java @@ -0,0 +1,29 @@ +/*! Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT-0 + */ +package com.example.durableagent.model; + +/** + * The reviewer's verdict, as delivered to the waiting callback. + * + *

Approve and reject are both callback successes

+ * + * Both are sent with {@code SendDurableExecutionCallbackSuccess}. The success/failure axis of the + * callback API describes whether a decision was obtained, not whether the answer was + * favourable - a reviewer who rejects a document has successfully decided. Reserve + * {@code SendDurableExecutionCallbackFailure} for cases where no decision can be produced at all, + * such as an abandoned review or an unreachable approver; that surfaces in the workflow as a + * thrown exception rather than as a value. + * + *

Carrying the verdict as data also keeps replay deterministic: the workflow branches on this + * checkpointed value, so every replay takes the same path. + * + * @param decision {@code approved} or {@code rejected} + * @param comments optional reviewer notes, passed to the agent when finalizing + */ +public record Decision(String decision, String comments) { + + public boolean isApproved() { + return "approved".equalsIgnoreCase(decision); + } +} diff --git a/lambda-durable-agentcore-springai-sam-java/orchestrator/src/main/java/com/example/durableagent/model/ReviewRequest.java b/lambda-durable-agentcore-springai-sam-java/orchestrator/src/main/java/com/example/durableagent/model/ReviewRequest.java new file mode 100644 index 000000000..d35bbadd5 --- /dev/null +++ b/lambda-durable-agentcore-springai-sam-java/orchestrator/src/main/java/com/example/durableagent/model/ReviewRequest.java @@ -0,0 +1,14 @@ +/*! Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT-0 + */ +package com.example.durableagent.model; + +/** + * Workflow input, submitted through {@code POST /documents}. + * + * @param documentId caller-supplied identifier, used to derive a stable AgentCore session ID + * @param title short human-readable label included in the approval notification + * @param documentText the content the agent should review + */ +public record ReviewRequest(String documentId, String title, String documentText) { +} diff --git a/lambda-durable-agentcore-springai-sam-java/orchestrator/src/main/java/com/example/durableagent/model/ReviewResult.java b/lambda-durable-agentcore-springai-sam-java/orchestrator/src/main/java/com/example/durableagent/model/ReviewResult.java new file mode 100644 index 000000000..143067258 --- /dev/null +++ b/lambda-durable-agentcore-springai-sam-java/orchestrator/src/main/java/com/example/durableagent/model/ReviewResult.java @@ -0,0 +1,56 @@ +/*! Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT-0 + */ +package com.example.durableagent.model; + +/** + * The workflow's return value. Lambda checkpoints this as the durable execution's result, so it + * is readable with {@code aws lambda get-durable-execution} for as long as the configured + * retention period allows - the pattern stores it nowhere else. + * + *

{@code outcome} and {@code decision} answer two different questions, and keeping them apart + * avoids conflating "the reviewer said no" with "nobody ever answered": + * + *

    + *
  • {@code outcome} - was a decision obtained at all? {@code DECIDED} or {@code EXPIRED}. + *
  • {@code decision} - what the reviewer chose: {@code APPROVED} or {@code REJECTED}. + * Null when {@code outcome} is {@code EXPIRED}. + *
+ * + * @param documentId the identifier supplied on submission + * @param outcome {@code DECIDED} if a human responded, {@code EXPIRED} if the callback timed out + * @param decision {@code APPROVED} or {@code REJECTED}; null when {@code outcome} is {@code EXPIRED} + * @param draft the agent's review draft, retained whatever the outcome + * @param finalSummary the polished summary; null unless the reviewer approved + * @param comments reviewer notes, if any + */ +public record ReviewResult( + String documentId, + String outcome, + String decision, + String draft, + String finalSummary, + String comments) { + + public static final String DECIDED = "DECIDED"; + public static final String EXPIRED = "EXPIRED"; + public static final String APPROVED = "APPROVED"; + public static final String REJECTED = "REJECTED"; + + /** A review a human approved. */ + public static ReviewResult approved( + String documentId, String draft, String finalSummary, String comments) { + return new ReviewResult(documentId, DECIDED, APPROVED, draft, finalSummary, comments); + } + + /** A review a human rejected. Still a decision, so the outcome is {@code DECIDED}. */ + public static ReviewResult rejected(String documentId, String draft, String comments) { + return new ReviewResult(documentId, DECIDED, REJECTED, draft, null, comments); + } + + /** No decision arrived before the callback timed out. */ + public static ReviewResult expired(String documentId, String draft) { + return new ReviewResult(documentId, EXPIRED, null, draft, null, + "No decision was received before the approval window closed"); + } +} diff --git a/lambda-durable-agentcore-springai-sam-java/orchestrator/src/main/resources/log4j2.xml b/lambda-durable-agentcore-springai-sam-java/orchestrator/src/main/resources/log4j2.xml new file mode 100644 index 000000000..ae116b6dc --- /dev/null +++ b/lambda-durable-agentcore-springai-sam-java/orchestrator/src/main/resources/log4j2.xml @@ -0,0 +1,49 @@ + + + + + + + + { + "timestamp": {"$resolver": "timestamp", "pattern": {"format": "yyyy-MM-dd'T'HH:mm:ss.SSSZ"}}, + "level": {"$resolver": "level", "field": "name"}, + "logger": {"$resolver": "logger", "field": "name"}, + "message": {"$resolver": "message", "stringified": true}, + "executionArn": {"$resolver": "mdc", "key": "executionArn"}, + "operationId": {"$resolver": "mdc", "key": "operationId"}, + "operationName": {"$resolver": "mdc", "key": "operationName"}, + "exception": { + "class": {"$resolver": "exception", "field": "className"}, + "message": {"$resolver": "exception", "field": "message"}, + "stackTrace": {"$resolver": "exception", "field": "stackTrace", "stringified": true} + } + } + + + + + + + + + + + + + + + diff --git a/lambda-durable-agentcore-springai-sam-java/orchestrator/src/test/java/com/example/durableagent/DocumentReviewWorkflowTest.java b/lambda-durable-agentcore-springai-sam-java/orchestrator/src/test/java/com/example/durableagent/DocumentReviewWorkflowTest.java new file mode 100644 index 000000000..04a04b585 --- /dev/null +++ b/lambda-durable-agentcore-springai-sam-java/orchestrator/src/test/java/com/example/durableagent/DocumentReviewWorkflowTest.java @@ -0,0 +1,143 @@ +/*! Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT-0 + */ +package com.example.durableagent; + +import java.util.concurrent.atomic.AtomicInteger; + +import com.example.durableagent.model.ReviewRequest; +import com.example.durableagent.model.ReviewResult; +import org.junit.jupiter.api.Test; +import software.amazon.lambda.durable.model.ExecutionStatus; +import software.amazon.lambda.durable.testing.LocalDurableTestRunner; +import software.amazon.lambda.durable.testing.TestResult; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Exercises all three approval outcomes against the durable execution SDK's in-memory runner - + * no AWS account, no deployment. The agent is replaced with a counting stand-in so the tests can + * assert on control flow and, crucially, on replay behaviour. + */ +class DocumentReviewWorkflowTest { + + /** + * waitForCallback expands into a child context containing a callback and a submitter step. + * The CALLBACK operation is the "-callback" child, which is what the runner keys resume on. + */ + private static final String CALLBACK_OP = "await-human-review-callback"; + + private static final ReviewRequest REQUEST = + new ReviewRequest("doc-001", "Q3 Budget", "Headcount increases by 4 FTE."); + + /** Counts invocations per mode, so a step re-executed on replay would be visible. */ + private static final class FakeAgent extends AgentClient { + final AtomicInteger analyzeCalls = new AtomicInteger(); + final AtomicInteger finalizeCalls = new AtomicInteger(); + + FakeAgent() { + super(null, "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/test"); + } + + @Override + public String invoke(String documentId, String mode, String document, String draft, String comments) { + if ("analyze".equals(mode)) { + analyzeCalls.incrementAndGet(); + return "DRAFT: " + document; + } + finalizeCalls.incrementAndGet(); + return "FINAL: " + draft + (comments == null ? "" : " / " + comments); + } + } + + private record Fixture( + LocalDurableTestRunner runner, + FakeAgent agent) { + } + + private static Fixture fixture() { + FakeAgent agent = new FakeAgent(); + LocalDurableTestRunner runner = + LocalDurableTestRunner.create(ReviewRequest.class, new DocumentReviewWorkflow(agent)) + .withOutputType(ReviewResult.class); + return new Fixture(runner, agent); + } + + @Test + void approvedReviewProducesFinalSummary() { + Fixture f = fixture(); + + // The first invocation runs the analyze step, then suspends at the callback. + f.runner().run(REQUEST); + + // A human approves. + f.runner().completeCallback(f.runner().getCallbackId(CALLBACK_OP), + "{\"decision\":\"approved\",\"comments\":\"Looks good\"}"); + + TestResult result = f.runner().runUntilComplete(REQUEST); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + ReviewResult review = result.getResult(); + assertEquals("DECIDED", review.outcome()); + assertEquals("APPROVED", review.decision()); + assertEquals("DRAFT: " + REQUEST.documentText(), review.draft()); + assertTrue(review.finalSummary().startsWith("FINAL: DRAFT:")); + assertTrue(review.finalSummary().contains("Looks good"), "reviewer comments reach the agent"); + + // The heart of the durable model: the handler body ran again after the callback resumed + // it, but the completed step was served from its checkpoint rather than re-executed. + assertEquals(1, f.agent().analyzeCalls.get(), "analyze step re-executed on replay"); + assertEquals(1, f.agent().finalizeCalls.get()); + } + + @Test + void rejectedReviewSkipsFinalizeStep() { + Fixture f = fixture(); + f.runner().run(REQUEST); + + f.runner().completeCallback(f.runner().getCallbackId(CALLBACK_OP), + "{\"decision\":\"rejected\",\"comments\":\"Needs cost breakdown\"}"); + + TestResult result = f.runner().runUntilComplete(REQUEST); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + ReviewResult review = result.getResult(); + assertEquals("DECIDED", review.outcome(), "a rejection is still a completed review"); + assertEquals("REJECTED", review.decision()); + assertNull(review.finalSummary(), "rejected reviews must not be finalized"); + assertEquals("Needs cost breakdown", review.comments()); + assertEquals(0, f.agent().finalizeCalls.get(), "finalize must be skipped on rejection"); + } + + @Test + void timedOutApprovalIsRecorded() { + Fixture f = fixture(); + f.runner().run(REQUEST); + + f.runner().timeoutCallback(f.runner().getCallbackId(CALLBACK_OP)); + + TestResult result = f.runner().runUntilComplete(REQUEST); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + ReviewResult review = result.getResult(); + assertEquals("EXPIRED", review.outcome()); + assertNull(review.decision(), "no decision was ever made"); + assertNotNull(review.draft(), "the draft is retained even when approval expires"); + assertEquals(0, f.agent().finalizeCalls.get()); + } + + @Test + void sessionIdMeetsServiceMinimumAndIsStable() { + String sessionId = AgentClient.sessionIdFor("doc-001"); + assertTrue(sessionId.length() >= 33, "AgentCore requires at least 33 characters"); + assertTrue(sessionId.startsWith("doc-review-doc-001"), "document ID stays legible in logs"); + assertEquals(sessionId, AgentClient.sessionIdFor("doc-001"), + "must be a pure function of the document ID, or replay would change the session"); + assertNotEquals(sessionId, AgentClient.sessionIdFor("doc-002"), + "different documents must not share a session"); + } +} diff --git a/lambda-durable-agentcore-springai-sam-java/scripts/build-agent-image.sh b/lambda-durable-agentcore-springai-sam-java/scripts/build-agent-image.sh new file mode 100755 index 000000000..91eff3779 --- /dev/null +++ b/lambda-durable-agentcore-springai-sam-java/scripts/build-agent-image.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# +# Builds the Spring AI agent as an ARM64 container image and pushes it to ECR. +# +# AgentCore Runtime can host plain source code only for Python and Node, so a Java agent must +# be delivered as a container image. SAM cannot build it for us either: its Metadata/Dockerfile +# support applies to PackageType: Image Lambda functions and builds for the host architecture. +# So the image is produced here, before `sam deploy`, and its URI is passed in as a parameter. +# +# Usage: ./scripts/build-agent-image.sh [region] [repository-name] + +set -euo pipefail + +REGION="${1:-${AWS_REGION:-us-east-1}}" +REPO_NAME="${2:-document-review-agent}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +AGENT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)/agent" + +# Prefer docker, fall back to finch, nerdctl or podman. +CONTAINER_CLI="" +for candidate in docker finch nerdctl podman; do + if command -v "$candidate" >/dev/null 2>&1; then + CONTAINER_CLI="$candidate" + break + fi +done +if [[ -z "$CONTAINER_CLI" ]]; then + echo "ERROR: no container CLI found (looked for docker, finch, nerdctl, podman)." >&2 + exit 1 +fi + +command -v aws >/dev/null 2>&1 || { echo "ERROR: aws CLI is not installed." >&2; exit 1; } + +if command -v mvn >/dev/null 2>&1; then + MVN=mvn +elif [[ -n "${MAVEN_HOME:-}" && -x "${MAVEN_HOME}/bin/mvn" ]]; then + MVN="${MAVEN_HOME}/bin/mvn" +else + echo "ERROR: Maven is not installed or not on PATH." >&2 + exit 1 +fi + +ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)" +REGISTRY="${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com" +IMAGE_URI="${REGISTRY}/${REPO_NAME}" + +echo "==> Building the Spring AI agent jar" +"$MVN" -q -f "${AGENT_DIR}/pom.xml" clean package -DskipTests + +echo "==> Ensuring ECR repository '${REPO_NAME}' exists in ${REGION}" +if ! aws ecr describe-repositories --repository-names "$REPO_NAME" --region "$REGION" >/dev/null 2>&1; then + aws ecr create-repository \ + --repository-name "$REPO_NAME" \ + --region "$REGION" \ + --image-scanning-configuration scanOnPush=true >/dev/null + echo " created" +else + echo " already exists" +fi + +echo "==> Logging in to ECR" +aws ecr get-login-password --region "$REGION" \ + | "$CONTAINER_CLI" login --username AWS --password-stdin "$REGISTRY" + +# A content-independent but unique tag; AgentCore requires an explicit tag (not :latest only) +# and a new tag makes each deployment an unambiguous update. +TAG="$(date +%Y%m%d%H%M%S)" + +echo "==> Building ARM64 image with ${CONTAINER_CLI}" +"$CONTAINER_CLI" build \ + --platform linux/arm64 \ + -t "${IMAGE_URI}:${TAG}" \ + -t "${IMAGE_URI}:latest" \ + "$AGENT_DIR" + +echo "==> Pushing" +"$CONTAINER_CLI" push "${IMAGE_URI}:${TAG}" +"$CONTAINER_CLI" push "${IMAGE_URI}:latest" + +cat < Done. + +Image: ${IMAGE_URI}:${TAG} + +Deploy with: + + sam deploy --guided --parameter-overrides \\ + ApproverEmail=you@example.com \\ + AgentImageUri=${IMAGE_URI}:${TAG} + +EOF diff --git a/lambda-durable-agentcore-springai-sam-java/template.yaml b/lambda-durable-agentcore-springai-sam-java/template.yaml new file mode 100644 index 000000000..42e0b047d --- /dev/null +++ b/lambda-durable-agentcore-springai-sam-java/template.yaml @@ -0,0 +1,165 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: >- + Serverless patterns - Orchestrating a Spring AI agent on Amazon Bedrock AgentCore with an AWS + Lambda durable function (uksb-1tthgi812) (tag:lambda-durable-agentcore-springai-sam-java) + +Parameters: + AgentImageUri: + Type: String + Description: >- + ECR URI of the Spring AI agent image, including the tag. Run + scripts/build-agent-image.sh first - it creates the repository, builds the ARM64 image, + pushes it, and prints the value to use here. + AllowedPattern: '^\d{12}\.dkr\.ecr\.[a-z0-9-]+\.amazonaws\.com/.+:.+$' + ConstraintDescription: Must be a full ECR image URI including a tag. + + AgentRuntimeName: + Type: String + Default: document_review_agent + Description: >- + Name of the AgentCore runtime. Only letters, digits and underscores are allowed - + the service rejects hyphens. + AllowedPattern: '^[a-zA-Z][a-zA-Z0-9_]{0,47}$' + ConstraintDescription: Letters, digits and underscores only (no hyphens), max 48 characters. + + BedrockModelId: + Type: String + Default: us.anthropic.claude-sonnet-4-5-20250929-v1:0 + Description: Bedrock model (or inference profile) the Spring AI agent calls. + +Resources: + + # ------------------------------------------------------------------------------------------ + # The Spring AI agent, hosted on AgentCore Runtime + # + # AgentCore can host plain source code, but only for Python and Node runtimes, so a Java agent + # must be supplied as an ARM64 container image. scripts/build-agent-image.sh builds and pushes + # it before this stack is deployed, and passes the URI in as AgentImageUri. + # ------------------------------------------------------------------------------------------ + + AgentExecutionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: bedrock-agentcore.amazonaws.com + Action: sts:AssumeRole + # Confused-deputy protection: only this account's AgentCore resources may assume + # this role. + Condition: + StringEquals: + aws:SourceAccount: !Ref AWS::AccountId + ArnLike: + aws:SourceArn: !Sub 'arn:${AWS::Partition}:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:*' + Policies: + - PolicyName: AgentRuntimePermissions + PolicyDocument: + Version: '2012-10-17' + Statement: + - Sid: InvokeBedrockModels + Effect: Allow + Action: + - bedrock:InvokeModel + - bedrock:InvokeModelWithResponseStream + - bedrock:Converse + - bedrock:ConverseStream + Resource: + - !Sub 'arn:${AWS::Partition}:bedrock:*::foundation-model/*' + - !Sub 'arn:${AWS::Partition}:bedrock:${AWS::Region}:${AWS::AccountId}:inference-profile/*' + - Sid: PullAgentImage + Effect: Allow + Action: + - ecr:BatchCheckLayerAvailability + - ecr:BatchGetImage + - ecr:GetDownloadUrlForLayer + Resource: !Sub 'arn:${AWS::Partition}:ecr:${AWS::Region}:${AWS::AccountId}:repository/*' + - Sid: EcrAuthorization + Effect: Allow + # GetAuthorizationToken cannot be scoped to a repository. + Action: ecr:GetAuthorizationToken + Resource: '*' + - Sid: WriteAgentLogs + Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + - logs:DescribeLogStreams + Resource: !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/bedrock-agentcore/runtimes/*' + + AgentRuntime: + Type: AWS::BedrockAgentCore::Runtime + Properties: + AgentRuntimeName: !Ref AgentRuntimeName + Description: Spring AI document review agent + AgentRuntimeArtifact: + ContainerConfiguration: + ContainerUri: !Ref AgentImageUri + NetworkConfiguration: + NetworkMode: PUBLIC + ProtocolConfiguration: HTTP + RoleArn: !GetAtt AgentExecutionRole.Arn + EnvironmentVariables: + BEDROCK_MODEL_ID: !Ref BedrockModelId + + # ------------------------------------------------------------------------------------------ + # The durable function that orchestrates the review + # ------------------------------------------------------------------------------------------ + + ReviewWorkflowFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub '${AWS::StackName}-review-workflow' + CodeUri: orchestrator + Handler: com.example.durableagent.DocumentReviewWorkflow::handleRequest + Runtime: java21 + Architectures: + - arm64 + MemorySize: 1024 + # Bounds a single invocation. The workflow's overall lifetime is governed by + # DurableConfig.ExecutionTimeout below, and no compute is billed while it is suspended + # waiting for a human. + Timeout: 300 + # Durable functions must be invoked through a qualified ARN, so publish an alias. + AutoPublishAlias: live + DurableConfig: + # 7 days. Must exceed the 24h callback timeout in DocumentReviewWorkflow, otherwise the + # execution would expire before the approval window closes. + ExecutionTimeout: 604800 + # How long the execution's checkpoints, history and result stay queryable after it ends. + RetentionPeriodInDays: 7 + Environment: + Variables: + AGENT_RUNTIME_ARN: !GetAtt AgentRuntime.AgentRuntimeArn + JAVA_TOOL_OPTIONS: '-XX:+TieredCompilation -XX:TieredStopAtLevel=1' + Policies: + # Grants lambda:CheckpointDurableExecution and lambda:GetDurableExecutionState alongside + # basic Lambda execution permissions. + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicDurableExecutionRolePolicy + - Statement: + - Sid: InvokeAgent + Effect: Allow + Action: bedrock-agentcore:InvokeAgentRuntime + Resource: + - !GetAtt AgentRuntime.AgentRuntimeArn + - !Sub '${AgentRuntime.AgentRuntimeArn}/*' + +Outputs: + WorkflowFunctionAlias: + Description: >- + Qualified function name to invoke. Start a review with + aws lambda invoke --function-name --invocation-type Event + --durable-execution-name --payload fileb://events/submit-document.json out.json + Value: !Sub '${AWS::StackName}-review-workflow:live' + + WorkflowFunctionName: + Description: Function name, for the aws lambda durable-execution commands. + Value: !Ref ReviewWorkflowFunction + + AgentRuntimeArn: + Description: ARN of the Spring AI agent runtime on AgentCore. + Value: !GetAtt AgentRuntime.AgentRuntimeArn