Skip to content

Iniital Human-in-the-Loop (HITL) Agentic Workflows

Introduction

Associated with each task of ChemConnect (and its derivatives such as JThermodynamicsCloud) is a transaction. A transaction has the set of parameters needed to perform the task and the set of prerequisite transaction that have to be performed before the current task can be performed. 

One of the keys to putting the process of transactions into a framework of agents is allowing for ‘Human-in-the-loop’ agent processing. In terms of transactions, the human in the loop processing would be allowing the input parameters to the transaction to be entered within the agent processing. One way to accomplish this is to have the user, in the form of a natural language description, give the values of the parameters:

Perform task X with parameter1 being 100 and parameter2 being 300.

The interaction would be a chat environment where the user gives the parameter valus and is queued for missing parameters.

Though having a chat environment is more in line with LLM agent thinking, another more systematic approach is going to be taken. An interaction between an angular client and the agent in the (JAVA) background will be taken. The fundamentals having having an Angular client connected to a JAVA ADK background service has been set up in the previous post. In this post, this work is expanded upon and a multi-agent prototype framework is setup. In this prototype, a (Angular)  parameter form is used at the client end and the backend agents manage the queuing and using the parameters. The prototype sets up an example of the essential flow of parameter querying and usage. The prompt is given to the agents. The agents determine that parameters are needed and returns back to the client which sets up a form to enter the parameters. Once the parameters are entered, the agents are called once again and the parameters are used to perform an operation. The result is sent back to the client interface. 

There are two major motivations for using an Angular form to input data. First, using a form, it is clear which parameters are needed. Another motivation is that the form can have more convenient forms of data entry. For example, pull-down lists can be made if the parameter needs to be chosen from a list of possibilities. This list could come from the ChemConnect ontology information (accessed while setting up the form) or even based on available information from the database. Such forms have already been set up for example, in JThermodynamicsCloud and have been quite successful and convenient. That is a further motivation, some input forms have already been set up and these could be directly used in the Angular-Agent interaction. Though, in future work, a more automated version of setting up the Angular forms could be set up. This would involve intimate interaction with the ontology set up for ChemConnect.

The Prototype

The prototype that is set up in this post illustrates the essential methodology for entering and using parameters in a Angular-Background Agent interaction. This framework can then be expanded upon to do the complete execution of a ChemConnect transaction task. This prototype is not meant to be general. It is basically a proof-of-concept and to work out some details of the Angular Client and background ADK JAVA agent interaction.

From a user perspective, the interaction looks like the following.


Chat window

In the Angular interface, the chat window appears. In the prompt, (‘Ask a question’), the user enters a prompt. In the prototype, the actual prompt text is not considered. In later implementations, the prompt would signal which input parameters are needed.


When the send button is pressed, the next window appears giving the form for the two parameters. The user then enters the parameter values in the form. When parameter fields are complete, the ‘Submit’ button appears. When the user presses the button, the parameters are sent back to the agents and the answer appears. In this case the parameters have the values 13 and 12.


After the agent is called the form disappears and the answer appears in the chat window. In this case the operation (executed with a agent function) is addtition. The answer, 25 then appears.


Basic Agent Setup

A simple multi-agent set up with three agents. A main COORDINATOR agent, which is the agent that is called by the client, and whose task is route to the appropriate subagents.The setupparameters agent is the agent that is called that signals that parameters are needed and a response is given back to the Angular client to set up input form. The useparameters agent takes the parameter values from the client and executes an operation. The useparameters agent performs the answer with a function tool.   

The following illustration shows the basic flow through the agents.

A summary of the flow is a follows:

  1. The Angular client sets up a window where the chat prompt is given
  2. The user enters a prompt (in this prototype, the meaning is not used) and sends it
  3. The Angular client sends a JSON object to the agent endpoint. The status field is set to ‘new‘, signifying that this is the first prompt.
  4. The COORDINATOR agent sees the status as ‘new’ and calls the subagent setupparameters 
  5. The setupparameters  agent then responds with a new JSON object where the status is set to ‘GetParameters‘. 
  6. The client receives the answer from the backend.
  7. The client interprets the status and opens up the parameter window in the chat.
  8. The user enters the parameters. When the parameters are complete, the ‘Submit’ button appears.
  9. The user presses the ‘Submit’ button and the client sends a JSON object with status set to ‘use‘ and the data set to the values of parameter1 and parameter2.
  10. Once again, the COORDINATOR recieves the response and calls the subagent useparameters.
  11. The  useparameters agent then calls the function with the two parameters to perform the operation (in this prototype, addition).
  12. The  agent recieves the answer, 25, from the function and sets up a JSON object with status ‘answer’ and ‘result’ to be the answer, 25. The JSON object is the sent back the to client.
  13. The client receives the JSON object and determines from the status that the answer should be presented in the chat and the parameter input form should disappear. 

