Compare commits
7 Commits
61b4c0f5cd
...
e00e284d8c
| Author | SHA1 | Date | |
|---|---|---|---|
| e00e284d8c | |||
| e5f5670f68 | |||
| a2376b0742 | |||
| fbd7d8acb7 | |||
| 282a857248 | |||
| d710cac879 | |||
| bca9161a42 |
|
|
@ -13,3 +13,4 @@ Ticket を切るほどではないが、次に近所を触るときに合わせ
|
|||
|
||||
- `crates/tui/src/app.rs:478-485` — bad workflow slug を含む `Method::Run` 送信時、`Event::UserMessage` の早期 broadcast で `turn_index += 1` されターンヘッダだけ残る ("ghost turn header")。次に TUI のターンヘッダ / エラー表示周りを触るときに整理。→ [tickets/pod-input-validate-internalize.md] の review 由来。
|
||||
- `crates/pod/src/controller.rs:944` — `worker_error_code` で `PodError::WorkflowResolve(_) => InvalidRequest` が post-commit な resolve エラー (`KnowledgeNotFound` 等) にも適用される。意味論的には妥当方向だが、resolve 系のエラー粒度を分けたくなったタイミングで再評価。
|
||||
- `crates/pod/tests/controller_test.rs` — `double_run_returns_error` がたまに失敗する flakiness を観測。`pod-interrupt-prep-internalize` 以前から存在する別件。次に controller_test の Run 連投系のタイミングを触るときに併せて原因を切り分け。
|
||||
|
|
|
|||
4
TODO.md
4
TODO.md
|
|
@ -6,13 +6,13 @@
|
|||
- Permission: allow-all 既定 policy への整理 → [tickets/permission-default-policy.md](tickets/permission-default-policy.md)
|
||||
- Pod: 空応答ターン (Submit 後 AI 応答ゼロで Pause/Cancel) を自動巻き戻し → [tickets/pod-empty-turn-rollback.md](tickets/pod-empty-turn-rollback.md)
|
||||
- Pod: 任意ターンからの Fork(複数ターン巻き戻しを汎用化) → [tickets/pod-session-fork.md](tickets/pod-session-fork.md)
|
||||
- Pod: Paused→Run の interrupt 前処理を Pod 内部に閉じる → [tickets/pod-interrupt-prep-internalize.md](tickets/pod-interrupt-prep-internalize.md)
|
||||
- Pod: Inbound PodEvent ハンドリングの重複を統合 → [tickets/pod-inbound-pod-event-dedup.md](tickets/pod-inbound-pod-event-dedup.md)
|
||||
- Pod: セッションログをバックエンドにした Pod 単位の永続化 → [tickets/pod-persistent-state.md](tickets/pod-persistent-state.md)
|
||||
- 永続化層のセマンティック整理 → [tickets/persistence-semantics.md](tickets/persistence-semantics.md)
|
||||
- Exchange / Turn / Call セマンティクス整理 → [tickets/exchange-turn-call-semantics.md](tickets/exchange-turn-call-semantics.md)
|
||||
- Invoke / Turn / LlmCall セマンティクス整理 → [tickets/invoke-turn-llmcall-semantics.md](tickets/invoke-turn-llmcall-semantics.md)
|
||||
- llm-worker のエラー耐性
|
||||
- ストリーム途中失敗時の継続 → [tickets/llm-worker-stream-continuation.md](tickets/llm-worker-stream-continuation.md)
|
||||
- llm-worker: history append を callback 経由の単一経路に閉じる → [tickets/worker-history-append-contract.md](tickets/worker-history-append-contract.md)
|
||||
- ネイティブ GUI クライアント MVP → [tickets/native-gui-mvp.md](tickets/native-gui-mvp.md)
|
||||
- E2E テストハーネス(`tests/e2e/`、opt-in) → [tickets/e2e-harness.md](tickets/e2e-harness.md)
|
||||
- TUI 拡充
|
||||
|
|
|
|||
|
|
@ -94,7 +94,6 @@ async fn finish_controller_run<C, St>(
|
|||
/// `pod.run_for_notification()` drains the NotifyBuffer on its own.
|
||||
enum PendingRun {
|
||||
Run(Vec<Segment>),
|
||||
InterruptAndRun(Vec<Segment>),
|
||||
RunForNotification,
|
||||
Resume,
|
||||
}
|
||||
|
|
@ -108,7 +107,7 @@ impl PendingRun {
|
|||
/// notify buffer (Notify / inbound PodEvent) and stays silent.
|
||||
fn is_parent_originated(&self) -> bool {
|
||||
match self {
|
||||
PendingRun::Run(_) | PendingRun::InterruptAndRun(_) | PendingRun::Resume => true,
|
||||
PendingRun::Run(_) | PendingRun::Resume => true,
|
||||
PendingRun::RunForNotification => false,
|
||||
}
|
||||
}
|
||||
|
|
@ -536,21 +535,6 @@ async fn controller_loop<C, St>(
|
|||
)
|
||||
.await
|
||||
}
|
||||
PendingRun::InterruptAndRun(input) => {
|
||||
drive_turn(
|
||||
pod.interrupt_and_run(input),
|
||||
&mut method_rx,
|
||||
&event_tx,
|
||||
&cancel_tx,
|
||||
&shared_state,
|
||||
¬ify_buffer,
|
||||
self_parent_socket.as_ref(),
|
||||
&spawner_name,
|
||||
&spawned_registry,
|
||||
parent_originated,
|
||||
)
|
||||
.await
|
||||
}
|
||||
PendingRun::RunForNotification => {
|
||||
drive_turn(
|
||||
pod.run_for_notification(),
|
||||
|
|
@ -598,8 +582,7 @@ async fn controller_loop<C, St>(
|
|||
|
||||
match method {
|
||||
Method::Run { input } => {
|
||||
let status_before = shared_state.get_status();
|
||||
if status_before == PodStatus::Running {
|
||||
if shared_state.get_status() == PodStatus::Running {
|
||||
// Defensive: the inner select! inside drive_turn
|
||||
// already rejects `Run` while a turn is live, so
|
||||
// this branch is only reachable across a race window
|
||||
|
|
@ -616,17 +599,16 @@ async fn controller_loop<C, St>(
|
|||
// shared_state's `user_segments` is re-synced from
|
||||
// `pod` after the run completes, so we don't push
|
||||
// here. Workflow-invocation validation happens inside
|
||||
// `Pod::run` / `Pod::interrupt_and_run`; on failure the
|
||||
// turn errors out via `Event::Error { InvalidRequest }`
|
||||
// before any UserInput is committed.
|
||||
// `Pod::run`; on failure the turn errors out via
|
||||
// `Event::Error { InvalidRequest }` before any
|
||||
// UserInput is committed. Paused→Run cleanup (orphan
|
||||
// tool_result closure + interrupt system note) is
|
||||
// applied inside `Pod::run` itself when the worker's
|
||||
// `last_run_interrupted` flag is set.
|
||||
let _ = event_tx.send(Event::UserMessage {
|
||||
segments: input.clone(),
|
||||
});
|
||||
pending = Some(if status_before == PodStatus::Paused {
|
||||
PendingRun::InterruptAndRun(input)
|
||||
} else {
|
||||
PendingRun::Run(input)
|
||||
});
|
||||
pending = Some(PendingRun::Run(input));
|
||||
}
|
||||
|
||||
Method::Notify { message } => {
|
||||
|
|
@ -958,7 +940,6 @@ mod tests {
|
|||
#[test]
|
||||
fn pending_run_parent_origin_table() {
|
||||
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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
//! Transition from `Paused` to a fresh turn via user input.
|
||||
//! Pre-run cleanup that fires when a Pod transitions out of `Paused`
|
||||
//! into a fresh turn via new user input.
|
||||
//!
|
||||
//! The previously in-flight turn is treated as finished. Any orphan
|
||||
//! `Item::ToolCall` (tool_use emitted by the LLM but whose tool did not
|
||||
|
|
@ -6,59 +7,17 @@
|
|||
//! `Item::ToolResult` so the next request is wire-valid under providers
|
||||
//! that require every `tool_use` to be followed by a matching
|
||||
//! `tool_result` (Anthropic). A short system note is then inserted so
|
||||
//! the LLM understands the prior work was cut short, and finally the
|
||||
//! user's new input is appended via `worker.run(input)`.
|
||||
//! the LLM understands the prior work was cut short. Both side effects
|
||||
//! happen at the front of `Pod::run` when
|
||||
//! `worker.last_run_interrupted()` is set; see `Pod::apply_interrupt_prep`.
|
||||
|
||||
use llm_worker::Item;
|
||||
use llm_worker::llm_client::client::LlmClient;
|
||||
use protocol::Segment;
|
||||
use session_store::Store;
|
||||
|
||||
use crate::pod::{Pod, PodError, PodRunResult};
|
||||
#[cfg(test)]
|
||||
use crate::prompt::catalog::PromptCatalog;
|
||||
|
||||
impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
/// Close out the current (paused) turn and start a new one with `input`.
|
||||
///
|
||||
/// Invoked by the controller when a `Method::Run` arrives while the
|
||||
/// Pod is `Paused`. See module docs for the wire-compatibility
|
||||
/// rationale around synthetic tool results.
|
||||
pub async fn interrupt_and_run(
|
||||
&mut self,
|
||||
input: Vec<Segment>,
|
||||
) -> Result<PodRunResult, PodError> {
|
||||
// Validate before any side effects so a bad workflow slug does
|
||||
// not leave half-applied interrupt prep (orphan closure +
|
||||
// system note) in worker history. `Pod::run` validates again at
|
||||
// its own entry; the duplicate call is cheap (read-only) and
|
||||
// collapses naturally once `interrupt_and_run` folds into
|
||||
// `Pod::run` (see ticket pod-interrupt-prep-internalize).
|
||||
self.validate_workflow_invocations(&input)?;
|
||||
|
||||
let tool_result_summary = self
|
||||
.prompts()
|
||||
.interrupt_tool_result_summary()
|
||||
.map_err(PodError::from)?;
|
||||
let system_note = self
|
||||
.prompts()
|
||||
.interrupt_system_note()
|
||||
.map_err(PodError::from)?;
|
||||
|
||||
let closures: Vec<Item> =
|
||||
orphan_tool_result_closures(self.worker().history(), &tool_result_summary);
|
||||
if !closures.is_empty() {
|
||||
self.worker_mut().extend_history(closures);
|
||||
}
|
||||
self.worker_mut()
|
||||
.push_item(Item::system_message(system_note));
|
||||
self.run(input).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Build synthetic `Item::ToolResult` items for every unanswered
|
||||
/// `Item::ToolCall` in `history`, preserving order.
|
||||
fn orphan_tool_result_closures(history: &[Item], summary: &str) -> Vec<Item> {
|
||||
pub(crate) fn orphan_tool_result_closures(history: &[Item], summary: &str) -> Vec<Item> {
|
||||
let mut answered: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||
for item in history {
|
||||
if let Item::ToolResult { call_id, .. } = item {
|
||||
|
|
@ -11,7 +11,7 @@ pub mod spawn;
|
|||
pub mod workflow;
|
||||
|
||||
mod factory;
|
||||
mod interrupt_and_run;
|
||||
mod interrupt_prep;
|
||||
mod permission;
|
||||
mod pod;
|
||||
|
||||
|
|
|
|||
|
|
@ -1138,10 +1138,24 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
pub async fn run(&mut self, input: Vec<Segment>) -> Result<PodRunResult, PodError> {
|
||||
// Validate workflow invocations up front so an invalid slug
|
||||
// never commits a UserInput entry, never triggers pre-run
|
||||
// compaction, and never half-applies interrupt prep when run
|
||||
// from `interrupt_and_run`. Read-only against `workflow_registry`.
|
||||
// compaction, and never half-applies interrupt prep when the
|
||||
// previous turn was interrupted. Read-only against
|
||||
// `workflow_registry`.
|
||||
self.validate_workflow_invocations(&input)?;
|
||||
|
||||
// Paused→Run transition: if the previous turn was cut short,
|
||||
// any `Item::ToolCall` whose tool never produced a matching
|
||||
// `ToolResult` is closed with a synthetic one, and a short
|
||||
// system note explaining the interruption is appended — so the
|
||||
// next request is wire-valid (Anthropic) and the LLM knows
|
||||
// prior work was abandoned. Driven by the worker's own
|
||||
// `last_run_interrupted` flag; `Pod::resume` reuses the prior
|
||||
// context via a different entry point and never triggers this
|
||||
// path.
|
||||
if self.worker.as_ref().unwrap().last_run_interrupted() {
|
||||
self.apply_interrupt_prep()?;
|
||||
}
|
||||
|
||||
self.prepare_for_run().await?;
|
||||
|
||||
// Persist the user input as typed segments before the worker
|
||||
|
|
@ -1399,11 +1413,40 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
/// Stage the post-interruption cleanup at the front of worker
|
||||
/// history: close every unanswered `Item::ToolCall` with a synthetic
|
||||
/// `Item::ToolResult` (Anthropic wire-validity), then append a
|
||||
/// system note so the LLM understands the prior turn was cut
|
||||
/// short. Called from `Pod::run` when the worker's
|
||||
/// `last_run_interrupted` flag is set (i.e. the Pod just transitioned
|
||||
/// out of Paused via a new user input).
|
||||
fn apply_interrupt_prep(&mut self) -> Result<(), PodError> {
|
||||
let tool_result_summary = self
|
||||
.prompts()
|
||||
.interrupt_tool_result_summary()
|
||||
.map_err(PodError::from)?;
|
||||
let system_note = self
|
||||
.prompts()
|
||||
.interrupt_system_note()
|
||||
.map_err(PodError::from)?;
|
||||
|
||||
let closures = crate::interrupt_prep::orphan_tool_result_closures(
|
||||
self.worker().history(),
|
||||
&tool_result_summary,
|
||||
);
|
||||
if !closures.is_empty() {
|
||||
self.worker_mut().extend_history(closures);
|
||||
}
|
||||
self.worker_mut()
|
||||
.push_item(llm_worker::Item::system_message(system_note));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate explicit workflow invocations without reading dependency
|
||||
/// bodies. Called from `Pod::run` / `Pod::interrupt_and_run` entry so
|
||||
/// an invalid slug aborts the turn before any session-log commit or
|
||||
/// interrupt-prep side effects; `pub` so completion / preview paths
|
||||
/// can also dry-check inputs.
|
||||
/// bodies. Called from `Pod::run` entry so an invalid slug aborts
|
||||
/// the turn before any session-log commit or interrupt-prep side
|
||||
/// effects; `pub` so completion / preview paths can also dry-check
|
||||
/// inputs.
|
||||
pub fn validate_workflow_invocations(
|
||||
&self,
|
||||
segments: &[Segment],
|
||||
|
|
|
|||
|
|
@ -1367,9 +1367,10 @@ async fn paused_then_run_closes_orphan_tool_use_for_next_request() {
|
|||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
assert_eq!(handle.shared_state.get_status(), PodStatus::Paused);
|
||||
|
||||
// New user input while Paused → controller routes to
|
||||
// `Pod::interrupt_and_run`, which closes the orphan + injects a
|
||||
// system note before the fresh user message.
|
||||
// New user input while Paused → `Pod::run` observes
|
||||
// `last_run_interrupted` and runs its interrupt-prep step, which
|
||||
// closes the orphan + injects a system note before the fresh user
|
||||
// message.
|
||||
handle.send(Method::run_text("new request")).await.unwrap();
|
||||
assert!(
|
||||
drain_until(&mut rx, std::time::Duration::from_secs(2), |e| matches!(
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
# Exchange / Turn / Call セマンティクス整理
|
||||
|
||||
## 背景
|
||||
|
||||
現在のコード・protocol・UI では `turn` / `run` / `request` の意味が混ざり始めている。
|
||||
|
||||
特に `llm-worker` では、`run()` によって user input を history に append し、その後 LLM 呼び出し、tool 実行、再度 LLM 呼び出し、という自走 loop が完了するまでを扱う。一方で `turn_count` や `TurnStart` / `TurnEnd` は、実態としては loop 全体ではなく、loop 内の 1 回の LLM 生成境界に近い。
|
||||
|
||||
ただし `Turn` は本来「誰の番か」「誰が発話・行動する区間か」を表す語であり、user input から Agent の自走完了までの外側のまとまりを `Turn` と呼ぶと語感が歪む。
|
||||
|
||||
今後、永続化層の `Thread` / `Segment` 整理、compaction、fork、resume、usage accounting、TUI 表示を進めるにあたり、外側のまとまり、actor ごとの turn、LLM 呼び出し、I/O request を簡潔に区別する必要がある。
|
||||
|
||||
## 方針
|
||||
|
||||
このプロジェクトでは、中心語を以下のように整理する。
|
||||
|
||||
- `Exchange`: history に commit された 1 つの外部 input を起点に、Agent が LLM 呼び出しや tool 実行を含めて自走し、いったん停止状態に戻るまでのまとまり。
|
||||
- `Turn`: Exchange 内で、ある actor が発話・行動する区間。例: user turn / assistant turn / tool turn / system turn。
|
||||
- `Call` / `LlmCall`: LLM を 1 回呼び、1 回の assistant generation を得る単位。assistant turn を実現する低レイヤー単位だが、retry / continuation などを考えると turn と同義にはしない。
|
||||
- `Request`: protocol / provider / HTTP などの I/O 要求。会話上の単位としては使わない。
|
||||
- `Run`: 実装上の関数名・runtime 制御語としては残してよいが、ユーザー向け・永続化上の中心概念にはしない。
|
||||
|
||||
構造としては以下を基本にする。
|
||||
|
||||
```text
|
||||
Exchange
|
||||
├─ UserTurn
|
||||
├─ AssistantTurn
|
||||
│ └─ LlmCall
|
||||
├─ ToolTurn
|
||||
│ └─ ToolExecution
|
||||
├─ AssistantTurn
|
||||
│ └─ LlmCall
|
||||
└─ ...
|
||||
```
|
||||
|
||||
Exchange 列をまとめる外側の階層(会話の家系や永続化単位)は永続化層の責務であり、本チケットの範囲外。
|
||||
|
||||
## 要件
|
||||
|
||||
- `Exchange` / `Turn` / `Call` / `Request` / `Run` の定義を、永続化セマンティクスおよび worker/protocol/UI の用語に反映できる状態にする。
|
||||
- 裸の `turn` は actor ごとの発話・行動区間を指す語として扱い、外側の自走完了単位には使わない。
|
||||
- 現在 LLM 1 回生成を `turn` と呼んでいる箇所は、原則として `call` / `llm_call` へ寄せる方針を明示する。
|
||||
- ユーザー向け表示では、外側のまとまりを `Exchange` またはそれに相当する表示概念として扱う。`Turn` と表示する場合は actor-based turn との混同を避ける。
|
||||
- Usage / prompt cache / provider request metrics は `LlmCall` に紐づくものとして整理する。
|
||||
- `Request` は I/O request に限定し、Exchange / Turn と同義にしない。
|
||||
- 既存 protocol 互換が必要な場合、`TurnStart` / `TurnEnd` などは段階移行または alias を検討する。
|
||||
|
||||
## 検討事項
|
||||
|
||||
- 現在の `Event::TurnStart` / `Event::TurnEnd` が実態として LLM Call 境界であることをどう移行するか。
|
||||
- 例: `LlmCallStart` / `LlmCallEnd` を追加し、既存イベントは互換用 alias とする。
|
||||
- TUI の `TurnHeader` が何を表すべきか。
|
||||
- 外側のまとまりなら `ExchangeHeader` 相当に寄せる。
|
||||
- actor ごとの区間なら `UserTurn` / `AssistantTurn` / `ToolTurn` の表示に寄せる。
|
||||
- LLM Call ごとの表示を残す場合は `CallHeader` 相当に名称を変える。
|
||||
- `WorkerResult` / `RunResult` / `TurnResult` の責務境界。
|
||||
- Exchange の結果、actor Turn の結果、LlmCall の結果を混同しない。
|
||||
- compaction / resume により Exchange が複数の永続化単位 / runtime attempt にまたがる場合の扱い。
|
||||
- `Notify` / `PodEvent` / `SystemReminder` など user input ではない起点を Exchange と呼ぶことが適切か、または `ExchangeKind` のような分類で表すか。
|
||||
|
||||
## 完了条件
|
||||
|
||||
- `Exchange` / `Turn` / `Call` / `Request` / `Run` の定義が文書化されている。
|
||||
- worker / protocol / TUI / usage accounting で rename または互換 alias が必要な箇所が洗い出されている。
|
||||
- 実装変更を行う場合、外側 Exchange、actor Turn、内側 LlmCall の境界がコード上で判別できる。
|
||||
- 既存 API / event 名をすぐ壊さない段階移行方針が決まっている。
|
||||
|
||||
## 範囲外
|
||||
|
||||
- このチケット単体での大規模 rename 実装。
|
||||
- 永続化 DB backend の実装。
|
||||
- TUI の詳細 UX 設計。
|
||||
- protocol の互換破壊的変更。
|
||||
|
||||
## 関連
|
||||
|
||||
- `tickets/persistence-semantics.md` — 外側の永続化単位を定義する別チケット。本チケットは論理単位 (Exchange / Turn / LlmCall) の定義に閉じる。
|
||||
- `crates/llm-worker/`
|
||||
- `crates/protocol/`
|
||||
- `crates/tui/`
|
||||
155
tickets/invoke-turn-llmcall-semantics.md
Normal file
155
tickets/invoke-turn-llmcall-semantics.md
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
# Invoke / Turn / LlmCall セマンティクス整理
|
||||
|
||||
## 背景
|
||||
|
||||
現在のコード・protocol・UI では `turn` / `run` / `request` の意味が混ざり始めている。
|
||||
|
||||
特に `llm-worker` では、`run()` によって user input を history に append し、その後 LLM 呼び出し、tool 実行、再度 LLM 呼び出し、という自走 loop が完了するまでを扱う。一方で `turn_count` や `TurnStart` / `TurnEnd` は、実態としては loop 全体ではなく、loop 内の 1 回の LLM 生成境界に近い。
|
||||
|
||||
界隈の慣習でも、Anthropic / OpenAI 系 API では「1 input → 1 generation」をおおむね 1 turn と呼ぶ用例が多く、外側の自走完結単位に turn を当てると読み手の期待と逆走する。
|
||||
|
||||
加えて `turn` は本来「順番」「誰の番か」を表す語であり、user → assistant → tool → assistant ... の各 actor 区間に当てる用途と、外側の自走完結単位に当てる用途を兼ねさせると衝突する。
|
||||
|
||||
今後、永続化層 (`tickets/persistence-semantics.md`) の Session / Segment 整理、compaction、fork、resume、usage accounting、TUI 表示を進めるにあたり、これらを簡潔に区別する必要がある。
|
||||
|
||||
## 方針
|
||||
|
||||
中心語を以下に整理する。
|
||||
|
||||
- **`Invoke`**: IDLE → active 遷移を示す **marker entry**。「ここから新しい自走サイクルが始まった」という区切りを記録するのみ。range (まとまり) は Invoke から次の Invoke までとして暗黙に表現する。kind 以外の payload は持たない。
|
||||
- **`InvokeKind`**: Invoke の種別。`UserSend` / `Notify` / `PodEvent` / `SystemReminder` / `Wakeup` / ...
|
||||
- **`Turn`**: 1 actor の発話・行動区間。慣習に従い、actor 視点の「番」を素直に表す。
|
||||
- `UserTurn`: user の発話
|
||||
- `AgentTurn`: assistant の応答区間 (内部に 1 または複数の `LlmCall`)
|
||||
- `ToolTurn`: tool 実行区間
|
||||
- `SystemTurn`: Hook / Notify payload / `<system-reminder>` injection など、システム介入のキャッチオール
|
||||
- **`LlmCall`**: LLM を 1 回呼び、1 回の generation を得る単位。AgentTurn の内部に 1:N で属する。retry を含む。
|
||||
- **`Request`**: protocol / provider / HTTP などの I/O 要求。会話上の単位には使わない。
|
||||
- **`Run`**: 実装上の関数名・runtime 制御語としては残してよいが、ユーザー向け・永続化上の中心概念にはしない。
|
||||
|
||||
外側の自走完結単位を 1 概念として閉じ込めない (= `Exchange` / `Round` 等の新語は導入しない)。それは `Invoke` から次の `Invoke` までの range として暗黙に扱う。
|
||||
|
||||
### 構造
|
||||
|
||||
Segment 内 entry 列 (flat) として表すと以下:
|
||||
|
||||
```text
|
||||
Invoke(kind=UserSend) ← marker のみ (payload なし)
|
||||
UserTurn { text }
|
||||
AgentTurn
|
||||
LlmCall { usage, content (tool_use 含む) }
|
||||
LlmCall ← retry (同一 input、network error / 5xx 等)
|
||||
ToolTurn { tool_call_id, result }
|
||||
AgentTurn
|
||||
LlmCall
|
||||
Invoke(kind=Notify)
|
||||
SystemTurn { notify payload }
|
||||
AgentTurn
|
||||
LlmCall
|
||||
(自走中の割り込み: Invoke なし、SystemTurn のみ)
|
||||
SystemTurn { hook output }
|
||||
```
|
||||
|
||||
ポイント:
|
||||
|
||||
- `Invoke` は seq に挟まる「---」相当の marker。kind のみを持ち、内容 (user の入力テキスト等) は直後の Turn entry に書く。actor 分類 (Turn) と起動 trigger (Invoke) が直交した役割を保つ。
|
||||
- 自走中の割り込み (Hook 等) は IDLE → active 遷移ではないので Invoke を伴わず、SystemTurn のみが entry 列に現れる。
|
||||
- TUI の "Send #N" 表示は `kind=UserSend` の Invoke を数えた連番。
|
||||
|
||||
### retry の境界
|
||||
|
||||
AgentTurn 内に LlmCall を 1:N で持たせる際、retry の境界線を以下で定義する:
|
||||
|
||||
- **同 AgentTurn 内の LlmCall 連続** = input messages 列が **完全に同一** な再呼び出し (network error / 5xx / rate limit / stream 切断後の再接続)
|
||||
- **新 AgentTurn** = input messages 列が変化したとき (tool result が増えた、user が割り込んだ、など)
|
||||
|
||||
この基準は判別が明快で、usage / cost 集計も「LlmCall 単位で取り、AgentTurn 単位で sum」の 2 段集計で済む。stream 切断 → 再接続のケースが「同じ turn の継続」として自然に扱える。
|
||||
|
||||
### System actor の範囲
|
||||
|
||||
`SystemTurn` は actor 分類のキャッチオール枠として、以下を全て含む:
|
||||
|
||||
- Hook 出力の context 注入
|
||||
- Notify payload の history 記録 (Invoke(Notify) と組で現れる)
|
||||
- `<system-reminder>` injection
|
||||
- pod.scope 変更などシステム由来の追記
|
||||
- その他、user でも assistant でも tool でもない context 介入
|
||||
|
||||
これにより actor 分類は 4 値 (User / Agent / Tool / System) に収まる。
|
||||
|
||||
## 既存コードへの影響
|
||||
|
||||
### Event 名
|
||||
|
||||
現在の `Event::TurnStart` / `Event::TurnEnd` は実態として **LLM call 境界** で発火している。以下のいずれかで段階移行する:
|
||||
|
||||
- (案 A) 新規イベント `LlmCallStart` / `LlmCallEnd` を導入し、現状の TurnStart/TurnEnd の発火タイミングはそちらに移す。`TurnStart` / `TurnEnd` は AgentTurn 境界 (= retry を含むまとまり) の意味で再定義。
|
||||
- (案 B) 旧 event 名は alias として残しつつ、新名 (`LlmCallStart` / `LlmCallEnd` および `AgentTurnStart` / `AgentTurnEnd` 等) で完全分離。
|
||||
|
||||
どちらにせよ、AgentTurn と LlmCall の発火頻度は retry 集約により異なる点だけは明示する。
|
||||
|
||||
### Usage / cost accounting
|
||||
|
||||
- Usage / prompt cache hit / provider request metrics は **LlmCall に紐づく**。
|
||||
- AgentTurn の usage は内部 LlmCall の sum。
|
||||
- Invoke 単位の usage が必要なら、Invoke → 次の Invoke までの全 LlmCall を sum。
|
||||
|
||||
### TUI
|
||||
|
||||
- Invoke 境界に対応するヘッダー (`Send #1` / `Notify` / `Event` を kind に応じて表示) を新設または既存 `TurnHeader` を意味繰り上げ。
|
||||
- actor ごとの表示は UserTurn / AgentTurn / ToolTurn / SystemTurn それぞれに対応するブロックとして描画。
|
||||
- LlmCall 境界を UI に出すかは別判断 (デフォルトは AgentTurn にまとめる)。
|
||||
|
||||
### Worker / Protocol
|
||||
|
||||
- llm-worker は IDLE → active 遷移時に Invoke marker を history に追記する責務を持つ。
|
||||
- `WorkerResult` / `RunResult` / `TurnResult` などの命名は、責務 (Invoke 範囲の結果 / AgentTurn の結果 / LlmCall の結果) を区別して整理する。
|
||||
|
||||
## persistence-semantics との関係
|
||||
|
||||
`tickets/persistence-semantics.md` の Segment 内 `(segment_id, seq)` PK と整合する:
|
||||
|
||||
- Invoke marker / Turn 境界 / LlmCall 境界 / content entry は全て同じ entry 列に flat に並ぶ。Tree を畳む必要なし。
|
||||
- fork 起点の `at_turn_index` は **Invoke marker の seq** に揃える。TUI で見る "Send #N" 境界と fork 起点が一致し、ユーザーが "N 回目の send まで戻る" と素直に認識できる。
|
||||
- compaction の境界も Invoke 境界で取るのが自然 (途中 LlmCall や AgentTurn の中間では切らない)。
|
||||
|
||||
## 決定事項
|
||||
|
||||
- **Event 移行**: 案 A 採用。`Event::TurnStart` / `Event::TurnEnd` を **AgentTurn 境界** (retry 集約後) の意味に doc 再定義し、LLM call 境界の発火タイミングは **新規** `Event::LlmCallStart` / `Event::LlmCallEnd` に移す。protocol は新 variant 追加のみで既存 variant は破壊しない (互換維持)。
|
||||
- **`Worker::turn_count` の意味**: **AgentTurn 数**。現状 retry が実装されていないため LLM call 数と 1:1 だが、`llm-worker-stream-continuation` 等で retry が入った時に意味が分岐する想定。
|
||||
- **LlmCall 通し番号**: 新規 `llm_call_count: usize` を `Worker` に追加し、`Event::LlmCallStart` / `End` で通知。
|
||||
- **`InvokeKind`**: `UserSend` / `Notify` / `PodEvent` / `SystemReminder` / `Wakeup` の 5 variants を初期セットに含める。Wakeup は実体未登場だが将来追加コストを下げるため最初から枠を切る。配置は `protocol` crate (`session-store` が依存する向き)。
|
||||
- **`LogEntry::TurnEnd` の扱い**: **位置・形式そのまま維持**。新規 `LogEntry::Invoke { ts, kind: InvokeKind }` を **追加** (run / run_for_notification の開始時に commit)。TurnEnd の commit 位置を AgentTurn 単位に動かすのは互換破壊になるため、本チケット範囲外とする。`persistence-semantics` の `at_turn_index` は新 `Invoke` entry の seq を指すように後続で整合させる。
|
||||
- **`SystemTurn` の sub-kind 化**: 行わない。SystemTurn は actor キャッチオール枠の単一分類として平坦に扱う。Hook / Notify / system-reminder の区別は **対応する Invoke の `kind`** で取れるため重複しない。
|
||||
- **`Worker::history` への追記タイミング**: 実装中に Invoke 境界 (= run / run_for_notification 開始) と Invoke marker commit が同タイミングであることを確認する。ずれていれば marker 側を Worker 入口に揃える。
|
||||
|
||||
## 完了条件
|
||||
|
||||
- `Invoke` / `Turn` / `LlmCall` / `Request` / `Run` の定義が文書化されている。
|
||||
- AgentTurn における retry の境界線が明確化されている。
|
||||
- SystemTurn が actor キャッチオールとして定義され、Hook / Notify / system-reminder がそこに含まれることが示されている。
|
||||
- 既存 `Event::TurnStart` / `Event::TurnEnd` の段階移行方針 (案 A) が決まっている。
|
||||
- persistence-semantics の `at_turn_index` 等が Invoke seq を指すことが整合している。
|
||||
- **コード反映**: 以下が実装されている。
|
||||
- `protocol::Event::InvokeStart { kind: InvokeKind }` / `Event::LlmCallStart` / `Event::LlmCallEnd` が追加されている。
|
||||
- `protocol::InvokeKind` enum が定義されている。
|
||||
- `Worker` に `llm_call_count` フィールドと `on_llm_call_start` / `on_llm_call_end` callback が追加されている。
|
||||
- `Worker::turn_count` の doc が AgentTurn 数の意味に更新されている。
|
||||
- `session_store::LogEntry::Invoke { ts, kind }` が追加され、run / run_for_notification 開始時に commit されている。
|
||||
- controller が新 callback を wire し、新 Event を broadcast している。
|
||||
|
||||
## 範囲外
|
||||
|
||||
- このチケット単体での大規模 rename 実装 (具体的には `Hook::OnTurnEnd` の名前変更、`TUI Block::TurnHeader` の名前変更、`Pod::add_on_turn_end_hook` 等の API rename)。
|
||||
- `LogEntry::TurnEnd` の commit 位置を AgentTurn 単位に動かすこと (互換破壊)。
|
||||
- retry の実体実装 (`llm-worker-stream-continuation` の領分)。
|
||||
- 永続化 DB backend の実装。
|
||||
- TUI の詳細 UX 設計 (Invoke 境界での kind 別表示、LlmCall の UI 露出など)。
|
||||
- protocol の互換破壊的変更 (既存 variant の変更・削除)。
|
||||
|
||||
## 関連
|
||||
|
||||
- `tickets/persistence-semantics.md` — Segment / Entry の永続化単位を定義する別チケット。本チケットは論理単位 (Invoke / Turn / LlmCall) の定義に閉じる。
|
||||
- `crates/llm-worker/`
|
||||
- `crates/protocol/`
|
||||
- `crates/tui/`
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
# Paused→Run の interrupt 前処理を Pod 内部に閉じる
|
||||
|
||||
## 背景
|
||||
|
||||
`controller_loop` は `Method::Run { input }` を受けたとき、Pod の status を見て次のように 2 分岐している (`crates/pod/src/controller.rs:629`):
|
||||
|
||||
```rust
|
||||
pending = Some(if status_before == PodStatus::Paused {
|
||||
PendingRun::InterruptAndRun(input)
|
||||
} else {
|
||||
PendingRun::Run(input)
|
||||
});
|
||||
```
|
||||
|
||||
そして `drive_turn` 側でも `pod.run(input)` と `pod.interrupt_and_run(input)` を呼び分けている。
|
||||
|
||||
`pod.interrupt_and_run`(`crates/pod/src/interrupt_and_run.rs`)の中身は:
|
||||
|
||||
1. worker history に残る orphan な `Item::ToolCall`(前 turn が中断されて対応する `ToolResult` が未発行)に synthetic `ToolResult` を被せる(Anthropic 等の wire-validity 要件)
|
||||
2. 「直前の作業は中断された」旨の system note を `worker` に push
|
||||
3. 最後に `self.run(input)` に flow
|
||||
|
||||
つまり**入口の前処理が違うだけで、出口は通常の `pod.run` に合流する**。
|
||||
|
||||
しかもこの前処理が必要かどうかは、worker が一次情報として持っている `last_run_interrupted` フラグで判定できる(`pod.rs:1808` で既に使用済み)。`PodStatus::Paused` は controller がそれを観測したミラーに過ぎない。
|
||||
|
||||
結果として、Controller 層が Pod の内部状態を覗いて分岐するという責務漏れになっている。`PendingRun` enum は `Method::Run` 経由のものが 2 variant に分裂し、本来「parent originated か否か」など Run の意味論を扱うはずの enum に Pod 内部都合の分岐が混ざる。
|
||||
|
||||
## 要件
|
||||
|
||||
- `PendingRun::InterruptAndRun(input)` バリアントを削除し、`PendingRun::Run(input)` 一本に統合する。
|
||||
- `controller_loop` の `Method::Run` ハンドリングは status を見ずに `PendingRun::Run(input)` を stage するだけにする。
|
||||
- interrupt 前処理(orphan tool_result の closure + system note 挿入)は `Pod::run` の入口で `self.worker().last_run_interrupted()` を見て自発的に行う。
|
||||
- `Pod::interrupt_and_run` メソッドは削除する。`interrupt_and_run.rs` のロジック(`orphan_tool_result_closures` 等)は `Pod::run` の前段として `pod.rs` 側、または同モジュールから `Pod::run` が直接呼ぶ形に再配置する。
|
||||
- `Method::Resume`(`PendingRun::Resume`)の経路には影響させない。Resume は前 turn の context を生かして続行する別経路で、interrupt 前処理を入れてはいけない。
|
||||
- 動作上の変化はゼロ(リファクタ)。Paused 中に `Method::Run { input }` が来たときの挙動は今と完全に同じであること。
|
||||
|
||||
## 完了条件
|
||||
|
||||
- `PendingRun` は `Run(Vec<Segment>)` / `RunForNotification` / `Resume` の 3 variant のみ。
|
||||
- `controller_loop` の `Method::Run` ブランチに `status_before == PodStatus::Paused` 分岐が残っていない。
|
||||
- `Pod::run` を Paused 直後(`last_run_interrupted == true`)の状態で呼ぶと、orphan `ToolCall` の closure と interrupt system note が history に積まれてから新規 input が処理される。これを既存の `interrupt_and_run` のテスト相当でカバーする。
|
||||
- `Pod::run` を非 Paused 状態で呼ぶと従来通り interrupt 前処理は走らない。
|
||||
- 既存の Paused 関連 E2E / 結合テストが全て通る。
|
||||
|
||||
## 範囲外
|
||||
|
||||
- `[[pod-parent-turn-callback]]` の `is_parent_originated` 判定ロジック自体の変更(本チケット完了後は `Run` / `Resume` の 2 variant が true になる形に自然に整理されるが、本チケットの目的ではない)。
|
||||
- `worker.last_run_interrupted` のセマンティクス変更。
|
||||
- TUI 側で Paused 中の送信を `Run` / `Resume` どちらにマップするかの UX 議論。
|
||||
37
tickets/worker-history-append-contract.md
Normal file
37
tickets/worker-history-append-contract.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# llm-worker: history append を callback 経由の単一経路に閉じる
|
||||
|
||||
## 背景
|
||||
|
||||
`pod-interrupt-prep-internalize` レビュー過程で、`Worker` の history append API に **callback を踏む経路** と **踏まない経路** が併存していることが顕在化した。
|
||||
|
||||
- callback を踏む経路(worker 内部 private): `extend_history_with_callbacks` — `emit_history_append(&item)` を呼んでから `self.history.push(item)`。`Worker::run` 内部の streaming commit / tool result commit はこの経路。
|
||||
- callback を**踏まない**経路(外部公開): `Worker::push_item` / `Worker::extend_history` / builder の `with_items` — `self.history.push` / `extend` するだけで `emit_history_append` を呼ばない。
|
||||
|
||||
session-log への永続化は `Pod::wire_history_persistence` が `Worker::on_history_append` を立て、callback 内で `classify_history_item` → `LogEntry::AssistantItem` / `LogEntry::ToolResult` として `writer.append_entry` する作りになっている。
|
||||
|
||||
結果として「callback 不発火経路で append すると in-memory `worker.history` には載るが session-log の独立エントリにはならない」という非対称が API 契約として残っている。実際 production では `Pod::apply_interrupt_prep` (`crates/pod/src/pod.rs:1438` / `:1441`) がこの不発火経路を踏んでおり、Paused→Run 時の orphan tool_result closure と interrupt system note が session-log に独立行として残らない。
|
||||
|
||||
CLAUDE.md の「LLM コンテキスト加工原則」は「新しい input を context に乗せたいなら、必ず先に `worker.history` に append して commit すること。`history.json` への永続化はそこから自動的についてくる」と謳っているが、この「自動的についてくる」が現状の API 契約では保証されていない。**契約として callback バイパスが可能な設計であってはならない**。
|
||||
|
||||
## 要件
|
||||
|
||||
- `Worker` の history を成長させる経路を、必ず `on_history_append` callback を踏む単一経路に統一する。
|
||||
- 外部から呼び出せる「callback 不発火な history append」API を廃止する。
|
||||
- 対象: `Worker::push_item`、`Worker::extend_history`、`WorkerBuilder::with_items` 系
|
||||
- 内部実装の `extend_history_with_callbacks` 相当を唯一の append プリミティブに格上げする(命名は実装時に整理)
|
||||
- `Pod::apply_interrupt_prep` を新 API に乗せ替える。乗せ替えの副作用として、Paused→Run 時の orphan tool_result closure / interrupt system note が `LogEntry::ToolResult` / `LogEntry::SystemItem` 系として session-log に独立エントリで記録されるようになる(これは本チケットで肯定的に取り込む変化)。
|
||||
- 注: system message は `PodInterceptor` 経由で `LogEntry::SystemItem` として典型化されている経路があるので、callback 内のフィルタ (`pod.rs:381-389` の `Role::System` skip) との重複を作らないこと。interrupt system note は callback 単独で書く / interceptor 単独で書く / 両方書かない、のいずれかに整合させる。実装時に確認。
|
||||
- `worker_state_test.rs` を含む既存テストは、新 API に書き換えるか、builder 段階で history を仕込む正当な用途として整理する。前者を基本とする。
|
||||
|
||||
## スコープ外
|
||||
|
||||
- `Worker::clear_history` の扱い(append ではなく削除なので別論点)。
|
||||
- `LogEntry` バリアントの設計変更。
|
||||
- callback で書かれるエントリの形式・命名の整理(`classify_history_item` の現行挙動を前提として進める)。
|
||||
- `Notify` / `PodEvent` / `<system-reminder>` 系の history 反映ポリシー全体(`system-reminder` 注入機構の汎用化として別途整理する。本チケットはあくまで「API 契約から callback バイパスを消す」レイヤに閉じる)。
|
||||
|
||||
## 完了条件
|
||||
|
||||
- `Worker::push_item` / `Worker::extend_history` / `WorkerBuilder::with_items` 等、callback を踏まずに `worker.history` を成長させられる public API がコードベースに存在しない。
|
||||
- `cargo check --workspace` と `cargo test -p llm-worker --lib` / `cargo test -p pod` が通る。
|
||||
- `apply_interrupt_prep` が新 API 経由になり、interrupt 前処理由来の append が session-log の独立エントリとして残ることを `~/.insomnia/sessions/*.jsonl` で目視確認できる(手順をレビュー時の備考に記載)。
|
||||
Loading…
Reference in New Issue
Block a user