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:
+124
-1
@@ -209,7 +209,8 @@ pub enum Event {
|
||||
/// submitting clients would see tool calls and assistant text
|
||||
/// appear without any preceding user message.
|
||||
///
|
||||
/// Fires exactly once per accepted `Method::Run`, before
|
||||
/// Fires exactly once per accepted `Method::Run`, after
|
||||
/// `InvokeStart { kind: UserSend }` and before the first
|
||||
/// `TurnStart`. Rejected runs (e.g. `AlreadyRunning`) do not emit.
|
||||
UserMessage {
|
||||
segments: Vec<Segment>,
|
||||
@@ -231,13 +232,57 @@ pub enum Event {
|
||||
SystemItem {
|
||||
item: serde_json::Value,
|
||||
},
|
||||
/// A new self-driving cycle has begun (IDLE → active transition).
|
||||
///
|
||||
/// Marker event for the start of an Invoke range; the range extends
|
||||
/// implicitly until the next `InvokeStart`. Fires for every accepted
|
||||
/// `Method::Run` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),
|
||||
/// `Method::PodEvent` re-injection (kind=`PodEvent`), and any other
|
||||
/// IDLE-breaking trigger. Mid-run interrupts (e.g. hook output,
|
||||
/// `<system-reminder>` injection that doesn't break IDLE) do not
|
||||
/// emit `InvokeStart` — they appear as `SystemItem` only.
|
||||
///
|
||||
/// Carries `kind` only; the payload (user text / notify message /
|
||||
/// pod event body) is delivered separately via the immediately
|
||||
/// following `UserMessage` / `SystemItem` event.
|
||||
InvokeStart {
|
||||
kind: InvokeKind,
|
||||
},
|
||||
/// One AgentTurn boundary opened. An AgentTurn is a maximal run of
|
||||
/// LLM generation calls whose input messages are identical (i.e.
|
||||
/// retries from network errors / 5xx / stream disconnects collapse
|
||||
/// into the same AgentTurn). When the input changes (a new tool
|
||||
/// result lands, a user interrupts, etc.), the next LLM call belongs
|
||||
/// to a new AgentTurn.
|
||||
///
|
||||
/// `turn` is the AgentTurn index from `Worker::turn_count`.
|
||||
///
|
||||
/// Currently retry is not yet implemented (`llm-worker-stream-continuation`)
|
||||
/// so AgentTurn and `LlmCall` fire 1:1, but the contract here is
|
||||
/// the AgentTurn boundary; consumers that want per-LLM-call signals
|
||||
/// must subscribe to `LlmCallStart` / `LlmCallEnd` instead.
|
||||
TurnStart {
|
||||
turn: usize,
|
||||
},
|
||||
/// AgentTurn closed.
|
||||
TurnEnd {
|
||||
turn: usize,
|
||||
result: TurnResult,
|
||||
},
|
||||
/// One LLM generation call started (1 request → 1 generation, retry
|
||||
/// included). Multiple `LlmCall*` pairs may fire inside a single
|
||||
/// `TurnStart` / `TurnEnd` pair when a request is retried; today
|
||||
/// they fire 1:1 because retry is not implemented.
|
||||
///
|
||||
/// `llm_call` is the worker-wide running counter from
|
||||
/// `Worker::llm_call_count`.
|
||||
LlmCallStart {
|
||||
llm_call: usize,
|
||||
},
|
||||
/// LLM generation call ended.
|
||||
LlmCallEnd {
|
||||
llm_call: usize,
|
||||
},
|
||||
TextDelta {
|
||||
text: String,
|
||||
},
|
||||
@@ -466,6 +511,31 @@ pub enum TurnResult {
|
||||
Paused,
|
||||
}
|
||||
|
||||
/// Kind of trigger that opened a new Invoke (IDLE → active) range.
|
||||
///
|
||||
/// One Invoke groups all entries from this trigger up to the next
|
||||
/// `Invoke` marker. The kind is the only payload — content (user text,
|
||||
/// notify message, pod event body) is delivered by the immediately
|
||||
/// following Turn entry, not by the marker itself.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InvokeKind {
|
||||
/// `Method::Run` — a user submission.
|
||||
UserSend,
|
||||
/// `Method::Notify` — free-text notification injected into history.
|
||||
Notify,
|
||||
/// `Method::PodEvent` — typed lifecycle report from a child Pod.
|
||||
PodEvent,
|
||||
/// `<system-reminder>` etc. that crosses an IDLE boundary (mid-run
|
||||
/// reminders that don't break IDLE are SystemItem-only and do not
|
||||
/// open a new Invoke).
|
||||
SystemReminder,
|
||||
/// Cron / RemoteTrigger style scheduled wake-up. Reserved; no
|
||||
/// producer is wired today but the variant is part of the initial
|
||||
/// set so future schedulers don't have to amend the wire enum.
|
||||
Wakeup,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RunResult {
|
||||
@@ -678,6 +748,59 @@ mod tests {
|
||||
assert_eq!(parsed["data"]["result"], "limit_reached");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_invoke_start_roundtrip() {
|
||||
for kind in [
|
||||
InvokeKind::UserSend,
|
||||
InvokeKind::Notify,
|
||||
InvokeKind::PodEvent,
|
||||
InvokeKind::SystemReminder,
|
||||
InvokeKind::Wakeup,
|
||||
] {
|
||||
let event = Event::InvokeStart { kind };
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["event"], "invoke_start");
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
Event::InvokeStart { kind: k } => assert_eq!(k, kind),
|
||||
other => panic!("expected InvokeStart, got {other:?}"),
|
||||
}
|
||||
}
|
||||
let parsed: serde_json::Value = serde_json::from_str(
|
||||
&serde_json::to_string(&Event::InvokeStart {
|
||||
kind: InvokeKind::UserSend,
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(parsed["data"]["kind"], "user_send");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_llm_call_start_end_roundtrip() {
|
||||
for event in [
|
||||
Event::LlmCallStart { llm_call: 0 },
|
||||
Event::LlmCallEnd { llm_call: 7 },
|
||||
] {
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
match (&event, &decoded) {
|
||||
(Event::LlmCallStart { llm_call: a }, Event::LlmCallStart { llm_call: b })
|
||||
| (Event::LlmCallEnd { llm_call: a }, Event::LlmCallEnd { llm_call: b }) => {
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
_ => panic!("variant mismatch: {event:?} vs {decoded:?}"),
|
||||
}
|
||||
}
|
||||
let parsed: serde_json::Value = serde_json::from_str(
|
||||
&serde_json::to_string(&Event::LlmCallStart { llm_call: 3 }).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(parsed["event"], "llm_call_start");
|
||||
assert_eq!(parsed["data"]["llm_call"], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_notify_json_roundtrip() {
|
||||
let json = r#"{"method":"notify","params":{"message":"turn done"}}"#;
|
||||
|
||||
Reference in New Issue
Block a user