Technical Design Document: Spring AI Multi-Agent Architecture
DRAFT
Part 1: Overview
This document defines the shared Spring AI multi-agent architecture that powers two features in the Synapse platform:
V2 Custom Agents — Specialist Proxy: Production-grade specialist agents that custom Bedrock agents can delegate to via the
specialist_proxyreturn-control mechanism (Technical Design: Synapse Custom Agents Feature V2).Agent-Assisted Sample Sheet Generation: An autonomous multi-agent supervisor that orchestrates data retrieval, schema analysis, ETL code generation, and validation to produce workflow-ready sample sheets (Technical Design: Agent-Assisted Sample Sheet Generation).
Both features share a common Spring AI foundation but use different orchestration patterns:
Aspect | V2 Specialist Proxy | Sample Sheet Generation |
|---|---|---|
Trigger | ReturnControlHandler intercepts | ComputeTaskSubWorker started by async job |
Orchestration | Single specialist invocation per return-control event (custom Bedrock agent is the orchestrator) | Autonomous supervisor ChatClient decides execution order, delegates to sub-agent tools |
LLM API | Bedrock Converse API (via Spring AI ChatModel) | Bedrock Converse API (via Spring AI ChatModel) |
Memory | Stateless per invocation | AgentCore Short-Term Memory (scoped to single async job execution) |
Identity | UserInfo passed directly (in-process) | UserInfo passed directly (in-process) |
Design Principles
Deployment-agnostic: Runs inside the existing worker application. Works on both Elastic Beanstalk (current) and ECS Fargate (future).
Bounded communication: All agent-to-agent responses are capped. Large data is staged to AgentCore files; agents exchange references, not payloads.
LLM produces, Java persists: Agents generate and validate data; Java code handles all mutations to Synapse state (writes, task transitions, access control).
Live-testable: Each specialist and sub-agent has integration tests with a real LLM to validate that tool instructions produce correct behavior.
Part 2: Spring AI Foundation
This section describes the shared infrastructure that both features depend on.
Module Location
Spring AI lives in services/repository-managers alongside the existing agent infrastructure (AgentManager, ReturnControlHandler, AgentClientProvider). This keeps Spring AI ChatClient beans co-located with the Synapse manager beans they wrap as tools.
Dependencies (repository-managers pom.xml)
<!-- Spring AI BOM (in root pom dependencyManagement) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- In repository-managers pom.xml -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bedrock-converse</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>bedrockruntime</artifactId>
</dependency>Bean Configuration
Spring AI beans are configured via Java @Configuration classes (not XML). This is natural for Spring AI's builder-heavy API and allows clean injection of StackConfiguration for environment-specific model IDs.
@Configuration
public class SpringAiConfiguration {
@Bean
public BedrockConverseApi bedrockConverseApi(StackConfiguration stackConfig) {
return BedrockConverseApi.builder()
.region(Region.of(stackConfig.getRegion()))
.credentialsProvider(DefaultCredentialsProvider.create())
.build();
}
@Bean
public ChatModel bedrockChatModel(BedrockConverseApi api, StackConfiguration stackConfig) {
return BedrockConverseChatModel.builder()
.api(api)
.defaultOptions(BedrockConverseOptions.builder()
.model(stackConfig.getDefaultAgentModelId())
.build())
.build();
}
}Model Configuration via StackConfiguration
Model IDs are configurable per specialist/agent type via StackConfiguration, allowing different models per environment (dev vs prod) and per agent role (cheap model for simple tasks, expensive model for complex reasoning).
Config Property | Example Value | Used By |
|---|---|---|
|
| Default for all Spring AI agents |
|
| SYNAPSE_CORE_SEARCH specialist |
|
| SYNAPSE_CORE_METADATA specialist |
|
| Sample sheet supervisor agent |
|
| ETL Code Generation sub-agent |
Response Cap Policy
All agent-to-agent communication is bounded to prevent context window overflow and keep token costs predictable:
Boundary | Cap | Rationale |
|---|---|---|
Specialist → custom Bedrock agent (via SpecialistProxyHandler) | 4,000 chars | Response flows back into Bedrock agent context window |
Sub-agent → supervisor (sample sheet) | 4,000 chars | Keeps supervisor context lean across multiple sub-agent interactions |
Tool result → any agent | 4,000 chars | Prevents raw API payloads from inflating token usage |
When data exceeds the cap, agents stage it to AgentCore session files and pass file references in their responses.
Large Data Handling: AgentCore File Staging
For scenarios where tool output exceeds response caps (e.g., FileView query returning hundreds of annotation rows), the pattern is:
The tool writes full data as CSV/JSON to the AgentCore session filesystem via
AgentCoreCodeInterpreterClient.The tool returns a compact reference (filename, row count, schema summary) within the cap.
Downstream agents (e.g., Code Interpreter) read the staged file from the session filesystem.
This keeps agent-to-agent messages small while preserving full data fidelity for processing.
Part 3: AWS AgentCore Integration
AWS AgentCore provides two serverless APIs used by the multi-agent system:
Short-Term Memory
Purpose: Persists the turn-by-turn conversation transcript for the sample sheet supervisor agent across multiple sub-agent interactions within a single async job execution.
Lifecycle: A new AgentCore memory session is created when the async job starts and is discarded after the job completes (success or failure). If the user retries execution after a failure, a fresh session is created — no stale context carries over.
Integration pattern:
// Spring AI ChatMemory backed by AgentCore
@Bean
public ChatMemory agentCoreChatMemory(AgentCoreMemoryClient memoryClient) {
return new AgentCoreChatMemory(memoryClient);
}
// Used by the sample sheet supervisor ChatClient
@Bean
public ChatClient sampleSheetSupervisor(ChatModel model, ChatMemory memory,
List<Object> subAgentTools) {
return ChatClient.builder(model)
.defaultAdvisors(new MessageChatMemoryAdvisor(memory))
.defaultTools(subAgentTools)
.defaultSystem(SUPERVISOR_SYSTEM_PROMPT)
.build();
}Not used by: V2 specialists (stateless per-invocation — no memory needed).
Code Interpreter
Purpose: Provides a secure, network-isolated sandbox for executing AI-generated Python/pandas ETL scripts. Used by the Code Execution sub-agent in the sample sheet flow.
Integration pattern:
File staging: The sub-worker uploads source data (annotation CSV, target JSON Schema) to the AgentCore session via
AgentCoreCodeInterpreterClient.Execution: The Code Execution ChatClient has a
run_pythontool that sends Python scripts to the AgentCore sandbox and returns stdout/stderr.Self-correction: If execution fails, the error log is fed back to the ChatClient. The LLM rewrites the script and retries (bounded by max attempts).
Retrieval: On success, the sub-worker downloads the output file (generated sample sheet CSV) from the AgentCore session.
public class CodeInterpreterTool {
private final AgentCoreCodeInterpreterClient client;
private final String sessionId;
@Tool(description = "Execute a Python script in a secure sandbox. "
+ "The sandbox has pandas, numpy, and csv available. "
+ "Input files are in /mnt/user/. Write output to /mnt/user/output.csv")
public String runPython(String script) {
ExecutionResult result = client.executeCode(sessionId, script);
if (result.isError()) {
return "ERROR: " + truncate(result.getStderr(), MAX_RESPONSE_CHARS);
}
return truncate(result.getStdout(), MAX_RESPONSE_CHARS);
}
}Part 4: V2 Custom Agents — Specialist Proxy
This section describes how the specialist proxy bridges custom Bedrock agents into the Spring AI specialist runtime. For full context on the V2 feature (specialist cards, registration, security model), see the Technical Design: Synapse Custom Agents Feature V2.
Architecture
Custom Bedrock Agent (Supervisor)
│
├─ return_control: specialist_proxy(specialist_name, query)
│
▼
SpecialistProxyHandler (ReturnControlHandler impl)
│
├─ Validate specialist_name is in registration's delegatedSpecialists
├─ Resolve specialist bean by SpecialistType
│
▼
SpecialistService.invoke(type, query, userInfo, accessLevel)
│
▼
Spring AI ChatClient (specialist-specific model + tools + system prompt)
│
├─ Executes tools (calls Synapse managers with user's identity)
├─ Produces concise summary
│
▼
Bounded response (≤4000 chars) → back to Bedrock agentSpecialistProxyHandler
A ReturnControlHandler implementation registered with action group specialist_tools and function specialist_proxy:
@Service
public class SpecialistProxyHandler implements ReturnControlHandler {
private final SpecialistService specialistService;
private final AgentDao agentDao;
@Override
public String getActionGroup() { return "specialist_tools"; }
@Override
public String getFunction() { return "specialist_proxy"; }
@Override
public boolean needsWriteAccess() { return false; }
@Override
public String handleEvent(ReturnControlEvent event) {
String specialistName = event.getParameter("specialist_name");
String query = event.getParameter("query");
// Validate specialist is in the registration's delegatedSpecialists
AgentRegistration registration = agentDao.getRegeistration(event.getRegistrationId())
.orElseThrow(() -> new NotFoundException("Registration not found"));
validateDelegation(registration, specialistName);
// Resolve and invoke specialist
SpecialistType type = SpecialistType.valueOf(specialistName);
UserInfo userInfo = event.getUserInfo();
AgentAccessLevel accessLevel = event.getAccessLevel();
String response = specialistService.invoke(type, query, userInfo, accessLevel);
// Enforce hard cap
return StringUtils.truncate(response, MAX_SPECIALIST_RESPONSE_CHARS);
}
}SpecialistService
Central interface for invoking specialists. The in-process implementation directly injects specialist ChatClient beans:
public interface SpecialistService {
String invoke(SpecialistType type, String query, UserInfo userInfo, AgentAccessLevel accessLevel);
List<SpecialistCard> listSpecialists();
}
@Service
public class SpecialistServiceImpl implements SpecialistService {
private final Map<SpecialistType, Specialist> specialists;
@Autowired
public SpecialistServiceImpl(List<Specialist> specialistBeans) {
this.specialists = specialistBeans.stream()
.collect(Collectors.toMap(Specialist::getType, Function.identity()));
}
@Override
public String invoke(SpecialistType type, String query, UserInfo userInfo,
AgentAccessLevel accessLevel) {
Specialist specialist = specialists.get(type);
ValidateArgument.required(specialist, "specialist for type: " + type);
if (specialist.getLifecycleState() == SpecialistLifecycleState.RETIRED) {
throw new IllegalStateException("Specialist " + type + " is retired.");
}
return specialist.invoke(query, userInfo, accessLevel);
}
@Override
public List<SpecialistCard> listSpecialists() {
return specialists.values().stream()
.map(Specialist::getCard)
.collect(Collectors.toList());
}
}Specialist Interface
Each specialist implements this interface. Calling card metadata is provided by the implementation itself (hardcoded in Java, deployed with the specialist):
public interface Specialist {
SpecialistType getType();
SpecialistLifecycleState getLifecycleState();
SpecialistCard getCard();
String invoke(String query, UserInfo userInfo, AgentAccessLevel accessLevel);
}Example: Search Specialist
@Service
public class SearchSpecialist implements Specialist {
private final ChatClient chatClient;
public SearchSpecialist(ChatModel searchModel, SearchSpecialistTools tools) {
this.chatClient = ChatClient.builder(searchModel)
.defaultTools(tools)
.defaultSystem(SEARCH_SPECIALIST_SYSTEM_PROMPT)
.build();
}
@Override
public SpecialistType getType() { return SpecialistType.SYNAPSE_CORE_SEARCH; }
@Override
public SpecialistLifecycleState getLifecycleState() { return SpecialistLifecycleState.ACTIVE; }
@Override
public SpecialistCard getCard() {
SpecialistCard card = new SpecialistCard();
card.setSpecialistType(SpecialistType.SYNAPSE_CORE_SEARCH);
card.setDisplayName("Synapse Search Specialist");
card.setDescription("Searches Synapse entities, datasets, and metadata via full-text and structured queries.");
card.setVersion("1.0.0");
card.setLifecycleState(SpecialistLifecycleState.ACTIVE);
card.setCapabilities(List.of("full-text search", "entity metadata retrieval", "annotation lookup"));
card.setInputDescription("A natural language search query. Can include filters like entity type, project scope, or annotation constraints.");
card.setOutputDescription("A concise summary of matching entities with synIds, names, and relevant metadata.");
return card;
}
@Override
public String invoke(String query, UserInfo userInfo, AgentAccessLevel accessLevel) {
// Tools receive userInfo via ThreadLocal or direct injection pattern
UserContext.set(userInfo, accessLevel);
try {
return chatClient.prompt()
.user(query)
.call()
.content();
} finally {
UserContext.clear();
}
}
}Specialist Tools (New @Tool Methods)
Specialist tools are purpose-built for agent use — returning concise, structured summaries rather than raw API responses. They call existing Synapse managers internally:
@Component
public class SearchSpecialistTools {
private final SearchManager searchManager;
private final EntityManager entityManager;
@Tool(description = "Search for Synapse entities by keyword. Returns matching entities with synId, name, type, and relevance score. Max 20 results.")
public String searchEntities(String keywords, String entityType, String projectScope) {
UserInfo user = UserContext.getUserInfo();
// Build search request, call searchManager, format concise results
SearchResults results = searchManager.search(user, buildQuery(keywords, entityType, projectScope));
return formatSearchSummary(results); // Respects MAX_RESPONSE_CHARS
}
@Tool(description = "Get metadata and annotations for a specific entity by synId.")
public String getEntityMetadata(String synId) {
UserInfo user = UserContext.getUserInfo();
Entity entity = entityManager.getEntity(user, synId);
Annotations annotations = entityManager.getAnnotations(user, synId);
return formatMetadataSummary(entity, annotations);
}
}Database Schema: delegatedSpecialists
The delegatedSpecialists list is stored as a JSON column on the existing AGENT_REGISTRATION table:
ALTER TABLE AGENT_REGISTRATION
ADD COLUMN DELEGATED_SPECIALISTS JSON DEFAULT NULL
COMMENT 'JSON array of specialist types this supervisor may invoke. NULL for non-SUPERVISOR agents.';The JSON column stores a simple array: ["SYNAPSE_CORE_SEARCH", "SYNAPSE_CORE_METADATA"]. Validated at registration time against the set of ACTIVE specialists.
AgentType Enum Extension
The existing AgentType enum gains a new value:
{
"enum": [
{ "name": "BASELINE", "description": "The baseline agent for general Synapse interactions." },
{ "name": "CUSTOM", "description": "A custom agent (V1 pattern)." },
{ "name": "SUPERVISOR", "description": "A custom agent that delegates to specialists via specialist_proxy (V2 pattern)." }
]
}Part 5: Sample Sheet Generation — Multi-Agent Supervisor
This section describes how the sample sheet generation feature uses the Spring AI foundation to orchestrate a multi-agent workflow. For the async job framework, dispatch mechanism, and task state machine, see the Technical Design: Agent-Assisted Sample Sheet Generation.
Orchestration Model: Autonomous Supervisor
The supervisor is a Spring AI ChatClient with sub-agents registered as @Tool methods. The LLM drives the execution order — deciding which sub-agent to call next, how to interpret results, and when to retry on failure. This enables self-correction loops without hardcoded branching.
ComputeTaskSubWorker (Java)
│
├─ Creates AgentCore memory session
├─ Stages input files to AgentCore session
│
▼
Supervisor ChatClient (autonomous, with AgentCore memory)
│
├─ @Tool: retrieveAnnotations(fileViewId) → Data Retrieval sub-agent
├─ @Tool: analyzeSchema(schemaId) → Schema Analysis sub-agent
├─ @Tool: generateEtlScript(context) → ETL Code Generation sub-agent
├─ @Tool: executeCode(script) → Code Interpreter (AgentCore sandbox)
├─ @Tool: validateOutput(csv, schema) → Validation sub-agent
│
▼
Supervisor returns validated CSV content + validation report
│
▼
ComputeTaskSubWorker (Java)
│
├─ Persists RecordSet to Synapse (WriteRecordSet)
├─ Creates review CurationTask (CreateReviewTask)
├─ Updates task state → IN_REVIEW
│
▼
DoneSub-Agent Tools
Each sub-agent is itself a ChatClient with narrow system instructions and specialized tools. They are invoked as tool methods on the supervisor:
@Component
public class SampleSheetSubAgentTools {
private final ChatClient dataRetrievalAgent;
private final ChatClient schemaAnalysisAgent;
private final ChatClient etlCodeGenAgent;
private final ChatClient codeExecutionAgent;
private final ChatClient validationAgent;
@Tool(description = "Retrieve annotation data from a Synapse FileView. "
+ "Queries the view, stages full data as a CSV to the session filesystem, "
+ "and returns a summary of the schema and row count.")
public String retrieveAnnotations(String fileViewId) {
String prompt = String.format(
"Query FileView %s. Stage all annotation rows as CSV to /mnt/user/annotations.csv. "
+ "Return: column names, row count, and sample of first 3 rows.", fileViewId);
return dataRetrievalAgent.prompt().user(prompt).call().content();
}
@Tool(description = "Analyze a JSON Schema and produce a mapping specification. "
+ "Returns required fields, types, constraints, and mapping guidance.")
public String analyzeSchema(String schemaId) {
String prompt = String.format(
"Retrieve JSON Schema '%s' and produce a mapping spec: "
+ "list all required fields with types, constraints, and enum values.", schemaId);
return schemaAnalysisAgent.prompt().user(prompt).call().content();
}
@Tool(description = "Generate a Python/pandas ETL script that transforms source annotations "
+ "into the target sample sheet format. Input: annotation schema summary + target mapping spec.")
public String generateEtlScript(String annotationSummary, String mappingSpec) {
String prompt = String.format(
"Generate a Python script using pandas that reads /mnt/user/annotations.csv "
+ "and transforms it to match this target schema:\n%s\n\nSource data structure:\n%s\n"
+ "Write output to /mnt/user/output.csv", mappingSpec, annotationSummary);
return etlCodeGenAgent.prompt().user(prompt).call().content();
}
@Tool(description = "Execute a Python script in the secure sandbox. "
+ "Returns stdout on success or error details on failure.")
public String executeCode(String pythonScript) {
return codeExecutionAgent.prompt()
.user("Execute this script:\n```python\n" + pythonScript + "\n```")
.call().content();
}
@Tool(description = "Validate the generated sample sheet against the target schema. "
+ "Returns a validation report with completeness and correctness assessment.")
public String validateOutput(String schemaId) {
String prompt = String.format(
"Read /mnt/user/output.csv and validate it against JSON Schema '%s'. "
+ "Report: total rows, missing required fields, type violations, and overall pass/fail.", schemaId);
return validationAgent.prompt().user(prompt).call().content();
}
}Sub-Agent Configurations
Sub-Agent | System Prompt Focus | Tools Available | Memory |
|---|---|---|---|
Data Retrieval | Query FileViews, stage data as files, return concise summaries | QueryFileView, StageFile | None (stateless) |
Schema Analysis | Interpret JSON Schemas, produce mapping specifications | GetJsonSchema | None (stateless) |
ETL Code Generation | Write correct pandas transformation scripts from specs | None (pure generation) | None (stateless) |
Code Execution | Run Python, interpret errors, iterate on failures | run_python (AgentCore Code Interpreter) | None (stateless) |
Validation | Validate CSV against JSON Schema, report gaps | run_python (for validation logic) | None (stateless) |
Supervisor System Prompt (Conceptual)
You are a data engineering supervisor responsible for generating a workflow-ready
sample sheet from Synapse metadata.
Your workflow:
1. Retrieve annotation data from the input FileView
2. Analyze the target JSON Schema to understand the required output format
3. Generate a Python/pandas ETL script to transform source → target
4. Execute the script in the sandbox
5. Validate the output against the target schema
6. If validation fails, revise the ETL script and retry (max 3 attempts)
7. When valid, return the final output summary
Rules:
- Always retrieve data before generating code
- Always validate output before reporting success
- If execution fails, ask the ETL agent to fix the script based on the error
- Keep all responses concise — return summaries, not raw data
- Report a clear error message if all retry attempts are exhaustedSelf-Correction Flow
Because the supervisor is autonomous, it naturally handles retry logic:
Supervisor: "I need to generate a sample sheet. Let me start by retrieving annotations."
→ calls retrieveAnnotations(fileViewId)
← "annotations.csv staged: 150 rows, columns: [sample_id, read1, read2, ...]"
Supervisor: "Now let me understand the target format."
→ calls analyzeSchema(schemaId)
← "Required: patient, sample, lane, fastq_1, fastq_2. Types: string, string, integer, path, path"
Supervisor: "I'll generate the transformation script."
→ calls generateEtlScript(annotationSummary, mappingSpec)
← "```python\nimport pandas as pd\n..."
Supervisor: "Let me execute it."
→ calls executeCode(script)
← "ERROR: KeyError: 'patient' — column not found in source data"
Supervisor: "The source doesn't have a 'patient' column directly. Let me regenerate with a mapping."
→ calls generateEtlScript(annotationSummary + " Note: 'patient' maps to 'individualID'", mappingSpec)
← (revised script)
Supervisor: "Executing revised script."
→ calls executeCode(revisedScript)
← "Success: output.csv written, 150 rows"
Supervisor: "Validating..."
→ calls validateOutput(schemaId)
← "PASS: 150 rows, all required fields present, no type violations"
Return: { status: "success", summary: "Generated 150-row sample sheet..." }Sub-Worker Integration
The ComputeTaskSubWorker for sample sheet generation wraps the autonomous supervisor in the async job lifecycle:
@Service
public class SampleSheetGenerationSubWorker
implements ComputeTaskSubWorker<SampleSheetGenerationExecutionDetails> {
private final ChatClient supervisorClient;
private final AgentCoreCodeInterpreterClient codeInterpreterClient;
private final AgentCoreMemoryClient memoryClient;
private final RecordSetManager recordSetManager;
private final CurationTaskManager curationTaskManager;
@Override
public Class<SampleSheetGenerationExecutionDetails> getExecutionDetailsType() {
return SampleSheetGenerationExecutionDetails.class;
}
@Override
public void execute(UserInfo user, String jobId, CurationTask task,
SampleSheetGenerationExecutionDetails details, AsyncJobProgressCallback callback) {
// 1. Create fresh AgentCore session (scoped to this execution)
String sessionId = codeInterpreterClient.createSession();
try {
// 2. Stage input files to AgentCore session
stageInputFiles(sessionId, details);
// 3. Invoke autonomous supervisor
callback.progressMade("Agent processing started", 10L);
String result = supervisorClient.prompt()
.user(buildSupervisorPrompt(details, sessionId))
.call()
.content();
// 4. Download output from AgentCore session
callback.progressMade("Retrieving results", 80L);
byte[] outputCsv = codeInterpreterClient.downloadFile(sessionId, "output.csv");
// 5. Persist to Synapse (Java handles all mutations)
String recordSetId = recordSetManager.createFromCsv(
user, details.getOutputFolderId(), outputCsv);
details.setOutputRecordSetId(recordSetId);
// 6. Create review task
Long reviewTaskId = curationTaskManager.createReviewTask(
user, task, recordSetId);
details.setReviewTaskId(reviewTaskId);
callback.progressMade("Complete", 100L);
} finally {
// Always clean up the AgentCore session
codeInterpreterClient.deleteSession(sessionId);
memoryClient.deleteSession(sessionId);
}
}
}Part 6: Blocking Behavior & Concurrency
Blocking is Acceptable
Both orchestration patterns run within async jobs that already have dedicated worker threads:
V2 Specialist Proxy: The specialist ChatClient makes Bedrock Converse API calls (10-30+ seconds). This blocks the same worker thread that's already handling the custom Bedrock agent's invoke_agent loop. No additional concurrency concern.
Sample Sheet Supervisor: The autonomous supervisor may make many sequential LLM calls (one per sub-agent invocation). This blocks the async job's worker thread for the full duration. The async job framework handles timeouts and progress callbacks.
When the Fargate migration enables virtual threads, these blocking calls will automatically yield the carrier thread, improving throughput without code changes.
Worker Configuration
The AGENT_CHAT worker (which handles V2 specialist proxy invocations) and COMPUTE_TASK_EXECUTION worker (which handles sample sheet generation) both use the existing ConcurrentWorkerStack pattern with appropriate semaphore settings:
Worker | Semaphore Max Lock Count | Max Threads Per Machine | Timeout (seconds) |
|---|---|---|---|
AGENT_CHAT | 10 | 5 | 300 |
COMPUTE_TASK_EXECUTION | 5 | 2 | 600 |
Lower concurrency for compute tasks because each execution may involve many sequential LLM calls (higher total cost per job).
Part 7: Identity Propagation
Both features need agents to execute operations with the initiating user's identity and access level.
Approach: Direct UserInfo Pass-Through
Since all agents run in-process (same JVM), we pass UserInfo directly rather than generating/validating JWTs. This avoids cryptographic overhead for what is currently a local method call.
Thread-Local Pattern for Tool Execution
Spring AI tools (annotated methods) don't receive arbitrary context parameters — they receive only the parameters the LLM provides. To make UserInfo available to tools, we use a thread-local context:
public class UserContext {
private static final ThreadLocal<UserInfo> USER = new ThreadLocal<>();
private static final ThreadLocal<AgentAccessLevel> ACCESS = new ThreadLocal<>();
public static void set(UserInfo user, AgentAccessLevel access) {
USER.set(user);
ACCESS.set(access);
}
public static UserInfo getUserInfo() {
return ValidateArgument.required(USER.get(), "UserInfo in context");
}
public static AgentAccessLevel getAccessLevel() {
return ValidateArgument.required(ACCESS.get(), "AccessLevel in context");
}
public static void clear() {
USER.remove();
ACCESS.remove();
}
}The caller (SpecialistProxyHandler or ComputeTaskSubWorker) sets the context before invoking the ChatClient and clears it in a finally block.
Future extraction: If specialists are ever extracted to a separate service, the SpecialistService interface remains the seam. The remote implementation would generate a JWT from UserInfo, send it over HTTP, and the remote specialist would validate it and reconstruct UserInfo on the other side.
Part 8: Testing Strategy
Unit Tests (Mock ChatModel)
Component | Test Class | Validates |
|---|---|---|
SpecialistProxyHandler | SpecialistProxyHandlerTest | Delegation validation, rejected specialists, response cap enforcement, error handling |
SpecialistServiceImpl | SpecialistServiceImplTest | Routing by type, lifecycle state enforcement, listing |
ComputeTaskDispatcher | ComputeTaskDispatcherImplTest | State transitions, error propagation, sub-worker routing |
SampleSheetGenerationSubWorker | SampleSheetGenerationSubWorkerTest | Session lifecycle, file staging, persistence after success, cleanup after failure |
SearchSpecialistTools | SearchSpecialistToolsTest | Correct manager calls, response formatting, cap enforcement |
UserContext | UserContextTest | Set/get/clear, required validation |
Live Integration Tests (Real LLM)
Each specialist and sub-agent requires integration tests with a real Bedrock model to validate that tool instructions produce correct behavior. These tests live in the integration-test module and are gated by a feature flag or profile.
Test Class | Validates |
|---|---|
ITSearchSpecialist | Specialist correctly interprets search queries, uses search tools, returns bounded summaries |
ITMetadataSpecialist | Specialist retrieves entity metadata, formats annotations correctly |
ITSampleSheetSupervisor | Full pipeline: annotation retrieval → schema analysis → ETL generation → execution → validation |
ITAgentController (extended) | Round-trip: register SUPERVISOR, list specialists, V1 compatibility preserved |
Why live tests are mandatory: Prompt engineering is empirical. The only reliable way to ensure that agent system prompts and tool descriptions produce correct behavior is to test with the actual LLM. Mock-based tests verify wiring but cannot catch instruction ambiguity or tool misuse.
Part 9: Implementation Roadmap
Phase 1: Foundation (Shared Infrastructure)
Add Spring AI dependencies to root pom + repository-managers
Create
SpringAiConfiguration(ChatModel beans, StackConfiguration integration)Create
UserContextthread-local patternCreate
Specialistinterface andSpecialistServiceCreate
SpecialistType,SpecialistLifecycleStateenums (JSON schemas)Create
SpecialistCard,DelegatedSpecialistschemasAdd
DELEGATED_SPECIALISTSJSON column to AGENT_REGISTRATION DDLExtend AgentType enum with SUPERVISOR
Phase 2: V2 Specialist Proxy
Implement
SpecialistProxyHandlerExtend
AgentManagerregistration flow for SUPERVISOR type + delegatedSpecialists validationAdd
GET /agent/specialistsendpointExtend
AgentRegistrationRequest/AgentRegistrationschemasImplement first specialist (SYNAPSE_CORE_SEARCH) with tools + integration test
Implement second specialist (SYNAPSE_CORE_METADATA) with tools + integration test
Phase 3: Sample Sheet Generation
Add AgentCore SDK dependencies (Code Interpreter client, Memory client)
Create
ExecutableTaskExecutionDetailsinterface schemaCreate
SampleSheetGenerationExecutionDetailsschemaImplement
ComputeTaskDispatcher+ComputeTaskSubWorkerinterfaceImplement
ComputeTaskExecutionWorker(async job runner)Configure
COMPUTE_TASK_EXECUTIONqueue + triggerImplement sub-agent tools (Data Retrieval, Schema Analysis, ETL, Code Execution, Validation)
Implement
SampleSheetGenerationSubWorkerIntegration tests with real LLM + AgentCore sandbox
Part 10: Open Items
Item | Status | Notes |
|---|---|---|
Spring AI version selection | TO DO | Need to validate Spring AI 1.x compatibility with Spring 6.1 (non-Boot) |
AgentCore SDK availability | TO DO | Confirm Java SDK availability and API stability for Code Interpreter + Memory |
Response cap tuning | TO DO | 4000 chars may need adjustment based on real specialist output quality |
Rate limiting | DEFERRED | Per-user, per-specialist throttling (future work) |
Observability / correlation IDs | DEFERRED | Cross-agent trace correlation for debugging |
Specialist versioning | DEFERRED | How specialist versions relate to stack versions |
JWT for service extraction | DEFERRED | Add when/if specialists are extracted to separate service |