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) — главный цикл.
// 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:
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:
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):
- Base instructions —
core/src/review_prompt.mdдля review, иначе сборная из: - системного промпта (из templates) -developer_instructionsиз config -user_instructions(AGENTS.md и т.п.) - Memory — содержимое
~/.astracode/memories/memory_summary.md, если[memories].use_memories = true(см. 07-memories.md). - Skill metadata — короткие описания всех скиллов (для discovery).
- MCP server descriptions — какие серверы доступны.
- Apps/connectors — список настроенных connector'ов.
- Environment — git branch, cwd, OS, sandbox-mode.
- Collaboration mode hints — если включён нестандартный mode.
Tools, видимые модели
router.model_visible_specs() фильтрует tools по:
- Включённым feature flags (Feature::*).
- Доступным MCP-серверам.
- Активным скиллам (turn_skills).
- Текущим permissions.
- Deferred tools — спрятаны в начале, появляются по tool_search запросу.
5. Вызов LLM и streaming
// 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:
- Парсинг имени и аргументов JSON.
- Lookup в
ToolRouter(tools/router.rs:54) — какой handler соответствует имени. - Approval (
astracode_delegate.rs:35-40): - Дляlocal_shell/exec_command—ExecApprovalRequest. - Дляapply_patch—ApplyPatchApprovalRequest. - Для MCP — поdefault_tools_approval_modeсервера. - Approval отправляется как Server Request через JSON-RPC. Command approval используетaccept,acceptForSession,acceptWithExecpolicyAmendment,applyNetworkPolicyAmendment,declineилиcancel; file change поддерживает подмножество без amendment-вариантов. - Выполнение через соответствующий handler (см. enum
ToolHandlerKindв 18-tools-catalog.md): -Shell→ spawn'ит exec через ExecServer (sandbox). -ApplyPatch→ крейтapply-patch. -Mcp→ RPC к MCP-серверу. -SpawnAgentV2→astracode_delegate::run_astracode_thread_interactive(). -Goal→ мутацияgoal_runtimeсессии. - Эмиссия событий — Begin/Updated/End (например,
exec_command_begin→ многоexec_command_output_delta→exec_command_end). - Возврат результата в 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 в деталях
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 и его варианты
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) имеет ровно четыре варианта:
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'а
// Упрощённая схема 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)
Пользователь жмёт 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")]):
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.