diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index 3b13d440..49c29bea 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -15,8 +15,8 @@ use serde::{Deserialize, Serialize}; use crate::defaults; use crate::model::{AuthRef, ModelManifest, ReasoningControl}; use crate::{ - CompactionConfig, MemoryConfig, PodManifest, PodMeta, ScopeConfig, SkillsConfig, - ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WorkerManifest, + CompactionConfig, FileUploadLimits, MemoryConfig, PodManifest, PodMeta, ScopeConfig, + SkillsConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WorkerManifest, }; /// Partial-form Pod manifest. Every field is optional; one or more @@ -81,6 +81,8 @@ pub struct WorkerManifestConfig { pub reasoning: Option, #[serde(default)] pub tool_output: ToolOutputLimitsPartial, + #[serde(default)] + pub file_upload: FileUploadLimitsPartial, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -91,6 +93,12 @@ pub struct ToolOutputLimitsPartial { pub per_tool: HashMap, } +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FileUploadLimitsPartial { + #[serde(default)] + pub max_bytes: Option, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct PermissionConfigPartial { #[serde(default)] @@ -158,6 +166,9 @@ impl PodManifestConfig { default_max_bytes: Some(defaults::TOOL_OUTPUT_MAX_BYTES), per_tool: HashMap::new(), }, + file_upload: FileUploadLimitsPartial { + max_bytes: Some(defaults::FILE_UPLOAD_MAX_BYTES), + }, ..Default::default() }, ..Default::default() @@ -287,6 +298,7 @@ impl WorkerManifestConfig { stop_sequences: upper.stop_sequences.or(self.stop_sequences), reasoning: upper.reasoning.or(self.reasoning), tool_output: self.tool_output.merge(upper.tool_output), + file_upload: self.file_upload.merge(upper.file_upload), } } } @@ -302,6 +314,14 @@ impl ToolOutputLimitsPartial { } } +impl FileUploadLimitsPartial { + fn merge(self, upper: Self) -> Self { + Self { + max_bytes: upper.max_bytes.or(self.max_bytes), + } + } +} + impl PermissionConfigPartial { fn merge(mut self, upper: Self) -> Self { self.rules.extend(upper.rules); @@ -423,6 +443,13 @@ impl TryFrom for PodManifest { .unwrap_or(defaults::TOOL_OUTPUT_MAX_BYTES), per_tool: cfg.worker.tool_output.per_tool, }, + file_upload: FileUploadLimits { + max_bytes: cfg + .worker + .file_upload + .max_bytes + .unwrap_or(defaults::FILE_UPLOAD_MAX_BYTES), + }, }; if cfg.scope.allow.is_empty() { @@ -858,6 +885,29 @@ mod tests { assert_eq!(to.per_tool.get("Grep"), Some(&512)); } + #[test] + fn merge_file_upload_max_bytes_upper_wins() { + let lower = PodManifestConfig { + worker: WorkerManifestConfig { + file_upload: FileUploadLimitsPartial { + max_bytes: Some(8192), + }, + ..Default::default() + }, + ..Default::default() + }; + let upper = PodManifestConfig { + worker: WorkerManifestConfig { + file_upload: FileUploadLimitsPartial { + max_bytes: Some(54_321), + }, + ..Default::default() + }, + ..Default::default() + }; + let merged = lower.merge(upper); + assert_eq!(merged.worker.file_upload.max_bytes, Some(54_321)); + } #[test] fn merge_option_struct_field_wise() { let lower = PodManifestConfig { @@ -1000,12 +1050,16 @@ permission = "write" } #[test] - fn builtin_defaults_populates_tool_output_max_bytes() { + fn builtin_defaults_populates_worker_limit_defaults() { let cfg = PodManifestConfig::builtin_defaults(); assert_eq!( cfg.worker.tool_output.default_max_bytes, Some(defaults::TOOL_OUTPUT_MAX_BYTES) ); + assert_eq!( + cfg.worker.file_upload.max_bytes, + Some(defaults::FILE_UPLOAD_MAX_BYTES) + ); } #[test] @@ -1039,6 +1093,10 @@ permission = "write" manifest.worker.tool_output.default_max_bytes, defaults::TOOL_OUTPUT_MAX_BYTES ); + assert_eq!( + manifest.worker.file_upload.max_bytes, + defaults::FILE_UPLOAD_MAX_BYTES + ); } #[test] diff --git a/crates/manifest/src/defaults.rs b/crates/manifest/src/defaults.rs index c3d6022d..c790e157 100644 --- a/crates/manifest/src/defaults.rs +++ b/crates/manifest/src/defaults.rs @@ -8,7 +8,11 @@ /// Byte-size cap applied to any tool's `content` output when no /// per-tool override is set. See [`crate::ToolOutputLimits`]. -pub const TOOL_OUTPUT_MAX_BYTES: usize = 16 * 1024; +pub const TOOL_OUTPUT_MAX_BYTES: usize = 64 * 1024; + +/// Byte-size cap applied to each submit-time FileRef upload / attachment. +/// See [`crate::FileUploadLimits`]. +pub const FILE_UPLOAD_MAX_BYTES: usize = 256 * 1024; /// Number of most-recent turns protected from pruning. See /// [`crate::CompactionConfig::prune_protected_turns`]. diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 471ba39f..a92d44b6 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -7,8 +7,8 @@ mod scope; pub use cascade::{LayerLoadError, find_project_manifest_from, load_layer}; pub use config::{ - CompactionConfigPartial, PermissionConfigPartial, PodManifestConfig, PodMetaConfig, - ResolveError, ToolOutputLimitsPartial, WorkerManifestConfig, + CompactionConfigPartial, FileUploadLimitsPartial, PermissionConfigPartial, PodManifestConfig, + PodMetaConfig, ResolveError, ToolOutputLimitsPartial, WorkerManifestConfig, }; pub use model::{ AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind, @@ -183,10 +183,15 @@ pub struct WorkerManifest { pub reasoning: Option, /// Byte-size caps applied to tool `content` before it reaches the /// conversation history. The section is optional in TOML — when - /// omitted, `ToolOutputLimits::default()` (16KB default cap, no + /// omitted, `ToolOutputLimits::default()` (64 KiB default cap, no /// per-tool overrides) is applied so truncation is on by default. #[serde(default)] pub tool_output: ToolOutputLimits, + /// Byte-size cap applied to submit-time FileRef uploads / attachments. + /// This is intentionally separate from tool-output truncation because + /// user-requested file attachments can usually tolerate a larger budget. + #[serde(default)] + pub file_upload: FileUploadLimits, } /// Byte-size caps applied to tool execution `content` before it enters @@ -206,10 +211,26 @@ pub struct ToolOutputLimits { pub per_tool: HashMap, } +/// Byte-size cap for submit-time FileRef uploads / attachments. +/// +/// This governs the `[File: ]` system-message attachment produced +/// when a user explicitly submits a `@` reference. It does not affect +/// tool result truncation; see [`ToolOutputLimits`] for that path. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileUploadLimits { + /// Cap applied to each resolved FileRef body. + #[serde(default = "default_file_upload_max_bytes")] + pub max_bytes: usize, +} + fn default_tool_output_max_bytes() -> usize { defaults::TOOL_OUTPUT_MAX_BYTES } +fn default_file_upload_max_bytes() -> usize { + defaults::FILE_UPLOAD_MAX_BYTES +} + fn default_instruction() -> String { defaults::DEFAULT_INSTRUCTION.to_string() } @@ -223,6 +244,14 @@ impl Default for ToolOutputLimits { } } +impl Default for FileUploadLimits { + fn default() -> Self { + Self { + max_bytes: default_file_upload_max_bytes(), + } + } +} + impl ToolOutputLimits { /// Resolve the cap for a given tool name. pub fn limit_for(&self, tool_name: &str) -> usize { @@ -635,15 +664,19 @@ model_id = "claude-sonnet-4-20250514" } #[test] - fn omitted_tool_output_falls_back_to_default_16k() { + fn omitted_limits_fall_back_to_defaults() { let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap(); let limits = &manifest.worker.tool_output; - assert_eq!(limits.default_max_bytes, 16 * 1024); + assert_eq!(limits.default_max_bytes, defaults::TOOL_OUTPUT_MAX_BYTES); assert!(limits.per_tool.is_empty()); + assert_eq!( + manifest.worker.file_upload.max_bytes, + defaults::FILE_UPLOAD_MAX_BYTES + ); } #[test] - fn parse_tool_output_limits() { + fn parse_worker_output_limits() { let toml = MINIMAL_REQUIRED.replace( "[worker]\n", "[worker]\n\ @@ -651,7 +684,9 @@ model_id = "claude-sonnet-4-20250514" default_max_bytes = 8192\n\n\ [worker.tool_output.per_tool]\n\ Read = 32768\n\ - Grep = 4096\n", + Grep = 4096\n\n\ + [worker.file_upload]\n\ + max_bytes = 12345\n", ); let manifest = PodManifest::from_toml(&toml).unwrap(); let limits = &manifest.worker.tool_output; @@ -659,6 +694,7 @@ model_id = "claude-sonnet-4-20250514" assert_eq!(limits.limit_for("Read"), 32768); assert_eq!(limits.limit_for("Grep"), 4096); assert_eq!(limits.limit_for("Unknown"), 8192); + assert_eq!(manifest.worker.file_upload.max_bytes, 12345); } #[test] @@ -670,7 +706,7 @@ model_id = "claude-sonnet-4-20250514" ); let manifest = PodManifest::from_toml(&toml).unwrap(); let limits = &manifest.worker.tool_output; - assert_eq!(limits.default_max_bytes, 16 * 1024); + assert_eq!(limits.default_max_bytes, defaults::TOOL_OUTPUT_MAX_BYTES); assert!(limits.per_tool.is_empty()); } diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index afc44105..07ffe93f 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -1030,7 +1030,7 @@ impl Pod { let Segment::FileRef { path } = seg else { continue; }; - match view.resolve_file_ref(path, manifest::defaults::TOOL_OUTPUT_MAX_BYTES) { + match view.resolve_file_ref(path, self.manifest.worker.file_upload.max_bytes) { Ok(item) => out.push(item), Err(e) => { self.alert( @@ -3149,6 +3149,7 @@ mod build_summary_prompt_tests { stop_sequences: vec!["\n\n".into(), "".into()], reasoning: None, tool_output: manifest::ToolOutputLimits::default(), + file_upload: manifest::FileUploadLimits::default(), }; let config = request_config_from_worker_manifest(&manifest); diff --git a/crates/pod/tests/controller_test.rs b/crates/pod/tests/controller_test.rs index f73a8573..5f968d9f 100644 --- a/crates/pod/tests/controller_test.rs +++ b/crates/pod/tests/controller_test.rs @@ -127,7 +127,14 @@ async fn make_pod(client: MockClient) -> Pod { } async fn make_pod_with_pwd(client: MockClient) -> (Pod, std::path::PathBuf) { - let manifest = PodManifest::from_toml(MANIFEST_TOML).unwrap(); + make_pod_with_pwd_and_manifest(client, MANIFEST_TOML).await +} + +async fn make_pod_with_pwd_and_manifest( + client: MockClient, + manifest_toml: &str, +) -> (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(); std::mem::forget(store_tmp); @@ -538,6 +545,55 @@ async fn run_with_resolvable_file_ref_attaches_system_message_after_user() { ); } +#[tokio::test] +async fn run_with_file_ref_uses_manifest_file_upload_limit() { + let client = MockClient::new(simple_text_events()); + let client_for_assert = client.clone(); + let manifest_toml = format!("{MANIFEST_TOML}\n[worker.file_upload]\nmax_bytes = 5\n"); + let (pod, pwd) = make_pod_with_pwd_and_manifest(client, &manifest_toml).await; + std::fs::write(pwd.join("long.txt"), "abcdefghij").unwrap(); + let handle = spawn_controller(pod).await; + + handle + .send(Method::Run { + input: vec![protocol::Segment::FileRef { + path: "long.txt".into(), + }], + }) + .await + .unwrap(); + + 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 attachment = requests[0] + .items + .iter() + .find_map(|i| { + let text = i.as_text()?; + text.contains("[File: long.txt]").then_some(text) + }) + .expect("file attachment present"); + assert!(attachment.contains("abcde"), "got: {attachment:?}"); + assert!(!attachment.contains("abcdef"), "got: {attachment:?}"); + assert!( + attachment.contains("truncated, 10 bytes total"), + "got: {attachment:?}" + ); +} + #[tokio::test] async fn run_with_unresolved_segment_emits_alert_and_placeholder() { let client = MockClient::new(simple_text_events()); diff --git a/crates/tools/src/bash.rs b/crates/tools/src/bash.rs index 93dfa072..27156ab3 100644 --- a/crates/tools/src/bash.rs +++ b/crates/tools/src/bash.rs @@ -11,7 +11,7 @@ //! returned inline and the file is cleaned up. When it is longer the //! full output is left on disk and only the **last 80 lines** are //! returned, prefixed with the saved file's path. This sidesteps the -//! Worker's blanket `ToolOutputLimits` (default 16 KiB), which would +//! Worker's blanket `ToolOutputLimits` (default 64 KiB), which would //! otherwise drop the *tail* of the output — usually the most useful //! part (errors, exit messages, summary). The saved file lives under //! a caller-supplied directory that the parent has added to the @@ -56,7 +56,7 @@ const TAIL_LINES: usize = 80; /// Inline-return budget. Outputs at or below this are returned in full; /// above it triggers the spill-to-file path. Sized to leave headroom under -/// the Worker's 16 KiB default `ToolOutputLimits` cap so the inline path +/// the Worker's 64 KiB default `ToolOutputLimits` cap so the inline path /// reliably reaches the model intact. const INLINE_BYTE_BUDGET: usize = 12 * 1024; diff --git a/docs/manifest.toml b/docs/manifest.toml index ddecc082..e874eed8 100644 --- a/docs/manifest.toml +++ b/docs/manifest.toml @@ -131,16 +131,22 @@ ref = "anthropic/claude-sonnet-4-6" # reasoning = -1 # 任意。tool 実行 content の byte 長キャップ。 -# セクション省略時は default_max_bytes = 16 * 1024、per_tool 空。 +# セクション省略時は default_max_bytes = 64 * 1024、per_tool 空。 # [worker.tool_output] -# # 任意。デフォルト: 16384 (`defaults::TOOL_OUTPUT_MAX_BYTES` = 16 KiB)。 -# default_max_bytes = 16384 +# # 任意。デフォルト: 65536 (`defaults::TOOL_OUTPUT_MAX_BYTES` = 64 KiB)。 +# default_max_bytes = 65536 # # # 任意。デフォルト: 空マップ。tool 名キーで個別キャップ上書き。 # # キーは tool の登録名 ("Read", "Grep", "Glob", ...)。 # [worker.tool_output.per_tool] -# Read = 32768 -# Grep = 4096 +# Read = 131072 +# Grep = 8192 + +# 任意。submit 時の FileRef (`@`) upload / attachment byte 長キャップ。 +# Tool Output の truncation とは独立している。 +# [worker.file_upload] +# # 任意。デフォルト: 262144 (`defaults::FILE_UPLOAD_MAX_BYTES` = 256 KiB)。 +# max_bytes = 262144 # ===== [scope] ============================================================== diff --git a/docs/pod-factory.md b/docs/pod-factory.md index ab0408a8..774fae2e 100644 --- a/docs/pod-factory.md +++ b/docs/pod-factory.md @@ -12,7 +12,7 @@ overlay をマージして、検証済みの `PodManifest` と `PromptLoader` | 優先度 | 層 | 位置 | 典型的な内容 | |---|---|---|---| -| 1 | ビルトインのデフォルト | `manifest::defaults` モジュールの `pub const` 群を `PodManifestConfig::builtin_defaults()` が cascade 層として注入 | `tool_output.default_max_bytes = 16KB` など | +| 1 | ビルトインのデフォルト | `manifest::defaults` モジュールの `pub const` 群を `PodManifestConfig::builtin_defaults()` が cascade 層として注入 | `tool_output.default_max_bytes = 64 KiB`, `file_upload.max_bytes = 256 KiB` など | | 2 | ユーザー manifest | `/manifest.toml`(解決ルールは `manifest::paths`) | プロバイダ指定、デフォルトモデル、常用ツール設定 | | 3 | プロジェクト manifest | 起動ディレクトリから上方向に探索した最初の `/.insomnia/manifest.toml` | scope、compaction、プロジェクト固有の instruction | | 4 | プログラマティック overlay | CLI / GUI / 別 Pod からの spawn 等 | `pod.name`、spawn 時の `worker.instruction` のような Pod 固有値 | @@ -143,11 +143,14 @@ stop_sequences = ["\n\n", ""] reasoning = "medium" # 文字列 = effort label / 整数 = thinking budget tokens。詳細は docs/reasoning.md [worker.tool_output] -default_max_bytes = 16384 +default_max_bytes = 65536 [worker.tool_output.per_tool] -Read = 32768 -Grep = 4096 +Read = 131072 +Grep = 8192 + +[worker.file_upload] +max_bytes = 262144 [[scope.allow]] target = "/abs/path/to/project" @@ -210,8 +213,9 @@ scheme 側が吸収する。 | `top_k` | `u32` | 未指定 | top-k sampling。未対応 scheme では warning または provider 側挙動に任せる | | `stop_sequences` | `Vec` | `[]` | stop sequence。cascade では上層指定が配列ごと置換する | | `reasoning` | `String` または `i32` | 未指定 | reasoning / thinking 制御。詳細は `docs/reasoning.md` | -| `tool_output.default_max_bytes` | `usize` | `16384` | tool result `content` の既定 byte cap | +| `tool_output.default_max_bytes` | `usize` | `65536` | tool result `content` の既定 byte cap | | `tool_output.per_tool` | `Map` | `{}` | tool 名ごとの byte cap override | +| `file_upload.max_bytes` | `usize` | `262144` | submit 時の FileRef (`@`) upload / attachment の byte cap | 生成設定は provider 別の値域検証を行わない。型が TOML と合わない場合は manifest parse error になるが、provider が受け付けない値や組み合わせは API 応答で検出する。