Separate the AgentExecutor From the Protocol Handler
In the official JS SDK responsibility is neatly split, and that split is worth keeping. The AgentExecutor implements the agent's actual work and publishes events. The DefaultRequestHandler takes on the protocol operations and the task store. Thanks to this, the domain and the executor are tested separately from Express and the JSON-RPC binding - which means they can be changed independently.
The executor implements a two-part contract: how to cancel a task and how to execute it. Here is cancellation.
export class ReleaseReviewAgentExecutor implements AgentExecutor {
private readonly cancelledTasks = new Set<string>();
cancelTask = async (taskId: string, eventBus: ExecutionEventBus): Promise<void> => {
this.cancelledTasks.add(taskId);
eventBus.publish(AgentEvent.statusUpdate({
taskId,
contextId: "",
status: {
state: TaskState.TASK_STATE_CANCELED,
timestamp: new Date().toISOString(),
message: undefined
},
metadata: undefined
}));
};And the start of execution: create a Task, and if the release isn't named in the message - move to input_required rather than fail with an error.
execute = async (requestContext: RequestContext, eventBus: ExecutionEventBus): Promise<void> => {
const { taskId, contextId, userMessage } = requestContext;
const initialTask: Task = requestContext.task ?? {
id: taskId,
contextId,
status: {
state: TaskState.TASK_STATE_SUBMITTED,
timestamp: new Date().toISOString(),
message: undefined
},
artifacts: [],
history: [userMessage],
metadata: userMessage.metadata
};
eventBus.publish(AgentEvent.task(initialTask));
const reference = parseReleaseReference(readText(userMessage));
if (!reference) {
eventBus.publish(AgentEvent.statusUpdate({
taskId,
contextId,
status: {
state: TaskState.TASK_STATE_INPUT_REQUIRED,
timestamp: new Date().toISOString(),
message: agentMessage("State the release as service@1.2.3.", taskId, contextId)
},
metadata: undefined
}));
return;
}
eventBus.publish(AgentEvent.statusUpdate({
taskId,
contextId,
status: {
state: TaskState.TASK_STATE_WORKING,
timestamp: new Date().toISOString(),
message: agentMessage(`Reviewing ${reference.service}@${reference.version}.`, taskId, contextId)
},
metadata: undefined
}));Three roles here must not be mixed. The executor reads the RequestContext, runs the domain or model workflow, reacts to cancel, and publishes typed events. The request handler implements Send/Get/List/Cancel semantics, updates the task store, and wires in the executor. The transport adapter converts JSON-RPC, REST, or gRPC into common operations. Each role is replaceable as long as the boundaries between them are clean.
From this follows a practical rule about duration. If the reasoning runs for minutes, the handler must return a task or stream updates, and the executor must work with a cancellation signal and a durable job. In the demo returnImmediately: false is acceptable only because the check is instant and local.
InMemoryTaskStore is for the demo only. In production the task state must survive a restart, support tenant isolation, retention, pagination, and a consistent event order. Process memory here is the same trap as in MCP: the next request may land on a different instance.
The executor and handler are separated. Now let's assemble them into a working server with a concrete binding.