Background Agents

 

Agents

The COORDINATOR is the agent that is called from the service. Depending on the client input, the client transfers to one of two agents, setupparameters and useparameters. The setupparameters is the agent which asks for the human input by reponding with  ‘GetParameters’ for the status in the JSON response. The useparameters agent recieves the two parameters in the JSON input and sends these parameters to the function tool, AdditionTool. The tool adds these parameters together and the agent sents the sum as the ‘answer’ in the JSON response. 

COORDINATOR

The COORDINATOR is the root agent set up by the service. It’s purpose is to interpret the input JSON object and determine which subagent to call.  The ‘status’ field of the input JSON object to this root agent determines which sub-agent to call (Analyze the ‘status’ field and call one and only one of the following agents ):

  • new: If ‘new’, send the request to ‘Setup Parameters Agent’. (setupparameters)
  • use: If ‘use’, send the request to ‘Operation Agent’. (useparameters)

The response of the subagent, always a JSON object, is relayed as the response 

These agents will create a JSON response with the fields: ‘status’ and other fields based on the status. Rely this JSON response as your output.

COORDINATOR.java
public static LlmAgent COORDINATOR = LlmAgent.builder().name("Coordinator").model(Constants.MODEL)
			.description("I am the coordinator between setup and use of parameters")
			.instruction("Analyze the 'status' field and call one and only one of the following agents "
					+ "If 'new', send the request to 'Setup Parameters Agent'. "
					+ "If 'use', send the request to 'Operation Agent'."
					+ "These agents will create a JSON response with the fields: 'status' and other fields based on the status."
					+ "Rely this JSON response as your output.")
			.subAgents(SimpleHumanInTheLoop.setupparameters, SimpleHumanInTheLoop.useparameters) // Assign sub_agents
																									// here
			.build();

setupparameters

The input is a JSON object with the field ‘task’, the text is the prompt from the client:

Identify the task field of the input JSON object, for example ‘do operation’

Though the task is not interpreted in this agent, it is thought in implementations to come, this task will be interpreted and determine which input parameters are to be setup.

The purpose of this agent is to create a JSON object with the following fields:

  • task: Set ‘task’ field of the output to the input task field (in the example, ‘do operation’
  • status: Set ‘status’ to ‘GetParameters’.

The phrase:

You are a terminal response generator.

is to indicate that no further processing should occur. Without such a statement, the agent could trigger further processing. 

filename.java
private static LlmAgent setupparameters = LlmAgent.builder().name("Setup Parameters Agent").model(Constants.MODEL)
			.description(
					"The input is a task keyword. The output is the same task keyword and the status 'GetParameters' ")
			.instruction("You are a terminal response generator. "
					+ "1. Identify the task field of the input JSON object, for example 'do operation'"
					+ "2. Set 'task' field of the output to the input task field (in the example, 'do operation'"
					+ "3. Set 'status' to 'GetParameters'. ")
			.disallowTransferToParent(true).disallowTransferToPeers(true).build();

useparameters

The useparameters agent is called after the human input of two parameters. These two parameters are in the JSON object with names parameter1 and parameter2.

You are a calculation assistant. In the JSON input, the data field has the two parameters, parameter1 and parameter2

The operation on the parameters is done with a function tool:

tools(List.of(AdditionTool.ADDITION_TOOL))

Using a tool, though not necessary for this agent, was done to simulate a more real situation where the parameters would be sent to a tool by the agent to have further processing.

Use the ‘operation_tool’ to operate on them.  The response is a JSON object

The final response is the answer from the tool:

Set ‘result’ to the result of the operation.

After this operation, the agent will response with a status of answer, to signify to the client that the answer should be presented:

Set ‘status’ to ‘answer’.

Thus the final response is a JSON object with two fields:

  • resultthe result of the operation
  • statusSet ‘status’ to ‘answer

 

AdditionTool

The addition tool performs the operation.The operation itself is defined by a schema:

@Schema(name = “operation_tool”, description = “Operate on parameter1 and parameter2.”)

Another schema is used to define the input parameters isolated by the agent:

@Schema(name = “parameter1”, description = “The first parameter”) int parameter1,
@Schema(name = “parameter2”, description = “The second parameter”) int parameter2)

