Technical Design Document: Spring AI Multi-Agent Architecture

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:

  1. V2 Custom Agents — Specialist Proxy: Production-grade specialist agents that custom Bedrock agents can delegate to via the specialist_proxy return-control mechanism (Technical Design: Synapse Custom Agents Feature V2).

  2. 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

Aspect

V2 Specialist Proxy

Sample Sheet Generation

Trigger

ReturnControlHandler intercepts specialist_proxy call from custom Bedrock agent

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

  1. Deployment-agnostic: Runs inside the existing worker application. Works on both Elastic Beanstalk (current) and ECS Fargate (future).

  2. Bounded communication: All agent-to-agent responses are capped. Large data is staged to AgentCore files; agents exchange references, not payloads.

  3. LLM produces, Java persists: Agents generate and validate data; Java code handles all mutations to Synapse state (writes, task transitions, access control).

  4. 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

Config Property

Example Value

Used By

org.sagebionetworks.agent.model.default

us.anthropic.claude-sonnet-4-6-v1

Default for all Spring AI agents

org.sagebionetworks.agent.model.specialist.search

us.anthropic.claude-sonnet-4-6-v1

SYNAPSE_CORE_SEARCH specialist

org.sagebionetworks.agent.model.specialist.metadata

us.anthropic.claude-haiku-4-5-20251001

SYNAPSE_CORE_METADATA specialist

org.sagebionetworks.agent.model.samplesheet.supervisor

us.anthropic.claude-sonnet-4-6-v1

Sample sheet supervisor agent

org.sagebionetworks.agent.model.samplesheet.etl

us.anthropic.claude-sonnet-4-6-v1

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

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:

  1. The tool writes full data as CSV/JSON to the AgentCore session filesystem via AgentCoreCodeInterpreterClient.

  2. The tool returns a compact reference (filename, row count, schema summary) within the cap.

  3. 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:

  1. File staging: The sub-worker uploads source data (annotation CSV, target JSON Schema) to the AgentCore session via AgentCoreCodeInterpreterClient.

  2. Execution: The Code Execution ChatClient has a run_python tool that sends Python scripts to the AgentCore sandbox and returns stdout/stderr.

  3. 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).

  4. 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 agent

SpecialistProxyHandler

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 Done

Sub-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

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 exhausted

Self-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)

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

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

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 UserContext thread-local pattern

  • Create Specialist interface and SpecialistService

  • Create SpecialistType, SpecialistLifecycleState enums (JSON schemas)

  • Create SpecialistCard, DelegatedSpecialist schemas

  • Add DELEGATED_SPECIALISTS JSON column to AGENT_REGISTRATION DDL

  • Extend AgentType enum with SUPERVISOR

Phase 2: V2 Specialist Proxy

  • Implement SpecialistProxyHandler

  • Extend AgentManager registration flow for SUPERVISOR type + delegatedSpecialists validation

  • Add GET /agent/specialists endpoint

  • Extend AgentRegistrationRequest / AgentRegistration schemas

  • Implement 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 ExecutableTaskExecutionDetails interface schema

  • Create SampleSheetGenerationExecutionDetails schema

  • Implement ComputeTaskDispatcher + ComputeTaskSubWorker interface

  • Implement ComputeTaskExecutionWorker (async job runner)

  • Configure COMPUTE_TASK_EXECUTION queue + trigger

  • Implement sub-agent tools (Data Retrieval, Schema Analysis, ETL, Code Execution, Validation)

  • Implement SampleSheetGenerationSubWorker

  • Integration tests with real LLM + AgentCore sandbox

Part 10: Open Items

Item

Status

Notes

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