Compare commits
10 Commits
b192a3ce4e
...
8a1baa5020
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a1baa5020 | |||
| 6fa7f169b4 | |||
| 44d660c894 | |||
| b9575f1534 | |||
| e98a596235 | |||
| 3c90729156 | |||
| 51309ec5bf | |||
| af57d5b566 | |||
| 0f6b724184 | |||
| d5d0e4124b |
17
Cargo.lock
generated
17
Cargo.lock
generated
|
|
@ -2130,6 +2130,7 @@ dependencies = [
|
|||
"protocol",
|
||||
"provider",
|
||||
"schemars",
|
||||
"scope-lock",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"session-store",
|
||||
|
|
@ -2746,6 +2747,20 @@ dependencies = [
|
|||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scope-lock"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"fs4",
|
||||
"libc",
|
||||
"manifest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"session-store",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
|
|
@ -3574,7 +3589,9 @@ dependencies = [
|
|||
"manifest",
|
||||
"protocol",
|
||||
"ratatui",
|
||||
"scope-lock",
|
||||
"serde_json",
|
||||
"session-store",
|
||||
"tokio",
|
||||
"toml",
|
||||
"unicode-width",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ members = [
|
|||
"crates/pod",
|
||||
"crates/protocol",
|
||||
"crates/provider",
|
||||
"crates/scope-lock",
|
||||
"crates/tools",
|
||||
"crates/tui", "crates/memory",
|
||||
]
|
||||
|
|
|
|||
4
TODO.md
4
TODO.md
|
|
@ -5,19 +5,17 @@
|
|||
- [ ] Pod CLI: マニフェスト関連フラグの整理 → [tickets/pod-cli-manifest-flags.md](tickets/pod-cli-manifest-flags.md)
|
||||
- [ ] Pod オーケストレーション
|
||||
- [ ] 動的 Scope 変更 → [tickets/dynamic-scope.md](tickets/dynamic-scope.md)
|
||||
- [ ] `scope-lock` → `pod-registry` リネーム → [tickets/pod-registry-rename.md](tickets/pod-registry-rename.md)
|
||||
- [ ] ネイティブ GUI クライアント MVP → [tickets/native-gui-mvp.md](tickets/native-gui-mvp.md)
|
||||
- [ ] TUI 拡充
|
||||
- [ ] フルスクリーン化によるオーバーホール → [tickets/tui-fullscreen-overhaul.md](tickets/tui-fullscreen-overhaul.md)
|
||||
- [ ] Run 中の入力キューイング → [tickets/tui-input-queue.md](tickets/tui-input-queue.md)
|
||||
- [ ] ユーザーマニフェストのモデル設定 wizard → [tickets/tui-user-model-setup.md](tickets/tui-user-model-setup.md)
|
||||
- [ ] 既存セッションからの Pod 復帰 → [tickets/tui-session-restore.md](tickets/tui-session-restore.md)
|
||||
- [ ] サブミット入力
|
||||
- [ ] TUI 補完 + 型付き atom 化 → [tickets/submit-tui-completion.md](tickets/submit-tui-completion.md)
|
||||
- [ ] セッションログの Segment 保持 → [tickets/session-log-segments.md](tickets/session-log-segments.md)
|
||||
- [ ] メモリ機構
|
||||
- [ ] Phase 1 活動抽出 → [tickets/memory-phase1-extract.md](tickets/memory-phase1-extract.md)
|
||||
- [ ] Phase 2 consolidation → [tickets/memory-phase2-consolidation.md](tickets/memory-phase2-consolidation.md)
|
||||
- [ ] 使用頻度メトリクス + Knowledge 化候補レポート → [tickets/memory-usage-metrics.md](tickets/memory-usage-metrics.md)
|
||||
- [ ] GC(定期再評価) → [tickets/memory-gc.md](tickets/memory-gc.md)
|
||||
- ワークスペースのメモリーをLintするヘッドレスCLI
|
||||
- [ ] Thinking ブロックの TUI 表示 → [tickets/tui-thinking-display.md](tickets/tui-thinking-display.md)
|
||||
|
|
|
|||
|
|
@ -16,11 +16,12 @@ pub use scheme_impl::OpenAIResponsesState;
|
|||
|
||||
/// OpenAI Responses scheme 本体。
|
||||
///
|
||||
/// `store` / `include_encrypted_content` は scheme 固定の wire 設定で、
|
||||
/// デフォルトは stateless + ZDR 相当 (`store=false`, `include=[...]`)。
|
||||
/// 将来 ZDR 非対応環境で `store=true` にしたくなった場合に限り override
|
||||
/// する。`ModelCapability` には入れない(これはモデルの能力ではなく、
|
||||
/// クライアントの運用方針)。
|
||||
/// `store` / `include_encrypted_content` / `send_max_output_tokens` は
|
||||
/// scheme 固定の wire 設定で、デフォルトは公式 OpenAI Responses API
|
||||
/// 向け (stateless + ZDR + `max_output_tokens` 送出可)。ChatGPT backend
|
||||
/// (codex-oauth) のように受理パラメータが subset の経路では provider 層で
|
||||
/// `send_max_output_tokens=false` 等に上書きする。`ModelCapability` には
|
||||
/// 入れない(モデル能力ではなく wire policy)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OpenAIResponsesScheme {
|
||||
/// サーバ側に response を保存するか。ZDR/stateless 運用では `false`。
|
||||
|
|
@ -28,6 +29,10 @@ pub struct OpenAIResponsesScheme {
|
|||
/// `include: ["reasoning.encrypted_content"]` を付けるか。
|
||||
/// `store=false` で reasoning を使うなら必須。
|
||||
pub include_encrypted_content: bool,
|
||||
/// `max_output_tokens` を body に載せるか。公式 OpenAI Responses API は
|
||||
/// 受理するが、ChatGPT backend (codex-oauth) は `Unsupported parameter`
|
||||
/// で 400 を返すため、その経路では `false` にする。
|
||||
pub send_max_output_tokens: bool,
|
||||
}
|
||||
|
||||
impl Default for OpenAIResponsesScheme {
|
||||
|
|
@ -35,12 +40,14 @@ impl Default for OpenAIResponsesScheme {
|
|||
Self {
|
||||
store: false,
|
||||
include_encrypted_content: true,
|
||||
send_max_output_tokens: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenAIResponsesScheme {
|
||||
/// デフォルト設定 (`store=false`, `include=["reasoning.encrypted_content"]`)。
|
||||
/// デフォルト設定 (`store=false`, `include=["reasoning.encrypted_content"]`,
|
||||
/// `send_max_output_tokens=true`)。
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
|
@ -56,4 +63,10 @@ impl OpenAIResponsesScheme {
|
|||
self.include_encrypted_content = include;
|
||||
self
|
||||
}
|
||||
|
||||
/// `max_output_tokens` を body に載せるかを上書き。
|
||||
pub fn with_send_max_output_tokens(mut self, send: bool) -> Self {
|
||||
self.send_max_output_tokens = send;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ pub(crate) struct ResponsesRequest {
|
|||
/// `["reasoning.encrypted_content"]` 等。
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub include: Vec<&'static str>,
|
||||
/// 公式 OpenAI Responses API では受理されるが、ChatGPT backend
|
||||
/// (codex-oauth) は 400 で弾く。scheme の `send_max_output_tokens`
|
||||
/// が `false` のときは `None` のまま送る (skip_serializing_if で除外)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_output_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
|
@ -195,7 +198,11 @@ impl OpenAIResponsesScheme {
|
|||
store: self.store,
|
||||
stream: true,
|
||||
include,
|
||||
max_output_tokens: request.config.max_tokens,
|
||||
max_output_tokens: if self.send_max_output_tokens {
|
||||
request.config.max_tokens
|
||||
} else {
|
||||
None
|
||||
},
|
||||
temperature: request.config.temperature,
|
||||
top_p: request.config.top_p,
|
||||
}
|
||||
|
|
@ -444,13 +451,26 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn max_output_tokens_passed_through() {
|
||||
fn max_output_tokens_passed_through_by_default() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi").max_tokens(100);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.max_output_tokens, Some(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_output_tokens_dropped_when_send_disabled() {
|
||||
let scheme = OpenAIResponsesScheme::new().with_send_max_output_tokens(false);
|
||||
let req = Request::new().user("hi").max_tokens(100);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.max_output_tokens, None);
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert!(
|
||||
json.get("max_output_tokens").is_none(),
|
||||
"max_output_tokens key must not appear in serialised body, got: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_without_properties_is_normalized() {
|
||||
// schemars は引数なし struct から `type:"object"` だけのスキーマを
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@
|
|||
use serde_json::Value;
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError, auth::AuthRequirement, capability::ModelCapability, event::Event, scheme::Scheme,
|
||||
types::Request,
|
||||
ClientError, auth::AuthRequirement, capability::ModelCapability,
|
||||
client::ConfigWarning, event::Event, scheme::Scheme,
|
||||
types::{Request, RequestConfig},
|
||||
};
|
||||
|
||||
use super::OpenAIResponsesScheme;
|
||||
|
|
@ -51,4 +52,18 @@ impl Scheme for OpenAIResponsesScheme {
|
|||
fn default_capability(&self) -> ModelCapability {
|
||||
super::capability::default_capability()
|
||||
}
|
||||
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
let mut warnings = Vec::new();
|
||||
// ChatGPT backend (codex-oauth) は `max_output_tokens` を 400 で弾く。
|
||||
// scheme 構築時に `send_max_output_tokens=false` で組まれていれば
|
||||
// body 投影は止まっているので、ユーザの意図が落ちることだけを通知する。
|
||||
if !self.send_max_output_tokens && config.max_tokens.is_some() {
|
||||
warnings.push(ConfigWarning::unsupported(
|
||||
"max_tokens",
|
||||
"OpenAI Responses (ChatGPT backend)",
|
||||
));
|
||||
}
|
||||
warnings
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ session-store = { version = "0.1.0", path = "../session-store" }
|
|||
manifest = { version = "0.1.0", path = "../manifest" }
|
||||
protocol = { version = "0.1.0", path = "../protocol" }
|
||||
provider = { version = "0.1.0", path = "../provider" }
|
||||
scope-lock = { version = "0.1.0", path = "../scope-lock" }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
thiserror = "2.0"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::process::ExitCode;
|
|||
use clap::Parser;
|
||||
use manifest::paths;
|
||||
use pod::{Pod, PodController, PodFactory};
|
||||
use session_store::FsStore;
|
||||
use session_store::{FsStore, SessionId};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
|
|
@ -43,6 +43,14 @@ struct Cli {
|
|||
/// callbacks upward. Required alongside `--adopt`.
|
||||
#[arg(long, value_name = "PATH", requires = "adopt")]
|
||||
callback: Option<PathBuf>,
|
||||
|
||||
/// Restore a Pod from an existing session. The Pod re-uses the
|
||||
/// given session id and appends new turns to the same jsonl;
|
||||
/// concurrent writers are prevented by the `scope.lock` registry.
|
||||
/// Mutually exclusive with `--adopt` (spawned children always start
|
||||
/// fresh).
|
||||
#[arg(long, value_name = "UUID", conflicts_with = "adopt")]
|
||||
session: Option<SessionId>,
|
||||
}
|
||||
|
||||
async fn build_factory(cli: &Cli) -> Result<PodFactory, String> {
|
||||
|
|
@ -136,6 +144,14 @@ async fn main() -> ExitCode {
|
|||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
} else if let Some(source_session_id) = cli.session {
|
||||
match Pod::restore_from_manifest(source_session_id, manifest, store, loader).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("error: failed to restore pod: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match Pod::from_manifest(manifest, store, loader).await {
|
||||
Ok(p) => p,
|
||||
|
|
|
|||
|
|
@ -100,10 +100,11 @@ pub struct Pod<C: LlmClient, St: Store> {
|
|||
/// PodInterceptor installed in `ensure_interceptor_installed`.
|
||||
pending_notifies: NotifyBuffer,
|
||||
/// Scope allocation in the machine-wide lock file. `Some` for
|
||||
/// Pods built via `from_manifest` (production path); `None` for
|
||||
/// lower-level constructors (`Pod::new`, `Pod::restore`) that
|
||||
/// bypass the registry. Kept purely for its `Drop` impl, which
|
||||
/// releases the allocation when the Pod is dropped.
|
||||
/// Pods built via `from_manifest` / `from_manifest_spawned` /
|
||||
/// `restore_from_manifest` (production paths); `None` for the
|
||||
/// low-level `Pod::new` constructor used in tests, which bypasses
|
||||
/// the registry. Kept purely for its `Drop` impl, which releases
|
||||
/// the allocation when the Pod is dropped.
|
||||
#[allow(dead_code)]
|
||||
scope_allocation: Option<ScopeAllocationGuard>,
|
||||
/// Socket path of the spawning Pod. `Some` only for Pods built via
|
||||
|
|
@ -210,75 +211,11 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
self.inject_resident_knowledge = enabled;
|
||||
}
|
||||
|
||||
/// Restore a Pod from a persisted session.
|
||||
/// Shared handle to the prompt catalog. Cheap to clone (`Arc`).
|
||||
pub fn prompts(&self) -> &Arc<PromptCatalog> {
|
||||
&self.prompts
|
||||
}
|
||||
|
||||
pub async fn restore(
|
||||
session_id: SessionId,
|
||||
manifest: PodManifest,
|
||||
client: C,
|
||||
store: St,
|
||||
pwd: PathBuf,
|
||||
scope: Scope,
|
||||
) -> Result<Self, PodError> {
|
||||
let state = session_store::restore(&store, session_id).await?;
|
||||
let mut worker = Worker::new(client);
|
||||
if let Some(ref prompt) = state.system_prompt {
|
||||
worker.set_system_prompt(prompt);
|
||||
}
|
||||
// A leading `Role::System` item can only come from `compact`
|
||||
// (the Pod's one and only write path that prepends a summary at
|
||||
// history[0]). Restoring the anchor lets Anthropic re-use a
|
||||
// stable cache prefix for long-lived restored sessions.
|
||||
let anchored_on_summary = matches!(
|
||||
state.history.first(),
|
||||
Some(Item::Message {
|
||||
role: llm_worker::Role::System,
|
||||
..
|
||||
})
|
||||
);
|
||||
worker.set_history(state.history);
|
||||
worker.set_request_config(state.config);
|
||||
worker.set_turn_count(state.turn_count);
|
||||
worker.set_last_run_interrupted(state.last_run_interrupted);
|
||||
if anchored_on_summary {
|
||||
worker.set_cache_anchor(Some(0));
|
||||
}
|
||||
|
||||
let prompts = PromptCatalog::builtins_only()?;
|
||||
let extract_pointer = memory::extract::fold_pointer(&state.extensions);
|
||||
let mut pod = Self {
|
||||
manifest,
|
||||
worker: Some(worker),
|
||||
store,
|
||||
session_id,
|
||||
head_hash: state.head_hash,
|
||||
pwd,
|
||||
scope,
|
||||
hook_builder: HookRegistryBuilder::new(),
|
||||
interceptor_installed: false,
|
||||
compact_state: None,
|
||||
usage_tracker: Arc::new(UsageTracker::new()),
|
||||
usage_history: Arc::new(Mutex::new(state.usage_history)),
|
||||
tracker: None,
|
||||
system_prompt_template: None,
|
||||
alerter: None,
|
||||
event_tx: None,
|
||||
pending_notifies: NotifyBuffer::new(),
|
||||
scope_allocation: None,
|
||||
callback_socket: None,
|
||||
prompts,
|
||||
inject_resident_knowledge: true,
|
||||
extract_in_flight: Arc::new(AtomicBool::new(false)),
|
||||
extract_pointer: Mutex::new(extract_pointer),
|
||||
};
|
||||
pod.apply_prune_from_manifest();
|
||||
Ok(pod)
|
||||
}
|
||||
|
||||
/// The session ID used for persistence.
|
||||
pub fn session_id(&self) -> SessionId {
|
||||
self.session_id
|
||||
|
|
@ -781,6 +718,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
self.head_hash = Some(hash);
|
||||
return Ok(());
|
||||
}
|
||||
let prev_session_id = self.session_id;
|
||||
session_store::ensure_head_or_fork(
|
||||
&self.store,
|
||||
&mut self.session_id,
|
||||
|
|
@ -788,6 +726,13 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
state,
|
||||
)
|
||||
.await?;
|
||||
// ensure_head_or_fork mints a fresh session_id when it auto-
|
||||
// forks. Sync that to scope.lock 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() {
|
||||
scope_lock::update_session(&self.manifest.pod.name, self.session_id)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -1064,7 +1009,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
let mut summary_worker = Worker::new(summary_client)
|
||||
.system_prompt(summary_system_prompt)
|
||||
.temperature(0.0);
|
||||
summary_worker.set_max_tokens(4096);
|
||||
|
||||
// Cumulative input-token meter + interceptor. The meter is bumped
|
||||
// from the on_usage callback and read on every pre_llm_request.
|
||||
|
|
@ -1220,6 +1164,15 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
// until its first LLM call.
|
||||
self.session_id = new_session_id;
|
||||
self.head_hash = Some(new_head_hash);
|
||||
// Keep scope.lock 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
|
||||
// into, causing two writers to race on the same jsonl. Skipped
|
||||
// when no allocation is installed (e.g. compact under
|
||||
// `Pod::new` in tests).
|
||||
if self.scope_allocation.is_some() {
|
||||
scope_lock::update_session(&self.manifest.pod.name, new_session_id)?;
|
||||
}
|
||||
let worker = self.worker.as_mut().unwrap();
|
||||
worker.set_history(new_history);
|
||||
// Anchor the prompt cache at the summary item so that Anthropic
|
||||
|
|
@ -1413,7 +1366,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
let mut extract_worker = Worker::new(client)
|
||||
.system_prompt(extract::EXTRACT_SYSTEM_PROMPT)
|
||||
.temperature(0.0);
|
||||
extract_worker.set_max_tokens(4096);
|
||||
|
||||
// Cumulative input-token meter + interceptor (mirror of
|
||||
// CompactWorkerInterceptor). Aborts the extract worker if its
|
||||
|
|
@ -1536,15 +1488,18 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
|||
store: St,
|
||||
loader: PromptLoader,
|
||||
) -> Result<Self, PodError> {
|
||||
let pwd = current_pwd()?;
|
||||
let scope = build_scope_with_memory(&manifest, &pwd)?;
|
||||
if !scope.is_readable(&pwd) {
|
||||
return Err(PodError::PwdOutsideScope { pwd });
|
||||
}
|
||||
let common = prepare_pod_common(&manifest, &loader, /* parse_template */ true)?;
|
||||
|
||||
// Session creation is deferred to the first run (see
|
||||
// `ensure_session_head`) so the SessionStart entry can capture
|
||||
// the rendered system prompt, not the raw template source. The
|
||||
// session_id is allocated here so the scope-lock registration
|
||||
// can record it from the start.
|
||||
let session_id = session_store::new_session_id();
|
||||
|
||||
// Register this Pod in the machine-wide scope-lock registry
|
||||
// before building anything else, so a spawn that conflicts on
|
||||
// scope fails fast (and without having paid for client setup).
|
||||
// scope fails fast.
|
||||
let socket_path = dir::default_base()
|
||||
.map_err(ScopeLockError::from)?
|
||||
.join(&manifest.pod.name)
|
||||
|
|
@ -1553,50 +1508,34 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
|||
manifest.pod.name.clone(),
|
||||
std::process::id(),
|
||||
socket_path,
|
||||
scope.allow_rules(),
|
||||
common.scope.allow_rules(),
|
||||
session_id,
|
||||
)?;
|
||||
|
||||
let client = provider::build_client(&manifest.model)?;
|
||||
let mut worker = Worker::new(client);
|
||||
let mut worker = Worker::new(common.client);
|
||||
apply_worker_manifest(&mut worker, &manifest.worker);
|
||||
|
||||
// Resolve the instruction reference and parse the resulting
|
||||
// template eagerly (syntax check only). Rendering is deferred
|
||||
// to `ensure_system_prompt_materialized` at first turn so
|
||||
// runtime values (date, tools, scope summary, ...) can be
|
||||
// injected.
|
||||
let system_prompt_template = Some(
|
||||
SystemPromptTemplate::parse(&manifest.worker.instruction, loader.clone())
|
||||
.map_err(|source| PodError::InvalidSystemPromptTemplate { source })?,
|
||||
);
|
||||
|
||||
let prompts = PromptCatalog::load(&loader, manifest.pod.prompt_pack.as_deref())?;
|
||||
|
||||
// Session creation is deferred to the first run (see
|
||||
// `ensure_session_head`) so the SessionStart entry can capture
|
||||
// the rendered system prompt, not the raw template source.
|
||||
let session_id = session_store::new_session_id();
|
||||
let mut pod = Self {
|
||||
manifest,
|
||||
worker: Some(worker),
|
||||
store,
|
||||
session_id,
|
||||
head_hash: None,
|
||||
pwd,
|
||||
scope,
|
||||
pwd: common.pwd,
|
||||
scope: common.scope,
|
||||
hook_builder: HookRegistryBuilder::new(),
|
||||
interceptor_installed: false,
|
||||
compact_state: None,
|
||||
usage_tracker: Arc::new(UsageTracker::new()),
|
||||
usage_history: Arc::new(Mutex::new(Vec::new())),
|
||||
tracker: None,
|
||||
system_prompt_template,
|
||||
system_prompt_template: common.system_prompt_template,
|
||||
alerter: None,
|
||||
event_tx: None,
|
||||
pending_notifies: NotifyBuffer::new(),
|
||||
scope_allocation: Some(scope_allocation),
|
||||
callback_socket: None,
|
||||
prompts,
|
||||
prompts: common.prompts,
|
||||
inject_resident_knowledge: true,
|
||||
extract_in_flight: Arc::new(AtomicBool::new(false)),
|
||||
extract_pointer: Mutex::new(None),
|
||||
|
|
@ -1612,57 +1551,43 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
|||
/// [`scope_lock::delegate_scope`], rather than installing a new
|
||||
/// top-level entry. `callback_socket` carries the spawner's
|
||||
/// Unix-socket path so the spawned Pod can send `Method::Notify`
|
||||
/// back to the spawner; it is stored but unused in the
|
||||
/// `spawn-pod-tool` ticket — the receiving side lands in the
|
||||
/// follow-up `pod-callback` ticket.
|
||||
/// back to the spawner.
|
||||
pub async fn from_manifest_spawned(
|
||||
manifest: PodManifest,
|
||||
store: St,
|
||||
loader: PromptLoader,
|
||||
callback_socket: PathBuf,
|
||||
) -> Result<Self, PodError> {
|
||||
let pwd = current_pwd()?;
|
||||
let scope = build_scope_with_memory(&manifest, &pwd)?;
|
||||
if !scope.is_readable(&pwd) {
|
||||
return Err(PodError::PwdOutsideScope { pwd });
|
||||
}
|
||||
|
||||
let scope_allocation =
|
||||
scope_lock::adopt_allocation(manifest.pod.name.clone(), std::process::id())?;
|
||||
|
||||
let client = provider::build_client(&manifest.model)?;
|
||||
let mut worker = Worker::new(client);
|
||||
apply_worker_manifest(&mut worker, &manifest.worker);
|
||||
|
||||
let system_prompt_template = Some(
|
||||
SystemPromptTemplate::parse(&manifest.worker.instruction, loader.clone())
|
||||
.map_err(|source| PodError::InvalidSystemPromptTemplate { source })?,
|
||||
);
|
||||
|
||||
let prompts = PromptCatalog::load(&loader, manifest.pod.prompt_pack.as_deref())?;
|
||||
let common = prepare_pod_common(&manifest, &loader, /* parse_template */ true)?;
|
||||
|
||||
let session_id = session_store::new_session_id();
|
||||
let scope_allocation =
|
||||
scope_lock::adopt_allocation(manifest.pod.name.clone(), std::process::id(), session_id)?;
|
||||
|
||||
let mut worker = Worker::new(common.client);
|
||||
apply_worker_manifest(&mut worker, &manifest.worker);
|
||||
|
||||
let mut pod = Self {
|
||||
manifest,
|
||||
worker: Some(worker),
|
||||
store,
|
||||
session_id,
|
||||
head_hash: None,
|
||||
pwd,
|
||||
scope,
|
||||
pwd: common.pwd,
|
||||
scope: common.scope,
|
||||
hook_builder: HookRegistryBuilder::new(),
|
||||
interceptor_installed: false,
|
||||
compact_state: None,
|
||||
usage_tracker: Arc::new(UsageTracker::new()),
|
||||
usage_history: Arc::new(Mutex::new(Vec::new())),
|
||||
tracker: None,
|
||||
system_prompt_template,
|
||||
system_prompt_template: common.system_prompt_template,
|
||||
alerter: None,
|
||||
event_tx: None,
|
||||
pending_notifies: NotifyBuffer::new(),
|
||||
scope_allocation: Some(scope_allocation),
|
||||
callback_socket: Some(callback_socket),
|
||||
prompts,
|
||||
prompts: common.prompts,
|
||||
inject_resident_knowledge: true,
|
||||
extract_in_flight: Arc::new(AtomicBool::new(false)),
|
||||
extract_pointer: Mutex::new(None),
|
||||
|
|
@ -1671,6 +1596,113 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
|||
Ok(pod)
|
||||
}
|
||||
|
||||
/// Restore a Pod from an existing session log.
|
||||
///
|
||||
/// Resolves the manifest cascade exactly like [`Self::from_manifest`]
|
||||
/// (pwd / scope / scope-lock / client / prompt catalog), seeds a
|
||||
/// fresh Worker from the source session's `RestoredState`, and
|
||||
/// reuses the same `session_id` so subsequent turns append to the
|
||||
/// source jsonl as a continuation of the same conversation.
|
||||
///
|
||||
/// Concurrent writers are prevented by the `scope.lock` registry:
|
||||
/// the registration carries `session_id`, and this constructor
|
||||
/// refuses to start when `scope_lock::lookup_session` already finds
|
||||
/// a live Pod writing to `session_id`. So there is no need to fork —
|
||||
/// resume is "the same session, a different process owning it".
|
||||
///
|
||||
/// `system_prompt` is replayed verbatim from the session log —
|
||||
/// templates are not re-rendered on restore so a long-running
|
||||
/// session keeps a stable cache prefix even when the manifest's
|
||||
/// instruction template would render differently today.
|
||||
pub async fn restore_from_manifest(
|
||||
session_id: SessionId,
|
||||
manifest: PodManifest,
|
||||
store: St,
|
||||
loader: PromptLoader,
|
||||
) -> Result<Self, PodError> {
|
||||
let state = session_store::restore(&store, session_id).await?;
|
||||
if state.head_hash.is_none() {
|
||||
return Err(PodError::SessionEmpty { session_id });
|
||||
}
|
||||
|
||||
let common = prepare_pod_common(&manifest, &loader, /* parse_template */ false)?;
|
||||
|
||||
// Atomic: register_pod inside install_top_level rejects when
|
||||
// another live allocation already holds `session_id`. Wrapping
|
||||
// the lookup + install inside a single `LockFileGuard` is what
|
||||
// makes "no two live Pods write to the same session log"
|
||||
// actually structural rather than a hopeful pre-check.
|
||||
let socket_path = dir::default_base()
|
||||
.map_err(ScopeLockError::from)?
|
||||
.join(&manifest.pod.name)
|
||||
.join("sock");
|
||||
let scope_allocation = scope_lock::install_top_level(
|
||||
manifest.pod.name.clone(),
|
||||
std::process::id(),
|
||||
socket_path,
|
||||
common.scope.allow_rules(),
|
||||
session_id,
|
||||
)?;
|
||||
|
||||
// Build the worker and apply the manifest defaults first, then
|
||||
// overwrite the pieces the session log is authoritative for.
|
||||
let mut worker = Worker::new(common.client);
|
||||
apply_worker_manifest(&mut worker, &manifest.worker);
|
||||
if let Some(ref prompt) = state.system_prompt {
|
||||
worker.set_system_prompt(prompt);
|
||||
}
|
||||
// A leading `Role::System` item can only come from `compact`
|
||||
// (the Pod's one and only write path that prepends a summary at
|
||||
// history[0]). Restoring the anchor lets Anthropic re-use a
|
||||
// stable cache prefix for long-lived restored sessions.
|
||||
let anchored_on_summary = matches!(
|
||||
state.history.first(),
|
||||
Some(Item::Message {
|
||||
role: llm_worker::Role::System,
|
||||
..
|
||||
})
|
||||
);
|
||||
worker.set_history(state.history.clone());
|
||||
worker.set_request_config(state.config.clone());
|
||||
worker.set_turn_count(state.turn_count);
|
||||
worker.set_last_run_interrupted(state.last_run_interrupted);
|
||||
if anchored_on_summary {
|
||||
worker.set_cache_anchor(Some(0));
|
||||
}
|
||||
|
||||
let extract_pointer = memory::extract::fold_pointer(&state.extensions);
|
||||
|
||||
let mut pod = Self {
|
||||
manifest,
|
||||
worker: Some(worker),
|
||||
store,
|
||||
session_id,
|
||||
head_hash: state.head_hash,
|
||||
pwd: common.pwd,
|
||||
scope: common.scope,
|
||||
hook_builder: HookRegistryBuilder::new(),
|
||||
interceptor_installed: false,
|
||||
compact_state: None,
|
||||
usage_tracker: Arc::new(UsageTracker::new()),
|
||||
usage_history: Arc::new(Mutex::new(state.usage_history)),
|
||||
tracker: None,
|
||||
// Restore replays the saved system_prompt verbatim — no
|
||||
// template re-render on resume.
|
||||
system_prompt_template: None,
|
||||
alerter: None,
|
||||
event_tx: None,
|
||||
pending_notifies: NotifyBuffer::new(),
|
||||
scope_allocation: Some(scope_allocation),
|
||||
callback_socket: None,
|
||||
prompts: common.prompts,
|
||||
inject_resident_knowledge: true,
|
||||
extract_in_flight: Arc::new(AtomicBool::new(false)),
|
||||
extract_pointer: Mutex::new(extract_pointer),
|
||||
};
|
||||
pod.apply_prune_from_manifest();
|
||||
Ok(pod)
|
||||
}
|
||||
|
||||
/// Convenience: build a Pod from a single-layer TOML manifest string.
|
||||
///
|
||||
/// Parses the TOML into a [`PodManifestConfig`], converts to a
|
||||
|
|
@ -1864,6 +1896,63 @@ pub enum PodError {
|
|||
|
||||
#[error("memory Phase 1 staging write failed: {0}")]
|
||||
ExtractStaging(#[source] memory::extract::StagingError),
|
||||
|
||||
#[error("session {session_id} has no entries to restore")]
|
||||
SessionEmpty { session_id: SessionId },
|
||||
}
|
||||
|
||||
/// Bundle of resources that every high-level Pod constructor needs:
|
||||
/// pwd, scope, an LLM client, the prompt catalog, and (optionally) a
|
||||
/// parsed system-prompt template. Built once by [`prepare_pod_common`]
|
||||
/// from the manifest cascade and then split into Pod fields.
|
||||
struct PodCommon {
|
||||
pwd: PathBuf,
|
||||
scope: Scope,
|
||||
client: Box<dyn LlmClient>,
|
||||
prompts: Arc<PromptCatalog>,
|
||||
system_prompt_template: Option<SystemPromptTemplate>,
|
||||
}
|
||||
|
||||
/// Resolve pwd / scope / LLM client / prompt catalog from a validated
|
||||
/// manifest cascade. Used by `from_manifest`, `from_manifest_spawned`,
|
||||
/// and `restore_from_manifest` so they share one definition of "what
|
||||
/// pieces fall out of a manifest".
|
||||
///
|
||||
/// `parse_template` controls whether the manifest's instruction is
|
||||
/// parsed as a system-prompt template. New Pods always parse so the
|
||||
/// template is rendered at first turn; restored Pods skip parsing
|
||||
/// because the saved session log replays a previously-rendered
|
||||
/// `system_prompt` verbatim.
|
||||
fn prepare_pod_common(
|
||||
manifest: &PodManifest,
|
||||
loader: &PromptLoader,
|
||||
parse_template: bool,
|
||||
) -> Result<PodCommon, PodError> {
|
||||
let pwd = current_pwd()?;
|
||||
let scope = build_scope_with_memory(manifest, &pwd)?;
|
||||
if !scope.is_readable(&pwd) {
|
||||
return Err(PodError::PwdOutsideScope { pwd });
|
||||
}
|
||||
|
||||
let client = provider::build_client(&manifest.model)?;
|
||||
let prompts = PromptCatalog::load(loader, manifest.pod.prompt_pack.as_deref())?;
|
||||
|
||||
let system_prompt_template = if parse_template {
|
||||
Some(
|
||||
SystemPromptTemplate::parse(&manifest.worker.instruction, loader.clone())
|
||||
.map_err(|source| PodError::InvalidSystemPromptTemplate { source })?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(PodCommon {
|
||||
pwd,
|
||||
scope,
|
||||
client,
|
||||
prompts,
|
||||
system_prompt_template,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the Pod's runtime [`Scope`] from the manifest, layering the
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
pub mod dir;
|
||||
pub mod scope_lock;
|
||||
pub use ::scope_lock;
|
||||
|
|
|
|||
|
|
@ -441,7 +441,8 @@ fn scope_lock_err_to_tool(e: ScopeLockError) -> ToolError {
|
|||
ScopeLockError::NotSubset { .. }
|
||||
| ScopeLockError::WriteConflict { .. }
|
||||
| ScopeLockError::DuplicatePodName(_)
|
||||
| ScopeLockError::UnknownPod(_) => ToolError::InvalidArgument(e.to_string()),
|
||||
| ScopeLockError::UnknownPod(_)
|
||||
| ScopeLockError::SessionConflict { .. } => ToolError::InvalidArgument(e.to_string()),
|
||||
ScopeLockError::Io(_) => ToolError::ExecutionFailed(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -351,6 +351,7 @@ async fn stop_pod_sends_shutdown_and_releases_scope() {
|
|||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
}],
|
||||
session_store::new_session_id(),
|
||||
)
|
||||
.unwrap();
|
||||
scope_lock::delegate_scope(
|
||||
|
|
|
|||
|
|
@ -358,6 +358,7 @@ async fn shutdown_releases_scope_allocation_when_present() {
|
|||
std::process::id(),
|
||||
"/tmp/kid.sock".into(),
|
||||
vec![],
|
||||
session_store::new_session_id(),
|
||||
)
|
||||
.unwrap();
|
||||
std::mem::forget(guard);
|
||||
|
|
|
|||
86
crates/pod/tests/restore_test.rs
Normal file
86
crates/pod/tests/restore_test.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
//! Integration tests for `Pod::restore_from_manifest`'s pre-build
|
||||
//! validation paths.
|
||||
//!
|
||||
//! These cases all return before `prepare_pod_common` runs, so they
|
||||
//! do not need a real LLM client or scope-lock environment — only the
|
||||
//! session store needs to be present.
|
||||
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use pod::{Pod, PodError};
|
||||
use session_store::{FsStore, SessionId, StoreError};
|
||||
|
||||
const MINIMAL_MANIFEST_TOML: &str = r#"
|
||||
[pod]
|
||||
name = "restore-test"
|
||||
pwd = "./"
|
||||
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
model_id = "test-model"
|
||||
|
||||
[worker]
|
||||
max_tokens = 100
|
||||
|
||||
[[scope.allow]]
|
||||
target = "./"
|
||||
permission = "write"
|
||||
"#;
|
||||
|
||||
/// Serialises tests that mutate runtime-dir env vars, mirroring the
|
||||
/// pattern used by other integration tests in this crate.
|
||||
static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_from_manifest_rejects_unknown_session() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
let store_tmp = tempfile::tempdir().unwrap();
|
||||
let store = FsStore::new(store_tmp.path()).await.unwrap();
|
||||
let manifest = pod::PodManifest::from_toml(MINIMAL_MANIFEST_TOML).unwrap();
|
||||
|
||||
// A freshly-minted id with no jsonl file at all → store returns
|
||||
// NotFound, which `Pod::restore_from_manifest` surfaces verbatim
|
||||
// as `PodError::Store`.
|
||||
let unknown = session_store::new_session_id();
|
||||
let result = Pod::restore_from_manifest(
|
||||
unknown,
|
||||
manifest,
|
||||
store,
|
||||
pod::PromptLoader::builtins_only(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Err(PodError::Store(StoreError::NotFound(id))) => assert_eq!(id, unknown),
|
||||
Err(other) => panic!("expected Store(NotFound), got {other:?}"),
|
||||
Ok(_) => panic!("expected unknown session to fail"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_from_manifest_rejects_empty_session_log() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
let store_tmp = tempfile::tempdir().unwrap();
|
||||
let store = FsStore::new(store_tmp.path()).await.unwrap();
|
||||
let manifest = pod::PodManifest::from_toml(MINIMAL_MANIFEST_TOML).unwrap();
|
||||
|
||||
// Pre-create an empty `<id>.jsonl` so `read_all` succeeds with no
|
||||
// entries. `collect_state` returns `head_hash = None`, which
|
||||
// `restore_from_manifest` rejects with `SessionEmpty` *before* it
|
||||
// gets as far as building the LLM client — so the test does not
|
||||
// need credentials or a runtime sandbox.
|
||||
let id: SessionId = session_store::new_session_id();
|
||||
let path = store_tmp.path().join(format!("{id}.jsonl"));
|
||||
std::fs::write(&path, b"").unwrap();
|
||||
|
||||
let result =
|
||||
Pod::restore_from_manifest(id, manifest, store, pod::PromptLoader::builtins_only()).await;
|
||||
|
||||
match result {
|
||||
Err(PodError::SessionEmpty { session_id }) => assert_eq!(session_id, id),
|
||||
Err(other) => panic!("expected SessionEmpty, got {other:?}"),
|
||||
Ok(_) => panic!("expected empty session log to fail"),
|
||||
}
|
||||
}
|
||||
|
|
@ -73,6 +73,7 @@ async fn setup_spawner(
|
|||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
}],
|
||||
session_store::new_session_id(),
|
||||
)
|
||||
.unwrap();
|
||||
// Leak the guard — the spawner allocation needs to outlive the
|
||||
|
|
|
|||
|
|
@ -142,7 +142,12 @@ fn build_from_config(config: &ModelConfig) -> Result<Box<dyn LlmClient>, Provide
|
|||
SchemeKind::OpenaiChat => build_transport(OpenAIScheme::new(), config, resolved),
|
||||
SchemeKind::Gemini => build_transport(GeminiScheme::new(), config, resolved),
|
||||
SchemeKind::OpenaiResponses => {
|
||||
build_transport(OpenAIResponsesScheme::new(), config, resolved)
|
||||
// ChatGPT backend (codex-oauth) は `max_output_tokens` を
|
||||
// 400 で弾くため、その経路では送出を止める。
|
||||
let scheme = OpenAIResponsesScheme::new().with_send_max_output_tokens(
|
||||
!matches!(config.auth, AuthRef::CodexOAuth),
|
||||
);
|
||||
build_transport(scheme, config, resolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
crates/scope-lock/Cargo.toml
Normal file
17
crates/scope-lock/Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "scope-lock"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fs4 = { version = "0.13.1", features = ["sync"] }
|
||||
libc = "0.2.185"
|
||||
manifest = { version = "0.1.0", path = "../manifest" }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
session-store = { version = "0.1.0", path = "../session-store" }
|
||||
thiserror = "2.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.27.0"
|
||||
|
|
@ -21,6 +21,7 @@ use std::path::{Path, PathBuf};
|
|||
use fs4::fs_std::FileExt;
|
||||
use manifest::{Permission, ScopeRule, paths};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use session_store::SessionId;
|
||||
|
||||
/// On-disk representation of the allocation table.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
|
|
@ -50,6 +51,12 @@ pub struct Allocation {
|
|||
/// Name of the Pod that delegated scope to this one, or `None` for
|
||||
/// a top-level Pod started directly by a human.
|
||||
pub delegated_from: Option<String>,
|
||||
/// Session ID this Pod is currently writing to. `None` means this
|
||||
/// is a pre-reservation made by a spawner via [`delegate_scope`]
|
||||
/// before the child has come up; the child fills it in at
|
||||
/// [`adopt_allocation`] time.
|
||||
#[serde(default)]
|
||||
pub session_id: Option<SessionId>,
|
||||
}
|
||||
|
||||
impl LockFile {
|
||||
|
|
@ -60,6 +67,14 @@ impl LockFile {
|
|||
pub fn find_mut(&mut self, pod_name: &str) -> Option<&mut Allocation> {
|
||||
self.allocations.iter_mut().find(|a| a.pod_name == pod_name)
|
||||
}
|
||||
|
||||
/// Find the allocation currently writing to `session_id`. Skips
|
||||
/// pre-reservations whose `session_id` is still `None`.
|
||||
pub fn find_by_session(&self, session_id: SessionId) -> Option<&Allocation> {
|
||||
self.allocations
|
||||
.iter()
|
||||
.find(|a| a.session_id == Some(session_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Default on-disk path: `<runtime_dir>/scope.lock` resolved via
|
||||
|
|
@ -288,17 +303,29 @@ fn find_conflict_in_subtree(
|
|||
/// Register a top-level Pod (started directly by a human, no
|
||||
/// delegation parent). Reclaims stale entries before checking
|
||||
/// conflicts so a crashed Pod's allocation doesn't block the new one.
|
||||
///
|
||||
/// Rejects when another live allocation is already writing to
|
||||
/// `session_id`, so two `restore_from_manifest` calls under different
|
||||
/// `pod_name`s cannot both grab the same session log.
|
||||
pub fn register_pod(
|
||||
guard: &mut LockFileGuard,
|
||||
pod_name: String,
|
||||
pid: u32,
|
||||
socket: PathBuf,
|
||||
scope_allow: Vec<ScopeRule>,
|
||||
session_id: SessionId,
|
||||
) -> Result<(), ScopeLockError> {
|
||||
reclaim_stale(guard);
|
||||
if guard.data().find(&pod_name).is_some() {
|
||||
return Err(ScopeLockError::DuplicatePodName(pod_name));
|
||||
}
|
||||
if let Some(existing) = guard.data().find_by_session(session_id) {
|
||||
return Err(ScopeLockError::SessionConflict {
|
||||
session_id,
|
||||
pod_name: existing.pod_name.clone(),
|
||||
socket: existing.socket.clone(),
|
||||
});
|
||||
}
|
||||
for rule in scope_allow
|
||||
.iter()
|
||||
.filter(|r| r.permission == Permission::Write)
|
||||
|
|
@ -316,6 +343,7 @@ pub fn register_pod(
|
|||
socket,
|
||||
scope_allow,
|
||||
delegated_from: None,
|
||||
session_id: Some(session_id),
|
||||
});
|
||||
guard.save()?;
|
||||
Ok(())
|
||||
|
|
@ -361,6 +389,9 @@ pub fn delegate_scope(
|
|||
socket,
|
||||
scope_allow,
|
||||
delegated_from: Some(spawner.into()),
|
||||
// Pre-reservation. The child fills in its own session_id when
|
||||
// it calls `adopt_allocation` after the worker is built.
|
||||
session_id: None,
|
||||
});
|
||||
guard.save()?;
|
||||
Ok(())
|
||||
|
|
@ -483,10 +514,18 @@ pub fn install_top_level(
|
|||
pid: u32,
|
||||
socket: PathBuf,
|
||||
scope_allow: Vec<ScopeRule>,
|
||||
session_id: SessionId,
|
||||
) -> Result<ScopeAllocationGuard, ScopeLockError> {
|
||||
let lock_path = default_lock_path()?;
|
||||
let mut guard = LockFileGuard::open(&lock_path)?;
|
||||
register_pod(&mut guard, pod_name.clone(), pid, socket, scope_allow)?;
|
||||
register_pod(
|
||||
&mut guard,
|
||||
pod_name.clone(),
|
||||
pid,
|
||||
socket,
|
||||
scope_allow,
|
||||
session_id,
|
||||
)?;
|
||||
Ok(ScopeAllocationGuard {
|
||||
pod_name,
|
||||
lock_path,
|
||||
|
|
@ -497,13 +536,15 @@ pub fn install_top_level(
|
|||
/// a spawning Pod.
|
||||
///
|
||||
/// The spawning flow is two-stage: the spawner calls [`delegate_scope`]
|
||||
/// (with its own pid as a live placeholder), then exec's the child; the
|
||||
/// child, once running, calls this function to rewrite the allocation's
|
||||
/// pid to its own and claim the `ScopeAllocationGuard` so the entry is
|
||||
/// released when the child exits.
|
||||
/// (with its own pid as a live placeholder, `session_id = None`), then
|
||||
/// exec's the child; the child, once running, calls this function to
|
||||
/// rewrite the allocation's pid + session_id to its own and claim the
|
||||
/// `ScopeAllocationGuard` so the entry is released when the child
|
||||
/// exits.
|
||||
pub fn adopt_allocation(
|
||||
pod_name: String,
|
||||
new_pid: u32,
|
||||
session_id: SessionId,
|
||||
) -> Result<ScopeAllocationGuard, ScopeLockError> {
|
||||
let lock_path = default_lock_path()?;
|
||||
let mut guard = LockFileGuard::open(&lock_path)?;
|
||||
|
|
@ -512,6 +553,7 @@ pub fn adopt_allocation(
|
|||
.find_mut(&pod_name)
|
||||
.ok_or_else(|| ScopeLockError::UnknownPod(pod_name.clone()))?;
|
||||
alloc.pid = new_pid;
|
||||
alloc.session_id = Some(session_id);
|
||||
guard.save()?;
|
||||
Ok(ScopeAllocationGuard {
|
||||
pod_name,
|
||||
|
|
@ -519,6 +561,73 @@ pub fn adopt_allocation(
|
|||
})
|
||||
}
|
||||
|
||||
/// Rewrite the `session_id` recorded for `pod_name` to
|
||||
/// `new_session_id`.
|
||||
///
|
||||
/// The Pod's in-memory `session_id` can change underneath the
|
||||
/// allocation in two normal places:
|
||||
///
|
||||
/// - `Pod::compact` mints a fresh session and swaps it in.
|
||||
/// - `session_store::ensure_head_or_fork` auto-forks when another
|
||||
/// writer has advanced the store head behind our back.
|
||||
///
|
||||
/// Both paths must call this so subsequent `lookup_session` queries
|
||||
/// find the live session id, not the old one. Without this update a
|
||||
/// concurrent `restore_from_manifest(new_id)` would see "no live
|
||||
/// writer" and proceed to register a competing allocation on the
|
||||
/// session this Pod just moved into.
|
||||
///
|
||||
/// The lock is opened once and the allocation is rewritten inside the
|
||||
/// guard, so the session_id collision check is atomic with the
|
||||
/// rewrite.
|
||||
pub fn update_session(pod_name: &str, new_session_id: SessionId) -> Result<(), ScopeLockError> {
|
||||
let lock_path = default_lock_path()?;
|
||||
let mut guard = LockFileGuard::open(&lock_path)?;
|
||||
if let Some(other) = guard.data().find_by_session(new_session_id) {
|
||||
if other.pod_name != pod_name {
|
||||
return Err(ScopeLockError::SessionConflict {
|
||||
session_id: new_session_id,
|
||||
pod_name: other.pod_name.clone(),
|
||||
socket: other.socket.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let alloc = guard
|
||||
.data_mut()
|
||||
.find_mut(pod_name)
|
||||
.ok_or_else(|| ScopeLockError::UnknownPod(pod_name.into()))?;
|
||||
alloc.session_id = Some(new_session_id);
|
||||
guard.save()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Information about a Pod that currently holds an allocation for a
|
||||
/// given session.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionLockInfo {
|
||||
pub pod_name: String,
|
||||
pub socket: PathBuf,
|
||||
pub pid: u32,
|
||||
}
|
||||
|
||||
/// Open the default lock file, reclaim stale entries, and return the
|
||||
/// allocation currently writing to `session_id`, if any.
|
||||
///
|
||||
/// Used by `Pod::restore_from_manifest` to refuse a resume that would
|
||||
/// race a live writer on the same source session.
|
||||
pub fn lookup_session(session_id: SessionId) -> Result<Option<SessionLockInfo>, ScopeLockError> {
|
||||
let lock_path = default_lock_path()?;
|
||||
let mut guard = LockFileGuard::open(&lock_path)?;
|
||||
reclaim_stale(&mut guard);
|
||||
Ok(guard.data().find_by_session(session_id).map(|a| {
|
||||
SessionLockInfo {
|
||||
pod_name: a.pod_name.clone(),
|
||||
socket: a.socket.clone(),
|
||||
pid: a.pid,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// Errors raised by the mutating scope-lock operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ScopeLockError {
|
||||
|
|
@ -535,6 +644,15 @@ pub enum ScopeLockError {
|
|||
NotSubset { spawner: String, rule: ScopeRule },
|
||||
#[error("pod `{0}` is not registered")]
|
||||
UnknownPod(String),
|
||||
#[error(
|
||||
"session {session_id} is already held by pod `{pod_name}` at {}",
|
||||
.socket.display()
|
||||
)]
|
||||
SessionConflict {
|
||||
session_id: SessionId,
|
||||
pod_name: String,
|
||||
socket: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -548,6 +666,10 @@ mod tests {
|
|||
/// harness runs tests on multiple threads inside a single process,
|
||||
/// so env-var writes from one test would otherwise leak into a
|
||||
/// parallel test's `default_lock_path()` lookup.
|
||||
fn sid() -> SessionId {
|
||||
session_store::new_session_id()
|
||||
}
|
||||
|
||||
static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
|
||||
|
||||
/// Sandbox `INSOMNIA_RUNTIME_DIR` to a tempdir for the duration of
|
||||
|
|
@ -652,6 +774,7 @@ mod tests {
|
|||
std::process::id(),
|
||||
sock("a"),
|
||||
vec![write_rule("/src", true)],
|
||||
sid(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
|
@ -699,6 +822,7 @@ mod tests {
|
|||
std::process::id(),
|
||||
sock("a"),
|
||||
vec![write_rule("/src", true)],
|
||||
sid(),
|
||||
)
|
||||
.unwrap();
|
||||
let err = register_pod(
|
||||
|
|
@ -707,6 +831,7 @@ mod tests {
|
|||
std::process::id(),
|
||||
sock("b"),
|
||||
vec![write_rule("/src/core", true)],
|
||||
sid(),
|
||||
)
|
||||
.unwrap_err();
|
||||
match err {
|
||||
|
|
@ -726,6 +851,7 @@ mod tests {
|
|||
std::process::id(),
|
||||
sock("a"),
|
||||
vec![write_rule("/src", true)],
|
||||
sid(),
|
||||
)
|
||||
.unwrap();
|
||||
let err = register_pod(
|
||||
|
|
@ -734,6 +860,7 @@ mod tests {
|
|||
std::process::id(),
|
||||
sock("a2"),
|
||||
vec![write_rule("/docs", true)],
|
||||
sid(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ScopeLockError::DuplicatePodName(ref n) if n == "a"));
|
||||
|
|
@ -750,6 +877,7 @@ mod tests {
|
|||
std::process::id(),
|
||||
sock("a"),
|
||||
vec![write_rule("/src", true)],
|
||||
sid(),
|
||||
)
|
||||
.unwrap();
|
||||
let err = delegate_scope(
|
||||
|
|
@ -775,6 +903,7 @@ mod tests {
|
|||
std::process::id(),
|
||||
sock("a"),
|
||||
vec![write_rule("/src", true)],
|
||||
sid(),
|
||||
)
|
||||
.unwrap();
|
||||
delegate_scope(
|
||||
|
|
@ -812,6 +941,7 @@ mod tests {
|
|||
std::process::id(),
|
||||
sock("a"),
|
||||
vec![write_rule("/src", true)],
|
||||
sid(),
|
||||
)
|
||||
.unwrap();
|
||||
delegate_scope(
|
||||
|
|
@ -848,6 +978,7 @@ mod tests {
|
|||
std::process::id(),
|
||||
sock("a"),
|
||||
vec![write_rule("/src", true)],
|
||||
sid(),
|
||||
)
|
||||
.unwrap();
|
||||
delegate_scope(
|
||||
|
|
@ -886,6 +1017,7 @@ mod tests {
|
|||
std::process::id(),
|
||||
sock("a"),
|
||||
vec![write_rule("/src", true)],
|
||||
sid(),
|
||||
)
|
||||
.unwrap();
|
||||
delegate_scope(
|
||||
|
|
@ -939,6 +1071,7 @@ mod tests {
|
|||
std::process::id(),
|
||||
sock("a"),
|
||||
vec![write_rule("/src", true)],
|
||||
sid(),
|
||||
)
|
||||
.unwrap();
|
||||
// B only reads under the same tree — allowed.
|
||||
|
|
@ -948,6 +1081,7 @@ mod tests {
|
|||
std::process::id(),
|
||||
sock("b"),
|
||||
vec![read_rule("/src", true)],
|
||||
sid(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(g.data().allocations.len(), 2);
|
||||
|
|
@ -964,6 +1098,7 @@ mod tests {
|
|||
std::process::id(),
|
||||
sock("a"),
|
||||
vec![write_rule("/src", true)],
|
||||
sid(),
|
||||
)
|
||||
.unwrap();
|
||||
release_pod(&mut g, "a").unwrap();
|
||||
|
|
@ -973,6 +1108,7 @@ mod tests {
|
|||
std::process::id(),
|
||||
sock("b"),
|
||||
vec![write_rule("/src", true)],
|
||||
sid(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
|
@ -988,6 +1124,7 @@ mod tests {
|
|||
std::process::id(),
|
||||
sock("a"),
|
||||
vec![write_rule("/src", true)],
|
||||
sid(),
|
||||
)
|
||||
.unwrap();
|
||||
delegate_scope(
|
||||
|
|
@ -1023,6 +1160,7 @@ mod tests {
|
|||
std::process::id(),
|
||||
sock("a"),
|
||||
vec![write_rule("/src", true)],
|
||||
sid(),
|
||||
)
|
||||
.unwrap();
|
||||
{
|
||||
|
|
@ -1047,7 +1185,7 @@ mod tests {
|
|||
delegate_placeholder(&mut g, "child", std::process::id());
|
||||
}
|
||||
let child_pid = std::process::id().wrapping_add(1);
|
||||
let guard = adopt_allocation("child".into(), child_pid).unwrap();
|
||||
let guard = adopt_allocation("child".into(), child_pid, sid()).unwrap();
|
||||
{
|
||||
let g = LockFileGuard::open(&lock_path).unwrap();
|
||||
let alloc = g.data().find("child").unwrap();
|
||||
|
|
@ -1064,7 +1202,7 @@ mod tests {
|
|||
fn adopt_allocation_errors_on_unknown_pod() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let _sandbox = RuntimeDirSandbox::new(dir.path());
|
||||
let err = adopt_allocation("ghost".into(), 42).unwrap_err();
|
||||
let err = adopt_allocation("ghost".into(), 42, sid()).unwrap_err();
|
||||
assert!(matches!(err, ScopeLockError::UnknownPod(ref n) if n == "ghost"));
|
||||
}
|
||||
|
||||
|
|
@ -1078,6 +1216,7 @@ mod tests {
|
|||
socket: sock(pod_name),
|
||||
scope_allow: vec![write_rule("/tmp/child", true)],
|
||||
delegated_from: None,
|
||||
session_id: None,
|
||||
});
|
||||
g.save().unwrap();
|
||||
}
|
||||
|
|
@ -1093,6 +1232,7 @@ mod tests {
|
|||
std::process::id(),
|
||||
sock("a"),
|
||||
vec![write_rule("/src", true)],
|
||||
sid(),
|
||||
)
|
||||
.unwrap();
|
||||
delegate_scope(
|
||||
|
|
@ -1112,6 +1252,7 @@ mod tests {
|
|||
std::process::id(),
|
||||
sock("x"),
|
||||
vec![write_rule("/src/core/x", true)],
|
||||
sid(),
|
||||
)
|
||||
.unwrap_err();
|
||||
match err {
|
||||
|
|
@ -1119,4 +1260,164 @@ mod tests {
|
|||
other => panic!("expected WriteConflict, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_by_session_skips_none_placeholders() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("scope.lock");
|
||||
let mut g = open_empty(&path);
|
||||
// Pre-reservation: delegate_scope leaves session_id = None
|
||||
// until adopt_allocation rewrites it. find_by_session must not
|
||||
// match those placeholders, otherwise a freshly-spawning child
|
||||
// would shadow itself before it has even chosen a session.
|
||||
register_pod(
|
||||
&mut g,
|
||||
"parent".into(),
|
||||
std::process::id(),
|
||||
sock("parent"),
|
||||
vec![write_rule("/p", true)],
|
||||
sid(),
|
||||
)
|
||||
.unwrap();
|
||||
delegate_scope(
|
||||
&mut g,
|
||||
"parent",
|
||||
"child".into(),
|
||||
std::process::id(),
|
||||
sock("child"),
|
||||
vec![write_rule("/p/sub", true)],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let target_session = sid();
|
||||
// The placeholder allocation has session_id = None and must
|
||||
// not be returned for any lookup.
|
||||
assert!(g.data().find_by_session(target_session).is_none());
|
||||
|
||||
// After adopt-style rewrite, the same allocation is now found.
|
||||
g.data_mut()
|
||||
.find_mut("child")
|
||||
.unwrap()
|
||||
.session_id = Some(target_session);
|
||||
let found = g.data().find_by_session(target_session).unwrap();
|
||||
assert_eq!(found.pod_name, "child");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_pod_rejects_session_id_collision() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("scope.lock");
|
||||
let mut g = open_empty(&path);
|
||||
let shared_session = sid();
|
||||
register_pod(
|
||||
&mut g,
|
||||
"first".into(),
|
||||
std::process::id(),
|
||||
sock("first"),
|
||||
vec![write_rule("/work/a", true)],
|
||||
shared_session,
|
||||
)
|
||||
.unwrap();
|
||||
// Second registration tries to grab the same session_id under
|
||||
// a different pod_name. Without the SessionConflict check both
|
||||
// would succeed and race on the same jsonl.
|
||||
let err = register_pod(
|
||||
&mut g,
|
||||
"second".into(),
|
||||
std::process::id(),
|
||||
sock("second"),
|
||||
vec![write_rule("/work/b", true)],
|
||||
shared_session,
|
||||
)
|
||||
.unwrap_err();
|
||||
match err {
|
||||
ScopeLockError::SessionConflict {
|
||||
session_id,
|
||||
pod_name,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(session_id, shared_session);
|
||||
assert_eq!(pod_name, "first");
|
||||
}
|
||||
other => panic!("expected SessionConflict, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_session_returns_live_writer_info() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let _sandbox = RuntimeDirSandbox::new(dir.path());
|
||||
let s = sid();
|
||||
let guard = install_top_level(
|
||||
"live".into(),
|
||||
std::process::id(),
|
||||
sock("live"),
|
||||
vec![write_rule("/work", true)],
|
||||
s,
|
||||
)
|
||||
.unwrap();
|
||||
let info = lookup_session(s).unwrap().expect("expected live writer");
|
||||
assert_eq!(info.pod_name, "live");
|
||||
assert_eq!(info.socket, sock("live"));
|
||||
drop(guard);
|
||||
// After the guard's release, the lookup goes back to None.
|
||||
assert!(lookup_session(s).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_session_rewrites_allocation_session_id() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let _sandbox = RuntimeDirSandbox::new(dir.path());
|
||||
let original = sid();
|
||||
let updated = sid();
|
||||
let _guard = install_top_level(
|
||||
"p".into(),
|
||||
std::process::id(),
|
||||
sock("p"),
|
||||
vec![write_rule("/work", true)],
|
||||
original,
|
||||
)
|
||||
.unwrap();
|
||||
update_session("p", updated).unwrap();
|
||||
// lookup against the original is now empty, the updated id wins.
|
||||
assert!(lookup_session(original).unwrap().is_none());
|
||||
assert_eq!(lookup_session(updated).unwrap().unwrap().pod_name, "p");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_session_rejects_when_target_already_held() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let _sandbox = RuntimeDirSandbox::new(dir.path());
|
||||
let s_a = sid();
|
||||
let s_b = sid();
|
||||
let _g_a = install_top_level(
|
||||
"a".into(),
|
||||
std::process::id(),
|
||||
sock("a"),
|
||||
vec![write_rule("/work/a", true)],
|
||||
s_a,
|
||||
)
|
||||
.unwrap();
|
||||
let _g_b = install_top_level(
|
||||
"b".into(),
|
||||
std::process::id(),
|
||||
sock("b"),
|
||||
vec![write_rule("/work/b", true)],
|
||||
s_b,
|
||||
)
|
||||
.unwrap();
|
||||
// `a` cannot adopt b's live session id.
|
||||
let err = update_session("a", s_b).unwrap_err();
|
||||
match err {
|
||||
ScopeLockError::SessionConflict {
|
||||
pod_name,
|
||||
session_id,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(pod_name, "b");
|
||||
assert_eq!(session_id, s_b);
|
||||
}
|
||||
other => panic!("expected SessionConflict, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@ pub use session::{
|
|||
save_run_completed, save_run_errored, save_turn_end, save_usage,
|
||||
};
|
||||
pub use llm_worker::UsageRecord;
|
||||
pub use llm_worker::llm_client::types::{ContentPart, Item, Role};
|
||||
pub use session_log::{
|
||||
EntryHash, HashedEntry, LogEntry, RestoredState, SessionOrigin, build_chain, collect_state,
|
||||
compute_hash,
|
||||
|
|
|
|||
|
|
@ -14,3 +14,5 @@ unicode-width = "0.2.2"
|
|||
uuid = "1.23"
|
||||
toml = "1.1.2"
|
||||
manifest = { version = "0.1.0", path = "../manifest" }
|
||||
session-store = { version = "0.1.0", path = "../session-store" }
|
||||
scope-lock = { version = "0.1.0", path = "../scope-lock" }
|
||||
|
|
|
|||
|
|
@ -131,6 +131,9 @@ impl App {
|
|||
}
|
||||
Event::ThinkingDone { text } => {
|
||||
if let Some(b) = self.last_streaming_thinking_mut() {
|
||||
// Delta-accumulated text wins. `text` here is the
|
||||
// Done payload (full body), used only as a fallback
|
||||
// for providers that don't stream deltas.
|
||||
if b.text.is_empty() {
|
||||
b.text = text;
|
||||
}
|
||||
|
|
@ -325,6 +328,11 @@ impl App {
|
|||
}
|
||||
|
||||
fn mark_orphan_thinking_incomplete(&mut self) {
|
||||
// A turn can carry several thinking blocks; we walk all the way
|
||||
// to `TurnHeader` and convert every still-Streaming one rather
|
||||
// than breaking on the first Finished hit (which is what the
|
||||
// tool-call equivalent does, since tool calls finalize in
|
||||
// submission order).
|
||||
for b in self.blocks.iter_mut().rev() {
|
||||
match b {
|
||||
Block::Thinking(t) => {
|
||||
|
|
|
|||
|
|
@ -45,9 +45,10 @@ pub struct ThinkingBlock {
|
|||
}
|
||||
|
||||
pub enum ThinkingState {
|
||||
/// Live block: actively streaming. `started_at` is `None` only for
|
||||
/// blocks materialised from `Event::History`, which never enter the
|
||||
/// streaming state.
|
||||
/// Live block: actively streaming. `started_at` powers the
|
||||
/// `Thinking... (Xs)` live timer. History-restored blocks never
|
||||
/// enter this state — they materialise as `Finished { elapsed_secs:
|
||||
/// None }` since the original duration is not persisted.
|
||||
Streaming { started_at: Instant },
|
||||
/// Block ended cleanly with `ThinkingDone`.
|
||||
Finished { elapsed_secs: Option<u64> },
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ mod block;
|
|||
mod cache;
|
||||
mod client;
|
||||
mod input;
|
||||
mod picker;
|
||||
mod scroll;
|
||||
mod spawn;
|
||||
mod tool;
|
||||
|
|
@ -24,9 +25,11 @@ use crossterm::terminal::{
|
|||
use protocol::Method;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use session_store::SessionId;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::client::PodClient;
|
||||
use crate::picker::PickerOutcome;
|
||||
use crate::spawn::{SpawnOutcome, SpawnReady};
|
||||
|
||||
fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
|
||||
|
|
@ -47,27 +50,101 @@ enum Mode {
|
|||
pod_name: String,
|
||||
socket_override: Option<PathBuf>,
|
||||
},
|
||||
/// `tui -r` / `tui --resume`: open the session picker first, then
|
||||
/// run the same name dialog as Spawn but in resume mode.
|
||||
Resume,
|
||||
/// `tui --session <UUID>`: skip the picker, go straight to the
|
||||
/// resume name dialog with `id` baked in.
|
||||
ResumeWithSession(SessionId),
|
||||
}
|
||||
|
||||
fn parse_args() -> Mode {
|
||||
enum ParseError {
|
||||
Conflict,
|
||||
InvalidSession(String),
|
||||
MissingValue(&'static str),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ParseError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Conflict => write!(f, "--resume and --session are mutually exclusive"),
|
||||
Self::InvalidSession(s) => write!(f, "invalid --session UUID: {s}"),
|
||||
Self::MissingValue(flag) => write!(f, "{flag} requires a value"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Mode, ParseError> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.is_empty() {
|
||||
return Mode::Spawn;
|
||||
let mut resume = false;
|
||||
let mut session: Option<SessionId> = None;
|
||||
let mut socket_override: Option<PathBuf> = None;
|
||||
let mut positional: Option<String> = None;
|
||||
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"-r" | "--resume" => {
|
||||
resume = true;
|
||||
i += 1;
|
||||
}
|
||||
"--session" => {
|
||||
let raw = args
|
||||
.get(i + 1)
|
||||
.ok_or(ParseError::MissingValue("--session"))?;
|
||||
session = Some(
|
||||
raw.parse::<SessionId>()
|
||||
.map_err(|_| ParseError::InvalidSession(raw.clone()))?,
|
||||
);
|
||||
i += 2;
|
||||
}
|
||||
"--socket" => {
|
||||
let raw = args
|
||||
.get(i + 1)
|
||||
.ok_or(ParseError::MissingValue("--socket"))?;
|
||||
socket_override = Some(PathBuf::from(raw));
|
||||
i += 2;
|
||||
}
|
||||
other if positional.is_none() && !other.starts_with('-') => {
|
||||
positional = Some(other.to_string());
|
||||
i += 1;
|
||||
}
|
||||
_ => {
|
||||
// Unknown flag or extra positional — keep older
|
||||
// behaviour of ignoring unknowns rather than aborting.
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let pod_name = args[0].clone();
|
||||
let socket_override = args
|
||||
.windows(2)
|
||||
.find(|w| w[0] == "--socket")
|
||||
.map(|w| PathBuf::from(&w[1]));
|
||||
Mode::Attach {
|
||||
pod_name,
|
||||
socket_override,
|
||||
|
||||
if resume && session.is_some() {
|
||||
return Err(ParseError::Conflict);
|
||||
}
|
||||
|
||||
if let Some(id) = session {
|
||||
return Ok(Mode::ResumeWithSession(id));
|
||||
}
|
||||
if resume {
|
||||
return Ok(Mode::Resume);
|
||||
}
|
||||
if let Some(pod_name) = positional {
|
||||
return Ok(Mode::Attach {
|
||||
pod_name,
|
||||
socket_override,
|
||||
});
|
||||
}
|
||||
Ok(Mode::Spawn)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
let mode = parse_args();
|
||||
let mode = match parse_args() {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
eprintln!("tui: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = enable_raw_mode() {
|
||||
eprintln!("tui: failed to enter raw mode: {e}");
|
||||
|
|
@ -80,11 +157,13 @@ async fn main() -> ExitCode {
|
|||
}
|
||||
|
||||
let result = match mode {
|
||||
Mode::Spawn => run_spawn().await,
|
||||
Mode::Spawn => run_spawn(None).await,
|
||||
Mode::Attach {
|
||||
pod_name,
|
||||
socket_override,
|
||||
} => run_attach(pod_name, socket_override).await,
|
||||
Mode::Resume => run_resume().await,
|
||||
Mode::ResumeWithSession(id) => run_spawn(Some(id)).await,
|
||||
};
|
||||
|
||||
// Always restore the terminal first so any pending eprintln below
|
||||
|
|
@ -120,8 +199,19 @@ async fn run_attach(
|
|||
run(&mut terminal, pod_name, &socket_path, false).await
|
||||
}
|
||||
|
||||
async fn run_spawn() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let ready = match spawn::run().await? {
|
||||
async fn run_resume() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Phase 1: pick a session in its own inline viewport, dropping the
|
||||
// viewport before the name dialog opens so each phase gets fresh
|
||||
// vertical room.
|
||||
let id = match picker::run().await? {
|
||||
PickerOutcome::Picked(id) => id,
|
||||
PickerOutcome::Cancelled => return Ok(()),
|
||||
};
|
||||
run_spawn(Some(id)).await
|
||||
}
|
||||
|
||||
async fn run_spawn(resume_from: Option<SessionId>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let ready = match spawn::run(resume_from).await? {
|
||||
SpawnOutcome::Ready(r) => r,
|
||||
SpawnOutcome::Cancelled => return Ok(()),
|
||||
};
|
||||
|
|
|
|||
327
crates/tui/src/picker.rs
Normal file
327
crates/tui/src/picker.rs
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
//! Inline-viewport "pick a session to restore" UX.
|
||||
//!
|
||||
//! Reads the most recent sessions from the configured store, lets the
|
||||
//! user pick one with the arrow keys, and returns the chosen
|
||||
//! `SessionId`. Closes its inline viewport before returning so the
|
||||
//! caller can open a fresh viewport for the name dialog.
|
||||
//!
|
||||
//! The picker only handles selection. Forking, scope-lock checks, and
|
||||
//! actual `pod` launch happen later in the resume flow.
|
||||
|
||||
use std::io;
|
||||
use std::time::Duration;
|
||||
|
||||
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Constraint, Layout};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::{Frame, TerminalOptions, Viewport};
|
||||
use scope_lock::lookup_session;
|
||||
use session_store::{
|
||||
ContentPart, FsStore, HashedEntry, Item, LogEntry, SessionId, Store,
|
||||
};
|
||||
|
||||
const MAX_ROWS: usize = 10;
|
||||
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PickerError {
|
||||
Io(io::Error),
|
||||
Store(session_store::StoreError),
|
||||
NoSessions,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PickerError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Io(e) => write!(f, "io error: {e}"),
|
||||
Self::Store(e) => write!(f, "session store error: {e}"),
|
||||
Self::NoSessions => write!(
|
||||
f,
|
||||
"no sessions found — start a fresh pod with `tui` and try again"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PickerError {}
|
||||
|
||||
impl From<io::Error> for PickerError {
|
||||
fn from(e: io::Error) -> Self {
|
||||
Self::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<session_store::StoreError> for PickerError {
|
||||
fn from(e: session_store::StoreError) -> Self {
|
||||
Self::Store(e)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum PickerOutcome {
|
||||
Picked(SessionId),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// One row in the picker view. Rendered from the session log so the
|
||||
/// user can recognise their session at a glance without parsing UUIDs.
|
||||
struct Row {
|
||||
id: SessionId,
|
||||
/// Last user / assistant snippet, or a `[corrupt]` placeholder.
|
||||
preview: String,
|
||||
/// `Some(pod_name)` when a live Pod currently holds an allocation
|
||||
/// for this session in `scope.lock`. Picking such a row launches
|
||||
/// `pod --session <UUID>` which will fail with `SessionConflict` —
|
||||
/// the badge warns the user up-front.
|
||||
live_pod: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn run() -> Result<PickerOutcome, PickerError> {
|
||||
let store = open_default_store().await?;
|
||||
let ids = store.list_sessions().await?;
|
||||
if ids.is_empty() {
|
||||
return Err(PickerError::NoSessions);
|
||||
}
|
||||
let mut rows: Vec<Row> = Vec::with_capacity(MAX_ROWS);
|
||||
for id in ids.into_iter().take(MAX_ROWS) {
|
||||
let preview = build_preview(&store, id).await;
|
||||
// Best-effort live check. A scope.lock I/O hiccup downgrades
|
||||
// the row to "no badge" rather than killing the picker — the
|
||||
// user still gets to see the listing.
|
||||
let live_pod = lookup_session(id).ok().flatten().map(|info| info.pod_name);
|
||||
rows.push(Row {
|
||||
id,
|
||||
preview,
|
||||
live_pod,
|
||||
});
|
||||
}
|
||||
|
||||
let mut selected = 0usize;
|
||||
let mut terminal = make_inline_terminal()?;
|
||||
loop {
|
||||
terminal.draw(|f| draw(f, &rows, selected))?;
|
||||
match poll_event()? {
|
||||
None => continue,
|
||||
Some(Action::Up) => {
|
||||
if selected > 0 {
|
||||
selected -= 1;
|
||||
}
|
||||
}
|
||||
Some(Action::Down) => {
|
||||
if selected + 1 < rows.len() {
|
||||
selected += 1;
|
||||
}
|
||||
}
|
||||
Some(Action::Submit) => {
|
||||
close_viewport(&mut terminal)?;
|
||||
return Ok(PickerOutcome::Picked(rows[selected].id));
|
||||
}
|
||||
Some(Action::Cancel) => {
|
||||
close_viewport(&mut terminal)?;
|
||||
return Ok(PickerOutcome::Cancelled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Park the cursor at the very bottom of the picker's inline viewport
|
||||
/// and emit one newline before dropping the terminal. Without this the
|
||||
/// inline area is left with the cursor still inside it, so the next
|
||||
/// `Terminal::with_options(Inline(_))` call (the resume name dialog)
|
||||
/// computes its own area starting from inside the picker — drawing the
|
||||
/// new dialog on top of the lower picker rows.
|
||||
///
|
||||
/// Setting the cursor to `area.bottom() - 1` and writing `\r\n`
|
||||
/// scrolls the terminal up exactly one row, so the next inline
|
||||
/// viewport opens immediately below the picker rather than on top of
|
||||
/// it.
|
||||
fn close_viewport(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
) -> io::Result<()> {
|
||||
let area = terminal.get_frame().area();
|
||||
let last_row = area.bottom().saturating_sub(1);
|
||||
terminal.set_cursor_position((0, last_row))?;
|
||||
use std::io::Write;
|
||||
let mut out = io::stdout();
|
||||
out.write_all(b"\r\n")?;
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn open_default_store() -> Result<FsStore, PickerError> {
|
||||
let dir = manifest::paths::sessions_dir().ok_or_else(|| {
|
||||
PickerError::Io(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"could not resolve sessions directory \
|
||||
(set INSOMNIA_HOME, INSOMNIA_DATA_DIR, or HOME)",
|
||||
))
|
||||
})?;
|
||||
Ok(FsStore::new(&dir).await?)
|
||||
}
|
||||
|
||||
async fn build_preview(store: &FsStore, id: SessionId) -> String {
|
||||
match store.read_all(id).await {
|
||||
Ok(entries) => last_message_preview(&entries).unwrap_or_else(|| "[empty]".to_string()),
|
||||
Err(_) => "[corrupt]".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk the log from the tail looking for the most recent user-message
|
||||
/// or assistant-message entry, then render its first text fragment in
|
||||
/// a single line.
|
||||
fn last_message_preview(entries: &[HashedEntry]) -> Option<String> {
|
||||
for hashed in entries.iter().rev() {
|
||||
match &hashed.entry {
|
||||
LogEntry::UserInput { item, .. } => {
|
||||
if let Some(text) = first_text(item) {
|
||||
return Some(format!("user: {}", trim_one_line(&text, 60)));
|
||||
}
|
||||
}
|
||||
LogEntry::AssistantItems { items, .. } => {
|
||||
if let Some(text) = items.iter().find_map(first_text) {
|
||||
return Some(format!("assistant: {}", trim_one_line(&text, 60)));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn first_text(item: &Item) -> Option<String> {
|
||||
match item {
|
||||
Item::Message { content, .. } => content.iter().find_map(|p| match p {
|
||||
ContentPart::Text { text } => Some(text.clone()),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_one_line(s: &str, max_chars: usize) -> String {
|
||||
let collapsed: String = s.chars().map(|c| if c == '\n' { ' ' } else { c }).collect();
|
||||
if collapsed.chars().count() <= max_chars {
|
||||
collapsed
|
||||
} else {
|
||||
let truncated: String = collapsed.chars().take(max_chars - 1).collect();
|
||||
format!("{truncated}…")
|
||||
}
|
||||
}
|
||||
|
||||
fn make_inline_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
|
||||
let backend = CrosstermBackend::new(io::stdout());
|
||||
Terminal::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Inline(VIEWPORT_LINES),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
enum Action {
|
||||
Up,
|
||||
Down,
|
||||
Submit,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
fn poll_event() -> io::Result<Option<Action>> {
|
||||
if !event::poll(Duration::from_millis(100))? {
|
||||
return Ok(None);
|
||||
}
|
||||
match event::read()? {
|
||||
TermEvent::Key(k) if k.kind != KeyEventKind::Release => {
|
||||
let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
|
||||
Ok(match k.code {
|
||||
KeyCode::Up => Some(Action::Up),
|
||||
KeyCode::Down => Some(Action::Down),
|
||||
KeyCode::Char('k') if !ctrl => Some(Action::Up),
|
||||
KeyCode::Char('j') if !ctrl => Some(Action::Down),
|
||||
KeyCode::Enter => Some(Action::Submit),
|
||||
KeyCode::Esc => Some(Action::Cancel),
|
||||
KeyCode::Char('c') if ctrl => Some(Action::Cancel),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(f: &mut Frame<'_>, rows: &[Row], selected: usize) {
|
||||
let area = f.area();
|
||||
let mut constraints: Vec<Constraint> =
|
||||
Vec::with_capacity(rows.len() + 3);
|
||||
constraints.push(Constraint::Length(1)); // title
|
||||
for _ in rows {
|
||||
constraints.push(Constraint::Length(1));
|
||||
}
|
||||
constraints.push(Constraint::Length(1)); // hint
|
||||
constraints.push(Constraint::Length(1)); // spacer
|
||||
let layout = Layout::vertical(constraints).split(area);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
"resume pod pick a session",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
)])),
|
||||
layout[0],
|
||||
);
|
||||
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
f.render_widget(
|
||||
Paragraph::new(row_line(row, i == selected)),
|
||||
layout[i + 1],
|
||||
);
|
||||
}
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled("[↑/↓]", Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(" select "),
|
||||
Span::styled("[enter]", Style::default().fg(Color::Green)),
|
||||
Span::raw(" pick "),
|
||||
Span::styled("[esc]", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" cancel"),
|
||||
])),
|
||||
layout[rows.len() + 1],
|
||||
);
|
||||
}
|
||||
|
||||
fn row_line(row: &Row, selected: bool) -> Line<'_> {
|
||||
let marker = if selected { "▶ " } else { " " };
|
||||
let id_style = if selected {
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Cyan)
|
||||
};
|
||||
let preview_style = if selected {
|
||||
Style::default().fg(Color::White)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
let mut spans = vec![
|
||||
Span::raw(marker),
|
||||
Span::styled(short_session(row.id), id_style),
|
||||
Span::raw(" "),
|
||||
];
|
||||
if let Some(ref pod_name) = row.live_pod {
|
||||
spans.push(Span::styled(
|
||||
format!("[live: {pod_name}] "),
|
||||
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
spans.push(Span::styled(row.preview.clone(), preview_style));
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn short_session(id: SessionId) -> String {
|
||||
let s = id.to_string();
|
||||
s.chars().take(8).collect()
|
||||
}
|
||||
|
||||
|
|
@ -20,6 +20,7 @@ use std::time::Duration;
|
|||
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use manifest::{PodManifestConfig, find_project_manifest_from, load_layer, user_manifest_path};
|
||||
use ratatui::Terminal;
|
||||
use session_store::SessionId;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Constraint, Layout};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
|
|
@ -85,7 +86,10 @@ impl From<io::Error> for SpawnError {
|
|||
|
||||
type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
|
||||
|
||||
pub async fn run() -> Result<SpawnOutcome, SpawnError> {
|
||||
/// Source session for a resume run. `None` = fresh spawn (current
|
||||
/// behaviour); `Some(id)` swaps the dialog into "Resume Pod" mode and
|
||||
/// passes `--session <id>` to the spawned `pod` child.
|
||||
pub async fn run(resume_from: Option<SessionId>) -> Result<SpawnOutcome, SpawnError> {
|
||||
let cwd = std::env::current_dir().map_err(SpawnError::Io)?;
|
||||
|
||||
// Run the same merge pod itself uses, then read what's missing
|
||||
|
|
@ -135,6 +139,7 @@ pub async fn run() -> Result<SpawnOutcome, SpawnError> {
|
|||
name: default_name,
|
||||
message: None,
|
||||
editing: true,
|
||||
resume_from,
|
||||
};
|
||||
|
||||
let mut terminal = make_inline_terminal()?;
|
||||
|
|
@ -266,16 +271,19 @@ async fn wait_for_ready(
|
|||
let pod_bin = resolve_pod_command();
|
||||
let cwd = std::env::current_dir().map_err(SpawnError::Io)?;
|
||||
|
||||
let mut child = Command::new(&pod_bin)
|
||||
let mut command = Command::new(&pod_bin);
|
||||
command
|
||||
.arg("--overlay")
|
||||
.arg(overlay_toml)
|
||||
.current_dir(&cwd)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.map_err(SpawnError::PodLaunchFailed)?;
|
||||
.kill_on_drop(true);
|
||||
if let Some(id) = form.resume_from {
|
||||
command.arg("--session").arg(id.to_string());
|
||||
}
|
||||
let mut child = command.spawn().map_err(SpawnError::PodLaunchFailed)?;
|
||||
|
||||
let stderr = child
|
||||
.stderr
|
||||
|
|
@ -437,6 +445,11 @@ struct Form {
|
|||
/// cursor stays out so it does not collide with the shell prompt
|
||||
/// after the inline terminal is dropped.
|
||||
editing: bool,
|
||||
/// `Some(id)` flips the dialog into "Resume Pod" mode: the title
|
||||
/// switches, the source session is shown to the user, and the
|
||||
/// child pod is launched with `--session <id>` so it restores
|
||||
/// from `id` and appends to the same session log.
|
||||
resume_from: Option<SessionId>,
|
||||
}
|
||||
|
||||
impl Form {
|
||||
|
|
@ -500,8 +513,12 @@ fn draw_form(f: &mut Frame<'_>, form: &Form) {
|
|||
])
|
||||
.split(area);
|
||||
|
||||
let title_text = match form.resume_from {
|
||||
Some(id) => format!("resume pod session: {}", short_session(id)),
|
||||
None => "spawn pod".to_string(),
|
||||
};
|
||||
let title = Paragraph::new(Line::from(vec![Span::styled(
|
||||
"spawn pod",
|
||||
title_text,
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
)]));
|
||||
f.render_widget(title, layout[0]);
|
||||
|
|
@ -523,6 +540,13 @@ fn draw_form(f: &mut Frame<'_>, form: &Form) {
|
|||
}
|
||||
}
|
||||
|
||||
/// First 8 hex digits of a UUID — short enough to skim, long enough
|
||||
/// to disambiguate inside a 10-row picker.
|
||||
pub(crate) fn short_session(id: SessionId) -> String {
|
||||
let s = id.to_string();
|
||||
s.chars().take(8).collect()
|
||||
}
|
||||
|
||||
fn name_line(form: &Form) -> Line<'_> {
|
||||
Line::from(vec![
|
||||
Span::raw(" "),
|
||||
|
|
@ -600,6 +624,7 @@ mod tests {
|
|||
name_cursor: name.chars().count(),
|
||||
message: None,
|
||||
editing: true,
|
||||
resume_from: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ scheme 側が吸収する。
|
|||
| key | 型 | 既定 | 内容 |
|
||||
|---|---|---|---|
|
||||
| `instruction` | `String` | `$insomnia/default` | システムプロンプト本体として使う prompt asset 参照 |
|
||||
| `max_tokens` | `u32` | 未指定 | 1 request の最大出力 token。scheme が provider の該当 wire field に投影 |
|
||||
| `max_tokens` | `u32` | 未指定 | 1 request の最大出力 token。scheme が provider の該当 wire field に投影。scheme ごとのセマンティクス差は `docs/reasoning.md` |
|
||||
| `max_turns` | `NonZeroU32` | 未指定 | 1 run 内で Worker が進められる最大 turn 数 |
|
||||
| `temperature` | `f32` | 未指定 | sampling temperature |
|
||||
| `top_p` | `f32` | 未指定 | nucleus sampling |
|
||||
|
|
|
|||
|
|
@ -67,6 +67,21 @@ ref = "gemini/gemini-2.5-pro"
|
|||
reasoning = -1
|
||||
```
|
||||
|
||||
## `max_tokens` との関係
|
||||
|
||||
`[worker] max_tokens` は scheme ごとに wire field 名も意味論も異なる。reasoning モデルで併用するときは特に注意:
|
||||
|
||||
| Provider / scheme | wire field | `max_tokens` の意味 |
|
||||
|---|---|---|
|
||||
| OpenAI Chat (`openai_chat`) | `max_completion_tokens`(Ollama 互換は legacy `max_tokens`) | reasoning tokens を **含む** 合計上限 |
|
||||
| OpenAI Responses (`openai_responses`) | `max_output_tokens` | reasoning tokens を **含む** 合計上限 |
|
||||
| Anthropic (`anthropic`) | `max_tokens`(必須) | thinking tokens を **含む** 合計上限 |
|
||||
| Gemini (`gemini`) | `generationConfig.maxOutputTokens` | visible のみ。thinking tokens は **別計上** |
|
||||
|
||||
OpenAI / Anthropic で `max_tokens` を小さく取りつつ高 effort / 大 budget の reasoning を立てると、reasoning に枠を食われて visible output が空で返ることがある。Gemini は別計上なのでこの事故は起きない。
|
||||
|
||||
codex-oauth (ChatGPT backend) 経路では `max_output_tokens` が `Unsupported parameter` で 400 を返すため、`openai_responses` scheme は `send_max_output_tokens=false` で wire に載せない。manifest に `max_tokens` を書いても黙って落ちるが、scheme の `validate_config` が `ConfigWarning` を返すので worker 起動時に通知される。
|
||||
|
||||
## 範囲外
|
||||
|
||||
- UI プリセット(Low / Medium / High → 各 provider 値)の変換テーブル
|
||||
|
|
|
|||
44
docs/research/anthropic_max_tokens.md
Normal file
44
docs/research/anthropic_max_tokens.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# Anthropic Messages API: `max_tokens` パラメータ仕様
|
||||
|
||||
Source: https://platform.claude.com/docs/en/api/messages
|
||||
Retrieved: 2026-04-28
|
||||
|
||||
---
|
||||
|
||||
## 1. パラメータ名
|
||||
|
||||
`max_tokens`
|
||||
|
||||
`max_output_tokens` ではない。
|
||||
|
||||
## 2. 必須か任意か
|
||||
|
||||
**必須 (required)**。
|
||||
|
||||
`POST /v1/messages` のボディパラメータとして必須指定。
|
||||
insomnia の現在の実装(`max_tokens: u32`、未指定時 4096 にフォールバック)は仕様と合致している。
|
||||
|
||||
## 3. 型・範囲
|
||||
|
||||
- 型: integer (number)
|
||||
- 意味: 生成を停止する前に出力できるトークンの上限。モデルはこの値に達する前に停止することもある(上限の指定であり、保証値ではない)
|
||||
- モデルごとに最大値が異なる:
|
||||
- Claude Opus 4.6 / 4.7: 最大 128k トークン
|
||||
- Claude Sonnet 4.6 / Haiku 4.5: 最大 64k トークン
|
||||
- Message Batches API + beta ヘッダ `output-300k-2026-03-24`: 最大 300k トークン(Opus 4.7, 4.6, Sonnet 4.6)
|
||||
|
||||
## 4. Extended Thinking との組み合わせ制約
|
||||
|
||||
Source: https://platform.claude.com/docs/en/build-with-claude/extended-thinking
|
||||
|
||||
- `thinking.budget_tokens` は必ず `max_tokens` **未満** でなければならない
|
||||
- thinking トークンは `max_tokens` の上限に含まれてカウントされる
|
||||
- `budget_tokens` の最小値は **1,024 トークン**
|
||||
- 例外: ツールを伴う interleaved thinking では `budget_tokens` が `max_tokens` を超えることが許容される(予算がコンテキストウィンドウ全体に対して適用されるため)
|
||||
- `max_tokens` が 21,333 を超える場合はストリーミングが必須
|
||||
- Claude Opus 4.6 / Sonnet 4.6 以降では `budget_tokens` は非推奨になり、代わりに `effort` パラメータによる adaptive thinking が推奨されている
|
||||
|
||||
## 5. ドキュメント URL
|
||||
|
||||
- Messages API リファレンス: https://platform.claude.com/docs/en/api/messages
|
||||
- Extended Thinking ガイド: https://platform.claude.com/docs/en/build-with-claude/extended-thinking
|
||||
45
docs/research/gemini_max_output_tokens.md
Normal file
45
docs/research/gemini_max_output_tokens.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Google Gemini API: `maxOutputTokens` パラメータ仕様
|
||||
|
||||
Source: https://ai.google.dev/api/generate-content
|
||||
Source (thinking): https://ai.google.dev/gemini-api/docs/thinking
|
||||
Source (Gemini 2.5 Flash): https://ai.google.dev/gemini-api/docs/models/gemini-2.5-flash
|
||||
Source (Gemini 2.5 Pro): https://ai.google.dev/gemini-api/docs/models/gemini-2.5-pro
|
||||
Retrieved: 2026-04-28
|
||||
|
||||
---
|
||||
|
||||
## 1. パラメータ名と位置
|
||||
|
||||
`generationConfig.maxOutputTokens`
|
||||
|
||||
リクエストボディのトップレベルではなく、`generationConfig` オブジェクト内に配置する。
|
||||
SDK では `GenerateContentConfig(max_output_tokens=...)` として渡す。
|
||||
|
||||
## 2. 必須 / 任意
|
||||
|
||||
**任意 (optional)**。省略時はモデルのデフォルト上限が適用される。
|
||||
|
||||
## 3. 型と範囲
|
||||
|
||||
- 型: `integer`
|
||||
- モデル別の最大値:
|
||||
- `gemini-2.5-flash`: 最大 **65,536** トークン
|
||||
- `gemini-2.5-pro`: 最大 **65,536** トークン
|
||||
- 最小値の公式明記はないが、正の整数を指定する。
|
||||
|
||||
## 4. thinking トークンとの関係
|
||||
|
||||
- `maxOutputTokens` が制限するのは**最終レスポンスの出力トークン数のみ**。thinking トークンは `usageMetadata.thoughtsTokenCount` として別途計上され、`maxOutputTokens` のカウントには含まれない。
|
||||
- thinking トークンの制御には `generationConfig.thinkingConfig.thinkingBudget` を用いる。
|
||||
- Gemini 2.5 Flash / Pro: `128`〜`32768` トークン、`0` で thinking 無効化(モデルによる)、`-1` で動的
|
||||
- 課金は「output tokens + thinking tokens」の合算。
|
||||
- `maxOutputTokens` と `thinkingBudget` は独立したパラメータであり、両方を同時に指定できる。
|
||||
|
||||
> **注意**: 2025年10月時点で `gemini-2.5-flash` において `max_output_tokens` が無視されるバグが報告されており、Google 側が修正をロールアウトした経緯がある。最新モデルで想定通りに機能するか実測で確認することを推奨。
|
||||
|
||||
## 5. ドキュメント URL
|
||||
|
||||
- API リファレンス (GenerationConfig): https://ai.google.dev/api/generate-content#v1beta.GenerationConfig
|
||||
- Thinking ガイド: https://ai.google.dev/gemini-api/docs/thinking
|
||||
- Gemini 2.5 Flash モデル仕様: https://ai.google.dev/gemini-api/docs/models/gemini-2.5-flash
|
||||
- Gemini 2.5 Pro モデル仕様: https://ai.google.dev/gemini-api/docs/models/gemini-2.5-pro
|
||||
51
docs/research/openai_chat_max_tokens.md
Normal file
51
docs/research/openai_chat_max_tokens.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# OpenAI Chat Completions API — 出力トークン数制御パラメータ仕様
|
||||
|
||||
- **Source**: https://platform.openai.com/docs/api-reference/chat/create
|
||||
- **Supplementary**: https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/reasoning
|
||||
- **Retrieved**: 2026-04-28
|
||||
|
||||
---
|
||||
|
||||
## 1. `max_tokens` と `max_completion_tokens` の関係
|
||||
|
||||
| パラメータ | 状態 | 対応モデル |
|
||||
|---|---|---|
|
||||
| `max_tokens` | **Deprecated** | GPT-3.5, GPT-4 系など旧モデルでは動作する |
|
||||
| `max_completion_tokens` | 現行・推奨 | 全モデル(旧モデルにも後方互換あり) |
|
||||
|
||||
- `max_tokens` は o1 系以降では **受け付けられない**(エラーまたは無視)。
|
||||
- 変更の背景: 旧来の `max_tokens` は「返却トークン = 生成トークン = 課金トークン」を前提にしていた。o1 系で推論トークン(reasoning tokens)が導入されたことでこの前提が崩れ、新パラメータが設計された。
|
||||
- `max_completion_tokens` は旧モデルでも機能するため、**新規実装では `max_completion_tokens` を使うべき**。
|
||||
|
||||
## 2. 必須か任意か
|
||||
|
||||
- **任意(optional)**。
|
||||
- 指定しない場合はモデルのコンテキスト上限まで生成する(デフォルト: `null`)。
|
||||
|
||||
## 3. 型と範囲
|
||||
|
||||
- **型**: `integer | null`
|
||||
- **範囲**: `1` 以上、モデルのコンテキストウィンドウの残りトークン数以下。上限値はモデルごとに異なり、ドキュメント上に固定の最大値は明示されていない。
|
||||
- `null` を渡すと制限なし(モデル上限に従う)。
|
||||
|
||||
## 4. Reasoning モデルでの reasoning tokens のカウント
|
||||
|
||||
- `max_completion_tokens` の上限には **reasoning tokens(推論トークン)を含む**。
|
||||
- reasoning tokens: モデルが内部で生成するが API レスポンスには含まれない隠しトークン。
|
||||
- 課金対象は reasoning tokens + visible output tokens の合計。
|
||||
- レスポンスの `usage.completion_tokens_details.reasoning_tokens` で内訳を確認できる。
|
||||
- したがって、`max_completion_tokens = 5000` と設定しても、推論に多くのトークンを使った場合、目に見える出力は 5000 より少なくなる。
|
||||
|
||||
## 5. Ollama の OpenAI compat API での扱い(補助情報)
|
||||
|
||||
- Ollama の `/v1/chat/completions` は現時点で **`max_tokens` のみを公式サポート**している(内部的に `num_predict` にマッピング)。
|
||||
- `max_completion_tokens` サポートは Issue #7125 / PR #14464 で議論中だが、2026-04-28 時点では公式ドキュメント上に記載なし。
|
||||
- **Ollama に対しては `max_tokens` を使う**のが安全な選択。ただし将来的に `max_completion_tokens` に移行される見込み。
|
||||
|
||||
## 6. ドキュメント URL
|
||||
|
||||
- [OpenAI Chat Completions API Reference](https://platform.openai.com/docs/api-reference/chat/create)
|
||||
- [Azure OpenAI Reasoning Models (GPT-5, o3, o1)](https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/reasoning)
|
||||
- [Ollama OpenAI Compatibility](https://docs.ollama.com/api/openai-compatibility)
|
||||
- [Ollama Issue #7125 — max_completion_tokens support](https://github.com/ollama/ollama/issues/7125)
|
||||
- [OpenAI Community — Why max_tokens changed to max_completion_tokens](https://community.openai.com/t/why-was-max-tokens-changed-to-max-completion-tokens/938077)
|
||||
60
docs/research/openai_responses_max_output_tokens.md
Normal file
60
docs/research/openai_responses_max_output_tokens.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# OpenAI Responses API — `max_output_tokens` Parameter
|
||||
|
||||
- **Source**: https://platform.openai.com/docs/api-reference/responses/create
|
||||
- **Retrieved**: 2026-04-28
|
||||
|
||||
---
|
||||
|
||||
## 1. パラメータ名
|
||||
|
||||
`max_output_tokens` — 正しい。Chat Completions API の `max_tokens` / `max_completion_tokens` とは別物。
|
||||
|
||||
## 2. 必須 / 任意
|
||||
|
||||
**任意 (optional)**。省略時のデフォルトは `inf`(モデルが許容する最大出力トークン数)。
|
||||
|
||||
## 3. 型と範囲
|
||||
|
||||
| 項目 | 値 |
|
||||
|---|---|
|
||||
| 型 | `integer` または文字列 `"inf"` |
|
||||
| 最小値 | `1` |
|
||||
| 最大値 | モデルごとの最大出力トークン数(例: gpt-4.1 系は 32,768) |
|
||||
| デフォルト | `inf` |
|
||||
|
||||
上限に達した場合、レスポンスの `status` が `"incomplete"` になり、`incomplete_details.reason` が `"max_output_tokens"` にセットされる。
|
||||
|
||||
## 4. Reasoning tokens との関係 / Reasoning モデルとの組合せ制約
|
||||
|
||||
`max_output_tokens` は **reasoning tokens を含む** 合計生成トークン数の上限として機能する。
|
||||
公式ガイド (https://platform.openai.com/docs/guides/reasoning) には以下の記述がある:
|
||||
|
||||
> "You can limit the total number of tokens the model generates (including both reasoning and final output tokens) by using the max_output_tokens parameter."
|
||||
|
||||
**実用上の注意点:**
|
||||
- モデルが内部思考に多数の reasoning tokens を消費した後に上限に達すると、visible output が一切返らずに打ち切られる場合がある。
|
||||
- コスト制御目的には `reasoning.effort` (`"low"` など) の使用が推奨される。`max_output_tokens` はあくまで暴走抑止のガードとして位置づける。
|
||||
- o シリーズなど reasoning モデルでは `reasoning.max_tokens` (別パラメータ) で reasoning 専用の上限を設定できる場合もある。
|
||||
|
||||
## 5. ChatGPT backend (`https://chatgpt.com/backend-api/codex/responses`) における取り扱い
|
||||
|
||||
このエンドポイントは公式 Responses API のサブセットのみをサポートする非公式 backend であり、`max_output_tokens` を **サポートしないパラメータとして 400 エラーで拒否する**。
|
||||
|
||||
LiteLLM の調査 (https://github.com/BerriAI/litellm/issues/21193) によれば、ChatGPT Codex backend が受け付けるパラメータは以下に限られる:
|
||||
|
||||
```
|
||||
model, input, instructions, stream, store, include,
|
||||
tools, tool_choice, reasoning, previous_response_id, truncation
|
||||
```
|
||||
|
||||
`max_output_tokens`, `max_tokens`, `max_completion_tokens`, `temperature`, `user`, `metadata`, `context_management` はすべて拒否される。
|
||||
|
||||
Codex CLI 自身も `config.toml` の `model_max_output_tokens` を API リクエストに載せない実装になっており (https://github.com/openai/codex/issues/4138)、これはバグではなく ChatGPT backend の制約に対する回避策と解釈できる。
|
||||
|
||||
## 6. ドキュメント URL
|
||||
|
||||
- 公式 API リファレンス: https://platform.openai.com/docs/api-reference/responses/create
|
||||
- Reasoning ガイド: https://platform.openai.com/docs/guides/reasoning
|
||||
- Codex CLI issue (max_output_tokens 未送信): https://github.com/openai/codex/issues/4138
|
||||
- LiteLLM issue (ChatGPT backend 拒否パラメータ一覧): https://github.com/BerriAI/litellm/issues/21193
|
||||
- OpenAI Community (reasoning tokens 上限): https://community.openai.com/t/limiting-maximum-number-of-reasoning-tokens/1285430
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
You are here as an agent of the "insomnia system".
|
||||
|
||||
Stay precise, edit code directly when asked, and avoid speculative refactoring.
|
||||
|
||||
{% include "common/workspace" %}
|
||||
|
||||
{% include "common/tool-usage" %}
|
||||
|
|
|
|||
|
|
@ -1,78 +0,0 @@
|
|||
# メモリ機構: Phase 1 活動抽出
|
||||
|
||||
## 背景
|
||||
|
||||
`docs/plan/memory.md` §Phase 1 の実装。activity tokens の累積閾値で発火し、前回 Phase 1 以降の session log 範囲から「起きたこと」を 4 種の活動ログ候補として抽出、`memory/_staging/<id>.json` に書き出す。Knowledge 化や summary rewrite は Phase 2 に委ねる。
|
||||
|
||||
Pod を立てずに既存 compact と同じ Worker spawn 機構を再利用する。raw session log は `session-store` で保持されており、ここから range を切り出して入力に使う。
|
||||
|
||||
## 要件
|
||||
|
||||
### Trigger
|
||||
|
||||
- activity tokens 累積閾値(設定ファイルで tune)。input tokens cumulative since last pointer を使う
|
||||
- tool call カウントは不採用(ツールカスタマイズ非依存・大小重みづけのため)
|
||||
- 発火点は Pod の post-run hook で、**compact より前** に走らせる(compact は history を組み替えるため、extract の入力範囲を安定させたい)
|
||||
|
||||
### 実行主体と入出力
|
||||
|
||||
- 既存 compact の Worker spawn 機構を再利用、Pod は立てない
|
||||
- 入力: 前回 Phase 1 以降の session log 範囲
|
||||
- 出力 JSON schema: `decisions`, `discussions`, `attempts`, `requests` の候補配列。抽出対象なしは空配列
|
||||
- 出力に自由文の補足説明を入れさせない(schema 準拠のみ)
|
||||
|
||||
### 処理境界の pointer 永続化
|
||||
|
||||
- pointer は session log に書き、寿命を session と揃える
|
||||
- session-store のドメイン純度を保つため、汎用拡張点 `LogEntry::Extension { domain: String, payload: serde_json::Value }` を **本チケットで session-store に新設**し、`domain = "memory.extract"` で payload に `{ processed_through_entry: usize, staging_id: String }` を載せる
|
||||
- `RestoredState` には `extensions: Vec<(String, serde_json::Value)>` 形で raw 集積し、memory crate 側が `domain` で fold して最新 pointer を取り出す(session-store は memory のことを知らない)
|
||||
|
||||
### 並走防止 (Phase 1 同士)
|
||||
|
||||
- Pod 上の `extract_in_flight: AtomicBool` で in-flight 中の新規 trigger を skip
|
||||
- 完了時点で閾値再評価し、超過していれば直ちに次回を発火(新 pointer 以降の最大範囲を回収)
|
||||
- pending 状態は別途保持しない(完了時の再評価で coalesce 相当が自然に成立)
|
||||
- Phase 2 の進行状況ファイルとは別物(こちらは別チケット範囲外)
|
||||
|
||||
### 書き込み
|
||||
|
||||
- 書き込み先: `memory/_staging/<id>.json`(1 件 1 ファイル、UUIDv7 可)
|
||||
- pod 側ラッパーが `source: { session_id, range: [start_entry, end_entry] }` を**機械付与**して LLM 出力と wrap
|
||||
- LLM に source を推論させない
|
||||
|
||||
### モデル
|
||||
|
||||
- 設定 key `memory.extract_model`(軽量だが文脈理解できる中堅クラス想定)
|
||||
- 副次設定: `memory.extract_threshold`(input tokens 累積閾値、未設定で disable)、`memory.extract_worker_max_input_tokens`(extract worker 自身の input cap)
|
||||
|
||||
### prompt
|
||||
|
||||
- prompt 要件は `docs/plan/memory-prompts.md` §Phase 1: 活動抽出 prompt に従う
|
||||
|
||||
## 範囲外
|
||||
|
||||
- Phase 2 による staging の消費・クリーンアップ(別チケット)
|
||||
- staging の cleanup 戦略の詳細(Phase 2 で完了時に消す、実行中追加分は残す、という契約だけ本チケットで守る)
|
||||
- compact Worker spawn 機構自体の拡張(既存をそのまま使う。共通化が必要になったら別途)
|
||||
- Phase 2 並走防止ファイル(別チケット)
|
||||
|
||||
## 完了条件
|
||||
|
||||
- Pod 稼働中に閾値超過で Phase 1 が発火し、`memory/_staging/<id>.json` にファイルができる
|
||||
- ファイルは schema に準拠、`source` が機械付与されている
|
||||
- 抽出対象なしのときは空配列として書き出される(または発火そのものを skip、どちらでもよい)
|
||||
- session 側の処理済み pointer が更新され、次回 Phase 1 は続きから走る
|
||||
- 既存 compact の動作に回帰がない
|
||||
|
||||
## 参照
|
||||
|
||||
- `docs/plan/memory.md` §Phase 1: 活動抽出 / §ファイル形式(staging)
|
||||
- `docs/plan/memory-prompts.md` §共通原則 / §Phase 1: 活動抽出 prompt
|
||||
- 既存 `session-store` クレート(session log range 取得)
|
||||
- 既存 compact の Worker spawn 経路
|
||||
|
||||
## Review
|
||||
|
||||
- 状態: Request changes → 対応済み (2026-04-28、Blocking fix + 回帰テスト追加)
|
||||
- レビュー詳細: [./memory-phase1-extract.review.md](./memory-phase1-extract.review.md)
|
||||
- 日付: 2026-04-28
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
# Review: メモリ機構 Phase 1 活動抽出
|
||||
|
||||
## 前提・要件の確認
|
||||
|
||||
- **Trigger (input tokens 累積閾値、tool call カウント不採用)**: `Pod::cumulative_input_tokens_since` (`crates/pod/src/pod.rs:1254`) で `usage_history` の `input_total_tokens` を集計し、`memory.extract_threshold` 未設定時は no-op (`pod.rs:1280`)。tool call は不参照で要件適合。閾値の単位については後述の Non-blocking 参照
|
||||
- **Trigger (compact より前)**: Controller の post-run ブロック 4 箇所すべてで `try_post_run_extract` → `try_post_run_compact` の順に呼ばれる (`crates/pod/src/controller.rs:335,393,448,545`)。要件どおり
|
||||
- **実行主体 (compact と同じ Worker spawn 機構を再利用、Pod は立てない)**: `run_extract_once` が `llm_worker::Worker` を直接組んで `system_prompt`/`temperature`/`max_tokens`/usage callback/interceptor を貼る構成 (`pod.rs:1378-1404`)。compact 経路 (`pod.rs:1046-1077`) と同じ素の Worker spawn パターン。Pod は立てていない
|
||||
- **入出力 (前回 Phase 1 以降の session log 範囲)**: `processed_through_history_len..current_history_len` を `Worker::history()` から切り出し (`pod.rs:1369`)、`source.range = [start_entry, end_entry]` は session-store の entry index で機械付与 (`pod.rs:1417-1420`)
|
||||
- **出力 schema (4 種候補配列、空配列許容、自由文不可)**: `ExtractedPayload` (`crates/memory/src/extract/payload.rs:13`) が `decisions/discussions/attempts/requests` を保持し全 default 空。EXTRACT_SYSTEM_PROMPT (`crates/memory/src/extract/prompt.rs:7`) は schema 遵守と空配列許容、自由文禁止を明示。`write_extracted` ツール 1 本のみ提供で「ツールで提出して終わる」枠組みに閉じている
|
||||
- **pointer 永続化 (session-store 拡張点 `LogEntry::Extension`)**: `LogEntry::Extension { ts, domain, payload }` を新設 (`crates/session-store/src/session_log.rs:175`)、`RestoredState.extensions: Vec<(String, Value)>` を追加 (`session_log.rs:223`)、`save_extension` ヘルパー (`crates/session-store/src/session.rs:333`) を export。session-store 側に `"memory.extract"` 定数も `payload` 構造の知識も持たせていない (replay は順番に積むだけ)。fold 責務は `memory::extract::fold_pointer` (`crates/memory/src/extract/pointer.rs:26`) に閉じている。ドメイン純度の要件を満たす
|
||||
- **並走防止 (`extract_in_flight: AtomicBool`、完了時再評価ループ)**: `Pod::extract_in_flight: Arc<AtomicBool>` (`pod.rs:131`) を `compare_exchange` でガード (`pod.rs:1287`)、完了時は `loop` で再評価 (`pod.rs:1289-1314`)。`pending` 状態は持たず、完了時に閾値が落ちていれば自然に Skipped で抜けるため coalesce 相当が成立。要件どおり
|
||||
- **書き込み (`memory/_staging/<id>.json`、source 機械付与、UUIDv7 可)**: `write_staging` (`crates/memory/src/extract/staging.rs:40`) が UUIDv7 ファイル名で `StagingRecord { source, payload }` を pretty JSON 書き出し。`source` は Pod 側で `session_id` + `range` を付与し、LLM には推論させない (system prompt にも明示)
|
||||
- **モデル設定 (`memory.extract_model` + 副次設定)**: `MemoryConfig` に `extract_model` / `extract_threshold` / `extract_worker_max_input_tokens` の 3 つを追加 (`crates/manifest/src/lib.rs:75-86`)、cascade merge も対称 (`crates/manifest/src/config.rs:215-219`)、`extract_worker_max_input_tokens` の default 定数も追加 (`crates/manifest/src/defaults.rs:50`)。`extract_model` 未設定時は main client の `clone_boxed()` にフォールバック (`pod.rs:1238-1247`)
|
||||
- **prompt 要件 (`docs/plan/memory-prompts.md` §Phase 1)**: `EXTRACT_SYSTEM_PROMPT` は §共通原則 (source 推論禁止、空出力許容) と §Phase 1 (派生物作成禁止、4 種限定、自由文禁止、shallow 除外) を網羅。`#共通原則` の「既存 docs と重複保存しない」も "Do not duplicate content already captured by static project docs" として反映済み
|
||||
|
||||
## 完了条件の確認
|
||||
|
||||
- 閾値超過で発火し staging file 作成: `run_extract_once` の最後で `write_staging` → pointer save。空 payload でも pointer は前進し、staging 書き込みのみ skip という分岐が pointer.rs:19 のコメントと整合
|
||||
- schema 準拠 + `source` 機械付与: `WriteExtractedTool` が `from_str::<ExtractedPayload>` で受け、Pod 側で `SourceRef` を wrap (`pod.rs:1417-1422`)。LLM 側に source は渡らない
|
||||
- 抽出対象なしは空配列 / skip どちらでも可: 現実装は空配列でも skip (空時は staging file を作らず、pointer だけ前進)。仕様上どちらでも良いとある通り
|
||||
- session 側 pointer 更新で続きから走る: `save_extension` で `EXTRACT_DOMAIN` 永続化 + 同期して in-memory `extract_pointer` 更新 (`pod.rs:1442-1445`)。restore 時は `fold_pointer` で最新値を取り出す (`pod.rs:254`)
|
||||
- 既存 compact の動作に回帰なし: pod 141 / session-store 16 / memory 82 / manifest 75 全 pass、compact のテストは無改変
|
||||
|
||||
## アーキテクチャ・スコープ
|
||||
|
||||
- **session-store ドメイン純度**: `LogEntry::Extension` は domain 文字列を不透明に扱い、payload は `serde_json::Value` のまま積むだけ。`"memory.extract"` 定数も `ExtractPointerPayload` の構造も memory crate に閉じている。session-store 単体テスト (`session_log.rs:644-700`) も memory ドメインを知らない汎用テストになっており、設計通りの分離が取れている
|
||||
- **memory crate 内のモジュール分割**: `extract/{mod,input,payload,pointer,prompt,staging,tool}.rs` で関心ごとに 1 ファイルずつ切れている。`mod.rs` の re-export と doc が綺麗。`pod.rs` に直書きせず memory 側に責務が寄っており、好ましい
|
||||
- **Pod の責務**: trigger 判定・worker spawn・source 機械付与・pointer 永続化のみ。`memory::extract::*` を呼ぶだけで自分は schema/prompt/tool を知らない。範囲外(Phase 2 / staging cleanup / compact spawn 機構の共通化 / Phase 2 並走防止ファイル)には手を出していない
|
||||
- **compact との並列性**: `MemoryExtractWorkerInterceptor` は `CompactWorkerInterceptor` のミラー実装。共通化していないが、ticket §範囲外で「compact Worker spawn 機構自体の拡張」を明確に除外しているので OK。ただし将来 3 個目が出るなら共通化候補
|
||||
- **manifest 設定の cascade**: `MemoryConfig::merge_with_upper` に追加 3 フィールド全てを忘れず追記済み (`config.rs:215-219`)、defaults.rs に定数化、lib.rs に `Option` の意図 doc を追加とパターン遵守
|
||||
- **依存追加**: `memory/Cargo.toml` に `uuid = { version = "1.23.1", features = ["v7", "serde"] }` 追加。バージョンとフォーマットは `cargo add` の出力体裁
|
||||
|
||||
## 指摘事項
|
||||
|
||||
### Blocking
|
||||
|
||||
- **Compact 後に extract_pointer が陳腐化して Phase 1 が止まる** — `crates/pod/src/pod.rs:1180-1216` の `compact()` は `session_id` を新しいコンパクト後セッションへ swap し `usage_history` を `clear()` しているが、`extract_pointer` (in-memory) は古いセッションの `processed_through_history_len`(典型的には 50+)を保持したまま。次回以降 `cumulative_input_tokens_since(processed_history_len)` の filter `r.history_len > history_len_pointer` は新しいセッションの低い `history_len` をすべて除外し、永久に 0 を返す。結果としてプロセス継続中に compact が走ると、その後 Phase 1 は二度と発火しない。fix は `compact()` の swap 直後(`usage_history.clear()` の隣)で `*self.extract_pointer.lock() = None` を行うのが最小。新セッション側の log には Extension entry がまだ無いので、cold restore でも `fold_pointer = None` になっており、cold restore と挙動を一致させられる
|
||||
- **対応済み (2026-04-28)**: `Pod::compact()` (`crates/pod/src/pod.rs:1217-1226`) で `usage_history.clear()` の直後に `*self.extract_pointer.lock() = None` を実行。意図と cold restore 一致を doc コメントに明記。
|
||||
- **完了条件「session 側の処理済み pointer が更新され、次回 Phase 1 は続きから走る」が compact を挟むと崩れる** — 上記 Blocking の直接の帰結。テストでは現れていないが、compact + extract の組合せシナリオを想定したテストがあれば検出できる
|
||||
- **対応済み (2026-04-28)**: `Pod::extract_pointer() -> Option<ExtractPointerPayload>` accessor を追加し、回帰テスト `compact_resets_extract_pointer_so_phase1_can_fire_again` (`crates/pod/tests/compact_events_test.rs:319-381`) を追加。extract → compact の連続シナリオで pointer が None に戻ることを検証。
|
||||
|
||||
### Non-blocking / Follow-up
|
||||
|
||||
- **閾値の単位(cumulative `input_total_tokens` の解釈)** — `UsageRecord.input_total_tokens` は「そのリクエスト送信時のプロンプト全長」(`session-store/src/session_log.rs:147-149`) で増分ではない。pointer 以降の records を素直に sum すると、長い 1 turn 内の連続 LLM call では各 prompt が前 prompt の super-set なので合計は実トークン消費の数倍に膨らむ。ticket / 設計の「cumulative input tokens since last pointer」をどう取るかは複数解釈あるが、現実装は最もリベラル(=最も発火しやすい)解釈になっており、頻繁発火を許容する仕様前提と整合はする。今後 threshold の運用値を tune するときに「3 turn で hit する」程度の感覚と乖離するので、doc に "billed input cumulative" と明記するか、`input_total_tokens - cache_read` の差分版や、最後の record だけを見る (= 現プロンプト長) など別解釈に切り替えるか、いずれ運用観察で決める
|
||||
- **対応済み (2026-04-28)**: `Pod::tokens_added_since(history_len_pointer)` を `Pod::total_tokens_at(now) - Pod::total_tokens_at(pointer)` の差分計算に切り替え (`crates/pod/src/pod.rs:1284-1294`)。compact 側と同じ accounting (measured/interpolated/extrapolated) に乗るので、threshold 値は「pointer 以降に追加されたプロンプト全長」と素直に解釈できる。`Pod::total_tokens_at(history_len)` を `compact::token_counter` に追加 (`pub` API、将来別チケットで llm-worker に下ろす予定)。
|
||||
- **`build_extract_input` が ToolCall 引数と ToolResult 本文を落とす** — `crates/memory/src/extract/input.rs:40-45`。compact 用 `build_summary_prompt` のミラーだが、Phase 1 の `attempts` 抽出は「何を試したか」が tool 引数(書き換えた箇所、実行コマンド)に集中することが多く、tool 名だけだと "ran read_file" レベルの粒度になる。tool 結果 summary は残るので致命ではないが、Phase 1 prompt の `attempts.action` 品質を観察してから decide。共通ヘルパー化したいなら memory crate へ持って行く整理も将来検討
|
||||
- **空 payload で pointer だけ前進する設計の妥当性** — `pod.rs:1414-1424` で `payload.is_empty()` なら staging を作らず pointer だけ進める。spec §完了条件の「空配列で書き出す または skip、どちらでもよい」に合致。一方 `LogEntry::Extension` は payload を増やし続けるので、空 turn が連続すると Extension entry が積み上がる。session 寿命なので Phase 1 の頻度なら許容範囲だが、log size 監視のときに見える要素として把握しておく
|
||||
- **`write_extracted` が呼ばれずに worker が終了したケースの取り扱い** — `pod.rs:1406-1412` で warn を出して空 payload 扱い、pointer は前進。再実行で同 range を再抽出できないので、ここで pointer を前進させる選択は LLM 不安定時に情報を取りこぼす可能性がある一方、無限ループ防止という意味では合理的。仕様上は "skip でも空配列でもよい" なので問題ないが、運用で `write_extracted` 呼び忘れが頻発するなら spec 変更(pointer 据え置きで再 trigger)が選択肢
|
||||
- **Controller 側の `if let Err(e)` 分岐は到達不能** — `try_post_run_extract` は最後に internal `self.alert` した上で常に `Ok(())` を返すので、controller の `if let Err(e) = pod.try_post_run_extract()` は dead branch。`try_post_run_compact` も同様で先例を踏襲しているだけだが、両方とも内部 alert だけに統一するか、controller-only alert にするかの整理は別 PR で
|
||||
|
||||
### Nits
|
||||
|
||||
- `pointer.rs:80-90` の malformed entry テスト: コメント「壊れた最新を黙って無視すると意図しない再抽出を招く」は妥当な保守姿勢。ただし複数 Extension entries がある中で**たまたま最新だけ malformed** の場合は古い良 entry に fallback したほうが安全とも言える。現状で良いが、Phase 2 の stagings 消費と接続するときに再度 visit
|
||||
- `tool.rs:42-48` の `call_count` は debug 用のみで Pod 側からは未使用。残しておくならいずれ logging で警告に繋ぐ
|
||||
- `pod.rs:1339` 直前の comment「`history_len_pointer == 0` means everything so far」は「history_len > 0 のすべての record」という意味で正しいが、説明が `cumulative_input_tokens_since(0)` と読むと若干誤読しやすい。doc に「pointer 0 = 未抽出」と書き換えると親切
|
||||
- `MEMORY_EXTRACT_WORKER_MAX_INPUT_TOKENS = 30_000` の default 値はコメントに根拠を 1 行付記しても良い (compact が 50_000 なので、抽出は context 圧縮後の slice であってより小さくて足りる、という意図か)
|
||||
|
||||
## 判断
|
||||
|
||||
**Request changes → 対応済み (2026-04-28)** — Blocking 指摘事項 (compact 後の extract_pointer リセット漏れ) は `crates/pod/src/pod.rs:1217-1226` で fix し、`crates/pod/tests/compact_events_test.rs:319-381` に回帰テストを追加して再発防止を担保した。workspace 全テスト pass。Non-blocking / Nits は仕様準拠と保守姿勢の範囲で別チケット / 運用観察に委ねる方針で完了とできる。
|
||||
35
tickets/pod-registry-rename.md
Normal file
35
tickets/pod-registry-rename.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# `scope-lock` → `pod-registry` リネーム
|
||||
|
||||
## 背景
|
||||
|
||||
`crates/scope-lock` は元々 `crates/pod/src/runtime/scope_lock.rs` にあった機能を、TUI picker から live セッション判定 (`lookup_session`) のために参照したいという理由で独立クレートに切り出したもの。実体は「マシン全体で生きている Pod の allocation テーブル」であり、scope 衝突判定はその一機能にすぎない(session_id 衝突防止・delegation tree の reparent・stale 回収などは scope と独立に効いている)。
|
||||
|
||||
加えて、永続化先のファイル名 `scope.lock` も実態と合っていない:
|
||||
|
||||
- 中身は `flock(2)` で保護された JSON ステート。`Cargo.lock` のようなバージョン固定ファイルではない
|
||||
- `flock` は read-modify-write のトランザクションを直列化するためだけに保持しており、Pod の生存期間ずっとロックを保持しているわけでもない
|
||||
- 拡張子で `.lock` を名乗っていることで「触るな」という誤った印象を与える
|
||||
|
||||
## ゴール
|
||||
|
||||
クレート名とファイル名を実態に合わせる:
|
||||
|
||||
- crate `scope-lock` → `pod-registry`
|
||||
- ファイル `<runtime_dir>/scope.lock` → `<runtime_dir>/pods.json`
|
||||
- `manifest::paths::scope_lock_path` → `manifest::paths::pod_registry_path`
|
||||
- インポート `scope_lock::...` / `crate::runtime::scope_lock::...` → `pod_registry::...`
|
||||
- `crates/pod/src/runtime/mod.rs` の `pub use ::scope_lock` → `pub use ::pod_registry`
|
||||
- ドキュメンテーション・コメント中の "scope.lock" / "scope-lock registry" の言い換え
|
||||
|
||||
## 範囲外
|
||||
|
||||
- 内部型名のリネーム(`LockFile` / `LockFileGuard` / `ScopeAllocationGuard` / `ScopeLockError` 等)。これらは内部 API でリネームしても挙動は変わらず、必要が生じた時に別途整理する。
|
||||
- `ScopeRule` / `scope_allow` / `delegate_scope` 等、scope そのものを扱うシンボル名は据え置き(クレートが何かではなく、何を扱うかなので)。
|
||||
- 既存の `scope.lock` ファイルが残っている環境での互換性(dev 期間中の互換不要、必要なら手で消す)。
|
||||
|
||||
## 完了条件
|
||||
|
||||
- `crates/pod-registry` として独立クレートが存在し、`pod`/`tui` の依存もこの名前を指している
|
||||
- `<runtime_dir>/pods.json` が新しいレジストリの保存先として使われる
|
||||
- 既存テスト(pod / pod-registry / tui 関連)がすべて緑
|
||||
- ドキュメンテーション・チケット本文中の参照(`tickets/dynamic-scope.md` 等)が新名に揃っている
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
# TUI: 既存セッションからの Pod 復帰
|
||||
|
||||
## 背景
|
||||
|
||||
`session-store` は JSONL ログから Worker 状態を復元でき、Pod 側にも `Pod::restore(session_id, ...)` が存在する。一方で、現在の実行経路は新規 Pod 起動 (`Pod::from_manifest`) と生存中 Pod への attach / `Paused` 状態の `Resume` に限られており、停止済み Pod を既存 `SessionId` から起動するユーザー向け導線がない。
|
||||
|
||||
TUI には既に新規 Pod 起動用の spawn UI があるため、同じような選択 UI で既存 session を一覧し、選択した session を復元した Pod を起動して attach できるようにする。
|
||||
|
||||
## 要件
|
||||
|
||||
### 起動導線
|
||||
|
||||
- TUI の既存挙動は維持する:
|
||||
- `tui`(引数なし): 新規 Pod spawn。現在と同じ name 入力ダイアログから始める
|
||||
- `tui <pod-name>`: 生存中 Pod への attach
|
||||
- 既存 session 復帰用に `tui -r` / `tui --resume` を追加する
|
||||
- `--resume` はユーザー向けの「過去 session から復帰」入口であり、protocol の `Method::Resume`(Paused turn の続行)とは別概念として扱う
|
||||
- `--resume` 指定時のみ、現在の name 入力ダイアログの前段に session 選択プロンプトを表示する
|
||||
- session id を直接指定するショートカットとして `tui --session <UUID>` を追加する
|
||||
- `--session` は session picker をスキップし、指定 session を復元対象にした name 入力ダイアログから始める
|
||||
- `--resume` と `--session` は併用不可
|
||||
- 直接起動用に、Pod CLI に session id を指定して復元起動するフラグを追加する(`pod --session <UUID>`)
|
||||
- TUI の `--resume` / `--session` 復帰フローは最終的にこの Pod CLI 復元起動経路を使う
|
||||
|
||||
### セッション一覧
|
||||
|
||||
- `manifest::paths::sessions_dir()` または既存の `--store` 相当設定で解決される session store を読み、既存 session を新しい順に一覧表示する
|
||||
- 一覧には少なくとも以下を表示する:
|
||||
- `SessionId`
|
||||
- 最終更新時刻、または store が提供できる同等の並び順情報
|
||||
- 履歴の簡易プレビュー(最後の user / assistant メッセージ等、取得できる範囲でよい)
|
||||
- session log が壊れている、復元不能、または現在のバージョンで読めない場合は、その行を復帰不可として表示するか、エラー表示してスキップする
|
||||
- session が 1 件もない場合は、新規 Pod 起動へ戻れる導線を出す
|
||||
|
||||
### 復元 Pod の構築
|
||||
|
||||
- 選択した `SessionId` を使って `Pod::restore` 経由で Pod を構築する
|
||||
- manifest / scope / tool 登録 / prompt loader は、通常の新規 Pod 起動と同じ現在の cascade 解決結果を使う
|
||||
- Worker の会話履歴・system prompt・request config・turn count・usage history 等は session log 由来の状態を使う
|
||||
- 復元起動時、runtime の `history.json` / `status.json` / `Event::History` で TUI が初期履歴を正しく再構築できる
|
||||
- 復元された session が interrupted / paused 相当の状態を持つ場合、起動直後に `Resume` 可能な状態として扱う。通常終了済みなら `Idle` として新しい入力を受け付ける
|
||||
|
||||
### 二重起動の扱い
|
||||
|
||||
- 既に生きている Pod が同じ session を持っている場合は、新規復元起動せず既存 Pod への attach を促す
|
||||
- 少なくとも、同じ session id に対する複数 Pod の同時書き込みが発生しないようにする
|
||||
- runtime dir / status.json から検出できる範囲でよいが、検出不能な場合のエラーメッセージは明示する
|
||||
|
||||
### UI / 操作
|
||||
|
||||
- `tui -r` / `tui --resume` では、name 入力の前に session picker を表示する
|
||||
- session picker は上下キーで session を選択し、Enter で決定、Esc / Ctrl-C でキャンセルできる
|
||||
- session が多い場合でも使えるよう、最低限のスクロールを備える
|
||||
- session 決定後は既存の name 入力ダイアログを再利用する
|
||||
- 入力する name は、復元された session を載せる新しい Pod 実行インスタンス名(runtime dir / socket 名)
|
||||
- default name は現行と同じ cwd 由来でよい
|
||||
- 表示上は `Resume Pod` / `session: <short-id>` のように、新規 spawn ではなく復帰であることを明示する
|
||||
- `tui --session <UUID>` では session picker を省略し、指定 session を対象にした name 入力ダイアログから始める
|
||||
- 将来的な検索フィルタ追加を妨げない state 構造にするが、本チケットでは必須にしない
|
||||
- 復帰に失敗した場合、inline / alt-screen 内にエラーを表示し、一覧へ戻るか終了できる
|
||||
|
||||
## 完了条件
|
||||
|
||||
- `pod --session <UUID>` で既存 session から Pod を起動できる
|
||||
- `tui -r` / `tui --resume` で既存 session 一覧を表示し、選択した session を復元対象にできる
|
||||
- `tui --session <UUID>` で session picker を経由せず、指定 session の復帰 name 入力へ進める
|
||||
- 復帰フローでは session 選択後または `--session` 指定後に name 入力ダイアログが表示され、その name の Pod として起動・attach できる
|
||||
- 復元直後の TUI に過去履歴が表示される
|
||||
- 復元後に新しい入力を送ると、既存履歴に続く turn として動作し、session log に追記される
|
||||
- interrupted / paused 状態の session では、復元直後に Resume 導線が動作する
|
||||
- 同一 session の生存中 Pod がある場合は二重起動せず attach または明示的なエラーになる
|
||||
|
||||
## 範囲外
|
||||
|
||||
- session log の全文検索 UI
|
||||
- compact 前後の session chain を 1 つの論理スレッドとして束ねる UI
|
||||
- 過去 session の編集・削除・名前付け
|
||||
- spawn された子 Pod / scope delegation ツリー全体の復元
|
||||
- 別マシンから転送された session store の import UI
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
# Thinking ブロックの TUI 表示
|
||||
|
||||
## 背景
|
||||
|
||||
Reasoning(extended thinking)系の対応は llm-worker レイヤまで降りている:
|
||||
|
||||
- Anthropic の `thinking`、OpenAI Responses の `reasoning_text` / `reasoning_summary_text`、Gemini の thinking はいずれも `DeltaContent::Thinking(String)` に正規化され、Timeline 層には `ThinkingBlockKind` の Start / Delta / Stop が流れている
|
||||
- history 上は `Item::Reasoning { text, summary, encrypted_content, ... }` として保持され、session-store にも persist されている
|
||||
|
||||
ところが上位層への通り道が無い:
|
||||
|
||||
- `Worker` の closure 公開 API には `on_thinking_block` が無い(`on_text_block` / `on_tool_use_block` のみ)
|
||||
- `protocol::Event` に Thinking 系イベントが無い
|
||||
- pod controller でブリッジしていない
|
||||
- TUI に Thinking 用ブロックが無い
|
||||
|
||||
結果として、provider が thinking 平文を返していても TUI からは「無音で時間が過ぎる」状態になる。実行中であることが見えず、終わった後に「どれくらい考えていたか」も残らない。
|
||||
|
||||
provider ごとに本文を流せるかは異なる:
|
||||
|
||||
- **Anthropic**: extended thinking は平文で流れる
|
||||
- **OpenAI Responses**: モデル / 設定によって本文(`reasoning_text`)が流れない場合がある。`reasoning_summary` は流れることがある
|
||||
- **Gemini**: thinking は流れるが provider 設定依存
|
||||
|
||||
「平文があれば流す。無くても thinking 中であることは見せる」というのが基本方針。
|
||||
|
||||
## 方針
|
||||
|
||||
- llm-worker → protocol → pod controller → TUI に Thinking の通り道を作る
|
||||
- worker は `DeltaContent::Thinking` 由来の本文をそのまま渡す。本文を出さない provider のときは Delta が来ないだけで Start / Done は届く
|
||||
- TUI は実行中とその後の両方を残す:
|
||||
- **実行中**: `Thinking... (Xs)` ヘッダ + 本文があれば直近 1 行のライブ表示
|
||||
- **終了後**: `Thought for Xs` として履歴に残す。`detail` モードでは累積本文を展開
|
||||
- token 数表記は当面入れない(`UsageEvent` に reasoning 分離が無く、別チケットで `Usage` を拡張するまで保留)
|
||||
|
||||
## 要件
|
||||
|
||||
### Worker API
|
||||
|
||||
- `Worker::on_thinking_block(setup)` を `on_text_block` と対称に追加。setup は per-block で 1 回呼ばれ、`block.on_delta(|text|)` / `block.on_stop(|full_text|)` を登録できる
|
||||
|
||||
### Protocol
|
||||
|
||||
- `Event::ThinkingStart`、`Event::ThinkingDelta { text }`、`Event::ThinkingDone { text }` を追加(`text` には完成形を載せる、`TextDone` と同じ流儀)
|
||||
- 本文を返さない provider では Delta が 0 件のまま Start → Done が届く(破綻しない)
|
||||
- 1 turn に複数の thinking block が来る可能性がある(provider 都合)。各ブロックは独立して扱う
|
||||
|
||||
### Pod Controller
|
||||
|
||||
- `worker.on_thinking_block` で上記 3 イベントに変換して `event_tx` に流す
|
||||
|
||||
### TUI
|
||||
|
||||
- 新ブロック種別 `Block::Thinking` を持つ
|
||||
- 実行中は以下のように表示:
|
||||
```
|
||||
Thinking... (10s)
|
||||
<累積本文の末尾を 1 行に切り詰めたもの>
|
||||
```
|
||||
- 終了後は `Thought for 12s` を残す。`detail` モードでは本文をそのまま展開して読める
|
||||
- `overview` モードは 1 行(例: `Thought for 12s`)
|
||||
- 経過時間表示のため、Thinking ブロックが実行中の間は再描画が定期的に走る必要がある(粒度は 1Hz 程度で十分)
|
||||
- ライブ 1 行の選び方は「累積テキストの最後の改行以降を取り、表示幅で切り詰める」を MVP とする
|
||||
- 同一 turn 内の複数 thinking block は別ブロックとして表示される
|
||||
|
||||
### イベント欠落耐性
|
||||
|
||||
- `ThinkingDone` が来ないまま `TurnEnd` が来た場合、対応する `Block::Thinking` は経過時間を凍結した状態で履歴に残す。`ToolCall` 側の `Incomplete` と同じ思想
|
||||
|
||||
### History 再生
|
||||
|
||||
- `Event::History` の `Item::Reasoning { text, ... }` を `Block::Thinking { text, finished: true }` として復元する(経過時間は持たないので `Thought` のみで時間表示は省く)
|
||||
|
||||
## 範囲外
|
||||
|
||||
- `UsageEvent` の reasoning_tokens 分離。Anthropic は `output_tokens` に thinking を含み、OpenAI Responses は `output_tokens_details.reasoning_tokens` を別途返すが、現状の `UsageEvent` ではそれを分離していない。本チケットでは token 数表示そのものを行わない
|
||||
- Anthropic Redacted Thinking(暗号化 blob)の表示。現状 plaintext 経路のみ流れる
|
||||
- Thinking 本文の Markdown レンダリング / シンタックスハイライト
|
||||
- thinking を context から prune する話(別軸)
|
||||
|
||||
## 完了条件
|
||||
|
||||
- Anthropic で extended thinking を有効にした session で「Thinking... (Xs) + 本文 1 行」が live で見え、終了後に `Thought for Xs` として履歴に残る
|
||||
- 本文を流さない provider 設定でも `Thinking...` ヘッダが表示され、終了後に `Thought for ...` が残る
|
||||
- `detail` モードで thinking 本文が全行展開できる
|
||||
- 同一 turn に複数 thinking block が来てもそれぞれ独立に表示される
|
||||
- `Event::History` 再生で過去の thinking が `Block::Thinking { finished: true }` として復元される
|
||||
- `ThinkingDone` 欠落でも panic せず、Incomplete 相当の表示で残る
|
||||
- 既存のテキスト / ツール / notification / compact 表示が壊れない
|
||||
Loading…
Reference in New Issue
Block a user