The function itself is a named operateParameters:

public static double operateParameters(

The FunctionTool definition, which is needed for the tools command in the calling agent, is defined with a create method using the class, AdditionTool,  and the name of the method within the class, operateParameters.

public static final FunctionTool ADDITION_TOOL
= FunctionTool.create(AdditionTool.class, “operateParameters” );

filename.java
public class AdditionTool {
	@Schema(name = "operation_tool", description = "Operate on parameter1 and parameter2.")
	public static double operateParameters(
			@Schema(name = "parameter1", description = "The first parameter") int parameter1,
			@Schema(name = "parameter2", description = "The second parameter") int parameter2) {
		return parameter1 + parameter2;
	}
	public static final FunctionTool ADDITION_TOOL 
	= FunctionTool.create(AdditionTool.class, "operateParameters" 
	);
}
🌐
filename.html
private static LlmAgent useparameters = LlmAgent.builder().name("Operation Agent").model(Constants.MODEL)
			.description("An agent use the parameters to perform addition.")
			.instruction(
					"You are a calculation assistant. In the JSON input, the data field has the two parameters, parameter1 and parameter2"
							+ "Use the 'operation_tool' to operate on them. " + "The response is a JSON object"
							+ "Set 'status' to 'answer' " + "Set 'result' to the result of the operation. ")
			.tools(List.of(AdditionTool.ADDITION_TOOL)).build();

Background Servlet

The real logic of the service is in the definitions of the agents. The background servlet for this prototype, SimpleHITLServlet, is similar to the defintion from the simple prototype for calling an agent from a client.

The critical differences are, of course, the endpoint definition:

WebServlet(“/api/agent/simplehitl”)

And the definiition of the Runner, where the name of the root agent, COORDINATOR,  is specified:

Runner runner = Runner.builder().appName(Constants.HITLAPPNAME)

         .agent(SimpleHumanInTheLoop.COORDINATOR)
         .sessionService(sessionService) 
          .build();

In this version of processEvents, all the events are looped through, which includes the tool call, and printed. Only the final result is sent to the client:

mapper.writeValue(resp.getWriter(), text);

 

SimpleHITLServlet.java
package esblurock.info.JavaAIAgent.hitl;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.adk.events.Event;
import com.google.adk.runner.Runner;
import com.google.adk.sessions.InMemorySessionService;
import com.google.genai.types.Content;
import com.google.genai.types.Part;
import esblurock.info.JavaAIAgent.Constants;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@WebServlet("/api/agent/simplehitl")
public class SimpleHITLServlet extends HttpServlet {
	private static final long serialVersionUID = 1L;
	ObjectMapper mapper = new ObjectMapper();
	protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
		System.out.println("Entered SimpleHITLServlet doPost");
		resp.setContentType("application/json");
		resp.setCharacterEncoding("UTF-8");
		// Set CORS for Angular (Change localhost:4200 to your actual frontend URL)
		resp.setHeader("Access-Control-Allow-Origin", "http://localhost:4200");
		resp.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
		resp.setHeader("Access-Control-Allow-Headers", "Content-Type");
		TypeReference<Map<String, Object>> mapType = new TypeReference<>() {
		};
		Map<String, Object> body = mapper.readValue(req.getReader(), mapType);
		String userId = (String) body.get("userId");
		String sessionId = (String) body.get("sessionId");
		String promptString = mapper.writeValueAsString(body);
		System.out.println("SimpleLLMServlet doPost: " + promptString);
		// set up a seession service with the current session and user ID
		ConcurrentMap<String, Object> initialState = new ConcurrentHashMap<String, Object>();
		InMemorySessionService sessionService = new InMemorySessionService();
		sessionService.createSession(Constants.HITLAPPNAME, // App Name
				userId, // User ID
				initialState, // Initial state (Map<String, Object>)
				sessionId // Wrap your String ID in an Optional
		).blockingGet(); // Wait for completion/ Use blockingGet() to ensure it's created before the next
							// line
		// Create the runner with the top-level agent and session service
		Runner runner = Runner.builder().appName(Constants.HITLAPPNAME).agent(SimpleHumanInTheLoop.COORDINATOR)
				.sessionService(sessionService) // <--- Tell the runner where to look
				.build();
		// set up content with the prompt
		Content newMessage = Content.builder().role("user") // <--- Some versions REQUIRE the role to be set to "user"
				.parts(List.of(Part.builder().text(promptString).build())).build();
		// run the agent asynchronously
		Iterable<Event> events = runner.runAsync(userId, sessionId, newMessage // <--- Ensure this object actually
																				// contains the text
		).blockingIterable();
		// We put the processing in a separate method for clarity
		processEvents(events, sessionId, resp);
	}
	private void processEvents(Iterable<Event> events, String sessionId, HttpServletResponse resp) {
		try {
			for (Event event : events) {
				System.out.println("Event Type: " + event.stringifyContent());
				// 1. Check if it's the final answer
				if (event.finalResponse()) {
					// stringifyContent() is the library's built-in way to turn the
					// complex Content object into a simple String for your UI.
					String text = event.stringifyContent();
					System.out.println("----------------------------------------------------------");
					System.out.println("Final Event: " + text);
					mapper.writeValue(resp.getWriter(), text);
					return;
				} else {
					String text = event.stringifyContent();
					System.out.println("----------------------------------------------------------");
					System.out.println("Intermediate Event: " + text);
					// mapper.writeValue(resp.getWriter(), new AgentJsonResponse("SUCCESS", text,
					// sessionId));
				}
			}
		} catch (Exception e) {
			e.printStackTrace();
		}
		System.out.println("DONE   ----------------------------------------------------------");
	}
}

