0.4.11
19 · Техническая документация

19. Agent loop и жизненный цикл turn'а

Этот документ — подробная разборка того, что происходит в ядре AstraCode за один ход диалога: от прихода SendMessage до записи TurnComplete в rollout. Для общей архитектуры см. 01-architecture.md, для протокола TUI↔server — 20-protocol.md.

1. Сущности

Сущность Файл Назначение
Session core/src/session/session.rs:10 Корневой объект сессии. Один на тред. Держит state, mailbox, активный turn, goal runtime, MCP, skills.
SessionConfiguration session.rs:34 Снимок конфигурации сессии (provider, model, sandbox, permissions, cwd, base instructions). Меняется через apply().
SessionTask tasks/mod.rs:185 Trait для одной фоновой задачи в сессии (Regular/Review/Compact/Index).
TurnContext session/turn_context.rs:44 Всё state, нужное одному turn'у: модель, reasoning effort, permissions, tools-config, environment, skills.
ActiveTurn session/session.rs Активный turn (один в каждый момент времени).
ToolRouter core/src/tools/router.rs:54 Маршрутизатор tool-вызовов на handler'ы.
ModelClientSession core/src/client.rs:378 Stream'инг-обёртка над ModelProvider для одного turn'а.
RolloutRecorder rollout/src/recorder.rs Запись событий turn'а в JSONL.

2. Точка входа

Клиент (TUI) шлёт turn/start (JSON-RPC, см. 20-protocol.md). Сервер маршрутизирует в AstraCodeMessageProcessor::process_turn_start(), который вызывает AstraCode::start_turn() на корневом state-объекте.

Внутри: 1. Создаётся новый RegularTask (или ReviewTask/CompactTask/IndexTask для других потоков). 2. Через Session::spawn_task() запускается фон с cancellation_token. 3. SessionTask::run() вызывает run_turn() (session/turn.rs:139) — главный цикл.

rust
// tasks/regular.rs:40-86
async fn run(self, session, ctx, input, cancellation_token) -> Option<String> {
    run_turn(session.session.clone(), ctx, input, prewarm, cancellation_token.child_token())
        .await
}

3. TurnContext

Создаётся при старте turn'а из текущего SessionConfiguration + per-turn overrides:

rust
pub struct TurnContext {
    pub model_info: ModelInfo,             // slug, context_window, input/output modalities
    pub reasoning_effort: ReasoningEffort, // none/low/medium/high
    pub reasoning_summary: ReasoningSummaryConfig,
    pub cwd: AbsolutePathBuf,
    pub environments: Vec<TurnEnvironmentSelection>,
    pub permission_profile: PermissionProfile,
    pub approval_policy: AskForApproval,
    pub tools_config: ToolsConfig,
    pub turn_metadata_state: Mutex<TurnMetadataState>, // sticky model routing
    pub turn_timing_state: Mutex<TurnTimingState>,
    pub turn_skills: TurnSkillsContext,    // загруженные skills для этого turn'а
    pub config: Arc<Config>,
    pub session_telemetry: TelemetryHandle,
    ...
}

