Глава 25

Отделите AgentExecutor от protocol handler

В официальном JS SDK ответственность аккуратно разделена, и это разделение стоит сохранить. AgentExecutor реализует собственно работу агента и публикует события. DefaultRequestHandler берёт на себя протокольные операции и task store. Благодаря этому домен и executor тестируются отдельно от Express и JSON-RPC binding - а значит, их можно менять независимо.

Executor реализует контракт из двух частей: как отменить задачу и как её выполнить. Вот отмена.

TypeScript
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, а не падать с ошибкой.

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

Ссылки