Angular Client

On the client side, there are basically two important components that are unique to the task:

  • ChatHITL: This is the main chat window that includes the call to the backend and steering the flow based on the status (as input to the backend and output from the backend). 
  • HItl2Parameters: This is the interface for the parameters. It is a child to the parent ChatHITL. It appears as a subwindow (when needed) in the parent window.

All the flow logic is found in the ChatHITL component and the service to call with AgentHITL is fairly generic. The method onSend just sends the body of the message that was created by the calling component (in this case ChatHITL). AgentHITL as keeps track of the messages in the chat.

ChatHITL

The ChatHITL is based on the chat window of the simple angular-agent interface. The major differences are:

  • Agent Interface Logic: All the interface logic to set up the JSON object to the agent is done in the ChatHITL component. The onSend method takes a body that is created from the button logic of the interface (methods getPrompt and  getParameters) and sends to the HITL endpoint. The response is handled in the onSend by interpreting the status field of the JSON response.
  • HiTl2Parameters: This is a simple form to input two parameters. It appears in the chat window when called for. When the submit button is pressed, it sends emits and Event which is caught by the parent. This transfers the ‘logic’ (building the JSON for the agent call) to the parent.

There are three main routines in ChatHITL

on-Send(body)

This routine takes a JSON object (body) and sends it to the backend agents.  The observable that is sent back from the Agent service (AgentHITL) is retrieved through a subscribe:

this.agentService.sendMessage(body).subscribe

The type of JSON response is handled by the status field. 

const data = JSON.parse(res);
const status = data.status;

Two status responses are expected:

  • GetParameters: This sets the showparameterinput to true which allows the HITL2Parameters component to be visible.
  • answer: This is the result when the operation is performed. The answer is in the result field and is displayed in the chat window (this.agentService.addMessage) and the HITL2Parameters component is made not visible (this.showparameterinput = false).

getPrompt()

This is called when the prompt button is pressed:

<input matInput [(ngModel)]=”userInput” (keyup.enter)=”getPrompt()”>

This displays the prompt from the user:

this.agentService.addMessage({ role: ‘user’, text: this.userInput });

A JSON object is created with the prompt (text) and the status set to ‘new’:

const body = {
status: ‘new’,
task: prompt,
data: data,
sessionId: this.agentService.getSessionId(),
userId: this.agentService.getUserId()
};

This is sent to the agent service (onSend(body))

getParameters()

The getParameters($event) comes from a series of events. When the parameters are all filled in, the Submit button is activated. When the submit button is pressed, then an event is emitted that passes the two parameters. The connection to the parent component is:

<app-hitl2-parameters (parameters)=”getParameters($event)”></app-hitl2-parameters>