TurnContext передаётся как Arc<TurnContext> во все sub-функции turn'а — он immutable для самого turn'а (изменения через apply() создают новый snapshot для следующего turn'а).

4. Построение prompt'а

build_prompt() (turn.rs:1039) собирает payload для LLM:

rust
Prompt {
    input: history.for_prompt(modalities),   // ResponseItem'ы из message_history
    tools: router.model_visible_specs(),     // tools, видимые модели в этом turn'е
    base_instructions: ctx.base_instructions, // system prompt
    personality: ctx.personality,
    output_schema: ctx.output_schema,
    parallel_tool_calls: ctx.tools_config.parallel_tool_calls,
}

Что входит в history

  • User messages, assistant messages, reasoning блоки, tool calls — все прошлые turn'ы.
  • Initial context (см. ниже).
  • Goal context, если активна цель.
  • MCP server resource snapshots, если модель их подтянула.

Initial context (первый turn сессии)

Перед первым user message в history попадает «начальная развёртка» (session/mod.rs:build_initial_context):

  1. Base instructionscore/src/review_prompt.md для review, иначе сборная из: - системного промпта (из templates) - developer_instructions из config - user_instructions (AGENTS.md и т.п.)
  2. Memory — содержимое ~/.astracode/memories/memory_summary.md, если [memories].use_memories = true (см. 07-memories.md).
  3. Skill metadata — короткие описания всех скиллов (для discovery).
  4. MCP server descriptions — какие серверы доступны.
  5. Apps/connectors — список настроенных connector'ов.
  6. Environment — git branch, cwd, OS, sandbox-mode.
  7. Collaboration mode hints — если включён нестандартный mode.

Tools, видимые модели

router.model_visible_specs() фильтрует tools по: - Включённым feature flags (Feature::*). - Доступным MCP-серверам. - Активным скиллам (turn_skills). - Текущим permissions. - Deferred tools — спрятаны в начале, появляются по tool_search запросу.

5. Вызов LLM и streaming

rust
// turn.rs:1908
let mut stream = client_session
    .stream(prompt, &ctx.model_info, &ctx.session_telemetry,
            ctx.reasoning_effort, ctx.reasoning_summary, ...)
    .await??;

ModelClientSession::stream() (см. core/src/client.rs:665) делает HTTP POST к провайдеру (OpenAI/Anthropic/local) и парсит SSE-stream в события ResponseEvent.

ResponseEvent варианты

Loop в try_run_sampling_request() (turn.rs:1936-2293) обрабатывает (ResponseEvent определён в api/src/common.rs:49):

ResponseEvent Что делает
Created Инициализация — создаётся response_id, эмитится task_started
OutputItemAdded Начало нового item'а (assistant message / tool call / reasoning)
OutputItemDone item завершён — финализируем (записываем в history, вызываем tool)
OutputTextDelta Кусок текста ассистента → agent_message_delta
ReasoningContentDelta Кусок reasoning → agent_reasoning_delta (а также ReasoningRawContentDeltaEvent)
ServerModel Сервер сообщил, какую модель реально использовал (для fallback'ов)
ModelVerifications Метаданные про fingerprints, safety и т.п.

Plan mode

Если tools_config.plan_mode — текст ассистента дополнительно прогоняется через PlanModeStreamState (turn.rs:1934), который выделяет блоки /plan ... /plan и эмитит PlanDelta события.

6. Tool dispatch

Когда приходит OutputItemDone с ResponseItem::FunctionCall или CustomToolCall:

  1. Парсинг имени и аргументов JSON.
  2. Lookup в ToolRouter (tools/router.rs:54) — какой handler соответствует имени.
  3. Approval (astracode_delegate.rs:35-40): - Для local_shell/exec_commandExecApprovalRequest. - Для apply_patchApplyPatchApprovalRequest. - Для MCP — по default_tools_approval_mode сервера. - Approval отправляется как Server Request через JSON-RPC. Command approval использует accept, acceptForSession, acceptWithExecpolicyAmendment, applyNetworkPolicyAmendment, decline или cancel; file change поддерживает подмножество без amendment-вариантов.
  4. Выполнение через соответствующий handler (см. enum ToolHandlerKind в 18-tools-catalog.md): - Shell → spawn'ит exec через ExecServer (sandbox). - ApplyPatch → крейт apply-patch. - Mcp → RPC к MCP-серверу. - SpawnAgentV2astracode_delegate::run_astracode_thread_interactive(). - Goal → мутация goal_runtime сессии.
  5. Эмиссия событий — Begin/Updated/End (например, exec_command_begin → много exec_command_output_deltaexec_command_end).
  6. Возврат результата в model — output добавляется в history как ResponseItem::FunctionCallOutput для следующей итерации turn'а.

Parallel tool calls

Если модель эмитит несколько FunctionCall в одном OutputItemDone пакете И tools_config.parallel_tool_calls = true И все вовлечённые tools имеют supports_parallel_tool_calls = true — они выполняются конкурентно через tokio::join_all.

Иначе — последовательно.

7. Approval flow в деталях

text
Tool call requires approval
    ↓
core posts ServerRequest::item/commandExecution/requestApproval
    ↓ (через app-server → JSON-RPC)
TUI получает request
    ↓
ChatWidget показывает approval dialog
    ↓
Пользователь выбирает один из вариантов approval dialog
    ↓
TUI отправляет типизированный approval decision
    ↓
core продолжает (выполняет tool / отказывает / эскалирует)

Session-scoped approval и exec/network policy amendments имеют разные wire variants и сохраняются по правилам соответствующего approval flow; их нельзя сводить к одному универсальному Allow always.

8. SessionTask trait и его варианты

rust
pub(crate) trait SessionTask: Send + Sync {
    fn kind(&self) -> TaskKind;
    fn span_name(&self) -> &'static str;
    fn run(self: Arc<Self>, session, ctx, input, cancellation_token) -> impl Future<Output = Option<String>>;
    fn abort(&self, session, ctx) -> impl Future<Output = ()>;
}

TaskKind (core/src/state/turn.rs:65) имеет ровно четыре варианта:

rust
pub(crate) enum TaskKind {
    Regular,
    Review,
    Compact,
    Index,
}

Реализации SessionTask:

TaskKind Файл Что делает
Regular tasks/regular.rs:40 Стандартный turn по user message
Review tasks/review.rs:49 /review — запускает sub-agent с REVIEW_PROMPT, специальный output schema
Compact tasks/compact.rs /compact — генерирует резюме и заменяет history
Index tasks/index.rs:44 Индексирование проекта (project index)

Отдельный вариант TaskKind::Undo не существует: UndoTask и UndoPatchTask из tasks/undo.rs возвращают TaskKind::Regular из kind() — откат turn'а выполняется как regular-задача.

Одна сессия — максимум один активный ActiveTurn в каждый момент. Старт нового turn'а, пока работает старый, переключает через abort_all_tasks(TurnAbortReason::Replaced) (tasks/mod.rs:300).

9. Sub-agent / multi-agent

Инструменты multi-agent v1 и v2 создают обычные дочерние threads через общий AgentControl (agent/control.rs:172). Один экземпляр control-plane разделяется всем деревом от одного root thread и хранит scoped registry агентов. При spawn дочерний thread получает актуальные runtime-настройки turn'а: provider/model, reasoning, cwd, permission profile, approval policy и sandbox.

Стабильный v1 (multi_agent = true) адресует агентов по UUID и по умолчанию создаёт их без transcript родителя; история передаётся только при fork_context: true. Экспериментальный v2 (multi_agent_v2 = false по умолчанию) адресует дерево по task path и, наоборот, использует полный очищенный fork при отсутствующем fork_turns. Полный контракт обоих API — в 18-tools-catalog.

Отдельный механизм astracode_delegate обслуживает внутренние одноразовые или интерактивные делегаты (ReviewTask, IndexTask, guardian review), а не handlers spawn_agent. run_astracode_thread_interactive() (astracode_delegate.rs:65) перенаправляет events и ops между делегатом и вызывающей сессией; run_astracode_thread_one_shot() — обёртка для одноразового результата.

10. Окончание turn'а

rust
// Упрощённая схема core/src/session/turn.rs
if !needs_follow_up {
    let stop = hooks.run_stop(...).await;
    if stop.should_block {
        // reason становится continuation prompt
        continue;
    }
    if !stop.should_stop {
        // Отдельный legacy after-agent registry, включая top-level notify.
        hooks.dispatch(HookEvent::AfterAgent { ... }).await;
    }
    break;
}

То есть lifecycle-событие Stop выполняется раньше отдельного legacy after-agent registry. Stop с decision:block откладывает завершение и запускает ещё один model cycle. Это не hook закрытия всей сессии.

При TurnComplete: - В rollout пишется событие с финальным last_agent_message. - В state.sqlite обновляется threads.updated_at и tokens_used. - ActiveTurn зачищается. - UI получает TurnCompletedNotification.

При TurnAborted (отмена, ошибка): - В history вставляется маркер InterruptedTurnHistoryMarker::ContextualUser или ::Developer (tasks/mod.rs:68-111) — чтобы следующая модель видела «turn был прерван». - Аналогично пишется в rollout как turn_aborted event. - Tool-вызовы, начатые в этом turn'е, остаются с маркером tool was aborted в output.

11. Прерывания (Esc, Ctrl-C)

text
Пользователь жмёт Esc в TUI
    ↓
TUI отправляет turn/interrupt (или ServerRequest cancel)
    ↓
Session::abort_all_tasks(TurnAbortReason::UserInterrupt)
    ↓
cancellation_token.cancel()
    ↓
В turn loop'е: проверка is_cancelled() в нескольких точках
    ↓
Если LLM stream в полёте → drop'ается; reqwest закрывает соединение
Если tool в работе → отдельный cancellation_token для tool, корректный shutdown
Если процесс exec → SIGTERM (через graceful timeout 100ms), потом SIGKILL
    ↓
Эмитится turn_aborted в rollout, history-маркер вставляется

Параметр GRACEFULL_INTERRUPTION_TIMEOUT_MS = 100 (tasks/mod.rs:65) — сколько ждать корректного завершения tool'а перед force kill.

12. История сообщений и компакция

message_history (внутри Session, защищён mutex'ом) — это Vec<HistoryEntry>, где каждая запись — ResponseItem (user/assistant/reasoning/tool_call/tool_output).

При приближении к model_context_window сессия: 1. Auto-warning в UI (когда tokens_used > model_auto_compact_token_limit). 2. По команде /compact или auto — запускается CompactTask: - Берёт всю историю кроме последних N turn'ов. - Просит модель сгенерировать сжатое резюме. - Заменяет старые сообщения на один ResponseItem::Message с резюме. - В rollout пишется event_msg типа context_compacted.

Подробнее — см. core/src/compact.rs и 04-slash-commands.md.

13. Hooks в turn loop'е

Единого hook_engine.fire(...) в коде нет. Core собирает event-specific request и вызывает методы Hooks::run_* через core/src/hook_runtime.rs, tool registry и turn loop. Фактические точки lifecycle:

Hook event Когда
SessionStart В начале первого следующего turn после startup, resume или clear
UserPromptSubmit После получения нового user message, но до записи в history и запроса к модели
PreToolUse До выполнения tool, если его handler сформировал pre-tool hook-payload
PermissionRequest До показа approval-диалога (можно auto-approve/deny)
PostToolUse Только после успешного tool result и при наличии post-tool hook-payload
Stop Когда модель естественно завершила текущий turn; может запросить продолжение

Для PreToolUse блокирующим является только exit code 2 с непустым stderr или поддерживаемое JSON-решение deny/block. Другие ненулевые exit codes дают failed hook и не блокируют tool. У UserPromptSubmit, PermissionRequest, PostToolUse, SessionStart и Stop exit code 2 имеет разную семантику; полная таблица приведена в 08-hooks.md.

14. Memory интеграция

Phase 1 (извлечение) запускается асинхронно при SessionStart для прошлых rollout'ов (см. 07-memories.md).

Phase 2 (консолидация) — внутренний sub-agent без сетевого доступа, который агрегирует raw_memories.md в memory_summary.md.

memory_summary.md инжектится в initial context при use_memories = true.

15. Связь с rollout

На протяжении turn'а RolloutRecorder пишет (имена событий — сериализованные имена EventMsg из protocol/src/protocol.rs:1334, #[serde(tag="type", rename_all="snake_case")]):

text
event_msg: task_started
event_msg: user_message
event_msg: agent_reasoning / agent_reasoning_delta (...)
event_msg: agent_message_delta (много)
event_msg: agent_message (финальное)
event_msg: item_started / exec_command_begin / patch_apply_begin
event_msg: item_completed / exec_command_end / patch_apply_end (с output)
... (повтор tool циклов)
event_msg: task_complete или turn_aborted

Запись асинхронна, буферизуется через MPSC канал в фоновый writer. Подробно — 21-rollout-format.md.

16. Связь с state DB

После каждого turn'а: - threads.updated_at, tokens_used обновляются.

Подробно — 23-state-db.md.