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 для других потоков).
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 — в developer instructions подмешивается содержимое
~/.astracode/memories/memory_summary.md(memories/read/src/prompts.rs:32-33,42,49), если[memories].use_memories = true. Сам файлMEMORY.mdв контекст не подставляется — это поисковый индекс (searchable registry), по которому агент ищет нужные записи (memories/read/templates/memories/read_path.md). Важно: сама функция памяти выключена по умолчанию (feature-флагFeature::MemoryTool,default_enabled: false) — настройки[memories]не дают эффекта, пока флаг не включён. И даже при включённой функции агент только читает память, запись делает отдельный фоновый пайплайн (см. 07-memories.md). - Skill metadata — короткие описания всех скиллов (для обнаружения).
- 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() (см. model-provider/) делает HTTP POST к провайдеру (OpenAI/Anthropic/local) и парсит SSE-stream в события ResponseEvent.
ResponseEvent варианты
Loop в try_run_sampling_request() (turn.rs:1936-2293) обрабатывает:
| ResponseEvent | Что делает |
|---|---|
Created |
Инициализация — создаётся response_id, эмитится TurnStarted |
OutputItemAdded |
Начало нового item'а (assistant message / tool call / reasoning) |
OutputItemDone |
item завершён — финализируем (записываем в history, вызываем tool) |
TextDelta |
Кусок текста ассистента → AgentMessageContentDeltaEvent |
ReasoningDelta |
Кусок reasoning → ReasoningContentDeltaEvent / 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; UI отвечаетapprove|deny|allowOnce|block. - Выполнение через соответствующий 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 (например,
ExecCommandBegin→ многоExecCommandOutputDelta→ExecCommandEnd). - Возврат результата в 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. Процесс подтверждения в деталях
Tool call requires approval
↓
core posts ServerRequest::item/commandExecution/requestApproval
↓ (через app-server → JSON-RPC)
TUI получает request
↓
ChatWidget показывает approval dialog
↓
Пользователь жмёт клавишу (по умолчанию, tui/src/keymap.rs:509-514):
y — подтвердить, a — подтвердить для сессии, p — подтвердить для префикса,
d — отклонить, Esc/n — отменить, c — прервать
↓
TUI отправляет response с decision: approve | deny | allowOnce | block
↓
core продолжает (выполняет tool / отказывает / эскалирует)
Если выбрано Allow always — путь/команда добавляются в ApprovedCommandPrefixSaved, и в этой сессии больше не спрашивается.
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 | Файл | Что делает |
|---|---|---|
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 |
Индексация (фоновая) |
Одна сессия — максимум один активный ActiveTurn в каждый момент. Старт нового turn'а, пока работает старый, переключает через abort_all_tasks(TurnAbortReason::Replaced) (tasks/mod.rs:300).
Варианты TurnAbortReason (protocol/src/protocol.rs): Interrupted, Replaced, ReviewEnded, BudgetLimited.
9. Sub-agent / multi-agent
run_astracode_thread_interactive() (astracode_delegate.rs:65-150) создаёт независимый AstraCode thread как sub-agent:
- Наследует от parent:
environment_manager,auth_manager,mcp_manager,skills_manager(astracode_delegate.rs:81-85). - Forward events: события sub-agent'а перенаправляются как
Eventв parent session (astracode_delegate.rs:132-142) — UI видит их с префиксом. - Forward ops: parent может отправлять команды sub-agent'у (например, прервать).
- Approval routing: approval-запросы sub-agent'а маршрутизируются на parent session (не на отдельный UI).
Используется в:
- SpawnAgentV2 (multi-agent работа).
- ReviewTask (review запускается как sub-agent с другим prompt'ом).
- AgentJobs (CSV-batch workers).
run_astracode_thread_one_shot() — упрощённая версия для одноразовых вызовов.
10. Окончание turn'а
// turn.rs:532
if !needs_follow_up {
// 1. after_agent hooks
fire_after_agent_hooks(...).await;
// 2. эмит TurnCompleteEvent
return Ok(TurnComplete);
}
При 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 как TurnAborted event.
- Tool-вызовы, начатые в этом turn'е, остаются с маркером tool was aborted в output.
11. Прерывания (Esc, Ctrl-C)
Пользователь жмёт Esc в TUI
↓
TUI отправляет turn/interrupt (или ServerRequest cancel)
↓
Session::abort_all_tasks(TurnAbortReason::Interrupted)
↓
cancellation_token.cancel()
↓
В turn loop'е: проверка is_cancelled() в нескольких точках
↓
Если LLM stream в полёте → drop'ается; reqwest закрывает соединение
Если tool в работе → отдельный cancellation_token для tool, корректный shutdown
Если процесс exec → SIGTERM (через graceful timeout 100ms), потом SIGKILL
↓
Эмитится TurnAborted в 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 типа Compacted.
Подробнее — см. core/src/compact.rs (если есть) и 04-slash-commands.md.
13. Hooks в turn loop'е
hook_engine.fire(Event::PreToolUse {...}) вызывается (см. 08-hooks.md) в нескольких точках:
| Hook event | Когда |
|---|---|
SessionStart |
При создании сессии |
UserPromptSubmit |
После получения нового user message, до build_prompt |
PreToolUse |
До каждого tool dispatch |
PermissionRequest |
До показа approval-диалога (можно auto-approve/deny) |
PostToolUse |
После завершения tool'а (успех или ошибка) |
Stop |
При завершении сессии |
Hook может вернуть exit-code, который повлияет на дальнейшее (например, ненулевой exit на PreToolUse блокирует tool).
14. Memory интеграция
Phase 1 (извлечение) запускается асинхронно при SessionStart для прошлых rollout'ов (см. 07-memories.md).
Phase 2 (консолидация) — внутренний sub-agent без сетевого доступа, который агрегирует raw_memories.md в MEMORY.md (поисковый индекс) и memory_summary.md (краткая сводка).
В initial context при use_memories = true подмешивается memory_summary.md; MEMORY.md же служит поисковым индексом, по которому агент ищет нужные записи (см. memories/read/templates/memories/read_path.md).
15. Связь с rollout
На протяжении turn'а RolloutRecorder пишет:
event_msg: TurnStarted event_msg: UserMessage event_msg: ReasoningStart / ReasoningDelta / ReasoningEnd event_msg: AssistantMessageDelta (много) event_msg: AssistantMessage (финальное) event_msg: ToolUseStart event_msg: ToolUseEnd (с output) ... (повтор tool циклов) event_msg: TurnComplete или TurnAborted
Запись асинхронна, буферизуется через MPSC канал в фоновый writer. Подробно — 21-rollout-format.md.
16. Связь с state DB
После каждого turn'а:
- threads.updated_at, tokens_used обновляются.
- tool_uses — записываются (имя, duration, success).
- approvals — audit-журнал approval-решений.
Подробно — 23-state-db.md.