The parameters are saved in the component:

this.parameter1 = data.parameter1;
this.parameter2 = data.parameter2;

and the body to be sent to the agent service is set up:

const body = {
status: ‘use’,
task: prompt,
data: $event,
sessionId: this.agentService.getSessionId(),
userId: this.agentService.getUserId()
};

The body is set to the agent service (onSend(body))

📘
chat-hitl.ts
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { AgentHITL } from '../../services/agent-hitl';
import { HITL2Parameters } from './hitl2-parameters/hitl2-parameters';
@Component({
	selector: 'app-chat-hitl',
	standalone: true,
	imports: [
		CommonModule,
		FormsModule,
		MatCardModule,
		MatFormFieldModule,
		MatInputModule,
		MatButtonModule,
		MatIconModule,
		HITL2Parameters
	],
	templateUrl: './chat-hitl.html',
	styleUrl: './chat-hitl.scss',
})
export class ChatHITL {
	// The prompt field bound to the input box
	userInput = '';
	// This activates the parameter input component
	showparameterinput = false;
	// The two input parameters and result
	parameter1 = '';
	parameter2 = '';
	result = '';
	// this list manipulation is done through the AgentService
	constructor(public agentService: AgentHITL) {
		// Initialize user and session by the agent
		this.agentService.setUserAndSession();
	}
	/* Main function to send the message to the backend and handle the response
	  body: the body to send to the backend API (set up by calling functions)
	  
	  - GetParameters: show the parameter input component
	  - answer: display the result in the chat
	*/
	onSend(body: any) {
		// Send the body to the backend to get a response and add it to the chat
		this.agentService.sendMessage(body).subscribe({
			next: (res) => {
				try {
					const data = JSON.parse(res);
					const status = data.status;
					if (status === 'GetParameters') {
						// Show the parameter input component
						this.showparameterinput = true;
					} else if (status === 'answer') {
						// Isolate the result
						this.result = data.result;
						const text = 'Operation(' + this.parameter1 + ', ' + this.parameter2 + ') = ' + this.result;
						// display the result in the chat
						this.agentService.addMessage({ role: 'assistant', text: text });
						// Hide the parameter input component
						this.showparameterinput = false;
					}
				} catch (e) {
					console.error('Error parsing response', e);
				}
			},
			error: (err) => console.error('Communication failed', err)
		});;
	}
	/*  Called when the user submits a prompt 
	
	1. Adds the user's message to the chat via the service
	2. Prepares the body for the backend API call (add the prompt and set status to 'new')
	3. Calls onSend to send the message to the backend
	*/
	getPrompt() {
		// userInput comes from the input field bound with [(ngModel)]
		this.agentService.addMessage({ role: 'user', text: this.userInput });
		const prompt = this.userInput;
		if (!prompt.trim()) return;
		this.userInput = ''; // Reset the input field
		// Add the user's message to the UI immediately through the service
		const data = {};
		const body = {
			status: 'new',
			task: prompt,
			data: data,
			sessionId: this.agentService.getSessionId(),
			userId: this.agentService.getUserId()
		};
		this.onSend(body);
	}
	/* Called when the parameter input component emits the parameters
	1. Prepares the body for the backend API call (add the parameters and set status to 'use')
	*/
	getParameters($event: any) {
		const data = $event;
		this.parameter1 = data.parameter1;
		this.parameter2 = data.parameter2;
		const body = {
			status: 'use',
			task: prompt,
			data: $event,
			sessionId: this.agentService.getSessionId(),
			userId: this.agentService.getUserId()
		};
		this.onSend(body);
	}
}
🎨
chat-hitl.scss
.chat-container {
  max-width: 800px;
  margin: 20px auto;
  height: 80vh; /* Give the card a fixed height */
  display: flex;
  flex-direction: column;
}
.spacer { flex: 1 1 auto; }
.message-area {
   height: 400px;
   overflow-y: auto;
   display: flex;
   flex-direction: column;
   padding: 16px;
 }
 .input-area {
    padding: 0 16px 16px 16px;
    display: block; /* Ensures the form-field takes full width */
  }
  .full-width {
    width: 100%;
  }
  .user-bubble {
    align-self: flex-end;
    background-color: #e3f2fd;
    padding: 8px 12px;
    border-radius: 15px 15px 0 15px;
    margin-bottom: 8px;
    max-width: 80%;
  }
  /* User on the right, blueish */
  .assistant-bubble {
    align-self: flex-start;
    background-color: #f5f5f5;
    padding: 8px 12px;
    border-radius: 15px 15px 15px 0;
    margin-bottom: 8px;
    max-width: 80%;
  }
