ファイル参照を与えた際に自動的に読ませる実装
This commit is contained in:
parent
75c61bd3cb
commit
c331936455
0
.insomnia/knowledge/test.md
Normal file
0
.insomnia/knowledge/test.md
Normal file
1
TODO.md
1
TODO.md
|
|
@ -15,6 +15,7 @@
|
||||||
- [ ] ユーザーマニフェストのモデル設定 wizard → [tickets/tui-user-model-setup.md](tickets/tui-user-model-setup.md)
|
- [ ] ユーザーマニフェストのモデル設定 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)
|
- [ ] 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)
|
- [ ] Phase 2 consolidation → [tickets/memory-phase2-consolidation.md](tickets/memory-phase2-consolidation.md)
|
||||||
- [ ] 使用頻度メトリクス + Knowledge 化候補レポート → [tickets/memory-usage-metrics.md](tickets/memory-usage-metrics.md)
|
- [ ] 使用頻度メトリクス + Knowledge 化候補レポート → [tickets/memory-usage-metrics.md](tickets/memory-usage-metrics.md)
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,18 @@ use crate::tool::{Tool, ToolCall, ToolMeta, ToolResult};
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/// Action after prompt submission.
|
/// Action after prompt submission.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum PromptAction {
|
pub enum PromptAction {
|
||||||
/// Proceed normally.
|
/// Proceed normally.
|
||||||
Continue,
|
Continue,
|
||||||
/// Cancel with a reason.
|
/// Cancel with a reason.
|
||||||
Cancel(String),
|
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. `@<path>` file content) so they sit
|
||||||
|
/// adjacent to the user message that referenced them.
|
||||||
|
ContinueWith(Vec<Item>),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Action before an LLM request.
|
/// Action before an LLM request.
|
||||||
|
|
|
||||||
|
|
@ -1338,16 +1338,20 @@ impl<C: LlmClient> Worker<C, Locked> {
|
||||||
self.reset_interruption_state();
|
self.reset_interruption_state();
|
||||||
// Interceptor: on_prompt_submit
|
// Interceptor: on_prompt_submit
|
||||||
let mut user_item = Item::user_message(user_input);
|
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) => {
|
PromptAction::Cancel(reason) => {
|
||||||
self.last_run_interrupted = true;
|
self.last_run_interrupted = true;
|
||||||
return self
|
return self
|
||||||
.finalize_interruption(Err(WorkerError::Aborted(reason)))
|
.finalize_interruption(Err(WorkerError::Aborted(reason)))
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
PromptAction::Continue => {}
|
PromptAction::Continue => Vec::new(),
|
||||||
}
|
PromptAction::ContinueWith(items) => items,
|
||||||
|
};
|
||||||
self.history.push(user_item);
|
self.history.push(user_item);
|
||||||
|
if !extras.is_empty() {
|
||||||
|
self.history.extend(extras);
|
||||||
|
}
|
||||||
let result = self.run_turn_loop().await;
|
let result = self.run_turn_loop().await;
|
||||||
self.finalize_interruption(result).await
|
self.finalize_interruption(result).await
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use llm_worker::Item;
|
use llm_worker::Item;
|
||||||
use tools::ScopedFs;
|
use tools::{ScopedFs, ToolsError};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
/// 補完候補1件の最大数。`list_file_completions` がこの値を超えたら打ち切り。
|
/// 補完候補1件の最大数。`list_file_completions` がこの値を超えたら打ち切り。
|
||||||
|
|
@ -45,6 +45,29 @@ pub struct FileCandidate {
|
||||||
pub is_dir: bool,
|
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 {
|
impl PodFsView {
|
||||||
pub fn new(fs: ScopedFs) -> Self {
|
pub fn new(fs: ScopedFs) -> Self {
|
||||||
Self { fs }
|
Self { fs }
|
||||||
|
|
@ -83,6 +106,41 @@ impl PodFsView {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `path` を ScopedFs 経由で読み、`[File: <path>]\n<body>` 形式の
|
||||||
|
/// system message を返す。submit 時の `Segment::FileRef` リゾルバが
|
||||||
|
/// 使う経路。
|
||||||
|
///
|
||||||
|
/// - `path` は relative なら pwd 相対、absolute なら absolute として解釈
|
||||||
|
/// - `max_bytes` を超える本文は切り詰め、末尾に
|
||||||
|
/// `[...truncated, <total> 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<Item, ResolveError> {
|
||||||
|
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` にマッチするファイル / ディレクトリを scope 内で浅く列挙する。
|
||||||
///
|
///
|
||||||
/// - `prefix` が空 or `pwd` 相対のときは pwd 直下を見る
|
/// - `prefix` が空 or `pwd` 相対のときは pwd 直下を見る
|
||||||
|
|
@ -227,6 +285,61 @@ mod tests {
|
||||||
assert!(!rendered.contains("alpha"));
|
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]
|
#[test]
|
||||||
fn render_auto_read_skips_unreadable_targets() {
|
fn render_auto_read_skips_unreadable_targets() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,30 @@ use llm_worker::interceptor::{
|
||||||
use llm_worker::tool::ToolOutput;
|
use llm_worker::tool::ToolOutput;
|
||||||
use serde_json::Value;
|
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<Item>)` 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<HookPromptAction> 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)
|
// Hook input summary types (read-only)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -121,7 +145,7 @@ pub struct OnAbort;
|
||||||
|
|
||||||
impl HookEventKind for OnPromptSubmit {
|
impl HookEventKind for OnPromptSubmit {
|
||||||
type Input = PromptSubmitInfo;
|
type Input = PromptSubmitInfo;
|
||||||
type Output = PromptAction;
|
type Output = HookPromptAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HookEventKind for PreLlmRequest {
|
impl HookEventKind for PreLlmRequest {
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@ use tracing::info;
|
||||||
|
|
||||||
use crate::compact::state::CompactState;
|
use crate::compact::state::CompactState;
|
||||||
use crate::hook::{
|
use crate::hook::{
|
||||||
AbortInfo, HookRegistry, PreRequestInfo, PromptSubmitInfo, ToolCallSummary, ToolResultSummary,
|
AbortInfo, HookPromptAction, HookRegistry, PreRequestInfo, PromptSubmitInfo, ToolCallSummary,
|
||||||
TurnEndInfo,
|
ToolResultSummary, TurnEndInfo,
|
||||||
};
|
};
|
||||||
use crate::ipc::notify_buffer::{NotifyBuffer, format_notify};
|
use crate::ipc::notify_buffer::{NotifyBuffer, format_notify};
|
||||||
use crate::prompt::catalog::PromptCatalog;
|
use crate::prompt::catalog::PromptCatalog;
|
||||||
|
|
@ -43,6 +43,11 @@ pub(crate) struct PodInterceptor {
|
||||||
/// Pending-notification buffer drained into the per-request
|
/// Pending-notification buffer drained into the per-request
|
||||||
/// context at the head of `pre_llm_request`.
|
/// context at the head of `pre_llm_request`.
|
||||||
pending_notifies: NotifyBuffer,
|
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<Mutex<Vec<Item>>>,
|
||||||
/// Prompt catalog used to render the injected notification wrapper.
|
/// Prompt catalog used to render the injected notification wrapper.
|
||||||
prompts: Arc<PromptCatalog>,
|
prompts: Arc<PromptCatalog>,
|
||||||
/// Next turn index assigned by `on_prompt_submit`.
|
/// Next turn index assigned by `on_prompt_submit`.
|
||||||
|
|
@ -57,6 +62,7 @@ impl PodInterceptor {
|
||||||
compact_state: Option<Arc<CompactState>>,
|
compact_state: Option<Arc<CompactState>>,
|
||||||
usage_history: Option<Arc<Mutex<Vec<UsageRecord>>>>,
|
usage_history: Option<Arc<Mutex<Vec<UsageRecord>>>>,
|
||||||
pending_notifies: NotifyBuffer,
|
pending_notifies: NotifyBuffer,
|
||||||
|
pending_attachments: Arc<Mutex<Vec<Item>>>,
|
||||||
prompts: Arc<PromptCatalog>,
|
prompts: Arc<PromptCatalog>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -64,6 +70,7 @@ impl PodInterceptor {
|
||||||
compact_state,
|
compact_state,
|
||||||
usage_history,
|
usage_history,
|
||||||
pending_notifies,
|
pending_notifies,
|
||||||
|
pending_attachments,
|
||||||
prompts,
|
prompts,
|
||||||
next_turn_index: AtomicUsize::new(0),
|
next_turn_index: AtomicUsize::new(0),
|
||||||
tool_calls_this_turn: 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 {
|
for hook in &self.registry.on_prompt_submit {
|
||||||
let action = hook.call(&info).await;
|
let action = hook.call(&info).await;
|
||||||
if !matches!(action, PromptAction::Continue) {
|
if !matches!(action, HookPromptAction::Continue) {
|
||||||
return action;
|
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<Item>) -> PreRequestAction {
|
async fn pre_llm_request(&self, context: &mut Vec<Item>) -> PreRequestAction {
|
||||||
|
|
@ -297,6 +314,7 @@ mod tests {
|
||||||
Some(state),
|
Some(state),
|
||||||
Some(history),
|
Some(history),
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
|
|
@ -321,6 +339,7 @@ mod tests {
|
||||||
Some(state),
|
Some(state),
|
||||||
Some(history),
|
Some(history),
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
|
|
@ -346,6 +365,7 @@ mod tests {
|
||||||
Some(state),
|
Some(state),
|
||||||
Some(history),
|
Some(history),
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
|
|
@ -365,6 +385,7 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
);
|
);
|
||||||
let mut ctx: Vec<Item> = Vec::new();
|
let mut ctx: Vec<Item> = Vec::new();
|
||||||
|
|
@ -396,6 +417,7 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
buffer.clone(),
|
buffer.clone(),
|
||||||
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
);
|
);
|
||||||
let mut ctx: Vec<Item> = vec![Item::user_message("hi")];
|
let mut ctx: Vec<Item> = vec![Item::user_message("hi")];
|
||||||
|
|
@ -431,6 +453,7 @@ mod tests {
|
||||||
Some(state),
|
Some(state),
|
||||||
Some(history),
|
Some(history),
|
||||||
buffer.clone(),
|
buffer.clone(),
|
||||||
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
|
|
@ -456,6 +479,7 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
);
|
);
|
||||||
let mut ctx: Vec<Item> = Vec::new();
|
let mut ctx: Vec<Item> = Vec::new();
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,12 @@ pub struct Pod<C: LlmClient, St: Store> {
|
||||||
/// injection into the next LLM request. Shared with the
|
/// injection into the next LLM request. Shared with the
|
||||||
/// PodInterceptor installed in `ensure_interceptor_installed`.
|
/// PodInterceptor installed in `ensure_interceptor_installed`.
|
||||||
pending_notifies: NotifyBuffer,
|
pending_notifies: NotifyBuffer,
|
||||||
|
/// Submit-scoped stash for resolver-produced system messages
|
||||||
|
/// (currently `@<path>` 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<Mutex<Vec<Item>>>,
|
||||||
/// Scope allocation in the machine-wide lock file. `Some` for
|
/// Scope allocation in the machine-wide lock file. `Some` for
|
||||||
/// Pods built via `from_manifest` / `from_manifest_spawned` /
|
/// Pods built via `from_manifest` / `from_manifest_spawned` /
|
||||||
/// `restore_from_manifest` (production paths); `None` for the
|
/// `restore_from_manifest` (production paths); `None` for the
|
||||||
|
|
@ -185,6 +191,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
alerter: None,
|
alerter: None,
|
||||||
event_tx: None,
|
event_tx: None,
|
||||||
pending_notifies: NotifyBuffer::new(),
|
pending_notifies: NotifyBuffer::new(),
|
||||||
|
pending_attachments: Arc::new(Mutex::new(Vec::new())),
|
||||||
scope_allocation: None,
|
scope_allocation: None,
|
||||||
callback_socket: None,
|
callback_socket: None,
|
||||||
prompts,
|
prompts,
|
||||||
|
|
@ -502,6 +509,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
compact_state,
|
compact_state,
|
||||||
usage_history_handle,
|
usage_history_handle,
|
||||||
self.pending_notifies.clone(),
|
self.pending_notifies.clone(),
|
||||||
|
self.pending_attachments.clone(),
|
||||||
self.prompts.clone(),
|
self.prompts.clone(),
|
||||||
);
|
);
|
||||||
self.worker_mut().set_interceptor(interceptor);
|
self.worker_mut().set_interceptor(interceptor);
|
||||||
|
|
@ -614,6 +622,18 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
.await?;
|
.await?;
|
||||||
self.user_segments.push(input.clone());
|
self.user_segments.push(input.clone());
|
||||||
|
|
||||||
|
// Resolve `@<path>` 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 flattened = self.flatten_segments(&input);
|
||||||
|
|
||||||
let history_before = self.worker.as_ref().unwrap().history().len();
|
let history_before = self.worker.as_ref().unwrap().history().len();
|
||||||
|
|
@ -627,26 +647,46 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
self.handle_worker_result(result, history_before).await
|
self.handle_worker_result(result, history_before).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve every `Segment::FileRef` in `segments` to a `[File: <path>]`
|
||||||
|
/// 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<Item> {
|
||||||
|
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
|
/// Flatten a typed segment list into the single string the Worker
|
||||||
/// receives as the user message, and emit user-facing alerts for
|
/// 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).
|
/// 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.
|
/// shared with replay paths that should not re-alert.
|
||||||
fn flatten_segments(&self, segments: &[Segment]) -> String {
|
fn flatten_segments(&self, segments: &[Segment]) -> String {
|
||||||
for seg in segments {
|
for seg in segments {
|
||||||
match seg {
|
match seg {
|
||||||
Segment::Text { .. } | Segment::Paste { .. } => {}
|
Segment::Text { .. } | Segment::Paste { .. } | Segment::FileRef { .. } => {}
|
||||||
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::KnowledgeRef { slug } => {
|
Segment::KnowledgeRef { slug } => {
|
||||||
self.alert(
|
self.alert(
|
||||||
AlertLevel::Warn,
|
AlertLevel::Warn,
|
||||||
|
|
@ -1550,6 +1590,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||||
alerter: None,
|
alerter: None,
|
||||||
event_tx: None,
|
event_tx: None,
|
||||||
pending_notifies: NotifyBuffer::new(),
|
pending_notifies: NotifyBuffer::new(),
|
||||||
|
pending_attachments: Arc::new(Mutex::new(Vec::new())),
|
||||||
scope_allocation: Some(scope_allocation),
|
scope_allocation: Some(scope_allocation),
|
||||||
callback_socket: None,
|
callback_socket: None,
|
||||||
prompts: common.prompts,
|
prompts: common.prompts,
|
||||||
|
|
@ -1606,6 +1647,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||||
alerter: None,
|
alerter: None,
|
||||||
event_tx: None,
|
event_tx: None,
|
||||||
pending_notifies: NotifyBuffer::new(),
|
pending_notifies: NotifyBuffer::new(),
|
||||||
|
pending_attachments: Arc::new(Mutex::new(Vec::new())),
|
||||||
scope_allocation: Some(scope_allocation),
|
scope_allocation: Some(scope_allocation),
|
||||||
callback_socket: Some(callback_socket),
|
callback_socket: Some(callback_socket),
|
||||||
prompts: common.prompts,
|
prompts: common.prompts,
|
||||||
|
|
@ -1714,6 +1756,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||||
alerter: None,
|
alerter: None,
|
||||||
event_tx: None,
|
event_tx: None,
|
||||||
pending_notifies: NotifyBuffer::new(),
|
pending_notifies: NotifyBuffer::new(),
|
||||||
|
pending_attachments: Arc::new(Mutex::new(Vec::new())),
|
||||||
scope_allocation: Some(scope_allocation),
|
scope_allocation: Some(scope_allocation),
|
||||||
callback_socket: None,
|
callback_socket: None,
|
||||||
prompts: common.prompts,
|
prompts: common.prompts,
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,10 @@ permission = "write"
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
async fn make_pod(client: MockClient) -> Pod<MockClient, FsStore> {
|
async fn make_pod(client: MockClient) -> Pod<MockClient, FsStore> {
|
||||||
|
make_pod_with_pwd(client).await.0
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn make_pod_with_pwd(client: MockClient) -> (Pod<MockClient, FsStore>, std::path::PathBuf) {
|
||||||
let manifest = PodManifest::from_toml(MANIFEST_TOML).unwrap();
|
let manifest = PodManifest::from_toml(MANIFEST_TOML).unwrap();
|
||||||
let store_tmp = tempfile::tempdir().unwrap();
|
let store_tmp = tempfile::tempdir().unwrap();
|
||||||
let store = FsStore::new(store_tmp.path()).await.unwrap();
|
let store = FsStore::new(store_tmp.path()).await.unwrap();
|
||||||
|
|
@ -137,7 +141,10 @@ async fn make_pod(client: MockClient) -> Pod<MockClient, FsStore> {
|
||||||
std::mem::forget(pwd_tmp);
|
std::mem::forget(pwd_tmp);
|
||||||
|
|
||||||
let worker = Worker::new(client);
|
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;
|
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]
|
#[tokio::test]
|
||||||
async fn run_with_unresolved_segment_emits_alert_and_placeholder() {
|
async fn run_with_unresolved_segment_emits_alert_and_placeholder() {
|
||||||
let client = MockClient::new(simple_text_events());
|
let client = MockClient::new(simple_text_events());
|
||||||
|
|
@ -448,11 +507,12 @@ async fn run_with_unresolved_segment_emits_alert_and_placeholder() {
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|i| i.as_text().map(|s| s.to_string()))
|
.find_map(|i| i.as_text().map(|s| s.to_string()))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
// LLM context carries a placeholder so the model can ask for the
|
// The user message keeps the literal `@<path>` token (matching what
|
||||||
// missing content rather than silently miss the user's intent.
|
// the user typed). Resolution failure surfaces via the Alert above;
|
||||||
|
// the LLM still sees the intent as a sigil-prefixed reference.
|
||||||
assert!(
|
assert!(
|
||||||
user_text.contains("[unresolved file ref: src/lib.rs]"),
|
user_text.contains("@src/lib.rs"),
|
||||||
"placeholder missing, got: {user_text:?}"
|
"literal sigil missing, got: {user_text:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -150,9 +150,13 @@ impl Segment {
|
||||||
/// to surface user-visible alerts for unresolved refs should do so
|
/// to surface user-visible alerts for unresolved refs should do so
|
||||||
/// alongside this call (Pod does so at submit time).
|
/// alongside this call (Pod does so at submit time).
|
||||||
///
|
///
|
||||||
/// Unresolved variants (`FileRef` / `KnowledgeRef` / `WorkflowInvoke`)
|
/// Sigil-prefixed variants (`FileRef` / `KnowledgeRef` / `WorkflowInvoke`)
|
||||||
/// and `Unknown` map to `[unresolved <kind>: <key>]` placeholders so
|
/// flatten back to their literal sigil form (`@<path>`, `#<slug>`,
|
||||||
/// the LLM sees an explicit token rather than silent omission.
|
/// `/<slug>`) — 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 {
|
pub fn flatten_to_text(segments: &[Segment]) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
for seg in segments {
|
for seg in segments {
|
||||||
|
|
@ -160,13 +164,16 @@ impl Segment {
|
||||||
Segment::Text { content } => out.push_str(content),
|
Segment::Text { content } => out.push_str(content),
|
||||||
Segment::Paste { content, .. } => out.push_str(content),
|
Segment::Paste { content, .. } => out.push_str(content),
|
||||||
Segment::FileRef { path } => {
|
Segment::FileRef { path } => {
|
||||||
out.push_str(&format!("[unresolved file ref: {path}]"));
|
out.push('@');
|
||||||
|
out.push_str(path);
|
||||||
}
|
}
|
||||||
Segment::KnowledgeRef { slug } => {
|
Segment::KnowledgeRef { slug } => {
|
||||||
out.push_str(&format!("[unresolved knowledge ref: {slug}]"));
|
out.push('#');
|
||||||
|
out.push_str(slug);
|
||||||
}
|
}
|
||||||
Segment::WorkflowInvoke { slug } => {
|
Segment::WorkflowInvoke { slug } => {
|
||||||
out.push_str(&format!("[unresolved workflow invoke: {slug}]"));
|
out.push('/');
|
||||||
|
out.push_str(slug);
|
||||||
}
|
}
|
||||||
Segment::Unknown => {
|
Segment::Unknown => {
|
||||||
out.push_str("[unknown input segment]");
|
out.push_str("[unknown input segment]");
|
||||||
|
|
|
||||||
|
|
@ -713,7 +713,7 @@ mod tests {
|
||||||
assert_eq!(content.len(), 1);
|
assert_eq!(content.len(), 1);
|
||||||
match &content[0] {
|
match &content[0] {
|
||||||
llm_worker::ContentPart::Text { text } => {
|
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:?}"),
|
other => panic!("unexpected content: {other:?}"),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
tickets/submit-file-ref-resolver.md
Normal file
81
tickets/submit-file-ref-resolver.md
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# サブミット入力: FileRef リゾルバ
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
`tickets/submit-tui-completion.md` で `@<path>` が 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 の `@<path>` と同等の挙動 — 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` 由来のテキスト(`@<path>` トークンが残った placeholder 込み)。直後に `[File: <path>]\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, <total> 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: <path>]` プレースホルダーがそのまま LLM に届く)
|
||||||
|
|
||||||
|
これは「ユーザーの誤入力を早期に可視化する」狙い。silent fallback にしない。
|
||||||
|
|
||||||
|
### Worker 側 API 拡張
|
||||||
|
|
||||||
|
submit 時に user message と system messages を一つの turn の前置として履歴に積む経路を、既存の `Interceptor` action-return パターンに合わせて足す。`TurnEndAction::ContinueWithMessages(Vec<Item>)` (`crates/llm-worker/src/worker.rs:903`) と同形:
|
||||||
|
|
||||||
|
- `Interceptor::on_prompt_submit` の戻り値を拡張し、`Continue` / `Cancel(String)` に加えて `ContinueWith(Vec<Item>)` を返せるようにする
|
||||||
|
- 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<Item, ResolveError>` を新設。`ScopedFs` で読み、UTF-8 検証 + 16 KB 切詰めを行い `Item::system_message` を返す。エラーは `OutOfScope` / `NotFound` / `Binary` / `Io(io::Error)` を区別する
|
||||||
|
- `PodSharedState` に submit 中だけ使う stash (`Mutex<Vec<Item>>`) を一個追加。`pending_notifies` / `compact_state` と同じ流儀
|
||||||
|
- `Pod::run` で submit 直前に `Vec<Segment>` を走査して 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` 等を入れる別チケット)
|
||||||
|
- `@<path>:<line>-<line>` のような行範囲指定構文
|
||||||
|
- compact 後の auto-read との重複排除(compact が user message 由来の FileRef を読み直す可能性は許容)
|
||||||
|
|
||||||
|
## 完了条件
|
||||||
|
|
||||||
|
- `@<path>` を含む 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`)
|
||||||
Loading…
Reference in New Issue
Block a user