feat: add manifest output upload limits

This commit is contained in:
Keisuke Hirata 2026-05-12 16:20:15 +09:00
parent 19730ba7c0
commit 5882341b21
8 changed files with 191 additions and 26 deletions

View File

@ -15,8 +15,8 @@ use serde::{Deserialize, Serialize};
use crate::defaults;
use crate::model::{AuthRef, ModelManifest, ReasoningControl};
use crate::{
CompactionConfig, MemoryConfig, PodManifest, PodMeta, ScopeConfig, 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<ReasoningControl>,
#[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<String, usize>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FileUploadLimitsPartial {
#[serde(default)]
pub max_bytes: Option<usize>,
}
#[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<PodManifestConfig> 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]

View File

@ -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`].

View File

@ -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<ReasoningControl>,
/// 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<String, usize>,
}
/// Byte-size cap for submit-time FileRef uploads / attachments.
///
/// This governs the `[File: <path>]` system-message attachment produced
/// when a user explicitly submits a `@<path>` 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());
}

View File

@ -1030,7 +1030,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
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(), "</stop>".into()],
reasoning: None,
tool_output: manifest::ToolOutputLimits::default(),
file_upload: manifest::FileUploadLimits::default(),
};
let config = request_config_from_worker_manifest(&manifest);

View File

@ -127,7 +127,14 @@ async fn make_pod(client: MockClient) -> Pod<MockClient, FsStore> {
}
async fn make_pod_with_pwd(client: MockClient) -> (Pod<MockClient, FsStore>, 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<MockClient, FsStore>, 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());

View File

@ -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;

View File

@ -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 (`@<path>`) upload / attachment byte 長キャップ。
# Tool Output の truncation とは独立している。
# [worker.file_upload]
# # 任意。デフォルト: 262144 (`defaults::FILE_UPLOAD_MAX_BYTES` = 256 KiB)。
# max_bytes = 262144
# ===== [scope] ==============================================================

View File

@ -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 | `<config_dir>/manifest.toml`(解決ルールは `manifest::paths` | プロバイダ指定、デフォルトモデル、常用ツール設定 |
| 3 | プロジェクト manifest | 起動ディレクトリから上方向に探索した最初の `<root>/.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", "</stop>"]
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<String>` | `[]` | 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<String, usize>` | `{}` | tool 名ごとの byte cap override |
| `file_upload.max_bytes` | `usize` | `262144` | submit 時の FileRef (`@<path>`) upload / attachment の byte cap |
生成設定は provider 別の値域検証を行わない。型が TOML と合わない場合は manifest
parse error になるが、provider が受け付けない値や組み合わせは API 応答で検出する。