Отделите AgentExecutor от protocol handler
В официальном JS SDK ответственность аккуратно разделена, и это разделение стоит сохранить. AgentExecutor реализует собственно работу агента и публикует события. DefaultRequestHandler берёт на себя протокольные операции и task store. Благодаря этому домен и executor тестируются отдельно от Express и JSON-RPC binding - а значит, их можно менять независимо.
Executor реализует контракт из двух частей: как отменить задачу и как её выполнить. Вот отмена.
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
}));
};И начало выполнения: создать Task, а если релиз в сообщении не указан - перейти в input_required, а не падать с ошибкой.
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("Укажите релиз в формате 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(`Проверяю ${reference.service}@${reference.version}.`, taskId, contextId)
},
metadata: undefined
}));Три роли здесь важно не смешивать. Executor читает RequestContext, запускает domain- или model-workflow, реагирует на cancel и публикует типизированные события. Request handler реализует семантику Send/Get/List/Cancel, обновляет task store и подключает executor. Transport adapter преобразует JSON-RPC, REST или gRPC в общие операции. Каждая роль заменяема, пока границы между ними чисты.
Из этого следует практическое правило про длительность. Если reasoning длится минуты, handler должен вернуть task или стримить обновления, а executor - работать с cancellation signal и durable job. В демо returnImmediately: false допустим только потому, что проверка мгновенная и локальная.
InMemoryTaskStore только для demo. В продакшене task state должен переживать restart, поддерживать tenant isolation, retention, pagination и согласованный порядок событий. Память процесса здесь - та же ловушка, что и в MCP: следующий запрос может прийти в другой instance.
Executor и handler разделены. Теперь соберём их в работающий сервер с конкретным binding.