🌐
chat-hitl.html
<mat-card class="chat-container">
	<mat-card-header>
		<mat-card-title>Simple LLM Physics Agent</mat-card-title>
		<span class="spacer"></span>
		<button mat-icon-button (click)="agentService.clearChat()">
			<mat-icon>delete_sweep</mat-icon>
		</button>
	</mat-card-header>
	<mat-card-content #scrollContainer class="message-area">
		<div *ngFor="let msg of agentService.messages()">
			<div [ngClass]="msg.role + '-bubble'">
				{{ msg.text }}
			</div>
		</div>
		<ng-container *ngIf='showparameterinput'>
			<app-hitl2-parameters (parameters)="getParameters($event)"></app-hitl2-parameters>
		</ng-container>
	</mat-card-content>
	<mat-card-actions class="input-area">
		<mat-form-field appearance="outline" class="full-width">
			<mat-label>Ask a question...</mat-label>
			<input matInput [(ngModel)]="userInput" (keyup.enter)="getPrompt()">
			<button mat-icon-button matSuffix (click)="getPrompt()">
				<mat-icon>send</mat-icon>
			</button>
		</mat-form-field>
	</mat-card-actions>
</mat-card>

HITL2Parameters

The HITL2Parameters component sets up the input of the 2 parameters. This is done with a FormGroup:

this.objectform = this.formBuilder.group({
parameter1: [”, Validators.required],
parameter2: [”, Validators.required]
});

The submit button only enabled when both parameters have been entered. This is in the method:

invalid(): boolean {
// Return true if all the field have values
var ans = this.objectform.invalid;
return ans;
}

which is used in the html code:

<ng-container *ngIf=’!invalid()‘>
<button mat-raised-button color=”primary” (click)=”submit()” class=”submit”>Submit</button>
</ng-container>

When the button is pressed the submit() method is called.

Communication with the parent occurs through the parameter EventEmitter:

@Output() parameters = new EventEmitter<any>();

The submit() collects the parameters in a data JSON object and then emits an event so the parent can act upon the parameters:

this.parameters.emit(data);

 

🌐
hitl-parameters.html
<mat-card-header>
	<mat-card-title-group class="title-group">
		<mat-card-title>Input Parameters</mat-card-title>
	</mat-card-title-group>
</mat-card-header>
<mat-card-content>
		<form [formGroup]="objectform" class="infoform">
			<mat-grid-list cols="2" rowHeight="100px">
				<mat-grid-tile [colspan]="1">
					<mat-form-field class="parameter">
						<mat-label>Parameter1</mat-label>
						<input matInput formControlName="parameter1">
						<mat-hint>The first parameter</mat-hint>
					</mat-form-field>
				</mat-grid-tile>
				<mat-grid-tile [colspan]="1">
					<mat-form-field class="parameter">
						<mat-label>Parameter2</mat-label>
						<input matInput formControlName="parameter2">
						<mat-hint>The second parameter</mat-hint>
					</mat-form-field>
				</mat-grid-tile>
				<mat-grid-tile [colspan]="2">
					<ng-container *ngIf='!invalid()'>
					<button mat-raised-button color="primary" (click)="submit()" class="submit">Submit</button>
					</ng-container>
					
				</mat-grid-tile>
			</mat-grid-list>
			
		</form>
</mat-card-content>
📘
hitl-parameters.ts
import { Component, EventEmitter, Output } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { AgentHITL } from '../../../services/agent-hitl';
import { CommonModule } from '@angular/common';
import { MatCardModule } from '@angular/material/card';
import { MatGridListModule } from '@angular/material/grid-list';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
@Component({
  selector: 'app-hitl2-parameters',
  standalone: true,
  imports: [
	CommonModule,
	MatCardModule,
	MatGridListModule,
	ReactiveFormsModule,
	MatFormFieldModule,
	MatInputModule,
	MatButtonModule
  ],
  templateUrl: './hitl2-parameters.html',
  styleUrl: './hitl2-parameters.scss',
})
export class HITL2Parameters {
	
