Compare commits

...

33 Commits

Author SHA1 Message Date
04f1837fa9
feat: Workflowの読み取り位置変更の実装 2026-05-08 00:15:50 +09:00
5ec24707f4
docs(tickets): reportの運用・Workflowのディレクトリ位置修正 2026-05-07 23:34:00 +09:00
c0c5eb9ad2
feat: TUIのmarkdown対応 2026-05-05 18:30:25 +09:00
e9e80c5918
docs(tickets): PermissionのチケットとTUIのmd表示 2026-05-05 17:16:03 +09:00
f4ab361889
docs(tickets): agent-skills完了 2026-05-05 16:00:40 +09:00
60c779b80c
update: Agent skills実装のレビュー・対応 2026-05-05 13:54:02 +09:00
50fa2ce3f7
feat: writingに対する基本的な指示promptを追加 2026-05-05 13:42:34 +09:00
760b304969
feat: agent skillsの互換実装 2026-05-05 13:16:10 +09:00
5fe9a5805e
fix: Reasoningの永続化のスキーマのミスを修正 2026-05-05 12:30:29 +09:00
64b5d61a23
docs(tickets): turnのセマンティクスを変える計画 2026-05-05 12:29:52 +09:00
461d7f9142
docs(tickets): reasoning-history-perisit完了 2026-05-04 23:06:21 +09:00
6f62ea8ce8
update: Reasoningコンテキスト管理のレビュー・対応 2026-05-04 23:05:08 +09:00
9fd61e8068
feat: Reasoningのコンテキスト管理の対応 2026-05-04 21:31:44 +09:00
dd3903efde
docs(tickets): Reasoningのコンテキスト管理とPruneの調整チケット追加 2026-05-04 21:16:31 +09:00
089db05535
docs(tickets): tui-task-display完了 2026-05-04 20:43:21 +09:00
0a83909f30
feat: Task表示のレビュー・修正 2026-05-04 17:28:39 +09:00
9072ac4e03
feat: TUI上に進行中のTaskを表示する実装 2026-05-04 17:06:02 +09:00
6178812979
docs(tickets): Compaction進行中のライブ表示 2026-05-04 17:03:51 +09:00
d385a72d85
docs(tickets): post-run memory detach 完了 2026-05-04 16:11:38 +09:00
c48b99cfe3
feat: Pos処理の非同期化・Busy状態の削除 2026-05-04 15:52:27 +09:00
632d63df33
docs(tickets): 追加:タスクリストの表示とコンテキスト長インジケータ 2026-05-04 15:32:40 +09:00
107dcf6636
docs(tickets): Busyの切り離し 2026-05-04 13:20:25 +09:00
7f9d2f93f9
Merge branch 'llm-worker-transient-retry' into develop 2026-05-04 13:16:26 +09:00
9771533b31
docs(tickets): pod状態のTUI同期完了 2026-05-04 13:08:44 +09:00
36a4e9f9b8
feat: Podのステータス同期の修正 2026-05-04 12:55:29 +09:00
0be30052c1
feat: Podのステータスを厳密にし、同期漏れを防ぐ 2026-05-04 12:55:11 +09:00
72e03f9e8f
docs(tickets): llm-worker-transient-retry完了 2026-05-04 12:51:41 +09:00
09a1cde92c
docs(tickets): llm-worker-transient-retry レビュー追記
7183847 のレビュー結果を Approve として記録する。チケット要件
(リトライ対象 / バックオフ / Retry-After 上書き / mid-stream 温存 /
完了条件) はすべて満たしており、コードベースの層構造を歪める変更も
ない。Retry-After テストの方針差 (実時間 1s vs 仮想時間 5s) と
connect refused テストの試行回数未検証は non-blocking として
review.md に記録。
2026-05-04 12:49:13 +09:00
7183847ee5
feat(llm-worker): HTTP transient エラーへのリトライを追加
`transport.rs` の HTTP 送信〜ステータスチェック区間に指数バックオフ
+ フルジッターのリトライループを追加する。SSE 読み出し開始後 (
`bytes_stream()` 以降) のエラーは従来どおりそのまま流す。

- `is_retryable(&ClientError)`: 408/425/429/500/502/503/504/529 と
  reqwest の connect/timeout のみ true
- `RetryPolicy` (default: base 500ms / cap 10s / max_attempts 4 /
  total_timeout 30s)
- `Retry-After` ヘッダ (秒数) があればバックオフを上書き
- リトライ発火ごとに warn! でステータス・attempt・wait を出す

ref: tickets/llm-worker-transient-retry.md
2026-05-04 12:45:33 +09:00
1451998e0e
Merge branch 'tui-system-message-render' into develop 2026-05-04 12:10:17 +09:00
5bc6fb4b5c
docs(tickets): tui-system-message-render完了 2026-05-04 12:05:50 +09:00
ac1a672973
feat: システムメッセージをTUIで表示させる 2026-05-04 12:04:09 +09:00
4ec1c8b64c
update: Taskツールの説明を更新 2026-05-04 11:32:04 +09:00
93 changed files with 6895 additions and 945 deletions

View File

@ -1,2 +1,7 @@
_staging
memory
# Generated / session-derived memory state
/memory/_staging/
/memory/summary.md
/memory/decisions/
/memory/requests/
# Project-authored workflows and knowledge are intentionally tracked.

View File

@ -0,0 +1,149 @@
---
description: TODO.md と tickets/ から半自動で実装候補を選び、worktree・実装 Pod・reviewer・人間確認を使い分けて完了候補まで進める
model_invokation: false
user_invocable: true
requires: []
---
# Auto Maintain Workflow
半自動 maintainer として、`TODO.md` と `tickets/` を俯瞰し、実装できる作業を選び、必要に応じて worktree / 実装 Pod / reviewer Pod を orchestration する。最終判断、git commit / merge / push、ticket 完了削除は人間に戻す。
この Workflow は常駐 scheduler ではない。ユーザーが `/auto-maintain` を明示的に呼んだ時だけ動く。
## 基本方針
- main workspace は制御面として扱う。
- `.insomnia/`
- `TODO.md`
- `tickets/`
- `docs/report/`
- maintainer inbox / trial log
- 実装差分は原則 child git worktree に隔離する。
- child worktree には `.insomnia` を置かない。必要なら `/worktree-workflow` の手順に従い sparse checkout で `.insomnia` を除外する。
- 実装 Pod と reviewer Pod は原則分ける。ただし scope 衝突や作業粒度により、親 Pod が review してよい。
- review artifact や完了候補の記録は main workspace 側に置き、実装 Pod には書かせない。
- git commit / merge / push / ticket 完了削除は行わず、人間へ完了候補として報告する。
## 事前調査
最初に以下を読む。
1. `TODO.md`
2. 対象候補の `tickets/*.md`
3. 関連 docs / report
4. 既存 review file があれば `tickets/*.review.md`
TODO と tickets の不整合を見つけた場合は、勝手に ticket を作成・削除せず、人間へ報告する。
## 着手候補の選び方
優先する作業:
- ticket の要件と完了条件が具体的
- 影響範囲が限定的
- build / test で確認しやすい
- scope / permission / history 永続化 / prompt context 加工原則に触れない
- narrow write scope を切りやすい
初回試走や小さな運用確認では、TUI 表示改善など局所的な ticket を優先する。
避ける、または人間確認してから進める作業:
- 複数の設計方針が自然に導け、将来構造に影響する
- permission / scope / Pod lifecycle / history persistence / prompt context 加工原則に触れる
- manifest cascade や protocol wire を広く変える
- test 不能または再現不能
- ticket 自体の大幅な要件変更が必要
## エスカレーション基準
以下では作業を止めて人間へ確認する。
- ticket の要件から複数の設計方針が自然に導ける
- scope / permission / history 永続化 / prompt context 加工原則など安全モデルに触れる
- 新 ticket 追加、既存 ticket の大幅変更、ticket 完了削除が必要
- git commit / merge / push / branch cleanup などの git 書き込み操作が必要
- `git worktree add` など、作業ツリー作成の許可が明示されていない
- テスト不能、再現不能、作業範囲外の不具合に遭遇した
- child worktree に `.insomnia` が出てしまった
## Worktree 作成
実装差分を隔離する必要がある場合、`/worktree-workflow` の手順を使う。要点は以下。
```bash
git worktree add .worktree/<task-name> -b <task-name>
git -C .worktree/<task-name> sparse-checkout init --no-cone
git -C .worktree/<task-name> sparse-checkout set --no-cone \
'/*' \
'!/.insomnia/' \
'!/.insomnia/**'
```
`git worktree add` は git 書き込み操作なので、ユーザーが明示的に許可していない場合は実行前に確認する。
## 実装 Pod の spawn
実装 Pod を使う場合は、対象 ticket、child worktree path、write scope、禁止事項を明示する。
推奨 scope:
- read: main workspace 全体
- write: child worktree 内の必要最小ディレクトリ
実装 Pod への依頼には以下を含める。
- 対象 ticket と完了条件
- 作業対象は child worktree であり、Bash は `cd <child-worktree> && ...` で実行すること
- `.insomnia` / `TODO.md` / `tickets/` / review artifact / inbox は書かないこと
- git commit / merge / push はしないこと
- build / test / format の結果を報告すること
- 未解決事項と人間確認が必要な点を報告すること
子 Pod の出力は `ReadPodOutput` で確認し、追加依頼が必要なら `SendToPod` を使う。不要になった Pod は `StopPod` する。
## Review
実装 Pod 完了後、原則として実装 Pod を停止して scope を回収してから review する。
reviewer Pod を使う場合は read-only を基本にする。reviewer には以下を依頼する。
- ticket の背景・要件・完了条件を読む
- diff が要件を満たすか確認する
- 既存設計を歪めていないか確認する
- 不必要な実装や過剰な抽象がないか確認する
- build / test 結果が妥当か確認する
review artifact を書く場合は、親 Pod が main workspace 側に書く。child worktree 内の `tickets/` に書く運用は scope 衝突を起こしやすいため避ける。
## 修正依頼
review 指摘があれば、指摘内容をまとめて実装 Pod に再依頼する。既存 Pod を止めている場合は、同じ child worktree と narrow write scope で再 spawn する。
修正後は build / test を再確認し、必要なら再 review する。
## 完了候補報告
最後は作業を完了削除せず、次の形式で人間へ報告する。
```text
完了候補:
- ticket: <path>
- worktree: <path>
- branch: <name>
- 実装概要: ...
- 変更ファイル: ...
- build/test: ...
- review: approve / changes requested / skipped
- 未解決事項: ...
- 人間に戻す判断:
- commit するか
- merge するか
- ticket / review file を更新または削除するか
- TODO.md から削除するか
```
## 試走記録
この Workflow 自体の試走では、対象 ticket、scope 方針、実装 Pod / reviewer の使い分け、停止した理由、不足点を記録する。不足が見つかっても、この Workflow 内で永続ジョブキュー、scope handoff、Pod sleep、ticket lifecycle 再設計を実装しない。必要なら別 ticket として人間に提案する。

View File

@ -0,0 +1,87 @@
---
description: Git worktree を使って実装用作業ツリーを作り、main workspace の管理ファイルと子 worktree のコード差分を分離して開発を進める
model_invokation: false
user_invocable: true
requires: []
---
# Worktree Workflow
Git worktree を使って、実装差分を main workspace から分離して進める。main workspace は ticket / review / inbox / `.insomnia` を持つ制御面、子 worktree はコード変更専用の作業面として扱う。
この Workflow は `.claude/skills/worktree-workflow/SKILL.md` の運用を insomnia 向けに移植したもの。insomnia では Pod の write scope が排他的に委譲されるため、子 worktree に `.insomnia` を置かず、親 Pod が main workspace 側の管理ファイルを書ける状態を保つ。
## 原則
- 1 セッション / 1 ticket / 1 task につき 1 worktree を作る。
- worktree は `./.worktree/<task-name>` に作る。
- branch 名は原則 `<task-name>` と同じにする。
- 子 worktree はコード差分専用にし、`TODO.md` / `tickets/` / `docs/report/` / inbox などの管理ファイルは main workspace 側で扱う。
- 子 worktree には `.insomnia` を出さない。worktree 作成後に sparse checkout で `.insomnia` を除外する。
- git commit / merge / push / branch deletion / worktree remove は、人間が明示した場合以外は行わない。
## 事前確認
作業前に以下を確認する。
1. 対象 ticket / task 名が決まっているか。
2. branch / worktree 名に使える kebab-case の `<task-name>` があるか。
3. git 書き込み操作を行ってよい明示許可があるか。
4. main workspace の未保存差分や既存 worktree と衝突しないか。
許可が曖昧な場合、`git worktree add` の前に人間へ確認する。
## 作成手順
`<task-name>` を確定したら、main workspace で以下を実行する。
```bash
git worktree add .worktree/<task-name> -b <task-name>
git -C .worktree/<task-name> sparse-checkout init --no-cone
git -C .worktree/<task-name> sparse-checkout set --no-cone \
'/*' \
'!/.insomnia/' \
'!/.insomnia/**'
```
既に branch がある場合は `-b` で新規作成せず、既存 branch を使うか人間へ確認する。`git worktree add` が失敗した場合は、worktree / branch / lock の状態を確認してから人間へ報告する。
## 子 Pod へ渡す scope
子 Pod を使う場合、子 Pod の cwd は現状 main workspace のままになる。子 Pod には作業対象が child worktree であることを明示し、Bash 実行時は必ず `cd .worktree/<task-name> && ...` させる。
推奨 scope:
- read: main workspace 全体
- write: child worktree の必要最小ディレクトリだけ
例:
```text
read: /home/hare/Projects/insomnia
write: /home/hare/Projects/insomnia/.worktree/<task-name>/crates/tui
```
子 Pod には `tickets/` や inbox を書かせない。review artifact や完了候補の記録は親 Pod が main workspace 側に書く。
## 実装中のルール
- child worktree 内で build / test / format を実行する。
- main workspace の `.insomnia` や memory は child worktree にコピーしない。
- `.insomnia` が child worktree に現れた場合は作業を止め、sparse checkout 設定を確認する。
- ticket 要件外の設計変更、scope / permission / history 永続化 / prompt context 加工原則に触れる変更は実装前に人間へ確認する。
- 依存関係追加や大きな設計変更が必要になった場合も人間へ確認する。
## 完了時
実装が終わったら、merge は行わず、以下を報告する。
- worktree path
- branch 名
- 変更ファイル
- 実装概要
- 実行した build / test / format
- 未解決事項
- review に回せるかどうか
マージウィンドウとして人間が明示的にこの Workflow を呼んだ場合だけ、merge / worktree remove / branch cleanup の候補手順を提示する。実行は人間の明示許可を待つ。

View File

@ -51,3 +51,7 @@ TODO.mdのリンクは完了後に切れるが、そのリンクを元にgitで
`.review.md` にはレビューの指摘事項と判断結果を記載する。
レビューはdiffの確認だけでなく、チケットはどのような前提・要件であり、それが達成されたかの確認まで含めて行う。
常に、提出された実装で良いのか、コードベースを歪めていないか、不必要な実装ではないかを確認すること。
---
insomniaでinsomniaを開発している際、AI自身のフィードバックを元に改善を回すために `docs/report/`ディレクトリに感じた障壁や改善案等を書き残す形にした。 明確に力不足な点/ツールの問題があった場合や、ユーザーからの指示があった際に作ること。

21
Cargo.lock generated
View File

@ -1650,6 +1650,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"trybuild",
"wiremock",
]
[[package]]
@ -2249,6 +2250,17 @@ dependencies = [
"wiremock",
]
[[package]]
name = "pulldown-cmark"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad"
dependencies = [
"bitflags 2.11.0",
"memchr",
"unicase",
]
[[package]]
name = "quinn"
version = "0.11.9"
@ -3613,11 +3625,14 @@ dependencies = [
"manifest",
"pod-registry",
"protocol",
"pulldown-cmark",
"ratatui",
"serde",
"serde_json",
"session-store",
"tokio",
"toml",
"tools",
"unicode-width",
"uuid",
]
@ -3634,6 +3649,12 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.24"

13
TODO.md
View File

@ -1,22 +1,29 @@
- Workflow / Skills
- 内部 Worker / 内部 Pod の Workflow 化 → [tickets/internal-worker-workflow.md](tickets/internal-worker-workflow.md)
- Agent Skills を Workflow として ingest → [tickets/agent-skills.md](tickets/agent-skills.md)
- 半自動開発運用 Workflow → [tickets/auto-maintain-workflow.md](tickets/auto-maintain-workflow.md)
- Workflow の物理配置を `.insomnia/workflow` に分離 → [tickets/workflow-directory-layout.md](tickets/workflow-directory-layout.md)
- パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md)
- Pod CLI: マニフェスト関連フラグの整理 → [tickets/pod-cli-manifest-flags.md](tickets/pod-cli-manifest-flags.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: 子→親の TurnEnded/Errored callback を親由来ターンのみに絞る → [tickets/pod-parent-turn-callback.md](tickets/pod-parent-turn-callback.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)
- llm-worker のエラー耐性
- HTTP transient リトライ → [tickets/llm-worker-transient-retry.md](tickets/llm-worker-transient-retry.md)
- ストリーム途中失敗時の継続 → [tickets/llm-worker-stream-continuation.md](tickets/llm-worker-stream-continuation.md)
- llm-worker: Anthropic projection で assistant ターン内ブロックを 1 message に束ねる → [tickets/anthropic-assistant-burst-bundling.md](tickets/anthropic-assistant-burst-bundling.md)
- ネイティブ GUI クライアント MVP → [tickets/native-gui-mvp.md](tickets/native-gui-mvp.md)
- TUI 拡充
- Run 中の入力キューイング → [tickets/tui-input-queue.md](tickets/tui-input-queue.md)
- ユーザーマニフェストのモデル設定 wizard → [tickets/tui-user-model-setup.md](tickets/tui-user-model-setup.md)
- spawn 失敗時に Pod の stderr が TUI に表示されない → [tickets/tui-spawn-error-surface.md](tickets/tui-spawn-error-surface.md)
- role:system の system message を TUI に表示する仕組み → [tickets/tui-system-message-render.md](tickets/tui-system-message-render.md)
- 巻き戻されたターンの入力テキストを編集領域に復元 → [tickets/tui-empty-turn-restore.md](tickets/tui-empty-turn-restore.md)
- セッションコンテキスト長 / ウィンドウ占有率の常時表示 → [tickets/tui-context-usage-indicator.md](tickets/tui-context-usage-indicator.md)
- Compaction 進行中のライブ表示 → [tickets/tui-compact-progress.md](tickets/tui-compact-progress.md)
- Assistant 応答の Markdown スタイル表示 → [tickets/tui-assistant-markdown.md](tickets/tui-assistant-markdown.md)
- Manifest: Tool Output / File Upload 上限の分離とデフォルト緩和 → [tickets/manifest-output-upload-limits.md](tickets/manifest-output-upload-limits.md)
- Prune: 保護境界を turn 数から末尾 token budget に置き換え → [tickets/prune-token-budget.md](tickets/prune-token-budget.md)
- メモリ機構
- 使用頻度メトリクス + Knowledge 化候補レポート → [tickets/memory-usage-metrics.md](tickets/memory-usage-metrics.md)
- セッション内 Task ツールの注意機構(無アクティビティで `<system-reminder>` ナッジ) → [tickets/session-todo-reminder.md](tickets/session-todo-reminder.md)

View File

@ -25,3 +25,4 @@ tempfile = { workspace = true }
dotenv = "0.15"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
trybuild = "1.0.116"
wiremock = "0.6.5"

View File

@ -91,6 +91,16 @@ impl Kind for ErrorKind {
type Event = ErrorEvent;
}
/// Reasoning item Kind - 完成済み reasoning item の永続化用
///
/// 1 reasoning item につき 1 度だけ発火する。Worker は
/// `ReasoningItemCollector` 経由で受け取り、ターン終了時に
/// `Item::Reasoning` として history に append する。
pub struct ReasoningItemKind;
impl Kind for ReasoningItemKind {
type Event = ReasoningItemEvent;
}
// =============================================================================
// Block Kind Definitions
// =============================================================================

View File

@ -74,6 +74,12 @@ pub trait LlmClient: Send + Sync {
}
}
impl Clone for Box<dyn LlmClient> {
fn clone(&self) -> Self {
self.clone_boxed()
}
}
/// `Box<dyn LlmClient>` に対する `LlmClient` の実装
///
/// これにより、動的ディスパッチを使用するクライアントも `Worker` で利用可能になる。

View File

@ -67,3 +67,69 @@ impl From<serde_json::Error> for ClientError {
ClientError::Json(err)
}
}
/// transient な失敗としてリトライ対象になるかを判定する。
///
/// 対象:
/// - `Api { status }` のうち 408 / 425 / 429 / 500 / 502 / 503 / 504 / 529
/// - `Http(reqwest::Error)` のうち `is_connect()` または `is_timeout()`
///
/// それ以外Json、Sse、Config、上記以外の Api ステータス)は false。
/// SSE 読み出し開始後の失敗は呼び出し側で `Sse` として上に流すため、
/// ここで対象外にしておけば自動的に弾かれる。
pub fn is_retryable(error: &ClientError) -> bool {
match error {
ClientError::Api {
status: Some(code), ..
} => matches!(*code, 408 | 425 | 429 | 500 | 502 | 503 | 504 | 529),
ClientError::Api { status: None, .. } => false,
ClientError::Http(e) => e.is_connect() || e.is_timeout(),
ClientError::Json(_) | ClientError::Sse(_) | ClientError::Config(_) => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn api_err(status: Option<u16>) -> ClientError {
ClientError::Api {
status,
code: None,
message: String::new(),
}
}
#[test]
fn retryable_status_codes() {
for code in [408u16, 425, 429, 500, 502, 503, 504, 529] {
assert!(
is_retryable(&api_err(Some(code))),
"status {code} should be retryable",
);
}
}
#[test]
fn non_retryable_status_codes() {
for code in [400u16, 401, 403, 404, 409, 410, 422, 501] {
assert!(
!is_retryable(&api_err(Some(code))),
"status {code} should not be retryable",
);
}
}
#[test]
fn api_without_status_not_retryable() {
assert!(!is_retryable(&api_err(None)));
}
#[test]
fn json_sse_config_not_retryable() {
let json_err = serde_json::from_str::<serde_json::Value>("not json").unwrap_err();
assert!(!is_retryable(&ClientError::Json(json_err)));
assert!(!is_retryable(&ClientError::Sse("boom".into())));
assert!(!is_retryable(&ClientError::Config("boom".into())));
}
}

View File

@ -17,6 +17,9 @@ use serde::{Deserialize, Serialize};
///
/// - **メタイベント**: `Ping`, `Usage`, `Status`, `Error`
/// - **ブロックイベント**: `BlockStart`, `BlockDelta`, `BlockStop`, `BlockAbort`
/// - **永続化イベント**: `ReasoningItem` (history に commit すべき完成済み
/// reasoning item。streaming 表示用の Thinking BlockStart/Delta/Stop と
/// は別経路で発火する)
///
/// # ブロックのライフサイクル
///
@ -41,6 +44,18 @@ pub enum Event {
BlockStop(BlockStop),
/// ブロック中断
BlockAbort(BlockAbort),
/// Reasoning item の完成。scheme が「次の request に送り返すための
/// reasoning material が揃った」点で 1 度だけ発火する。
///
/// - Anthropic: 1 つの `thinking` content_block 完了ごと
/// - OpenAI Responses: 1 つの reasoning output_item 完了ごと
///
/// 上位層Worker / ReasoningItemCollectorはこれを `Item::Reasoning`
/// として `worker.history` に append する。streaming 表示用の
/// `BlockStart(Thinking)` / `BlockDelta(Thinking)` / `BlockStop(Thinking)`
/// は依然として並行発火するlive display と round-trip persist の責務分離)。
ReasoningItem(ReasoningItemEvent),
}
// =============================================================================
@ -212,6 +227,31 @@ impl BlockAbort {
}
}
// =============================================================================
// Reasoning Item Event
// =============================================================================
/// 完成済み reasoning item。scheme が round-trip に必要なすべての
/// materialtext, summary, encrypted_content, signature, idを揃えて
/// 1 度だけ発火する。
///
/// `Item::Reasoning` のフィールドを 1:1 に持つ。
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct ReasoningItemEvent {
/// scheme 側で観測した item idOpenAI Responses の `id`)。
pub id: Option<String>,
/// reasoning 本体テキスト。Anthropic は `thinking` 累積、OpenAI は
/// `reasoning_text` 累積。redacted_thinking では空。
pub text: String,
/// summary (OpenAI Responses の `summary_text[]`)。他 scheme は空。
pub summary: Vec<String>,
/// 暗号化された opaque blobAnthropic `redacted_thinking.data` /
/// OpenAI Responses `encrypted_content`)。
pub encrypted_content: Option<String>,
/// Anthropic extended thinking signature。round-trip 必須。
pub signature: Option<String>,
}
/// 停止理由
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum StopReason {

View File

@ -23,6 +23,7 @@ pub mod error;
pub mod event;
pub mod types;
pub mod retry;
pub mod scheme;
pub mod transport;

View File

@ -0,0 +1,104 @@
//! HTTP transient エラー向けリトライポリシー。
//!
//! `transport.rs` の HTTP 送信〜ステータスチェック区間で `is_retryable`
//! が true を返した失敗をリトライする際に、待ち時間と打ち切り条件を
//! 提供する。SSE 読み出し開始後の失敗は対象外。
use std::time::Duration;
/// 指数バックオフ + ジッター + 累積タイムアウトを表すポリシー。
///
/// `Default` は llm-worker 全体の固定値を返す。manifest 経由の上書きが
/// 必要になったら拡張する(現状は不要 → `tickets/llm-worker-transient-retry.md`)。
#[derive(Debug, Clone)]
pub struct RetryPolicy {
/// 指数の基準値。`base * 2^attempt` を `cap` で頭打ちにした上限から
/// フルジッターで実際の wait を抽選する。
pub base: Duration,
/// 1 回あたりの wait の上限。
pub cap: Duration,
/// 試行の合計回数(初回 + リトライ)。`1` ならリトライしない。
pub max_attempts: u32,
/// 初回送信開始からの累積タイムアウト。これを超える wait は打ち切る。
pub total_timeout: Duration,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
base: Duration::from_millis(500),
cap: Duration::from_secs(10),
max_attempts: 4,
total_timeout: Duration::from_secs(30),
}
}
}
impl RetryPolicy {
/// `attempt` 回目の失敗0-indexed後に待つ時間を返す。
/// `Retry-After` で上書きしたい場合は呼び出さず、その値をそのまま使う。
pub fn backoff(&self, attempt: u32) -> Duration {
let shift = attempt.min(20);
let base_nanos = self.base.as_nanos() as u64;
let exp_nanos = base_nanos.saturating_mul(1u64 << shift);
let cap_nanos = self.cap.as_nanos() as u64;
let upper = exp_nanos.min(cap_nanos);
Duration::from_nanos(jitter_nanos(upper))
}
}
/// `[0, max_nanos]` から擬似乱数的に 1 つ取り出す。`SystemTime` の
/// 下位ビットを splitmix64 で攪拌するだけの軽量実装で、暗号的乱数性は
/// 持たないがフルジッターのぶつかり回避には十分。
fn jitter_nanos(max_nanos: u64) -> u64 {
if max_nanos == 0 {
return 0;
}
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let mut x = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
x ^= x >> 31;
x % (max_nanos + 1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_policy_values() {
let p = RetryPolicy::default();
assert_eq!(p.base, Duration::from_millis(500));
assert_eq!(p.cap, Duration::from_secs(10));
assert_eq!(p.max_attempts, 4);
assert_eq!(p.total_timeout, Duration::from_secs(30));
}
#[test]
fn backoff_respects_cap() {
let p = RetryPolicy::default();
for attempt in 0..30u32 {
assert!(
p.backoff(attempt) <= p.cap,
"attempt {attempt} exceeded cap",
);
}
}
#[test]
fn backoff_zero_when_base_zero() {
let p = RetryPolicy {
base: Duration::ZERO,
cap: Duration::from_secs(10),
max_attempts: 4,
total_timeout: Duration::from_secs(30),
};
for attempt in 0..5 {
assert_eq!(p.backoff(attempt), Duration::ZERO);
}
}
}

View File

@ -12,6 +12,7 @@ use crate::llm_client::{
use serde::Deserialize;
use super::AnthropicScheme;
use super::scheme_impl::{AnthropicState, PendingThinking};
/// Anthropic SSEイベントタイプ
#[derive(Debug, Clone, PartialEq, Eq)]
@ -75,7 +76,21 @@ pub(crate) enum ContentBlock {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "thinking")]
Thinking { thinking: String },
Thinking {
#[serde(default)]
thinking: String,
/// 非ストリーミングレスポンス由来の初期 signature通常はストリームでは
/// 空 → `signature_delta` で埋まる)。
#[serde(default)]
signature: Option<String>,
},
#[serde(rename = "redacted_thinking")]
RedactedThinking {
/// 暗号化された opaque blob。signature ではなく、まるごと
/// `redacted_thinking.data` として送り返す必要がある。
#[serde(default)]
data: String,
},
#[serde(rename = "tool_use")]
ToolUse {
id: String,
@ -228,7 +243,9 @@ impl AnthropicScheme {
fn convert_block_start(&self, event: &ContentBlockStartEvent) -> Event {
let (block_type, metadata) = match &event.content_block {
ContentBlock::Text { .. } => (BlockType::Text, BlockMetadata::Text),
ContentBlock::Thinking { .. } => (BlockType::Thinking, BlockMetadata::Thinking),
ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => {
(BlockType::Thinking, BlockMetadata::Thinking)
}
ContentBlock::ToolUse { id, name, .. } => (
BlockType::ToolUse,
BlockMetadata::ToolUse {
@ -264,6 +281,123 @@ impl AnthropicScheme {
}))
}
/// state を持ち回す上位パース。
///
/// `parse_event` の単発 Event に加えて、以下を行う:
/// - `content_block_stop` の `block_type` を直前の Start 値で書き戻す
/// - `thinking` / `redacted_thinking` ブロックの本体・signature・data を
/// `state.pending_thinking` に蓄積し、`content_block_stop` で
/// `Event::ReasoningItem` を追加発火する
/// - `signature_delta` を蓄積Stream channel には流さず、reasoning event
/// にだけ反映する)
pub(crate) fn parse_with_state(
&self,
event_type: &str,
data: &str,
state: &mut AnthropicState,
) -> Result<Vec<Event>, ClientError> {
let Some(parsed_event_type) = AnthropicEventType::parse(event_type) else {
return Ok(Vec::new());
};
// signature_delta はストリーム表示には流さず、state にだけ蓄積。
// それ以外は parse_event で標準 Event 化する。
let mut emitted: Vec<Event> = Vec::new();
match parsed_event_type {
AnthropicEventType::ContentBlockStart => {
let raw: ContentBlockStartEvent = serde_json::from_str(data)?;
state.current_block_type = Some(match &raw.content_block {
ContentBlock::Text { .. } => BlockType::Text,
ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => {
BlockType::Thinking
}
ContentBlock::ToolUse { .. } => BlockType::ToolUse,
});
match &raw.content_block {
ContentBlock::Thinking {
thinking, signature,
} => {
state.pending_thinking = Some(PendingThinking {
text: thinking.clone(),
signature: signature.clone(),
redacted_data: None,
});
}
ContentBlock::RedactedThinking { data: blob } => {
state.pending_thinking = Some(PendingThinking {
text: String::new(),
signature: None,
redacted_data: Some(blob.clone()),
});
}
_ => {}
}
emitted.push(self.convert_block_start(&raw));
}
AnthropicEventType::ContentBlockDelta => {
let raw: ContentBlockDeltaEvent = serde_json::from_str(data)?;
match &raw.delta {
DeltaBlock::ThinkingDelta { thinking } => {
if let Some(pending) = state.pending_thinking.as_mut() {
pending.text.push_str(thinking);
}
emitted.push(Event::BlockDelta(BlockDelta {
index: raw.index,
delta: DeltaContent::Thinking(thinking.clone()),
}));
}
DeltaBlock::SignatureDelta { signature } => {
if let Some(pending) = state.pending_thinking.as_mut() {
// 通常 1 回しか来ないが、複数 fragment 来ても連結しておく
match &mut pending.signature {
Some(acc) => acc.push_str(signature),
None => pending.signature = Some(signature.clone()),
}
}
}
DeltaBlock::TextDelta { text } => {
emitted.push(Event::BlockDelta(BlockDelta {
index: raw.index,
delta: DeltaContent::Text(text.clone()),
}));
}
DeltaBlock::InputJsonDelta { partial_json } => {
emitted.push(Event::BlockDelta(BlockDelta {
index: raw.index,
delta: DeltaContent::InputJson(partial_json.clone()),
}));
}
}
}
AnthropicEventType::ContentBlockStop => {
let raw: ContentBlockStopEvent = serde_json::from_str(data)?;
let block_type = state
.current_block_type
.take()
.unwrap_or(BlockType::Text);
emitted.push(Event::BlockStop(BlockStop {
index: raw.index,
block_type,
stop_reason: None,
}));
if matches!(block_type, BlockType::Thinking) {
if let Some(pending) = state.pending_thinking.take() {
emitted.push(Event::ReasoningItem(pending.into_event()));
}
}
}
// 残りは state を必要としない。既存 parse_event に委譲。
_ => {
if let Some(event) = self.parse_event(event_type, data)? {
emitted.push(event);
}
}
}
Ok(emitted)
}
fn convert_usage(&self, usage: &UsageData) -> UsageEvent {
// Anthropic の `input_tokens` は **キャッシュ外** の入力トークンのみで、
// プロンプト全長は input_tokens + cache_read + cache_creation。
@ -391,6 +525,117 @@ mod tests {
}
}
#[test]
fn thinking_block_emits_reasoning_item_with_signature() {
// thinking ブロックが完了したら ReasoningItem に text+signature が乗ること
let scheme = AnthropicScheme::new();
let mut state = AnthropicState::default();
let evs = scheme
.parse_with_state(
"content_block_start",
r#"{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#,
&mut state,
)
.unwrap();
assert!(matches!(evs[0], Event::BlockStart(_)));
scheme
.parse_with_state(
"content_block_delta",
r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"hello "}}"#,
&mut state,
)
.unwrap();
scheme
.parse_with_state(
"content_block_delta",
r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"world"}}"#,
&mut state,
)
.unwrap();
scheme
.parse_with_state(
"content_block_delta",
r#"{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"SIG-XYZ"}}"#,
&mut state,
)
.unwrap();
let stop_evs = scheme
.parse_with_state(
"content_block_stop",
r#"{"type":"content_block_stop","index":0}"#,
&mut state,
)
.unwrap();
// BlockStop と ReasoningItem の 2 件が並ぶ
assert!(matches!(stop_evs[0], Event::BlockStop(_)));
let Event::ReasoningItem(reasoning) = &stop_evs[1] else {
panic!("expected ReasoningItem, got {:?}", stop_evs[1]);
};
assert_eq!(reasoning.text, "hello world");
assert_eq!(reasoning.signature.as_deref(), Some("SIG-XYZ"));
assert!(reasoning.encrypted_content.is_none());
}
#[test]
fn redacted_thinking_emits_reasoning_item_with_data() {
let scheme = AnthropicScheme::new();
let mut state = AnthropicState::default();
scheme
.parse_with_state(
"content_block_start",
r#"{"type":"content_block_start","index":0,"content_block":{"type":"redacted_thinking","data":"opaque-blob"}}"#,
&mut state,
)
.unwrap();
let stop_evs = scheme
.parse_with_state(
"content_block_stop",
r#"{"type":"content_block_stop","index":0}"#,
&mut state,
)
.unwrap();
let Event::ReasoningItem(reasoning) = &stop_evs[1] else {
panic!("expected ReasoningItem");
};
assert!(reasoning.text.is_empty());
assert!(reasoning.signature.is_none());
assert_eq!(reasoning.encrypted_content.as_deref(), Some("opaque-blob"));
}
#[test]
fn text_block_does_not_emit_reasoning_item() {
let scheme = AnthropicScheme::new();
let mut state = AnthropicState::default();
scheme
.parse_with_state(
"content_block_start",
r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#,
&mut state,
)
.unwrap();
scheme
.parse_with_state(
"content_block_delta",
r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}"#,
&mut state,
)
.unwrap();
let stop_evs = scheme
.parse_with_state(
"content_block_stop",
r#"{"type":"content_block_stop","index":0}"#,
&mut state,
)
.unwrap();
assert_eq!(stop_evs.len(), 1);
assert!(matches!(stop_evs[0], Event::BlockStop(_)));
}
#[test]
fn test_parse_ping() {
let scheme = AnthropicScheme::new();

View File

@ -77,6 +77,21 @@ pub(crate) enum AnthropicContentPart {
#[serde(skip_serializing_if = "Option::is_none")]
cache_control: Option<CacheControl>,
},
#[serde(rename = "thinking")]
Thinking {
thinking: String,
signature: String,
#[serde(skip_serializing_if = "Option::is_none")]
cache_control: Option<CacheControl>,
},
#[serde(rename = "redacted_thinking")]
RedactedThinking {
/// 暗号化済み reasoning blob。`Item::Reasoning::encrypted_content`
/// から渡る。
data: String,
#[serde(skip_serializing_if = "Option::is_none")]
cache_control: Option<CacheControl>,
},
#[serde(rename = "tool_use")]
ToolUse {
id: String,
@ -102,6 +117,21 @@ impl AnthropicContentPart {
}
}
fn thinking(thinking: String, signature: String) -> Self {
Self::Thinking {
thinking,
signature,
cache_control: None,
}
}
fn redacted_thinking(data: String) -> Self {
Self::RedactedThinking {
data,
cache_control: None,
}
}
fn tool_use(id: String, name: String, input: serde_json::Value) -> Self {
Self::ToolUse {
id,
@ -122,6 +152,8 @@ impl AnthropicContentPart {
fn set_cache_control(&mut self, cc: CacheControl) {
match self {
Self::Text { cache_control, .. }
| Self::Thinking { cache_control, .. }
| Self::RedactedThinking { cache_control, .. }
| Self::ToolUse { cache_control, .. }
| Self::ToolResult { cache_control, .. } => {
*cache_control = Some(cc);
@ -305,11 +337,33 @@ impl AnthropicScheme {
.push((i, AnthropicContentPart::tool_result(call_id.clone(), text)));
}
Item::Reasoning { text, .. } => {
Item::Reasoning {
text,
encrypted_content,
signature,
..
} => {
flush_pending(&mut messages, &mut pending_user, "user", &mut locations);
// Reasoning is treated as assistant text in Anthropic
// (actual thinking blocks are handled differently in streaming).
pending_assistant.push((i, AnthropicContentPart::text(text.clone())));
// Anthropic はアシスタントターン中の `thinking` /
// `redacted_thinking` ブロックを必ず assistant role の
// content_part として送り返す必要がある。
//
// - signature あり: `thinking` content_part を投影
// - signature 無し + encrypted_content あり:
// `redacted_thinking` content_part を投影
// - どちらも無い: 他 schemeOpenAI 等)から流入した
// 素の reasoning text。Anthropic に投げる意味も
// round-trip の根拠も無いので drop。
if let Some(sig) = signature.clone() {
pending_assistant.push((
i,
AnthropicContentPart::thinking(text.clone(), sig),
));
} else if let Some(data) = encrypted_content.clone() {
pending_assistant
.push((i, AnthropicContentPart::redacted_thinking(data)));
}
// どちらも None なら何も pend せず、本 item は無視。
}
}
}
@ -542,6 +596,8 @@ mod tests {
fn part_cache_control(part: &AnthropicContentPart) -> Option<CacheControl> {
match part {
AnthropicContentPart::Text { cache_control, .. }
| AnthropicContentPart::Thinking { cache_control, .. }
| AnthropicContentPart::RedactedThinking { cache_control, .. }
| AnthropicContentPart::ToolUse { cache_control, .. }
| AnthropicContentPart::ToolResult { cache_control, .. } => *cache_control,
}
@ -737,6 +793,163 @@ mod tests {
assert!(breakpoint_positions(&req).is_empty());
}
fn collect_assistant_thinking_parts(req: &AnthropicRequest) -> Vec<&AnthropicContentPart> {
let mut out = Vec::new();
for msg in &req.messages {
if msg.role != "assistant" {
continue;
}
if let AnthropicContent::Parts(parts) = &msg.content {
for part in parts {
if matches!(
part,
AnthropicContentPart::Thinking { .. }
| AnthropicContentPart::RedactedThinking { .. }
) {
out.push(part);
}
}
}
}
out
}
#[test]
fn reasoning_with_signature_projects_thinking_part() {
// Item::Reasoning に signature があれば assistant role の
// `thinking` content_part として送る。
let scheme = AnthropicScheme::new();
let request = Request::new()
.user("hi")
.item(Item::reasoning("step-by-step").with_signature("SIG-A"))
.item(Item::assistant_message("done"));
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
let thinking_parts = collect_assistant_thinking_parts(&req);
assert_eq!(thinking_parts.len(), 1);
match thinking_parts[0] {
AnthropicContentPart::Thinking {
thinking, signature, ..
} => {
assert_eq!(thinking, "step-by-step");
assert_eq!(signature, "SIG-A");
}
other => panic!("expected Thinking part, got {other:?}"),
}
}
#[test]
fn reasoning_with_only_encrypted_content_projects_redacted_thinking() {
let scheme = AnthropicScheme::new();
let request = Request::new()
.user("hi")
.item(Item::reasoning("").with_encrypted_content("opaque"))
.item(Item::assistant_message("done"));
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
let parts = collect_assistant_thinking_parts(&req);
assert_eq!(parts.len(), 1);
match parts[0] {
AnthropicContentPart::RedactedThinking { data, .. } => {
assert_eq!(data, "opaque");
}
other => panic!("expected RedactedThinking, got {other:?}"),
}
}
#[test]
fn reasoning_without_signature_or_encrypted_is_dropped() {
// 他 scheme から流入した素の reasoning は Anthropic に投げない。
let scheme = AnthropicScheme::new();
let request = Request::new()
.user("hi")
.item(Item::reasoning("plain text"))
.item(Item::assistant_message("done"));
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
// thinking part は 1 つも乗らない
assert!(collect_assistant_thinking_parts(&req).is_empty());
}
#[test]
fn thinking_part_lands_in_assistant_role_message() {
// wire 構造の position 検証: thinking part は assistant role の
// message 配列に並ぶuser role には絶対に入らない)。
let scheme = AnthropicScheme::new();
let request = Request::new()
.user("question?")
.item(Item::reasoning("thinking inside").with_signature("SIG-A"))
.item(Item::tool_call("c1", "tool_a", "{}"))
.item(Item::tool_result("c1", "result"))
.user("follow up");
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
// 全 thinking part が assistant role の message に存在すること
let mut thinking_msg_indices = Vec::new();
for (i, msg) in req.messages.iter().enumerate() {
if let AnthropicContent::Parts(parts) = &msg.content {
if parts
.iter()
.any(|p| matches!(p, AnthropicContentPart::Thinking { .. }))
{
assert_eq!(
msg.role, "assistant",
"thinking part must be in assistant role, got {} at msg {}",
msg.role, i,
);
thinking_msg_indices.push(i);
}
}
}
assert!(
!thinking_msg_indices.is_empty(),
"expected at least one thinking part in messages: {:?}",
req.messages,
);
// thinking part を含む assistant message は、それに続く tool_use を含む
// assistant message より前 (= 先頭側) に位置すること
// (Anthropic 仕様: 同一論理ターン内で thinking → tool_use の順)
let mut tool_use_msg_indices = Vec::new();
for (i, msg) in req.messages.iter().enumerate() {
if let AnthropicContent::Parts(parts) = &msg.content {
if parts
.iter()
.any(|p| matches!(p, AnthropicContentPart::ToolUse { .. }))
{
tool_use_msg_indices.push(i);
}
}
}
assert!(!tool_use_msg_indices.is_empty(), "expected tool_use part");
let first_thinking = thinking_msg_indices[0];
let first_tool_use = tool_use_msg_indices[0];
assert!(
first_thinking <= first_tool_use,
"thinking msg ({}) must precede tool_use msg ({})",
first_thinking,
first_tool_use,
);
}
#[test]
fn redacted_thinking_part_lands_in_assistant_role_message() {
// RedactedThinking も同様に assistant role に置かれること。
let scheme = AnthropicScheme::new();
let request = Request::new()
.user("ask")
.item(Item::reasoning("").with_encrypted_content("opaque"))
.item(Item::tool_call("c1", "tool_a", "{}"))
.item(Item::tool_result("c1", "ok"));
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
for msg in &req.messages {
if let AnthropicContent::Parts(parts) = &msg.content {
for part in parts {
if matches!(part, AnthropicContentPart::RedactedThinking { .. }) {
assert_eq!(msg.role, "assistant");
}
}
}
}
}
#[test]
fn tool_definitions_carry_no_cache_control() {
// Tool JSON schema must serialise unchanged — no sneak-in of

View File

@ -9,7 +9,7 @@ use crate::llm_client::{
ClientError,
auth::AuthRequirement,
capability::ModelCapability,
event::{BlockStop, BlockType, Event},
event::{BlockType, Event, ReasoningItemEvent},
scheme::Scheme,
types::Request,
};
@ -18,12 +18,37 @@ use super::AnthropicScheme;
/// Anthropic の SSE パースで必要な状態。
///
/// `content_block_stop` イベントは `block_type` を持たない仕様なので、
/// 1. `content_block_stop` イベントは `block_type` を持たない仕様なので、
/// 直前の `content_block_start` で観測した `block_type` を保持して
/// `BlockStop` に書き戻す。
/// 2. `thinking` ブロック中の `thinking_delta` テキストと `signature_delta`
/// 署名、および `redacted_thinking` ブロックの `data` を蓄積し、
/// `content_block_stop` で `Event::ReasoningItem` を発火する
/// round-trip 永続化のため)。
#[derive(Debug, Default)]
pub struct AnthropicState {
current_block_type: Option<BlockType>,
pub(crate) current_block_type: Option<BlockType>,
pub(crate) pending_thinking: Option<PendingThinking>,
}
/// 1 つの `thinking` または `redacted_thinking` content_block の蓄積バッファ。
#[derive(Debug, Default)]
pub(crate) struct PendingThinking {
pub(crate) text: String,
pub(crate) signature: Option<String>,
pub(crate) redacted_data: Option<String>,
}
impl PendingThinking {
pub(crate) fn into_event(self) -> ReasoningItemEvent {
ReasoningItemEvent {
id: None,
text: self.text,
summary: Vec::new(),
encrypted_content: self.redacted_data,
signature: self.signature,
}
}
}
impl Scheme for AnthropicScheme {
@ -73,24 +98,7 @@ impl Scheme for AnthropicScheme {
data: &str,
state: &mut Self::State,
) -> Result<Vec<Event>, ClientError> {
let Some(mut event) = self.parse_event(event_type, data)? else {
return Ok(Vec::new());
};
match &event {
Event::BlockStart(start) => {
state.current_block_type = Some(start.block_type);
}
Event::BlockStop(stop) => {
if let Some(block_type) = state.current_block_type.take() {
event = Event::BlockStop(BlockStop {
block_type,
..stop.clone()
});
}
}
_ => {}
}
Ok(vec![event])
self.parse_with_state(event_type, data, state)
}
fn default_capability(&self) -> ModelCapability {

View File

@ -13,7 +13,7 @@ use crate::llm_client::{
ClientError,
event::{
BlockDelta, BlockMetadata, BlockStart, BlockStop, BlockType, DeltaContent, ErrorEvent,
Event, ResponseStatus, StatusEvent, UsageEvent,
Event, ReasoningItemEvent, ResponseStatus, StatusEvent, UsageEvent,
},
};
@ -22,6 +22,21 @@ use crate::llm_client::{
pub struct OpenAIResponsesState {
slots: HashMap<SlotKey, SlotInfo>,
next_index: usize,
/// 蓄積中の reasoning output_item。`output_item.added`(Reasoning) で
/// 確保し、`reasoning_text.delta` / `reasoning_summary_text.delta` で
/// 蓄積、`output_item.done`(Reasoning) で `Event::ReasoningItem` を
/// 発火してエントリを除去する。
pending_reasoning: HashMap<usize, PendingReasoning>,
}
/// 1 つの reasoning output_item の蓄積バッファ。
#[derive(Debug, Default)]
struct PendingReasoning {
id: Option<String>,
/// `reasoning_text.delta` の累積。複数 content_part あれば順に concat。
text: String,
/// `reasoning_summary_text.delta` を summary_index 順に蓄積。
summary: Vec<String>,
}
impl OpenAIResponsesState {
@ -45,6 +60,18 @@ impl OpenAIResponsesState {
(self.allocate(key, block_type), true)
}
}
fn ensure_reasoning(&mut self, output_index: usize) -> &mut PendingReasoning {
self.pending_reasoning.entry(output_index).or_default()
}
fn extend_reasoning_summary(&mut self, output_index: usize, summary_index: usize, text: &str) {
let entry = self.ensure_reasoning(output_index);
if entry.summary.len() <= summary_index {
entry.summary.resize(summary_index + 1, String::new());
}
entry.summary[summary_index].push_str(text);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@ -89,8 +116,12 @@ enum OutputItem {
id: Option<String>,
},
Reasoning {
#[allow(dead_code)]
#[serde(default)]
id: Option<String>,
/// `output_item.done` で初めて埋まる。`include=["reasoning.encrypted_content"]`
/// 指定時に opaque blob が乗る。
#[serde(default)]
encrypted_content: Option<String>,
},
FunctionCall {
#[allow(dead_code)]
@ -319,12 +350,49 @@ pub(crate) fn parse_sse(
metadata: BlockMetadata::ToolUse { id: call_id, name },
})])
}
OutputItem::Reasoning { id, .. } => {
// wrapper を確保。中身の content_part / summary_part は
// 別 SlotKey で扱われ続けるStreaming 表示は維持)。
let entry = state.ensure_reasoning(ev.output_index);
if id.is_some() {
entry.id = id;
}
Ok(Vec::new())
}
_ => Ok(Vec::new()),
}
}
"response.output_item.done" => {
let ev: OutputItemDone = from_json(data)?;
// Reasoning wrapper の done で蓄積分を ReasoningItem として発火。
// これは `slots` の OutputItem slot とは独立している
// (FunctionCall は slots、Reasoning は pending_reasoning)。
if let OutputItem::Reasoning {
id,
encrypted_content,
..
} = ev.item
{
let mut pending = state
.pending_reasoning
.remove(&ev.output_index)
.unwrap_or_default();
if pending.id.is_none() {
pending.id = id;
}
return Ok(vec![Event::ReasoningItem(ReasoningItemEvent {
id: pending.id,
text: pending.text,
summary: pending
.summary
.into_iter()
.filter(|s| !s.is_empty())
.collect(),
encrypted_content,
signature: None,
})]);
}
if let Some(info) = state.slots.remove(&SlotKey::OutputItem(ev.output_index)) {
Ok(vec![Event::BlockStop(BlockStop {
index: info.flat_index,
@ -389,6 +457,8 @@ pub(crate) fn parse_sse(
"response.reasoning_text.delta" => {
let ev: ReasoningTextDelta = from_json(data)?;
// round-trip 用に蓄積
state.ensure_reasoning(ev.output_index).text.push_str(&ev.delta);
Ok(ensure_and_delta(
state,
SlotKey::ContentPart {
@ -419,6 +489,8 @@ pub(crate) fn parse_sse(
"response.reasoning_summary_text.delta" => {
let ev: ReasoningSummaryTextDelta = from_json(data)?;
// round-trip 用に蓄積
state.extend_reasoning_summary(ev.output_index, ev.summary_index, &ev.delta);
Ok(ensure_and_delta(
state,
SlotKey::Summary {
@ -797,6 +869,98 @@ mod tests {
));
}
#[test]
fn reasoning_output_item_emits_reasoning_item_with_text_summary_encrypted() {
// 完成済み reasoning wrapper が text + summary[] + encrypted_content を持って
// ReasoningItem として届くこと。
let mut state = OpenAIResponsesState::default();
// wrapper added (id だけ持つ)
with(
&mut state,
"response.output_item.added",
r#"{"output_index":0,"item":{"type":"reasoning","id":"r1"}}"#,
);
// 内側の reasoning_text 用 content_part
with(
&mut state,
"response.content_part.added",
r#"{"output_index":0,"content_index":0,"item_id":"r1","part":{"type":"reasoning_text","text":""}}"#,
);
with(
&mut state,
"response.reasoning_text.delta",
r#"{"output_index":0,"content_index":0,"item_id":"r1","delta":"hello "}"#,
);
with(
&mut state,
"response.reasoning_text.delta",
r#"{"output_index":0,"content_index":0,"item_id":"r1","delta":"world"}"#,
);
with(
&mut state,
"response.content_part.done",
r#"{"output_index":0,"content_index":0,"item_id":"r1","part":{"type":"reasoning_text","text":"hello world"}}"#,
);
// summary 1 件
with(
&mut state,
"response.reasoning_summary_part.added",
r#"{"output_index":0,"summary_index":0,"item_id":"r1","part":{"type":"summary_text","text":""}}"#,
);
with(
&mut state,
"response.reasoning_summary_text.delta",
r#"{"output_index":0,"summary_index":0,"item_id":"r1","delta":"sum-A"}"#,
);
with(
&mut state,
"response.reasoning_summary_part.done",
r#"{"output_index":0,"summary_index":0,"item_id":"r1"}"#,
);
// wrapper done (encrypted_content が乗る)
let evs = with(
&mut state,
"response.output_item.done",
r#"{"output_index":0,"item":{"type":"reasoning","id":"r1","encrypted_content":"ENC-XYZ"}}"#,
);
assert_eq!(evs.len(), 1);
let Event::ReasoningItem(reasoning) = &evs[0] else {
panic!("expected ReasoningItem, got {:?}", evs[0]);
};
assert_eq!(reasoning.id.as_deref(), Some("r1"));
assert_eq!(reasoning.text, "hello world");
assert_eq!(reasoning.summary, vec!["sum-A".to_string()]);
assert_eq!(reasoning.encrypted_content.as_deref(), Some("ENC-XYZ"));
assert!(reasoning.signature.is_none());
// pending_reasoning は drain されていること
assert!(state.pending_reasoning.is_empty());
}
#[test]
fn reasoning_wrapper_without_inner_content_emits_empty_text() {
// encrypted_content だけ届くreasoning_text 無し)ケースでも
// ReasoningItem は発火する。
let mut state = OpenAIResponsesState::default();
with(
&mut state,
"response.output_item.added",
r#"{"output_index":2,"item":{"type":"reasoning","id":"r9"}}"#,
);
let evs = with(
&mut state,
"response.output_item.done",
r#"{"output_index":2,"item":{"type":"reasoning","id":"r9","encrypted_content":"BLOB"}}"#,
);
let Event::ReasoningItem(r) = &evs[0] else {
panic!()
};
assert!(r.text.is_empty());
assert!(r.summary.is_empty());
assert_eq!(r.encrypted_content.as_deref(), Some("BLOB"));
}
#[test]
fn unknown_event_is_ignored() {
let (events, _) = run("response.in_progress", "{}");

View File

@ -100,7 +100,11 @@ pub(crate) enum InputItem {
Reasoning {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
/// Responses API は reasoning item に `summary` フィールドを必須で
/// 要求する(中身が空でも `[]` として送る必要がある。GPT-5 など
/// summary を返さないモデル + reasoning effort 指定なしのターンでは
/// summary text が一切付かないので、ここを skip すると 400
/// "Missing required parameter: 'input[N].summary'" で弾かれる。
summary: Vec<ReasoningSummaryPart>,
#[serde(skip_serializing_if = "Vec::is_empty")]
content: Vec<ReasoningContentPart>,
@ -473,6 +477,29 @@ mod tests {
}
}
#[test]
fn reasoning_summary_field_is_always_serialized() {
// Responses API は reasoning item に `summary` を必須で要求する。
// summary が空でも wire 上に `summary: []` として残らないと、
// ChatGPT backend (codex-oauth) が
// 400 invalid_request_error: Missing required parameter:
// 'input[N].summary'.
// で弾く。GPT-5 + reasoning effort 未指定のターンでは summary text
// が付かないことがあるため、空のままでも skip しないこと。
let scheme = OpenAIResponsesScheme::new();
let item = Item::reasoning("").with_encrypted_content("ENC");
let req = Request::new().user("hi").item(item);
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
let json = serde_json::to_value(&body).unwrap();
let reasoning_item = &json["input"][1];
assert_eq!(reasoning_item["type"], "reasoning");
assert!(
reasoning_item.get("summary").is_some(),
"summary key must be present even when empty, got: {reasoning_item}"
);
assert_eq!(reasoning_item["summary"], serde_json::json!([]));
}
#[test]
fn reasoning_effort_projected_when_supported() {
let scheme = OpenAIResponsesScheme::new();

View File

@ -6,17 +6,21 @@
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::{Stream, StreamExt, TryStreamExt};
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue, RETRY_AFTER};
use tokio::time::Instant;
use tracing::warn;
use super::auth::{AuthProvider, AuthRequirement};
use super::capability::ModelCapability;
use super::client::{ConfigWarning, LlmClient};
use super::error::ClientError;
use super::error::{ClientError, is_retryable};
use super::event::Event;
use super::retry::RetryPolicy;
use super::scheme::Scheme;
use super::types::{Request, RequestConfig};
@ -63,6 +67,7 @@ pub struct HttpTransport<S: Scheme> {
base_url: String,
auth: ResolvedAuth,
capability: ModelCapability,
retry_policy: RetryPolicy,
}
impl<S: Scheme> HttpTransport<S> {
@ -84,6 +89,7 @@ impl<S: Scheme> HttpTransport<S> {
base_url,
auth,
capability,
retry_policy: RetryPolicy::default(),
}
}
@ -93,6 +99,12 @@ impl<S: Scheme> HttpTransport<S> {
self
}
/// リトライポリシーを差し替える(テスト用 / 将来の manifest 化フック)。
pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
self.retry_policy = policy;
self
}
fn build_url(&self) -> String {
let path = self.scheme.path(&self.model_id);
let url = format!("{}{}", self.base_url, path);
@ -159,10 +171,45 @@ impl<S: Scheme + Clone> Clone for HttpTransport<S> {
base_url: self.base_url.clone(),
auth: self.auth.clone(),
capability: self.capability.clone(),
retry_policy: self.retry_policy.clone(),
}
}
}
/// エラーレスポンスを `ClientError::Api` に変換し、`Retry-After` の秒数を
/// 同時に取り出す。リトライループで wait の上書きに使う。
async fn classify_error_response(resp: reqwest::Response) -> (ClientError, Option<Duration>) {
let status = resp.status().as_u16();
let retry_after = resp
.headers()
.get(RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.trim().parse::<u64>().ok())
.map(Duration::from_secs);
let text = resp.text().await.unwrap_or_default();
let err = if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
let error = json.get("error").unwrap_or(&json);
let code = error.get("type").and_then(|v| v.as_str()).map(String::from);
let message = error
.get("message")
.and_then(|v| v.as_str())
.unwrap_or(&text)
.to_string();
ClientError::Api {
status: Some(status),
code,
message,
}
} else {
ClientError::Api {
status: Some(status),
code: None,
message: text,
}
};
(err, retry_after)
}
#[async_trait]
impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
@ -183,37 +230,41 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
.scheme
.build_request_body(&self.model_id, &request, &self.capability);
let response = self
let policy = &self.retry_policy;
let started = Instant::now();
let mut attempt: u32 = 0;
let response = loop {
let send_result = self
.http_client
.post(&url)
.headers(headers)
.headers(headers.clone())
.json(&body)
.send()
.await?;
.await;
if !response.status().is_success() {
let status = response.status().as_u16();
let text = response.text().await.unwrap_or_default();
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
let error = json.get("error").unwrap_or(&json);
let code = error.get("type").and_then(|v| v.as_str()).map(String::from);
let message = error
.get("message")
.and_then(|v| v.as_str())
.unwrap_or(&text)
.to_string();
return Err(ClientError::Api {
status: Some(status),
code,
message,
});
let (err, retry_after) = match send_result {
Ok(resp) if resp.status().is_success() => break resp,
Ok(resp) => classify_error_response(resp).await,
Err(e) => (ClientError::Http(e), None),
};
let next_attempt = attempt + 1;
if next_attempt >= policy.max_attempts || !is_retryable(&err) {
return Err(err);
}
return Err(ClientError::Api {
status: Some(status),
code: None,
message: text,
});
let wait = retry_after.unwrap_or_else(|| policy.backoff(attempt));
if started.elapsed() + wait > policy.total_timeout {
return Err(err);
}
warn!(
error = %err,
attempt = next_attempt,
wait_ms = wait.as_millis() as u64,
"transient HTTP error, retrying"
);
tokio::time::sleep(wait).await;
attempt = next_attempt;
};
let scheme = self.scheme.clone();
let byte_stream = response.bytes_stream().map_err(std::io::Error::other);

View File

@ -94,8 +94,15 @@ pub enum Item {
summary: Vec<String>,
/// サーバから返された暗号化済み reasoning blob。ZDR / `store=false`
/// 運用で stateless に再送するときそのまま添える必要がある。
/// Anthropic の `redacted_thinking.data` もここに格納する。
#[serde(default, skip_serializing_if = "Option::is_none")]
encrypted_content: Option<String>,
/// Anthropic extended thinking の `signature`。新世代 Claude
/// (Opus 4.5+/Sonnet 4.6+) では同一論理ターン内の `thinking`
/// ブロックを送り返す際に必須。改ざん検知に使われる。他 scheme
/// では `None`。
#[serde(default, skip_serializing_if = "Option::is_none")]
signature: Option<String>,
/// Item status
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<ItemStatus>,
@ -224,6 +231,7 @@ impl Item {
text: text.into(),
summary: Vec::new(),
encrypted_content: None,
signature: None,
status: None,
}
}
@ -247,6 +255,14 @@ impl Item {
self
}
/// Set Anthropic `signature` on a `Reasoning` item. No-op on other variants.
pub fn with_signature(mut self, sig: impl Into<String>) -> Self {
if let Self::Reasoning { signature, .. } = &mut self {
*signature = Some(sig.into());
}
self
}
// ========================================================================
// Builder methods
// ========================================================================

View File

@ -10,12 +10,14 @@
//! - [`ToolCallCollector`] - ツール呼び出しを収集するHandler
pub mod event;
mod reasoning_item_collector;
mod text_block_collector;
mod timeline;
mod tool_call_collector;
// 公開API
pub use event::*;
pub use reasoning_item_collector::ReasoningItemCollector;
pub use text_block_collector::TextBlockCollector;
pub use timeline::Timeline;
pub use tool_call_collector::ToolCallCollector;
@ -28,6 +30,7 @@ pub use crate::handler::{
Handler,
Kind,
PingKind,
ReasoningItemKind,
StatusKind,
// Block Events
TextBlockEvent,

View File

@ -0,0 +1,77 @@
//! `ReasoningItemCollector` - 完成済み reasoning item を収集する Handler
//!
//! Timeline の `ReasoningItemKind` Handler として登録し、scheme 側が
//! `Event::ReasoningItem` を発火するたびに 1 件ずつバッファに溜める。
//! Worker はターン終了時に `take_collected()` でドレインして
//! `Item::Reasoning` として `worker.history` に append する。
use std::sync::{Arc, Mutex};
use crate::handler::{Handler, ReasoningItemKind};
use crate::llm_client::event::ReasoningItemEvent;
/// 収集された reasoning item の連列。
#[derive(Clone, Default)]
pub struct ReasoningItemCollector {
collected: Arc<Mutex<Vec<ReasoningItemEvent>>>,
}
impl ReasoningItemCollector {
pub fn new() -> Self {
Self::default()
}
/// 収集済み item を取り出してクリア
pub fn take_collected(&self) -> Vec<ReasoningItemEvent> {
let mut guard = self.collected.lock().unwrap();
std::mem::take(&mut *guard)
}
/// 収集をクリア
pub fn clear(&self) {
self.collected.lock().unwrap().clear();
}
}
impl Handler<ReasoningItemKind> for ReasoningItemCollector {
type Scope = ();
fn on_event(&mut self, _scope: &mut Self::Scope, event: &ReasoningItemEvent) {
self.collected.lock().unwrap().push(event.clone());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm_client::event::Event;
use crate::timeline::Timeline;
#[test]
fn collects_in_order() {
let collector = ReasoningItemCollector::new();
let mut timeline = Timeline::new();
timeline.on_reasoning_item(collector.clone());
timeline.dispatch(&Event::ReasoningItem(ReasoningItemEvent {
id: Some("r1".into()),
text: "first".into(),
signature: Some("sig1".into()),
..Default::default()
}));
timeline.dispatch(&Event::ReasoningItem(ReasoningItemEvent {
id: Some("r2".into()),
text: "second".into(),
..Default::default()
}));
let items = collector.take_collected();
assert_eq!(items.len(), 2);
assert_eq!(items[0].text, "first");
assert_eq!(items[0].signature.as_deref(), Some("sig1"));
assert_eq!(items[1].text, "second");
// take は drain なので 2 度目は空
assert!(collector.take_collected().is_empty());
}
}

View File

@ -381,6 +381,7 @@ pub struct Timeline {
ping_handlers: Vec<Box<dyn ErasedHandler<PingKind>>>,
status_handlers: Vec<Box<dyn ErasedHandler<StatusKind>>>,
error_handlers: Vec<Box<dyn ErasedHandler<ErrorKind>>>,
reasoning_item_handlers: Vec<Box<dyn ErasedHandler<ReasoningItemKind>>>,
// Block系ハンドラーBlockTypeごとにグループ化
text_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
@ -410,6 +411,7 @@ impl Timeline {
ping_handlers: Vec::new(),
status_handlers: Vec::new(),
error_handlers: Vec::new(),
reasoning_item_handlers: Vec::new(),
text_block_handlers: Vec::new(),
thinking_block_handlers: Vec::new(),
tool_use_block_handlers: Vec::new(),
@ -471,6 +473,18 @@ impl Timeline {
self
}
/// `ReasoningItemKind` 用 Handler を登録
pub fn on_reasoning_item<H>(&mut self, handler: H) -> &mut Self
where
H: Handler<ReasoningItemKind> + Send + Sync + 'static,
H::Scope: Send + Sync,
{
let mut wrapper = HandlerWrapper::new(handler);
wrapper.start_scope();
self.reasoning_item_handlers.push(Box::new(wrapper));
self
}
/// TextBlockKind用のHandlerを登録
pub fn on_text_block<H>(&mut self, handler: H) -> &mut Self
where
@ -522,6 +536,9 @@ impl Timeline {
Event::BlockDelta(d) => self.handle_block_delta(d),
Event::BlockStop(s) => self.handle_block_stop(s),
Event::BlockAbort(a) => self.handle_block_abort(a),
// 完成済み reasoning item: 即時ディスパッチ
Event::ReasoningItem(r) => self.dispatch_reasoning_item(r),
}
}
@ -564,6 +581,12 @@ impl Timeline {
}
}
fn dispatch_reasoning_item(&mut self, event: &ReasoningItemEvent) {
for handler in &mut self.reasoning_item_handlers {
handler.dispatch(event);
}
}
fn handle_block_start(&mut self, start: &BlockStart) {
self.current_block = Some(start.block_type);

View File

@ -22,7 +22,7 @@ use crate::{
},
state::{Locked, Mutable, WorkerState},
timeline::event::{ErrorEvent, StatusEvent, UsageEvent},
timeline::{TextBlockCollector, Timeline, ToolCallCollector},
timeline::{ReasoningItemCollector, TextBlockCollector, Timeline, ToolCallCollector},
tool::{
ToolCall, ToolDefinition as WorkerToolDefinition, ToolError, ToolOutputLimits, ToolResult,
truncate_content,
@ -140,6 +140,9 @@ pub struct Worker<C: LlmClient, S: WorkerState = Mutable> {
text_block_collector: TextBlockCollector,
/// Tool call collector (Timeline handler)
tool_call_collector: ToolCallCollector,
/// Reasoning item collector (Timeline handler)。完成済み reasoning
/// item を 1 ターン分バッファし、history に append する。
reasoning_item_collector: ReasoningItemCollector,
/// Tool server handle
tool_server: ToolServerHandle,
/// Interceptor for control-flow decisions
@ -168,6 +171,10 @@ pub struct Worker<C: LlmClient, S: WorkerState = Mutable> {
/// truncation have been applied — i.e. on the same data that
/// enters history.
tool_result_cbs: Vec<Box<dyn Fn(&ToolResult) + Send + Sync>>,
/// History-append callbacks. Invoked for non-streamed items when they
/// are appended to persistent worker history, so upper layers can
/// broadcast those items using history itself as the source of truth.
history_append_cbs: Vec<Box<dyn Fn(&Item) + Send + Sync>>,
/// Request configuration (max_tokens, temperature, etc.)
request_config: RequestConfig,
/// Whether the previous run was interrupted
@ -346,6 +353,25 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
}
}
/// Register a callback invoked for items appended directly to worker
/// history outside streaming timeline callbacks.
pub fn on_history_append(&mut self, callback: impl Fn(&Item) + Send + Sync + 'static) {
self.history_append_cbs.push(Box::new(callback));
}
fn emit_history_append(&self, item: &Item) {
for cb in &self.history_append_cbs {
cb(item);
}
}
fn extend_history_with_callbacks(&mut self, items: impl IntoIterator<Item = Item>) {
for item in items {
self.emit_history_append(&item);
self.history.push(item);
}
}
/// Register a turn-end callback (receives 0-based turn number).
pub fn on_turn_end(&mut self, callback: impl Fn(usize) + Send + Sync + 'static) {
self.turn_end_cbs.push(Box::new(callback));
@ -564,10 +590,37 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
self.tool_server.tool_definitions_sorted()
}
/// Build assistant response items from text blocks and tool calls
fn build_assistant_items(&self, text_blocks: &[String], tool_calls: &[ToolCall]) -> Vec<Item> {
/// Build assistant response items from reasoning items, text blocks, and tool calls.
///
/// Reasoning items come first (Anthropic / OpenAI Responses 双方ともに
/// アシスタント応答内で reasoning は先頭に並ぶ仕様)。これは Anthropic
/// が新世代モデルで thinking ブロックを assistant メッセージの先頭に
/// 置くことを要求するためでもある。
fn build_assistant_items(
&self,
reasoning_items: &[crate::llm_client::event::ReasoningItemEvent],
text_blocks: &[String],
tool_calls: &[ToolCall],
) -> Vec<Item> {
let mut items = Vec::new();
for r in reasoning_items {
let mut item = Item::reasoning(r.text.clone());
if let Some(id) = &r.id {
item = item.with_id(id);
}
if !r.summary.is_empty() {
item = item.with_reasoning_summary(r.summary.clone());
}
if let Some(enc) = &r.encrypted_content {
item = item.with_encrypted_content(enc);
}
if let Some(sig) = &r.signature {
item = item.with_signature(sig);
}
items.push(item);
}
// Add text as assistant message if present
let text = text_blocks.join("");
if !text.is_empty() {
@ -863,7 +916,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
// get persisted by the upper layer that owns history.json.
let pending = self.interceptor.pending_history_appends().await;
if !pending.is_empty() {
self.history.extend(pending);
self.extend_history_with_callbacks(pending);
}
// Clone the history into a per-request context. Everything
@ -950,9 +1003,11 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
self.turn_count += 1;
// Collect and commit assistant items
let reasoning_items = self.reasoning_item_collector.take_collected();
let text_blocks = self.text_block_collector.take_collected();
let tool_calls = self.tool_call_collector.take_collected();
let assistant_items = self.build_assistant_items(&text_blocks, &tool_calls);
let assistant_items =
self.build_assistant_items(&reasoning_items, &text_blocks, &tool_calls);
self.history.extend(assistant_items);
if tool_calls.is_empty() {
@ -962,7 +1017,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
return Ok(WorkerResult::Finished);
}
TurnEndAction::ContinueWithMessages(additional) => {
self.history.extend(additional);
self.extend_history_with_callbacks(additional);
continue;
}
TurnEndAction::Pause => {
@ -1095,18 +1150,21 @@ impl<C: LlmClient> Worker<C, Mutable> {
pub fn new(client: C) -> Self {
let text_block_collector = TextBlockCollector::new();
let tool_call_collector = ToolCallCollector::new();
let reasoning_item_collector = ReasoningItemCollector::new();
let mut timeline = Timeline::new();
let (cancel_tx, cancel_rx) = mpsc::channel(1);
// Register collectors with Timeline
timeline.on_text_block(text_block_collector.clone());
timeline.on_tool_use_block(tool_call_collector.clone());
timeline.on_reasoning_item(reasoning_item_collector.clone());
Self {
client,
timeline,
text_block_collector,
tool_call_collector,
reasoning_item_collector,
tool_server: ToolServer::new().handle(),
interceptor: Box::new(DefaultInterceptor),
system_prompt: None,
@ -1118,6 +1176,7 @@ impl<C: LlmClient> Worker<C, Mutable> {
turn_end_cbs: Vec::new(),
warning_cbs: Vec::new(),
tool_result_cbs: Vec::new(),
history_append_cbs: Vec::new(),
request_config: RequestConfig::default(),
last_run_interrupted: false,
cancel_tx,
@ -1364,6 +1423,7 @@ impl<C: LlmClient> Worker<C, Mutable> {
timeline: self.timeline,
text_block_collector: self.text_block_collector,
tool_call_collector: self.tool_call_collector,
reasoning_item_collector: self.reasoning_item_collector,
tool_server: self.tool_server,
interceptor: self.interceptor,
system_prompt: self.system_prompt,
@ -1375,6 +1435,7 @@ impl<C: LlmClient> Worker<C, Mutable> {
turn_end_cbs: self.turn_end_cbs,
warning_cbs: self.warning_cbs,
tool_result_cbs: self.tool_result_cbs,
history_append_cbs: self.history_append_cbs,
request_config: self.request_config,
last_run_interrupted: self.last_run_interrupted,
@ -1415,7 +1476,7 @@ impl<C: LlmClient> Worker<C, Locked> {
};
self.history.push(user_item);
if !extras.is_empty() {
self.history.extend(extras);
self.extend_history_with_callbacks(extras);
}
let result = self.run_turn_loop().await;
self.finalize_interruption(result).await
@ -1445,6 +1506,7 @@ impl<C: LlmClient> Worker<C, Locked> {
timeline: self.timeline,
text_block_collector: self.text_block_collector,
tool_call_collector: self.tool_call_collector,
reasoning_item_collector: self.reasoning_item_collector,
tool_server: self.tool_server,
interceptor: self.interceptor,
system_prompt: self.system_prompt,
@ -1456,6 +1518,7 @@ impl<C: LlmClient> Worker<C, Locked> {
turn_end_cbs: self.turn_end_cbs,
warning_cbs: self.warning_cbs,
tool_result_cbs: self.tool_result_cbs,
history_append_cbs: self.history_append_cbs,
request_config: self.request_config,
last_run_interrupted: self.last_run_interrupted,

View File

@ -0,0 +1,212 @@
//! Reasoning history round-trip 統合テスト
//!
//! Worker のストリーム → history append → 次リクエスト送出までの
//! ライフサイクルで `Item::Reasoning` が脱落せず保持されることを確認する。
//!
//! 検証点:
//! - Anthropic 由来の thinking + signature が `Item::Reasoning::signature` として
//! history に残る
//! - OpenAI Responses 由来の reasoning text + summary + encrypted_content が
//! `Item::Reasoning` の各フィールドに展開される
//! - 直前の reasoning は次の outgoing request の `request.items` の先頭付近に
//! 含まれるassistant メッセージの先頭、Anthropic 仕様)
mod common;
use common::MockLlmClient;
use llm_worker::Item;
use llm_worker::Worker;
use llm_worker::llm_client::event::{
Event, ReasoningItemEvent, ResponseStatus, StatusEvent,
};
/// Anthropic 風: thinking ブロック → text → 終了 のシーケンス。
/// Worker history に Reasoning(signature 付き) → assistant_message が並ぶ。
#[tokio::test]
async fn anthropic_thinking_round_trips_signature_into_history() {
let events = vec![
Event::ReasoningItem(ReasoningItemEvent {
id: None,
text: "let me think...".into(),
summary: Vec::new(),
encrypted_content: None,
signature: Some("SIG-OPUS".into()),
}),
Event::text_block_start(0),
Event::text_delta(0, "Here's the answer"),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let client = MockLlmClient::new(events);
let worker = Worker::new(client);
let out = worker.run("question?").await.expect("run ok");
let worker = out.worker;
let history = worker.history();
// user / reasoning / assistant_message
assert_eq!(history.len(), 3, "history: {history:?}");
assert!(matches!(history[0], Item::Message { .. }));
match &history[1] {
Item::Reasoning {
text, signature, ..
} => {
assert_eq!(text, "let me think...");
assert_eq!(signature.as_deref(), Some("SIG-OPUS"));
}
other => panic!("expected Reasoning, got {other:?}"),
}
assert_eq!(history[2].as_text(), Some("Here's the answer"));
}
/// OpenAI Responses 風: encrypted_content + summary を持った reasoning が
/// `Item::Reasoning` のフィールドに展開されること。
#[tokio::test]
async fn openai_reasoning_round_trips_encrypted_and_summary() {
let events = vec![
Event::ReasoningItem(ReasoningItemEvent {
id: Some("r1".into()),
text: "inner reasoning".into(),
summary: vec!["sum-A".into(), "sum-B".into()],
encrypted_content: Some("ENC-OPAQUE".into()),
signature: None,
}),
Event::text_block_start(0),
Event::text_delta(0, "answer"),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let client = MockLlmClient::new(events);
let worker = Worker::new(client);
let out = worker.run("q").await.expect("run ok");
let worker = out.worker;
let history = worker.history();
match &history[1] {
Item::Reasoning {
text,
summary,
encrypted_content,
signature,
id,
..
} => {
assert_eq!(text, "inner reasoning");
assert_eq!(summary, &vec!["sum-A".to_string(), "sum-B".to_string()]);
assert_eq!(encrypted_content.as_deref(), Some("ENC-OPAQUE"));
assert!(signature.is_none());
assert_eq!(id.as_deref(), Some("r1"));
}
other => panic!("expected Reasoning, got {other:?}"),
}
}
/// Reasoning は assistant ターン内で text/tool_call より先に並ぶことAnthropic
/// が thinking を assistant メッセージの先頭に要求するため)。
#[tokio::test]
async fn reasoning_precedes_text_in_assistant_burst() {
let events = vec![
// text/tool_call とは独立に、ReasoningItem が中盤で発火しても、
// history append 時には assistant items の先頭に置かれる。
Event::text_block_start(0),
Event::text_delta(0, "intermediate"),
Event::text_block_stop(0, None),
Event::ReasoningItem(ReasoningItemEvent {
text: "after text".into(),
signature: Some("SIG".into()),
..Default::default()
}),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let client = MockLlmClient::new(events);
let worker = Worker::new(client);
let out = worker.run("q").await.expect("run ok");
let worker = out.worker;
let history = worker.history();
// user / reasoning(先頭) / assistant_message
assert!(matches!(history[1], Item::Reasoning { .. }));
assert_eq!(history[2].as_text(), Some("intermediate"));
}
/// resume シナリオ: history.json 由来の Item::Reasoning(signature) を Worker に
/// 注入して run しても、次の outgoing request の `Request::items` にそのまま
/// 載って LLM へ渡るworker は items を改変しない契約)。
#[tokio::test]
async fn injected_reasoning_survives_into_outgoing_request() {
use async_trait::async_trait;
use futures::Stream;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use llm_worker::llm_client::{ClientError, LlmClient, Request};
/// Request を 1 度だけキャプチャして空ストリームを返す client。
#[derive(Clone)]
struct CapturingClient {
captured: Arc<Mutex<Option<Request>>>,
}
#[async_trait]
impl LlmClient for CapturingClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError>
{
*self.captured.lock().unwrap() = Some(request);
let stream = futures::stream::iter(vec![Ok(Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}))]);
Ok(Box::pin(stream))
}
}
let captured = Arc::new(Mutex::new(None));
let client = CapturingClient {
captured: captured.clone(),
};
let mut worker = Worker::new(client);
// resume: 既存 history を流し込む
worker.set_history(vec![
Item::user_message("prior question"),
Item::reasoning("prior thinking").with_signature("SIG-PRIOR"),
Item::assistant_message("prior answer"),
]);
let _ = worker.run("follow up").await.expect("run ok");
let req = captured
.lock()
.unwrap()
.take()
.expect("client should have received a request");
// Reasoning item が outgoing items に保持されていること
let mut found = false;
for item in &req.items {
if let Item::Reasoning {
text, signature, ..
} = item
{
assert_eq!(text, "prior thinking");
assert_eq!(signature.as_deref(), Some("SIG-PRIOR"));
found = true;
}
}
assert!(
found,
"Reasoning item must survive into outgoing request items: {req:?}",
req = req.items,
);
}

View File

@ -0,0 +1,251 @@
//! HTTP transport の transient エラーリトライ挙動の integration テスト。
//!
//! 対応チケット: `tickets/llm-worker-transient-retry.md`。
//! - 503 / 529 / connect refused でリトライ発火
//! - max_attempts 上限到達でエラー
//! - `Retry-After` ヘッダで指数バックオフを上書き
//! - `parse_sse` 由来の `ClientError::Sse`mid-stream 想定)はリトライしない
use std::time::{Duration, Instant};
use futures::StreamExt;
use llm_worker::llm_client::LlmClient;
use llm_worker::llm_client::auth::AuthRequirement;
use llm_worker::llm_client::capability::ModelCapability;
use llm_worker::llm_client::error::ClientError;
use llm_worker::llm_client::event::Event;
use llm_worker::llm_client::retry::RetryPolicy;
use llm_worker::llm_client::scheme::Scheme;
use llm_worker::llm_client::transport::{HttpTransport, ResolvedAuth};
use llm_worker::llm_client::types::Request;
use serde_json::Value;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// SSE 本体は触らないテスト用 scheme。`parse_fail` を立てると
/// stream 消費中(= retry loop の外)で `ClientError::Sse` を返す。
#[derive(Clone)]
struct DummyScheme {
parse_fail: bool,
}
impl Scheme for DummyScheme {
type State = ();
fn default_base_url(&self) -> &'static str {
""
}
fn path(&self, _: &str) -> String {
"/v1/chat".into()
}
fn required_auth(&self) -> AuthRequirement {
AuthRequirement::None
}
fn build_request_body(&self, _: &str, _: &Request, _: &ModelCapability) -> Value {
serde_json::json!({})
}
fn parse_sse(&self, _: &str, _: &str, _: &mut ()) -> Result<Vec<Event>, ClientError> {
if self.parse_fail {
Err(ClientError::Sse(
"simulated mid-stream parse failure".into(),
))
} else {
Ok(vec![])
}
}
fn default_capability(&self) -> ModelCapability {
ModelCapability::minimal()
}
}
fn fast_policy(max_attempts: u32) -> RetryPolicy {
RetryPolicy {
base: Duration::from_millis(1),
cap: Duration::from_millis(1),
max_attempts,
total_timeout: Duration::from_secs(60),
}
}
fn build_transport(
base_url: impl Into<String>,
parse_fail: bool,
policy: RetryPolicy,
) -> HttpTransport<DummyScheme> {
HttpTransport::new(
DummyScheme { parse_fail },
"test-model",
base_url,
ResolvedAuth::None,
ModelCapability::minimal(),
)
.with_retry_policy(policy)
}
fn ok_sse() -> ResponseTemplate {
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(b"".to_vec(), "text/event-stream")
}
#[tokio::test]
async fn retries_503_then_succeeds() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat"))
.respond_with(ResponseTemplate::new(503).set_body_string("upstream connect error"))
.up_to_n_times(2)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/chat"))
.respond_with(ok_sse())
.mount(&server)
.await;
let transport = build_transport(server.uri(), false, fast_policy(5));
let mut stream = transport
.stream(Request::default())
.await
.expect("stream should succeed after retries");
while stream.next().await.is_some() {}
let received = server.received_requests().await.unwrap();
assert_eq!(received.len(), 3, "two failures plus one success expected");
}
#[tokio::test]
async fn retries_529_then_exhausts() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat"))
.respond_with(ResponseTemplate::new(529).set_body_string("overloaded"))
.mount(&server)
.await;
let transport = build_transport(server.uri(), false, fast_policy(3));
match transport.stream(Request::default()).await {
Err(ClientError::Api {
status: Some(529), ..
}) => {}
Err(other) => panic!("expected Api(529), got {other:?}"),
Ok(_) => panic!("expected error after exhausting retries"),
}
let received = server.received_requests().await.unwrap();
assert_eq!(received.len(), 3, "should hit max_attempts and stop");
}
#[tokio::test]
async fn connect_refused_retries_then_fails() {
// 接続不能なローカルアドレスを使う。Linux では `Connection refused` で
// 即時失敗するため、`fast_policy` ならテストが秒以下で終わる。
let unreachable = "http://127.0.0.1:1";
let transport = build_transport(unreachable, false, fast_policy(3));
match transport.stream(Request::default()).await {
Err(ClientError::Http(e)) => {
assert!(
e.is_connect() || e.is_timeout(),
"expected connect/timeout, got {e:?}"
);
}
Err(other) => panic!("expected Http error, got {other:?}"),
Ok(_) => panic!("expected error connecting to closed port"),
}
}
#[tokio::test]
async fn retry_after_header_overrides_backoff() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat"))
.respond_with(ResponseTemplate::new(503).insert_header("retry-after", "1"))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/chat"))
.respond_with(ok_sse())
.mount(&server)
.await;
// base/cap を 1ms に絞った policy で `Retry-After: 1` を観察すると、
// 指数バックオフ単独なら 1ms 程度で終わるはずが Retry-After に従って
// 1 秒待つ → 経過時間で override を検証できる。
let policy = RetryPolicy {
base: Duration::from_millis(1),
cap: Duration::from_millis(1),
max_attempts: 3,
total_timeout: Duration::from_secs(10),
};
let transport = build_transport(server.uri(), false, policy);
let start = Instant::now();
let mut stream = transport.stream(Request::default()).await.expect("ok");
while stream.next().await.is_some() {}
let elapsed = start.elapsed();
assert!(
elapsed >= Duration::from_secs(1),
"Retry-After=1 should make us wait >=1s, elapsed={elapsed:?}"
);
assert!(
elapsed < Duration::from_secs(3),
"Retry-After=1 should not balloon, elapsed={elapsed:?}"
);
}
#[tokio::test]
async fn mid_stream_sse_error_does_not_retry() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(
b"event: data\ndata: payload\n\n".to_vec(),
"text/event-stream",
),
)
.mount(&server)
.await;
let transport = build_transport(server.uri(), true, fast_policy(5));
let mut stream = transport
.stream(Request::default())
.await
.expect("status 200 should bypass retry loop");
let mut saw_sse_err = false;
while let Some(item) = stream.next().await {
if matches!(item, Err(ClientError::Sse(_))) {
saw_sse_err = true;
}
}
assert!(saw_sse_err, "expected Sse error from stream consumer");
let received = server.received_requests().await.unwrap();
assert_eq!(received.len(), 1, "mid-stream Sse must not retry");
}
#[tokio::test]
async fn non_retryable_status_returns_immediately() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat"))
.respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
.mount(&server)
.await;
let transport = build_transport(server.uri(), false, fast_policy(5));
match transport.stream(Request::default()).await {
Err(ClientError::Api {
status: Some(401), ..
}) => {}
Err(other) => panic!("expected Api(401), got {other:?}"),
Ok(_) => panic!("expected error"),
}
let received = server.received_requests().await.unwrap();
assert_eq!(received.len(), 1, "401 must not retry");
}

View File

@ -15,8 +15,8 @@ use serde::{Deserialize, Serialize};
use crate::defaults;
use crate::model::{AuthRef, ModelManifest, ReasoningControl};
use crate::{
CompactionConfig, MemoryConfig, PodManifest, PodMeta, ScopeConfig, ToolOutputLimits,
WorkerManifest,
CompactionConfig, MemoryConfig, PodManifest, PodMeta, ScopeConfig, SkillsConfig,
ToolOutputLimits, WorkerManifest,
};
/// Partial-form Pod manifest. Every field is optional; one or more
@ -41,6 +41,9 @@ pub struct PodManifestConfig {
/// Memory subsystem opt-in. See [`MemoryConfig`].
#[serde(default)]
pub memory: Option<MemoryConfig>,
/// External Agent Skills directories. See [`crate::SkillsConfig`].
#[serde(default)]
pub skills: Option<SkillsConfig>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@ -183,6 +186,11 @@ impl PodManifestConfig {
{
resolve_auth_file(&mut cp.auth, base);
}
if let Some(ref mut skills) = self.skills {
for dir in &mut skills.directories {
*dir = join_if_relative(base, dir);
}
}
self
}
@ -202,10 +210,18 @@ impl PodManifestConfig {
CompactionConfigPartial::merge,
),
memory: merge_option(self.memory, upper.memory, MemoryConfig::merge),
skills: merge_option(self.skills, upper.skills, SkillsConfig::merge),
}
}
}
impl SkillsConfig {
fn merge(mut self, upper: Self) -> Self {
self.directories.extend(upper.directories);
self
}
}
impl MemoryConfig {
fn merge(self, upper: Self) -> Self {
Self {
@ -411,6 +427,12 @@ impl TryFrom<PodManifestConfig> for PodManifest {
})
.transpose()?;
if let Some(ref skills) = cfg.skills {
for dir in &skills.directories {
ensure_absolute("skills.directories", dir)?;
}
}
Ok(PodManifest {
pod: PodMeta { name, prompt_pack },
model: cfg.model,
@ -418,6 +440,7 @@ impl TryFrom<PodManifestConfig> for PodManifest {
scope: cfg.scope,
compaction,
memory: cfg.memory,
skills: cfg.skills,
})
}
}
@ -461,6 +484,7 @@ mod tests {
},
compaction: None,
memory: None,
skills: None,
}
}
@ -892,6 +916,73 @@ name = "dbg"
assert_eq!(manifest.scope.allow.len(), 1);
}
#[test]
fn skills_directories_resolved_against_base() {
let mut cfg = minimal_valid();
cfg.skills = Some(SkillsConfig {
directories: vec![PathBuf::from(".claude/skills"), PathBuf::from("/abs/elsewhere")],
});
let resolved = cfg.resolve_paths(Path::new("/workspace/proj"));
let dirs = resolved.skills.as_ref().unwrap().directories.clone();
assert_eq!(dirs[0], PathBuf::from("/workspace/proj/.claude/skills"));
assert_eq!(dirs[1], PathBuf::from("/abs/elsewhere"));
}
#[test]
fn skills_relative_path_rejected_post_resolve() {
let mut cfg = minimal_valid();
cfg.skills = Some(SkillsConfig {
directories: vec![PathBuf::from("relative/skills")],
});
let err = PodManifest::try_from(cfg).unwrap_err();
assert!(matches!(
err,
ResolveError::RelativePath {
field: "skills.directories",
..
}
));
}
#[test]
fn skills_merge_extends_directories() {
let lower = PodManifestConfig {
skills: Some(SkillsConfig {
directories: vec![PathBuf::from("/a")],
}),
..Default::default()
};
let upper = PodManifestConfig {
skills: Some(SkillsConfig {
directories: vec![PathBuf::from("/b")],
}),
..Default::default()
};
let merged = lower.merge(upper);
let dirs = merged.skills.unwrap().directories;
assert_eq!(dirs, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
}
#[test]
fn from_toml_parses_skills_section() {
let toml = r#"
[pod]
name = "x"
[skills]
directories = [".claude/skills", ".cursor/skills"]
"#;
let cfg = PodManifestConfig::from_toml(toml).unwrap();
let dirs = cfg.skills.unwrap().directories;
assert_eq!(
dirs,
vec![
PathBuf::from(".claude/skills"),
PathBuf::from(".cursor/skills"),
]
);
}
#[test]
fn merge_preserves_ref() {
let lower = PodManifestConfig {

View File

@ -44,6 +44,30 @@ pub struct PodManifest {
/// memory tools registered.
#[serde(default)]
pub memory: Option<MemoryConfig>,
/// External Agent Skills (`SKILL.md`) directories to ingest as
/// Workflows. Each entry is a path to a skills *root* (i.e. a
/// directory whose children are individual `<name>/SKILL.md` skill
/// bundles). Paths are resolved against the manifest's base
/// directory like other path fields. Absent ⇒ no skills loaded;
/// there is no implicit `$config_dir/skills/` or builtin probe.
#[serde(default)]
pub skills: Option<SkillsConfig>,
}
/// External Agent Skills (`SKILL.md`) ingest configuration. Skills are
/// loaded *only* from the directories listed here — there is no
/// implicit `$config_dir/skills/` or builtin probe. Cascade-merged
/// across manifest layers, so a user-level manifest can declare a
/// shared skill root once while a project manifest adds its own
/// `.claude/skills/` / `.cursor/skills/` paths on top.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SkillsConfig {
/// Skills *roots*. Children of each root must be individual
/// `<name>/SKILL.md` bundles; the directory itself is not a skill.
/// Resolved against the manifest base directory before
/// [`PodManifest`] is materialised.
#[serde(default)]
pub directories: Vec<PathBuf>,
}
/// Memory subsystem configuration. Presence in the manifest enables

View File

@ -698,7 +698,10 @@ mod tests {
std::fs::create_dir(&sub).unwrap();
let shared = SharedScope::new(Scope::writable(dir.path()).unwrap());
let target = sub.join("a.txt");
assert_eq!(shared.load().permission_at(&target), Some(Permission::Write));
assert_eq!(
shared.load().permission_at(&target),
Some(Permission::Write)
);
shared
.update(|cur| {
cur.with_added_deny_rules([ScopeRule {

View File

@ -203,7 +203,13 @@ pub fn render_tidy_hints(tidy: &TidyHints) -> String {
"**Sources overflow** — consider trimming to the most recent entries (git log keeps the rest):\n",
);
for s in &tidy.sources_overflow {
let _ = writeln!(&mut out, "- {} `{}` ({} sources)", s.kind.as_str(), s.slug, s.count);
let _ = writeln!(
&mut out,
"- {} `{}` ({} sources)",
s.kind.as_str(),
s.slug,
s.count
);
}
out.push('\n');
}
@ -276,10 +282,7 @@ mod tests {
.unwrap();
let staging = crate::consolidate::staging::list_staging_entries(&layout);
let tidy = TidyHints {
replaced_decisions: [(
"old".to_string(),
Some("new".to_string()),
)]
replaced_decisions: [("old".to_string(), Some("new".to_string()))]
.into_iter()
.collect(),
sources_overflow: vec![SourcesOverflow {

View File

@ -295,8 +295,7 @@ mod tests {
fn release_is_resilient_to_missing_consumed_entries() {
let (_dir, layout) = make_layout();
let phantom = uuid::Uuid::now_v7();
let lock =
StagingLock::acquire(&layout, std::process::id(), "pod", vec![phantom]).unwrap();
let lock = StagingLock::acquire(&layout, std::process::id(), "pod", vec![phantom]).unwrap();
let lock_path = lock.path().to_path_buf();
// No file at <staging>/<phantom>.json — release must not panic.
lock.release_with_cleanup(&layout);

View File

@ -74,10 +74,7 @@ pub fn collect_tidy_hints(layout: &WorkspaceLayout) -> TidyHints {
for (slug, content) in &decisions {
let fm = parse_yaml::<DecisionFrontmatter>(content);
if let Some(fm) = fm.as_ref() {
if matches!(
fm.status,
crate::schema::DecisionStatus::Replaced
) {
if matches!(fm.status, crate::schema::DecisionStatus::Replaced) {
hints
.replaced_decisions
.insert(slug.clone(), fm.replaced_by.as_ref().map(|s| s.to_string()));
@ -113,9 +110,9 @@ pub fn collect_tidy_hints(layout: &WorkspaceLayout) -> TidyHints {
}
}
}
hints
.sources_overflow
.sort_by(|a, b| (a.kind.as_str(), a.slug.as_str()).cmp(&(b.kind.as_str(), b.slug.as_str())));
hints.sources_overflow.sort_by(|a, b| {
(a.kind.as_str(), a.slug.as_str()).cmp(&(b.kind.as_str(), b.slug.as_str()))
});
let decision_slugs: Vec<&str> = decisions.keys().map(|s| s.as_str()).collect();
let request_slugs: Vec<&str> = requests.keys().map(|s| s.as_str()).collect();
@ -139,10 +136,7 @@ pub fn collect_tidy_hints(layout: &WorkspaceLayout) -> TidyHints {
/// `<root>/.insomnia/memory/<kind>/*.md` (Knowledge は
/// `<root>/.insomnia/knowledge/*.md`) を slug ごとに `(slug, full content)`
/// 化して返す。
fn read_kind_records(
layout: &WorkspaceLayout,
kind: RecordKind,
) -> BTreeMap<String, String> {
fn read_kind_records(layout: &WorkspaceLayout, kind: RecordKind) -> BTreeMap<String, String> {
let dir = match kind {
RecordKind::Decision => layout.decisions_dir(),
RecordKind::Request => layout.requests_dir(),

View File

@ -70,7 +70,7 @@ pub enum LintError {
BodyTooLong { actual: usize, limit: usize },
#[error(
"write to `memory/workflow/` is forbidden via the memory tool — Workflows are human-edited"
"write to a Workflow path is forbidden via the memory tool — Workflows are human-edited"
)]
WorkflowWriteForbidden,

View File

@ -13,6 +13,7 @@ pub mod linter;
pub mod resident;
pub mod schema;
pub mod scope;
pub mod skill;
pub mod slug;
pub mod tool;
pub mod workflow;
@ -23,9 +24,10 @@ pub use extract::ExtractPointerPayload;
pub use linter::{LintReport, Linter};
pub use resident::{ResidentKnowledgeEntry, collect_resident_knowledge};
pub use scope::deny_write_rules;
pub use skill::{SKILL_FILENAME, SkillParseError, SkillRecord, load_skills_from_dir, parse_skill_md};
pub use slug::Slug;
pub use workflow::{
ResidentWorkflowEntry, WORKFLOW_DESCRIPTION_HARD_CAP, WorkflowLoadError, WorkflowRecord,
WorkflowRegistry, load_workflows,
ResidentWorkflowEntry, ShadowedSkill, WORKFLOW_DESCRIPTION_HARD_CAP, WorkflowLoadError,
WorkflowRecord, WorkflowRegistry, WorkflowSource, load_workflows,
};
pub use workspace::WorkspaceLayout;

View File

@ -1,6 +1,7 @@
//! Walks `<workspace>/memory/{decisions,requests}/`, `memory/workflow/`,
//! and `<workspace>/knowledge/` to collect the slug set the linter
//! needs for reference-integrity and same-slug-duplication checks.
//! Walks `<workspace>/memory/{decisions,requests}/`,
//! `<workspace>/workflow/`, and `<workspace>/knowledge/` to collect
//! the slug set the linter needs for reference-integrity and
//! same-slug-duplication checks.
//!
//! No caching: each lint call walks fresh. Tree size is expected to
//! stay small (hundreds of files, not thousands).

View File

@ -335,7 +335,7 @@ mod tests {
#[test]
fn workflow_write_rejected() {
let (dir, linter) = workspace();
let path = dir.path().join(".insomnia/memory/workflow/wf.md");
let path = dir.path().join(".insomnia/workflow/wf.md");
let content =
"---\ndescription: x\nmodel_invokation: false\nuser_invocable: true\n---\nbody"
.to_string();

View File

@ -3,7 +3,8 @@
//! NOTE: Workflows are written by humans, not by the memory tool. The
//! linter only validates frontmatter when invoked directly (e.g. by a
//! future CLI / pre-commit hook). The memory write/edit tool rejects
//! `memory/workflow/` paths outright via [`LintError::WorkflowWriteForbidden`].
//! `.insomnia/workflow/` paths outright via
//! [`LintError::WorkflowWriteForbidden`].
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

View File

@ -13,13 +13,17 @@ use manifest::{Permission, ScopeRule};
use crate::workspace::WorkspaceLayout;
/// Build deny rules that strip Write permission from `<workspace>/memory/`
/// and `<workspace>/knowledge/`. Recursive — every descendant is capped
/// at Read for the generic tools, including `memory/workflow/`.
/// Build deny rules that strip Write permission from `<workspace>/memory/`,
/// `<workspace>/knowledge/`, and `<workspace>/workflow/`. Recursive —
/// every descendant is capped at Read for the generic tools.
///
/// Workflow files are human-edited on the host side; the generic CRUD
/// tools must not touch them.
pub fn deny_write_rules(layout: &WorkspaceLayout) -> Vec<ScopeRule> {
vec![
deny_write(layout.memory_dir().as_path()),
deny_write(layout.knowledge_dir().as_path()),
deny_write(layout.workflow_dir().as_path()),
]
}
@ -37,13 +41,14 @@ mod tests {
use std::path::PathBuf;
#[test]
fn deny_targets_memory_and_knowledge() {
fn deny_targets_memory_knowledge_and_workflow() {
let layout = WorkspaceLayout::new(PathBuf::from("/ws"));
let rules = deny_write_rules(&layout);
assert_eq!(rules.len(), 2);
assert_eq!(rules.len(), 3);
assert_eq!(rules[0].target, PathBuf::from("/ws/.insomnia/memory"));
assert_eq!(rules[0].permission, Permission::Write);
assert!(rules[0].recursive);
assert_eq!(rules[1].target, PathBuf::from("/ws/.insomnia/knowledge"));
assert_eq!(rules[2].target, PathBuf::from("/ws/.insomnia/workflow"));
}
}

447
crates/memory/src/skill.rs Normal file
View File

@ -0,0 +1,447 @@
//! Agent Skills (`SKILL.md`) parser.
//!
//! Skills follow the [agentskills.io](https://agentskills.io/specification)
//! spec: a directory `<root>/<name>/` containing `SKILL.md` (YAML frontmatter
//! + Markdown body) and optional `scripts/` / `references/` / `assets/`
//! subdirectories. The body is procedural agent guidance; insomnia ingests
//! it as a Workflow so `/<name>` resolves to it just like an internal
//! Workflow.
//!
//! Parsing is intentionally lenient at the directory-scan level — one
//! malformed SKILL.md emits `tracing::warn!` and is skipped, leaving sibling
//! skills loadable. Internal Workflows (`.insomnia/workflow/<slug>.md`) keep
//! their hard-error semantics.
use std::io;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use thiserror::Error;
use tracing::warn;
use crate::error::LintError;
use crate::schema::split_frontmatter;
use crate::slug::Slug;
use crate::workflow::{WORKFLOW_DESCRIPTION_HARD_CAP, WorkflowRecord, WorkflowSource};
/// Filename within a skill directory carrying the frontmatter + body.
pub const SKILL_FILENAME: &str = "SKILL.md";
/// SKILL.md frontmatter as defined by the agent-skills spec.
///
/// Fields beyond `name` / `description` are accepted to be spec-compatible
/// but not used by insomnia today: `license`, `compatibility`, and
/// `metadata` are documentary, while `allowed-tools` is recognised and
/// emits a warning until [`permission-extension-point.md`] lands.
#[derive(Debug, Clone, Deserialize)]
pub struct SkillFrontmatter {
pub name: String,
pub description: String,
#[serde(default)]
pub license: Option<String>,
#[serde(default)]
pub compatibility: Option<String>,
#[serde(default)]
pub metadata: Option<serde_yaml::Value>,
#[serde(default, rename = "allowed-tools")]
pub allowed_tools: Option<serde_yaml::Value>,
}
/// Validated skill record. Constructed by [`parse_skill_md`] and converted
/// to a `WorkflowRecord` by the caller via the `Skill → Workflow`
/// projection in [`crate::workflow`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillRecord {
pub slug: Slug,
pub description: String,
pub body: String,
/// The skill directory (parent of `SKILL.md`). Carried so callers can
/// register `scripts/` / `references/` / `assets/` against the Pod's
/// scope.
pub dir: PathBuf,
/// Path to the `SKILL.md` file itself. Used as the resolved path on
/// the resulting `WorkflowRecord`.
pub skill_md_path: PathBuf,
}
impl SkillRecord {
/// Project this skill into a [`WorkflowRecord`]. Skill-sourced
/// Workflows are advertised resident (`model_invokation: true`,
/// matching the agentskills progressive-disclosure model), are
/// invocable as `/<slug>`, and carry no `requires` since the SKILL
/// spec has no Knowledge-dependency concept.
pub fn into_workflow_record(self, source: WorkflowSource) -> WorkflowRecord {
WorkflowRecord {
slug: self.slug,
description: self.description,
model_invokation: true,
user_invocable: true,
requires: Vec::new(),
body: self.body,
path: self.skill_md_path,
source,
}
}
}
#[derive(Debug, Error)]
pub enum SkillParseError {
#[error("skill path has no parent directory: {}", .0.display())]
NoParentDir(PathBuf),
#[error("failed to read SKILL.md at {}: {source}", .path.display())]
ReadFile { path: PathBuf, source: io::Error },
#[error("invalid frontmatter in {}: {source}", .path.display())]
Frontmatter {
path: PathBuf,
#[source]
source: LintError,
},
#[error(
"SKILL.md `name` `{name}` does not match its directory name `{dir_name}` (at {})",
.skill_md_path.display()
)]
NameDirMismatch {
name: String,
dir_name: String,
skill_md_path: PathBuf,
},
#[error("SKILL.md `name` is not a valid slug at {}: {source}", .skill_md_path.display())]
InvalidName {
skill_md_path: PathBuf,
#[source]
source: LintError,
},
#[error("SKILL.md `description` must be non-empty (at {})", .skill_md_path.display())]
DescriptionEmpty { skill_md_path: PathBuf },
#[error(
"SKILL.md `description` length {actual} exceeds limit {limit} (at {})",
.skill_md_path.display()
)]
DescriptionTooLong {
skill_md_path: PathBuf,
actual: usize,
limit: usize,
},
}
/// Parse a single `SKILL.md`. The directory name is taken from the parent
/// of `skill_md_path` and validated against the frontmatter `name`.
pub fn parse_skill_md(skill_md_path: &Path) -> Result<SkillRecord, SkillParseError> {
let dir = skill_md_path
.parent()
.map(|p| p.to_path_buf())
.ok_or_else(|| SkillParseError::NoParentDir(skill_md_path.to_path_buf()))?;
let dir_name = dir
.file_name()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.ok_or_else(|| SkillParseError::NoParentDir(skill_md_path.to_path_buf()))?;
let raw =
std::fs::read_to_string(skill_md_path).map_err(|source| SkillParseError::ReadFile {
path: skill_md_path.to_path_buf(),
source,
})?;
let (yaml, body) = split_frontmatter(&raw).map_err(|source| SkillParseError::Frontmatter {
path: skill_md_path.to_path_buf(),
source,
})?;
warn_unknown_skill_fields(skill_md_path, yaml);
let frontmatter: SkillFrontmatter =
serde_yaml::from_str(yaml).map_err(|err| SkillParseError::Frontmatter {
path: skill_md_path.to_path_buf(),
source: LintError::MalformedFrontmatter(err.to_string()),
})?;
if frontmatter.allowed_tools.is_some() {
warn!(
path = %skill_md_path.display(),
"SKILL.md `allowed-tools` is recognised but not yet enforced; ignoring"
);
}
let desc_chars = frontmatter.description.chars().count();
if desc_chars == 0 {
return Err(SkillParseError::DescriptionEmpty {
skill_md_path: skill_md_path.to_path_buf(),
});
}
if desc_chars > WORKFLOW_DESCRIPTION_HARD_CAP {
return Err(SkillParseError::DescriptionTooLong {
skill_md_path: skill_md_path.to_path_buf(),
actual: desc_chars,
limit: WORKFLOW_DESCRIPTION_HARD_CAP,
});
}
if frontmatter.name != dir_name {
return Err(SkillParseError::NameDirMismatch {
name: frontmatter.name,
dir_name,
skill_md_path: skill_md_path.to_path_buf(),
});
}
let slug = Slug::parse(frontmatter.name).map_err(|source| SkillParseError::InvalidName {
skill_md_path: skill_md_path.to_path_buf(),
source,
})?;
Ok(SkillRecord {
slug,
description: frontmatter.description,
body: body.to_string(),
dir,
skill_md_path: skill_md_path.to_path_buf(),
})
}
/// Scan a skills root for `<root>/<name>/SKILL.md`. Returns successfully
/// parsed skills; per-skill errors emit a `tracing::warn!` and are
/// skipped. A missing root is treated as zero skills, not an error —
/// callers can probe optional directories without pre-checking.
pub fn load_skills_from_dir(root: &Path) -> Vec<SkillRecord> {
let entries = match std::fs::read_dir(root) {
Ok(it) => it,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Vec::new(),
Err(err) => {
warn!(
dir = %root.display(),
error = %err,
"failed to read skills directory; treating as empty"
);
return Vec::new();
}
};
let mut paths: Vec<PathBuf> = Vec::new();
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(err) => {
warn!(
dir = %root.display(),
error = %err,
"skill directory entry read error; skipping"
);
continue;
}
};
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_md = path.join(SKILL_FILENAME);
if skill_md.is_file() {
paths.push(skill_md);
}
}
paths.sort();
let mut out = Vec::new();
for path in paths {
match parse_skill_md(&path) {
Ok(record) => out.push(record),
Err(err) => warn!(path = %path.display(), error = %err, "SKILL.md skipped"),
}
}
out
}
fn warn_unknown_skill_fields(path: &Path, yaml: &str) {
let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(yaml) else {
return;
};
let Some(map) = value.as_mapping() else {
return;
};
for key in map.keys().filter_map(|k| k.as_str()) {
if !matches!(
key,
"name" | "description" | "license" | "compatibility" | "metadata" | "allowed-tools"
) {
warn!(path = %path.display(), field = key, "unknown SKILL.md frontmatter field ignored");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write_skill(root: &Path, name: &str, frontmatter: &str, body: &str) -> PathBuf {
let dir = root.join(name);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(SKILL_FILENAME);
std::fs::write(&path, format!("---\n{frontmatter}\n---\n{body}")).unwrap();
path
}
#[test]
fn parses_minimal_skill() {
let dir = TempDir::new().unwrap();
let path = write_skill(
dir.path(),
"do-thing",
"name: do-thing\ndescription: Do the thing",
"Step 1\nStep 2\n",
);
let record = parse_skill_md(&path).unwrap();
assert_eq!(record.slug.as_str(), "do-thing");
assert_eq!(record.description, "Do the thing");
assert_eq!(record.body, "Step 1\nStep 2\n");
assert_eq!(record.dir, dir.path().join("do-thing"));
assert_eq!(record.skill_md_path, path);
}
#[test]
fn name_dir_mismatch_is_error() {
let dir = TempDir::new().unwrap();
let path = write_skill(
dir.path(),
"actual-dir",
"name: declared-name\ndescription: x",
"body",
);
let err = parse_skill_md(&path).unwrap_err();
assert!(matches!(err, SkillParseError::NameDirMismatch { .. }));
}
#[test]
fn invalid_slug_name_is_error() {
let dir = TempDir::new().unwrap();
let path = write_skill(
dir.path(),
"BAD-Caps",
"name: BAD-Caps\ndescription: x",
"body",
);
// Slug::parse rejects uppercase before the dir match check fires;
// either way the parse is rejected.
let err = parse_skill_md(&path).unwrap_err();
assert!(matches!(
err,
SkillParseError::InvalidName { .. } | SkillParseError::NameDirMismatch { .. }
));
}
#[test]
fn empty_description_is_error() {
let dir = TempDir::new().unwrap();
let path = write_skill(dir.path(), "x", "name: x\ndescription: \"\"", "body");
let err = parse_skill_md(&path).unwrap_err();
assert!(matches!(err, SkillParseError::DescriptionEmpty { .. }));
}
#[test]
fn description_at_cap_is_accepted() {
let dir = TempDir::new().unwrap();
let desc = "x".repeat(WORKFLOW_DESCRIPTION_HARD_CAP);
let path = write_skill(
dir.path(),
"x",
&format!("name: x\ndescription: {desc}"),
"body",
);
let record = parse_skill_md(&path).unwrap();
assert_eq!(record.description.chars().count(), WORKFLOW_DESCRIPTION_HARD_CAP);
}
#[test]
fn description_over_cap_is_error() {
let dir = TempDir::new().unwrap();
let desc = "x".repeat(WORKFLOW_DESCRIPTION_HARD_CAP + 1);
let path = write_skill(
dir.path(),
"x",
&format!("name: x\ndescription: {desc}"),
"body",
);
let err = parse_skill_md(&path).unwrap_err();
assert!(matches!(err, SkillParseError::DescriptionTooLong { .. }));
}
#[test]
fn missing_frontmatter_is_error() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("x").join(SKILL_FILENAME);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "no frontmatter at all\n").unwrap();
let err = parse_skill_md(&path).unwrap_err();
assert!(matches!(err, SkillParseError::Frontmatter { .. }));
}
#[test]
fn extra_frontmatter_fields_are_kept() {
let dir = TempDir::new().unwrap();
let path = write_skill(
dir.path(),
"x",
"name: x\ndescription: ok\nlicense: MIT\ncompatibility: claude-4\n\
metadata:\n team: foo\nallowed-tools:\n - Read",
"body",
);
let record = parse_skill_md(&path).unwrap();
assert_eq!(record.slug.as_str(), "x");
// allowed-tools triggers a warn, but parse succeeds.
}
#[test]
fn load_skills_from_dir_skips_broken_and_keeps_good() {
let dir = TempDir::new().unwrap();
write_skill(dir.path(), "good", "name: good\ndescription: ok", "body");
// Mismatch — should be skipped, not abort the scan.
write_skill(
dir.path(),
"bad-dir",
"name: declared-different\ndescription: ok",
"body",
);
// A bare file at the root (not a directory) is ignored.
std::fs::write(dir.path().join("stray.md"), "not a skill").unwrap();
let records = load_skills_from_dir(dir.path());
let slugs: Vec<&str> = records.iter().map(|r| r.slug.as_str()).collect();
assert_eq!(slugs, vec!["good"]);
}
#[test]
fn load_skills_from_dir_missing_root_is_empty() {
let dir = TempDir::new().unwrap();
let records = load_skills_from_dir(&dir.path().join("does-not-exist"));
assert!(records.is_empty());
}
#[test]
fn into_workflow_record_uses_skill_defaults() {
let dir = TempDir::new().unwrap();
let path = write_skill(
dir.path(),
"x",
"name: x\ndescription: Project X",
"Steps\n",
);
let record = parse_skill_md(&path).unwrap();
let wf = record.into_workflow_record(WorkflowSource::Skill {
dir: dir.path().to_path_buf(),
});
assert_eq!(wf.slug.as_str(), "x");
assert_eq!(wf.description, "Project X");
assert!(wf.model_invokation);
assert!(wf.user_invocable);
assert!(wf.requires.is_empty());
assert_eq!(wf.body, "Steps\n");
assert!(matches!(wf.source, WorkflowSource::Skill { .. }));
}
#[test]
fn load_skills_from_dir_orders_deterministically() {
let dir = TempDir::new().unwrap();
write_skill(dir.path(), "b", "name: b\ndescription: b", "");
write_skill(dir.path(), "a", "name: a\ndescription: a", "");
write_skill(dir.path(), "c", "name: c\ndescription: c", "");
let records = load_skills_from_dir(dir.path());
let slugs: Vec<&str> = records.iter().map(|r| r.slug.as_str()).collect();
assert_eq!(slugs, vec!["a", "b", "c"]);
}
}

View File

@ -7,8 +7,8 @@
//! enumerate what records exist without knowing what's inside them.
//!
//! - `MemoryQuery` walks `.insomnia/memory/{summary.md,decisions/,
//! requests/}`. `.insomnia/memory/workflow/` and
//! `.insomnia/memory/_staging/` are excluded by construction.
//! requests/}`. `.insomnia/workflow/` and `.insomnia/memory/_staging/`
//! are excluded by construction.
//! - `KnowledgeQuery` walks `.insomnia/knowledge/*.md` and supports a
//! `kind` filter against the Knowledge frontmatter's `kind` field.
//!

View File

@ -1,8 +1,8 @@
//! Workflow loader and registry.
//!
//! Workflows live under `<workspace>/.insomnia/memory/workflow/<slug>.md`.
//! They are human-authored Markdown documents with YAML frontmatter. The loader
//! is intentionally strict about malformed records because Pod startup should
//! Workflows live under `<workspace>/.insomnia/workflow/<slug>.md`. They are
//! human-authored Markdown documents with YAML frontmatter. The loader is
//! intentionally strict about malformed records because Pod startup should
//! fail rather than silently ignoring a broken procedural instruction.
use std::collections::BTreeMap;
@ -21,6 +21,30 @@ use crate::workspace::WorkspaceLayout;
/// Mirrors agent-skills and resident Knowledge descriptions.
pub const WORKFLOW_DESCRIPTION_HARD_CAP: usize = 1024;
/// Origin of a [`WorkflowRecord`]. Used to break ties when the same slug
/// is provided by multiple sources: workspace-authored Workflows always
/// win over external skills.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkflowSource {
/// `<workspace>/.insomnia/workflow/<slug>.md`. Authored in-tree by
/// the project.
WorkspaceWorkflow,
/// SKILL.md ingested from a `[skills] directories` entry in the
/// manifest. `dir` is the skills root that contained
/// `<slug>/SKILL.md`.
Skill { dir: PathBuf },
}
impl WorkflowSource {
/// Human-readable label used in shadow-notification messages.
pub fn label(&self) -> &'static str {
match self {
Self::WorkspaceWorkflow => "workspace workflow",
Self::Skill { .. } => "skill",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkflowRecord {
pub slug: Slug,
@ -31,6 +55,37 @@ pub struct WorkflowRecord {
/// Markdown body after the closing frontmatter delimiter.
pub body: String,
pub path: PathBuf,
/// Where this record was loaded from. Determines shadowing priority
/// when [`WorkflowRegistry::merge_skill`] encounters a slug
/// collision.
pub source: WorkflowSource,
}
/// Returned by [`WorkflowRegistry::merge_skill`] when an incoming skill is
/// shadowed by an existing record (either an internal Workflow or a
/// higher-priority skill). Carries enough context for a `Notification` to
/// explain which side won.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShadowedSkill {
pub slug: Slug,
pub kept_source: WorkflowSource,
pub kept_path: PathBuf,
pub shadowed_source: WorkflowSource,
pub shadowed_path: PathBuf,
}
impl ShadowedSkill {
/// One-line message for `Notification` payloads.
pub fn message(&self) -> String {
format!(
"skill /{slug} from {shadowed_label} ({shadowed_path}) was shadowed by existing {kept_label} ({kept_path})",
slug = self.slug,
shadowed_label = self.shadowed_source.label(),
shadowed_path = self.shadowed_path.display(),
kept_label = self.kept_source.label(),
kept_path = self.kept_path.display(),
)
}
}
#[derive(Debug, Clone, Default)]
@ -77,6 +132,26 @@ impl WorkflowRegistry {
.map(|record| record.slug.to_string())
.collect()
}
/// Insert a skill-derived record. If an existing record (internal
/// Workflow or earlier-fed skill) already owns the slug, the
/// incoming record is dropped and a [`ShadowedSkill`] describing the
/// collision is returned. Callers feed records in priority order
/// (highest first); the registry is "first-insert wins" and does
/// not re-rank.
pub fn merge_skill(&mut self, record: WorkflowRecord) -> Option<ShadowedSkill> {
if let Some(existing) = self.records.get(&record.slug) {
return Some(ShadowedSkill {
slug: record.slug.clone(),
kept_source: existing.source.clone(),
kept_path: existing.path.clone(),
shadowed_source: record.source,
shadowed_path: record.path,
});
}
self.records.insert(record.slug.clone(), record);
None
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -176,6 +251,7 @@ pub fn load_workflows(layout: &WorkspaceLayout) -> Result<WorkflowRegistry, Work
requires: frontmatter.requires,
body: body.to_string(),
path: path.clone(),
source: WorkflowSource::WorkspaceWorkflow,
};
records.insert(slug.clone(), record);
}
@ -233,15 +309,13 @@ mod tests {
fn setup() -> (TempDir, WorkspaceLayout) {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".insomnia/memory/workflow")).unwrap();
std::fs::create_dir_all(dir.path().join(".insomnia/workflow")).unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
(dir, layout)
}
fn write_workflow(root: &Path, slug: &str, frontmatter: &str, body: &str) {
let path = root
.join(".insomnia/memory/workflow")
.join(format!("{slug}.md"));
let path = root.join(".insomnia/workflow").join(format!("{slug}.md"));
std::fs::write(path, format!("---\n{frontmatter}\n---\n{body}")).unwrap();
}
@ -297,6 +371,134 @@ mod tests {
assert!(matches!(err, WorkflowLoadError::Frontmatter { .. }));
}
#[test]
fn workflow_under_memory_is_ignored() {
// The legacy `.insomnia/memory/workflow/` location is no longer
// a Workflow source. Files placed there must be ignored (the
// loader is rooted at `.insomnia/workflow/` only).
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
let legacy = dir.path().join(".insomnia/memory/workflow");
std::fs::create_dir_all(&legacy).unwrap();
std::fs::write(
legacy.join("ghost.md"),
"---\ndescription: ghost\n---\nbody\n",
)
.unwrap();
let got = load_workflows(&layout).unwrap();
assert!(got.is_empty());
}
fn skill_record(slug: &str, path: &Path) -> WorkflowRecord {
WorkflowRecord {
slug: Slug::parse(slug).unwrap(),
description: format!("desc {slug}"),
model_invokation: true,
user_invocable: true,
requires: Vec::new(),
body: format!("body for {slug}"),
path: path.to_path_buf(),
source: WorkflowSource::Skill {
dir: path.parent().unwrap().parent().unwrap().to_path_buf(),
},
}
}
#[test]
fn merge_skill_inserts_when_no_collision() {
let mut reg = WorkflowRegistry::empty();
let path = std::path::PathBuf::from("/tmp/skills/x/SKILL.md");
let shadow = reg.merge_skill(skill_record("x", &path));
assert!(shadow.is_none());
assert_eq!(reg.len(), 1);
}
#[test]
fn merge_skill_shadows_existing_workflow() {
let (dir, layout) = setup();
write_workflow(dir.path(), "shared", "description: Internal", "internal body");
let mut reg = load_workflows(&layout).unwrap();
let skill_path = dir.path().join("user-skills").join("shared").join("SKILL.md");
std::fs::create_dir_all(skill_path.parent().unwrap()).unwrap();
std::fs::write(&skill_path, "ignored").unwrap();
let incoming = WorkflowRecord {
slug: Slug::parse("shared").unwrap(),
description: "From skill".into(),
model_invokation: true,
user_invocable: true,
requires: Vec::new(),
body: "skill body".into(),
path: skill_path.clone(),
source: WorkflowSource::Skill {
dir: dir.path().join("user-skills"),
},
};
let shadow = reg.merge_skill(incoming).expect("expected shadow");
assert_eq!(shadow.slug.as_str(), "shared");
assert!(matches!(shadow.kept_source, WorkflowSource::WorkspaceWorkflow));
assert!(matches!(shadow.shadowed_source, WorkflowSource::Skill { .. }));
// The kept record is still the workspace workflow.
let kept = reg.get(&Slug::parse("shared").unwrap()).unwrap();
assert!(matches!(kept.source, WorkflowSource::WorkspaceWorkflow));
assert_eq!(kept.body, "internal body");
}
#[test]
fn merge_skill_first_fed_wins_on_collision() {
let mut reg = WorkflowRegistry::empty();
let first_path = std::path::PathBuf::from("/a/skills/x/SKILL.md");
let second_path = std::path::PathBuf::from("/b/skills/x/SKILL.md");
let first = WorkflowRecord {
slug: Slug::parse("x").unwrap(),
description: "first".into(),
model_invokation: true,
user_invocable: true,
requires: Vec::new(),
body: "first body".into(),
path: first_path.clone(),
source: WorkflowSource::Skill {
dir: std::path::PathBuf::from("/a/skills"),
},
};
let second = WorkflowRecord {
slug: Slug::parse("x").unwrap(),
description: "second".into(),
model_invokation: true,
user_invocable: true,
requires: Vec::new(),
body: "second body".into(),
path: second_path.clone(),
source: WorkflowSource::Skill {
dir: std::path::PathBuf::from("/b/skills"),
},
};
// Caller is responsible for feeding in priority order; the
// registry just keeps whichever arrives first.
assert!(reg.merge_skill(first).is_none());
let shadow = reg
.merge_skill(second)
.expect("later-fed skill must shadow");
assert_eq!(shadow.kept_path, first_path);
assert!(matches!(shadow.kept_source, WorkflowSource::Skill { .. }));
}
#[test]
fn shadow_message_is_human_readable() {
let s = ShadowedSkill {
slug: Slug::parse("x").unwrap(),
kept_source: WorkflowSource::WorkspaceWorkflow,
kept_path: std::path::PathBuf::from("/ws/.insomnia/workflow/x.md"),
shadowed_source: WorkflowSource::Skill {
dir: std::path::PathBuf::from("/skills"),
},
shadowed_path: std::path::PathBuf::from("/skills/x/SKILL.md"),
};
let msg = s.message();
assert!(msg.contains("/x"));
assert!(msg.contains("workspace workflow"));
assert!(msg.contains("skill"));
}
#[test]
fn resident_description_cap_is_enforced() {
let (dir, layout) = setup();

View File

@ -3,15 +3,18 @@
//! `WorkspaceLayout` carries the workspace root (typically the Pod's
//! pwd). All insomnia-managed content lives under the conventional
//! `<root>/.insomnia/` subdirectory — the same place that holds
//! `manifest.toml` and `prompts/`. The memory subsystem nests its
//! trees inside it:
//! `manifest.toml` and `prompts/`. The trees inside it:
//!
//! - `<root>/.insomnia/workflow/<slug>.md`
//! - `<root>/.insomnia/knowledge/<slug>.md`
//! - `<root>/.insomnia/memory/summary.md`
//! - `<root>/.insomnia/memory/decisions/<slug>.md`
//! - `<root>/.insomnia/memory/requests/<slug>.md`
//! - `<root>/.insomnia/memory/workflow/<slug>.md`
//! - `<root>/.insomnia/memory/_staging/<id>.json`
//! - `<root>/.insomnia/knowledge/<slug>.md`
//!
//! `memory/` is reserved for session-derived / generated state;
//! Workflows are human-managed and live one level up under
//! `.insomnia/workflow/`.
//!
//! Configuring `[memory]` with an empty body is therefore sufficient
//! for any workspace that already uses the `.insomnia/` convention; no
@ -25,10 +28,10 @@ use crate::slug::Slug;
const INSOMNIA_DIR: &str = ".insomnia";
const MEMORY_DIR: &str = "memory";
const KNOWLEDGE_DIR: &str = "knowledge";
const WORKFLOW_DIR: &str = "workflow";
const SUMMARY_FILE: &str = "summary.md";
const DECISIONS_DIR: &str = "decisions";
const REQUESTS_DIR: &str = "requests";
const WORKFLOW_DIR: &str = "workflow";
const STAGING_DIR: &str = "_staging";
/// What kind of record a path under the memory tree represents.
@ -114,8 +117,9 @@ impl WorkspaceLayout {
self.memory_dir().join(REQUESTS_DIR)
}
/// Workflow directory: `<root>/.insomnia/workflow/`.
pub fn workflow_dir(&self) -> PathBuf {
self.memory_dir().join(WORKFLOW_DIR)
self.insomnia_dir().join(WORKFLOW_DIR)
}
pub fn staging_dir(&self) -> PathBuf {
@ -139,9 +143,9 @@ impl WorkspaceLayout {
}
/// Classify a path under the memory tree. Returns `None` if the
/// path is not under `.insomnia/memory/` or `.insomnia/knowledge/`
/// of this workspace, or if it lives in `_staging/` (which is
/// opaque to the linter).
/// path is not under `.insomnia/memory/`, `.insomnia/knowledge/`,
/// or `.insomnia/workflow/` of this workspace, or if it lives in
/// `_staging/` (which is opaque to the linter).
///
/// On a conventional path that's *almost* a record but malformed
/// (e.g. `.insomnia/memory/decisions/Foo.md` with an invalid slug),
@ -150,10 +154,14 @@ impl WorkspaceLayout {
pub fn classify(&self, path: &Path) -> Result<Option<ClassifiedPath>, LintError> {
let memory = self.memory_dir();
let knowledge = self.knowledge_dir();
let workflow = self.workflow_dir();
if let Ok(rel) = path.strip_prefix(&knowledge) {
return Ok(Some(classify_kinded_md(rel, RecordKind::Knowledge, path)?));
}
if let Ok(rel) = path.strip_prefix(&workflow) {
return Ok(Some(classify_kinded_md(rel, RecordKind::Workflow, path)?));
}
let rel = match path.strip_prefix(&memory) {
Ok(r) => r,
Err(_) => return Ok(None),
@ -183,8 +191,6 @@ impl WorkspaceLayout {
RecordKind::Decision
} else if first == REQUESTS_DIR {
RecordKind::Request
} else if first == WORKFLOW_DIR {
RecordKind::Workflow
} else {
return Err(LintError::InvalidPath(path.to_path_buf()));
};
@ -264,10 +270,19 @@ mod tests {
#[test]
fn classifies_workflow() {
let cp = layout()
.classify(&PathBuf::from("/ws/.insomnia/memory/workflow/wf.md"))
.classify(&PathBuf::from("/ws/.insomnia/workflow/wf.md"))
.unwrap()
.unwrap();
assert_eq!(cp.kind, RecordKind::Workflow);
assert_eq!(cp.slug.unwrap().as_str(), "wf");
}
#[test]
fn workflow_under_memory_is_invalid_path() {
let err = layout()
.classify(&PathBuf::from("/ws/.insomnia/memory/workflow/wf.md"))
.unwrap_err();
assert!(matches!(err, LintError::InvalidPath(_)));
}
#[test]

View File

@ -49,8 +49,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
let metrics = self.metrics_tracker_handle();
let usage_tracker = self.usage_tracker_handle();
let observer: PruneObserver = Box::new(move |eval| {
match &eval.decision {
let observer: PruneObserver = Box::new(move |eval| match &eval.decision {
PruneDecision::Fired { .. } => {
let correlation_id = uuid::Uuid::now_v7().to_string();
let mut metric = Metric::now("prune.fire")
@ -64,9 +63,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
usage_tracker.note_correlation_id(correlation_id);
}
PruneDecision::SkippedNoCandidates => {
metrics.push(
Metric::now("prune.skip").with_dimension("reason", "no_candidates"),
);
metrics.push(Metric::now("prune.skip").with_dimension("reason", "no_candidates"));
}
PruneDecision::SkippedBelowMinSavings => {
metrics.push(
@ -76,7 +73,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
.with_value(eval.estimated_savings as f64),
);
}
}
});
let worker = self.worker_mut();

View File

@ -4,8 +4,8 @@
//! flags shared between:
//! - `PodInterceptor` (reads `request_threshold` — the *safety net* for
//! between-requests yielding)
//! - `Pod::try_post_run_compact` (reads `post_run_threshold` — the
//! *proactive* check between turns)
//! - `Pod::try_pre_run_compact` (reads `post_run_threshold` — the
//! *proactive* check before the next turn starts)
//! - `Pod::run()` / `resume()` (circuit breaker, thrash detection)
//!
//! Current occupancy (input-token count) is **not** stored here. The single
@ -19,8 +19,8 @@ const MAX_COMPACT_FAILURES: usize = 3;
/// Shared mutable state for compaction decisions.
pub(crate) struct CompactState {
/// Between-turns threshold (proactive). Checked by the Controller
/// after a run completes. `None` disables the post-run check.
/// Between-turns threshold (proactive). Checked before the next turn
/// starts. `None` disables the pre-run check.
post_run_threshold: Option<u64>,
/// Between-requests threshold (safety net). Checked inside a turn
/// before each LLM request. `None` disables the request check.

View File

@ -3,6 +3,7 @@ use std::sync::Arc;
use llm_worker::WorkerError;
use llm_worker::llm_client::client::LlmClient;
use llm_worker::llm_client::types::{Item, Role};
use session_store::Store;
use tokio::sync::{broadcast, mpsc, oneshot};
@ -11,13 +12,25 @@ use crate::ipc::notify_buffer::NotifyBuffer;
use crate::ipc::server::SocketServer;
use crate::pod::{Pod, PodError, PodRunResult};
use crate::runtime::dir::RuntimeDir;
use crate::shared_state::{PodSharedState, PodStatus};
use crate::shared_state::PodSharedState;
use crate::spawn::comm_tools::{
list_pods_tool, read_pod_output_tool, send_to_pod_tool, stop_pod_tool,
};
use crate::spawn::registry::SpawnedPodRegistry;
use crate::spawn::tool::spawn_pod_tool;
use protocol::{AlertLevel, AlertSource, ErrorCode, Event, Method, RunResult, TurnResult};
use protocol::{
AlertLevel, AlertSource, ErrorCode, Event, Method, PodStatus, RunResult, TurnResult,
};
fn is_system_message_item(item: &Item) -> bool {
matches!(
item,
Item::Message {
role: Role::System,
..
}
)
}
// ---------------------------------------------------------------------------
// PodHandle — client-facing, Clone-able
@ -52,6 +65,35 @@ impl PodHandle {
}
}
async fn set_controller_status(
shared_state: &Arc<PodSharedState>,
runtime_dir: &RuntimeDir,
event_tx: &broadcast::Sender<Event>,
status: PodStatus,
) {
shared_state.set_status(status);
let _ = runtime_dir.write_status(shared_state).await;
let _ = event_tx.send(Event::Status { status });
}
async fn finish_controller_run<C, St>(
pod: &mut Pod<C, St>,
shared_state: &Arc<PodSharedState>,
runtime_dir: &RuntimeDir,
event_tx: &broadcast::Sender<Event>,
new_status: PodStatus,
) where
C: LlmClient + Clone + 'static,
St: Store + Clone + 'static,
{
let items = pod.worker().history().to_vec();
shared_state.update_history(items);
shared_state.set_user_segments(pod.user_segments().to_vec());
set_controller_status(shared_state, runtime_dir, event_tx, new_status).await;
let _ = runtime_dir.write_history(shared_state).await;
pod.spawn_post_run_memory_jobs();
}
// ---------------------------------------------------------------------------
// PodController — actor that owns a Pod
// ---------------------------------------------------------------------------
@ -66,8 +108,8 @@ impl PodController {
runtime_base: &Path,
) -> Result<(PodHandle, ShutdownReceiver), std::io::Error>
where
C: LlmClient + 'static,
St: Store + 'static,
C: LlmClient + Clone + 'static,
St: Store + Clone + 'static,
{
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
let (method_tx, mut method_rx) = mpsc::channel::<Method>(32);
@ -246,6 +288,14 @@ impl PodController {
alerter_for_worker.alert(AlertLevel::Warn, AlertSource::Worker, message.to_owned());
});
let tx = event_tx.clone();
worker.on_history_append(move |item| {
if is_system_message_item(item) {
let value = serde_json::to_value(item).expect("Item is Serialize");
let _ = tx.send(Event::SystemMessage { item: value });
}
});
// Register the builtin file-manipulation tools (Read / Write /
// Edit / Glob / Grep / Bash). `ScopedFs` carries the pod-
// lifetime scope/pwd; `Tracker` is session-scoped — a fresh
@ -395,8 +445,13 @@ impl PodController {
let _ = event_tx.send(Event::UserMessage {
segments: input.clone(),
});
shared_state.set_status(PodStatus::Running);
let _ = runtime_dir.write_status(&shared_state).await;
set_controller_status(
&shared_state,
&runtime_dir,
&event_tx,
PodStatus::Running,
)
.await;
let run_future = async {
if was_paused {
@ -418,39 +473,14 @@ impl PodController {
)
.await;
if new_status == PodStatus::Idle {
if let Err(e) = pod.try_post_run_extract().await {
tracing::warn!(error = %e, "Post-run memory extract error");
alerter.alert(
AlertLevel::Warn,
AlertSource::Pod,
format!("post-run memory extract error: {e}"),
);
}
if let Err(e) = pod.try_post_run_consolidate().await {
tracing::warn!(error = %e, "Post-run memory consolidate error");
alerter.alert(
AlertLevel::Warn,
AlertSource::Pod,
format!("post-run memory consolidate error: {e}"),
);
}
if let Err(e) = pod.try_post_run_compact().await {
tracing::warn!(error = %e, "Post-run compaction error");
alerter.alert(
AlertLevel::Warn,
AlertSource::Compactor,
format!("post-run compaction error: {e}"),
);
}
}
let items = pod.worker().history().to_vec();
shared_state.update_history(items);
shared_state.set_user_segments(pod.user_segments().to_vec());
shared_state.set_status(new_status);
let _ = runtime_dir.write_status(&shared_state).await;
let _ = runtime_dir.write_history(&shared_state).await;
finish_controller_run(
&mut pod,
&shared_state,
&runtime_dir,
&event_tx,
new_status,
)
.await;
if shutdown {
let _ = event_tx.send(Event::Shutdown);
@ -463,17 +493,23 @@ impl PodController {
message: message.clone(),
});
pod.push_notify(message);
if shared_state.get_status() != PodStatus::Idle {
let status = shared_state.get_status();
if status != PodStatus::Idle {
// RUNNING / Paused: the buffer push is the
// entire operation; the in-flight turn (or
// next Resume) will drain the buffer at its
// next pre_llm_request.
// entire operation; an in-flight turn (or the
// next Resume/Run) will drain the buffer
// at its next pre_llm_request.
continue;
}
// IDLE: auto-start a turn so the LLM sees the
// buffered notification(s) without a human Run.
shared_state.set_status(PodStatus::Running);
let _ = runtime_dir.write_status(&shared_state).await;
set_controller_status(
&shared_state,
&runtime_dir,
&event_tx,
PodStatus::Running,
)
.await;
let (new_status, shutdown) = run_with_cancel_support(
pod.run_for_notification(),
@ -488,39 +524,14 @@ impl PodController {
)
.await;
if new_status == PodStatus::Idle {
if let Err(e) = pod.try_post_run_extract().await {
tracing::warn!(error = %e, "Post-run memory extract error");
alerter.alert(
AlertLevel::Warn,
AlertSource::Pod,
format!("post-run memory extract error: {e}"),
);
}
if let Err(e) = pod.try_post_run_consolidate().await {
tracing::warn!(error = %e, "Post-run memory consolidate error");
alerter.alert(
AlertLevel::Warn,
AlertSource::Pod,
format!("post-run memory consolidate error: {e}"),
);
}
if let Err(e) = pod.try_post_run_compact().await {
tracing::warn!(error = %e, "Post-run compaction error");
alerter.alert(
AlertLevel::Warn,
AlertSource::Compactor,
format!("post-run compaction error: {e}"),
);
}
}
let items = pod.worker().history().to_vec();
shared_state.update_history(items);
shared_state.set_user_segments(pod.user_segments().to_vec());
shared_state.set_status(new_status);
let _ = runtime_dir.write_status(&shared_state).await;
let _ = runtime_dir.write_history(&shared_state).await;
finish_controller_run(
&mut pod,
&shared_state,
&runtime_dir,
&event_tx,
new_status,
)
.await;
if shutdown {
let _ = event_tx.send(Event::Shutdown);
@ -536,8 +547,13 @@ impl PodController {
});
continue;
}
shared_state.set_status(PodStatus::Running);
let _ = runtime_dir.write_status(&shared_state).await;
set_controller_status(
&shared_state,
&runtime_dir,
&event_tx,
PodStatus::Running,
)
.await;
let (new_status, shutdown) = run_with_cancel_support(
pod.resume(),
@ -552,39 +568,14 @@ impl PodController {
)
.await;
if new_status == PodStatus::Idle {
if let Err(e) = pod.try_post_run_extract().await {
tracing::warn!(error = %e, "Post-run memory extract error");
alerter.alert(
AlertLevel::Warn,
AlertSource::Pod,
format!("post-run memory extract error: {e}"),
);
}
if let Err(e) = pod.try_post_run_consolidate().await {
tracing::warn!(error = %e, "Post-run memory consolidate error");
alerter.alert(
AlertLevel::Warn,
AlertSource::Pod,
format!("post-run memory consolidate error: {e}"),
);
}
if let Err(e) = pod.try_post_run_compact().await {
tracing::warn!(error = %e, "Post-run compaction error");
alerter.alert(
AlertLevel::Warn,
AlertSource::Compactor,
format!("post-run compaction error: {e}"),
);
}
}
let items = pod.worker().history().to_vec();
shared_state.update_history(items);
shared_state.set_user_segments(pod.user_segments().to_vec());
shared_state.set_status(new_status);
let _ = runtime_dir.write_status(&shared_state).await;
let _ = runtime_dir.write_history(&shared_state).await;
finish_controller_run(
&mut pod,
&shared_state,
&runtime_dir,
&event_tx,
new_status,
)
.await;
if shutdown {
let _ = event_tx.send(Event::Shutdown);
@ -600,11 +591,12 @@ impl PodController {
}
Method::Pause => {
// Already paused → idempotent no-op. Otherwise
// the Pod is Idle (Running turns go through
// `run_with_cancel_support`, not this outer
// match), so there is nothing to pause.
if shared_state.get_status() != PodStatus::Paused {
// Already paused → idempotent no-op. Otherwise the
// Pod is Idle (Running turns go through
// `run_with_cancel_support`, not this outer match), so
// there is nothing to pause.
let status = shared_state.get_status();
if status != PodStatus::Paused {
let _ = event_tx.send(Event::Error {
code: ErrorCode::NotRunning,
message: "Pod is not running".into(),
@ -647,8 +639,13 @@ impl PodController {
// notification is not stranded. Matches the
// `Method::Notify` idle path.
if shared_state.get_status() == PodStatus::Idle {
shared_state.set_status(PodStatus::Running);
let _ = runtime_dir.write_status(&shared_state).await;
set_controller_status(
&shared_state,
&runtime_dir,
&event_tx,
PodStatus::Running,
)
.await;
let (new_status, shutdown) = run_with_cancel_support(
pod.run_for_notification(),
@ -663,39 +660,14 @@ impl PodController {
)
.await;
if new_status == PodStatus::Idle {
if let Err(e) = pod.try_post_run_extract().await {
tracing::warn!(error = %e, "Post-run memory extract error");
alerter.alert(
AlertLevel::Warn,
AlertSource::Pod,
format!("post-run memory extract error: {e}"),
);
}
if let Err(e) = pod.try_post_run_consolidate().await {
tracing::warn!(error = %e, "Post-run memory consolidate error");
alerter.alert(
AlertLevel::Warn,
AlertSource::Pod,
format!("post-run memory consolidate error: {e}"),
);
}
if let Err(e) = pod.try_post_run_compact().await {
tracing::warn!(error = %e, "Post-run compaction error");
alerter.alert(
AlertLevel::Warn,
AlertSource::Compactor,
format!("post-run compaction error: {e}"),
);
}
}
let items = pod.worker().history().to_vec();
shared_state.update_history(items);
shared_state.set_user_segments(pod.user_segments().to_vec());
shared_state.set_status(new_status);
let _ = runtime_dir.write_status(&shared_state).await;
let _ = runtime_dir.write_history(&shared_state).await;
finish_controller_run(
&mut pod,
&shared_state,
&runtime_dir,
&event_tx,
new_status,
)
.await;
if shutdown {
let _ = event_tx.send(Event::Shutdown);
@ -706,6 +678,11 @@ impl PodController {
}
}
// Background memory jobs own extract/consolidate workers after a
// turn completes. Join them before the controller task exits so
// staging writes and consolidation cleanups are not abandoned.
pod.wait_for_memory_jobs().await;
// Report upward that this Pod is stopping before the
// controller task exits. Awaited (not fire-and-forget):
// after `shutdown_tx.send` the process may exit quickly,

View File

@ -17,9 +17,9 @@ use llm_worker::interceptor::{
Interceptor, PostToolAction, PreRequestAction, PreToolAction, PromptAction, ToolCallInfo,
ToolResultInfo, TurnEndAction,
};
use tracing::warn;
use llm_worker::tool::ToolOutput;
use tracing::info;
use tracing::warn;
use crate::compact::state::CompactState;
use crate::hook::{

View File

@ -159,10 +159,12 @@ async fn handle_connection(stream: tokio::net::UnixStream, handle: PodHandle) {
})
.collect();
let greeting = handle.shared_state.greeting.clone();
let status = handle.shared_state.get_status();
if writer
.write(&Event::History {
items: values,
greeting,
status,
})
.await
.is_err()

View File

@ -26,7 +26,7 @@ pub use pod::{Pod, PodError, PodRunResult, apply_worker_manifest};
pub use prompt::catalog::{CatalogError, PodPrompt, PromptCatalog};
pub use prompt::loader::PromptLoader;
pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
pub use protocol::{ErrorCode, Event, Method, TurnResult};
pub use protocol::{ErrorCode, Event, Method, PodStatus, TurnResult};
pub use provider::{ProviderError, build_client};
pub use runtime::dir::RuntimeDir;
pub use shared_state::{PodSharedState, PodStatus};
pub use shared_state::PodSharedState;

View File

@ -1,17 +1,18 @@
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tokio::sync::Mutex as AsyncMutex;
use llm_worker::Item;
use llm_worker::llm_client::RequestConfig;
use llm_worker::llm_client::client::LlmClient;
use llm_worker::state::Mutable;
use llm_worker::{ToolOutputLimits, UsageRecord, Worker, WorkerError, WorkerResult};
use llm_worker::{Role, ToolOutputLimits, UsageRecord, Worker, WorkerError, WorkerResult};
use session_store::{EntryHash, PodScopeSnapshot, SessionId, SessionStartState, Store, StoreError};
use tracing::{info, warn};
use manifest::{
PodManifest, PodManifestConfig, ResolveError, Scope, ScopeConfig, ScopeError, ScopeRule,
Permission, PodManifest, PodManifestConfig, ResolveError, Scope, ScopeConfig, ScopeError, ScopeRule,
SharedScope, WorkerManifest,
};
@ -35,6 +36,12 @@ use async_trait::async_trait;
use llm_worker::interceptor::PreRequestAction;
use protocol::{AlertLevel, AlertSource, Event, Segment};
use tokio::sync::broadcast;
use tokio::task::JoinHandle;
struct SessionHead {
session_id: SessionId,
head_hash: Option<EntryHash>,
}
/// Pre-LLM-request hook that records `history.len()` at send time into a
/// shared `UsageTracker`. The on_usage callback later pairs this with the
@ -61,7 +68,7 @@ pub struct Pod<C: LlmClient, St: Store> {
worker: Option<Worker<C, Mutable>>,
store: St,
session_id: SessionId,
head_hash: Option<EntryHash>,
session_head: Arc<AsyncMutex<SessionHead>>,
/// Absolute working directory of the Pod.
pwd: PathBuf,
/// Shared, atomically-swappable view of the Pod's resolved scope.
@ -141,8 +148,8 @@ pub struct Pod<C: LlmClient, St: Store> {
/// [`Self::from_manifest`], or defaults to the builtin pack when a
/// Pod is constructed through lower-level paths that have no loader.
prompts: Arc<PromptCatalog>,
/// Registry loaded from `<workspace>/.insomnia/memory/workflow/*.md`
/// when memory is enabled. Missing memory config keeps this empty.
/// Registry loaded from `<workspace>/.insomnia/workflow/*.md` when
/// memory is enabled. Missing memory config keeps this empty.
workflow_registry: memory::WorkflowRegistry,
/// Memory workspace layout used by the workflow resolver to load required
/// Knowledge records by exact slug.
@ -172,7 +179,13 @@ pub struct Pod<C: LlmClient, St: Store> {
/// run yet on this session — next extract starts from entry 0.
/// Restored from `RestoredState.extensions` on `restore`, updated
/// after each successful extract via `save_extension`.
extract_pointer: Mutex<Option<memory::ExtractPointerPayload>>,
extract_pointer: Arc<Mutex<Option<memory::ExtractPointerPayload>>>,
/// Phase 1/2 memory job running outside the controller method loop.
/// The task owns the extract/consolidate worker execution and is joined
/// at shutdown. A single slot is enough: Phase 1/2 implementations loop
/// until thresholds fall below their trigger points, and concurrent
/// triggers are coalesced by skipping when this handle is still active.
memory_task: Option<JoinHandle<()>>,
/// Typed user submissions in submit order. K-th entry corresponds to
/// the K-th `Item::user_message` in `worker.history()` (modulo seed
/// history loaded via `SessionStart.history`, whose original segments
@ -182,6 +195,84 @@ pub struct Pod<C: LlmClient, St: Store> {
user_segments: Vec<Vec<Segment>>,
}
impl<C: LlmClient + 'static, St: Store + 'static> Pod<C, St> {
pub async fn wait_for_memory_jobs(&mut self) {
if let Some(handle) = self.memory_task.take()
&& let Err(e) = handle.await
{
tracing::warn!(error = %e, "Post-run memory task join failed");
}
}
}
impl<C: LlmClient + Clone + 'static, St: Store + Clone + 'static> Pod<C, St> {
fn clone_for_memory_task(&self) -> Self {
// The cloned Pod's worker exists only as a snapshot for the memory
// task: `run_extract_once` reads `worker.history()`, and the
// extract/consolidate workers are built fresh inside their own
// methods using `worker.client()` as fallback when no override
// model is configured. system_prompt / request_config / cache_key
// are unused on this path, so we deliberately skip copying them.
let source_worker = self.worker.as_ref().expect("worker present");
let mut worker = Worker::new(source_worker.client().clone());
worker.set_history(source_worker.history().to_vec());
Self {
manifest: self.manifest.clone(),
worker: Some(worker),
store: self.store.clone(),
session_id: self.session_id,
session_head: self.session_head.clone(),
pwd: self.pwd.clone(),
scope: self.scope.clone(),
hook_builder: HookRegistryBuilder::new(),
interceptor_installed: false,
compact_state: None,
usage_tracker: Arc::new(UsageTracker::new()),
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
usage_history: self.usage_history.clone(),
tracker: None,
task_store: self.task_store.clone(),
system_prompt_template: None,
alerter: self.alerter.clone(),
event_tx: self.event_tx.clone(),
pending_notifies: NotifyBuffer::new(),
pending_attachments: Arc::new(Mutex::new(Vec::new())),
scope_allocation: None,
callback_socket: None,
prompts: self.prompts.clone(),
workflow_registry: self.workflow_registry.clone(),
memory_layout: self.memory_layout.clone(),
inject_resident_knowledge: self.inject_resident_knowledge,
pending_scope_snapshot: self.pending_scope_snapshot.clone(),
extract_in_flight: self.extract_in_flight.clone(),
consolidation_in_flight: self.consolidation_in_flight.clone(),
extract_pointer: self.extract_pointer.clone(),
memory_task: None,
user_segments: self.user_segments.clone(),
}
}
pub fn spawn_post_run_memory_jobs(&mut self) {
// Drop a finished prior handle so we can spawn a fresh task.
// If the prior task is still running, coalesce by skipping —
// Phase 1/2 implementations re-evaluate thresholds on completion.
self.cleanup_finished_memory_task();
if self.memory_task.is_some() {
return;
}
let mut pod = self.clone_for_memory_task();
self.memory_task = Some(tokio::spawn(async move {
if let Err(e) = pod.try_post_run_extract().await {
tracing::warn!(error = %e, "Post-run memory extract task error");
}
if let Err(e) = pod.try_post_run_consolidate().await {
tracing::warn!(error = %e, "Post-run memory consolidate task error");
}
}));
}
}
impl<C: LlmClient, St: Store> Pod<C, St> {
/// Create a new Pod from a pre-built Worker and store.
///
@ -209,8 +300,11 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
manifest,
worker: Some(worker),
store,
session_id,
session_head: Arc::new(AsyncMutex::new(SessionHead {
session_id,
head_hash: None,
})),
pwd,
scope: SharedScope::new(scope),
hook_builder: HookRegistryBuilder::new(),
@ -235,7 +329,8 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
pending_scope_snapshot: Arc::new(Mutex::new(None)),
extract_in_flight: Arc::new(AtomicBool::new(false)),
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
extract_pointer: Mutex::new(None),
extract_pointer: Arc::new(Mutex::new(None)),
memory_task: None,
user_segments: Vec::new(),
};
pod.apply_prune_from_manifest();
@ -330,7 +425,8 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
/// can restore the narrowed scope instead of reclaiming delegated
/// writes.
pub async fn persist_scope_snapshot(&mut self) -> Result<(), StoreError> {
if self.head_hash.is_none() {
let mut head = self.session_head.lock().await;
if head.head_hash.is_none() {
return Ok(());
}
let snapshot = {
@ -340,7 +436,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
deny: scope.deny_rules(),
}
};
session_store::save_pod_scope(&self.store, self.session_id, &mut self.head_hash, &snapshot)
session_store::save_pod_scope(&self.store, head.session_id, &mut head.head_hash, &snapshot)
.await
}
@ -362,10 +458,11 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
.expect("pending_scope_snapshot poisoned")
.take();
if let Some(snapshot) = snapshot {
let mut head = self.session_head.lock().await;
session_store::save_pod_scope(
&self.store,
self.session_id,
&mut self.head_hash,
head.session_id,
&mut head.head_hash,
&snapshot,
)
.await?;
@ -531,10 +628,11 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
/// (the entry is dropped) and a `Warn` alert + `tracing::warn!` are
/// emitted so the failure isn't completely silent.
async fn try_record_metric(&mut self, metric: &session_metrics::Metric) {
let mut head = self.session_head.lock().await;
if let Err(err) = session_metrics::record_metric(
&self.store,
self.session_id,
&mut self.head_hash,
head.session_id,
&mut head.head_hash,
metric,
)
.await
@ -557,6 +655,20 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
}
}
fn broadcast_system_message_item(&self, item: &Item) {
if !matches!(
item,
Item::Message {
role: Role::System,
..
}
) {
return;
}
let value = serde_json::to_value(item).expect("Item is Serialize");
self.send_event(Event::SystemMessage { item: value });
}
/// Push a `Method::Notify` (or rendered `Method::PodEvent`) entry
/// onto the pending buffer.
///
@ -789,6 +901,52 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
self.run(vec![Segment::text(s)]).await
}
/// Drop the prior memory_task handle if it has finished. Keep it if
/// still running so callers can decide whether to wait or coalesce.
fn cleanup_finished_memory_task(&mut self) {
if self.memory_task.as_ref().is_some_and(|h| h.is_finished()) {
self.memory_task = None;
}
}
/// Wait for the in-flight memory task (if any) to finish. Used before
/// compact rewrites history (extract reads the same history).
async fn join_memory_task(&mut self) {
if let Some(handle) = self.memory_task.take()
&& let Err(e) = handle.await
{
tracing::warn!(error = %e, "Memory task join failed");
}
}
/// Whether `try_pre_run_compact` would actually compact. The same
/// check is duplicated inside `try_pre_run_compact` itself for
/// defensive reasons; this is the gate for joining the memory task
/// before the compact runs.
fn should_pre_run_compact(&self) -> bool {
self.compact_state.as_ref().is_some_and(|s| {
!s.is_disabled()
&& !s.just_compacted()
&& s.exceeds_post_run(self.total_tokens().tokens)
})
}
/// Prelude shared by `run` / `run_for_notification` / `resume`.
/// Wires up worker hooks, ensures the session is materialized on the
/// store, and runs pre-run compact (joining any in-flight memory task
/// first so extract sees a stable history range).
async fn prepare_for_run(&mut self) -> Result<(), PodError> {
self.ensure_interceptor_installed();
self.ensure_system_prompt_materialized()?;
self.cleanup_finished_memory_task();
self.ensure_session_head().await?;
if self.should_pre_run_compact() {
self.join_memory_task().await;
}
self.try_pre_run_compact().await;
Ok(())
}
/// Send user input and run until the LLM turn completes.
///
/// `input` is a typed segment list (see [`protocol::Segment`]). The
@ -802,20 +960,22 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
/// the Worker is aborted, history is compacted, and execution resumes
/// automatically.
pub async fn run(&mut self, input: Vec<Segment>) -> Result<PodRunResult, PodError> {
self.ensure_interceptor_installed();
self.ensure_system_prompt_materialized()?;
self.ensure_session_head().await?;
self.prepare_for_run().await?;
// 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.
{
let mut head = self.session_head.lock().await;
self.session_id = head.session_id;
session_store::save_user_input(
&self.store,
self.session_id,
&mut self.head_hash,
head.session_id,
&mut head.head_hash,
input.clone(),
)
.await?;
}
self.user_segments.push(input.clone());
// Resolve `@<path>` refs and `/<slug>` workflow invocations to
@ -975,9 +1135,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
/// Worker's resume path issues the LLM request without a new
/// user turn.
pub async fn run_for_notification(&mut self) -> Result<PodRunResult, PodError> {
self.ensure_interceptor_installed();
self.ensure_system_prompt_materialized()?;
self.ensure_session_head().await?;
self.prepare_for_run().await?;
let history_before = self.worker.as_ref().unwrap().history().len();
@ -991,9 +1149,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
/// Resume from a paused state.
pub async fn resume(&mut self) -> Result<PodRunResult, PodError> {
self.ensure_interceptor_installed();
self.ensure_system_prompt_materialized()?;
self.ensure_session_head().await?;
self.prepare_for_run().await?;
let history_before = self.worker.as_ref().unwrap().history().len();
@ -1021,27 +1177,29 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
config: w.request_config(),
history: w.history(),
};
if self.head_hash.is_none() {
let mut head = self.session_head.lock().await;
if head.head_hash.is_none() {
let hash =
session_store::create_session_with_id(&self.store, self.session_id, state).await?;
self.head_hash = Some(hash);
session_store::create_session_with_id(&self.store, head.session_id, state).await?;
head.head_hash = Some(hash);
drop(head);
self.persist_scope_snapshot().await?;
return Ok(());
}
let prev_session_id = self.session_id;
session_store::ensure_head_or_fork(
&self.store,
&mut self.session_id,
&mut self.head_hash,
state,
)
let prev_session_id = head.session_id;
let mut session_id = head.session_id;
let mut head_hash = head.head_hash.clone();
session_store::ensure_head_or_fork(&self.store, &mut session_id, &mut head_hash, state)
.await?;
head.session_id = session_id;
head.head_hash = head_hash;
self.session_id = session_id;
// ensure_head_or_fork mints a fresh session_id when it auto-
// forks. Sync that to pods.json so a concurrent
// restore_from_manifest can't see "no live writer" for the new
// session and grab it.
if self.session_id != prev_session_id && self.scope_allocation.is_some() {
pod_registry::update_session(&self.manifest.pod.name, self.session_id)?;
if session_id != prev_session_id && self.scope_allocation.is_some() {
pod_registry::update_session(&self.manifest.pod.name, session_id)?;
}
Ok(())
}
@ -1128,17 +1286,21 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
})
}
/// Attempt proactive compaction (called by Controller after run).
/// Attempt proactive compaction at the beginning of a controller Run.
///
/// Best-effort: failures are logged but do not propagate.
pub async fn try_post_run_compact(&mut self) -> Result<(), PodError> {
/// This used to run in the controller's post-run path. Keeping it here
/// preserves the ordering requirement that the next turn starts with a
/// compacted history, without introducing a separate Busy controller state.
/// Best-effort: failures are logged and surfaced, but do not abort the
/// user turn that triggered the check.
pub async fn try_pre_run_compact(&mut self) {
let state = match self.compact_state.as_ref() {
Some(s) if !s.is_disabled() && !s.just_compacted() => s.clone(),
_ => return Ok(()),
_ => return,
};
let current_tokens = self.total_tokens().tokens;
if !state.exceeds_post_run(current_tokens) {
return Ok(());
return;
}
let retained = state.retained_tokens();
@ -1147,24 +1309,22 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
Ok(new_session_id) => {
info!(
new_session_id = %new_session_id,
"Proactive post-run compaction succeeded"
"Proactive pre-run compaction succeeded"
);
self.send_event(Event::CompactDone { new_session_id });
state.record_compact_success();
Ok(())
}
Err(e) => {
warn!(error = %e, "Proactive post-run compaction failed");
warn!(error = %e, "Proactive pre-run compaction failed");
self.send_event(Event::CompactFailed {
error: e.to_string(),
});
self.alert(
AlertLevel::Warn,
AlertSource::Compactor,
format!("post-run compaction failed: {e}"),
format!("pre-run compaction failed: {e}"),
);
state.record_compact_failure();
Ok(())
}
}
}
@ -1179,19 +1339,24 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
// head_hash mutable).
let w = self.worker.as_ref().unwrap();
let new_items = &w.history()[history_before..];
session_store::save_delta(&self.store, self.session_id, &mut self.head_hash, new_items)
let mut head = self.session_head.lock().await;
self.session_id = head.session_id;
session_store::save_delta(&self.store, head.session_id, &mut head.head_hash, new_items)
.await?;
drop(head);
self.flush_pending_scope_snapshot().await?;
let turn_count = self.worker.as_ref().unwrap().turn_count();
let mut head = self.session_head.lock().await;
session_store::save_turn_end(
&self.store,
self.session_id,
&mut self.head_hash,
head.session_id,
&mut head.head_hash,
turn_count,
)
.await?;
drop(head);
// Flush any sync-buffered metrics from this run first
// (currently `prune.fire` / `prune.skip` from the prune observer).
@ -1224,10 +1389,11 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
record,
correlation_id,
} = recorded;
let mut head = self.session_head.lock().await;
session_store::save_usage(
&self.store,
self.session_id,
&mut self.head_hash,
head.session_id,
&mut head.head_hash,
record.history_len,
record.input_total_tokens,
record.cache_read_tokens,
@ -1235,6 +1401,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
record.output_tokens,
)
.await?;
drop(head);
if let Some(id) = correlation_id {
let metric = session_metrics::Metric::now("prune.post_request")
.with_correlation_id(&id)
@ -1252,20 +1419,22 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
let interrupted = self.worker.as_ref().unwrap().last_run_interrupted();
match result {
Ok(r) => {
let mut head = self.session_head.lock().await;
session_store::save_run_completed(
&self.store,
self.session_id,
&mut self.head_hash,
head.session_id,
&mut head.head_hash,
r.clone(),
interrupted,
)
.await?;
}
Err(e) => {
let mut head = self.session_head.lock().await;
session_store::save_run_errored(
&self.store,
self.session_id,
&mut self.head_hash,
head.session_id,
&mut head.head_hash,
e.to_string(),
interrupted,
)
@ -1466,19 +1635,29 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
+ reference_message.is_some() as usize
+ retained_items.len(),
);
new_history.push(Item::system_message(format!(
"[Compacted context summary]\n\n{summary_text}"
)));
let mut compact_introduced_system_messages =
Vec::with_capacity(2 + auto_read_messages.len() + reference_message.is_some() as usize);
let summary_message =
Item::system_message(format!("[Compacted context summary]\n\n{summary_text}"));
compact_introduced_system_messages.push(summary_message.clone());
compact_introduced_system_messages.extend(auto_read_messages.iter().cloned());
if let Some(msg) = reference_message.as_ref() {
compact_introduced_system_messages.push(msg.clone());
}
let task_snapshot_message = Item::system_message(format!(
"[Session TaskStore snapshot]\n\n{task_snapshot_text}\n\n\
This is the complete session task list preserved across compaction. \
The following TaskList tool result presents the same state through the tool lane."
));
compact_introduced_system_messages.push(task_snapshot_message.clone());
new_history.push(summary_message);
new_history.extend(auto_read_messages);
if let Some(msg) = reference_message {
new_history.push(msg);
}
new_history.extend(retained_items);
new_history.push(Item::system_message(format!(
"[Session TaskStore snapshot]\n\n{task_snapshot_text}\n\n\
This is the complete session task list preserved across compaction. \
The following TaskList tool result presents the same state through the tool lane."
)));
new_history.push(task_snapshot_message);
new_history.push(Item::tool_call("compact-tasklist", "TaskList", "{}"));
new_history.push(Item::tool_result_with_content(
"compact-tasklist",
@ -1487,8 +1666,9 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
));
// Persist as a new compacted session.
let old_session_id = self.session_id;
let old_head_hash = self
let mut head = self.session_head.lock().await;
let old_session_id = head.session_id;
let old_head_hash = head
.head_hash
.clone()
.expect("head_hash should be set after at least one entry");
@ -1511,7 +1691,8 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
// session — the new compacted session starts with no measurements
// until its first LLM call.
self.session_id = new_session_id;
self.head_hash = Some(new_head_hash);
head.session_id = new_session_id;
head.head_hash = Some(new_head_hash);
// Keep pods.json pointing at the live session_id. Without this
// a concurrent `restore_from_manifest(new_session_id)` would
// see no live writer and grab the session this Pod just moved
@ -1521,6 +1702,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
if self.scope_allocation.is_some() {
pod_registry::update_session(&self.manifest.pod.name, new_session_id)?;
}
drop(head);
// Align user_segments with the post-compaction history. Items
// before `retain_from` (now folded into the summary) lose their
// segments; only the user_messages surviving in retained_items
@ -1531,8 +1713,11 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
self.user_segments.drain(..drop_n);
}
self.worker.as_mut().unwrap().set_history(new_history);
for item in &compact_introduced_system_messages {
self.broadcast_system_message_item(item);
}
let worker = self.worker.as_mut().unwrap();
worker.set_history(new_history);
// Anchor the prompt cache at the summary item so that Anthropic
// can place a durable `cache_control` breakpoint there — our
// compact layout guarantees history[0] is the summary.
@ -1614,10 +1799,10 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
/// Phase 1 (memory.extract) post-run trigger.
///
/// Called by the Controller **before** [`try_post_run_compact`] so
/// the extract worker sees a stable session-log entry range
/// (compact rewrites history). Best-effort: failures are logged but
/// not propagated.
/// Called by the Controller before spawning the background memory task so
/// the extract worker sees a stable session-log entry range while compact
/// is deferred until the next turn starts. Best-effort: failures are
/// logged but not propagated.
///
/// Behaviour follows `docs/plan/memory.md` §Phase 1 並走防止:
/// in-flight 中の trigger は skip し、完了時点で閾値再評価する
@ -1771,11 +1956,12 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
extract::ExtractedPayload::default()
});
let source_session_id = self.session_head.lock().await.session_id;
let staging_id = if payload.is_empty() {
String::new()
} else {
let source = memory::schema::SourceRef {
session_id: self.session_id.to_string(),
session_id: source_session_id.to_string(),
range: [start_entry as u64, end_entry as u64],
};
let (id, _) = extract::write_staging(&layout, source, payload)
@ -1790,14 +1976,18 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
};
let payload_value = serde_json::to_value(&pointer_payload)
.expect("ExtractPointerPayload is always JSON-serializable");
{
let mut head = self.session_head.lock().await;
session_store::save_extension(
&self.store,
self.session_id,
&mut self.head_hash,
head.session_id,
&mut head.head_hash,
extract::EXTRACT_DOMAIN,
payload_value,
)
.await?;
self.session_id = head.session_id;
}
*self
.extract_pointer
@ -1823,12 +2013,11 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
Ok(worker.client().clone_boxed())
}
/// Phase 2 (memory.consolidation) post-run trigger.
/// Phase 2 (memory.consolidation) trigger.
///
/// Called by the Controller **after** [`try_post_run_extract`] and
/// **before** [`try_post_run_compact`]: extract feeds staging, compact
/// rewrites history. Phase 2 must consume staging before compact
/// reshapes the session.
/// Intended to run from a background memory task after Phase 1 may have
/// added staging entries. Compact is deferred until the next turn starts,
/// so consolidation no longer blocks the controller's post-run path.
///
/// Behaviour follows `docs/plan/memory.md` §Phase 2 / §並走防止:
/// the staging-side `StagingLock` enforces cross-process exclusion;
@ -2036,7 +2225,8 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
store: St,
loader: PromptLoader,
) -> Result<Self, PodError> {
let common = prepare_pod_common(&manifest, &loader, /* parse_template */ true)?;
let mut common = prepare_pod_common(&manifest, &loader, /* parse_template */ true)?;
let skill_shadows = std::mem::take(&mut common.skill_shadows);
// Session creation is deferred to the first run (see
// `ensure_session_head`) so the SessionStart entry can capture
@ -2068,8 +2258,11 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
manifest,
worker: Some(worker),
store,
session_id,
session_head: Arc::new(AsyncMutex::new(SessionHead {
session_id,
head_hash: None,
})),
pwd: common.pwd,
scope: SharedScope::new(common.scope),
hook_builder: HookRegistryBuilder::new(),
@ -2094,10 +2287,12 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
pending_scope_snapshot: Arc::new(Mutex::new(None)),
extract_in_flight: Arc::new(AtomicBool::new(false)),
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
extract_pointer: Mutex::new(None),
extract_pointer: Arc::new(Mutex::new(None)),
memory_task: None,
user_segments: Vec::new(),
};
pod.apply_prune_from_manifest();
drain_skill_shadows(&pod, skill_shadows);
Ok(pod)
}
@ -2115,7 +2310,8 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
loader: PromptLoader,
callback_socket: PathBuf,
) -> Result<Self, PodError> {
let common = prepare_pod_common(&manifest, &loader, /* parse_template */ true)?;
let mut common = prepare_pod_common(&manifest, &loader, /* parse_template */ true)?;
let skill_shadows = std::mem::take(&mut common.skill_shadows);
let session_id = session_store::new_session_id();
let scope_allocation = pod_registry::adopt_allocation(
@ -2132,8 +2328,11 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
manifest,
worker: Some(worker),
store,
session_id,
session_head: Arc::new(AsyncMutex::new(SessionHead {
session_id,
head_hash: None,
})),
pwd: common.pwd,
scope: SharedScope::new(common.scope),
hook_builder: HookRegistryBuilder::new(),
@ -2158,10 +2357,12 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
pending_scope_snapshot: Arc::new(Mutex::new(None)),
extract_in_flight: Arc::new(AtomicBool::new(false)),
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
extract_pointer: Mutex::new(None),
extract_pointer: Arc::new(Mutex::new(None)),
memory_task: None,
user_segments: Vec::new(),
};
pod.apply_prune_from_manifest();
drain_skill_shadows(&pod, skill_shadows);
Ok(pod)
}
@ -2198,7 +2399,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
.clone()
.ok_or(PodError::SessionScopeMissing { session_id })?;
let common = prepare_pod_common_with_scope(
let mut common = prepare_pod_common_with_scope(
&manifest,
&loader,
/* parse_template */ false,
@ -2207,6 +2408,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
deny: scope_snapshot.deny,
},
)?;
let skill_shadows = std::mem::take(&mut common.skill_shadows);
// Atomic: register_pod inside install_top_level rejects when
// another live allocation already holds `session_id`. Wrapping
@ -2260,8 +2462,11 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
manifest,
worker: Some(worker),
store,
session_id,
session_head: Arc::new(AsyncMutex::new(SessionHead {
session_id,
head_hash: state.head_hash,
})),
pwd: common.pwd,
scope: SharedScope::new(common.scope),
hook_builder: HookRegistryBuilder::new(),
@ -2288,10 +2493,12 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
pending_scope_snapshot: Arc::new(Mutex::new(None)),
extract_in_flight: Arc::new(AtomicBool::new(false)),
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
extract_pointer: Mutex::new(extract_pointer),
extract_pointer: Arc::new(Mutex::new(extract_pointer)),
memory_task: None,
user_segments: state.user_segments,
};
pod.apply_prune_from_manifest();
drain_skill_shadows(&pod, skill_shadows);
Ok(pod)
}
@ -2533,6 +2740,11 @@ struct PodCommon {
workflow_registry: memory::WorkflowRegistry,
memory_layout: Option<memory::WorkspaceLayout>,
system_prompt_template: Option<SystemPromptTemplate>,
/// SKILL.md shadow events surfaced during workflow-registry build.
/// The Pod constructor drains these into the notify buffer right
/// after the Pod is materialised so the first LLM request observes
/// any skill ↔ workflow collisions.
skill_shadows: Vec<memory::ShadowedSkill>,
}
/// Resolve pwd / scope / LLM client / prompt catalog from a validated
@ -2583,10 +2795,11 @@ fn prepare_pod_common_from_scope(
.memory
.as_ref()
.map(|mem| memory::WorkspaceLayout::resolve(mem, &pwd));
let workflow_registry = match memory_layout.as_ref() {
let mut workflow_registry = match memory_layout.as_ref() {
Some(layout) => memory::load_workflows(layout).map_err(PodError::WorkflowLoad)?,
None => memory::WorkflowRegistry::empty(),
};
let skill_shadows = ingest_skills(&mut workflow_registry, manifest);
let system_prompt_template = if parse_template {
Some(
@ -2605,24 +2818,88 @@ fn prepare_pod_common_from_scope(
workflow_registry,
memory_layout,
system_prompt_template,
skill_shadows,
})
}
/// Ingest external SKILL.md sources into the workflow registry.
///
/// Skills come exclusively from the manifest's `[skills] directories`
/// list (resolved against the manifest base directory). Internal
/// Workflows already loaded via [`memory::load_workflows`] take priority
/// over skills sharing the same slug; collisions are surfaced as
/// [`memory::ShadowedSkill`] events that the caller pushes onto the
/// Pod's notification buffer.
fn ingest_skills(
registry: &mut memory::WorkflowRegistry,
manifest: &PodManifest,
) -> Vec<memory::ShadowedSkill> {
let mut shadows = Vec::new();
let Some(skills_cfg) = manifest.skills.as_ref() else {
return shadows;
};
for dir in &skills_cfg.directories {
for skill in memory::load_skills_from_dir(dir) {
let source = memory::WorkflowSource::Skill { dir: dir.clone() };
let record = skill.into_workflow_record(source);
if let Some(shadow) = registry.merge_skill(record) {
shadows.push(shadow);
}
}
}
shadows
}
/// Drain skill-ingest shadow events into the Pod's notify buffer so the
/// first LLM request renders them as system-message attachments.
fn drain_skill_shadows<C, S>(pod: &Pod<C, S>, shadows: Vec<memory::ShadowedSkill>)
where
C: LlmClient,
S: Store,
{
for shadow in shadows {
pod.push_notify(format!("[Skill shadowed] {}", shadow.message()));
}
}
/// Build the Pod's runtime [`Scope`] from the manifest, layering the
/// memory subsystem's deny-write rules on top when `[memory]` is
/// present. The deny rules cap generic CRUD tools so they cannot
/// touch `<workspace>/memory/` or `<workspace>/knowledge/` while the
/// memory tools (registered separately) bypass `ScopedFs` and write
/// through `std::fs` directly.
/// present, and read-allow rules for any external Agent Skills
/// directories ingested. The deny rules cap generic CRUD tools so they
/// cannot touch `<workspace>/memory/` or `<workspace>/knowledge/` while
/// the memory tools (registered separately) bypass `ScopedFs` and write
/// through `std::fs` directly. Skill directories are added at
/// `Permission::Read` so the agent can `Read` `scripts/` / `references/`
/// / `assets/` referenced by the Workflow body.
fn build_scope_with_memory(manifest: &PodManifest, pwd: &Path) -> Result<Scope, PodError> {
let mut scope_config = manifest.scope.clone();
if let Some(mem) = manifest.memory.as_ref() {
let layout = memory::WorkspaceLayout::resolve(mem, pwd);
scope_config.deny.extend(memory::deny_write_rules(&layout));
}
scope_config.allow.extend(skill_dir_read_rules(manifest));
Scope::from_config(&scope_config).map_err(PodError::Scope)
}
/// Allow-rules granting `Read` access to every skill directory the Pod
/// will ingest from the manifest's `[skills] directories`. Returned
/// rules are recursive so the entire skill bundle (`SKILL.md` +
/// `scripts/` + `references/` + `assets/`) is readable.
fn skill_dir_read_rules(manifest: &PodManifest) -> Vec<ScopeRule> {
let Some(skills_cfg) = manifest.skills.as_ref() else {
return Vec::new();
};
skills_cfg
.directories
.iter()
.map(|dir| ScopeRule {
target: dir.clone(),
permission: Permission::Read,
recursive: true,
})
.collect()
}
/// Snapshot the process's current working directory as the Pod's pwd,
/// canonicalising symlinks and any `.`/`..` components. The Pod keeps
/// this value for its lifetime; changes to the process-wide cwd after
@ -2710,4 +2987,86 @@ mod build_summary_prompt_tests {
let prompt = build_summary_prompt(&items);
assert_eq!(prompt, "[User] fix the bug\n\n[Assistant] done");
}
fn minimal_manifest_with_skills(dirs: Vec<PathBuf>) -> PodManifest {
// Construct the smallest possible PodManifest that resolves; only
// the `skills` field matters for `skill_dir_read_rules`.
let toml_str = r#"
[pod]
name = "x"
[model]
scheme = "anthropic"
model_id = "claude-sonnet-4-20250514"
[worker]
[[scope.allow]]
target = "/abs/scope"
permission = "write"
"#;
let mut manifest = PodManifest::from_toml(toml_str).unwrap();
if !dirs.is_empty() {
manifest.skills = Some(manifest::SkillsConfig { directories: dirs });
}
manifest
}
#[test]
fn skill_dir_read_rules_lists_workspace_skill_directories() {
let manifest = minimal_manifest_with_skills(vec![
PathBuf::from("/abs/skills-a"),
PathBuf::from("/abs/skills-b"),
]);
let rules = skill_dir_read_rules(&manifest);
let workspace_rules: Vec<_> = rules
.iter()
.filter(|r| {
r.target == PathBuf::from("/abs/skills-a")
|| r.target == PathBuf::from("/abs/skills-b")
})
.collect();
assert_eq!(workspace_rules.len(), 2);
for rule in &workspace_rules {
assert_eq!(rule.permission, Permission::Read);
assert!(rule.recursive);
}
}
#[test]
fn skill_dir_read_rules_empty_when_skills_section_missing() {
let manifest = minimal_manifest_with_skills(vec![]);
let rules = skill_dir_read_rules(&manifest);
assert!(rules.is_empty());
}
#[test]
fn ingest_skills_returns_empty_when_skills_section_missing() {
let manifest = minimal_manifest_with_skills(vec![]);
let mut registry = memory::WorkflowRegistry::empty();
let shadows = ingest_skills(&mut registry, &manifest);
assert!(shadows.is_empty());
assert!(registry.is_empty());
}
#[test]
fn ingest_skills_loads_from_workspace_directories() {
let dir = tempfile::tempdir().unwrap();
let skills_root = dir.path().join("skills");
std::fs::create_dir_all(skills_root.join("alpha")).unwrap();
std::fs::write(
skills_root.join("alpha").join("SKILL.md"),
"---\nname: alpha\ndescription: Alpha skill\n---\nbody\n",
)
.unwrap();
let manifest = minimal_manifest_with_skills(vec![skills_root.clone()]);
let mut registry = memory::WorkflowRegistry::empty();
let shadows = ingest_skills(&mut registry, &manifest);
// workspace skill `alpha` should be registered (no collision).
assert!(registry.get(&memory::Slug::parse("alpha").unwrap()).is_some());
// No workflow exists to shadow `alpha`, so no shadow event for it.
assert!(shadows.iter().all(|s| s.slug.as_str() != "alpha"));
}
}

View File

@ -154,7 +154,7 @@ pub struct SystemPromptContext<'a> {
/// section entirely (memory disabled, or a Phase 2 worker that opts
/// out); `Some(&[])` also yields no section.
pub resident_knowledge: Option<&'a [ResidentKnowledgeEntry]>,
/// Resident workflow descriptions from `<workspace>/memory/workflow/*`
/// Resident workflow descriptions from `<workspace>/.insomnia/workflow/*`
/// whose frontmatter has `model_invokation: true`. `None` disables the
/// section; Phase 2 workers opt out together with resident Knowledge.
pub resident_workflows: Option<&'a [ResidentWorkflowEntry]>,

View File

@ -131,7 +131,8 @@ pub fn default_base() -> Result<PathBuf, io::Error> {
#[cfg(test)]
mod tests {
use super::*;
use crate::shared_state::{PodSharedState, PodStatus};
use crate::shared_state::PodSharedState;
use protocol::PodStatus;
fn test_state() -> PodSharedState {
PodSharedState::new(

View File

@ -1,8 +1,8 @@
use std::sync::{OnceLock, RwLock};
use llm_worker::llm_client::types::Item;
use protocol::Segment;
use serde::{Deserialize, Serialize};
use protocol::{PodStatus, Segment};
use serde_json::json;
use session_store::SessionId;
use crate::fs_view::PodFsView;
@ -39,14 +39,6 @@ pub struct PodSharedState {
workflows: OnceLock<Vec<WorkflowCandidate>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PodStatus {
Idle,
Running,
Paused,
}
impl PodSharedState {
pub fn new(
pod_name: String,
@ -138,7 +130,7 @@ impl PodSharedState {
/// Serialize status as JSON.
pub fn status_json(&self) -> String {
let status = self.get_status();
serde_json::json!({
json!({
"state": status,
"session_id": self.session_id.to_string(),
"pod_name": self.pod_name,

View File

@ -147,7 +147,7 @@ mod tests {
"---\ncreated_at: 2026-01-01T00:00:00Z\nupdated_at: 2026-01-01T00:00:00Z\nkind: policy\ndescription: p\nmodel_invokation: false\nuser_invocable: true\nlast_sources: []\n---\npolicy body\n",
);
write(
&dir.path().join(".insomnia/memory/workflow/run-it.md"),
&dir.path().join(".insomnia/workflow/run-it.md"),
"---\ndescription: run\nrequires: [policy]\n---\nworkflow body\n",
);
let registry = memory::load_workflows(&layout).unwrap();
@ -171,7 +171,7 @@ mod tests {
fn user_invocable_false_errors() {
let (dir, layout, _registry) = setup();
write(
&dir.path().join(".insomnia/memory/workflow/hidden.md"),
&dir.path().join(".insomnia/workflow/hidden.md"),
"---\ndescription: hidden\nuser_invocable: false\n---\nbody\n",
);
let registry = memory::load_workflows(&layout).unwrap();
@ -184,7 +184,7 @@ mod tests {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
write(
&dir.path().join(".insomnia/memory/workflow/bad.md"),
&dir.path().join(".insomnia/workflow/bad.md"),
"---\ndescription: bad\nrequires: [ghost]\n---\nbody\n",
);
let registry = memory::load_workflows(&layout).unwrap();

View File

@ -1,8 +1,8 @@
//! Compact lifecycle `Event` broadcasting.
//!
//! Covers three paths:
//! - `try_post_run_compact` success → `CompactStart + CompactDone`
//! - `try_post_run_compact` failure → `CompactStart + CompactFailed`
//! - `try_pre_run_compact` success → `CompactStart + CompactDone`
//! - `try_pre_run_compact` failure → `CompactStart + CompactFailed`
//! - mid-turn `do_compact_and_resume` success → `CompactStart + CompactDone`
//! (driven by `compact_request_threshold` → `PreRequestAction::Yield`)
@ -14,6 +14,7 @@ use async_trait::async_trait;
use futures::Stream;
use llm_worker::Worker;
use llm_worker::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use llm_worker::llm_client::types::Item;
use llm_worker::llm_client::{ClientError, LlmClient, Request};
use protocol::Event;
use session_store::FsStore;
@ -95,7 +96,7 @@ fn write_summary_tool_use_events(call_id: &str, text: &str) -> Vec<LlmEvent> {
]
}
// A low compact_threshold guarantees `try_post_run_compact` will fire
// A low compact_threshold guarantees `try_pre_run_compact` will fire
// the first time we check after a run.
const POST_RUN_MANIFEST_TOML: &str = r#"
[pod]
@ -176,8 +177,58 @@ fn drain(rx: &mut broadcast::Receiver<Event>) -> Vec<Event> {
out
}
fn system_event_text(event: &Event) -> Option<&str> {
match event {
Event::SystemMessage { item } => item["content"]
.as_array()
.and_then(|parts| parts.iter().filter_map(|p| p["text"].as_str()).next()),
_ => None,
}
}
#[tokio::test]
async fn post_run_compact_success_broadcasts_start_and_done() {
async fn compact_broadcasts_only_new_system_messages_not_retained_ones() {
let client = MockClient::new(vec![
single_text_events("hi"),
write_summary_tool_use_events("call-1", "summary"),
single_text_events("done"),
]);
let mut pod = make_pod(client).await;
let (tx, mut rx) = broadcast::channel::<Event>(64);
pod.attach_event_tx(tx);
pod.run_text("first").await.unwrap();
let retained_message = Item::system_message("[Retained system]\nold");
pod.worker_mut().push_item(retained_message);
let _ = drain(&mut rx);
pod.compact(10_000).await.unwrap();
let events = drain(&mut rx);
let system_texts: Vec<&str> = events.iter().filter_map(system_event_text).collect();
assert!(
system_texts
.iter()
.any(|text| text.starts_with("[Compacted context summary]")),
"summary system message missing from {system_texts:?}"
);
assert!(
system_texts
.iter()
.any(|text| text.starts_with("[Session TaskStore snapshot]")),
"task snapshot system message missing from {system_texts:?}"
);
assert!(
!system_texts
.iter()
.any(|text| text.starts_with("[Retained system]")),
"retained system message should not be rebroadcast: {system_texts:?}"
);
}
#[tokio::test]
async fn pre_run_compact_success_broadcasts_start_and_done() {
// Responses: (1) first run returns short text, (2) compact worker
// emits write_summary then closes (two LLM calls inside the compact
// worker: one for write_summary, one that the compact loop consumes
@ -196,7 +247,7 @@ async fn post_run_compact_success_broadcasts_start_and_done() {
// Drain run events so only compact events remain in `rx`.
let _ = drain(&mut rx);
pod.try_post_run_compact().await.unwrap();
pod.try_pre_run_compact().await;
let events = drain(&mut rx);
let kinds: Vec<&str> = events
@ -361,7 +412,7 @@ async fn compact_resets_extract_pointer_so_phase1_can_fire_again() {
// Compact runs. Without the fix the in-memory pointer would still
// reference the old session's history_len.
pod.try_post_run_compact().await.unwrap();
pod.try_pre_run_compact().await;
assert!(
pod.extract_pointer().is_none(),
"extract_pointer must be reset to None after compact (matches cold-restore on the new session)"
@ -412,7 +463,7 @@ async fn extract_threshold_zero_is_disabled() {
}
#[tokio::test]
async fn post_run_compact_failure_broadcasts_start_and_failed() {
async fn pre_run_compact_failure_broadcasts_start_and_failed() {
// Only the first run has a response. Compaction will run the
// compact worker which immediately exhausts the mock → failure.
let client = MockClient::new(vec![single_text_events("hi")]);
@ -425,7 +476,7 @@ async fn post_run_compact_failure_broadcasts_start_and_failed() {
let _ = drain(&mut rx);
// Best-effort: returns Ok(()) even on failure, but emits CompactFailed.
pod.try_post_run_compact().await.unwrap();
pod.try_pre_run_compact().await;
let events = drain(&mut rx);
let kinds: Vec<&str> = events
@ -446,3 +497,85 @@ async fn post_run_compact_failure_broadcasts_start_and_failed() {
"unexpected CompactDone in {kinds:?}"
);
}
// ---------------------------------------------------------------------------
// Detached post-run memory jobs (`spawn_post_run_memory_jobs` /
// `wait_for_memory_jobs`). Covers the detach round-trip and the structural
// invariant that the cloned memory-task Pod shares `SessionHead` with the
// source Pod, so that `save_extension` from the background extract does not
// leave the next turn's `save_user_input` looking at a stale head_hash.
const EXTRACT_NO_COMPACT_MANIFEST: &str = r#"
[pod]
name = "test-pod"
pwd = "./"
[model]
scheme = "anthropic"
model_id = "test-model"
[worker]
max_tokens = 100
[memory]
extract_threshold = 1
[[scope.allow]]
target = "./"
permission = "write"
"#;
#[tokio::test]
async fn spawn_and_wait_drives_extract_to_completion() {
let client = MockClient::new(vec![
text_events_with_usage("hi", 1000),
write_extracted_tool_use_events("ec1"),
single_text_events("done"),
]);
let mut pod = make_pod_with_manifest(EXTRACT_NO_COMPACT_MANIFEST, client).await;
pod.run_text("first").await.unwrap();
assert!(
pod.extract_pointer().is_none(),
"extract has not run yet — pointer must be None"
);
pod.spawn_post_run_memory_jobs();
pod.wait_for_memory_jobs().await;
assert!(
pod.extract_pointer().is_some(),
"spawn + wait must complete extract; pointer should be set"
);
}
#[tokio::test]
async fn detached_extract_does_not_fork_session_log() {
// Source pod and the cloned memory-task pod share `SessionHead` via
// `Arc<AsyncMutex<_>>`. The detached extract advances head_hash through
// `save_extension`; the next `run` must see that same head_hash so
// `ensure_head_or_fork` does not spawn a new session.
let client = MockClient::new(vec![
text_events_with_usage("hi", 1000),
write_extracted_tool_use_events("ec1"),
single_text_events("done"),
text_events_with_usage("ok", 1000),
]);
let mut pod = make_pod_with_manifest(EXTRACT_NO_COMPACT_MANIFEST, client).await;
pod.run_text("first").await.unwrap();
let session_before = pod.session_id();
pod.spawn_post_run_memory_jobs();
pod.wait_for_memory_jobs().await;
pod.run_text("second").await.unwrap();
let session_after = pod.session_id();
assert_eq!(
session_before, session_after,
"detached extract's save_extension and the next turn's save_user_input \
must share head_hash through SessionHead a fork here means the clone \
carried its own head_hash"
);
}

View File

@ -206,10 +206,7 @@ async fn no_thresholds_is_a_noop() {
.expect("phase 2 disabled when both thresholds are None");
// No staging entries removed.
assert_eq!(
memory::consolidate::list_staging_entries(&layout).len(),
5
);
assert_eq!(memory::consolidate::list_staging_entries(&layout).len(), 5);
}
#[tokio::test]
@ -256,10 +253,7 @@ async fn below_threshold_skips_and_does_not_take_lock() {
pod.try_post_run_consolidate().await.unwrap();
// Staging untouched.
assert_eq!(
memory::consolidate::list_staging_entries(&layout).len(),
1
);
assert_eq!(memory::consolidate::list_staging_entries(&layout).len(), 1);
// Lock file must not exist.
let lock_path = layout.staging_dir().join(".consolidation.lock");
assert!(!lock_path.exists(), "lock file should not be created");
@ -285,10 +279,7 @@ async fn fires_on_threshold_and_cleans_up_consumed_entries() {
);
// Lock removed too.
let lock_path = layout.staging_dir().join(".consolidation.lock");
assert!(
!lock_path.exists(),
"lock file must be removed on success"
);
assert!(!lock_path.exists(), "lock file must be removed on success");
}
#[tokio::test]
@ -300,7 +291,12 @@ async fn in_flight_guard_skips_reentry_without_clearing() {
write_n_staging(&layout, 2);
let client = MockClient::new(vec![]);
let mut pod = make_pod_with(FILES_THRESHOLD_TOML, pwd.path().to_path_buf(), client.clone()).await;
let mut pod = make_pod_with(
FILES_THRESHOLD_TOML,
pwd.path().to_path_buf(),
client.clone(),
)
.await;
// Pre-set the in-flight flag as if another concurrent caller had
// entered run_consolidate_once. The CAS at the top of
@ -334,7 +330,9 @@ async fn in_flight_guard_skips_reentry_without_clearing() {
let mut pod2 = make_pod_with(FILES_THRESHOLD_TOML, pwd.path().to_path_buf(), client2).await;
pod2.try_post_run_consolidate().await.unwrap();
assert!(
!pod2.consolidation_in_flight_handle().load(Ordering::Acquire),
!pod2
.consolidation_in_flight_handle()
.load(Ordering::Acquire),
"in-flight flag must be cleared after a normal run"
);
}
@ -356,7 +354,12 @@ async fn coalesce_loop_terminates_with_one_iteration_when_snapshot_drains_stagin
// run_consolidate_once after Completed, the second sub-worker run
// would exhaust the mock and surface as an error.
let client = MockClient::new(vec![done("ok")]);
let mut pod = make_pod_with(FILES_THRESHOLD_TOML, pwd.path().to_path_buf(), client.clone()).await;
let mut pod = make_pod_with(
FILES_THRESHOLD_TOML,
pwd.path().to_path_buf(),
client.clone(),
)
.await;
pod.try_post_run_consolidate().await.unwrap();
assert_eq!(
@ -393,8 +396,5 @@ async fn live_lock_held_by_other_pod_skips() {
.expect("InUse lock must surface as graceful skip");
// Staging untouched: lock holder owns the snapshot, not us.
assert_eq!(
memory::consolidate::list_staging_entries(&layout).len(),
3
);
assert_eq!(memory::consolidate::list_staging_entries(&layout).len(), 3);
}

View File

@ -10,7 +10,7 @@ use llm_worker::llm_client::{ClientError, LlmClient, Request};
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use session_store::FsStore;
use pod::{Event, Method, Pod, PodController, PodManifest, PodStatus};
use pod::{Event, Method, Pod, PodController, PodHandle, PodManifest, PodStatus};
// ---------------------------------------------------------------------------
// Mock LLM Client
@ -147,8 +147,6 @@ async fn make_pod_with_pwd(client: MockClient) -> (Pod<MockClient, FsStore>, std
(pod, pwd)
}
use pod::PodHandle;
async fn spawn_controller(pod: Pod<MockClient, FsStore>) -> PodHandle {
let tmp = tempfile::tempdir().unwrap();
let runtime_base = tmp.path().to_owned();
@ -157,9 +155,85 @@ async fn spawn_controller(pod: Pod<MockClient, FsStore>) -> PodHandle {
handle
}
async fn wait_for_status(handle: &PodHandle, status: PodStatus) {
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
loop {
if handle.shared_state.get_status() == status {
return;
}
assert!(
tokio::time::Instant::now() < deadline,
"timed out waiting for status {status:?}; current={:?}",
handle.shared_state.get_status()
);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[tokio::test]
async fn run_end_returns_to_idle_without_busy_status() {
let client = MockClient::new(simple_text_events());
let pod = make_pod(client).await;
let handle = spawn_controller(pod).await;
let mut rx = handle.subscribe();
handle.send(Method::run_text("Hello")).await.unwrap();
let mut saw_run_end = false;
let mut saw_idle_status = false;
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
loop {
tokio::select! {
event = rx.recv() => {
match event {
Ok(Event::RunEnd { result: protocol::RunResult::Finished }) => {
saw_run_end = true;
}
Ok(Event::Status { status: PodStatus::Idle }) if saw_run_end => {
saw_idle_status = true;
break;
}
Ok(_) => {}
Err(_) => break,
}
}
_ = tokio::time::sleep_until(deadline) => break,
}
}
assert!(saw_run_end, "expected RunEnd::Finished");
assert!(
saw_idle_status,
"expected idle status immediately after RunEnd"
);
assert_eq!(handle.shared_state.get_status(), PodStatus::Idle);
}
#[tokio::test]
async fn attach_history_includes_current_status() {
let client = MockClient::sequential(vec![MockResponse::Hang(simple_text_events())]);
let pod = make_pod(client).await;
let handle = spawn_controller(pod).await;
handle.send(Method::run_text("Hello")).await.unwrap();
wait_for_status(&handle, PodStatus::Running).await;
let stream = tokio::net::UnixStream::connect(handle.runtime_dir.socket_path())
.await
.unwrap();
let (reader, writer) = stream.into_split();
let mut reader = protocol::stream::JsonLineReader::new(reader);
let mut writer = protocol::stream::JsonLineWriter::new(writer);
writer.write(&Method::GetHistory).await.unwrap();
let event = reader.next::<Event>().await.unwrap().unwrap();
match event {
Event::History { status, .. } => assert_eq!(status, PodStatus::Running),
other => panic!("expected History, got {other:?}"),
}
}
#[tokio::test]
async fn shared_state_starts_idle() {
@ -565,10 +639,10 @@ async fn notify_while_idle_auto_starts_turn_and_injects_system_message() {
// request context for that turn).
let requests = client_for_assert.captured_requests();
assert_eq!(requests.len(), 1, "one LLM call expected");
let notify_in_request = requests[0]
.items
.iter()
.any(|i| i.as_text().is_some_and(|t| t.contains("[Notification]") && t.contains("turn finished")));
let notify_in_request = requests[0].items.iter().any(|i| {
i.as_text()
.is_some_and(|t| t.contains("[Notification]") && t.contains("turn finished"))
});
assert!(
notify_in_request,
"injected system message missing from request, got items: {:?}",
@ -583,13 +657,17 @@ async fn notify_while_idle_auto_starts_turn_and_injects_system_message() {
// (and therefore eventually into history.json), per
// tickets/notify-history-persist.md.
let history = handle.shared_state.history();
let notify_in_history = history
.iter()
.any(|i| i.as_text().is_some_and(|t| t.contains("[Notification]") && t.contains("turn finished")));
let notify_in_history = history.iter().any(|i| {
i.as_text()
.is_some_and(|t| t.contains("[Notification]") && t.contains("turn finished"))
});
assert!(
notify_in_history,
"notify must be committed to worker.history, got items: {:?}",
history.iter().filter_map(|i| i.as_text()).collect::<Vec<_>>()
history
.iter()
.filter_map(|i| i.as_text())
.collect::<Vec<_>>()
);
}
@ -671,7 +749,10 @@ async fn pod_event_turn_ended_while_idle_auto_starts_turn_and_injects_system_mes
assert!(
event_in_history,
"PodEvent must be committed to worker.history, got items: {:?}",
history.iter().filter_map(|i| i.as_text()).collect::<Vec<_>>()
history
.iter()
.filter_map(|i| i.as_text())
.collect::<Vec<_>>()
);
}

View File

@ -174,6 +174,7 @@ fn serve_history(listener: UnixListener, items: Vec<Item>) -> JoinHandle<()> {
scope_summary: String::new(),
tools: Vec::new(),
},
status: protocol::PodStatus::Idle,
};
let _ = writer.write(&event).await;
}

View File

@ -22,9 +22,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use async_trait::async_trait;
use futures::Stream;
use llm_worker::Worker;
use llm_worker::llm_client::event::{
Event as LlmEvent, ResponseStatus, StatusEvent, UsageEvent,
};
use llm_worker::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent, UsageEvent};
use llm_worker::llm_client::{ClientError, LlmClient, Request};
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use session_metrics::{DOMAIN, Metric, metrics_from_extensions};
@ -169,7 +167,11 @@ async fn make_pod(
manifest_toml: String,
client: MockClient,
tool_name: &'static str,
) -> (Pod<MockClient, FsStore>, tempfile::TempDir, tempfile::TempDir) {
) -> (
Pod<MockClient, FsStore>,
tempfile::TempDir,
tempfile::TempDir,
) {
let manifest = PodManifest::from_toml(&manifest_toml).unwrap();
let store_tmp = tempfile::tempdir().unwrap();
let store = FsStore::new(store_tmp.path()).await.unwrap();
@ -199,8 +201,7 @@ async fn prune_metrics_emit_skip_then_fire_with_post_request_join() {
text_response_with_cache("ok", 0, 200),
text_response_with_cache("done", 1234, 50),
]);
let (mut pod, _store_tmp, _pwd_tmp) =
make_pod(manifest_toml(1, 1), client, "big_tool").await;
let (mut pod, _store_tmp, _pwd_tmp) = make_pod(manifest_toml(1, 1), client, "big_tool").await;
let session_id = pod.session_id();
// Cloning the store handle to read the session log back after the
// runs complete — the Pod retains its own copy.
@ -253,10 +254,7 @@ async fn prune_metrics_emit_skip_then_fire_with_post_request_join() {
fire.dimensions.contains_key("border_turn"),
"fire missing border_turn: {fire:?}"
);
assert!(
fire.value.is_some(),
"fire missing estimated_savings value"
);
assert!(fire.value.is_some(), "fire missing estimated_savings value");
let fire_id = fire
.correlation_id
.as_ref()
@ -272,7 +270,9 @@ async fn prune_metrics_emit_skip_then_fire_with_post_request_join() {
assert_eq!(post.correlation_id.as_ref(), Some(fire_id));
assert_eq!(post.value, Some(1234.0));
assert_eq!(
post.dimensions.get("cache_write_tokens").map(String::as_str),
post.dimensions
.get("cache_write_tokens")
.map(String::as_str),
Some("50")
);
assert!(post.dimensions.contains_key("history_len"));
@ -457,7 +457,10 @@ permission = "write"
let state = session_store::restore(&store, session_id).await.unwrap();
let metrics = metrics_from_extensions(&state.extensions);
assert!(metrics.is_empty(), "no metrics should be recorded: {metrics:?}");
assert!(
metrics.is_empty(),
"no metrics should be recorded: {metrics:?}"
);
// And no extension entries at all in the metrics domain.
assert!(state.extensions.iter().all(|(d, _)| d != DOMAIN));

View File

@ -223,6 +223,15 @@ pub enum Event {
Notify {
message: String,
},
/// Persisted `role:system` history item that should be rendered by
/// clients through the same path used for `Event::History` replay.
///
/// The payload is the serialized history item, not an ad-hoc display
/// DTO, so live subscribers and late subscribers have the same source
/// of truth: worker history / history.json.
SystemMessage {
item: serde_json::Value,
},
/// Echo of `Method::PodEvent` received by this Pod. Same rationale
/// as `Notify`: subscribers render the event as a log element,
/// while a rendered summary is independently injected into the LLM
@ -305,6 +314,17 @@ pub enum Event {
History {
items: Vec<serde_json::Value>,
greeting: Greeting,
/// Current Pod controller status at the moment the history snapshot
/// was taken. This lets late-attaching clients render and route
/// controls from the real controller state instead of inferring from
/// replayed history.
#[serde(default)]
status: PodStatus,
},
/// Current Pod controller status. Broadcast on every controller-level
/// transition and included in `History` snapshots for late attach.
Status {
status: PodStatus,
},
/// Reply to `Method::ListCompletions`. Delivered only to the
/// requesting socket (not broadcast). `entries` is empty when no
@ -413,6 +433,15 @@ pub struct Greeting {
// Supporting types
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum PodStatus {
#[default]
Idle,
Running,
Paused,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TurnResult {
@ -715,6 +744,7 @@ mod tests {
scope_summary: "Writable:\n - /tmp".into(),
tools: vec!["Read".into()],
},
status: PodStatus::Paused,
};
let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
@ -723,6 +753,36 @@ mod tests {
assert_eq!(parsed["data"]["items"][0]["role"], "user");
assert_eq!(parsed["data"]["greeting"]["pod_name"], "test");
assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read");
assert_eq!(parsed["data"]["status"], "paused");
}
#[test]
fn event_status_format() {
let event = Event::Status {
status: PodStatus::Running,
};
let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event"], "status");
assert_eq!(parsed["data"]["status"], "running");
let decoded: Event = serde_json::from_str(&json).unwrap();
assert!(matches!(
decoded,
Event::Status {
status: PodStatus::Running
}
));
}
#[test]
fn event_history_legacy_without_status_defaults_to_idle() {
let json = r#"{"event":"history","data":{"items":[],"greeting":{"pod_name":"test","cwd":"/tmp","provider":"anthropic","model":"claude","scope_summary":"","tools":[]}}}"#;
let decoded: Event = serde_json::from_str(json).unwrap();
match decoded {
Event::History { status, .. } => assert_eq!(status, PodStatus::Idle),
other => panic!("expected History, got {other:?}"),
}
}
#[test]

View File

@ -89,9 +89,7 @@ pub async fn record_metric(
/// `Metric` 列に fold する。
///
/// schema 変更で deserialize できない payload は無視する(後方互換)。
pub fn metrics_from_extensions(
extensions: &[(String, serde_json::Value)],
) -> Vec<Metric> {
pub fn metrics_from_extensions(extensions: &[(String, serde_json::Value)]) -> Vec<Metric> {
extensions
.iter()
.filter(|(domain, _)| domain == DOMAIN)

View File

@ -39,6 +39,10 @@ pub enum LoggedItem {
summary: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
encrypted_content: Option<String>,
/// Anthropic extended thinking signature。新世代 Claude で round-trip
/// 必須。OpenAI Responses など他 scheme では `None`。
#[serde(default, skip_serializing_if = "Option::is_none")]
signature: Option<String>,
},
}
@ -92,11 +96,13 @@ impl From<&Item> for LoggedItem {
text,
summary,
encrypted_content,
signature,
..
} => Self::Reasoning {
text: text.clone(),
summary: summary.clone(),
encrypted_content: encrypted_content.clone(),
signature: signature.clone(),
},
}
}
@ -142,11 +148,13 @@ impl From<LoggedItem> for Item {
text,
summary,
encrypted_content,
signature,
} => Item::Reasoning {
id: None,
text,
summary,
encrypted_content,
signature,
status: None,
},
}
@ -278,6 +286,48 @@ mod tests {
}
}
#[test]
fn round_trip_reasoning_preserves_signature() {
// 新世代 Claude の thinking signature が history.json に永続化され、
// resume 後の Item::Reasoning に復元されること。
let original = Item::reasoning("inner thought").with_signature("SIG-OPUS-XYZ");
let logged: LoggedItem = (&original).into();
let json = serde_json::to_string(&logged).unwrap();
// wire 形式に signature キーが乗ること(古い形式との互換のため
// 値が None のときは省略される。Some の値は載る)
assert!(
json.contains("SIG-OPUS-XYZ"),
"serialised JSON must carry signature: {json}",
);
let parsed: LoggedItem = serde_json::from_str(&json).unwrap();
match Item::from(parsed) {
Item::Reasoning {
text, signature, ..
} => {
assert_eq!(text, "inner thought");
assert_eq!(signature.as_deref(), Some("SIG-OPUS-XYZ"));
}
other => panic!("unexpected variant: {other:?}"),
}
}
#[test]
fn legacy_reasoning_without_signature_field_deserializes() {
// signature フィールドが無い旧形式の history.json を読み込んでも
// None としてロードできる(後方互換性)。
let legacy_json = r#"{"kind":"reasoning","text":"old","summary":[],"encrypted_content":null}"#;
let parsed: LoggedItem = serde_json::from_str(legacy_json).unwrap();
match Item::from(parsed) {
Item::Reasoning {
text, signature, ..
} => {
assert_eq!(text, "old");
assert!(signature.is_none());
}
other => panic!("unexpected variant: {other:?}"),
}
}
#[test]
fn round_trip_tool_result_with_content() {
let original = Item::tool_result_with_content("call_1", "ok", "full output");

View File

@ -252,21 +252,23 @@ struct TaskUpdateTool {
}
const CREATE_DESCRIPTION: &str = "Create a session-lifetime task for short-term current-work \
tracking, not project management. Tasks are user-visible real-time status. Use this whenever you \
set a goal and work through steps, including implementation. Input only `subject` and \
`description`; `taskid` is assigned automatically and initial `status` is `pending`.";
tracking, not project management. Tasks are user-visible real-time status for work with a \
concrete goal that needs multiple meaningful steps, such as implementation, debugging, \
investigation, or structured review. Do not create tasks for simple questions, brief answers, or \
single-step actions. Input only `subject` and `description`; `taskid` is assigned automatically \
and initial `status` is `pending`.";
const LIST_DESCRIPTION: &str = "List every session-lifetime task, including completed and \
deleted entries. Tasks are user-visible real-time status for short-term current-work tracking. \
Takes an empty object as input.";
const GET_DESCRIPTION: &str = "Get one session-lifetime task by `taskid`. Tasks are \
user-visible real-time status for short-term current-work tracking. Returns an error if the task \
does not exist.";
const UPDATE_DESCRIPTION: &str = "Update an existing session-lifetime task before moving to the \
next step. Tasks are user-visible real-time status; when working through steps, keep status \
current with `pending`, `inprogress`, `completed`, or `deleted`. Provide `taskid` and at least \
one of `status`, `subject`, or `description`; deletion is logical (`status = deleted`). If an \
unexpected problem blocks progress, do not force the next step: leave the task as-is, summarize \
the problem to the user, and end the turn.";
const UPDATE_DESCRIPTION: &str = "Update an existing session-lifetime task as progress changes \
between meaningful steps. Tasks are user-visible real-time status for multi-step work; keep \
status current with `pending`, `inprogress`, `completed`, or `deleted`. Provide `taskid` and at \
least one of `status`, `subject`, or `description`; deletion is logical (`status = deleted`). If \
an unexpected problem blocks progress, do not force the next step: leave the task as-is, \
summarize the problem to the user, and end the turn.";
#[async_trait]
impl Tool for TaskCreateTool {
@ -383,8 +385,8 @@ pub fn render_snapshot(tasks: &[TaskEntry]) -> String {
let snapshot = TaskSnapshot {
tasks: tasks.to_vec(),
};
let json = serde_json::to_string_pretty(&snapshot)
.unwrap_or_else(|_| String::from("{\"tasks\":[]}"));
let json =
serde_json::to_string_pretty(&snapshot).unwrap_or_else(|_| String::from("{\"tasks\":[]}"));
format!("{}\n\n```json\n{}\n```\n", snapshot_overview(tasks), json)
}
@ -544,7 +546,7 @@ mod tests {
assert_eq!(tasks[1].status, TaskStatus::Completed);
}
/// Wrap snapshot text the way `Pod::try_post_run_compact` does, so tests
/// Wrap snapshot text the way `Pod::try_pre_run_compact` does, so tests
/// exercise the exact format that goes through the session log.
fn wrap_snapshot_system_message(snapshot: &str) -> String {
format!(
@ -558,7 +560,8 @@ mod tests {
fn replay_history_uses_compact_snapshot_and_continues_updates() {
let pre = TaskStore::new();
pre.create("kept".into(), "from compact".into());
pre.update(1, Some(TaskStatus::Inprogress), None, None).unwrap();
pre.update(1, Some(TaskStatus::Inprogress), None, None)
.unwrap();
let history = vec![
Item::system_message(wrap_snapshot_system_message(&pre.snapshot_text())),
Item::tool_call("u1", "TaskUpdate", r#"{"taskid":1,"status":"completed"}"#),
@ -585,13 +588,23 @@ mod tests {
// pre-compact `TaskCreate`s do not surface as duplicates.
let pre = TaskStore::new();
pre.create("A".into(), "A-desc".into());
pre.update(1, Some(TaskStatus::Completed), None, None).unwrap();
pre.update(1, Some(TaskStatus::Completed), None, None)
.unwrap();
pre.create("B".into(), "B-desc".into());
pre.update(2, Some(TaskStatus::Inprogress), None, None).unwrap();
pre.update(2, Some(TaskStatus::Inprogress), None, None)
.unwrap();
let history = vec![
Item::tool_call("c1", "TaskCreate", r#"{"subject":"A","description":"A-desc"}"#),
Item::tool_call(
"c1",
"TaskCreate",
r#"{"subject":"A","description":"A-desc"}"#,
),
Item::tool_call("u1", "TaskUpdate", r#"{"taskid":1,"status":"completed"}"#),
Item::tool_call("c2", "TaskCreate", r#"{"subject":"B","description":"B-desc"}"#),
Item::tool_call(
"c2",
"TaskCreate",
r#"{"subject":"B","description":"B-desc"}"#,
),
Item::tool_call("u2", "TaskUpdate", r#"{"taskid":2,"status":"inprogress"}"#),
Item::system_message(wrap_snapshot_system_message(&pre.snapshot_text())),
Item::tool_call("compact-tasklist", "TaskList", "{}"),
@ -623,7 +636,8 @@ mod tests {
"subject with\nembedded newline\n- bullet".into(),
"desc:\n status: not-actually-a-field\n ```code fence```".into(),
);
pre.update(1, Some(TaskStatus::Inprogress), None, None).unwrap();
pre.update(1, Some(TaskStatus::Inprogress), None, None)
.unwrap();
let history = vec![Item::system_message(wrap_snapshot_system_message(
&pre.snapshot_text(),
@ -641,7 +655,7 @@ mod tests {
#[test]
fn synthetic_compact_tasklist_pair_is_well_formed() {
// Mirrors `Pod::try_post_run_compact`'s synthetic insertion:
// Mirrors `Pod::try_pre_run_compact`'s synthetic insertion:
// a system snapshot message followed by a TaskList tool_call/tool_result
// pair sharing the `compact-tasklist` id. Verify the structural
// contract every provider request builder relies on (matched call_id,

View File

@ -16,3 +16,8 @@ toml = { workspace = true }
manifest = { workspace = true }
session-store = { workspace = true }
pod-registry = { workspace = true }
serde = { workspace = true, features = ["derive"] }
pulldown-cmark = { version = "0.13.3", default-features = false }
[dev-dependencies]
tools = { workspace = true }

View File

@ -1,7 +1,8 @@
use std::time::Instant;
use protocol::{
AlertLevel, AlertSource, CompletionEntry, CompletionKind, Event, Method, RunResult, Segment,
AlertLevel, AlertSource, CompletionEntry, CompletionKind, Event, Method, PodStatus, RunResult,
Segment,
};
use crate::block::{
@ -10,6 +11,7 @@ use crate::block::{
use crate::cache::FileCache;
use crate::input::InputBuffer;
use crate::scroll::Scroll;
use crate::task::TaskStore;
use crate::ui::Mode;
/// In-flight completion popup state. Lives on `App` while the user is
@ -41,10 +43,12 @@ impl CompletionState {
pub struct App {
pub pod_name: String,
pub connected: bool,
/// Last controller status reported by the Pod. Drives the status line
/// and Ctrl-key routing; do not infer this solely from replayed history.
pub pod_status: PodStatus,
/// True while the Pod is in `PodStatus::Running`.
pub running: bool,
/// True while the Pod is in `PodStatus::Paused`. Set on
/// `RunEnd::Paused` and cleared when a new turn starts (either via
/// `Resume` or a fresh `Run`).
/// True while the Pod is in `PodStatus::Paused`.
pub paused: bool,
pub run_requests: usize,
/// Sum of `input_tokens - cache_read_input_tokens` across the
@ -73,6 +77,16 @@ pub struct App {
/// Completion popup state, when an `@` / `#` / `/` token is in
/// flight. `None` whenever the trigger conditions don't hold.
pub completion: Option<CompletionState>,
/// In-TUI mirror of the Pod's session task store, reconstructed
/// directly from observed `TaskCreate` / `TaskUpdate` tool calls and
/// `[Session TaskStore snapshot]` system messages — no protocol
/// surface added on the Pod side.
pub task_store: TaskStore,
/// Whether the right-side task pane is currently open.
pub task_pane_open: bool,
/// Top entry index of the task pane's visible window. Clamped on
/// render so it never points past the end of the list.
pub task_pane_scroll: usize,
}
impl App {
@ -80,6 +94,7 @@ impl App {
Self {
pod_name,
connected: false,
pod_status: PodStatus::Idle,
running: false,
paused: false,
run_requests: 0,
@ -96,6 +111,33 @@ impl App {
cache: FileCache::new(),
assistant_streaming: false,
completion: None,
task_store: TaskStore::new(),
task_pane_open: false,
task_pane_scroll: 0,
}
}
pub fn toggle_task_pane(&mut self) {
self.task_pane_open = !self.task_pane_open;
if !self.task_pane_open {
self.task_pane_scroll = 0;
}
}
pub fn scroll_task_pane_up(&mut self, n: usize) {
self.task_pane_scroll = self.task_pane_scroll.saturating_sub(n);
}
pub fn scroll_task_pane_down(&mut self, n: usize) {
self.task_pane_scroll = self.task_pane_scroll.saturating_add(n);
}
pub fn set_pod_status(&mut self, status: PodStatus) {
self.pod_status = status;
self.running = status == PodStatus::Running;
self.paused = status == PodStatus::Paused;
if self.running {
self.quit_confirm = None;
}
}
@ -304,6 +346,133 @@ impl App {
});
}
fn push_history_item(&mut self, item: &serde_json::Value) {
let item_type = item["type"].as_str().unwrap_or("");
match item_type {
"message" => {
let role = item["role"].as_str().unwrap_or("");
let text = message_text(item);
match role {
"user" => {
self.turn_index += 1;
self.blocks.push(Block::TurnHeader {
turn: self.turn_index,
});
// Pod attaches the original `Vec<Segment>` to user
// messages from live submissions, so we can rebuild
// typed atoms (paste chips, refs) here. Seed history
// loaded post-compaction has no `segments` field —
// fall back to a single text segment.
let segments = item
.get("segments")
.and_then(|v| serde_json::from_value::<Vec<Segment>>(v.clone()).ok())
.unwrap_or_else(|| {
if text.is_empty() {
Vec::new()
} else {
vec![Segment::text(text.clone())]
}
});
if !segments.is_empty() {
self.blocks.push(Block::UserMessage { segments });
}
}
"assistant" if !text.is_empty() => {
self.blocks.push(Block::AssistantText { text });
}
"system" if !text.is_empty() => {
self.task_store.apply_system_message_text(&text);
self.blocks.push(Block::SystemMessage { text });
}
_ => {}
}
}
"tool_call" => {
// `Item::ToolCall` serializes the linking key as
// `call_id`; `id` is a separate optional item-level
// identifier. Use `call_id` so this matches how
// Event::ToolCallStart populates the block.
let id = item["call_id"].as_str().unwrap_or("").to_owned();
let name = item["name"].as_str().unwrap_or("?").to_owned();
let arguments = item["arguments"].as_str().map(|s| s.to_owned());
if let Some(args) = arguments.as_deref() {
self.task_store.apply_tool_call(&name, args);
}
self.blocks.push(Block::ToolCall(ToolCallBlock {
id,
name,
args_stream: arguments.clone().unwrap_or_default(),
arguments,
state: ToolCallState::Executing,
edit_snapshot: None,
}));
}
"reasoning" => {
let text = item["text"].as_str().unwrap_or("").to_owned();
let body = if text.is_empty() {
item["summary"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default()
} else {
text
};
self.blocks.push(Block::Thinking(ThinkingBlock {
text: body,
state: ThinkingState::Finished { elapsed_secs: None },
}));
}
"tool_result" => {
let id = item["call_id"].as_str().unwrap_or("").to_owned();
let summary = item["summary"].as_str().unwrap_or("").to_owned();
let output = item["content"].as_str().map(|s| s.to_owned());
let is_error = item["is_error"].as_bool().unwrap_or(false);
let (name, args) = self
.find_tool_call_mut(&id)
.map(|b| (b.name.clone(), b.arguments.clone()))
.unwrap_or_default();
let edit_snapshot = if !is_error && name == "Edit" {
args.as_deref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v["file_path"].as_str().map(|s| s.to_owned()))
.and_then(|path| self.cache.get(&path).map(|s| s.to_owned()))
} else {
None
};
if let Some(tc) = self.find_tool_call_mut(&id) {
if edit_snapshot.is_some() {
tc.edit_snapshot = edit_snapshot;
}
tc.state = if is_error {
ToolCallState::Error {
summary,
output: output.clone(),
}
} else {
ToolCallState::Done {
summary,
output: output.clone(),
}
};
if !is_error {
apply_cache_update(
&mut self.cache,
&name,
args.as_deref(),
output.as_deref(),
);
}
}
}
_ => {}
}
}
pub fn handle_pod_event(&mut self, event: Event) {
match event {
Event::UserMessage { segments } => {
@ -322,9 +491,12 @@ impl App {
self.blocks.push(Block::PodEvent { event });
self.assistant_streaming = false;
}
Event::SystemMessage { item } => {
self.push_history_item(&item);
self.assistant_streaming = false;
}
Event::TurnStart { .. } => {
self.running = true;
self.paused = false;
self.set_pod_status(PodStatus::Running);
self.run_requests += 1;
self.current_tool = None;
self.assistant_streaming = false;
@ -396,6 +568,12 @@ impl App {
}
Event::ToolCallDone { id, arguments, .. } => {
self.current_tool = None;
let name = self
.find_tool_call_mut(&id)
.map(|b| b.name.clone());
if let Some(name) = name.as_deref() {
self.task_store.apply_tool_call(name, &arguments);
}
if let Some(b) = self.find_tool_call_mut(&id) {
b.arguments = Some(arguments);
// Only advance the state when it's still in-flight.
@ -490,8 +668,10 @@ impl App {
upload_tokens: self.run_upload_tokens,
output_tokens: self.run_output_tokens,
});
self.running = false;
self.paused = matches!(result, RunResult::Paused);
self.set_pod_status(match result {
RunResult::Paused => PodStatus::Paused,
RunResult::Finished | RunResult::LimitReached => PodStatus::Idle,
});
self.run_requests = 0;
self.run_upload_tokens = 0;
self.run_output_tokens = 0;
@ -516,8 +696,16 @@ impl App {
message: alert.message,
});
}
Event::History { items, greeting } => {
Event::History {
items,
greeting,
status,
} => {
self.restore_history(&items, greeting);
self.set_pod_status(status);
}
Event::Status { status } => {
self.set_pod_status(status);
}
Event::Completions { kind, entries } => {
// Apply only if the popup is still on the same
@ -666,134 +854,13 @@ impl App {
self.turn_index = 0;
self.blocks.clear();
self.cache = FileCache::new();
self.task_store = TaskStore::new();
self.task_pane_scroll = 0;
self.blocks.push(Block::Greeting(greeting));
self.assistant_streaming = false;
for item in items {
let item_type = item["type"].as_str().unwrap_or("");
match item_type {
"message" => {
let role = item["role"].as_str().unwrap_or("");
let text = item["content"]
.as_array()
.and_then(|parts| parts.iter().filter_map(|p| p["text"].as_str()).next())
.unwrap_or("")
.to_owned();
match role {
"user" => {
self.turn_index += 1;
self.blocks.push(Block::TurnHeader {
turn: self.turn_index,
});
// Pod attaches the original `Vec<Segment>` to
// user messages from live submissions, so we
// can rebuild typed atoms (paste chips, refs)
// here. Seed history loaded post-compaction
// has no `segments` field — fall back to a
// single text segment.
let segments = item
.get("segments")
.and_then(|v| {
serde_json::from_value::<Vec<Segment>>(v.clone()).ok()
})
.unwrap_or_else(|| {
if text.is_empty() {
Vec::new()
} else {
vec![Segment::text(text.clone())]
}
});
if !segments.is_empty() {
self.blocks.push(Block::UserMessage { segments });
}
}
"assistant" if !text.is_empty() => {
self.blocks.push(Block::AssistantText { text });
}
_ => {}
}
}
"tool_call" => {
// `Item::ToolCall` serializes the linking key as
// `call_id`; `id` is a separate optional item-level
// identifier. Use `call_id` so this matches how
// Event::ToolCallStart populates the block.
let id = item["call_id"].as_str().unwrap_or("").to_owned();
let name = item["name"].as_str().unwrap_or("?").to_owned();
let arguments = item["arguments"].as_str().map(|s| s.to_owned());
self.blocks.push(Block::ToolCall(ToolCallBlock {
id,
name,
args_stream: arguments.clone().unwrap_or_default(),
arguments,
state: ToolCallState::Executing,
edit_snapshot: None,
}));
}
"reasoning" => {
let text = item["text"].as_str().unwrap_or("").to_owned();
let body = if text.is_empty() {
item["summary"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default()
} else {
text
};
self.blocks.push(Block::Thinking(ThinkingBlock {
text: body,
state: ThinkingState::Finished { elapsed_secs: None },
}));
}
"tool_result" => {
let id = item["call_id"].as_str().unwrap_or("").to_owned();
let summary = item["summary"].as_str().unwrap_or("").to_owned();
let output = item["content"].as_str().map(|s| s.to_owned());
let is_error = item["is_error"].as_bool().unwrap_or(false);
let (name, args) = self
.find_tool_call_mut(&id)
.map(|b| (b.name.clone(), b.arguments.clone()))
.unwrap_or_default();
let edit_snapshot = if !is_error && name == "Edit" {
args.as_deref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v["file_path"].as_str().map(|s| s.to_owned()))
.and_then(|path| self.cache.get(&path).map(|s| s.to_owned()))
} else {
None
};
if let Some(tc) = self.find_tool_call_mut(&id) {
if edit_snapshot.is_some() {
tc.edit_snapshot = edit_snapshot;
}
tc.state = if is_error {
ToolCallState::Error {
summary,
output: output.clone(),
}
} else {
ToolCallState::Done {
summary,
output: output.clone(),
}
};
if !is_error {
apply_cache_update(
&mut self.cache,
&name,
args.as_deref(),
output.as_deref(),
);
}
}
}
_ => {}
}
self.push_history_item(item);
}
// Any tool_call entries that never got paired with a
@ -823,6 +890,19 @@ pub fn fmt_tokens(n: u64) -> String {
}
}
fn message_text(item: &serde_json::Value) -> String {
item["content"]
.as_array()
.map(|parts| {
parts
.iter()
.filter_map(|p| p["text"].as_str())
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default()
}
/// Strip the `cat -n` line-number gutter that the Read tool prepends to
/// its output (one `"{n:>6}\t{content}"` per line) and return the raw
/// file body. Lines that don't match the pattern are kept verbatim, so
@ -1185,6 +1265,202 @@ mod completion_flow_tests {
});
assert!(app.completion.as_ref().unwrap().entries.is_empty());
}
#[test]
fn history_restore_renders_system_message_block() {
let mut app = App::new("test".into());
app.handle_pod_event(Event::History {
greeting: test_greeting(),
items: vec![serde_json::json!({
"type": "message",
"role": "system",
"content": [{
"type": "text",
"text": "[File: src/main.rs]\nfn main() {}",
}],
})],
status: PodStatus::Running,
});
assert!(matches!(app.pod_status, PodStatus::Running));
assert!(app.running);
assert!(matches!(
app.blocks.get(1),
Some(Block::SystemMessage { text }) if text == "[File: src/main.rs]\nfn main() {}"
));
}
#[test]
fn live_system_message_event_uses_history_item_path() {
let mut app = App::new("test".into());
app.handle_pod_event(Event::SystemMessage {
item: serde_json::json!({
"type": "message",
"role": "system",
"content": [{
"type": "text",
"text": "[Workflow /build]\nRun the build",
}],
}),
});
assert!(matches!(
app.blocks.as_slice(),
[Block::SystemMessage { text }] if text == "[Workflow /build]\nRun the build"
));
}
fn test_greeting() -> protocol::Greeting {
protocol::Greeting {
pod_name: "test".into(),
cwd: "/tmp".into(),
provider: "test-provider".into(),
model: "test-model".into(),
scope_summary: String::new(),
tools: Vec::new(),
}
}
#[test]
fn live_task_create_updates_task_store() {
let mut app = App::new("test".into());
app.handle_pod_event(Event::ToolCallStart {
id: "c1".into(),
name: "TaskCreate".into(),
});
app.handle_pod_event(Event::ToolCallDone {
id: "c1".into(),
name: "TaskCreate".into(),
arguments: r#"{"subject":"impl tasks","description":"do it"}"#.into(),
});
let tasks = app.task_store.tasks();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].subject, "impl tasks");
assert_eq!(tasks[0].status, crate::task::TaskStatus::Pending);
}
#[test]
fn live_task_update_advances_status() {
let mut app = App::new("test".into());
for (id, args) in [
("c1", r#"{"subject":"a","description":"A"}"#),
("u1", r#"{"taskid":1,"status":"completed"}"#),
] {
let name = if id.starts_with('c') {
"TaskCreate"
} else {
"TaskUpdate"
};
app.handle_pod_event(Event::ToolCallStart {
id: id.into(),
name: name.into(),
});
app.handle_pod_event(Event::ToolCallDone {
id: id.into(),
name: name.into(),
arguments: args.into(),
});
}
assert_eq!(
app.task_store.tasks()[0].status,
crate::task::TaskStatus::Completed
);
}
#[test]
fn live_system_snapshot_replaces_task_store() {
let mut app = App::new("test".into());
// Stale entry that the snapshot must wipe out.
app.handle_pod_event(Event::ToolCallStart {
id: "c1".into(),
name: "TaskCreate".into(),
});
app.handle_pod_event(Event::ToolCallDone {
id: "c1".into(),
name: "TaskCreate".into(),
arguments: r#"{"subject":"stale","description":""}"#.into(),
});
let snapshot = "[Session TaskStore snapshot]\n\n\
TaskStore: 1 task(s)\n\n\
```json\n{\n \"tasks\": [\n {\n \"taskid\": 4,\n \
\"status\": \"inprogress\",\n \"subject\": \"from snapshot\",\n \
\"description\": \"d\"\n }\n ]\n}\n```\n";
app.handle_pod_event(Event::SystemMessage {
item: serde_json::json!({
"type": "message",
"role": "system",
"content": [{ "type": "text", "text": snapshot }],
}),
});
let tasks = app.task_store.tasks();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].taskid, 4);
assert_eq!(tasks[0].subject, "from snapshot");
}
#[test]
fn history_replay_reconstructs_task_store() {
let mut app = App::new("test".into());
// Live tool call before history lands — restore_history must
// wipe this so it doesn't double-count after replay.
app.handle_pod_event(Event::ToolCallStart {
id: "live".into(),
name: "TaskCreate".into(),
});
app.handle_pod_event(Event::ToolCallDone {
id: "live".into(),
name: "TaskCreate".into(),
arguments: r#"{"subject":"live","description":""}"#.into(),
});
app.handle_pod_event(Event::History {
greeting: test_greeting(),
items: vec![
serde_json::json!({
"type": "tool_call",
"call_id": "c1",
"name": "TaskCreate",
"arguments": r#"{"subject":"a","description":"A"}"#,
}),
serde_json::json!({
"type": "tool_call",
"call_id": "c2",
"name": "TaskCreate",
"arguments": r#"{"subject":"b","description":"B"}"#,
}),
serde_json::json!({
"type": "tool_call",
"call_id": "u1",
"name": "TaskUpdate",
"arguments": r#"{"taskid":2,"status":"inprogress"}"#,
}),
],
status: PodStatus::Running,
});
let tasks = app.task_store.tasks();
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].subject, "a");
assert_eq!(tasks[1].subject, "b");
assert_eq!(tasks[1].status, crate::task::TaskStatus::Inprogress);
}
#[test]
fn task_pane_toggle_flips_state_and_resets_scroll() {
let mut app = App::new("test".into());
app.task_pane_scroll = 7;
assert!(!app.task_pane_open);
app.toggle_task_pane();
assert!(app.task_pane_open);
// Scroll position is preserved on open so the user keeps their
// place if they re-open after closing.
assert_eq!(app.task_pane_scroll, 7);
app.toggle_task_pane();
assert!(!app.task_pane_open);
assert_eq!(app.task_pane_scroll, 0);
}
}
/// Seed / mutate the file-content cache based on a completed tool call.

View File

@ -19,6 +19,12 @@ pub enum Block {
UserMessage {
segments: Vec<Segment>,
},
/// Persisted `role:system` history item rendered as an ordinary log
/// element. File refs, auto-read snippets, workflow bodies, and future
/// system-message injections all share this lane.
SystemMessage {
text: String,
},
/// Echo of `Method::Notify` received by this Pod, surfaced as a log
/// element so subscribers see the external input that drove any
/// following auto-kicked turn.

View File

@ -35,6 +35,10 @@ impl PodClient {
self.writer.write(method).await
}
pub fn try_next_event(&mut self) -> Option<Event> {
self.event_rx.try_recv().ok()
}
pub async fn next_event(&mut self) -> Option<Event> {
self.event_rx.recv().await
}

View File

@ -3,9 +3,11 @@ mod block;
mod cache;
mod client;
mod input;
mod markdown;
mod picker;
mod scroll;
mod spawn;
mod task;
mod tool;
mod ui;
@ -21,7 +23,7 @@ use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use protocol::Method;
use protocol::{Method, PodStatus};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use session_store::SessionId;
@ -283,29 +285,44 @@ async fn run_loop(
break;
}
tokio::select! {
_ = tokio::task::spawn_blocking(|| event::poll(std::time::Duration::from_millis(50))) => {
// Drain any already-buffered Pod events in a bounded batch before
// polling the terminal. This keeps status fresh without letting a
// busy event stream starve Ctrl-C / Ctrl-X input.
for _ in 0..32 {
match client.try_next_event() {
Some(ev) => app.handle_pod_event(ev),
None => break,
}
}
// Always give the terminal queue a non-blocking pass each frame.
// The awaited select below only waits after this pass found nothing.
let mut handled_term_event = false;
while event::poll(std::time::Duration::ZERO)? {
match event::read()? {
TermEvent::Key(key) => {
if let Some(method) = handle_key(app, key) {
client.send(&method).await?;
handled_term_event = true;
handle_terminal_event(app, &mut client, event::read()?).await?;
if app.quit {
break;
}
}
TermEvent::Mouse(mouse) => {
handle_mouse(app, mouse);
}
TermEvent::Paste(s) => {
app.insert_paste(s);
}
TermEvent::Resize(_, _) => {
// No-op: next draw repaints in full.
}
_ => {}
}
if app.quit {
break;
}
if handled_term_event {
terminal.draw(|f| ui::draw(f, app))?;
continue;
}
tokio::select! {
term_event = tokio::task::spawn_blocking(|| {
if event::poll(std::time::Duration::from_millis(50))? {
event::read().map(Some)
} else {
Ok(None)
}
}) => {
if let Some(term_event) = term_event?? {
handle_terminal_event(app, &mut client, term_event).await?;
}
}
event = client.next_event(), if app.connected => {
@ -325,6 +342,31 @@ async fn run_loop(
Ok(())
}
async fn handle_terminal_event(
app: &mut App,
client: &mut PodClient,
event: TermEvent,
) -> Result<(), Box<dyn std::error::Error>> {
match event {
TermEvent::Key(key) => {
if let Some(method) = handle_key(app, key) {
client.send(&method).await?;
}
}
TermEvent::Mouse(mouse) => {
handle_mouse(app, mouse);
}
TermEvent::Paste(s) => {
app.insert_paste(s);
}
TermEvent::Resize(_, _) => {
// No-op: next draw repaints in full.
}
_ => {}
}
Ok(())
}
fn run_disconnected(_app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
loop {
if event::poll(std::time::Duration::from_millis(100))?
@ -344,6 +386,11 @@ fn run_disconnected(_app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
/// looking for.
const WHEEL_LINES: usize = 3;
/// Lines to advance per PageUp / PageDown when the task side pane is
/// open. Calibrated so a couple of presses moves through one entry's
/// subject + description block.
const PANE_SCROLL_LINES: usize = 5;
fn handle_mouse(app: &mut App, mouse: MouseEvent) {
match mouse.kind {
MouseEventKind::ScrollUp => app.scroll.scroll_up(WHEEL_LINES),
@ -387,15 +434,18 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
app.mode = app.mode.cycle();
Some(None)
}
KeyCode::Char('t') if ctrl => {
app.toggle_task_pane();
Some(None)
}
KeyCode::Char('a') if ctrl => {
app.move_cursor_start();
Some(app.refresh_completion())
}
KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)),
KeyCode::Char('x') if ctrl => Some(if app.running {
Some(Method::Cancel)
} else {
Some(Method::Shutdown)
KeyCode::Char('x') if ctrl => Some(match app.pod_status {
PodStatus::Running => Some(Method::Cancel),
PodStatus::Paused | PodStatus::Idle => Some(Method::Shutdown),
}),
KeyCode::Char('d') if ctrl => {
app.quit = true;
@ -416,14 +466,24 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
return None;
}
// Scroll / navigation (history view).
// Scroll / navigation. PageUp / PageDown defaults to history; while
// the task pane is open it scrolls the pane instead so the user can
// browse past entries without first closing the pane.
match key.code {
KeyCode::PageUp => {
if app.task_pane_open {
app.scroll_task_pane_up(PANE_SCROLL_LINES);
} else {
app.scroll.page_up();
}
return None;
}
KeyCode::PageDown => {
if app.task_pane_open {
app.scroll_task_pane_down(PANE_SCROLL_LINES);
} else {
app.scroll.page_down();
}
return None;
}
_ => {}
@ -536,7 +596,7 @@ const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
/// Running → send `Method::Pause`.
/// Idle / Paused → 2-tap to quit the TUI (the Pod keeps running).
fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
if app.running {
if app.pod_status == PodStatus::Running {
return Some(Method::Pause);
}
if let Some(t) = app.quit_confirm

386
crates/tui/src/markdown.rs Normal file
View File

@ -0,0 +1,386 @@
//! Markdown renderer for assistant text.
//!
//! Streams `pulldown-cmark` events into ratatui `Line`s that drop straight
//! into the rest of the TUI's wrap/scroll pipeline. Scope (which Markdown
//! features get styled) and exclusions are documented in
//! `tickets/tui-assistant-markdown.md`.
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
const LIST_INDENT: &str = " ";
const RULE_WIDTH: usize = 40;
pub fn render(text: &str, base: Style) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
let mut r = Renderer::new(base);
let parser = Parser::new_ext(text, Options::ENABLE_STRIKETHROUGH);
for event in parser {
r.handle(event, &mut out);
}
r.finish(&mut out);
out
}
struct Renderer {
base: Style,
line_prefix: Vec<Span<'static>>,
pending_marker: Option<Span<'static>>,
current: Vec<Span<'static>>,
bold: u32,
italic: u32,
strike: u32,
in_link: u32,
in_inline_code: u32,
image_depth: u32,
heading: Option<HeadingLevel>,
in_code_block: bool,
/// One entry per open list. `Some(n)` carries the next ordinal to
/// emit for an ordered list; `None` means a bullet list.
list_stack: Vec<Option<u64>>,
has_emitted: bool,
just_blanked: bool,
}
impl Renderer {
fn new(base: Style) -> Self {
Self {
base,
line_prefix: Vec::new(),
pending_marker: None,
current: Vec::new(),
bold: 0,
italic: 0,
strike: 0,
in_link: 0,
in_inline_code: 0,
image_depth: 0,
heading: None,
in_code_block: false,
list_stack: Vec::new(),
has_emitted: false,
just_blanked: false,
}
}
fn span_style(&self) -> Style {
if self.in_inline_code > 0 {
return Style::default().fg(Color::Yellow).bg(Color::Rgb(40, 40, 40));
}
if self.in_code_block {
return Style::default().fg(Color::Cyan);
}
if let Some(level) = self.heading {
return heading_style(level);
}
let mut s = self.base;
if self.bold > 0 {
s = s.add_modifier(Modifier::BOLD);
}
if self.italic > 0 {
s = s.add_modifier(Modifier::ITALIC);
}
if self.strike > 0 {
s = s.add_modifier(Modifier::CROSSED_OUT);
}
if self.in_link > 0 {
s = s.fg(Color::Cyan).add_modifier(Modifier::UNDERLINED);
}
s
}
fn push_text(&mut self, content: String) {
if self.image_depth > 0 || content.is_empty() {
return;
}
let style = self.span_style();
self.current.push(Span::styled(content, style));
}
fn flush_line(&mut self, out: &mut Vec<Line<'static>>) {
if self.current.is_empty() && self.pending_marker.is_none() {
return;
}
let mut spans: Vec<Span<'static>> = self.line_prefix.clone();
if let Some(m) = self.pending_marker.take() {
spans.push(m);
}
spans.extend(self.current.drain(..));
out.push(Line::from(spans));
self.has_emitted = true;
self.just_blanked = false;
}
fn emit_blank(&mut self, out: &mut Vec<Line<'static>>) {
if !self.has_emitted || self.just_blanked {
return;
}
out.push(Line::from(""));
self.just_blanked = true;
}
fn handle(&mut self, ev: Event<'_>, out: &mut Vec<Line<'static>>) {
match ev {
Event::Start(tag) => self.start(tag, out),
Event::End(tag) => self.end(tag, out),
Event::Text(s) => {
if self.in_code_block {
let mut iter = s.split('\n').peekable();
while let Some(piece) = iter.next() {
if !piece.is_empty() {
self.push_text(piece.to_owned());
}
if iter.peek().is_some() {
self.flush_line(out);
}
}
} else {
self.push_text(s.into_string());
}
}
Event::Code(s) => {
self.in_inline_code += 1;
self.push_text(s.into_string());
self.in_inline_code -= 1;
}
Event::SoftBreak => self.push_text(" ".to_owned()),
Event::HardBreak => self.flush_line(out),
Event::Rule => {
self.emit_blank(out);
out.push(Line::from(Span::styled(
"".repeat(RULE_WIDTH),
Style::default().fg(Color::DarkGray),
)));
self.has_emitted = true;
self.just_blanked = false;
self.emit_blank(out);
}
// HTML / inline HTML / footnote refs / task list markers etc.
// are intentionally dropped or fall through as raw text in
// Text events that surround them — the ticket scopes those
// out explicitly.
_ => {}
}
}
fn start(&mut self, tag: Tag<'_>, out: &mut Vec<Line<'static>>) {
match tag {
Tag::Paragraph => {
self.emit_blank(out);
}
Tag::Heading { level, .. } => {
self.emit_blank(out);
self.heading = Some(level);
}
Tag::CodeBlock(_) => {
self.emit_blank(out);
self.in_code_block = true;
}
Tag::List(start) => {
// Close any in-flight line (in tight nested lists the
// parent item's text arrives without a Paragraph wrapper,
// so it's still sitting in `current` when the child list
// opens).
self.flush_line(out);
if self.list_stack.is_empty() {
self.emit_blank(out);
}
if !self.list_stack.is_empty() {
self.line_prefix.push(Span::raw(LIST_INDENT));
}
self.list_stack.push(start);
}
Tag::Item => {
self.flush_line(out);
let marker_text = match self.list_stack.last_mut() {
Some(Some(n)) => {
let s = format!("{}. ", *n);
*n += 1;
s
}
_ => "".to_owned(),
};
self.pending_marker = Some(Span::styled(
marker_text,
Style::default().fg(Color::DarkGray),
));
}
Tag::BlockQuote(_) => {
self.emit_blank(out);
self.line_prefix.push(Span::styled(
"",
Style::default().fg(Color::DarkGray),
));
}
Tag::Strong => self.bold += 1,
Tag::Emphasis => self.italic += 1,
Tag::Strikethrough => self.strike += 1,
Tag::Link { .. } => self.in_link += 1,
Tag::Image { .. } => self.image_depth += 1,
_ => {}
}
}
fn end(&mut self, tag: TagEnd, out: &mut Vec<Line<'static>>) {
match tag {
TagEnd::Paragraph => {
self.flush_line(out);
}
TagEnd::Heading(_) => {
self.flush_line(out);
self.heading = None;
}
TagEnd::CodeBlock => {
self.flush_line(out);
self.in_code_block = false;
}
TagEnd::List(_) => {
self.list_stack.pop();
if !self.list_stack.is_empty() {
self.line_prefix.pop();
}
// Don't emit a blank between a closing inner list and
// its parent item's continuation — the parent will close
// its own paragraph if it had one.
}
TagEnd::Item => {
self.flush_line(out);
// Empty list item: marker was never consumed, drop it
// so it doesn't bleed onto the next item.
self.pending_marker = None;
}
TagEnd::BlockQuote(_) => {
self.flush_line(out);
self.line_prefix.pop();
}
TagEnd::Strong => self.bold = self.bold.saturating_sub(1),
TagEnd::Emphasis => self.italic = self.italic.saturating_sub(1),
TagEnd::Strikethrough => self.strike = self.strike.saturating_sub(1),
TagEnd::Link => self.in_link = self.in_link.saturating_sub(1),
TagEnd::Image => self.image_depth = self.image_depth.saturating_sub(1),
_ => {}
}
}
fn finish(&mut self, out: &mut Vec<Line<'static>>) {
self.flush_line(out);
while matches!(out.last(), Some(l) if l.spans.iter().all(|s| s.content.is_empty())) {
out.pop();
}
}
}
fn heading_style(level: HeadingLevel) -> Style {
let base = Style::default().add_modifier(Modifier::BOLD);
match level {
HeadingLevel::H1 | HeadingLevel::H2 => base.fg(Color::Cyan),
HeadingLevel::H3 => base.fg(Color::Magenta),
HeadingLevel::H4 | HeadingLevel::H5 | HeadingLevel::H6 => base.fg(Color::White),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn line_text(line: &Line<'_>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
fn render_plain(text: &str) -> Vec<String> {
render(text, Style::default())
.iter()
.map(line_text)
.collect()
}
#[test]
fn plain_paragraph() {
assert_eq!(render_plain("hello world"), vec!["hello world"]);
}
#[test]
fn paragraphs_separated_by_blank_line() {
let lines = render_plain("first\n\nsecond");
assert_eq!(lines, vec!["first", "", "second"]);
}
#[test]
fn soft_break_collapses_to_space() {
// CommonMark: a single newline inside a paragraph is a soft break.
let lines = render_plain("a\nb");
assert_eq!(lines, vec!["a b"]);
}
#[test]
fn heading_emits_dedicated_line() {
let lines = render_plain("# Title\n\nbody");
assert_eq!(lines, vec!["Title", "", "body"]);
}
#[test]
fn unordered_list_uses_bullet_marker() {
let lines = render_plain("- a\n- b");
assert_eq!(lines, vec!["• a", "• b"]);
}
#[test]
fn ordered_list_numbers_continue() {
let lines = render_plain("1. a\n2. b");
assert_eq!(lines, vec!["1. a", "2. b"]);
}
#[test]
fn nested_list_indents() {
let lines = render_plain("- a\n - b\n- c");
assert_eq!(lines, vec!["• a", " • b", "• c"]);
}
#[test]
fn block_quote_prefixes_pipe() {
let lines = render_plain("> quoted");
assert_eq!(lines, vec!["│ quoted"]);
}
#[test]
fn fenced_code_block_preserves_lines() {
let lines = render_plain("```rust\nlet x = 1;\nlet y = 2;\n```");
assert!(lines.contains(&"let x = 1;".to_owned()));
assert!(lines.contains(&"let y = 2;".to_owned()));
}
#[test]
fn rule_renders_horizontal_line() {
let lines = render_plain("a\n\n---\n\nb");
assert!(lines.iter().any(|l| l.contains('─')));
}
#[test]
fn image_alt_is_dropped() {
let lines = render_plain("![alt text](http://x)");
// Empty image paragraph collapses to nothing visible.
assert!(lines.iter().all(|l| !l.contains("alt text")));
}
#[test]
fn link_text_is_kept() {
let lines = render_plain("see [here](http://x)");
assert_eq!(lines, vec!["see here"]);
}
#[test]
fn empty_input_yields_no_lines() {
assert!(render_plain("").is_empty());
}
#[test]
fn unfinished_emphasis_is_treated_as_text() {
// Streaming partial: opener arrived, closer hasn't.
let lines = render_plain("hello **world");
assert_eq!(lines, vec!["hello **world"]);
}
}

430
crates/tui/src/task.rs Normal file
View File

@ -0,0 +1,430 @@
//! In-TUI mirror of the session-lifetime task store.
//!
//! This deliberately does NOT depend on `tools::TaskStore`. The TUI is a
//! presentation layer; pulling in `tools` would drag along `llm-worker`
//! and the whole tool surface. Instead we mirror the small subset we
//! need:
//!
//! - `TaskEntry` / `TaskStatus`: shaped to round-trip with `tools`'s JSON
//! serialization (`#[serde(rename_all = "lowercase")]` on the status,
//! matching field names on the entry).
//! - Just enough state machine to apply `TaskCreate` / `TaskUpdate`
//! tool-call arguments and the `[Session TaskStore snapshot]` system
//! message that compaction emits.
//!
//! The snapshot text format is owned by `tools::render_snapshot`. Since
//! `tools` itself parses it back on resume, the shape is a stable
//! contract.
use serde::Deserialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TaskStatus {
Pending,
Inprogress,
Completed,
Deleted,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct TaskEntry {
pub taskid: u64,
pub status: TaskStatus,
pub subject: String,
pub description: String,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct TaskCounts {
pub pending: usize,
pub inprogress: usize,
pub completed: usize,
pub deleted: usize,
}
impl TaskCounts {
pub fn total(&self) -> usize {
self.pending + self.inprogress + self.completed + self.deleted
}
pub fn active(&self) -> usize {
self.pending + self.inprogress
}
}
#[derive(Debug, Default, Clone)]
pub struct TaskStore {
next_taskid: u64,
tasks: Vec<TaskEntry>,
}
impl TaskStore {
pub fn new() -> Self {
Self {
next_taskid: 1,
tasks: Vec::new(),
}
}
pub fn tasks(&self) -> &[TaskEntry] {
&self.tasks
}
pub fn is_empty(&self) -> bool {
self.tasks.is_empty()
}
pub fn counts(&self) -> TaskCounts {
let mut c = TaskCounts::default();
for t in &self.tasks {
match t.status {
TaskStatus::Pending => c.pending += 1,
TaskStatus::Inprogress => c.inprogress += 1,
TaskStatus::Completed => c.completed += 1,
TaskStatus::Deleted => c.deleted += 1,
}
}
c
}
/// Apply a completed `TaskCreate` / `TaskUpdate` tool_call. Other
/// tool names and unparseable JSON are silent no-ops, matching the
/// resilience of `tools::TaskStore::replay_history`.
pub fn apply_tool_call(&mut self, name: &str, arguments: &str) {
match name {
"TaskCreate" => {
if let Ok(p) = serde_json::from_str::<TaskCreateParams>(arguments) {
self.tasks.push(TaskEntry {
taskid: self.next_taskid,
status: TaskStatus::Pending,
subject: p.subject,
description: p.description,
});
self.next_taskid = self.next_taskid.saturating_add(1);
}
}
"TaskUpdate" => {
if let Ok(p) = serde_json::from_str::<TaskUpdateParams>(arguments)
&& let Some(t) = self.tasks.iter_mut().find(|t| t.taskid == p.taskid)
{
if let Some(s) = p.status {
t.status = s;
}
if let Some(s) = p.subject {
t.subject = s;
}
if let Some(d) = p.description {
t.description = d;
}
}
}
_ => {}
}
}
/// Replace all state from a `[Session TaskStore snapshot]` system
/// message. No-op if the text doesn't carry one.
pub fn apply_system_message_text(&mut self, text: &str) {
if let Some(tasks) = parse_snapshot_text(text) {
self.replace_with(tasks);
}
}
fn replace_with(&mut self, tasks: Vec<TaskEntry>) {
self.next_taskid = tasks
.iter()
.map(|t| t.taskid)
.max()
.unwrap_or(0)
.saturating_add(1)
.max(1);
self.tasks = tasks;
}
}
#[derive(Debug, Deserialize)]
struct TaskCreateParams {
subject: String,
description: String,
}
#[derive(Debug, Deserialize)]
struct TaskUpdateParams {
taskid: u64,
#[serde(default)]
status: Option<TaskStatus>,
#[serde(default)]
subject: Option<String>,
#[serde(default)]
description: Option<String>,
}
#[derive(Debug, Deserialize)]
struct TaskSnapshot {
tasks: Vec<TaskEntry>,
}
fn parse_snapshot_text(text: &str) -> Option<Vec<TaskEntry>> {
if !text.starts_with("[Session TaskStore snapshot]") {
return None;
}
let start_marker = "```json\n";
let end_marker = "\n```";
let start = text.find(start_marker)? + start_marker.len();
let rest = &text[start..];
let end = rest.find(end_marker)?;
let snapshot: TaskSnapshot = serde_json::from_str(&rest[..end]).ok()?;
Some(snapshot.tasks)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn task_create_assigns_sequential_ids() {
let mut s = TaskStore::new();
s.apply_tool_call("TaskCreate", r#"{"subject":"a","description":"A"}"#);
s.apply_tool_call("TaskCreate", r#"{"subject":"b","description":"B"}"#);
let tasks = s.tasks();
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].taskid, 1);
assert_eq!(tasks[0].subject, "a");
assert_eq!(tasks[0].status, TaskStatus::Pending);
assert_eq!(tasks[1].taskid, 2);
}
#[test]
fn task_update_changes_status_and_fields() {
let mut s = TaskStore::new();
s.apply_tool_call("TaskCreate", r#"{"subject":"a","description":"A"}"#);
s.apply_tool_call(
"TaskUpdate",
r#"{"taskid":1,"status":"inprogress","subject":"a-renamed"}"#,
);
let t = &s.tasks()[0];
assert_eq!(t.status, TaskStatus::Inprogress);
assert_eq!(t.subject, "a-renamed");
assert_eq!(t.description, "A");
}
#[test]
fn malformed_arguments_are_silently_ignored() {
let mut s = TaskStore::new();
s.apply_tool_call("TaskCreate", r#"{"subject":1}"#);
s.apply_tool_call("TaskCreate", "not json");
s.apply_tool_call("Unknown", r#"{"subject":"x","description":"y"}"#);
s.apply_tool_call("TaskUpdate", r#"{"taskid":99,"status":"deleted"}"#);
assert!(s.tasks().is_empty());
}
#[test]
fn counts_classifies_each_status() {
let mut s = TaskStore::new();
s.apply_tool_call("TaskCreate", r#"{"subject":"a","description":""}"#);
s.apply_tool_call("TaskCreate", r#"{"subject":"b","description":""}"#);
s.apply_tool_call("TaskCreate", r#"{"subject":"c","description":""}"#);
s.apply_tool_call("TaskUpdate", r#"{"taskid":1,"status":"inprogress"}"#);
s.apply_tool_call("TaskUpdate", r#"{"taskid":2,"status":"completed"}"#);
let c = s.counts();
assert_eq!(c.pending, 1);
assert_eq!(c.inprogress, 1);
assert_eq!(c.completed, 1);
assert_eq!(c.deleted, 0);
assert_eq!(c.total(), 3);
assert_eq!(c.active(), 2);
}
/// Snapshot text matches the wrapping `Pod::try_pre_run_compact` /
/// `tools::render_snapshot` produce: header line, blank, overview
/// line, blank, fenced JSON, trailing prose.
fn wrap_snapshot(json_body: &str, overview: &str) -> String {
format!(
"[Session TaskStore snapshot]\n\n{overview}\n\n```json\n{json_body}\n```\n\n\
This is the complete session task list preserved across compaction. \
The following TaskList tool result presents the same state through the tool lane."
)
}
#[test]
fn snapshot_text_replaces_state_and_advances_next_id() {
let body = r#"{
"tasks": [
{
"taskid": 5,
"status": "completed",
"subject": "first",
"description": "first desc"
},
{
"taskid": 7,
"status": "pending",
"subject": "second",
"description": "second desc"
}
]
}"#;
let text = wrap_snapshot(
body,
"TaskStore: 2 task(s) (pending: 1, inprogress: 0, completed: 1, deleted: 0)",
);
let mut s = TaskStore::new();
s.apply_tool_call("TaskCreate", r#"{"subject":"stale","description":""}"#);
s.apply_system_message_text(&text);
let tasks = s.tasks();
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].taskid, 5);
assert_eq!(tasks[0].status, TaskStatus::Completed);
assert_eq!(tasks[1].taskid, 7);
// Subsequent TaskCreate must continue beyond the highest taskid
// observed in the snapshot.
s.apply_tool_call("TaskCreate", r#"{"subject":"new","description":""}"#);
assert_eq!(s.tasks()[2].taskid, 8);
}
#[test]
fn unrelated_system_message_is_ignored() {
let mut s = TaskStore::new();
s.apply_tool_call("TaskCreate", r#"{"subject":"a","description":""}"#);
s.apply_system_message_text("[File: src/main.rs]\nfn main() {}");
assert_eq!(s.tasks().len(), 1);
}
#[test]
fn snapshot_text_with_multiline_subject_round_trips() {
// Newlines / shape-breaking chars must survive JSON escaping.
let body = r#"{
"tasks": [
{
"taskid": 1,
"status": "inprogress",
"subject": "subject with\nembedded newline",
"description": "desc:\n status: not-actually-a-field"
}
]
}"#;
let text = wrap_snapshot(body, "TaskStore: 1 task(s)");
let mut s = TaskStore::new();
s.apply_system_message_text(&text);
let t = &s.tasks()[0];
assert_eq!(t.subject, "subject with\nembedded newline");
assert_eq!(
t.description,
"desc:\n status: not-actually-a-field"
);
}
}
/// Cross-crate contract tests. The TUI deliberately re-implements a
/// stripped-down mirror of `tools::TaskStore` instead of depending on
/// the real one (see `tickets/tui-task-display.md`). That decoupling
/// means a format change on the tools side — a renamed field on
/// `TaskEntry`, a different fence syntax in `render_snapshot`, a new
/// JSON wrapper — would silently leave the TUI parsing nothing instead
/// of failing loudly.
///
/// These tests pull `tools` in as a dev-dependency so the contract is
/// exercised at CI time. If they fail, either the format genuinely
/// changed (update both sides) or the TUI mirror has drifted (re-sync
/// it).
#[cfg(test)]
mod cross_format_contract {
use super::*;
use tools::task::{TaskStatus as ToolsTaskStatus, TaskStore as ToolsTaskStore};
/// Mirrors the envelope `Pod::try_pre_run_compact` wraps the raw
/// snapshot text in. Hand-rolled here so the test fails loudly if
/// the prose around the JSON fence ever shifts.
fn wrap_pod_style(snapshot_text: &str) -> String {
format!(
"[Session TaskStore snapshot]\n\n{snapshot_text}\n\n\
This is the complete session task list preserved across compaction. \
The following TaskList tool result presents the same state through the tool lane."
)
}
fn tools_status_label(s: ToolsTaskStatus) -> &'static str {
match s {
ToolsTaskStatus::Pending => "pending",
ToolsTaskStatus::Inprogress => "inprogress",
ToolsTaskStatus::Completed => "completed",
ToolsTaskStatus::Deleted => "deleted",
}
}
fn tui_status_label(s: TaskStatus) -> &'static str {
match s {
TaskStatus::Pending => "pending",
TaskStatus::Inprogress => "inprogress",
TaskStatus::Completed => "completed",
TaskStatus::Deleted => "deleted",
}
}
#[test]
fn tools_snapshot_text_round_trips_into_tui_store() {
let upstream = ToolsTaskStore::new();
upstream.create("first".into(), "first desc".into());
upstream.create("second".into(), "second desc with\nnewline".into());
upstream
.update(1, Some(ToolsTaskStatus::Inprogress), None, None)
.expect("update 1");
upstream
.update(2, Some(ToolsTaskStatus::Completed), None, None)
.expect("update 2");
let envelope = wrap_pod_style(&upstream.snapshot_text());
let mut downstream = TaskStore::new();
downstream.apply_system_message_text(&envelope);
let upstream_tasks = upstream.list();
let downstream_tasks = downstream.tasks();
assert_eq!(
downstream_tasks.len(),
upstream_tasks.len(),
"TUI parsed wrong number of tasks — `tools::render_snapshot` shape may have shifted"
);
for (u, d) in upstream_tasks.iter().zip(downstream_tasks.iter()) {
assert_eq!(d.taskid, u.taskid);
assert_eq!(d.subject, u.subject);
assert_eq!(d.description, u.description);
assert_eq!(tui_status_label(d.status), tools_status_label(u.status));
}
}
#[test]
fn tools_taskentry_field_shape_deserializes_into_tui_taskentry() {
// A single `tools::TaskEntry` round-tripped through JSON. Field
// renames like `taskid` → `task_id` or status case changes on
// the tools side would surface here as a serde failure or a
// wrong-status assertion.
let upstream = ToolsTaskStore::new();
let created = upstream.create("subj".into(), "desc".into());
let json = serde_json::to_string(&created).expect("serialize tools::TaskEntry");
let parsed: TaskEntry =
serde_json::from_str(&json).expect("deserialize into tui::task::TaskEntry");
assert_eq!(parsed.taskid, created.taskid);
assert_eq!(parsed.subject, created.subject);
assert_eq!(parsed.description, created.description);
assert_eq!(tui_status_label(parsed.status), "pending");
}
#[test]
fn empty_tools_store_snapshot_is_recognised_by_tui() {
// Edge case: a freshly initialised TaskStore still produces a
// valid snapshot envelope. The TUI must parse it as "zero
// tasks", not silently fall through to no-op.
let upstream = ToolsTaskStore::new();
let envelope = wrap_pod_style(&upstream.snapshot_text());
// Seed the TUI store with stale state to confirm replacement.
let mut downstream = TaskStore::new();
downstream.apply_tool_call("TaskCreate", r#"{"subject":"stale","description":""}"#);
assert_eq!(downstream.tasks().len(), 1);
downstream.apply_system_message_text(&envelope);
assert!(downstream.is_empty());
}
}

View File

@ -26,6 +26,7 @@ use protocol::{AlertLevel, CompletionEntry, Greeting, PodEvent, Segment};
use crate::app::{App, CompletionState, alert_source_label, fmt_tokens};
use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState};
use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore};
/// Display density for the history view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -64,9 +65,16 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
let input_content_width = area.width.saturating_sub(2).max(1);
let input_render = app.input.render(input_content_width);
let input_height = input_area_height(&input_render, area.height);
let mini_view_h = task_mini_view_height(&app.task_store);
// One blank row separates the history tail from the mini-view so
// the latest message doesn't visually crash into the task summary.
// Folds away with the mini-view when there are no tasks.
let mini_view_gap = if mini_view_h > 0 { 1 } else { 0 };
let chunks = Layout::vertical([
Constraint::Min(0), // history view
Constraint::Length(mini_view_gap), // gap above mini-view
Constraint::Length(mini_view_h), // task mini-view (0 when empty)
Constraint::Length(1), // separator
Constraint::Length(1), // status
Constraint::Length(input_height), // input area
@ -74,11 +82,109 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
.split(area);
draw_history(frame, app, chunks[0]);
draw_separator(frame, chunks[1]);
draw_status(frame, app, chunks[2]);
draw_input(frame, &input_render, chunks[3]);
if mini_view_h > 0 {
draw_task_mini_view(frame, &app.task_store, chunks[2]);
}
draw_separator(frame, chunks[3]);
draw_status(frame, app, chunks[4]);
draw_input(frame, &input_render, chunks[5]);
if let Some(state) = app.completion.as_ref().filter(|c| c.is_active()) {
draw_completion_popup(frame, state, chunks[3]);
draw_completion_popup(frame, state, chunks[5]);
}
}
/// Maximum number of active (pending / inprogress) tasks the mini-view
/// shows above the summary line. Exceeding tasks are still counted in
/// the summary.
const MINI_VIEW_MAX_ACTIVE: usize = 3;
/// Height the mini-view section occupies. Returns 0 when there are no
/// tasks at all, so the section collapses cleanly into surrounding
/// layout — there's no point reserving rows for an empty store.
fn task_mini_view_height(store: &TaskStore) -> u16 {
if store.is_empty() {
return 0;
}
let active_shown = store.counts().active().min(MINI_VIEW_MAX_ACTIVE);
// active rows + 1 summary line
(active_shown as u16).saturating_add(1)
}
fn draw_task_mini_view(frame: &mut Frame, store: &TaskStore, area: Rect) {
if area.height == 0 || area.width == 0 {
return;
}
let outer_block = UiBlock::default().padding(Padding::horizontal(HISTORY_PADDING));
let inner = outer_block.inner(area);
if inner.width == 0 || inner.height == 0 {
return;
}
let mut lines: Vec<Line<'static>> = Vec::with_capacity(area.height as usize);
let mut shown = 0usize;
for entry in store.tasks() {
if shown >= MINI_VIEW_MAX_ACTIVE {
break;
}
if !matches!(entry.status, TaskStatus::Pending | TaskStatus::Inprogress) {
continue;
}
lines.push(mini_view_active_line(entry, inner.width));
shown += 1;
}
lines.push(mini_view_summary_line(store.counts(), inner.width));
Paragraph::new(lines)
.block(outer_block)
.render(area, frame.buffer_mut());
}
fn mini_view_active_line(entry: &TaskEntry, width: u16) -> Line<'static> {
let mark = task_status_mark(entry.status);
// Subject's first line only; embedded newlines would otherwise
// wreck the one-row-per-task layout.
let subject = entry.subject.lines().next().unwrap_or("");
let mark_width = UnicodeWidthStr::width(mark.0);
// Reserve mark + space.
let budget = (width as usize).saturating_sub(mark_width + 1);
let shown = truncate_with_ellipsis(subject, budget);
Line::from(vec![
Span::styled(mark.0.to_owned(), mark.1),
Span::raw(" "),
Span::raw(shown),
])
}
fn mini_view_summary_line(counts: TaskCounts, width: u16) -> Line<'static> {
let text = format!(
"{} task(s) — pending: {}, inprogress: {}, completed: {}, deleted: {}",
counts.total(),
counts.pending,
counts.inprogress,
counts.completed,
counts.deleted,
);
let shown = truncate_with_ellipsis(&text, width as usize);
Line::from(Span::styled(
shown,
Style::default().fg(Color::DarkGray),
))
}
/// Two-character status marker + the style to render it with. Mirrors
/// the four `TaskStatus` values; deleted ones never appear in the
/// mini-view but are listed in the side pane.
fn task_status_mark(status: TaskStatus) -> (&'static str, Style) {
match status {
TaskStatus::Pending => ("[ ]", Style::default().fg(Color::DarkGray)),
TaskStatus::Inprogress => (
"[~]",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
TaskStatus::Completed => ("[x]", Style::default().fg(Color::Green)),
TaskStatus::Deleted => ("[-]", Style::default().fg(Color::Red)),
}
}
@ -217,8 +323,23 @@ fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) {
app.scroll.turn_starts.clear();
return;
}
// When the task pane is open and the area is wide enough, carve a
// vertical strip on the right for it. Side pane lives inside the
// history rect only — separator / status / input stay full width to
// keep the input experience and completion popup geometry intact.
let pane_w = task_side_pane_width(area.width, app.task_pane_open);
let history_area = if pane_w > 0 {
let split =
Layout::horizontal([Constraint::Min(1), Constraint::Length(pane_w)]).split(area);
draw_task_side_pane(frame, app, split[1]);
split[0]
} else {
area
};
let outer_block = UiBlock::default().padding(Padding::horizontal(HISTORY_PADDING));
let inner = outer_block.inner(area);
let inner = outer_block.inner(history_area);
if inner.width == 0 || inner.height == 0 {
return;
}
@ -248,6 +369,98 @@ fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) {
// uniformly for all rows.
Paragraph::new(visible)
.block(outer_block)
.render(history_area, frame.buffer_mut());
}
/// Width to reserve for the task side pane within the history rect.
/// Returns 0 when the pane is closed or the rect is too narrow to host
/// it without crushing the history view.
fn task_side_pane_width(area_width: u16, open: bool) -> u16 {
if !open {
return 0;
}
// Need a reasonable history column on the left, and enough room on
// the right for taskid + status mark + a few words of subject. Skip
// entirely on narrow terminals.
if area_width < 60 {
return 0;
}
(area_width / 3).clamp(28, 44)
}
fn draw_task_side_pane(frame: &mut Frame, app: &mut App, area: Rect) {
if area.width < 4 || area.height < 1 {
return;
}
let pane_block = UiBlock::default()
.borders(Borders::LEFT)
.border_style(Style::default().fg(Color::DarkGray))
.padding(Padding::horizontal(1));
let inner = pane_block.inner(area);
if inner.width == 0 || inner.height == 0 {
return;
}
let store = &app.task_store;
let counts = store.counts();
let title_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let body_style = Style::default().fg(Color::DarkGray);
let muted_style = Style::default().fg(Color::DarkGray);
let mut logical: Vec<Line<'static>> = Vec::new();
logical.push(Line::from(Span::styled(
format!("Tasks ({})", counts.total()),
title_style,
)));
logical.push(Line::from(""));
if store.is_empty() {
logical.push(Line::from(Span::styled("(no tasks)", muted_style)));
} else {
for entry in store.tasks() {
let mark = task_status_mark(entry.status);
let subject_first = entry.subject.lines().next().unwrap_or("");
logical.push(Line::from(vec![
Span::styled(format!("#{} ", entry.taskid), muted_style),
Span::styled(mark.0.to_owned(), mark.1),
Span::raw(" "),
Span::raw(subject_first.to_owned()),
]));
// Subject continuations (multiline subjects).
for cont in entry.subject.lines().skip(1) {
logical.push(Line::from(vec![
Span::raw(" "),
Span::raw(cont.to_owned()),
]));
}
for raw in entry.description.lines() {
logical.push(Line::from(vec![
Span::styled(" ", body_style),
Span::styled(raw.to_owned(), body_style),
]));
}
logical.push(Line::from(""));
}
}
// Pre-wrap to inner width so scroll math degenerates to row indices.
let mut wrapped: Vec<Line<'static>> = Vec::with_capacity(logical.len());
for line in logical {
wrap_line_into(line, inner.width, &mut wrapped);
}
let max_scroll = wrapped.len().saturating_sub(inner.height as usize);
if app.task_pane_scroll > max_scroll {
app.task_pane_scroll = max_scroll;
}
let start = app.task_pane_scroll;
let end = (start + inner.height as usize).min(wrapped.len());
let visible: Vec<Line<'static>> = wrapped[start..end].to_vec();
Paragraph::new(visible)
.block(pane_block)
.render(area, frame.buffer_mut());
}
@ -361,6 +574,7 @@ fn render_block_into(lines: &mut Vec<Line<'static>>, block: &Block, width: u16,
)));
}
Block::UserMessage { segments } => render_user_message(lines, segments, width, mode),
Block::SystemMessage { text } => render_system_message(lines, text, width, mode),
Block::Notify { message } => {
let text = format!("[notify] {message}");
match mode {
@ -377,7 +591,7 @@ fn render_block_into(lines: &mut Vec<Line<'static>>, block: &Block, width: u16,
}
Block::AssistantText { text } => match mode {
Mode::Overview => push_overview_line(lines, text, width, MessageKind::Assistant, ""),
_ => push_padded_lines(lines, text, MessageKind::Assistant),
_ => lines.extend(crate::markdown::render(text, kind_style(MessageKind::Assistant))),
},
Block::Thinking(t) => render_thinking(lines, t, width, mode),
// ToolCall is dispatched in `compute_history` via `tool::render_tool`
@ -481,6 +695,77 @@ fn render_user_message(
}
}
fn render_system_message(lines: &mut Vec<Line<'static>>, text: &str, width: u16, mode: Mode) {
let header_style = kind_style(MessageKind::System);
let body_style = Style::default().fg(Color::DarkGray);
let (header, body) = split_system_message(text);
let overview_text = if body.is_empty() {
header.to_owned()
} else {
format!("{header} {body}")
};
match mode {
Mode::Overview => push_overview_line(lines, &overview_text, width, MessageKind::System, ""),
Mode::Detail => {
lines.push(Line::from(Span::styled(header.to_owned(), header_style)));
for raw in body.lines() {
lines.push(Line::from(vec![
Span::styled(" ", body_style),
Span::styled(raw.to_owned(), body_style),
]));
}
if body.is_empty() && header.is_empty() {
lines.push(Line::from(""));
}
}
Mode::Normal => {
lines.push(Line::from(Span::styled(header.to_owned(), header_style)));
let preview = system_message_preview(body, 4);
for line in preview.lines {
lines.push(Line::from(vec![
Span::styled(" ", body_style),
Span::styled(line, body_style),
]));
}
if preview.omitted_lines > 0 {
lines.push(Line::from(vec![
Span::styled(" ", body_style),
Span::styled(
format!("… ({} more lines)", preview.omitted_lines),
body_style.add_modifier(Modifier::ITALIC),
),
]));
}
}
}
}
fn split_system_message(text: &str) -> (&str, &str) {
match text.split_once('\n') {
Some((header, body)) => (header, body.trim_start_matches('\n')),
None => (text, ""),
}
}
struct SystemMessagePreview {
lines: Vec<String>,
omitted_lines: usize,
}
fn system_message_preview(body: &str, max_lines: usize) -> SystemMessagePreview {
let all: Vec<&str> = body.lines().collect();
let lines = all
.iter()
.take(max_lines)
.map(|line| (*line).to_owned())
.collect();
SystemMessagePreview {
lines,
omitted_lines: all.len().saturating_sub(max_lines),
}
}
/// Style + display text for a single chip-style `Segment`. `fallback`
/// is used for `Segment::Text` (which the caller handles inline) and
/// for `Segment::Unknown` so future variants degrade gracefully.
@ -931,6 +1216,8 @@ pub enum MessageKind {
/// Visually distinct from User / Assistant / Notice so it's clear
/// the line came from another Pod or operator, not the local user.
Notify,
/// Persisted role:system history item preview.
System,
Assistant,
Thinking,
TurnStats,
@ -943,6 +1230,7 @@ pub fn kind_style(kind: MessageKind) -> Style {
MessageKind::TurnHeader => Style::default().fg(Color::DarkGray),
MessageKind::User => Style::default().fg(Color::Green),
MessageKind::Notify => Style::default().fg(Color::Yellow),
MessageKind::System => Style::default().fg(Color::Cyan),
MessageKind::Assistant => Style::default().fg(Color::White),
MessageKind::Thinking => Style::default()
.fg(Color::Magenta)
@ -975,7 +1263,9 @@ fn format_pod_event(event: &PodEvent) -> String {
format!("[pod_event] {pod_name} → shut_down")
}
PodEvent::ScopeSubDelegated {
parent_pod, sub_pod, ..
parent_pod,
sub_pod,
..
} => {
format!("[pod_event] {parent_pod} → scope_sub_delegated: {sub_pod}")
}

View File

@ -23,8 +23,8 @@ Pod::handle_worker_result
→ persist_turn旧セッションに記録
→ compact() → resume()
[ターンの合間 — Pod::run 完了後]
Controller::try_post_run_compact ← proactive
[ターンの合間 — 次の Pod::run 冒頭]
Pod::try_pre_run_compact ← proactive
→ input_tokens > post_run_threshold なら compact() (best-effort)
```
@ -66,7 +66,7 @@ pub struct ToolOutput {
### トリガー2段階の閾値
1. **ターンの合間 (Controller)**: `try_post_run_compact()` で `input_tokens > post_run_threshold` → best-effort
1. **ターンの合間 (次 turn 冒頭)**: `try_pre_run_compact()` で `input_tokens > post_run_threshold` → best-effort
2. **リクエストの合間 (CompactInterceptor)**: `pre_llm_request``input_tokens > request_threshold``PreRequestAction::Yield`
**ターンの合間が proactive (小さい閾値)**:

285
docs/manifest.toml Normal file
View File

@ -0,0 +1,285 @@
# ============================================================================
# Pod Manifest リファレンス
# ============================================================================
# Pod の宣言的設定 (`PodManifest` / `PodManifestConfig`)。
#
# カスケード層は下から順に
# 1. builtin defaults (`manifest::defaults`)
# 2. user manifest (`<config_dir>/manifest.toml`)
# 3. project manifest (cwd から上方向に探す `.insomnia/manifest.toml`)
# 4. programmatic overlay (呼び出し側が差し込む)
# 上の層が同名フィールドを上書き、scope rule と skills.directories は
# 累積マージ、tool_output.per_tool は key 単位でマージ。
#
# パス解決: 相対パスは「その層の manifest ファイルが置かれているディレクトリ」
# を base に絶対パスへ解決される (overlay 層は cwd)。マージは絶対化済みの
# 値同士で行われる。
#
# 凡例:
# - 必須 … 値が無いと resolve エラー
# - 任意 … 省略可
# - デフォルト … 省略時に採用される値 (None なら "なし")
# - 値 … enum 等で取り得る値
# ----------------------------------------------------------------------------
# ===== [pod] ================================================================
# Pod メタデータ。
[pod]
# 必須。Pod の表示名 (ResolveError::MissingField("pod.name") の対象)。
name = "example-agent"
# 任意。デフォルト: なし。
# PromptCatalog の 4 つ目の overlay 層として読み込む TOML pack のパス。
# 相対パスは manifest base 起点で解決。`worker.instruction` (`$prefix/...`)
# とは別系統の単なるファイルパス。
# prompt_pack = "./prompts.local.toml"
# ===== [model] ==============================================================
# LLM モデル設定。次の 3 形態を受ける:
# (a) `ref` 単独 — カタログから全部解決
# (b) `ref` + 一部 override — auth など個別差し替え
# (c) `scheme` + `model_id` 直書き — カタログを使わない inline 指定
# (b) / (c) では `ref` 未指定なら `scheme` / `model_id` / `auth` が必須。
# (実際の必須判定は `crates/provider` の resolve 側で行う)
[model]
# 任意。形式: "<provider_id>/<model_id_in_ref>"。
# 最初の `/` だけで split されるので、`openrouter/anthropic/claude-sonnet-4`
# のように内部 model_id に `/` が含まれる場合もそのまま書ける。
ref = "anthropic/claude-sonnet-4-6"
# 任意 (ただし ref 未指定時は実質必須)。デフォルト: なし。
# 値: "anthropic" | "openai_chat" | "openai_responses" | "gemini"
# scheme = "anthropic"
# 任意 (ref 未指定時は実質必須)。デフォルト: なし。
# プロバイダが受け付けるモデル ID 文字列。
# model_id = "claude-sonnet-4-20250514"
# 任意。デフォルト: scheme ごとの組み込み既定 URL。
# base_url = "https://api.anthropic.com"
# 任意 (ref 未指定時は実質必須)。デフォルト: なし。
# kind の値: "none" | "api_key" | "codex_oauth"
# - "none" … 認証不要 (ローカル Ollama 等)
# - "api_key" … env / file のいずれかで key を渡す。両方指定なら env 優先。
# env 未指定時は scheme ごとの既定環境変数:
# Anthropic -> INSOMNIA_API_KEY_ANTHROPIC
# OpenaiChat / OpenaiResponses -> INSOMNIA_API_KEY_OPENAI
# Gemini -> INSOMNIA_API_KEY_GEMINI
# file 指定時、相対パスは manifest base 起点で解決。
# - "codex_oauth" … ChatGPT OAuth (`~/.codex/auth.json`)。追加フィールドなし。
# auth = { kind = "none" }
# auth = { kind = "api_key" } # env のみ既定使用
# auth = { kind = "api_key", env = "MY_ANTHROPIC_KEY" }
# auth = { kind = "api_key", file = "./sk-ant.local" }
# auth = { kind = "codex_oauth" }
# 任意。デフォルト: モデルカタログ → provider.default_capability → scheme 既定
# の順で解決される。明示 override したいときだけ書く。
# [model.capability]
# # 値: "none" | "sequential" | "parallel"
# tool_calling = "parallel"
# # 値: "none" | "json_object" | "json_schema"
# structured_output = "json_schema"
# # 任意。値: 省略 (= None) | "effort" | "budget_tokens" | "both"
# reasoning = "both"
# # 任意。デフォルト: false
# vision = false
# # 値: { kind = "explicit", max_breakpoints = <u8> } | { kind = "auto" }
# prompt_caching = { kind = "explicit", max_breakpoints = 4 }
# ===== [worker] =============================================================
# ワーカーの生成パラメータ等。セクション自体省略可 (全フィールド任意)。
[worker]
# 任意。デフォルト: "$insomnia/default" (`defaults::DEFAULT_INSTRUCTION`)。
# システムプロンプト本体の `PromptLoader` 参照。
# プレフィクス: "$insomnia/..." | "$user/..." | "$workspace/..."
# instruction = "$insomnia/default"
# 任意。デフォルト: なし (プロバイダ任せ)。
# 1 レスポンスあたりの出力 token 上限。
# max_tokens = 4096
# 任意。デフォルト: なし (無制限)。
# 1 セッションの最大ターン数。NonZeroU32 — 0 は parse エラー。
# max_turns = 50
# 任意。デフォルト: なし (プロバイダ既定)。
# temperature = 0.3
# 任意。デフォルト: なし (プロバイダ既定)。
# top_p = 0.9
# 任意。デフォルト: なし (プロバイダ既定)。
# top_k = 40
# 任意。デフォルト: 空配列。
# stop_sequences = ["\n\n", "</stop>"]
# 任意。デフォルト: なし。
# 値:
# - 文字列 effort: "minimal" | "low" | "medium" | "high" | "xhigh"
# あるいは provider-native な任意ラベル文字列
# - 整数: Anthropic 系 thinking.budget_tokens (-1 で dynamic)
# reasoning = "medium"
# reasoning = -1
# 任意。tool 実行 content の byte 長キャップ。
# セクション省略時は default_max_bytes = 16 * 1024、per_tool 空。
# [worker.tool_output]
# # 任意。デフォルト: 16384 (`defaults::TOOL_OUTPUT_MAX_BYTES` = 16 KiB)。
# default_max_bytes = 16384
#
# # 任意。デフォルト: 空マップ。tool 名キーで個別キャップ上書き。
# # キーは tool の登録名 ("Read", "Grep", "Glob", ...)。
# [worker.tool_output.per_tool]
# Read = 32768
# Grep = 4096
# ===== [scope] ==============================================================
# Pod がアクセスできるディレクトリ/ファイル範囲。
# - allow: 最低 1 件必要 (空だと ResolveError / ScopeError::EmptyAllow)。
# 複数 allow がマッチした場合は最大の permission が採用される。
# - deny : 任意。マッチした deny の最小 permission *未満* に effective を
# 押し下げる (deny.read で完全遮断、deny.write で Read 止まり)。
# `target` は最終的に絶対パスでなければならない。manifest 内では相対 OK
# (manifest base 起点で resolve)。
# 必須: 最低 1 件の allow ルール。
[[scope.allow]]
# 必須。manifest 内では相対 OK (base 起点で絶対化)。
target = "./"
# 必須。値: "read" | "write"
permission = "write"
# 任意。デフォルト: true (再帰的にマッチ)。
# false の場合、ルール自身および直下の child のみマッチ。
# recursive = true
# allow は何件でも書ける。
# [[scope.allow]]
# target = "/abs/docs"
# permission = "read"
# recursive = false
# 任意。アクセスを *ルール内 permission 未満* に押し下げる。
# [[scope.deny]]
# target = "./secrets"
# permission = "write" # write を禁止 → 該当パスは Read までに降格
# recursive = true
#
# [[scope.deny]]
# target = "./secrets/key"
# permission = "read" # read 自体を禁止 → アクセス完全遮断
# recursive = true
# ===== [compaction] =========================================================
# コンテキスト圧縮 (Prune / Compact)。セクション省略で両方無効。
# セクションを書いた時点で Prune は有効化、Compact は閾値が None なら無効。
# [compaction]
#
# # 任意。デフォルト: 3 (`defaults::PRUNE_PROTECTED_TURNS`)。
# # pruning から保護する末尾ターン数。
# prune_protected_turns = 3
#
# # 任意。デフォルト: 4096 (`defaults::PRUNE_MIN_SAVINGS`)。
# # prune が発火するための最低節約 token 推定値。
# prune_min_savings = 4096
#
# # 任意。デフォルト: なし (proactive compact 無効)。
# # ターン間チェック (Controller post-run)。占有 token > これ で次ターン前に compact。
# compact_threshold = 80000
#
# # 任意。デフォルト: なし (safety-net compact 無効)。
# # ターン中チェック (PodInterceptor::pre_llm_request)。期待される関係:
# # compact_threshold < compact_request_threshold (proactive を先に発火)。
# # 逆順設定は許容するが warn ログを出す。
# compact_request_threshold = 90000
#
# # 任意。デフォルト: 8000 (`defaults::COMPACT_RETAINED_TOKENS`)。
# # compaction 後の history 末尾に verbatim で残す token budget。
# compact_retained_tokens = 8000
#
# # 任意。デフォルト: 8000 (`defaults::COMPACT_AUTO_READ_BUDGET`)。
# # compact worker が `mark_read_required` で取り込める累計 token。
# compact_auto_read_budget = 8000
#
# # 任意。デフォルト: 50000 (`defaults::COMPACT_WORKER_MAX_INPUT_TOKENS`)。
# # compact worker 自身の累積入力 token cap。超過で abort (circuit breaker)。
# compact_worker_max_input_tokens = 50000
#
# # 任意。デフォルト: メインモデルを `clone_boxed()` で複製。
# # compact 専用モデルを使う場合のみ書く ([model] と同じ形式)。
# # [compaction.model]
# # ref = "anthropic/claude-haiku-4-5"
# ===== [memory] =============================================================
# Memory subsystem の opt-in。
# - セクションが *ある* … memory tools (MemoryRead/Write/Edit) を登録、
# `<workspace>/memory/` と `<workspace>/knowledge/`
# の通常 write を Pod 自体に対して deny する。
# - セクションが *無い* … 何も起きない (legacy 動作)。
# `[memory]` だけ書いて中身を省略するのも有効 (全フィールド既定値で有効化)。
# [memory]
#
# # 任意。デフォルト: Pod の pwd (構築時)。
# # 必ず絶対パス (相対なら manifest base 起点で resolve)。
# workspace_root = "/abs/path/to/workspace"
#
# # 任意。デフォルト: tool 側既定 = 20。
# # MemoryQuery / KnowledgeQuery が 1 回に返す最大件数。
# query_result_limit = 20
#
# # 任意。デフォルト: tool 側既定 = 3。
# # 各マッチ前後に表示するコンテキスト行数。`query` 省略時は無視。
# query_excerpt_lines = 3
#
# # 任意。デフォルト: メインモデルを `clone_boxed()` で複製。
# # Phase 1 (extract) ワーカーのモデル ([model] と同じ形式)。
# # Haiku / 4o-mini / Flash クラスの軽量 reasoning モデル推奨。
# # [memory.extract_model]
# # ref = "anthropic/claude-haiku-4-5"
#
# # 任意。デフォルト: なし (Phase 1 自動発火を完全停止)。
# # 前回 extract pointer 以降の累積入力 token がこの値を超えると Phase 1 起動。
# # ※ memory tools と resident injection は extract_threshold が None でも動く。
# extract_threshold = 30000
#
# # 任意。デフォルト: 30000 (`defaults::MEMORY_EXTRACT_WORKER_MAX_INPUT_TOKENS`)。
# # extract worker 自身の累積入力 token cap (超過で abort)。
# extract_worker_max_input_tokens = 30000
#
# # 任意。デフォルト: メインモデルを `clone_boxed()` で複製。
# # Phase 2 (consolidation) ワーカーのモデル。reasoning クラス推奨。
# # [memory.consolidation_model]
# # ref = "anthropic/claude-sonnet-4-6"
#
# # 任意。デフォルト: なし。
# # `_staging/` のエントリ数がこの値以上で Phase 2 発火 (files / bytes は OR)。
# consolidation_threshold_files = 50
#
# # 任意。デフォルト: なし。
# # `_staging/` の総バイト数がこの値以上で Phase 2 発火 (files / bytes は OR)。
# # files / bytes の両方が None だと Phase 2 完全無効。
# consolidation_threshold_bytes = 1048576
# ===== [skills] =============================================================
# 外部 Agent Skills (`SKILL.md`) を Workflow として読み込むディレクトリ群。
# セクション省略 = 何もロードしない (implicit な `$config_dir/skills/` 検索や
# builtin probe は存在しない)。
# [skills]
#
# # 任意。デフォルト: 空配列。
# # 各エントリは skills *root* (root の child 各々が `<name>/SKILL.md` を持つ
# # skill バンドル)。root 自身は skill ではない。
# # 相対パスは manifest base 起点で解決。マージ時は層を跨いで concat される。
# directories = [".claude/skills", ".cursor/skills"]

View File

@ -98,7 +98,7 @@ Linter ルールは 2 系統:
- Decisions / Requests: `created_at`, `updated_at`, `sources`
- Knowledge: `kind`, `description`, `model_invokation`, `user_invocable`, `last_sources`, `created_at`, `updated_at`
- Summary: `updated_at`optional: `last_rewritten_from_range`
- `memory/workflow/` への書き込み禁止sub-Worker context のみ、人間編集は除外)
- Workflow パス(`.insomnia/workflow/`への書き込み禁止sub-Worker context のみ、人間編集は除外)
- 同 slug での新規作成禁止(既存があれば update に切り替えるサイン)
- `#<slug>` 参照が実在ファイルを指す
- `replaced_by: <slug>` が実在 record を指す

View File

@ -24,7 +24,8 @@ Workflow は制約付きの強制的な作業フロー。`/<slug>` で明示的
### 格納先とファイル形式
- `memory/workflow/<slug>.md`(ファイル名 = slug がそのまま識別子、`name` field は持たない)
- `.insomnia/workflow/<slug>.md`(ファイル名 = slug がそのまま識別子、`name` field は持たない)
- `.insomnia/memory/` は session-derived state 専用、Workflow は配置しない
- frontmatter + Markdown 本文
- frontmatter フィールド: `description`, `auto_invoke`, `user_invocable`, `requires`

View File

@ -0,0 +1,217 @@
# LLMのReasoningコンテキスト管理仕様 比較レポート
**対象**: Claude (Anthropic) / ChatGPT (OpenAI) / Ollama
**作成日**: 2026年5月4日
**目的**: 各LLMプロバイダがReasoning思考トレースをマルチターン会話でどのように扱うかを整理し、実装時の注意点をまとめる。
---
## 1. はじめに
Reasoning対応モデルは、最終応答の前に「思考プロセス」を生成する。この思考はユーザーに見せる/見せない、次ターンに残す/残す、ツール使用中に保持する/しない、といった扱いがプロバイダごとに大きく異なる。本レポートでは Claude / ChatGPT / Ollama の3プラットフォームについて、コンテキスト管理の仕様を比較する。
主要な論点は以下の3つ:
1. **思考の表現形式** — どのフィールド/ブロックに格納されるか
2. **マルチターン保持** — 次のユーザーターンに進んだとき思考は残るか
3. **ツール使用との関係** — ツール呼び出しの前後で思考をどう扱うか
---
## 2. Claude (Anthropic)
### 2.1 表現形式
Claudeは API レスポンスに専用の `thinking` ブロックを返す。コンテンツブロック配列の中に `type: "thinking"` の要素として並び、最終応答は `type: "text"` ブロックとして別に格納される。
`thinking` ブロックには `signature` という暗号化フィールドが付与され、改ざん検知や正当性確認に使われる。これは Claude 4 系列で長くなり、API 利用者は値を解釈・パースしてはならない仕様。
### 2.2 マルチターン保持の挙動
モデル世代によってデフォルトが分かれる:
- **Opus 4.5+ / Sonnet 4.6+**: 過去ターンの thinking ブロックがコンテキストに**保持される**(入力トークンとしてカウント)
- **それ以前の Opus/Sonnet および全 Haiku**: 過去ターンの thinking ブロックは**剥離される**(コンテキストに加算されない)
これは Anthropic 側の方針転換で、新世代では「推論の連続性」を優先する設計に変わった。実効コンテキストウィンドウの計算式は次のようになる:
```
context_window = (current input tokens previous thinking tokens) + (thinking tokens + encrypted thinking tokens + text output tokens)
```
### 2.3 ツール使用との関係
ツール使用時は仕様が厳密になる。`tool_use` → `tool_result` → 続きのアシスタント応答という流れが**同一論理ターン**として扱われ、この間 thinking ブロックは**必ず保持して API に渡し戻す**必要がある。これはモデルの推論連続性を維持するため。
ただし境界条件として、tool_result ではない通常の user メッセージが入った時点で、それまでの thinking ブロックは無視・剥離される。つまり「新しいユーザー発話」がリセットポイントになる。
### 2.4 制御API
`clear_thinking_20251015` という context-editing 戦略により、開発者側で保持ポリシーを上書きできる:
```python
context_management={
"edits": [{
"type": "clear_thinking_20251015",
"keep": {"type": "thinking_turns", "value": 2}
}]
}
```
`keep.value` で「直近Nターン分の thinking だけ残す」といった粒度で指定可能。新世代モデルで thinking 保持がデフォルトになったため、コンテキスト圧迫を避けたい場合に使う。
---
## 3. ChatGPT (OpenAI)
### 3.1 表現形式
OpenAIの設計思想は Claude/Ollama と大きく異なり、**生のreasoningトークンはユーザーに見せない**。安全性上の理由から、reasoning は要約された形式reasoning summariesでのみ可視化される。
API レスポンスには `ResponseReasoningItem` という型のオブジェクトが含まれ、これは ID とオプションの暗号化コンテンツを持つが、本文は黒箱として扱われる。raw reasoning を `reasoning summary` 以外の方法で抽出しようとする試みは利用規約違反となる可能性がある。
### 3.2 マルチターン保持の挙動
ここが最も特殊。**Chat Completions API と Responses API で挙動が違う**。
#### Chat Completions API (旧来)
reasoning トークンは各ターンの後に破棄される。次ターンには引き継がれないinput/output tokens のみが次ステップに送られる)。これは o1 系列の初期設計。
#### Responses API (推奨)
ステートフル管理が可能。reasoning items を次のリクエストに引き継ぐには2方式ある:
1. `previous_response_id` パラメータで過去のレスポンスを参照
2. `response.output` の全アイテムを次の `input` に手動で渡す
ステートレス利用(`store=false`、ZDR組織の場合は `include=["reasoning.encrypted_content"]` を指定すれば暗号化された推論コンテンツを受け取り、次リクエストに渡すことで推論を引き継げる。
#### モデル世代差
- **o1 / o3-mini / o1-mini / o1-preview**: フォローアップリクエストで reasoning items は常に無視されるinput に含めても)
- **o3 / o4-mini 以降**: function call に隣接する一部の reasoning items はコンテキストに含まれ、ツール使用の連続性を改善する
ZDR (Zero Data Retention) を有効にした組織でも、暗号化機構により reasoning items を OpenAI 側に保存せずに API リクエスト間で再利用できるようになっている。
### 3.3 ツール使用との関係
o3 / o4-mini 以降では、function call をチェーン・オブ・ソート内で直接呼べるようになっており、reasoning tokens がリクエスト・ツール呼び出しを跨いで保持される。これにより複数ステップのエージェント的タスクでインテリジェンスが向上し、コスト/レイテンシも削減される(推論キャッシュの再利用)。
### 3.4 制御API
`reasoning.effort` パラメータで思考量を `low` / `medium` / `high` で指定可能o1-mini は非対応)。`max_output_tokens` で reasoning + 最終出力の合計トークンを制限できる。
---
## 4. Ollama
### 4.1 表現形式
Ollamaはローカル実行プラットフォームで、モデルごとに思考タグの規約が異なる。レスポンスでは `message.thinking`chat エンドポイント)または `thinking`generate エンドポイント)に推論トレース、`message.content` / `response` に最終回答が分離されて格納される。
内部的にはモデル固有のパーサが動く:
- **Qwen3Parser** — Qwen 系の thinking タグとツール呼び出しタグを処理
- **DeepSeek3Parser** — DeepSeek の推論出力に最適化
- **Harmony** — GPT-OSS モデル用、low/medium/high の段階的 thinking レベルをサポート
ハードコードされたパーサがないモデルでは `thinking.InferTags` がプロンプトテンプレートをスキャンし、`<think>...</think>` などのデリミタを推測する。
### 4.2 マルチターン保持の挙動
**Ollama自体はthinking履歴を管理しない**。これは設計上の重要な特徴で、メッセージ配列を組み立てるクライアント側の責任になる。
モデル側のテンプレート設計レベルで「履歴に思考を残すな」と明示しているケースが多い。例えば Gemma 4 系は公式ドキュメントで明示的に「マルチターン会話では過去のモデル出力には最終応答のみを含めるべきで、過去のモデルターンの思考は次のユーザーターンの前に追加してはいけない」と指示している。DeepSeek-R1 や Qwen3 も同様の前提で訓練されている。
したがって標準的な実装パターンは「**次ターン送信時に thinking フィールドを落とし、content だけ履歴に積む**」となる。Ollama が thinking を別フィールドに分離しているのは、ユーザーが履歴構築時に簡単に捨てられるようにする意図もある。
### 4.3 ツール使用との関係
ツール呼び出しループで思考を保持したい場合、`clear_thinking=false` を上流APIで設定することで `<think>` ブロックを会話コンテキストに保持できる。OllamaはThinkValueが nil または true のときこれを自動処理する。
実用上は Open WebUI などのクライアント実装が参考になる。Open WebUI は同一ターン内のツール呼び出しでは reasoning コンテンツを保持し、`<think>...</think>` タグでシリアライズして次のAPIコール時の assistant メッセージの content フィールドに含める方式を採っている。
### 4.4 制御API
主要な制御は以下:
- `think` パラメータ — booleantrue/false、ただし GPT-OSS は `"low"` / `"medium"` / `"high"` の文字列のみ受け付ける
- CLI: `--think` / `--think=false` / `--hidethinking`(思考はするが表示しない)
- サーバー起動時: `ollama serve --reasoning-parser deepseek_r1` でパーサを明示
サポート判定は `supportsThinking` 関数が `config.json` を見て、glm4moe / deepseek / qwen3 などの既知アーキテクチャか確認する仕組み。
---
## 5. 比較表
### 5.1 仕様サマリー
| 観点 | Claude | ChatGPT (OpenAI) | Ollama |
|---|---|---|---|
| **思考の可視性** | 完全可視生のthinking | 要約のみ可視(生は黒箱) | 完全可視(モデル次第でタグ形式) |
| **思考の格納先** | 専用ブロック (`type: "thinking"`) | `ResponseReasoningItem` | `message.thinking` フィールド |
| **暗号化/署名** | signature付き | encrypted_contentオプション | なし |
| **デフォルトの履歴保持** | Opus 4.5+/Sonnet 4.6+: 保持<br>それ以前: 剥離 | Chat Completions: 破棄<br>Responses: 引き継ぎ可 | クライアント責任<br>(モデル指示は剥離が主流) |
| **ツール使用中の保持** | 必須(同一ターン内) | o3/o4-mini で関連部分自動保持 | `clear_thinking=false` で制御 |
| **管理層** | API側がマネージド | API側がマネージド | クライアント側で実装 |
| **思考量制御** | `clear_thinking_20251015`戦略 | `reasoning.effort` (low/medium/high) | `think`パラメータ (bool / level) |
### 5.2 設計思想の違い
| プロバイダ | 思想 |
|---|---|
| **Claude** | 「マネージドな推論ブロック」として抽象化。署名付きで改ざん耐性を持たせ、API側で保持/剥離を判断 |
| **ChatGPT** | 「思考は黒箱、要約だけ見せる」。安全性とIP保護優先で、推論の引き継ぎはAPI仕様Responsesでハンドル |
| **Ollama** | 「プロンプトに混ぜるタグの開閉とパース」という素朴な仕組み。クライアントの自由度が高いが責任も大きい |
---
## 6. 実装上の注意点
### 6.1 共通の落とし穴
- **モデル世代差を見落とす** — Claude も OpenAI も、世代によってデフォルトが大きく異なる。バージョン固定や挙動確認は必須
- **ツール使用ループでの推論喪失** — Claude/Ollama 共に、ツール使用中に thinking を落とすと推論連続性が壊れる。同一ターン内は必ず保持する
- **トークンコスト** — Claude 新世代では thinking が入力トークンとして加算される。長い対話では `clear_thinking_20251015` でのトリミングが必要
### 6.2 プロバイダ別の推奨パターン
**Claude を使うとき**
- 4.5+/4.6+ ではデフォルトで thinking が残るため、長期会話ではコンテキスト圧迫に注意
- 旧世代との互換コードを書く場合は、両方のデフォルト挙動を意識する
- ツール使用時は受け取った thinking ブロックを**完全な形で**渡し戻す
**ChatGPT を使うとき**
- 新規実装は **Responses API** を選ぶChat Completions は推論引き継ぎが弱い)
- ZDR組織でも `reasoning.encrypted_content` で推論を引き継げる
- raw reasoning の抽出を試みない(規約違反の可能性)
**Ollama を使うとき**
- クライアント側で thinking フィールドの扱いを明示的に決める
- モデル毎のテンプレート規約を確認するGemma 4 のように「履歴に残すな」指示があるモデルもある)
- サーバー起動時に `--reasoning-parser` を適切に設定する
---
## 7. まとめ
3プラットフォームのReasoning管理は、それぞれ異なる優先事項を反映している:
- **Claude** は「推論の透明性と連続性」を最重視し、API側でリッチなマネジメントを提供
- **ChatGPT** は「安全性とIP保護」を最重視し、生の推論をユーザーから隠蔽しつつ機能性は Responses API で担保
- **Ollama** は「ローカル実行と柔軟性」を最重視し、クライアントに最大限の制御権を委ねる
実装者にとっては、「次のユーザーターンに進んだ時点で何が剥がれるか」を**プロバイダ × モデル世代 × API選択**の3軸で把握しておくことが重要。特にツール使用を伴うエージェント構築では、各プラットフォームの推論保持機構を正しく使わないと、推論連続性の喪失によりインテリジェンスが大幅に低下する。
---
## 参考資料
- Anthropic: [Building with extended thinking](https://docs.claude.com/en/docs/build-with-claude/extended-thinking)
- Anthropic: [Context editing](https://platform.claude.com/docs/en/build-with-claude/context-editing)
- OpenAI: [Reasoning models guide](https://developers.openai.com/api/docs/guides/reasoning)
- OpenAI Cookbook: [Better performance from reasoning models using the Responses API](https://cookbook.openai.com/examples/responses_api/reasoning_items)
- Ollama: [Thinking capability docs](https://docs.ollama.com/capabilities/thinking)
- Ollama Blog: [Thinking](https://ollama.com/blog/thinking)

View File

@ -0,0 +1,94 @@
# ファイルベース Ticket と Scope 委譲の相性
日付: 2026-05-05
## 要旨
insomnia を使って insomnia を開発する運用では、ファイルベースの ticket / review lifecycle と、Pod の write scope 委譲が衝突しやすい。特に「実装 Pod が worktree を書く」直後に「親 Pod または reviewer が同じ worktree の ticket review artifact を書く」流れでは、同じファイルツリーに複数主体が順番に書きたい一方、Scope は write 権限を排他的に委譲するため、自然なレビュー操作が詰まる。
## 今回起きたこと
`tickets/permission-extension-point.md` の実装で、worktree workflow に従って `.worktree/permission-extension-point` を作り、実装用 Pod を Spawn した。
実装 Pod には worktree への write scope を渡したため、親 Pod から見るとその worktree は委譲中の領域になった。その後、同じ worktree に対して ticket review workflow を行い、`tickets/permission-extension-point.review.md` の作成と `tickets/permission-extension-point.md` の Review section 追記をしようとしたところ、親 Pod の `Write` tool は次のように拒否された。
```text
path is read-only in this scope: /home/hare/Projects/insomnia/.worktree/permission-extension-point/tickets/permission-extension-point.review.md
```
実装 Pod を `StopPod` して scope を回収した後に review artifact を書く必要があった。
また、最初に SpawnPod した際、write scope を worktree のみに絞ると spawned Pod の cwd である `/home/hare/Projects/insomnia` が readable scope 外になり、起動に失敗した。実装用 Pod には worktree write に加えて親 project root の read scope も渡す必要があった。
## 障壁
### 1. Ticket lifecycle は同じファイルツリーへの複数主体の逐次書き込みを要求する
このプロジェクトの ticket lifecycle は、実装後に以下のようなファイル操作を行う。
- `tickets/<ticket>.md` を読む
- `tickets/<ticket>.review.md` を作る
- `tickets/<ticket>.md` に Review section を追記する
- 完了時には ticket / review file を削除する
つまり ticket は単なる入力仕様ではなく、実装・レビュー・完了状態を表す共有ファイルでもある。実装者 Pod、親 Pod、reviewer Pod のいずれも、同じ worktree 内の `tickets/` を書きたくなる。
### 2. Scope は write ownership を安全側に排他的にする
SpawnPod の scope 委譲は、子 Pod に渡した write 領域を親から切り離す安全モデルになっている。このモデル自体は正しい。子が作業中の worktree を親が同時に書けないため、意図しない競合や横取りを防げる。
ただし file-based ticket workflow では、レビューを書く段階でも同じ worktree を書く必要がある。実装 Pod がまだ alive で write scope を保持していると、親が review artifact を書けない。reviewer Pod を別に Spawn する場合も、同じ write scope を同時に渡せない。
### 3. Review のために実装 Pod を止めると対話継続性が落ちる
今回の回避策は実装 Pod を停止して scope を回収することだった。これは review artifact を書くには有効だが、実装者 Pod に追加質問したり、レビュー指摘をそのまま反映させたりする流れとは相性が悪い。
本来欲しい流れは以下に近い。
1. 実装 Pod が worktree に実装する
2. reviewer が同じ diff を見て review artifact を書く
3. 実装 Pod に修正を依頼する
4. reviewer が再レビューする
現在の排他的 write scope では、2 と 3 の間で scope owner を何度も移す必要がある。
## 影響
- Ticket review が「実装 Pod を停止してからでないと書けない」運用になりやすい
- 実装者 Pod と reviewer Pod の並行・往復がしにくい
- file-based ticket が作業対象 worktree 内にあるため、コード変更と管理メタデータ変更が同じ scope boundary に巻き込まれる
- 親 Pod が orchestration をしているのに、review artifact のような管理操作まで child scope に阻まれる
## 暫定運用
当面は次の運用が現実的。
- 実装 Pod に worktree write scope を渡したら、レビュー artifact を親が書く前に実装 Pod を停止して scope を回収する
- 実装 Pod に追加修正を依頼したい場合は、レビュー作成後に再 Spawn する
- Spawn 時は worktree write scope だけでなく、Pod 起動 cwd や参照元 ticket を読める read scope も明示する
この運用は安全だが、レビュー往復の体験は重い。
## 改善案
### A. Review artifact の書き込み場所を親側に逃がす
review を worktree 内の `tickets/*.review.md` ではなく、親 session 側の report / review storage に書く案。Scope 衝突は避けられるが、現行の ticket lifecycle と git 上の履歴管理から外れるため、採用するなら ticket lifecycle 自体の再設計が必要。
### B. Scope に「管理ファイルの親書き込み例外」を作る
親 Pod が delegated worktree のうち `tickets/*.review.md``tickets/*.md` の Review section だけを書ける例外を作る案。実装はできるが、Scope の安全モデルに穴を開けるため慎重に扱う必要がある。パターンベース permission と似た問題になり、どのファイルを管理操作とみなすかの境界も曖昧になる。
### C. Scope owner の handoff を明示操作にする
`StopPod` より軽い「write scope を一時返却する / 再取得する」操作を protocol に作る案。実装 Pod の会話状態を保ったまま、reviewer / parent が短時間だけ同じ worktree に書ける。ただし停止していない Pod が後で再開した時の整合性、同時実行防止、失効した tool call の扱いを設計する必要がある。
### D. Reviewer を同じ Pod に依頼する
実装 Pod 自身に review artifact まで書かせる案。Scope 衝突は起きないが、実装者と reviewer の分離が弱くなる。ticket-reviewer の目的である独立レビューには向かない。
## 現時点の判断
短期的には「実装完了 → 実装 Pod 停止 → 親または reviewer が review artifact 作成」を標準手順として明記するのが良い。中期的には、Scope owner の handoff か、ticket/review artifact の置き場所を再検討する必要がある。
今回の障壁はバグというより、insomnia の安全な scope 委譲モデルと、ファイルを状態機械として使う ticket lifecycle の設計上の摩擦である。

View File

@ -0,0 +1,13 @@
## Writing substantive responses
When you respond with prose — design discussion, opinion, analysis, proposals — the output must be the product of completed thinking, not thinking-in-progress.
**Settle your position in thinking before writing.** Compress your stance into one sentence. If you cannot, keep thinking; you are not ready to write. Candidate comparison, hesitation, and self-correction happen in the thinking phase and stay there. Do not start drafting prose in order to discover what you think.
**Lead with the position.** The first one or two sentences state what you think or propose. Supporting reasoning follows; it never leads. Background, framing, and "the situation has N layers" preambles come after the position, or not at all.
**Match assertiveness to the user's stage.** When the user is still exploring or has not yet decided, present your stance as a proposal or opinion, not as a settled fact. The same response gets re-read in later turns, and assertive phrasing tends to be reused as if it were a decided outcome. Switch to declarative form only when the user has decided and is asking for execution.
**Do not narrate the deliberation.** Skip option-enumeration with comparison structures unless the rejected alternatives genuinely matter to the reader's judgment. State the position and the one or two reasons that matter. Hedge phrases that signal ongoing internal weighing — rather than communicating calibrated uncertainty about a stated position — are deliberation leaking into the output. Cut them.
**Length follows information.** A single design judgment is usually a few short paragraphs. Headings, bullets, and tables earn their place only when prose would be measurably harder to read. Never restate the same point as prose, then as bullets, then as a table.

View File

@ -5,3 +5,5 @@ Stay precise, edit code directly when asked, and avoid speculative refactoring.
{% include "common/workspace" %}
{% include "common/tool-usage" %}
{% include "common/writing" %}

View File

@ -1,120 +0,0 @@
# Agent Skills を Workflow として ingest
## 背景
[agentskills.io](https://agentskills.io/) の "Agent Skills" は Anthropic 発のオープン標準で、Claude Code / Cursor / OpenCode / Gemini CLI / Goose / OpenHands など主要な coding agent 群が採用している。ユーザーが既に書いた skill を insomnia に持ち込めるようにすることで、他ツールと能力資産を共有できる。
insomnia 側の一級概念は **Workflow** (`/<slug>`、`tickets/workflow.md`)。SKILL.md 形式は Workflow と意味的にほぼ同型procedural な指示本体 + メタデータ + 付随リソース)であり、別経路として並列管理するより **Workflow にマップして ingest する**方が呼び出し UX (`/<slug>`) と内部経路を一本化できる。
`docs/plan/memory.md` が「`SKILL.md` 形式は採用しない」と書いているのは Knowledge (`#<slug>`) の表現形式としての話で、本チケットの「Workflow への ingest 経路」とは矛盾しない。
### Skill の骨格 (仕様要旨)
```
skill-name/
├── SKILL.md # 必須: YAML frontmatter + Markdown 本体
├── scripts/ # 任意: 実行コード
├── references/ # 任意: 詳細ドキュメント
└── assets/ # 任意: テンプレート等の静的資産
```
SKILL.md frontmatter:
| フィールド | 必須 | 制約 |
|---|---|---|
| `name` | ○ | 1-64 chars, `[a-z0-9-]+`, **ディレクトリ名と一致** |
| `description` | ○ | 1-1024 chars, 非空 |
| `license` | | 自由文 |
| `compatibility` | | 1-500 chars |
| `metadata` | | 任意の key-value map |
| `allowed-tools` | | experimental |
## 前提チケット
- `tickets/workflow.md` — Workflow loader / `/<slug>` resolve / `model_invokation` 注入の本実装。本チケットはその ingest 経路を増やすだけで、Workflow 側の意味論には手を入れない
## 方針
### ロードソース
- **`$user/skills/<name>/SKILL.md`** — `$XDG_CONFIG_HOME/insomnia/skills/` 配下。**デフォルトで有効**
- **`$workspace/skills/`** — **デフォルトで無効**。manifest の `[skills]` セクションで明示指定したパスのみ ingest する
```toml
[skills]
directories = [".claude/skills", ".cursor/skills"]
```
各パスは workspace root からの相対 or 絶対。manifest の base directory に対して resolve する(既存 path 解決と同方針。Claude Code / Cursor 等が既に書いている `.claude/skills/` `.cursor/skills/` をそのまま流用できることが目的。
- ビルトイン `$insomnia/skills/` は不要になるまで作らない(前ガイドラインのまま)
### SKILL → Workflow マッピング
| SKILL.md frontmatter | Workflow frontmatter | 備考 |
|---|---|---|
| `name` | (ファイル名 = slug として扱う) | `name` がディレクトリ名と一致することは仕様上の不変。slug としてはディレクトリ名を使用 |
| `description` | `description` | そのまま |
| — | `model_invokation` | **`true` 固定**。agentskills の progressive disclosureメタデータ常時注入と整合 |
| — | `user_invocable` | **`true` 固定** |
| — | `requires` | **空配列**。SKILL 側に概念がない |
| `license` / `compatibility` / `metadata` | — | 保持はするが Workflow 実行には影響しない |
| `allowed-tools` | — | `permission-extension-point.md` が Permission 層を整備するまで `tracing::warn!` して無視 |
Workflow 本文には SKILL.md 本体frontmatter 直下の Markdownをそのまま使う。
### scripts / references / assets
skill ディレクトリ全体(`SKILL.md` 本体だけでなく `scripts/` `references/` `assets/`)を Pod の Scope に `permission = read` で自動 union する。`scripts/` の実行は Bash ツール + Permission 層(`permission-extension-point.md`)が整うまで Read 経由の閲覧に留める。
### 衝突解決
同一 slug が複数ソースから来た場合の優先順位:
1. `<workspace>/.insomnia/memory/workflow/<slug>.md`(内製 Workflow
2. workspace skillsmanifest 指定パス)
3. user skills`$user/skills/`
衝突時は上位を採用し、shadow した側について `Event::Notification` を発行する。「明示的に書かれた内製 Workflow が外部資産より強い」順に並べる。
### 検証
- frontmatter は SKILL 仕様通り検証。`name` ↔ ディレクトリ名一致、`description` 長さ、`name` の文字種制約を hard error
- 検証エラーは個別 skill 単位で skip + `tracing::warn!`。一個の壊れた SKILL が Pod 起動を止めない(内製 Workflow とは違う扱い: 外部資産は緩く受ける)
- 未知フィールドは無視
### 範囲外
- `allowed-tools` の実効化 → `permission-extension-point.md` 待ち
- skill 専用の実行機構(`scripts/` の自動実行)— Bash ツールと Permission 層で自然に扱う
- skill の自動生成Hermes 風 Skill Library— memory 系チケットで別途、本チケットの ingest 経路を再利用する側
- ビルトイン skill (`$insomnia/skills/`) — 必要になるまで追加しない
- skill 間依存の解決 — 独立単位、相互参照は本体の Markdown リンクで
## 完了条件
- `$user/skills/` 配下の SKILL.md が Workflow として登録され、`/<name>` で呼び出せる
- manifest で `[skills] directories = [...]` を指定した workspace では、そのパス配下の SKILL.md だけが追加で ingest される。指定しない workspace では workspace 側 skill は 0 件
- 内製 Workflow と同 slug の skill は内製優先で shadow され、Notification が発行される
- skill ディレクトリSKILL.md 本体・`scripts/`・`references/`・`assets/`)が scope readable に含まれ、agent が Read ツールでアクセスできる
- frontmatter 違反の skill は warn でスキップされ、他の skill / Pod 起動は影響を受けない
- 単体テストで frontmatter 検証、Workflow へのマッピング、衝突解決(内製 > workspace > user、manifest 未指定時の workspace skip が verify される
## 実装順序
1. SKILL.md パーサと frontmatter 検証を実装。Workflow frontmatter への変換器を含めてテスト完結
2. `$user/skills/` の loader を Workflow registry に接続
3. manifest に `[skills] directories: Vec<PathBuf>` を追加し、workspace 側 ingest を実装
4. 衝突解決と Notification 発行を乗せる
5. skill ディレクトリの Scope unionread 自動 allow
各ステップ終了時点でビルド通過・既存テスト合格を維持する。
## 参照
- 仕様本体: https://agentskills.io/specification
- 採用状況: https://agentskills.io/home
- Anthropic 公式サンプル: https://github.com/anthropics/skills
- 検証 CLI: https://github.com/agentskills/agentskills/tree/main/skills-ref
- 前提: `tickets/workflow.md`
- 関連: `tickets/permission-extension-point.md``allowed-tools` 実効化の受け皿)、`docs/plan/workflow.md`、`docs/plan/memory.md`、`docs/ref/memory-systems.md` §Skill Library

View File

@ -0,0 +1,31 @@
# Anthropic projection: assistant ターン内ブロックを 1 message に束ねる
## 背景
`crates/llm-worker/src/llm_client/scheme/anthropic/request.rs``convert_items_to_messages` は、Worker が 1 ターンで生成する `[Reasoning, assistant_message, ToolCall]` の連列を、Anthropic wire 上で **複数の隣接した assistant message** に分割している。
具体的には:
- `Item::Reasoning``pending_assistant` に push
- 次の `Item::Message { Role::Assistant }` が到来すると `pending_assistant` を flush し、自分自身は別 message として messages に直 push
- 続く `Item::ToolCall` は再び `pending_assistant` に積まれ、turn 末で flush され 3 つ目の assistant message に
結果として 1 turn が `assistant[Thinking] / assistant[text] / assistant[tool_use]` の 3 message に展開される。
Anthropic Messages API は user/assistant の交互を要求し、同一論理 turn 内の thinking/text/tool_use は **1 つの assistant message の `content` 配列** に並べる仕様。新世代 Claude (Opus 4.5+/Sonnet 4.6+) で thinking signature を round-trip する際、隣接 assistant message に分かれていると signature の文脈が崩れて 400 になる懸念があるreasoning-history-persist のレビュー指摘)。
なお、本バグは reasoning-history-persist で導入されたものではなく、`assistant_message` + `tool_call` の組合せで以前から存在していた pre-existing な分割。Reasoning が同じ flush 経路を継承した形。
## 要件
- 同一論理ターンに属する `Item::Reasoning` / `Item::Message(Assistant)` / `Item::ToolCall` を、Anthropic wire 上の **1 つの assistant message の `content` 配列** に束ねる
- 順序は arrival 順 (= history 順)。Anthropic 仕様の典型は thinking → text → tool_use
- user / system role の `Item::Message``Item::ToolResult` を境界として assistant burst を区切る
- 既存の breakpoint (cache_control) 計算が壊れないこと: 各 item のオリジン index → (msg_idx, part_idx) マッピングは flush_pending 経由で記録されているので、Item::Message(Assistant) も pending を経由するように揃えれば自然に追従する
- Single-text 専用の `AnthropicContent::Text` shorthand は assistant burst 内 1 part のみのときに限定して維持するか、簡潔さのために常に `Parts` 形式に統一するかは実装時に判断
- 既存テスト群(`completed_turn`, `single_text_message_uses_text_shorthand_without_breakpoint`, `breakpoint_on_tool_result_head` 等)の意図を逸脱しないよう更新
## スコープ外
- モデル世代別の thinking keep/strip デフォルト分岐reasoning-history-persist のフォローアップ候補と同じ扱い)
- `clear_thinking_20251015` context-edit
- prune.rs の reasoning aware 化

View File

@ -0,0 +1,69 @@
# 半自動開発運用 Workflow
## 背景
insomnia では insomnia 自身の開発を、ユーザーがタスクを投げ、設計相談をし、実装 Pod / reviewer Pod に分担させる形で進めている。既に Workflow / Skills、SpawnPod、Pod 間通信、scope 委譲、ticket / review lifecycle は揃っており、局所的な実装判断は AI に移譲できる余地が大きい。
一方で、完全な unattended 自動開発にするには、永続ジョブキュー、git 書き込み権限、設計判断のエスカレーション基準など未整理の領域がある。初期段階では、常駐 scheduler ではなく、ユーザーが明示的に起動する「maintainer workflow」として、TODO / tickets を俯瞰し、実装・レビュー・修正依頼を orchestration し、設計境界や完了判断だけを人間に戻す運用を整備する。
## 要件
### Workflow の役割
`/auto-maintain` 相当の Workflow を用意し、親 Pod が以下を実行できるようにする。
- `TODO.md``tickets/` から着手候補を把握する
- 既存方針・既存 ticket から実装方針が十分に導ける作業を選ぶ
- 要件が曖昧、または設計判断が必要な場合は実装前に人間へ質問する
- 実装 Pod を spawn し、適切な read / write scope を委譲する
- 実装 Pod の完了報告と diff / build / test 結果を確認する
- 必要に応じて reviewer Pod、または親 Pod 自身でレビューする
- レビュー指摘があれば修正を依頼する
- 最終的に「完了候補」として人間に報告する
### エスカレーション基準
Workflow は、少なくとも以下の場合に作業を止めて人間へ確認する。
- ticket の要件から複数の設計方針が自然に導け、選択が将来の構造に影響する
- scope / permission / history 永続化 / prompt context 加工原則など、システムの安全モデルに触れる
- 新しい ticket の追加、既存 ticket の大幅な要件変更、ticket 完了削除を行う
- git の commit / merge / push など書き込み操作が必要になる
- テスト不能、再現不能、または作業範囲外の不具合に遭遇する
### Pod orchestration 規約
- 実装 Pod と reviewer Pod は原則分ける。ただし scope 衝突や作業粒度により、親 Pod がレビューしてもよい。
- 実装 Pod に worktree write scope を渡す場合、review artifact を親または reviewer が書く前に実装 Pod を停止して scope を回収する。
- spawn 時は、作業対象 worktree の write scope だけでなく、必要な参照元 ticket / project root の read scope も明示する。
- 子 Pod の出力は `ReadPodOutput` で確認し、必要なら `SendToPod` で追加依頼する。
- orphan 化した Pod や不要になった Pod は `StopPod` する。
### 成果物
- Workflow 本文、またはそれに準じる運用手順が workspace から呼び出せる形で追加される
- Workflow が resident workflow として広告可能かどうかを判断し、必要なら `model_invokation` 設定を含める
- 実際の insomnia 開発 ticket を 1 件以上試走し、実装 Pod / review / 人間確認の境界が機能することを確認する
- 試走で見つかった不足永続ジョブキュー、scope handoff、review artifact の置き場所等)は、本チケット内で解決せず、必要なら別 ticket として切り出す
## 範囲外
- 常駐 scheduler / daemon による unattended 実行
- git commit / merge / push の自動化
- ticket 完了削除の自動化
- Workflow の状態機械化、永続ジョブキュー化、トランザクション管理
- scope owner handoff など、Pod 権限モデル自体の変更
## 完了条件
- `/auto-maintain` 相当の半自動開発運用 Workflow が利用可能になっている
- Workflow は TODO / tickets から作業を選び、実装 Pod / reviewer / 人間確認を使い分ける手順を明示している
- エスカレーション基準により、設計判断・git 書き込み・ticket 完了判断が人間に戻る
- 少なくとも 1 件の小さな実開発作業で試走し、結果と不足点が記録されている
- 既存の Workflow / Skill / memory の設計方針、特に Workflow 自動生成禁止と history に commit されない context input 禁止に反していない
## 参照
- `docs/plan/workflow.md`
- `docs/report/2026-05-05-file-ticket-scope.md`
- `tickets/internal-worker-workflow.md`

View File

@ -0,0 +1,85 @@
# 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
Thread
└─ Segment
└─ Exchange
├─ UserTurn
├─ AssistantTurn
│ └─ LlmCall
├─ ToolTurn
│ └─ ToolExecution
├─ AssistantTurn
│ └─ LlmCall
└─ ...
```
## 要件
- `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 が複数 Segment または複数 runtime attempt にまたがる場合の扱い。
- `persistence-semantics.md``Thread` / `Segment` / `Entry` / `Checkpoint` モデルへの反映。
- `Notify` / `PodEvent` / `SystemReminder` など user input ではない起点を Exchange と呼ぶことが適切か、または `ExchangeKind` のような分類で表すか。
## 完了条件
- `Exchange` / `Turn` / `Call` / `Request` / `Run` の定義が文書化されている。
- `persistence-semantics.md` に反映すべき前提が整理されている。
- worker / protocol / TUI / usage accounting で rename または互換 alias が必要な箇所が洗い出されている。
- 実装変更を行う場合、外側 Exchange、actor Turn、内側 LlmCall の境界がコード上で判別できる。
- 既存 API / event 名をすぐ壊さない段階移行方針が決まっている。
## 範囲外
- このチケット単体での大規模 rename 実装。
- 永続化 DB backend の実装。
- TUI の詳細 UX 設計。
- protocol の互換破壊的変更。
## 関連
- `tickets/persistence-semantics.md`
- `tickets/pod-persistent-state.md`
- `tickets/pod-session-fork.md`
- `crates/llm-worker/`
- `crates/protocol/`
- `crates/tui/`

View File

@ -1,70 +0,0 @@
# llm-worker: HTTP transient リトライ
## 背景
`crates/llm-worker/src/llm_client/transport.rs` はリトライを持たず、
upstream が一時的に不調だったときのエラーがそのまま `WorkerError`
伝播して Run が中断する。実セッションでも以下が観測されている:
- セッション `019de419-6f8b-71a0-9e56-f0b9a6c7098b.jsonl:85`
`API error (status: 503): upstream connect error ... Connection refused`
これは `transport.rs:194-216``response.status().is_success()` 前に
返される pre-stream の経路。リクエストはまだ消費されていない。
- Anthropic の `overloaded_error` (529) や Codex backend の 503 も
同経路で観測される transient な事象。
これらは「ヘッダが返る前の段階」で出るため、SSE を読み始めて
出力トークンを発生させる前であり、素朴な再送でべき等に復旧できる典型ケース。
ストリームが途中で切れた場合のリカバリは別の話(→ `llm-worker-stream-continuation`)。
## 方針
`transport.rs` の HTTP 送信層に transient エラー向けの再送を追加する。
SSE 読み出し開始後 (`response.bytes_stream()` 以降) のエラーは対象外で、
従来どおり `ClientError::Sse` として上に流す。
schemeOpenAI / Anthropic / Responses 等)に依存しない共通処理として、
すべての client から同じ振る舞いで使える形にする。
## 要件
### リトライ対象
- HTTP ステータス: 408 / 425 / 429 / 500 / 502 / 503 / 504 / 529
- `reqwest::Error::is_connect()` / `is_timeout()` 由来の送信失敗
- それ以外の `ClientError::Api { status }` および `ClientError::Json`
ストリーム開始後の `ClientError::Sse` は対象外
判定は `is_retryable(&ClientError) -> bool``error.rs` に置いて一箇所に集約する。
### バックオフ
- フルジッタ付き指数base/cap は実装時に妥当な値で固定。後で manifest 化したくなったら別ticket
- `Retry-After` ヘッダがあれば指数バックオフを上書きしてその時間待つ
- 上限: 最大試行回数 + 累積タイムアウトの両方を持つ
### ログ
- リトライ発火ごとに `warn!`ステータス、attempt 番号、次の wait
### 既存挙動の温存
- ストリーム途中で切れた場合の挙動には手を入れない
`transport.rs:231` の `ClientError::Sse` 経路はそのまま)
- 成功時のレイテンシに観測可能なオーバヘッドを足さない
## 完了条件
- `is_retryable` のテーブル駆動 unit test
- 503 / 529 / connect refused をモックした unit test が、
規定回数までリトライして「最終的に成功」「上限到達でエラー」の両ケースを通る
- `Retry-After: 5` を返すモックでは指数を上書きして 5s 待っている
(仮想時間で検証)
- mid-stream で `ClientError::Sse` を起こすモックでリトライが発火しない
- `cargo check` / `cargo test``llm-worker` で通る
## 範囲外
- mid-streamSSE 読み中)の継続再開 → `llm-worker-stream-continuation`
- プロバイダ別の細かい retry policy共通既定で十分
- リトライ上限値の manifest からの上書き必要になったら別ticket

View File

@ -10,44 +10,95 @@ OpenCode はパターンベースのルールtool × pattern → allow/deny/a
## 方針
`PreToolCall` Hook として実装する。マニフェストにルールを宣言し、
insomnia 層の Hook 実装がツール呼び出し時に評価する。
Permission の評価点は `PreToolCall` Hook とする。マニフェストにルールを宣言し、
insomnia 層が built-in の `PreToolCall` Hook として登録してツール呼び出し時に評価する。
`deny` はターン全体の Cancel/Abort ではない。対象 tool call を実行せず、
permission denied を表す `is_error = true` の synthetic tool result を履歴に追加してターンを継続する。
これにより provider が要求する `tool_use` / `tool_result` の対応を壊さず、LLM は拒否結果を見て別手段の検討やユーザーへの説明に進める。
`ask``deny` の代替ではなく、ユーザー承認待ちを明示する action として扱う。承認されれば元の tool call を実行し、拒否されれば `deny` と同じ synthetic tool result に落とす。
```toml
[[permission]]
[permissions]
default_action = "allow" # allow | deny | ask
[[permissions.rule]]
tool = "bash"
pattern = "rm *"
action = "deny"
[[permission]]
[[permissions.rule]]
tool = "file_write"
pattern = "*.env"
action = "deny"
```
[[permission]]
tool = "*"
allowlist 型にしたい場合:
```toml
[permissions]
default_action = "deny"
[[permissions.rule]]
tool = "read"
pattern = "*"
action = "allow"
[[permissions.rule]]
tool = "grep"
pattern = "*"
action = "allow"
```
評価順序OpenCode に倣う):
1. 最初にマッチした `deny` → 拒否
2. すべてマッチする `allow` → 許可
3. それ以外 → `ask`(ユーザー確認)
確認待ちを基本にしたい場合:
```toml
[permissions]
default_action = "ask"
[[permissions.rule]]
tool = "bash"
pattern = "rm *"
action = "deny"
```
評価順序:
1. `[permissions]` が無い場合、Permission 層は無効。従来通り実行する
2. `[permissions]` がある場合、`default_action` は必須
3. `[[permissions.rule]]` は宣言順に評価し、最初に `tool``pattern` が一致した rule の `action` を採用する
4. 一致する rule が無ければ `permissions.default_action` を採用する
## 設計ポイント
- 設計原則3: 新しい trait は作らない。`PreToolCall` Hook として実装
- 設計原則2: マニフェストに宣言した以上、insomnia 層が解決する
- `ask` アクションは Pod Protocol の拡張が必要Method に `PermissionReply` を追加)
- Permission Hook は Pod が自動登録する built-in Hook とし、ユーザー追加 Hook より先に評価する
- `deny``PreToolAction::Abort` / 既存 `Skip` では表現しない。tool call 単位の拒否結果を履歴へ返すため、Worker 側に synthetic tool result を返せる action が必要
- `ask` アクションは Pod Protocol の拡張が必要Event に `PermissionRequest`、Method に `PermissionReply` を追加)
- `ask` を処理できない実行環境では暗黙に待機しない。設定時に validation error とするか、fail closed で `deny` 相当の synthetic tool result に落とす
- `Scope` との関係: Scope は書き込みの物理的境界、Permission はツール実行のポリシー。補完関係
- ルール評価はパターンマッチのみ。コンテキスト依存の判断はしない(シンプルに保つ)
## 段階的実装
1. **拡張ポイントの記録**(今): docs/pod.md の拡張ポイント表に追加
2. **deny/allow の実装**(ツール実装時): PreToolCall Hook でパターン評価
3. **ask の実装**Protocol 拡張時): Method/Event に Permission 関連メッセージを追加
2. **deny/allow の実装**(ツール実装時): `default_action` と rule 評価を manifest に追加し、built-in `PreToolCall` Hook でパターン評価
3. **拒否 tool result の実装**: `deny` が turn Abort ではなく synthetic error tool result として履歴に入るよう Worker の pre-tool action を拡張
4. **ask の実装**Protocol 拡張時): Method/Event に Permission 関連メッセージを追加し、承認後に元 tool call を実行、拒否時は synthetic error tool result を返す
## 受け皿になる外部仕様
### Agent Skills `allowed-tools`
`tickets/agent-skills.md` で ingest した SKILL.md の frontmatter には agent-skills 仕様の experimental field である `allowed-tools` (例: `["Read", "Bash"]`) が含まれる場合がある。`crates/memory/src/skill.rs::parse_skill_md` 時点では `tracing::warn!` で受け流しているだけで、実効化していない。
本チケットの Permission 層が固まった時点で、Skill 由来 Workflow を実行中のみ当該 skill の `allowed-tools` リストに含まれるツールしか走れない形で反映する。スコープは「Workflow 実行中」相当 (Workflow の system message が context に乗っているターン) に限定する想定。skill 単位で local な permission 集合を持つので、グローバルな `[[permissions.rule]]` ルールとは独立に評価する。
実装上の足がかり:
- `WorkflowRecord` の出所は `WorkflowSource::Skill { dir }` で識別済み (`crates/memory/src/workflow.rs`)。`dir` は manifest `[skills] directories` に書かれた skill ルートそのもの
- 受け皿実装時に `SkillFrontmatter::allowed_tools` の保持先を `WorkflowRecord` に伸ばすか、別の SkillRecord registry を持つかは本チケット内で決める
- 現状の `tracing::warn!` は受け皿実装と同時に消す
## 依存チケット

View File

@ -0,0 +1,91 @@
# 永続化層のセマンティック整理
## 背景
現在の永続化は `SessionId` 単位の append-only JSONL log を中心に構成されている。これは実装上は扱いやすい一方で、今後 Pod 単位永続化、compaction、fork、DB backend 追加などを進めるにあたり、以下の概念が混ざり始めている。
- ユーザー視点の「同じ会話 / 作業の継続単位」
- Pod 視点の「現在 active な会話状態」
- append-only log の物理的 / 復元上の単位
- compaction によって生成される新しい履歴系列
- fork の起点となる履歴中の境界
- runtime dir に置かれる一時状態と、data dir / DB に置く永続正本
特に、現在は compaction によって新しい `SessionId` が発行される。これは append-only log の低レベル単位としては自然だが、ユーザー視点では「同じ会話が継続している」とも見えるため、`Session` という名称・粒度が今後の設計上あいまいになり得る。
このチケットでは、実装変更に入る前に、永続化層のドメイン概念・名称・責務境界を整理する。
## 目的
- 永続化層で扱う概念を、ユーザー視点 / Pod 視点 / storage 視点に分けて定義する。
- `SessionId` が今後も適切な中心概念か、あるいは別概念に分解すべきかを判断する。
- compaction / fork / resume / Pod state / spawned child registry が、どの粒度のデータに属するかを決める。
- 将来 DB backend を追加しても歪みにくいデータ構造を設計する。
- 既存の session-store JSONL 実装から段階的に移行できる命名・API 境界を決める。
## 検討事項
- 会話継続単位と append-only log 単位を分けるべきか。
- 例: user-visible conversation/thread と、internal log segment の分離。
- compaction の扱い。
- compaction 後の履歴を新しい低レベル log として扱うのは自然か。
- その場合、ユーザー視点では同じ会話の継続としてどう表現するか。
- fork の扱い。
- fork は新しい会話単位を作るのか、同一会話内の branch とするのか。
- fork 起点を entry hash / turn boundary / checkpoint など、どの抽象度で表すか。
- Pod state の責務。
- Pod 名から active な会話 / log を復元するために何を持つべきか。
- Pod が過去に辿った session / log の順序付き履歴をどこに持つべきか。
- runtime state と persistent state の境界。
- `history.json` / `status.json` / `spawned_pods.json` を永続正本として扱わない方針の確認。
- DB backend を想定した場合のテーブル / relation 相当の構造。
- append-only entry log
- lineage / origin
- active pointer
- Pod / child Pod registry
- index / listing / GC の余地
- 既存 API / CLI 名称の移行方針。
- `--session` の扱い
- debug 用 ID とユーザー向け ID の分離
## 一案: Thread / Segment / Checkpoint に分ける案
これは現時点の決定ではなく、検討材料の一案として置く。
- `Pod`: agent 実行主体 / process identity。
- `Thread`: ユーザー視点の会話・作業継続単位。compaction しても同じ Thread と見なす。
- `Segment`: append-only log の物理的 / 復元上の単位。現在の `SessionId` に近い。compaction / fork で新しい Segment が生まれる。
- `Entry`: Segment 内の 1 永続化イベント。
- `Checkpoint`: fork / rollback / UI 選択などの起点を表す抽象境界。内部的には Segment + EntryHash を指してもよいが、表層 API では entry pointer を直接露出しすぎない。
この案では:
- compaction = same Thread, new Segment
- fork = new Thread または branch, new Segment
- resume = same Thread の active Segment に append
- Pod state = active Thread / spawned children / 必要な runtime 復元メタデータを保持
- lineage = Segment origin または Checkpoint reference として保持
この案を採用するかは本チケット内で改めて比較・判断する。
## 完了条件
- 永続化層の主要概念と名称が文書化されている。
- compaction / fork / resume / Pod state のデータ粒度が決まっている。
- 現在の `SessionId` / session-store API をどう扱うか、維持・alias・rename・段階移行の方針が決まっている。
- DB backend を追加する場合の概念モデルが、最低限テーブル / relation 相当で説明できる。
- `tickets/pod-persistent-state.md` や fork 関連チケットに反映すべき前提が整理されている。
## 範囲外
- このチケット単体での大規模 rename 実装。
- DB backend の実装。
- UI の履歴表示 / branch 表示の詳細 UX。
- GC / retention policy の実装。
## 関連
- `tickets/pod-persistent-state.md`
- `tickets/pod-session-fork.md`
- `crates/session-store/`
- `crates/pod/src/pod.rs`

View File

@ -0,0 +1,84 @@
# Pod: セッションログをバックエンドにした Pod 単位の永続化
## 背景
現在の永続化の主軸は session-store の append-only JSONL ログで、`SessionId` 単位に会話履歴・設定・scope snapshot・usage・拡張 payload を復元できる。一方で Pod 単位のランタイム状態は `<runtime_dir>/{pod_name}/` 配下の `status.json` / `history.json` / `spawned_pods.json` などに write-through されているが、runtime dir は再起動で消えてよい領域であり、Pod プロセスの寿命を超える復元ソースとしては扱えない。
特に spawned Pod の管理情報は `SpawnedPodRegistry` のコメントにもある通り、現状は runtime dir への write-through のみで、再起動した spawner が子 Pod 一覧を rebuild する future work になっている。
このチケットでは、既存の session-store を物理バックエンドとして利用しつつ、Pod 名をキーにした永続状態を追加し、Pod 単位で「最後にどの session を保持していたか」「spawned children をどう復元するか」を扱えるようにする。
## 方針
- session log は引き続き会話状態の唯一の復元ソースにする。
- `history.json` や runtime dir の snapshot を永続正本にはしない。
- LLM context に載せる新規 input は、既存方針通り先に worker history / session log に commit されている必要がある。
- Pod 単位の永続化は「Pod identity → session / child registry などへの参照」を保存する薄いメタデータ層として設計する。
- 会話本文を二重保存しない。
- active session だけでなく、compaction / fork / resume によってその Pod が辿ってきた過去 session を順序付きで保持する。これは UI の履歴表示、直近以前への復元、active session 変更の監査に使う。
- session-store の `Store` trait を拡張するか、隣接 trait / module を追加して、FsStore 以外の backend でも同じ形で実装できるようにする。
- FsStore のデフォルト layout は `<data_dir>/pods/` 配下など、`sessions/` と同じ data_dir 管理下に置く。
- runtime dir (`<runtime_dir>/{pod_name}/`) は引き続き socket / pid / status など一時状態専用。
- Pod lifecycle 上の write point を明確にする。
- Pod 作成時: pod name と allocated session id を記録。
- first run で `SessionStart` が materialize された後: active session / head を更新できる状態にする。
- compaction / fork / resume で active session が変わる場合: Pod state も同時に更新。
- `SpawnPod` / callback / `StopPod` による child registry 変更時: runtime dir だけでなく persistent Pod state にも write-through。
- 復元時は Pod state から active session を解決し、その session log を `restore_from_manifest` 相当の経路で復元する。
- session id を明示した resume は既存通り session を直接指定できる。
- Pod 名 resume は Pod state → active session → session restore の順に解決する。
- live writer 衝突は既存の pod-registry / session_id collision check を維持する。
## データ粒度の考え方
- ユーザー視点の会話継続単位と、内部の append-only log 単位を分けて扱う。
- ユーザー視点: Pod / thread / conversation のような安定 ID。compaction しても同じ会話として継続する。
- 内部 log 視点: session segment / revision / epoch のような履歴再構築単位。compaction や fork で新しい log root が必要なら新 ID になる。
- 現状の `SessionId` は内部 log 単位の性質が強い。compaction は履歴を要約済み prefix に置き換えて新しい append-only chain を始めるため、低レベルには「新 session」として扱うのは自然。ただし UX / データモデル上は「同じ Pod conversation の新 revision」と見せる。
- 将来 DB backend を追加する場合も、`Conversation/PodState` と `SessionSegment` を分ける形に寄せる。
- `pod_state.active_session_id` は現在 append 先の segment を指す。
- `pod_state.session_history[]` は Pod 視点で active だった segment の順序付き履歴。
- compaction / fork の構造的 lineage は session log の `SessionOrigin` または DB の relation として保持し、Pod state は「この Pod がどれを active にしたか」の操作履歴に留める。
## 要件
- Pod 名をキーに、少なくとも以下を永続化できること:
- active `SessionId`
- ordered session history: その Pod が active として保持してきた `SessionId` の時系列リスト
- 各 entry には最低限 `session_id` と遷移理由new / resume / compact / fork など)を持たせる
- compaction / fork の構造的な出自は session log の `SessionOrigin` を正本とし、Pod state 側は Pod 視点の active session 遷移履歴として扱う
- Pod manifest / scope 復元に必要な参照または snapshot の扱い(既存 session log の `pod.scope` snapshot と責務を重複させない)
- spawned children の registrypod name, socket path, delegated scope, callback address, child session id が必要なら含める)
- `SpawnedPodRegistry` が runtime dir の `spawned_pods.json` だけでなく、Pod 永続状態から初期化できること。
- `ListPods` / `SendToPod` / `ReadPodOutput` / `StopPod` は、復元後の spawner でも永続化された child registry を基に動作できること。
- ただし `ReadPodOutput` の read cursor は session-lifetime / in-memory のままでよい。永続化対象にしない。
- Pod の compaction により active session id が変わった場合、Pod 永続状態と pod-registry の session id が整合すること。
- 既存の `--session <UUID>` resume は壊さない。
- 新しい Pod 名単位 resume / attach の入口を決めること。
- 例: `pod --pod-state <name>` ではなく、既存 `pod.name` と manifest cascade から同名 Pod state を探す形など。
- CLI / TUI の最小導線を本チケット内で確定する。
## 完了条件
- `session-store` に Pod 単位メタデータを扱う backend API と FsStore 実装がある。
- Pod state が active session と ordered session history を保持し、new / resume / compaction / fork の遷移が順序付きで記録される。
- 新規 Pod 起動、resume、compaction、spawn / stop の各タイミングで Pod 永続状態が更新される。
- Pod プロセス再起動後、Pod 名から active session を復元し、会話を継続できる。
- spawner Pod の再起動後、永続化された spawned children 一覧から `ListPods` が復元され、到達可能な child に対して comm tools が使える。
- runtime dir は引き続き一時状態として扱われ、永続正本に依存しない。
- live writer の二重起動は既存 pod-registry / session lock と同等以上に防止される。
## 範囲外
- 会話履歴そのものの保存形式変更。
- session log の DB 化や remote backend 実装。
- Pod state の自動 GC / retention policy。
- TUI 上の高度な Pod 一覧 UI。最小限の resume / attach 導線を超える UX は別チケット。
- `ReadPodOutput` cursor の永続化。
## 関連
- `crates/session-store/`: 既存の session append-only backend。
- `crates/pod/src/runtime/dir.rs`: runtime dir の `history.json` / `spawned_pods.json`
- `crates/pod/src/spawn/registry.rs`: spawned children registry。現状は write-through のみで復元未実装。
- `tickets/pod-session-fork.md`: active session 切り替え設計との整合が必要。

View File

@ -0,0 +1,56 @@
# Prune: 保護境界を token budget 化
## 背景
現状の Prune は `prune_protected_turns`(デフォルト 3で user message 起点の turn 数を数え、直近 N turn 分の `Item::ToolResult.content` を保護する(`crates/llm-worker/src/prune.rs:107-164`、`crates/manifest/src/defaults.rs:13-15`)。
この境界定義は対話頻度に依存して挙動が極端に変わる:
- 短い対話中心のセッション: 4 turn 目以降は意図通り定常的に刈れる。
- 単発の長タスクagentic loop で 1 user message から数十〜数百 LLM call が走るケース): history 全体が 1 turn 扱いになり、`turn_starts.len() <= protected_turns` で候補抽出すら行われず、`SkippedNoCandidates` で恒常的に発火しない。コンテキスト窓が一方的に膨れて compaction に押し付ける形になる。
そもそも prune は「古い tool_result の content を切り詰めて token を回収する」機構であり、保護量も token で測るのが意味論的に一貫している。compaction 側は既に `compact_retained_tokens: 8000` という末尾 token budget で保護しており、二つの機構の保護軸を揃えると設定の理解が単純になる。
LLM call 境界assistant 出力の単位)を history から後追い検出する必要は無い。Prune が走るのは LLM call 直前のみで、その時点で Worker は usage 履歴を持っており、末尾からの累計トークンで境界を引ける。
## 方針
`prune_protected_turns` を撤廃し、`prune_protected_tokens: u64` に置き換える。
- 候補抽出: history 末尾から item ごとの推定トークンを累計し、累計が `protected_tokens` を超える位置までを保護領域とする。それより前の `Item::ToolResult { content: Some(_), .. }` が prune 候補。
- usage 測定が無い時点(最初の LLM call 前 / compact 直後)は推定が `NoData` を返すため、保護領域の決定もできない。この場合は候補抽出を諦めて `SkippedNoCandidates` 相当で抜ける(既存 NoData ハンドリングの踏襲)。
- `prune_min_savings` 判定はそのまま残す。二段の判定(候補があるか / savings が閾値を超えるか)は維持する。
- tool_call と tool_result のペアは `call_id` で対応が取られているため、保護境界が途中を切っても projection は content を `None` にするだけで summary 構造は壊れない。境界の精度よりも token 量の正確さを優先してよい。
## 要件
- Manifest: `compaction.prune_protected_turns` を撤廃し `compaction.prune_protected_tokens: u64` を追加する。後方互換 shim は入れない。
- デフォルト: `PRUNE_PROTECTED_TOKENS = 8000``COMPACT_RETAINED_TOKENS` と揃える)。
- `PruneConfig` も同様に `protected_turns``protected_tokens` に rename。
- 候補抽出ロジック (`prune.rs`) は token 累計ベースに切り替える。usage 推定の取得経路は既存 `SavingsEstimator` と同じ「Worker に callback を install する」パターンで足す。Worker / prune.rs 自体は usage source を知らないままに保つ。
- メトリクス: `prune.fire` / `prune.skip` の既存 dimension のうち `border_turn` は意味を失うので、保護境界を表す新 dimension保護領域の先頭 item index または保護領域の累計トークン)に差し替える。`candidate_count` と `value=estimated_savings` は維持する。
- 既存テスト (`prune.rs` 末尾のユニットテスト群) は token budget ベースに書き直す。
## 完了条件
- 単発の長タスクで重い ToolResult が積もるシナリオで、4 番目以降の LLM call から `prune.fire` が観測される(重い ToolResult ほど早く刈られる挙動)。
- 短い対話セッションでも、末尾 8000 token に収まらなくなった古い ToolResult の content が従来通り刈られる。
- `prune_protected_turns` を旧フィールド名で書いた manifest は明示的にエラーになる(後方互換無し)。
## 範囲外
- `Item``request_seq` 等の LLM call 境界情報を埋め込む案。今回は履歴構造を変更しない。
- compaction 側 (`compact_retained_tokens`) のロジック変更。
- `prune_min_savings` の値・判定ロジックの変更。
- token 推定アルゴリズム自体の改善(`UsageHistory` ベースの既存推定をそのまま使う)。
## 影響範囲
- `crates/manifest/src/defaults.rs`: `PRUNE_PROTECTED_TURNS` 削除、`PRUNE_PROTECTED_TOKENS` 追加。
- `crates/manifest/src/{config,lib}.rs`: `CompactionConfig` の field rename とカスケード解決の差し替え。
- `crates/llm-worker/src/prune.rs`: `PruneConfig`、`prunable_indices` / `evaluate_candidates` の引数とロジック、ユニットテスト。
- `crates/llm-worker/src/worker.rs`: prune 評価呼び出し箇所、必要なら新しい token-estimator callback の install 経路。
- `crates/pod/src/compact/prune.rs`: `PruneConfig` 組み立てと、新しい token 推定 callback の注入。
- `crates/pod/src/compact/token_counter.rs`: 末尾累計トークン算出のヘルパー(`savings_for_prune_impl` の隣に追加 or 共通化)。
- `crates/pod/tests/session_metrics_test.rs`: `prune.fire` / `prune.skip` の dimension 期待値。
- `docs/compaction.md`: 設定セクションの記述を更新。

View File

@ -0,0 +1,88 @@
# TUI: Assistant 応答の Markdown スタイル表示
## 背景
LLM の出力は実質 Markdown だが、TUI は `Block::AssistantText { text }`
`push_padded_lines` で 1 行ずつ素のテキストとして
`Style(MessageKind::Assistant)` に流しているだけで、`**強調**` /
`` `code` `` / `# 見出し` / `- list` 等の記号がそのまま見える状態になっている
`crates/tui/src/ui.rs:592-595, 640-648`)。スタイルが付かないため、
構造のあるアシスタント応答は読みにくい。
ratatui 0.30 の `Vec<Line<'static>>` で表現できる範囲のスタイル付けで
十分目的を満たせる。既存の `wrap_line_into``crates/tui/src/ui.rs:473-`)が
span 単位のラップを既に実装しているため、Markdown レンダラは
スタイル付きの `Vec<Line>` を返すだけでよく、ラップスクロールoverview
畳み込みの仕組みを変える必要はない。
## 方針
- `pulldown-cmark``tui` クレートの依存に追加し、Event ストリームを
既存の `MessageKind` / `ratatui::style::Style` 体系へ畳み込む小さな
自前レンダラを `crates/tui/src/markdown.rs` に置く。
- レンダラの公開面は `render(text: &str, base: Style) -> Vec<Line<'static>>`
程度の 1 関数。`Block::AssistantText` の `Mode::Detail` / `Mode::Normal`
描画から呼ぶ。`Mode::Overview` は現行通り 1 行畳み込みMarkdown 記号
含めて表示しても情報量はほぼ同じなので素のテキストでよい)。
- ストリーミング中の不完全要素(未閉鎖の `**` や開きっぱなしのフェンス)
は CommonMark の流儀テキスト扱いEOF で閉じる)に任せる。挙動が
破綻する場合だけ末尾要素を素のテキストにフォールバックする小さな
後処理を入れる余地を残す。
- `tui-markdown` クレートは採用しない。syntect 依存でビルドが肥大する
割にカスタマイズが効かず、本クレートの色味(`MessageKind`
パレット)との整合を握りにくいため。
## 対応する Markdown 要素
最小限の "対応できる範囲" を以下に限定する。CommonMark + GFM の一部。
- 強調: `**bold**` / `*italic*` / `~~strike~~`GFM
- インラインコード: `` `code` ``
- フェンスコードブロック: ` ```lang ` / ` ``` `(言語タグは無視、
ブロック全体を等幅・低彩度の背景/前景で塗る)
- 見出し: H1〜H4H5/H6 は H4 と同等)
- 箇条書きリスト: `-` / `*` / `+`、ネスト可(深さ分インデント)
- 順序リスト: `1.` / `1)`、ネスト可(番号は元の値で表示)
- 引用: `> ...`(ネスト可)
- 水平線: `---` / `***`
- リンク: `[text](url)``text` をリンク色で着色URL は表示しない)
## 範囲外
- 表GFM table
- 画像 `![alt](src)`(テキストとしても表示しない)
- HTML パススルー(タグはそのまま生テキストで出る)
- 数式(`$...$` / `$$...$$`
- コードブロックの syntax highlighting
- リンクのターミナルクリックOSC 8URL の自動表示
- `Thinking` 本文 / `SystemMessage` への適用
(同じ `markdown::render` を後で差せばよい。本チケットは
`Block::AssistantText` のみ)
- ライブストリーム最中の "途中要素のフォールバック" の作り込み
CommonMark のデフォルト挙動で破綻が見えたら別チケット)
## 完了条件
- アシスタント応答に含まれる上記要素が、それぞれ視認可能な
スタイルで描画される。
- ストリーミング中、テキストが追記されるたびに描画が更新され、
フェンスコードブロックの開きが先に着いて中身が後から流れる
ようなケースでも、テキスト全体の見た目が大きく崩れない。
- `Mode::Detail` / `Mode::Normal` で Markdown スタイルが、
`Mode::Overview` では従来通りの 1 行畳み込みが出る。
- 既存の `wrap_line_into` によるラップ・右パディング・スクロール
が引き続き機能する(行幅計算が乱れない)。
## 影響範囲
- `crates/tui/Cargo.toml`: `pulldown-cmark` を追加(`cargo add` 経由)。
- `crates/tui/src/markdown.rs`: 新設。`render(&str, Style) -> Vec<Line<'static>>`。
- `crates/tui/src/ui.rs`: `Block::AssistantText` 分岐で Markdown
レンダラを呼ぶ。`Mode::Overview` は現行のまま。
- `crates/tui/src/main.rs` または `lib.rs`: 新モジュールの宣言。
## Review
- 状態: Approve
- レビュー詳細: [./tui-assistant-markdown.review.md](./tui-assistant-markdown.review.md)
- 日付: 2026-05-05

View File

@ -0,0 +1,49 @@
# Review: TUI Assistant 応答の Markdown スタイル表示
## 前提・要件の確認
### 対応する Markdown 要素 (チケット「対応する Markdown 要素」セクション)
- 強調 `**bold**` / `*italic*` / `~~strike~~`: `Renderer::start``Tag::Strong/Emphasis/Strikethrough` で深さカウンタを増やし、`span_style` で `Modifier::BOLD/ITALIC/CROSSED_OUT` を付与 (`crates/tui/src/markdown.rs:219-221, 81-89`)。`Options::ENABLE_STRIKETHROUGH` も付いている (`crates/tui/src/markdown.rs:18`)。✓
- インラインコード: `Event::Code``in_inline_code` を立ててから `push_text` し、`span_style` で yellow on `Rgb(40,40,40)` を返す (`crates/tui/src/markdown.rs:145-149, 70-73`)。✓
- フェンスコードブロック: `Tag::CodeBlock``in_code_block=true`、`Text` イベント側で `\n` を実際に行分割しつつ等幅 (cyan) で塗る (`crates/tui/src/markdown.rs:131-140, 74-76`)。言語タグは `Tag::CodeBlock(_)` で破棄。✓
- 見出し H1〜H6: `Tag::Heading { level, .. }``self.heading` を立て、`span_style` で `heading_style` を返す。H5/H6 は H4 と同色 (`crates/tui/src/markdown.rs:175-178, 277-284`)。✓
- 箇条書きリスト (`-`/`*`/`+`、ネスト可): `Tag::List(None)` 経由で `list_stack` に積み、`LIST_INDENT` を `line_prefix` に push、`Tag::Item` で `• ` マーカー (`crates/tui/src/markdown.rs:183-211`)。テスト `nested_list_indents` で深さ 2 を確認。✓
- 順序リスト (`1.`/`1)`、ネスト可、開始番号尊重): `Tag::List(Some(n))``Some(n)` を積み、`Tag::Item` で `n.` マーカーを出して `n += 1`。`pulldown-cmark` 側でも `Start(List(Some(3)))` のように開始番号が来るのを probe で確認したので、`3. a / 4. b` のような表示は意図通りになる。✓
- 引用 (`> ...`、ネスト可): `Tag::BlockQuote(_)``│ ``line_prefix` に push、ネストすると `│ │ ` になる (`crates/tui/src/markdown.rs:212-218, 256-259`)。✓
- 水平線 (`---`/`***`): `Event::Rule``─` × 40 を DarkGray で出し、前後に blank を試みる (`crates/tui/src/markdown.rs:152-161`)。✓
- リンク `[text](url)`: `Tag::Link { .. }``in_link` を立て、`span_style` で cyan + underline。URL は表示しない。✓
### 範囲外項目の取り扱い
- 表 (GFM): `Options::ENABLE_TABLES` は付けていないので素通り。テーブル記号がそのまま見える形になるが、ストリーム自体は破綻しない。✓
- 画像 `![alt](src)`: `image_depth` カウンタで alt を含めて捨てる (`crates/tui/src/markdown.rs:97-102, 223, 264`)。テスト `image_alt_is_dropped` あり。✓
- HTML パススルー: チケットの「範囲外」では「タグはそのまま生テキストで出る」と書かれているが、実装では `Event::Html` / `InlineHtml` をハンドラの `_ => {}` で**完全に捨てている** (`crates/tui/src/markdown.rs:166`)。probe で `<div>hi</div>` 入りの入力に対し `Start(HtmlBlock) / Html / End(HtmlBlock)` 列が出ることを確認したが、これら 3 イベントはすべて未処理 = 表示されない。挙動としては「タグ含めて消える」になっている。チケットの記述とはわずかにズレるが、UX 上は無音で消える方が望ましいケースが多く、blocking にはしない。
- 数式 / syntax highlight / OSC 8 / Thinking 適用 / ライブストリーム途中要素フォールバック: 着手なし、チケット通り。✓
### 完了条件
- 「上記要素が視認可能なスタイルで描画される」: 上記の通り全要素にスタイルが付くことをコードと 14 ケースのユニットテストで確認。✓
- 「ストリーミング中、フェンスコードブロックの開きが先に着いて中身が後から流れるケースで全体の見た目が大きく崩れない」: probe で `before\n\n```rust\nlet x = 1;` (閉じ忘れ) を流すと `Start(Paragraph)/Text("before")/End(Paragraph)/Start(CodeBlock(Fenced))/Text("let x = 1;")/End(CodeBlock)` が出ることを確認。途中状態でも `End(CodeBlock)` が EOF で必ず付くため `in_code_block` は確実に閉じ、現状コードブロックを描画したまま自然に途切れる。fence-only (`` ```rust ``) は中身ゼロで blank 1 行分の領域だけ取る程度で破綻しない。`unfinished_emphasis_is_treated_as_text` のテストでも `**` 単体を素テキスト扱いできることが pulldown-cmark の出力から保証される。✓
- 「`Mode::Detail` / `Mode::Normal` で Markdown スタイル、`Mode::Overview` は従来通り」: `crates/tui/src/ui.rs:592-595``match mode``Overview` だけ従来の `push_overview_line` を保ち、それ以外を `markdown::render` に流している。✓
- 「`wrap_line_into` のラップ・右パディング・スクロールが乱れない」: `markdown::render``Line::from(spans)` を返すだけで line-level の `style.bg` を一切セットしない。よって `wrap_line_into``fill_to_width = line_style.bg.is_some()` は false のまま、右パディングは発生せず diff-style 行の挙動と干渉しない。char 幅は通常の Span をそのまま並べるだけなので `UnicodeWidthChar` 計算も従来同等。✓
## アーキテクチャ・スコープ
- 影響範囲はチケット通り `crates/tui/Cargo.toml` / `crates/tui/src/markdown.rs` (新設) / `crates/tui/src/ui.rs` の 1 行 / `crates/tui/src/main.rs``mod markdown;` 1 行のみ。`ui.rs` は 1 行差し替えに収まり (`crates/tui/src/ui.rs:594`)、レンダリングパイプライン (`compute_history` → `wrap_line_into` → スクロール) には触っていない。最小スコープが守られている。
- 公開面はチケット指定通り `pub fn render(text: &str, base: Style) -> Vec<Line<'static>>` の 1 関数のみ。`Renderer` 構造体は `pub` でない。過剰抽象化なし。
- 依存追加は `pulldown-cmark = { version = "0.13.3", default-features = false }` で、CommonMark コアのみを取り込む形。`tui-markdown` を避け、syntect 等の重量依存を持ち込んでいない (チケット方針通り)。
- 新規クレートは作っていないので命名ポリシー (insomnia- プレフィックス禁止) は対象外。
- `markdown` モジュールは `crates/tui/src/markdown.rs` の単一ファイルにまとまっており、`#[cfg(test)]` で 14 ケース同居。低レベル基盤クレート (`llm-worker` 等) を汚染していない、TUI レイヤ内に閉じる正しい配置。
## 指摘事項
### Non-blocking / Follow-up
- HTML 取り扱いがチケット記載 (「タグはそのまま生テキストで出る」) と実装 (完全に破棄) で食い違う。実装側の方が UX 的に望ましいので、チケット側の文面を「HTML はそのまま無視する」に直すか、レビュー記録のままにしておくかは判断に委ねる。`crates/tui/src/markdown.rs:162-166`。
- `span_style` 内で inline code / code block / heading が `self.base` を完全に無視している。Assistant の `kind_style` (`fg(White)`) しか base に来ない現状では実害ゼロだが、将来同じ `markdown::render``Thinking` (magenta + ITALIC) や `SystemMessage` (cyan) で使い回す際にコードブロックだけ palette から外れる。本チケットは Assistant のみが対象なので非ブロッキング。差すタイミングで「base を起点に code/heading の色相だけ寄せる」関数化を検討すると良い。`crates/tui/src/markdown.rs:70-94`。
- 空のリスト項目 (`- a\n-\n- c` のような) は `pending_marker``flush_line` で消費される結果、`• ` だけの行が出る。`TagEnd::Item` のコメントは「marker was never consumed」と書いてあるが、現実には `flush_line` (current 空 + pending_marker Some) のガード条件をすり抜けて消費される (`crates/tui/src/markdown.rs:104-116`)。挙動として「空項目は空のバレットを 1 行出す」になっているのは妥当だが、コメントの意図と挙動がやや不一致。pending_marker を消費するか落とすかは別チケットでも構わない範囲。
### Nits
- `RULE_WIDTH` が 40 固定。ターミナル幅に応じた可変化は本チケットの完了条件外なので OK だが、`wrap_line_into` 経由で右側に折り返されない (40 < width 前提) ことだけ将来確認が要る狭幅環境でも安全側 (はみ出さない) なので問題なし
- `pulldown_cmark::Options::ENABLE_STRIKETHROUGH` のみ有効。GFM のうち autolink / task list は今回対象外なので妥当。
## 判断
**Approve** — チケットの「対応する Markdown 要素」「範囲外」「完了条件」「影響範囲」のすべてに、コードとテストの両面で対応している。ストリーミング途中状態の堅牢性は CommonMark + pulldown-cmark 0.13 のセマンティクスに任せる方針が妥当に効いており、`wrap_line_into` との互換性も line-level style を空に保つことで担保できている。HTML 表示の文面ズレは非ブロッキング。

View File

@ -0,0 +1,41 @@
# TUI: Compaction 中の進行表示
## 背景
Pod で compact が走ると `Event::CompactStart` が 1 本流れ、その後 compact worker (要約 / auto-read 判定 / リファレンス選定) が走り終えるまで TUI には何も流れない。完了時に `Event::CompactDone` または `CompactFailed` が来てようやく次の block が積まれる。
実時間で数十秒〜分単位かかる区間が完全な無音になり、ユーザーからは「固まった」「Pod が落ちた」と区別がつかない。
現状の TUI 表示 (`crates/tui/src/app.rs:651-661`、`crates/tui/src/ui.rs:799-821`) は `CompactStart` / `CompactDone` / `CompactFailed` をそれぞれ独立した append-only な block として積んでいるだけで、進行中であることや経過時間は出ていない。
ThinkingBlock は同種の「LLM が応答するまでの待ち時間」を `ThinkingState::Streaming { started_at }` + ライブで `Thinking... (Xs)` 表示で扱っている (`crates/tui/src/block.rs:67-78`)。Compact も同じパターンに揃えたい。
## 要件
- compact 中であることがライブで分かる。経過秒数を 1 行で表示する (`Compacting... (Xs)` 程度)
- 完了 / 失敗時は進行表示が結果行に **置き換わる**。1 回の compact で block が 2 個積まれるのではなく、Start で積んだものを Done / Failed が更新する
- 完了行は新 session_id の short prefix と所要時間を含む
- 失敗行はエラー文と所要時間を含む
- 進行中のまま `Event::Shutdown` 等で取り残された場合は ThinkingState::Incomplete に相当する終端状態に落とす
- ステータスライン (`draw_status`) はこのチケットでは触らない。block 側のライブ表示のみ
## 完了条件
- compact が走っている間、TUI 末尾に `Compacting... (Xs)` が出て、s が秒単位で進む
- compact 成功で同じ行が `[compact] done (new session XXXXXXXX) (Ys)` 相当に更新される
- compact 失敗で同じ行が `[compact error] ... (Ys)` 相当に更新される
- 1 回の compact イベント列で `Block::Compact` は 1 個しか積まれない (履歴を遡っても重複しない)
- 既存テストが通る
## 範囲外
- 要約 worker の中身 (read_file ループ等) を TUI に流す仕組み
- ステータスラインへの `Compacting…` 表示
- session_id の TUI 表示全般 (旧 / 新 / 親 / 子セッション系譜の可視化は別チケット)
- compact がそもそも動いているかどうかの設定確認 UI
## 関連
- `docs/compaction.md` — compact のトリガーとイベント並びの全体像
- `crates/tui/src/block.rs``ThinkingBlock` / `ThinkingState` — 同型のライブ進行表示の前例
- `crates/tui/src/app.rs:714-746``last_streaming_thinking_mut` / `mark_orphan_thinking_incomplete` の参考実装

View File

@ -0,0 +1,50 @@
# TUI: セッションコンテキスト長 / ウィンドウ占有率の常時表示
## 背景
TUI の status line は現状、Run 単位の `↑net upload / ↓output` トークンしか出していない(`crates/tui/src/app.rs:55-61`、`crates/tui/src/ui.rs:850-895`)。これは「このターンで実際に課金される転送量」を示すには適切だが、「今このセッションがコンテキストウィンドウのどこにいるか」をユーザーが把握する手段がない。
Pod は `Event::Usage` で LLM 1 リクエスト分の `input_tokens`cache 込みの prompt prefix 占有量)を毎回送ってきており(`crates/protocol/src/lib.rs:301-306`、最新値がそのまま「現在のセッションコンテキスト長」になる。受信はしているが、TUI は cache 控除して run 集計に積むだけで保持していない。
ユーザーは compaction やプロンプト重複の発生を体感するために、コンテキスト消費を常に視認したい。
## 方針
3 段階の最小拡張で済ませる:
1. **TUI**: `Event::Usage::input_tokens`cache 控除しない素の値の最新値をセッション状態として持ち、status line に常時表示する。`Event::CompactDone` でリセット。
2. **Protocol**: `Greeting``context_window: u64``context_tokens: u64` を追加。前者はモデルのウィンドウ上限、後者は attach 時点の `Pod::total_tokens()` 推定値(次の Usage 到着まで TUI に表示できる初期値)。
3. **Manifest / Pod**: モデルのコンテキストウィンドウは provider catalog のモデルメタに常設の値として持たせ、必要なら manifest 側で override できる経路を 1 つ用意する。配置catalog エントリの新フィールド or `ModelCapability` 拡張 or `[worker] context_window`は実装着手時に確定。Greeting 構築時には必ず具体値が入る。
`Event::Usage` 自体は既存の wire を踏襲する。新イベントを足さない。
## 要件
- Run 中・Idle 中・Paused 中いずれでも status line に `ctx: <tokens> / <window> (<pct>%)` を表示する。
- セッション初期値: `Event::History``context_tokens` を採用。最初の `Event::Usage` 受信で上書き。
- 更新トリガ:
- `Event::Usage::input_tokens` 受信ごとに最新値で上書きcache 控除しない素の値)。
- `Event::CompactDone` で 0 にリセット(次の Usage で上書き)。`Event::CompactStart` / `Event::CompactFailed` ではリセットしない。
- `Event::TurnStart` / `RunEnd` ではリセットしないrun 集計とは別のセッション単位指標)。
- 既存の run 集計(`request: N | ↑x/↓y | tool: ...`)は残す。`ctx:` は別フィールドとして併置する。
## 完了条件
- TUI 起動から終了まで、status line にコンテキスト消費とウィンドウ占有率が常時出ている。
- compact が走った直後、`ctx:` が一旦 0 に戻り、次のリクエストでセッション開始サイズが入る挙動が見える。
- カタログ未掲載モデルを inline で指定した場合でも、manifest 側 override で値を入れれば同じ表示になる。
## 範囲外
- 履歴ビュー側でのターンごとコンテキスト推移グラフ。
- 課金額USD換算表示。
- compact 閾値 / `compact_request_threshold` との対比可視化threshold バー等)。本チケットは現値の表示までで、警告色の閾値運用は別チケット。
- pod_cli 等他クライアントの同等表示TUI 限定)。
## 影響範囲
- `crates/protocol/src/lib.rs`: `Greeting``context_window: u64` / `context_tokens: u64` 追加。
- `crates/manifest` / `crates/provider/src/catalog.rs`: context window をモデルメタとして宣言する経路。実装着手時に置き場所を確定。
- `crates/pod/src/controller.rs`: `build_greeting` で window と `total_tokens()` を Greeting に詰める。
- `crates/tui/src/app.rs`: `App``session_context_tokens: u64``context_window: u64` を持たせ、`handle_pod_event` で更新。
- `crates/tui/src/ui.rs`: `draw_status``ctx:` フィールドを追加。

View File

@ -1,47 +0,0 @@
# TUI で role:system の system message を表示する
## 背景
Pod は user 入力の `@<path>` chip / `/<slug>` chip を submit 時に展開し、`Item::system_message` を `pending_attachments` 経由で worker.history に commit している:
- `@<path>` ライブ submit: `Pod::run``resolve_file_refs` (crates/pod/src/pod.rs:825,852) → `PodFsView::resolve_file_ref` (crates/pod/src/fs_view.rs:119) で `[File: <path>]\n<body>` を生成
- compact worker の auto-read: `mark_read_required` で nominate された再読対象が `PodFsView::render_auto_read` (crates/pod/src/fs_view.rs:84) で `[Auto-read file: <path>:<range>]\n<body>` として乗る
- `/<slug>` ライブ submit: `Pod::run``resolve_workflow_invocations` (crates/pod/src/pod.rs:826,876) → `crate::workflow::resolve_workflow_invocation` (crates/pod/src/workflow/mod.rs:74) で `[Workflow /<slug>]\n<body>` と requires Knowledge 毎の `[Workflow /<slug> requires Knowledge #<req>]\n<body>` を生成
いずれも `PodInterceptor::on_prompt_submit` (crates/pod/src/ipc/interceptor.rs:114) で `PromptAction::ContinueWith(extras)` 経由で worker.history に commit され、history.json に永続化されている (CLAUDE.md「許される加工」原則に整合)。
## 問題
LLM のコンテキストには乗っているが TUI には何も出ない。TUI 側で role:system の Item が表示経路に乗っていないため:
1. **ライブ側**: 解決済み system message を運ぶ broadcast event が `protocol::Event` に存在しない (crates/protocol/src/lib.rs:204326)。`UserMessage` で `@<path>` / `/<slug>` chip 自体は表示されるが、解決された本体は出ない。失敗時のみ `Alert` で出るが、`Alert` はユーザー向け一過性通知で永続化されない (crates/protocol/src/lib.rs:328348) ため表示経路として不適切。
2. **履歴復元側**: `App::restore_history` (crates/tui/src/app.rs:650702) の match が `role: "user"` / `"assistant"` 以外を `_ => {}` で握り潰す。history.json に system role で残っているのに resume 時に消える。
結果として「`@<path>` や `/<slug>` を submit したのに、本当に読まれたのか / 何が context に乗ったのか TUI からは判別できない」状態になっている。
## 要件
- `Item::system_message` (role:system) を user / assistant メッセージと並列のログ要素として TUI に表示する**一般的な仕組み**を入れる。種別ごとの個別パッチではなく、role:system が来たら一律で表示経路に乗せる形にする。
- 仕組みとして最小限カバーすべき system message:
- `[File: <path>]` (ライブ `@<path>`)
- `[Auto-read file: <path>:<range>]` (compact 後の auto-read)
- `[Workflow /<slug>]`
- `[Workflow /<slug> requires Knowledge #<req>]`
- 表示の単一の情報源は永続化された history (= history.json の role:system Item)。ライブ submit 時 / 履歴復元時 / 別 client subscribe 時 の三経路で同じ Block バリアントを通る。
- 表示は本文プレビュー(数行 + 残行数 / 切り詰め注記)程度で良い。`[Auto-read file: ...:<range>]` の range ラベルは可視化する。workflow 本体と requires Knowledge は同じ workflow 起動に紐づく一連と分かる粒度で識別できる。
- 解決失敗時の従来経路は維持: `@<path>``Alert`、workflow は user-invocation エラーとして即座に Pod から返る (`Pod::validate_workflow_invocations`)。
## 範囲外 / 非目標
- `<system-reminder>` 注入機構そのものの汎用化や、notify_wrapper 適用後の本文表示。これらは別所(`session-todo-reminder` 等)。本チケットは**表示側の仕組みのみ**で、将来 `<system-reminder>` 系が role:system Item として history に乗るようになれば、同じ表示経路にそのまま流れる前提。
- 表示形式の凝った装飾(シンタックスハイライト / 折り畳み UI。最初は素のテキストプレビューで十分。
- `model_invokation: true` のみの workflowuser_invocable=falseの表示は対象外。
## 完了条件
- `@<path>` を submit すると、user message ブロックに続けて自動読み取り結果が TUI に出る。本文プレビューが視認できる。
- `/<slug>` を submit すると、workflow 起動結果のログ要素が TUI に出る。`requires` がある場合は Knowledge 注入もそれと分かる形で出る。
- compact 後の resume で `[Auto-read file: ...]` が同じログ要素として復元・表示される。
- 別 client が後から subscribe して `Event::History` を受けた場合も、同じログ要素として描画される。
- ライブ event と history 復元の表示が一致する(同じ Block バリアントを通る)。
- 解決失敗時の従来経路(`Alert` / user-invocation エラー)は維持される。

View File

@ -0,0 +1,115 @@
# Workflow の物理配置を `.insomnia/workflow` に分離する
## 背景
現行の Workflow は `<workspace>/.insomnia/memory/workflow/<slug>.md` に配置される。これは実装上 memory subsystem の loader / linter に載せていた名残だが、概念上は不自然になっている。
Workflow は session-derived な記憶ではなく、人間が管理する手順・操作ポリシーである。一方で `.insomnia/memory/summary.md`、`decisions/`、`requests/`、`_staging/` は自動抽出・統合される memory state であり、ignore や書き込み禁止の扱いも異なる。
この混在により、`.insomnia/.gitignore` で `memory` を ignore すると project-authored Workflow まで Git 管理から外れる。また、memory write tool が Workflow 書き込みを禁止するなど、「memory 配下にあるが memory ではない」ものとして扱う歪みが出ている。
Knowledge は既に `.insomnia/knowledge/<slug>.md` として `memory/` の外にある。Workflow も同様に `.insomnia/workflow/<slug>.md` を canonical path とし、memory state から分離する。
## 要件
### canonical path の変更
Workflow の標準配置を以下に変更する。
```text
<workspace>/.insomnia/workflow/<slug>.md
```
旧配置は以下。
```text
<workspace>/.insomnia/memory/workflow/<slug>.md
```
新規作成・ドキュメント・補完・resolver の説明は新配置を使う。旧配置の互換読み取りは行わない(このリポジトリ内で完結する移行であり、外部利用者の互換配慮は不要との判断)。旧配置にファイルが残っていても loader は読まないし、linter / scope deny も新配置のみを対象にする。
### linter / registry / completion
- Workflow linter は新配置を検査できる
- `requires` の Knowledge 参照検査は従来通り維持する
- `WorkflowRegistry` は新配置の source path を保持する
- `/<slug>` completion / invocation は新配置から読める
- resident workflow (`model_invokation: true`) の広告も新配置を対象にする
### memory state との分離
`.insomnia/memory/` は generated / session-derived state として扱い、Workflow は含めない。
推奨される layout:
```text
.insomnia/
manifest.toml
workflow/
auto-maintain.md
worktree-workflow.md
knowledge/
<slug>.md
memory/
summary.md
decisions/
requests/
_staging/
```
`.insomnia/.gitignore``memory` 丸ごと ignore ではなく、generated memory state のみを ignore する形にする。
例:
```gitignore
/memory/_staging/
/memory/summary.md
/memory/decisions/
/memory/requests/
```
### 既存ファイルの移行
workspace に既存 Workflow がある場合、新配置へ移す。
対象例:
```text
.insomnia/memory/workflow/auto-maintain.md
.insomnia/memory/workflow/worktree-workflow.md
```
移行後:
```text
.insomnia/workflow/auto-maintain.md
.insomnia/workflow/worktree-workflow.md
```
移行は Git 管理対象として扱えるようにする。
## 範囲外
- Workflow の自動生成ポリシー変更
- Workflow frontmatter schema の大幅変更
- Workflow DSL 化
- memory tool で Workflow を書けるようにする変更
- 内部 Worker Workflow 化そのもの(`tickets/internal-worker-workflow.md` の対象)
## 完了条件
- Workflow の canonical path が `.insomnia/workflow/<slug>.md` として実装・文書化されている
- loader / linter / scope deny / completion / invocation が新配置のみを対象にしている
- Workflow linter / loader / completion / invocation のテストが新配置をカバーしている
- `.insomnia/.gitignore` が generated memory state のみを ignore し、`.insomnia/workflow/*.md` を Git 管理できる
- この workspace の既存 Workflow が `.insomnia/workflow/` に移されている
- `docs/plan/workflow.md` や関連コメントが新配置に更新されている
## 参照
- `docs/plan/workflow.md`
- `crates/memory/src/workspace.rs`
- `crates/memory/src/workflow.rs`
- `crates/pod/src/workflow/mod.rs`
- `tickets/auto-maintain-workflow.md`
- `tickets/internal-worker-workflow.md`