diff --git a/.insomnia/knowledge/test.md b/.insomnia/knowledge/test.md new file mode 100644 index 00000000..e69de29b diff --git a/TODO.md b/TODO.md index 636cc38b..93cc88a0 100644 --- a/TODO.md +++ b/TODO.md @@ -15,6 +15,7 @@ - [ ] ユーザーマニフェストのモデル設定 wizard → [tickets/tui-user-model-setup.md](tickets/tui-user-model-setup.md) - [ ] サブミット入力 - [ ] TUI 補完 + 型付き atom 化 → [tickets/submit-tui-completion.md](tickets/submit-tui-completion.md) + - [ ] FileRef リゾルバ → [tickets/submit-file-ref-resolver.md](tickets/submit-file-ref-resolver.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) diff --git a/crates/llm-worker/src/interceptor.rs b/crates/llm-worker/src/interceptor.rs index 6d8a3e1a..4795d798 100644 --- a/crates/llm-worker/src/interceptor.rs +++ b/crates/llm-worker/src/interceptor.rs @@ -17,12 +17,18 @@ use crate::tool::{Tool, ToolCall, ToolMeta, ToolResult}; // ============================================================================= /// Action after prompt submission. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub enum PromptAction { /// Proceed normally. Continue, /// Cancel with a reason. Cancel(String), + /// Proceed, and append these items to history right after the user + /// message. Mirrors [`TurnEndAction::ContinueWithMessages`] for the + /// submit edge: lets the upper layer attach resolver-produced + /// system messages (e.g. `@` file content) so they sit + /// adjacent to the user message that referenced them. + ContinueWith(Vec), } /// Action before an LLM request. diff --git a/crates/llm-worker/src/worker.rs b/crates/llm-worker/src/worker.rs index 2285e217..23cc7dfe 100644 --- a/crates/llm-worker/src/worker.rs +++ b/crates/llm-worker/src/worker.rs @@ -1338,16 +1338,20 @@ impl Worker { self.reset_interruption_state(); // Interceptor: on_prompt_submit let mut user_item = Item::user_message(user_input); - match self.interceptor.on_prompt_submit(&mut user_item).await { + let extras = match self.interceptor.on_prompt_submit(&mut user_item).await { PromptAction::Cancel(reason) => { self.last_run_interrupted = true; return self .finalize_interruption(Err(WorkerError::Aborted(reason))) .await; } - PromptAction::Continue => {} - } + PromptAction::Continue => Vec::new(), + PromptAction::ContinueWith(items) => items, + }; self.history.push(user_item); + if !extras.is_empty() { + self.history.extend(extras); + } let result = self.run_turn_loop().await; self.finalize_interruption(result).await } diff --git a/crates/pod/src/fs_view.rs b/crates/pod/src/fs_view.rs index 1a02f6e8..9a4e9fd6 100644 --- a/crates/pod/src/fs_view.rs +++ b/crates/pod/src/fs_view.rs @@ -13,7 +13,7 @@ use std::path::{Path, PathBuf}; use llm_worker::Item; -use tools::ScopedFs; +use tools::{ScopedFs, ToolsError}; use tracing::warn; /// 補完候補1件の最大数。`list_file_completions` がこの値を超えたら打ち切り。 @@ -45,6 +45,29 @@ pub struct FileCandidate { pub is_dir: bool, } +/// `resolve_file_ref` の失敗理由。Pod 側で Alert に振り分けるために +/// ScopedFs / 内部判定の両方を区別できるよう保持する。 +#[derive(Debug)] +pub enum ResolveError { + /// Path resolution / scope check failed via `ScopedFs`. + Fs(ToolsError), + /// File contents are not valid UTF-8 (binary / non-text). + Binary { path: PathBuf }, +} + +impl std::fmt::Display for ResolveError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ResolveError::Fs(e) => write!(f, "{e}"), + ResolveError::Binary { path } => { + write!(f, "file is not valid UTF-8 text: {}", path.display()) + } + } + } +} + +impl std::error::Error for ResolveError {} + impl PodFsView { pub fn new(fs: ScopedFs) -> Self { Self { fs } @@ -83,6 +106,41 @@ impl PodFsView { out } + /// `path` を ScopedFs 経由で読み、`[File: ]\n` 形式の + /// system message を返す。submit 時の `Segment::FileRef` リゾルバが + /// 使う経路。 + /// + /// - `path` は relative なら pwd 相対、absolute なら absolute として解釈 + /// - `max_bytes` を超える本文は切り詰め、末尾に + /// `[...truncated, bytes total — use read_file for the rest]` + /// を付与する + /// - 非 UTF-8 (バイナリ) は `ResolveError::Binary` で拒否 + /// - スコープ外 / NotFound 等は `ResolveError::Fs` で返す + pub fn resolve_file_ref(&self, path: &str, max_bytes: usize) -> Result { + let p = Path::new(path); + let abs = if p.is_absolute() { + p.to_path_buf() + } else { + self.fs.pwd().join(p) + }; + let bytes = self.fs.read_bytes(&abs).map_err(ResolveError::Fs)?; + let total = bytes.len(); + let (body_bytes, truncated) = if total > max_bytes { + (&bytes[..max_bytes], true) + } else { + (bytes.as_slice(), false) + }; + let body = std::str::from_utf8(body_bytes) + .map_err(|_| ResolveError::Binary { path: abs.clone() })?; + let mut text = format!("[File: {path}]\n{body}"); + if truncated { + text.push_str(&format!( + "\n[...truncated, {total} bytes total — use read_file for the rest]" + )); + } + Ok(Item::system_message(text)) + } + /// `prefix` にマッチするファイル / ディレクトリを scope 内で浅く列挙する。 /// /// - `prefix` が空 or `pwd` 相対のときは pwd 直下を見る @@ -227,6 +285,61 @@ mod tests { assert!(!rendered.contains("alpha")); } + #[test] + fn resolve_file_ref_emits_system_message_with_path_header() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("hello.txt"), "hello world").unwrap(); + let view = PodFsView::new(fs_for(&dir)); + + let item = view.resolve_file_ref("hello.txt", 1024).unwrap(); + let text = format!("{item:?}"); + assert!(text.contains("[File: hello.txt]")); + assert!(text.contains("hello world")); + assert!(!text.contains("truncated")); + } + + #[test] + fn resolve_file_ref_truncates_with_hint_when_over_cap() { + let dir = TempDir::new().unwrap(); + let body = "x".repeat(2048); + std::fs::write(dir.path().join("big.txt"), &body).unwrap(); + let view = PodFsView::new(fs_for(&dir)); + + let item = view.resolve_file_ref("big.txt", 256).unwrap(); + let text = format!("{item:?}"); + assert!(text.contains("[File: big.txt]")); + assert!(text.contains("truncated")); + assert!(text.contains("2048 bytes total")); + } + + #[test] + fn resolve_file_ref_rejects_binary_with_binary_error() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("blob.bin"), [0xff, 0xfe, 0x00, 0x80]).unwrap(); + let view = PodFsView::new(fs_for(&dir)); + + let err = view.resolve_file_ref("blob.bin", 1024).unwrap_err(); + assert!(matches!(err, ResolveError::Binary { .. })); + } + + #[test] + fn resolve_file_ref_returns_fs_error_for_out_of_scope() { + let outer = TempDir::new().unwrap(); + let inner = outer.path().join("scoped"); + std::fs::create_dir(&inner).unwrap(); + std::fs::write(outer.path().join("secret.txt"), "nope").unwrap(); + let scope = Scope::writable(&inner).unwrap(); + let fs = ScopedFs::new(scope, inner.clone()); + let view = PodFsView::new(fs); + + // Absolute path outside of scope. + let outside = outer.path().join("secret.txt"); + let err = view + .resolve_file_ref(outside.to_str().unwrap(), 1024) + .unwrap_err(); + assert!(matches!(err, ResolveError::Fs(_))); + } + #[test] fn render_auto_read_skips_unreadable_targets() { let dir = TempDir::new().unwrap(); diff --git a/crates/pod/src/hook.rs b/crates/pod/src/hook.rs index fa3896ea..9f360293 100644 --- a/crates/pod/src/hook.rs +++ b/crates/pod/src/hook.rs @@ -21,6 +21,30 @@ use llm_worker::interceptor::{ use llm_worker::tool::ToolOutput; use serde_json::Value; +/// Hook-facing prompt-submit action. +/// +/// A strict subset of [`PromptAction`]: Hooks may continue or cancel +/// the submit, but cannot inject items into history. The +/// `ContinueWith(Vec)` variant is reserved for the internal +/// `Interceptor` so that Hook (the public extension surface) stays +/// read-only by construction (see module-level doc). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HookPromptAction { + /// Proceed normally. + Continue, + /// Cancel with a reason. + Cancel(String), +} + +impl From for PromptAction { + fn from(action: HookPromptAction) -> Self { + match action { + HookPromptAction::Continue => PromptAction::Continue, + HookPromptAction::Cancel(reason) => PromptAction::Cancel(reason), + } + } +} + // ============================================================================= // Hook input summary types (read-only) // ============================================================================= @@ -121,7 +145,7 @@ pub struct OnAbort; impl HookEventKind for OnPromptSubmit { type Input = PromptSubmitInfo; - type Output = PromptAction; + type Output = HookPromptAction; } impl HookEventKind for PreLlmRequest { diff --git a/crates/pod/src/ipc/interceptor.rs b/crates/pod/src/ipc/interceptor.rs index 6a9b54ee..f7b9d9de 100644 --- a/crates/pod/src/ipc/interceptor.rs +++ b/crates/pod/src/ipc/interceptor.rs @@ -22,8 +22,8 @@ use tracing::info; use crate::compact::state::CompactState; use crate::hook::{ - AbortInfo, HookRegistry, PreRequestInfo, PromptSubmitInfo, ToolCallSummary, ToolResultSummary, - TurnEndInfo, + AbortInfo, HookPromptAction, HookRegistry, PreRequestInfo, PromptSubmitInfo, ToolCallSummary, + ToolResultSummary, TurnEndInfo, }; use crate::ipc::notify_buffer::{NotifyBuffer, format_notify}; use crate::prompt::catalog::PromptCatalog; @@ -43,6 +43,11 @@ pub(crate) struct PodInterceptor { /// Pending-notification buffer drained into the per-request /// context at the head of `pre_llm_request`. pending_notifies: NotifyBuffer, + /// Submit-scoped stash of resolver-produced system messages. + /// Drained inside `on_prompt_submit` and returned via + /// `PromptAction::ContinueWith`. Populated by `Pod::run` immediately + /// before handing off to the worker. + pending_attachments: Arc>>, /// Prompt catalog used to render the injected notification wrapper. prompts: Arc, /// Next turn index assigned by `on_prompt_submit`. @@ -57,6 +62,7 @@ impl PodInterceptor { compact_state: Option>, usage_history: Option>>>, pending_notifies: NotifyBuffer, + pending_attachments: Arc>>, prompts: Arc, ) -> Self { Self { @@ -64,6 +70,7 @@ impl PodInterceptor { compact_state, usage_history, pending_notifies, + pending_attachments, prompts, next_turn_index: AtomicUsize::new(0), tool_calls_this_turn: AtomicUsize::new(0), @@ -98,11 +105,21 @@ impl Interceptor for PodInterceptor { }; for hook in &self.registry.on_prompt_submit { let action = hook.call(&info).await; - if !matches!(action, PromptAction::Continue) { - return action; + if !matches!(action, HookPromptAction::Continue) { + return action.into(); } } - PromptAction::Continue + let extras = std::mem::take( + &mut *self + .pending_attachments + .lock() + .expect("pending_attachments poisoned"), + ); + if extras.is_empty() { + PromptAction::Continue + } else { + PromptAction::ContinueWith(extras) + } } async fn pre_llm_request(&self, context: &mut Vec) -> PreRequestAction { @@ -297,6 +314,7 @@ mod tests { Some(state), Some(history), NotifyBuffer::new(), + Arc::new(Mutex::new(Vec::new())), PromptCatalog::builtins_only().unwrap(), ); let mut ctx = ctx_items; @@ -321,6 +339,7 @@ mod tests { Some(state), Some(history), NotifyBuffer::new(), + Arc::new(Mutex::new(Vec::new())), PromptCatalog::builtins_only().unwrap(), ); let mut ctx = ctx_items; @@ -346,6 +365,7 @@ mod tests { Some(state), Some(history), NotifyBuffer::new(), + Arc::new(Mutex::new(Vec::new())), PromptCatalog::builtins_only().unwrap(), ); let mut ctx = ctx_items; @@ -365,6 +385,7 @@ mod tests { None, None, NotifyBuffer::new(), + Arc::new(Mutex::new(Vec::new())), PromptCatalog::builtins_only().unwrap(), ); let mut ctx: Vec = Vec::new(); @@ -396,6 +417,7 @@ mod tests { None, None, buffer.clone(), + Arc::new(Mutex::new(Vec::new())), PromptCatalog::builtins_only().unwrap(), ); let mut ctx: Vec = vec![Item::user_message("hi")]; @@ -431,6 +453,7 @@ mod tests { Some(state), Some(history), buffer.clone(), + Arc::new(Mutex::new(Vec::new())), PromptCatalog::builtins_only().unwrap(), ); let mut ctx = ctx_items; @@ -456,6 +479,7 @@ mod tests { None, None, NotifyBuffer::new(), + Arc::new(Mutex::new(Vec::new())), PromptCatalog::builtins_only().unwrap(), ); let mut ctx: Vec = Vec::new(); diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index 4d200382..38231f43 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -99,6 +99,12 @@ pub struct Pod { /// injection into the next LLM request. Shared with the /// PodInterceptor installed in `ensure_interceptor_installed`. pending_notifies: NotifyBuffer, + /// Submit-scoped stash for resolver-produced system messages + /// (currently `@` file content). `Pod::run` fills this + /// before handing off to the worker; `PodInterceptor::on_prompt_submit` + /// drains it and returns `ContinueWith` so the items land in + /// history right after the user message that referenced them. + pending_attachments: Arc>>, /// Scope allocation in the machine-wide lock file. `Some` for /// Pods built via `from_manifest` / `from_manifest_spawned` / /// `restore_from_manifest` (production paths); `None` for the @@ -185,6 +191,7 @@ impl Pod { alerter: None, event_tx: None, pending_notifies: NotifyBuffer::new(), + pending_attachments: Arc::new(Mutex::new(Vec::new())), scope_allocation: None, callback_socket: None, prompts, @@ -502,6 +509,7 @@ impl Pod { compact_state, usage_history_handle, self.pending_notifies.clone(), + self.pending_attachments.clone(), self.prompts.clone(), ); self.worker_mut().set_interceptor(interceptor); @@ -614,6 +622,18 @@ impl Pod { .await?; self.user_segments.push(input.clone()); + // Resolve `@` refs to system messages stashed for the + // PodInterceptor to attach right after the user message. Failures + // surface as user-facing Alerts and the placeholder remains in + // the flattened text so the LLM sees the unresolved intent. + let attachments = self.resolve_file_refs(&input); + if !attachments.is_empty() { + *self + .pending_attachments + .lock() + .expect("pending_attachments poisoned") = attachments; + } + let flattened = self.flatten_segments(&input); let history_before = self.worker.as_ref().unwrap().history().len(); @@ -627,26 +647,46 @@ impl Pod { self.handle_worker_result(result, history_before).await } + /// Resolve every `Segment::FileRef` in `segments` to a `[File: ]` + /// system message via `PodFsView`. Resolution failures (out-of-scope, + /// not-found, binary, I/O) surface as `AlertLevel::Warn` Alerts and + /// are skipped — the unresolved placeholder stays in the flattened + /// user message so the LLM still sees the intent. + fn resolve_file_refs(&self, segments: &[Segment]) -> Vec { + let view = crate::fs_view::PodFsView::new(tools::ScopedFs::new( + self.scope.clone(), + self.pwd.clone(), + )); + let mut out = Vec::new(); + for seg in segments { + let Segment::FileRef { path } = seg else { + continue; + }; + match view.resolve_file_ref(path, manifest::defaults::TOOL_OUTPUT_MAX_BYTES) { + Ok(item) => out.push(item), + Err(e) => { + self.alert( + AlertLevel::Warn, + AlertSource::Pod, + format!("file ref @{path} could not be resolved: {e}"), + ); + } + } + } + out + } + /// Flatten a typed segment list into the single string the Worker /// receives as the user message, and emit user-facing alerts for - /// segments that fall through to placeholder (file/knowledge/workflow + /// segments that fall through to placeholder (knowledge / workflow /// refs without a resolver, or unknown variants from a newer client). - /// The text reconstruction itself comes from `Segment::flatten_to_text`, + /// `FileRef` is handled separately by `resolve_file_refs`. The text + /// reconstruction itself comes from `Segment::flatten_to_text`, /// shared with replay paths that should not re-alert. fn flatten_segments(&self, segments: &[Segment]) -> String { for seg in segments { match seg { - Segment::Text { .. } | Segment::Paste { .. } => {} - Segment::FileRef { path } => { - self.alert( - AlertLevel::Warn, - AlertSource::Pod, - format!( - "file ref @{path} cannot be resolved \ - (resolver not yet implemented); passed to LLM as placeholder" - ), - ); - } + Segment::Text { .. } | Segment::Paste { .. } | Segment::FileRef { .. } => {} Segment::KnowledgeRef { slug } => { self.alert( AlertLevel::Warn, @@ -1550,6 +1590,7 @@ impl Pod, St> { alerter: None, event_tx: None, pending_notifies: NotifyBuffer::new(), + pending_attachments: Arc::new(Mutex::new(Vec::new())), scope_allocation: Some(scope_allocation), callback_socket: None, prompts: common.prompts, @@ -1606,6 +1647,7 @@ impl Pod, St> { alerter: None, event_tx: None, pending_notifies: NotifyBuffer::new(), + pending_attachments: Arc::new(Mutex::new(Vec::new())), scope_allocation: Some(scope_allocation), callback_socket: Some(callback_socket), prompts: common.prompts, @@ -1714,6 +1756,7 @@ impl Pod, St> { alerter: None, event_tx: None, pending_notifies: NotifyBuffer::new(), + pending_attachments: Arc::new(Mutex::new(Vec::new())), scope_allocation: Some(scope_allocation), callback_socket: None, prompts: common.prompts, diff --git a/crates/pod/tests/controller_test.rs b/crates/pod/tests/controller_test.rs index 826751fd..39792920 100644 --- a/crates/pod/tests/controller_test.rs +++ b/crates/pod/tests/controller_test.rs @@ -123,6 +123,10 @@ permission = "write" "#; async fn make_pod(client: MockClient) -> Pod { + make_pod_with_pwd(client).await.0 +} + +async fn make_pod_with_pwd(client: MockClient) -> (Pod, std::path::PathBuf) { let manifest = PodManifest::from_toml(MANIFEST_TOML).unwrap(); let store_tmp = tempfile::tempdir().unwrap(); let store = FsStore::new(store_tmp.path()).await.unwrap(); @@ -137,7 +141,10 @@ async fn make_pod(client: MockClient) -> Pod { std::mem::forget(pwd_tmp); let worker = Worker::new(client); - Pod::new(manifest, worker, store, pwd, scope).await.unwrap() + let pod = Pod::new(manifest, worker, store, pwd.clone(), scope) + .await + .unwrap(); + (pod, pwd) } use pod::PodHandle; @@ -405,6 +412,58 @@ async fn run_with_paste_segment_inlines_content_and_emits_typed_user_message() { ); } +#[tokio::test] +async fn run_with_resolvable_file_ref_attaches_system_message_after_user() { + let client = MockClient::new(simple_text_events()); + let client_for_assert = client.clone(); + let (pod, pwd) = make_pod_with_pwd(client).await; + std::fs::write(pwd.join("notes.md"), "alpha\nbeta\n").unwrap(); + let handle = spawn_controller(pod).await; + + let segments = vec![ + protocol::Segment::text("see "), + protocol::Segment::FileRef { + path: "notes.md".into(), + }, + ]; + handle.send(Method::Run { input: segments }).await.unwrap(); + + // Wait for the turn to complete. + let mut rx = handle.subscribe(); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + tokio::select! { + event = rx.recv() => match event { + Ok(Event::TurnEnd { .. }) => break, + Err(_) => break, + _ => {} + }, + _ = tokio::time::sleep_until(deadline) => break, + } + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let requests = client_for_assert.captured_requests(); + let items = &requests[0].items; + // The submit produces 2 history items: user message then file content. + let user_idx = items + .iter() + .position(|i| i.is_user_message()) + .expect("user message present"); + let next = items + .get(user_idx + 1) + .expect("attachment item present after user"); + let next_text = next.as_text().unwrap_or_default(); + assert!( + next_text.contains("[File: notes.md]"), + "expected file header, got: {next_text:?}" + ); + assert!( + next_text.contains("alpha"), + "expected file body, got: {next_text:?}" + ); +} + #[tokio::test] async fn run_with_unresolved_segment_emits_alert_and_placeholder() { let client = MockClient::new(simple_text_events()); @@ -448,11 +507,12 @@ async fn run_with_unresolved_segment_emits_alert_and_placeholder() { .iter() .find_map(|i| i.as_text().map(|s| s.to_string())) .unwrap_or_default(); - // LLM context carries a placeholder so the model can ask for the - // missing content rather than silently miss the user's intent. + // The user message keeps the literal `@` token (matching what + // the user typed). Resolution failure surfaces via the Alert above; + // the LLM still sees the intent as a sigil-prefixed reference. assert!( - user_text.contains("[unresolved file ref: src/lib.rs]"), - "placeholder missing, got: {user_text:?}" + user_text.contains("@src/lib.rs"), + "literal sigil missing, got: {user_text:?}" ); } diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 48ab8e15..2b1a3a2c 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -150,9 +150,13 @@ impl Segment { /// to surface user-visible alerts for unresolved refs should do so /// alongside this call (Pod does so at submit time). /// - /// Unresolved variants (`FileRef` / `KnowledgeRef` / `WorkflowInvoke`) - /// and `Unknown` map to `[unresolved : ]` placeholders so - /// the LLM sees an explicit token rather than silent omission. + /// Sigil-prefixed variants (`FileRef` / `KnowledgeRef` / `WorkflowInvoke`) + /// flatten back to their literal sigil form (`@`, `#`, + /// `/`) — matching what the user originally typed. Resolved + /// content (e.g. file body for `FileRef`) is delivered as separate + /// `Item::system_message`s adjacent to the user message; the + /// resolution itself is the caller's job. `Unknown` falls back to + /// a bracketed placeholder since there is no sigil to render. pub fn flatten_to_text(segments: &[Segment]) -> String { let mut out = String::new(); for seg in segments { @@ -160,13 +164,16 @@ impl Segment { Segment::Text { content } => out.push_str(content), Segment::Paste { content, .. } => out.push_str(content), Segment::FileRef { path } => { - out.push_str(&format!("[unresolved file ref: {path}]")); + out.push('@'); + out.push_str(path); } Segment::KnowledgeRef { slug } => { - out.push_str(&format!("[unresolved knowledge ref: {slug}]")); + out.push('#'); + out.push_str(slug); } Segment::WorkflowInvoke { slug } => { - out.push_str(&format!("[unresolved workflow invoke: {slug}]")); + out.push('/'); + out.push_str(slug); } Segment::Unknown => { out.push_str("[unknown input segment]"); diff --git a/crates/session-store/src/session_log.rs b/crates/session-store/src/session_log.rs index 92ce337c..64f7257f 100644 --- a/crates/session-store/src/session_log.rs +++ b/crates/session-store/src/session_log.rs @@ -713,7 +713,7 @@ mod tests { assert_eq!(content.len(), 1); match &content[0] { llm_worker::ContentPart::Text { text } => { - assert_eq!(text, "see line1\nline2[unresolved file ref: src/main.rs]"); + assert_eq!(text, "see line1\nline2@src/main.rs"); } other => panic!("unexpected content: {other:?}"), } diff --git a/tickets/submit-file-ref-resolver.md b/tickets/submit-file-ref-resolver.md new file mode 100644 index 00000000..bc5807cb --- /dev/null +++ b/tickets/submit-file-ref-resolver.md @@ -0,0 +1,81 @@ +# サブミット入力: FileRef リゾルバ + +## 背景 + +`tickets/submit-tui-completion.md` で `@` が typed atom として入力され、submit 時に `Segment::FileRef { path }` で Pod へ届く経路が完成した。一方 Pod 側 (`Pod::flatten_segments` in `crates/pod/src/pod.rs`) は今 `FileRef` を見ても resolver を持たず、`Segment::flatten_to_text` の placeholder (`[unresolved file ref: ...]`) を user message に inline するだけで、Warn alert を吐いて終わっている。 + +ClaudeCode の `@` と同等の挙動 — submit 時にファイル本文を読み、LLM context にそのまま見せる — を入れる。`compact/worker.rs` の `mark_read_required` 経路で完成済の auto-read(`PodFsView::render_auto_read`)と兄弟関係になる、submit 時版のリゾルバ。 + +## 要件 + +### Item 配置 + +履歴に永続化する形は以下の **2 つの Item** にする: + +``` +[..., user_message, system_message(file1), system_message(file2), ...] +``` + +user message 自体は今と同じく `Segment::flatten_to_text` 由来のテキスト(`@` トークンが残った placeholder 込み)。直後に `[File: ]\n<本文>` 形式の system message を、`FileRef` の出現順に追加する。次ターン以降も LLM が見える状態で残す(compact が走った時点で既存の auto-read 機構が引き継ぐ)。 + +inline 結合(user 1 メッセージに本文を流し込む)は採らない。 + +### 本文の取り扱い + +- `PodFsView` (`crates/pod/src/fs_view.rs`) 経由で読む。スコープ判定は `ScopedFs` 任せ。 +- 上限は通常の Tool Output と同じ `manifest::defaults::TOOL_OUTPUT_MAX_BYTES` (16 KB)。超過分は捨て、末尾に `[...truncated, bytes total — use read_file for the rest]` を付ける。LLM が必要なら自分で `read_file` を呼ぶ前提。 +- 非 UTF-8(バイナリ)はリゾルバが拒否する。後述の失敗扱いに倒す。 + +### 失敗時の扱い + +スコープ外 / NotFound / バイナリ拒否は **Alert + placeholder 残置**: + +- ユーザー向け Alert を `AlertLevel::Warn` で発火(理由を含めた一文) +- 該当 segment の system message は出さない(user message 中の `[unresolved file ref: ]` プレースホルダーがそのまま LLM に届く) + +これは「ユーザーの誤入力を早期に可視化する」狙い。silent fallback にしない。 + +### Worker 側 API 拡張 + +submit 時に user message と system messages を一つの turn の前置として履歴に積む経路を、既存の `Interceptor` action-return パターンに合わせて足す。`TurnEndAction::ContinueWithMessages(Vec)` (`crates/llm-worker/src/worker.rs:903`) と同形: + +- `Interceptor::on_prompt_submit` の戻り値を拡張し、`Continue` / `Cancel(String)` に加えて `ContinueWith(Vec)` を返せるようにする +- Worker の `Locked::run` は `ContinueWith` を受けたら user_item の push 直後に extras を `history.extend` する +- Hook (`crates/pod/src/hook.rs`) 側の戻り値(`PromptAction`)はこの拡張に乗せない。Hook は read-only な公開拡張面という設計(hook.rs:8-15 のコメント)を維持するため、Hook と Interceptor で戻り値型を分離する + +### Pod 側の resolver 配線 + +- `PodFsView::resolve_file_ref(&self, path: &str, max_bytes: usize) -> Result` を新設。`ScopedFs` で読み、UTF-8 検証 + 16 KB 切詰めを行い `Item::system_message` を返す。エラーは `OutOfScope` / `NotFound` / `Binary` / `Io(io::Error)` を区別する +- `PodSharedState` に submit 中だけ使う stash (`Mutex>`) を一個追加。`pending_notifies` / `compact_state` と同じ流儀 +- `Pod::run` で submit 直前に `Vec` を走査して FileRef を resolver に通し、成功分は stash、失敗分は Alert に流す +- `PodInterceptor::on_prompt_submit` で stash を取り出して空でなければ `ContinueWith(items)` を返す + +## 範囲外 + +- Knowledge / Workflow resolver(それぞれ `tickets/memory-phase2-consolidation.md` と `tickets/workflow.md` 側) +- 画像など binary attachment の typed メッセージ化(将来 `ContentPart::Image` 等を入れる別チケット) +- `@:-` のような行範囲指定構文 +- compact 後の auto-read との重複排除(compact が user message 由来の FileRef を読み直す可能性は許容) + +## 完了条件 + +- `@` を含む submit が、user message + 解決済み system message の 2 Item として履歴に残る +- 16 KB を超えるファイルは truncate され、その旨が LLM に見える形で示される +- スコープ外 / NotFound / バイナリは Alert として通知され、LLM 側は placeholder を見るのみ +- Hook の戻り値型は据え置き、Interceptor のみ `ContinueWith` を受け付ける +- 既存ビルド・テストを壊さない + +## 依存 + +- `tickets/submit-tui-completion.md`(FileRef segment の wire 接続) + +## 参照 + +- `crates/pod/src/pod.rs`(`flatten_segments`, `Pod::run`) +- `crates/pod/src/fs_view.rs`(`PodFsView` — auto-read の隣に置く) +- `crates/pod/src/ipc/interceptor.rs`(`PodInterceptor::on_prompt_submit`) +- `crates/pod/src/shared_state.rs`(stash 追加先) +- `crates/llm-worker/src/interceptor.rs`(`PromptAction` 拡張) +- `crates/llm-worker/src/worker.rs:903`(`TurnEndAction::ContinueWithMessages` 既存パターン) +- `crates/pod/src/hook.rs:8-15`(Hook と Interceptor の責務分離 doc) +- `crates/manifest/src/defaults.rs`(`TOOL_OUTPUT_MAX_BYTES`)