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:
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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 { .. }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user