	objectform: FormGroup;
	// this event emitter sends the parameters to the parent component
	@Output() parameters = new EventEmitter<any>();
	
	constructor(
		private formBuilder: FormBuilder,
		public agentService: AgentHITL
	) {
		// The two input parameters form
		this.objectform = this.formBuilder.group({
			parameter1: ['', Validators.required],
			parameter2: ['', Validators.required]
		});
	}
	invalid(): boolean {
		// Return true if all the field have values
		var ans = this.objectform.invalid;
		return ans;
	}
	/* Submit the parameters when the user clicks the submit button
	*/
	submit() {
		if (this.objectform.valid) {
			const param1 = this.objectform.get('parameter1')?.value;
			const param2 = this.objectform.get('parameter2')?.value;
			// Here you can handle the parameters as needed
			const data = { parameter1: param1, parameter2: param2 };
			this.parameters.emit(data);
		  } else {
			console.log('Form is invalid');
		}
	}
	
}
🎨
hitl-parameters.scss
.parameter {
	width: 100%;
}
.submit {
	width: 100%;
}

AgentHITL

This service is basically the same as the previous simple prototype which connects an Angular client to a agent background.

It has primarily three tasks:

  1. Send the JSON data object(body)  to the HITL backend endpoint (/api/agent/simplehitl) using the method sendMessage(body).
  2. Manage the chat messages through clearChat and addMessage
  3. Manage the sessionID and userID. For this prototype, these are artificial.
🌐
agent-hilt.html
import { HttpClient } from '@angular/common/http';
import { effect, Injectable, signal } from '@angular/core';
import { ChatMessage } from '../models/chat-model';
import { Router } from '@angular/router';
import { Observable } from 'rxjs';
@Injectable({
  providedIn: 'root',
})
export class AgentHITL {
	// List of messages stored as a signal
	private messageList = signal<any[]>(this.loadMessages());
	messages = this.messageList.asReadonly();
	// storage of session ID
	
	userId: string = '';
	sessionId: string = '';
	
	constructor(private http: HttpClient, private router: Router) {
	}
	
	setUserAndSession() {
		this.userId = 'user123';
		this.sessionId = new Date().getTime().toString();
	}
	public getSessionId(): string {
		return this.sessionId;
		}
	public getUserId(): string {
		return this.userId;
		}
	// Retrieve messages from sessionStorage
	// Note that the session is updated by the effect defined in the constructor
	private loadMessages(): ChatMessage[] {
		const saved = sessionStorage.getItem('agent_messages');
		return saved ? JSON.parse(saved) : [];
	}
	// This is called to clear the chat history (from session and the list itself)
	clearChat() {
		sessionStorage.clear();
		this.messageList.set([]);
	}
	// This is the main function to send a message to the backend agent API
	// For this simple chat the endpoint is /api/agent/simple
	sendMessage(body: any): Observable<any>  {
		return this.http.post<any>('/api/agent/simplehitl', body, {
		  headers: { 'Content-Type': 'application/json' }
		})
	}
	// Add a message to the list (this is called by the chat  component)
	addMessage(msg: any) {
	  this.messageList.update(current => [...current, msg]);
	}
}

Some Theoretical Background

Some key academic and technical terms to put this work relative to the literature.:

  • Mixed-Initiative Interaction: This is the foundational term for systems where both the human and the agent can take the “initiative.” When an agent reaches a task it cannot complete without specific parameters, it “yields the initiative” to the user via a form and waits for the user to yield it back.

  • Human-as-a-Tool Pattern: In modern AI engineering (like LangGraph or AutoGen), the user is often modeled as a “tool.” When the agent calls this tool, it triggers an Interrupt. The system saves the agent’s state (memory/scratchpad), renders a UI, and “resumes” once the tool (the user) returns the JSON object.

  • Proactive Form Filling (Inverse): While much literature focuses on agents filling forms for users, the “User-Side Parameter Acquisition” pattern is becoming a standard in Agentic UX Design.

Academic or professional research terms dealing with this topic can be:on this:

  • A2UI (Agent-to-UI): A standard format for agents to generate updateable, native UI responses.

  • Human-in-the-loop (HITL) Orchestration: Systems designed to pause at “critical decision points” for human validation.

  • Asynchronous Tooling: Tools that do not return a value immediately but trigger a long-running process (like a user filling a form).

Leave a Reply

Your email address will not be published. Required fields are marked *

en_USEnglish