feat: Invoke marker と LlmCall callback を導入し AgentTurn セマンティクスを明確化

- protocol: InvokeKind enum、Event::InvokeStart / LlmCallStart / LlmCallEnd 追加
- llm-worker: Worker.llm_call_count と on_llm_call_start/end callback、turn_count を AgentTurn 数として doc 更新
- session-store: LogEntry::Invoke { ts, trigger } 追加 (replay は marker のみで state 不変)
- pod: run/run_for_notification 開始時に Invoke marker commit、PendingRun::RunForNotification(InvokeKind) で kind を伝搬
- pod ipc: sink + server で Invoke エントリーを Event::InvokeStart として broadcast
- tui: 新 Event 3種を no-op で受理 (UI 設計はチケット範囲外)
This commit is contained in:
2026-05-15 07:04:26 +09:00
parent fd8526799b
commit 79b8336a14
9 changed files with 354 additions and 20 deletions
+27 -7
View File
@@ -95,7 +95,11 @@ async fn finish_controller_run<C, St>(
enum PendingRun {
Run(Vec<Segment>),
InterruptAndRun(Vec<Segment>),
RunForNotification,
/// Self-initiated turn kicked from the notify buffer. The carried
/// `InvokeKind` is the trigger that flipped the Pod from IDLE
/// (Notify or PodEvent) and is recorded by the Invoke marker
/// committed at the start of `pod.run_for_notification`.
RunForNotification(protocol::InvokeKind),
Resume,
}
@@ -109,7 +113,7 @@ impl PendingRun {
fn is_parent_originated(&self) -> bool {
match self {
PendingRun::Run(_) | PendingRun::InterruptAndRun(_) | PendingRun::Resume => true,
PendingRun::RunForNotification => false,
PendingRun::RunForNotification(_) => false,
}
}
}
@@ -299,6 +303,16 @@ fn wire_event_bridges_on_worker<C, St>(
});
});
let tx = event_tx.clone();
worker.on_llm_call_start(move |llm_call| {
let _ = tx.send(Event::LlmCallStart { llm_call });
});
let tx = event_tx.clone();
worker.on_llm_call_end(move |llm_call| {
let _ = tx.send(Event::LlmCallEnd { llm_call });
});
let tx = event_tx.clone();
worker.on_text_block(move |block| {
let tx_d = tx.clone();
@@ -551,9 +565,9 @@ async fn controller_loop<C, St>(
)
.await
}
PendingRun::RunForNotification => {
PendingRun::RunForNotification(kind) => {
drive_turn(
pod.run_for_notification(),
pod.run_for_notification(kind),
&mut method_rx,
&event_tx,
&cancel_tx,
@@ -643,7 +657,9 @@ async fn controller_loop<C, St>(
// sees the buffered notification(s) without a human
// Run.
if shared_state.get_status() == PodStatus::Idle {
pending = Some(PendingRun::RunForNotification);
pending = Some(PendingRun::RunForNotification(
protocol::InvokeKind::Notify,
));
}
}
@@ -711,7 +727,9 @@ async fn controller_loop<C, St>(
// notification is not stranded. Matches the
// `Method::Notify` idle path.
if shared_state.get_status() == PodStatus::Idle {
pending = Some(PendingRun::RunForNotification);
pending = Some(PendingRun::RunForNotification(
protocol::InvokeKind::PodEvent,
));
}
}
}
@@ -960,7 +978,9 @@ mod tests {
assert!(PendingRun::Run(Vec::new()).is_parent_originated());
assert!(PendingRun::InterruptAndRun(Vec::new()).is_parent_originated());
assert!(PendingRun::Resume.is_parent_originated());
assert!(!PendingRun::RunForNotification.is_parent_originated());
assert!(
!PendingRun::RunForNotification(protocol::InvokeKind::Notify).is_parent_originated()
);
}
struct DriveTurnEnv {
+3
View File
@@ -115,6 +115,9 @@ async fn handle_connection(stream: tokio::net::UnixStream, handle: PodHandle) {
.expect("SystemItem is Serialize");
Some(Event::SystemItem { item: value })
}
session_store::LogEntry::Invoke { trigger, .. } => {
Some(Event::InvokeStart { kind: trigger })
}
other => {
// `SessionLogSink::is_live_relevant` keeps
// non-live-relevant variants off the
+31 -2
View File
@@ -1144,10 +1144,17 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
self.prepare_for_run().await?;
// IDLE → active marker. Commits first so the next UserInput entry
// is contained inside this Invoke range. See `tickets/invoke-turn-llmcall-semantics.md`.
self.session_id = self.session_head.lock().session_id;
self.commit_entry(LogEntry::Invoke {
ts: session_log::now_millis(),
trigger: protocol::InvokeKind::UserSend,
})?;
// Persist the user input as typed segments before the worker
// pushes its flattened copy into history. save_delta deliberately
// skips the resulting `is_user_message()` item to avoid double-write.
self.session_id = self.session_head.lock().session_id;
self.commit_entry(LogEntry::UserInput {
ts: session_log::now_millis(),
segments: input.clone(),
@@ -1482,9 +1489,31 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
/// `Item::system_message` into the per-request context, then the
/// Worker's resume path issues the LLM request without a new
/// user turn.
pub async fn run_for_notification(&mut self) -> Result<PodRunResult, PodError> {
pub async fn run_for_notification(
&mut self,
kind: protocol::InvokeKind,
) -> Result<PodRunResult, PodError> {
debug_assert!(
matches!(
kind,
protocol::InvokeKind::Notify
| protocol::InvokeKind::PodEvent
| protocol::InvokeKind::SystemReminder
| protocol::InvokeKind::Wakeup
),
"run_for_notification expects a non-UserSend InvokeKind; got {kind:?}"
);
self.prepare_for_run().await?;
// IDLE → active marker for the buffered notification / pod-event
// drain. The trailing SystemItem entries (drained by the
// PodInterceptor) carry the actual payload.
self.session_id = self.session_head.lock().session_id;
self.commit_entry(LogEntry::Invoke {
ts: session_log::now_millis(),
trigger: kind,
})?;
let history_before = self.worker.as_ref().unwrap().history().len();
let worker = self.worker.take().expect("worker taken during run");
+4 -1
View File
@@ -91,6 +91,7 @@ impl SessionLogSink {
/// lane does not cover:
/// - `LogEntry::SessionStart` → `Event::SessionRotated` on the wire.
/// - `LogEntry::SystemItem` → `Event::SystemItem`.
/// - `LogEntry::Invoke` → `Event::InvokeStart`.
/// Everything else (AssistantItem, ToolResult, UserInput, TurnEnd,
/// RunCompleted, RunErrored, LlmUsage, Extension, ConfigChanged) is
/// reflected in the mirror so reconnect snapshots stay accurate,
@@ -118,7 +119,9 @@ impl SessionLogSink {
fn is_live_relevant(entry: &LogEntry) -> bool {
matches!(
entry,
LogEntry::SessionStart { .. } | LogEntry::SystemItem { .. }
LogEntry::SessionStart { .. }
| LogEntry::SystemItem { .. }
| LogEntry::Invoke { .. }
)
}