Compare commits
7 Commits
59bf20f2cd
...
2b5da965ca
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b5da965ca | |||
| ba72a66a40 | |||
| 671e07a31e | |||
| 7ce4600a42 | |||
| 2f70411254 | |||
| 7c5b810fa1 | |||
| 86fc889606 |
20
.insomnia/knowledge/claude-code.md
Normal file
20
.insomnia/knowledge/claude-code.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
created_at: 2026-05-11T22:10:00Z
|
||||
updated_at: 2026-05-11T22:10:00Z
|
||||
kind: policy
|
||||
description: Claude Codeを用いてレビューやinsomniaだけではできないタスクを行う
|
||||
model_invokation: false
|
||||
user_invocable: true
|
||||
last_sources: []
|
||||
---
|
||||
|
||||
Bashツールを用いて`claude`を呼び出す。
|
||||
|
||||
`claude -p "<prompt>"`で非対話モードでのClaude Codeの利用が出来る。
|
||||
|
||||
また、`claude -p "<prompt>" --continue`を用いることで、直前のセッションを再開する形で実行できる。
|
||||
|
||||
|
||||
insomniaではまだできないのでclaudeにやらせたいタスク
|
||||
- WebSearch / WebFetch
|
||||
-
|
||||
5
.insomnia/memory/_usage/events.jsonl
Normal file
5
.insomnia/memory/_usage/events.jsonl
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{"id":"019e1729-5daf-7580-b765-095156c4009a","occurred_at":"2026-05-11T13:10:47.471344537Z","session_id":"019e1706-d800-7021-8042-e40debe644cd","event":"use","source":"WorkflowInvoke","records":[{"kind":"workflow","slug":"auto-maintain","file_bytes":6831,"file_tokens_estimate":1708}]}
|
||||
{"id":"019e1ac0-b9c0-7a31-a04c-12f6d74129b0","occurred_at":"2026-05-12T05:54:58.624201015Z","session_id":"019e1706-d800-7021-8042-e40debe644cd","event":"use","source":"WorkflowInvoke","records":[{"kind":"workflow","slug":"auto-maintain","file_bytes":6831,"file_tokens_estimate":1708}]}
|
||||
{"id":"019e1ac0-d0d0-7151-ae38-4cd148263cda","occurred_at":"2026-05-12T05:55:04.528959274Z","session_id":"019e1706-d800-7021-8042-e40debe644cd","event":"use","source":"WorkflowInvoke","records":[{"kind":"workflow","slug":"worktree-workflow","file_bytes":4188,"file_tokens_estimate":1047}]}
|
||||
{"id":"019e1b66-b238-7342-a75f-3b4258de8c92","occurred_at":"2026-05-12T08:56:15.672925627Z","session_id":"019e1b63-ae47-7123-89b6-3a49d73ae200","event":"use","source":"WorkflowInvoke","records":[{"kind":"workflow","slug":"auto-maintain","file_bytes":6783,"file_tokens_estimate":1696}]}
|
||||
{"id":"019e1b66-b239-71f0-90d7-968f4c0d2ee0","occurred_at":"2026-05-12T08:56:15.673018070Z","session_id":"019e1b63-ae47-7123-89b6-3a49d73ae200","event":"use","source":"WorkflowInvoke","records":[{"kind":"workflow","slug":"worktree-workflow","file_bytes":4188,"file_tokens_estimate":1047}]}
|
||||
|
|
@ -16,9 +16,6 @@ requires: []
|
|||
|
||||
- main workspace は制御面として扱う。
|
||||
- `.insomnia/`
|
||||
- `TODO.md`
|
||||
- `tickets/`
|
||||
- `docs/report/`
|
||||
- maintainer inbox / trial log
|
||||
- 実装差分は原則 child git worktree に隔離する。
|
||||
- child worktree には `.insomnia` を置かない。必要なら `/worktree-workflow` の手順に従い sparse checkout で `.insomnia` を除外する。
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ requires: []
|
|||
---
|
||||
# Worktree Workflow
|
||||
|
||||
Git worktree を使って、実装差分を main workspace から分離して進める。main workspace は ticket / review / inbox / `.insomnia` を持つ制御面、子 worktree はコード変更専用の作業面として扱う。
|
||||
Git worktree を使って、実装差分を main workspace から分離して進める。子 worktree はコード変更専用の作業面として扱う。
|
||||
|
||||
この Workflow は `.claude/skills/worktree-workflow/SKILL.md` の運用を insomnia 向けに移植したもの。insomnia では Pod の write scope が排他的に委譲されるため、子 worktree に `.insomnia` を置かず、親 Pod が main workspace 側の管理ファイルを書ける状態を保つ。
|
||||
insomnia では Pod の write scope が排他的に委譲されるため、子 worktree に `.insomnia` を置かず、親 Pod が main workspace 側の管理ファイルを書ける状態を保つ。
|
||||
|
||||
この Workflow は親 Pod / orchestrator が worktree を用意するための手順である。実装 Pod にこの Workflow を渡して worktree を作らせてはならない。実装 Pod は親 Pod から既存 child worktree を受け取り、その中で実装・build・test・報告だけを行う。
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ LLM に投げる context への割り込みは、大きく2種類に分かれる
|
|||
|
||||
---
|
||||
|
||||
## 実際のセッションを読んでデバッグする
|
||||
|
||||
`~/.insomnia/sessions`にすべてのセッションがある。jsonlなので、いい感じにBashで読むこと。
|
||||
|
||||
---
|
||||
|
||||
## Git操作
|
||||
|
||||
Gitはpush以外のすべての操作が許可されている。
|
||||
|
|
@ -53,6 +59,8 @@ b. 詳細化や前提の変化: `tickets/foo.md` を更新してcommit
|
|||
c. レビュー: `tickets/foo.md` にレビュー状態を追記 + `tickets/foo.review.md` を作成してcommit
|
||||
d. 完了: `tickets/foo.md` と `tickets/foo.review.md` を両方削除してcommit
|
||||
|
||||
worktreeと併用して作業を進める場合、必ずブランチを切る前に対象のチケットをコミットしてから切ること。
|
||||
|
||||
TODO.mdのリンクは完了後に切れるが、そのリンクを元にgitで消されたファイルを読み、内容を把握できる。
|
||||
`.review.md` にはレビューの指摘事項と判断結果を記載する。
|
||||
レビューはdiffの確認だけでなく、チケットはどのような前提・要件であり、それが達成されたかの確認まで含めて行う。
|
||||
|
|
|
|||
12
Cargo.lock
generated
12
Cargo.lock
generated
|
|
@ -1615,6 +1615,16 @@ dependencies = [
|
|||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lint-common"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
|
|
@ -1779,6 +1789,7 @@ dependencies = [
|
|||
"async-trait",
|
||||
"chrono",
|
||||
"libc",
|
||||
"lint-common",
|
||||
"llm-worker",
|
||||
"manifest",
|
||||
"schemars",
|
||||
|
|
@ -4404,6 +4415,7 @@ name = "workflow"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lint-common",
|
||||
"manifest",
|
||||
"memory",
|
||||
"serde",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ members = [
|
|||
"crates/provider",
|
||||
"crates/pod-registry",
|
||||
"crates/session-metrics",
|
||||
"crates/lint-common",
|
||||
"crates/tools",
|
||||
"crates/tui",
|
||||
"crates/memory",
|
||||
|
|
@ -28,6 +29,7 @@ client = { path = "crates/client" }
|
|||
llm-worker = { path = "crates/llm-worker", version = "0.2" }
|
||||
llm-worker-macros = { path = "crates/llm-worker-macros", version = "0.2" }
|
||||
manifest = { path = "crates/manifest" }
|
||||
lint-common = { path = "crates/lint-common" }
|
||||
memory = { path = "crates/memory" }
|
||||
workflow = { path = "crates/workflow" }
|
||||
pod-registry = { path = "crates/pod-registry" }
|
||||
|
|
|
|||
3
TODO.md
3
TODO.md
|
|
@ -3,7 +3,6 @@
|
|||
- 半自動開発運用 Workflow → [tickets/auto-maintain-workflow.md](tickets/auto-maintain-workflow.md)
|
||||
- AI maintainer 用 WorkItem / Thread 抽象 → [tickets/maintainer-work-items.md](tickets/maintainer-work-items.md)
|
||||
- Prompt / Workflow 評価メトリクスと改善 Offer → [tickets/prompt-eval-metrics.md](tickets/prompt-eval-metrics.md)
|
||||
- memory / workflow 共通基盤(Slug / frontmatter helpers)を別 crate に切り出す → [tickets/lint-common-crate.md](tickets/lint-common-crate.md)
|
||||
- Permission: allow-all 既定 policy への整理 → [tickets/permission-default-policy.md](tickets/permission-default-policy.md)
|
||||
- Pod CLI: マニフェスト関連フラグの整理 → [tickets/pod-cli-manifest-flags.md](tickets/pod-cli-manifest-flags.md)
|
||||
- Pod: 空応答ターン (Submit 後 AI 応答ゼロで Pause/Cancel) を自動巻き戻し → [tickets/pod-empty-turn-rollback.md](tickets/pod-empty-turn-rollback.md)
|
||||
|
|
@ -22,7 +21,7 @@
|
|||
- spawn 失敗時に Pod の stderr が TUI に表示されない → [tickets/tui-spawn-error-surface.md](tickets/tui-spawn-error-surface.md)
|
||||
- 巻き戻されたターンの入力テキストを編集領域に復元 → [tickets/tui-empty-turn-restore.md](tickets/tui-empty-turn-restore.md)
|
||||
- セッションコンテキスト長 / ウィンドウ占有率の常時表示 → [tickets/tui-context-usage-indicator.md](tickets/tui-context-usage-indicator.md)
|
||||
- Manifest: Tool Output / File Upload 上限の分離とデフォルト緩和 → [tickets/manifest-output-upload-limits.md](tickets/manifest-output-upload-limits.md)
|
||||
- Submit 時 FileRef でディレクトリを参照したときの挙動 → [tickets/file-ref-directory.md](tickets/file-ref-directory.md)
|
||||
- Prune: 保護境界を turn 数から末尾 token budget に置き換え → [tickets/prune-token-budget.md](tickets/prune-token-budget.md)
|
||||
- メモリ機構
|
||||
- extract / consolidation 監査ログ → [tickets/memory-audit-log.md](tickets/memory-audit-log.md)
|
||||
|
|
|
|||
13
crates/lint-common/Cargo.toml
Normal file
13
crates/lint-common/Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[package]
|
||||
name = "lint-common"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
81
crates/lint-common/src/frontmatter.rs
Normal file
81
crates/lint-common/src/frontmatter.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
//! Common frontmatter helpers.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::RecordLintError;
|
||||
|
||||
/// Trait record frontmatter types implement so linters can drive them uniformly.
|
||||
pub trait Frontmatter: Sized {
|
||||
/// Hard upper bound on body chars (excluding the frontmatter block).
|
||||
const BODY_LIMIT: usize;
|
||||
|
||||
fn created_at(&self) -> Option<DateTime<Utc>>;
|
||||
fn updated_at(&self) -> Option<DateTime<Utc>>;
|
||||
}
|
||||
|
||||
const FRONTMATTER_DELIM: &str = "---";
|
||||
|
||||
/// Split a markdown document into `(yaml_frontmatter, body)`.
|
||||
///
|
||||
/// Expects the document to start with `---\n` and have a closing
|
||||
/// `---\n` (or `---` at EOF) somewhere downstream. Trailing newline
|
||||
/// after the closing delimiter is consumed.
|
||||
pub fn split_frontmatter(content: &str) -> Result<(&str, &str), RecordLintError> {
|
||||
// The opening delimiter must be the very first line.
|
||||
let after_open = content
|
||||
.strip_prefix(FRONTMATTER_DELIM)
|
||||
.and_then(|s| s.strip_prefix('\n').or(Some(s)))
|
||||
.ok_or(RecordLintError::MissingFrontmatter)?;
|
||||
|
||||
// Look for the closing `---` on its own line.
|
||||
let mut yaml_end = None;
|
||||
let mut byte_offset = 0usize;
|
||||
for line in after_open.split_inclusive('\n') {
|
||||
let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
|
||||
if trimmed == FRONTMATTER_DELIM {
|
||||
yaml_end = Some((byte_offset, byte_offset + line.len()));
|
||||
break;
|
||||
}
|
||||
byte_offset += line.len();
|
||||
}
|
||||
|
||||
let (yaml_end_excl, body_start) = yaml_end.ok_or_else(|| {
|
||||
RecordLintError::MalformedFrontmatter("missing closing `---` line".to_string())
|
||||
})?;
|
||||
|
||||
let yaml = &after_open[..yaml_end_excl];
|
||||
let body = &after_open[body_start..];
|
||||
Ok((yaml, body))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn splits_simple() {
|
||||
let doc = "---\nfoo: 1\n---\nbody here\n";
|
||||
let (y, b) = split_frontmatter(doc).unwrap();
|
||||
assert_eq!(y, "foo: 1\n");
|
||||
assert_eq!(b, "body here\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_leading_delim_errors() {
|
||||
let err = split_frontmatter("hello").unwrap_err();
|
||||
assert!(matches!(err, RecordLintError::MissingFrontmatter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_closing_delim_errors() {
|
||||
let err = split_frontmatter("---\nfoo: 1\nno close\n").unwrap_err();
|
||||
assert!(matches!(err, RecordLintError::MalformedFrontmatter(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_empty_body() {
|
||||
let doc = "---\nfoo: 1\n---\n";
|
||||
let (_, b) = split_frontmatter(doc).unwrap();
|
||||
assert_eq!(b, "");
|
||||
}
|
||||
}
|
||||
20
crates/lint-common/src/lib.rs
Normal file
20
crates/lint-common/src/lib.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//! Shared record lint primitives for memory and workflow files.
|
||||
|
||||
mod frontmatter;
|
||||
mod slug;
|
||||
|
||||
pub use frontmatter::{Frontmatter, split_frontmatter};
|
||||
pub use slug::{Slug, is_valid_slug};
|
||||
|
||||
/// Common lint errors for Markdown record syntax shared by memory and workflow.
|
||||
#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum RecordLintError {
|
||||
#[error("invalid slug `{0}`: must match ^[a-z0-9](?:[a-z0-9-]{{0,62}}[a-z0-9])?$")]
|
||||
InvalidSlug(String),
|
||||
|
||||
#[error("malformed frontmatter: {0}")]
|
||||
MalformedFrontmatter(String),
|
||||
|
||||
#[error("frontmatter is missing or document is empty")]
|
||||
MissingFrontmatter,
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ use std::str::FromStr;
|
|||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::error::LintError;
|
||||
use crate::RecordLintError;
|
||||
|
||||
const MIN_LEN: usize = 1;
|
||||
const MAX_LEN: usize = 64;
|
||||
|
|
@ -23,13 +23,13 @@ const MAX_LEN: usize = 64;
|
|||
pub struct Slug(String);
|
||||
|
||||
impl Slug {
|
||||
/// Parse and validate. Returns [`LintError::InvalidSlug`] on rejection.
|
||||
pub fn parse(s: impl Into<String>) -> Result<Self, LintError> {
|
||||
/// Parse and validate. Returns [`RecordLintError::InvalidSlug`] on rejection.
|
||||
pub fn parse(s: impl Into<String>) -> Result<Self, RecordLintError> {
|
||||
let s = s.into();
|
||||
if is_valid_slug(&s) {
|
||||
Ok(Self(s))
|
||||
} else {
|
||||
Err(LintError::InvalidSlug(s))
|
||||
Err(RecordLintError::InvalidSlug(s))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -55,7 +55,7 @@ impl AsRef<str> for Slug {
|
|||
}
|
||||
|
||||
impl FromStr for Slug {
|
||||
type Err = LintError;
|
||||
type Err = RecordLintError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::parse(s)
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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`].
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ license.workspace = true
|
|||
async-trait = { workspace = true }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
libc = { workspace = true }
|
||||
lint-common = { workspace = true }
|
||||
llm-worker = { workspace = true }
|
||||
manifest = { workspace = true }
|
||||
schemars = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@
|
|||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::Slug;
|
||||
use crate::schema::{
|
||||
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, split_frontmatter,
|
||||
};
|
||||
use crate::slug::Slug;
|
||||
use crate::workspace::{RecordKind, WorkspaceLayout};
|
||||
|
||||
/// `sources` overflow を flag する閾値。`linter::warnings::SOURCES_OVERFLOW_THRESHOLD`
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use lint_common::RecordLintError;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Top-level error for memory operations that don't fit the lint flow.
|
||||
|
|
@ -40,14 +41,8 @@ pub enum LintError {
|
|||
#[error("path is for a different record kind than expected at this location: {}", .0.display())]
|
||||
WrongRecordKind(PathBuf),
|
||||
|
||||
#[error("invalid slug `{0}`: must match ^[a-z0-9](?:[a-z0-9-]{{0,62}}[a-z0-9])?$")]
|
||||
InvalidSlug(String),
|
||||
|
||||
#[error("malformed frontmatter: {0}")]
|
||||
MalformedFrontmatter(String),
|
||||
|
||||
#[error("frontmatter is missing or document is empty")]
|
||||
MissingFrontmatter,
|
||||
#[error(transparent)]
|
||||
Record(#[from] RecordLintError),
|
||||
|
||||
#[error("missing required frontmatter field: `{0}`")]
|
||||
MissingField(&'static str),
|
||||
|
|
|
|||
|
|
@ -13,17 +13,16 @@ pub mod linter;
|
|||
pub mod resident;
|
||||
pub mod schema;
|
||||
pub mod scope;
|
||||
pub mod slug;
|
||||
pub mod tool;
|
||||
pub mod usage;
|
||||
pub mod workspace;
|
||||
|
||||
pub use error::{LintError, LintWarning, MemoryError};
|
||||
pub use extract::ExtractPointerPayload;
|
||||
pub use lint_common::{RecordLintError, Slug, is_valid_slug};
|
||||
pub use linter::{LintReport, Linter};
|
||||
pub use resident::{ResidentKnowledgeEntry, collect_resident_knowledge, list_knowledge_slugs};
|
||||
pub use scope::deny_write_rules;
|
||||
pub use slug::Slug;
|
||||
pub use usage::{
|
||||
UsageEvent, UsageEventKind, UsageRecordSnapshot, UsageReport, UsageReportRecord, UsageSource,
|
||||
append_resident_exposure_event, append_usage_event, append_use_event, build_usage_report,
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ use std::collections::{HashMap, HashSet};
|
|||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::Slug;
|
||||
use crate::schema::{
|
||||
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, split_frontmatter,
|
||||
};
|
||||
use crate::slug::Slug;
|
||||
use crate::workspace::{RecordKind, WorkspaceLayout};
|
||||
|
||||
/// Snapshot of every record currently on disk under the workspace.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
//! YAML frontmatter parsing helpers shared by every kind.
|
||||
|
||||
use lint_common::RecordLintError;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::error::LintError;
|
||||
|
|
@ -26,7 +27,7 @@ fn map_serde_error(err: serde_yaml::Error) -> LintError {
|
|||
}
|
||||
return LintError::InvalidField { field, message };
|
||||
}
|
||||
LintError::MalformedFrontmatter(msg)
|
||||
LintError::Record(RecordLintError::MalformedFrontmatter(msg))
|
||||
}
|
||||
|
||||
fn parse_missing_field(msg: &str) -> Option<&'static str> {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ mod warnings;
|
|||
|
||||
use std::path::Path;
|
||||
|
||||
use lint_common::RecordLintError;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::error::{LintError, LintWarning};
|
||||
|
|
@ -104,8 +105,8 @@ impl Linter {
|
|||
let existing = match existing::scan_existing(&self.layout) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
report.push_error(LintError::MalformedFrontmatter(format!(
|
||||
"failed to scan existing records: {e}"
|
||||
report.push_error(LintError::Record(RecordLintError::MalformedFrontmatter(
|
||||
format!("failed to scan existing records: {e}"),
|
||||
)));
|
||||
return report;
|
||||
}
|
||||
|
|
@ -354,7 +355,8 @@ mod tests {
|
|||
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||
assert!(report.errors.iter().any(|e| matches!(
|
||||
e,
|
||||
LintError::MissingField(_) | LintError::MalformedFrontmatter(_)
|
||||
LintError::MissingField(_)
|
||||
| LintError::Record(RecordLintError::MalformedFrontmatter(_))
|
||||
)));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::Slug;
|
||||
use crate::error::LintError;
|
||||
use crate::linter::ExistingRecords;
|
||||
use crate::linter::LintReport;
|
||||
use crate::slug::Slug;
|
||||
use crate::workspace::RecordKind;
|
||||
|
||||
/// Validate a Decision's `replaced_by` against the existing record set.
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@
|
|||
//! integrated into the main linter pass when implemented; this file
|
||||
//! covers per-write checks that only need the proposed content.
|
||||
|
||||
use crate::Slug;
|
||||
use crate::error::LintWarning;
|
||||
use crate::linter::LintReport;
|
||||
use crate::linter::existing::ExistingRecords;
|
||||
use crate::slug::Slug;
|
||||
use crate::workspace::{ClassifiedPath, RecordKind};
|
||||
|
||||
const LARGE_BODY_THRESHOLD: usize = 1500;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
//! Common frontmatter helpers and shared types.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::LintError;
|
||||
|
||||
pub use lint_common::Frontmatter;
|
||||
|
||||
/// Reference to a session-store entry range. Stored in `sources` /
|
||||
/// `last_sources` arrays for traceability back to raw session logs.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -14,53 +15,15 @@ pub struct SourceRef {
|
|||
pub range: [u64; 2],
|
||||
}
|
||||
|
||||
/// Trait every kind-specific frontmatter implements so the linter can
|
||||
/// drive them uniformly.
|
||||
pub trait Frontmatter: Sized {
|
||||
/// Hard upper bound on body chars (excluding the frontmatter block).
|
||||
const BODY_LIMIT: usize;
|
||||
|
||||
fn created_at(&self) -> DateTime<Utc>;
|
||||
fn updated_at(&self) -> DateTime<Utc>;
|
||||
}
|
||||
|
||||
const FRONTMATTER_DELIM: &str = "---";
|
||||
|
||||
/// Split a markdown document into `(yaml_frontmatter, body)`.
|
||||
///
|
||||
/// Expects the document to start with `---\n` and have a closing
|
||||
/// `---\n` (or `---` at EOF) somewhere downstream. Trailing newline
|
||||
/// after the closing delimiter is consumed.
|
||||
pub fn split_frontmatter(content: &str) -> Result<(&str, &str), LintError> {
|
||||
// The opening delimiter must be the very first line.
|
||||
let after_open = content
|
||||
.strip_prefix(FRONTMATTER_DELIM)
|
||||
.and_then(|s| s.strip_prefix('\n').or(Some(s)))
|
||||
.ok_or(LintError::MissingFrontmatter)?;
|
||||
|
||||
// Look for the closing `---` on its own line.
|
||||
let mut yaml_end = None;
|
||||
let mut byte_offset = 0usize;
|
||||
for line in after_open.split_inclusive('\n') {
|
||||
let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
|
||||
if trimmed == FRONTMATTER_DELIM {
|
||||
yaml_end = Some((byte_offset, byte_offset + line.len()));
|
||||
break;
|
||||
}
|
||||
byte_offset += line.len();
|
||||
}
|
||||
|
||||
let (yaml_end_excl, body_start) = yaml_end
|
||||
.ok_or_else(|| LintError::MalformedFrontmatter("missing closing `---` line".to_string()))?;
|
||||
|
||||
let yaml = &after_open[..yaml_end_excl];
|
||||
let body = &after_open[body_start..];
|
||||
Ok((yaml, body))
|
||||
lint_common::split_frontmatter(content).map_err(Into::into)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lint_common::RecordLintError;
|
||||
|
||||
#[test]
|
||||
fn splits_simple() {
|
||||
|
|
@ -73,13 +36,19 @@ mod tests {
|
|||
#[test]
|
||||
fn no_leading_delim_errors() {
|
||||
let err = split_frontmatter("hello").unwrap_err();
|
||||
assert!(matches!(err, LintError::MissingFrontmatter));
|
||||
assert!(matches!(
|
||||
err,
|
||||
LintError::Record(RecordLintError::MissingFrontmatter)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_closing_delim_errors() {
|
||||
let err = split_frontmatter("---\nfoo: 1\nno close\n").unwrap_err();
|
||||
assert!(matches!(err, LintError::MalformedFrontmatter(_)));
|
||||
assert!(matches!(
|
||||
err,
|
||||
LintError::Record(RecordLintError::MalformedFrontmatter(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::Slug;
|
||||
use crate::schema::common::{Frontmatter, SourceRef};
|
||||
use crate::slug::Slug;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
|
|
@ -27,10 +27,10 @@ pub struct DecisionFrontmatter {
|
|||
impl Frontmatter for DecisionFrontmatter {
|
||||
const BODY_LIMIT: usize = 8000;
|
||||
|
||||
fn created_at(&self) -> DateTime<Utc> {
|
||||
self.created_at
|
||||
fn created_at(&self) -> Option<DateTime<Utc>> {
|
||||
Some(self.created_at)
|
||||
}
|
||||
fn updated_at(&self) -> DateTime<Utc> {
|
||||
self.updated_at
|
||||
fn updated_at(&self) -> Option<DateTime<Utc>> {
|
||||
Some(self.updated_at)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,10 +24,10 @@ pub struct KnowledgeFrontmatter {
|
|||
impl Frontmatter for KnowledgeFrontmatter {
|
||||
const BODY_LIMIT: usize = 8000;
|
||||
|
||||
fn created_at(&self) -> DateTime<Utc> {
|
||||
self.created_at
|
||||
fn created_at(&self) -> Option<DateTime<Utc>> {
|
||||
Some(self.created_at)
|
||||
}
|
||||
fn updated_at(&self) -> DateTime<Utc> {
|
||||
self.updated_at
|
||||
fn updated_at(&self) -> Option<DateTime<Utc>> {
|
||||
Some(self.updated_at)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ pub struct RequestFrontmatter {
|
|||
impl Frontmatter for RequestFrontmatter {
|
||||
const BODY_LIMIT: usize = 8000;
|
||||
|
||||
fn created_at(&self) -> DateTime<Utc> {
|
||||
self.created_at
|
||||
fn created_at(&self) -> Option<DateTime<Utc>> {
|
||||
Some(self.created_at)
|
||||
}
|
||||
fn updated_at(&self) -> DateTime<Utc> {
|
||||
self.updated_at
|
||||
fn updated_at(&self) -> Option<DateTime<Utc>> {
|
||||
Some(self.updated_at)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,10 +23,10 @@ impl Frontmatter for SummaryFrontmatter {
|
|||
/// than per-record kinds (~5k tokens at the upper end).
|
||||
const BODY_LIMIT: usize = 20000;
|
||||
|
||||
fn created_at(&self) -> DateTime<Utc> {
|
||||
self.created_at.unwrap_or(self.updated_at)
|
||||
fn created_at(&self) -> Option<DateTime<Utc>> {
|
||||
Some(self.created_at.unwrap_or(self.updated_at))
|
||||
}
|
||||
fn updated_at(&self) -> DateTime<Utc> {
|
||||
self.updated_at
|
||||
fn updated_at(&self) -> Option<DateTime<Utc>> {
|
||||
Some(self.updated_at)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ use std::path::PathBuf;
|
|||
use llm_worker::tool::ToolError;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::slug::Slug;
|
||||
use crate::Slug;
|
||||
use crate::workspace::{RecordKind, WorkspaceLayout};
|
||||
|
||||
pub use edit::edit_tool;
|
||||
|
|
|
|||
|
|
@ -227,7 +227,7 @@ fn record_path(
|
|||
}
|
||||
}
|
||||
|
||||
fn invalid_slug_error(err: crate::LintError) -> io::Error {
|
||||
fn invalid_slug_error(err: lint_common::RecordLintError) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,8 +22,9 @@
|
|||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::Slug;
|
||||
use crate::error::LintError;
|
||||
use crate::slug::Slug;
|
||||
use lint_common::RecordLintError;
|
||||
|
||||
const INSOMNIA_DIR: &str = ".insomnia";
|
||||
const MEMORY_DIR: &str = "memory";
|
||||
|
|
@ -159,7 +160,7 @@ impl WorkspaceLayout {
|
|||
///
|
||||
/// On a conventional path that's *almost* a record but malformed
|
||||
/// (e.g. `.insomnia/memory/decisions/Foo.md` with an invalid slug),
|
||||
/// returns `Err(LintError::InvalidSlug | InvalidPath)` so the caller
|
||||
/// returns `Err(LintError::Record(InvalidSlug) | InvalidPath)` so the caller
|
||||
/// can surface it as a write violation.
|
||||
pub fn classify(&self, path: &Path) -> Result<Option<ClassifiedPath>, LintError> {
|
||||
let memory = self.memory_dir();
|
||||
|
|
@ -320,7 +321,10 @@ mod tests {
|
|||
let err = layout()
|
||||
.classify(&PathBuf::from("/ws/.insomnia/memory/decisions/Foo.md"))
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, LintError::InvalidSlug(_)));
|
||||
assert!(matches!(
|
||||
err,
|
||||
LintError::Record(RecordLintError::InvalidSlug(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
@ -1221,7 +1221,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
continue;
|
||||
};
|
||||
let parsed = workflow_crate::Slug::parse(slug.clone())
|
||||
.map_err(WorkflowResolveError::InvalidSlug)?;
|
||||
.map_err(|source| WorkflowResolveError::InvalidSlug(source.into()))?;
|
||||
let record = self
|
||||
.workflow_registry
|
||||
.get(&parsed)
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -77,7 +77,8 @@ pub fn resolve_workflow_invocation(
|
|||
layout: &WorkspaceLayout,
|
||||
raw_slug: &str,
|
||||
) -> Result<Vec<Item>, WorkflowResolveError> {
|
||||
let slug = Slug::parse(raw_slug.to_string()).map_err(WorkflowResolveError::InvalidSlug)?;
|
||||
let slug = Slug::parse(raw_slug.to_string())
|
||||
.map_err(|source| WorkflowResolveError::InvalidSlug(source.into()))?;
|
||||
let record = registry
|
||||
.get(&slug)
|
||||
.ok_or_else(|| WorkflowResolveError::NotFound {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ license.workspace = true
|
|||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
lint-common = { workspace = true }
|
||||
manifest = { workspace = true }
|
||||
memory = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
|
|
|||
|
|
@ -2,19 +2,14 @@
|
|||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use lint_common::RecordLintError;
|
||||
use thiserror::Error;
|
||||
|
||||
/// A single Workflow linter violation.
|
||||
#[derive(Debug, Clone, Error, PartialEq, Eq)]
|
||||
pub enum WorkflowLintError {
|
||||
#[error("invalid slug `{0}`: must match ^[a-z0-9](?:[a-z0-9-]{{0,62}}[a-z0-9])?$")]
|
||||
InvalidSlug(String),
|
||||
|
||||
#[error("malformed frontmatter: {0}")]
|
||||
MalformedFrontmatter(String),
|
||||
|
||||
#[error("frontmatter is missing or document is empty")]
|
||||
MissingFrontmatter,
|
||||
#[error(transparent)]
|
||||
Record(#[from] RecordLintError),
|
||||
|
||||
#[error("missing required frontmatter field: `{0}`")]
|
||||
MissingField(&'static str),
|
||||
|
|
|
|||
|
|
@ -5,17 +5,16 @@ mod linter;
|
|||
mod schema;
|
||||
mod scope;
|
||||
mod skill;
|
||||
mod slug;
|
||||
mod workflow;
|
||||
|
||||
pub use error::WorkflowLintError;
|
||||
pub use lint_common::{RecordLintError, Slug, is_valid_slug};
|
||||
pub use linter::{WorkflowLintReport, WorkflowLinter};
|
||||
pub use schema::{WorkflowFrontmatter, split_frontmatter};
|
||||
pub use scope::deny_write_rules;
|
||||
pub use skill::{
|
||||
SKILL_FILENAME, SkillParseError, SkillRecord, load_skills_from_dir, parse_skill_md,
|
||||
};
|
||||
pub use slug::{Slug, is_valid_slug};
|
||||
pub use workflow::{
|
||||
ResidentWorkflowEntry, ShadowedSkill, WORKFLOW_DESCRIPTION_HARD_CAP, WorkflowLoadError,
|
||||
WorkflowRecord, WorkflowRegistry, WorkflowSource, load_workflows,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use std::collections::HashSet;
|
|||
use memory::WorkspaceLayout;
|
||||
|
||||
use crate::{Slug, WorkflowLintError};
|
||||
use lint_common::RecordLintError;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::schema::{WORKFLOW_BODY_LIMIT, WorkflowFrontmatter, split_frontmatter};
|
||||
|
|
@ -74,9 +75,11 @@ impl WorkflowLinter {
|
|||
let knowledge = match scan_knowledge_slugs(&self.layout) {
|
||||
Ok(knowledge) => knowledge,
|
||||
Err(err) => {
|
||||
report.push_error(WorkflowLintError::MalformedFrontmatter(format!(
|
||||
"failed to scan existing Knowledge records: {err}"
|
||||
)));
|
||||
report.push_error(WorkflowLintError::Record(
|
||||
RecordLintError::MalformedFrontmatter(format!(
|
||||
"failed to scan existing Knowledge records: {err}"
|
||||
)),
|
||||
));
|
||||
return report;
|
||||
}
|
||||
};
|
||||
|
|
@ -109,7 +112,7 @@ fn parse_frontmatter<F: DeserializeOwned>(
|
|||
if let Some(field) = parse_missing_field(&msg) {
|
||||
WorkflowLintError::MissingField(field)
|
||||
} else {
|
||||
WorkflowLintError::MalformedFrontmatter(msg)
|
||||
WorkflowLintError::Record(RecordLintError::MalformedFrontmatter(msg))
|
||||
}
|
||||
})?;
|
||||
Ok(Parsed { frontmatter, body })
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! Workflow frontmatter schema and frontmatter splitting helpers.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use lint_common::Frontmatter;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Slug, WorkflowLintError};
|
||||
|
|
@ -24,42 +25,31 @@ pub struct WorkflowFrontmatter {
|
|||
pub requires: Vec<Slug>,
|
||||
}
|
||||
|
||||
impl Frontmatter for WorkflowFrontmatter {
|
||||
const BODY_LIMIT: usize = WORKFLOW_BODY_LIMIT;
|
||||
|
||||
fn created_at(&self) -> Option<DateTime<Utc>> {
|
||||
self.created_at
|
||||
}
|
||||
|
||||
fn updated_at(&self) -> Option<DateTime<Utc>> {
|
||||
self.updated_at
|
||||
}
|
||||
}
|
||||
|
||||
fn default_user_invocable() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
const FRONTMATTER_DELIM: &str = "---";
|
||||
|
||||
/// Split a markdown document into `(yaml_frontmatter, body)`.
|
||||
pub fn split_frontmatter(content: &str) -> Result<(&str, &str), WorkflowLintError> {
|
||||
let after_open = content
|
||||
.strip_prefix(FRONTMATTER_DELIM)
|
||||
.and_then(|s| s.strip_prefix('\n').or(Some(s)))
|
||||
.ok_or(WorkflowLintError::MissingFrontmatter)?;
|
||||
|
||||
let mut yaml_end = None;
|
||||
let mut byte_offset = 0usize;
|
||||
for line in after_open.split_inclusive('\n') {
|
||||
let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
|
||||
if trimmed == FRONTMATTER_DELIM {
|
||||
yaml_end = Some((byte_offset, byte_offset + line.len()));
|
||||
break;
|
||||
}
|
||||
byte_offset += line.len();
|
||||
}
|
||||
|
||||
let (yaml_end_excl, body_start) = yaml_end.ok_or_else(|| {
|
||||
WorkflowLintError::MalformedFrontmatter("missing closing `---` line".to_string())
|
||||
})?;
|
||||
|
||||
let yaml = &after_open[..yaml_end_excl];
|
||||
let body = &after_open[body_start..];
|
||||
Ok((yaml, body))
|
||||
lint_common::split_frontmatter(content).map_err(Into::into)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lint_common::RecordLintError;
|
||||
|
||||
#[test]
|
||||
fn splits_simple() {
|
||||
|
|
@ -72,13 +62,19 @@ mod tests {
|
|||
#[test]
|
||||
fn no_leading_delim_errors() {
|
||||
let err = split_frontmatter("hello").unwrap_err();
|
||||
assert!(matches!(err, WorkflowLintError::MissingFrontmatter));
|
||||
assert!(matches!(
|
||||
err,
|
||||
WorkflowLintError::Record(RecordLintError::MissingFrontmatter)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_closing_delim_errors() {
|
||||
let err = split_frontmatter("---\nfoo: 1\nno close\n").unwrap_err();
|
||||
assert!(matches!(err, WorkflowLintError::MalformedFrontmatter(_)));
|
||||
assert!(matches!(
|
||||
err,
|
||||
WorkflowLintError::Record(RecordLintError::MalformedFrontmatter(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use lint_common::RecordLintError;
|
||||
use serde::Deserialize;
|
||||
use thiserror::Error;
|
||||
use tracing::warn;
|
||||
|
|
@ -150,7 +151,9 @@ pub fn parse_skill_md(skill_md_path: &Path) -> Result<SkillRecord, SkillParseErr
|
|||
let frontmatter: SkillFrontmatter =
|
||||
serde_yaml::from_str(yaml).map_err(|err| SkillParseError::Frontmatter {
|
||||
path: skill_md_path.to_path_buf(),
|
||||
source: WorkflowLintError::MalformedFrontmatter(err.to_string()),
|
||||
source: WorkflowLintError::Record(RecordLintError::MalformedFrontmatter(
|
||||
err.to_string(),
|
||||
)),
|
||||
})?;
|
||||
|
||||
if frontmatter.allowed_tools.is_some() {
|
||||
|
|
@ -183,7 +186,7 @@ pub fn parse_skill_md(skill_md_path: &Path) -> Result<SkillRecord, SkillParseErr
|
|||
}
|
||||
let slug = Slug::parse(frontmatter.name).map_err(|source| SkillParseError::InvalidName {
|
||||
skill_md_path: skill_md_path.to_path_buf(),
|
||||
source,
|
||||
source: source.into(),
|
||||
})?;
|
||||
|
||||
Ok(SkillRecord {
|
||||
|
|
|
|||
|
|
@ -1,146 +0,0 @@
|
|||
//! Slug type and validation.
|
||||
//!
|
||||
//! Syntax (agent-skills compatible):
|
||||
//! ^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$
|
||||
//! - 1–64 chars
|
||||
//! - lowercase ASCII alphanumerics and `-`
|
||||
//! - cannot start or end with `-`
|
||||
//! - no consecutive `--`
|
||||
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::WorkflowLintError;
|
||||
|
||||
const MIN_LEN: usize = 1;
|
||||
const MAX_LEN: usize = 64;
|
||||
|
||||
/// Validated slug. Constructible only via [`Slug::parse`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Slug(String);
|
||||
|
||||
impl Slug {
|
||||
/// Parse and validate. Returns [`WorkflowLintError::InvalidSlug`] on rejection.
|
||||
pub fn parse(s: impl Into<String>) -> Result<Self, WorkflowLintError> {
|
||||
let s = s.into();
|
||||
if is_valid_slug(&s) {
|
||||
Ok(Self(s))
|
||||
} else {
|
||||
Err(WorkflowLintError::InvalidSlug(s))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Slug {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for Slug {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Slug {
|
||||
type Err = WorkflowLintError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::parse(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Slug {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let raw = String::deserialize(deserializer)?;
|
||||
Self::parse(raw).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure-fn predicate matching the agent-skills slug regex without
|
||||
/// pulling in the `regex` crate.
|
||||
pub fn is_valid_slug(s: &str) -> bool {
|
||||
let bytes = s.as_bytes();
|
||||
let len = bytes.len();
|
||||
if len < MIN_LEN || len > MAX_LEN {
|
||||
return false;
|
||||
}
|
||||
if !is_alnum_lower(bytes[0]) || !is_alnum_lower(bytes[len - 1]) {
|
||||
return false;
|
||||
}
|
||||
let mut prev_dash = false;
|
||||
for &b in bytes {
|
||||
if b == b'-' {
|
||||
if prev_dash {
|
||||
return false;
|
||||
}
|
||||
prev_dash = true;
|
||||
} else if is_alnum_lower(b) {
|
||||
prev_dash = false;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn is_alnum_lower(b: u8) -> bool {
|
||||
b.is_ascii_digit() || b.is_ascii_lowercase()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accepts_basic_slugs() {
|
||||
for s in ["a", "ab", "abc-def", "x9", "a-b-c", "123", "a-1"] {
|
||||
assert!(is_valid_slug(s), "expected `{s}` valid");
|
||||
assert!(Slug::parse(s).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_slugs() {
|
||||
for s in [
|
||||
"", "-", "-foo", "foo-", "Foo", "foo_bar", "foo bar", "foo--bar", "foo.bar", "ä",
|
||||
] {
|
||||
assert!(!is_valid_slug(s), "expected `{s}` invalid");
|
||||
assert!(Slug::parse(s).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforces_length_bounds() {
|
||||
let too_long = "a".repeat(MAX_LEN + 1);
|
||||
assert!(!is_valid_slug(&too_long));
|
||||
let max = "a".repeat(MAX_LEN);
|
||||
assert!(is_valid_slug(&max));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_via_serde() {
|
||||
let json = "\"valid-slug\"";
|
||||
let slug: Slug = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(slug.as_str(), "valid-slug");
|
||||
|
||||
let bad = "\"BAD\"";
|
||||
let err: Result<Slug, _> = serde_json::from_str(bad);
|
||||
assert!(err.is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ use thiserror::Error;
|
|||
use tracing::warn;
|
||||
|
||||
use crate::schema::{WorkflowFrontmatter, split_frontmatter};
|
||||
use lint_common::RecordLintError;
|
||||
use memory::WorkspaceLayout;
|
||||
|
||||
use crate::{Slug, WorkflowLintError};
|
||||
|
|
@ -218,7 +219,7 @@ pub fn load_workflows(layout: &WorkspaceLayout) -> Result<WorkflowRegistry, Work
|
|||
let slug =
|
||||
Slug::parse(stem.to_string()).map_err(|source| WorkflowLoadError::InvalidSlug {
|
||||
path: path.clone(),
|
||||
source,
|
||||
source: source.into(),
|
||||
})?;
|
||||
if records.contains_key(&slug) {
|
||||
warn!(slug = %slug, path = %path.display(), "duplicate workflow slug encountered; keeping first record");
|
||||
|
|
@ -292,7 +293,7 @@ fn map_serde_workflow_error(err: serde_yaml::Error) -> WorkflowLintError {
|
|||
if let Some(field) = parse_missing_field(&msg) {
|
||||
return WorkflowLintError::MissingField(field);
|
||||
}
|
||||
WorkflowLintError::MalformedFrontmatter(msg)
|
||||
WorkflowLintError::Record(RecordLintError::MalformedFrontmatter(msg))
|
||||
}
|
||||
|
||||
fn parse_missing_field(msg: &str) -> Option<&'static str> {
|
||||
|
|
|
|||
|
|
@ -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] ==============================================================
|
||||
|
|
|
|||
|
|
@ -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 応答で検出する。
|
||||
|
|
|
|||
106
docs/report/2026-05-10-ticket-lifecycle-branch-placement.md
Normal file
106
docs/report/2026-05-10-ticket-lifecycle-branch-placement.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# Ticket lifecycle commit の配置ミス
|
||||
|
||||
日付: 2026-05-10
|
||||
|
||||
## 要旨
|
||||
|
||||
今回の `memory-usage-metrics` の review / merge workflow では、ticket lifecycle の状態遷移 commit を feature branch ではなく `develop` 側に作ってしまった。実装 branch を merge する前に review artifact 作成と ticket 完了削除を行う、という順序だけでなく、「その操作をどの branch / worktree で行うか」を workflow が強制・確認できていないことが問題だった。
|
||||
|
||||
## 今回起きたこと
|
||||
|
||||
`memory-usage-metrics` の実装 branch を review した後、親 worktree で以下を行った。
|
||||
|
||||
1. `tickets/memory-usage-metrics.review.md` を作成
|
||||
2. `tickets/memory-usage-metrics.md` にレビュー状態を追記
|
||||
3. `develop` に merge
|
||||
|
||||
その後、完了時には `.review.md` と ticket 本体を消す必要がある、という指摘を受けて、いったん merge を巻き戻して以下の順序に直した。
|
||||
|
||||
1. review commit
|
||||
2. ticket / review / TODO 削除 commit
|
||||
3. merge commit
|
||||
|
||||
しかし、この修正も `develop` 側で review commit と ticket 完了 commit を作っており、feature branch 側の lifecycle としては不正だった。最終的には、これらの commit を `memory-usage-metrics` branch に移し、`develop` は feature branch を merge するだけの形に直した。
|
||||
|
||||
最終形は次のようになった。
|
||||
|
||||
```text
|
||||
* merge: memory usage metrics # develop
|
||||
|\
|
||||
| * docs(tickets): complete memory usage metrics
|
||||
| * review: memory usage metrics
|
||||
| * feat: add memory usage event metrics # memory-usage-metrics
|
||||
|/
|
||||
* docs(tickets): complete memory phase naming cleanup
|
||||
```
|
||||
|
||||
## 障壁
|
||||
|
||||
### 1. Ticket lifecycle の「順序」と「配置」が別々に失敗しうる
|
||||
|
||||
現行 lifecycle は、作成・レビュー・完了を commit の履歴で表現する。ただし、守るべき制約は少なくとも二つある。
|
||||
|
||||
- review commit の後に completion commit を置く
|
||||
- それらを対象 ticket の作業 branch 側に置く
|
||||
|
||||
今回、最初の修正では前者だけを直し、後者を落とした。workflow の説明や自分のチェック観点が「merge 前に `.review.md` を消す」へ寄りすぎており、「それを向こうの branch でやる」という branch placement を明示的な検査項目にしていなかった。
|
||||
|
||||
### 2. 親 worktree で操作していると lifecycle commit を `develop` に作りやすい
|
||||
|
||||
reviewer / orchestrator は親 worktree にいることが多い。そこで ticket file を編集すると、自然に `develop` 上の変更になる。
|
||||
|
||||
一方で、このプロジェクトの運用では ticket / review artifact も feature branch の成果物として扱うべきで、`develop` の first-parent に review や ticket 完了 commit が直接並ぶのは望ましくない。親 worktree でレビュー文面を作ること自体は可能でも、commit 先は feature branch worktree でなければならない。
|
||||
|
||||
### 3. Git 履歴の見え方を確認するタイミングが遅かった
|
||||
|
||||
`git log --graph` は確認したが、最初は「merge commit ができたか」「ticket が消えているか」を見ており、first-parent 上に lifecycle commit が混ざっていないかを確認していなかった。
|
||||
|
||||
この問題は `git log --first-parent --oneline` を merge 前後に見るだけで早期に検出できる。feature branch workflow では、merge 前の `develop` first-parent に review / completion commit が増えていたら誤りである。
|
||||
|
||||
### 4. 無関係な `AGENTS.md` 変更を commit に巻き込みかけた
|
||||
|
||||
review commit 作成時に、既存の unrelated な `AGENTS.md` 変更を一度 commit に含めてしまい、soft reset で修正した。これは branch placement とは別問題だが、同じ workflow の中で起きた安全確認不足である。
|
||||
|
||||
`git add` の対象を明示しても、既に index に入っている unrelated change があると混入しうる。作業前に `git status --short` だけでなく `git diff --cached --stat` を確認する必要がある。
|
||||
|
||||
## 影響
|
||||
|
||||
- `develop` の first-parent に、feature branch 内で完結すべき review / ticket 完了 commit が直接混ざる
|
||||
- ticket lifecycle の履歴を辿る時に、対象 branch の作業としてまとまって見えない
|
||||
- merge 前後の状態遷移が曖昧になり、後から履歴を直すために reset / cherry-pick / re-merge が必要になる
|
||||
- unrelated change の混入リスクが上がる
|
||||
|
||||
## 暫定運用
|
||||
|
||||
feature branch の ticket を review / complete するときは、次を標準手順にする。
|
||||
|
||||
1. 親 worktree ではなく対象 branch worktree に移動する
|
||||
2. `git status --short --branch` で現在 branch を確認する
|
||||
3. review artifact を作成し、ticket にレビュー状態を追記して commit する
|
||||
4. completion として ticket / review / TODO を削除して commit する
|
||||
5. 親 worktree の `develop` に戻り、対象 branch を merge する
|
||||
6. `git log --first-parent --oneline -5` で `develop` first-parent に lifecycle commit が直接載っていないことを確認する
|
||||
7. `git status --short` と `git diff --cached --stat` で unrelated change が混ざっていないことを確認する
|
||||
|
||||
## 改善案
|
||||
|
||||
### A. Workflow checklist に branch placement を明記する
|
||||
|
||||
「レビューを書いたか」「ticket を消したか」だけでは足りない。ticket lifecycle commit は対象 branch に置く、という項目を review / merge workflow の checklist に入れる。
|
||||
|
||||
### B. Merge 前検査をコマンド化する
|
||||
|
||||
merge 前に以下を確認する小さな doctor / script があるとよい。
|
||||
|
||||
- current branch が `develop` か
|
||||
- feature branch 側に `.review.md` 作成 commit と ticket completion commit があるか
|
||||
- `develop` first-parent が merge base から進んでいないか、または進んでいる場合は意図した mainline commit だけか
|
||||
- index に unrelated staged changes がないか
|
||||
|
||||
### C. Ticket lifecycle 操作用の workflow を作る
|
||||
|
||||
review artifact 作成、ticket 状態追記、completion deletion、merge 前 first-parent 確認を一つの workflow としてまとめると、手順の抜けが減る。特に branch/worktree の切り替えを workflow 側が明示的に促すだけでも効果がある。
|
||||
|
||||
## 現時点の判断
|
||||
|
||||
今回の問題は Git 操作ミスというより、file-based ticket lifecycle を branch workflow に載せる時の検査不足である。今後は「どの順序で状態遷移したか」だけでなく、「どの branch に状態遷移 commit を置いたか」を first-class な確認項目にする必要がある。
|
||||
64
tickets/file-ref-directory.md
Normal file
64
tickets/file-ref-directory.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Submit 時 FileRef でディレクトリを参照したときの挙動
|
||||
|
||||
## 背景
|
||||
|
||||
submit 時に `Segment::FileRef { path }` は `crates/pod/src/fs_view.rs` の
|
||||
`PodFsView::resolve_file_ref` でファイル本文として読まれ、`[File: <path>]` system
|
||||
message に展開される。内部では `ScopedFs::read_bytes` を経由するため、`path` が
|
||||
ディレクトリだった場合は `ToolsError::IsDirectory` で失敗し、Pod 側は
|
||||
`AlertLevel::Warn` を出してそのセグメントを丸ごと捨てる
|
||||
(`crates/pod/src/pod.rs` の `resolve_file_refs`)。
|
||||
|
||||
ユーザーから見ると `@some-dir` を添付しても LLM 側にはプレースホルダが残る
|
||||
だけで、ディレクトリ参照という意図が反映されない。TUI completion は
|
||||
ディレクトリも候補として出すので、実際の挙動とのギャップが大きい。
|
||||
|
||||
`tickets/file-ref-symlink-diagnostics.md` は symlink が canonicalize 後に
|
||||
ディレクトリ・scope 外だった場合の診断にフォーカスしており、通常ディレクトリの
|
||||
FileRef 解決をどう扱うかは扱っていない。
|
||||
|
||||
## ゴール
|
||||
|
||||
submit 時に `Segment::FileRef` がディレクトリを指している場合の挙動を
|
||||
仕様として確定し、ユーザーが添付の意図どおりの結果を LLM に届けられる
|
||||
ようにする。
|
||||
|
||||
## 要件
|
||||
|
||||
ディレクトリに対する FileRef の意味論を決め、実装する。少なくとも以下の
|
||||
論点をチケット内で結論づけ、実装・テスト・ドキュメントを揃える:
|
||||
|
||||
- 採用する挙動: 浅い entry listing を `[Dir: <path>]` 等の label で返すか、
|
||||
明示的に reject して `read_file` / `glob` の利用を促すか、それ以外か
|
||||
- listing を返す場合の上限: entry 件数や本文 byte 数を、ファイル本文用の
|
||||
upload/attachment 上限と同じにするか別途持つか
|
||||
- 隠しファイル・gitignore・scope 外 entry の扱い
|
||||
- symlink entry の扱いは `tickets/file-ref-symlink-diagnostics.md` と矛盾
|
||||
しないこと(重複する判定は共通化を検討する)
|
||||
- TUI completion がディレクトリ候補を出す挙動と整合すること(候補に出る
|
||||
以上、submit 時に黙って捨てるのは UX として不整合)
|
||||
|
||||
## 完了条件
|
||||
|
||||
- 通常ディレクトリの FileRef が、`IsDirectory` Warn で黙って捨てられる
|
||||
現状の挙動を残していない
|
||||
- 採用した挙動が `PodFsView` のテストで覆われている
|
||||
- `Segment::FileRef` のドキュメント / コメントが新仕様に揃っている
|
||||
- TUI completion とのギャップが解消されている(ディレクトリを候補から
|
||||
外すのか、submit でも扱うのか、いずれかに寄せる)
|
||||
|
||||
## 範囲外
|
||||
|
||||
- 再帰的なディレクトリ走査、glob 展開、`tree` 風の深い表現
|
||||
- ディレクトリ内ファイルの自動 read 集約(auto-read / fs view 側の責務)
|
||||
- symlink 経由のディレクトリ判定の刷新
|
||||
(`tickets/file-ref-symlink-diagnostics.md` 側で扱う)
|
||||
- upload / attachment 上限の manifest 化(`tickets/manifest-output-upload-limits.md`)
|
||||
|
||||
## 参照
|
||||
|
||||
- `crates/pod/src/fs_view.rs` `resolve_file_ref` / `list_file_completions`
|
||||
- `crates/pod/src/pod.rs` `resolve_file_refs`
|
||||
- `crates/tools/src/scoped_fs.rs` `read_bytes`
|
||||
- `tickets/file-ref-symlink-diagnostics.md`
|
||||
- `tickets/manifest-output-upload-limits.md`
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
# memory / workflow の共通基盤を別 crate に切り出す
|
||||
|
||||
## 背景
|
||||
|
||||
`tickets/workflow-crate-extraction.md`(完了済 / git log 参照)で Workflow を `crates/memory/` から `crates/workflow/` に切り出した際、依存方向を「workflow → memory(`WorkspaceLayout` のみ)」に限定するため、本来共通であるべき型・関数を **両 crate にコピペで重複** させて済ませている。
|
||||
|
||||
具体的に重複しているもの:
|
||||
|
||||
- **`Slug` 型と `is_valid_slug`**: `crates/memory/src/slug.rs` と `crates/workflow/src/slug.rs` がエラー型(`LintError::InvalidSlug` / `WorkflowLintError::InvalidSlug`)以外完全に同じ。テストごと丸ごとコピー。
|
||||
- **`split_frontmatter`**: `crates/memory/src/schema/common.rs` と `crates/workflow/src/schema.rs` に同等の実装。返すエラー型だけ違う。
|
||||
- **YAML frontmatter の `MissingFrontmatter` / `MalformedFrontmatter` バリアント**: `LintError` と `WorkflowLintError` の両方に重複定義。
|
||||
- **`Frontmatter` trait(`created_at` / `updated_at` の統一アクセス)**: 現状 memory 側だけにあり、workflow 側の `WorkflowFrontmatter` は同 trait を実装していない。共通 crate に出るなら、workflow 側でも揃えられる。
|
||||
|
||||
memory / workflow どちらも agent-skills 互換のスラグ規約と Markdown + YAML frontmatter の同一フォーマットを採用しているため、これらは設計上「両者が共有すべき同一の概念」であって、別物として持つ理由はない。`tickets/workflow-crate-extraction.md` も完了条件と直交する形で「共有が必要なら共通部分を別 crate(例: `crates/lint-common/`)に切る判断を行う」と前置きしており、抽出時にスキップした判断を本チケットで補う。
|
||||
|
||||
## 要件
|
||||
|
||||
### 新 crate の新設
|
||||
|
||||
memory / workflow 双方が依存する共通 crate を 1 つ立てる。crate 名は実装時に決める(候補: `lint-common`, `record-core`, `frontmatter` など)。memory / workflow より下層に位置し、両者が import する。
|
||||
|
||||
新 crate が持つもの:
|
||||
|
||||
- `Slug` 型 + `is_valid_slug`(agent-skills 互換規約)
|
||||
- `split_frontmatter`(YAML frontmatter / Markdown body 分離)
|
||||
- 上記に紐づく共通エラー型(`InvalidSlug` / `MissingFrontmatter` / `MalformedFrontmatter`)
|
||||
- `Frontmatter` trait(`BODY_LIMIT` / `created_at` / `updated_at` のアクセサ)
|
||||
|
||||
### memory / workflow からの重複削除
|
||||
|
||||
- `crates/memory/src/slug.rs` と `crates/workflow/src/slug.rs` を削除し、新 crate の `Slug` を再 export または直接 import する形に書き換える
|
||||
- `crates/memory/src/schema/common.rs` 内の `split_frontmatter` と `crates/workflow/src/schema.rs` 内の `split_frontmatter` を新 crate のものに統合
|
||||
- `LintError` / `WorkflowLintError` の `InvalidSlug` / `MissingFrontmatter` / `MalformedFrontmatter` バリアントは、共通エラー型を `#[from]` で包む形に揃えるか、共通エラー型をそのまま使う形に切り替える(実装時に判断)
|
||||
- `WorkflowFrontmatter` も共通 `Frontmatter` trait を実装するように揃える(`BODY_LIMIT` を 8000 で踏襲)
|
||||
|
||||
### 依存方向
|
||||
|
||||
- 新 crate は memory / workflow / その他に依存しない(純粋なドメイン型のみ)
|
||||
- memory / workflow 双方が新 crate を import する
|
||||
- workflow → memory の `WorkspaceLayout` 依存は維持(このチケットの対象外)
|
||||
|
||||
## 範囲外
|
||||
|
||||
- linter 本体の共通化(memory `Linter` と workflow `WorkflowLinter` の統合)
|
||||
- `WorkspaceLayout` の memory crate からの切り出し
|
||||
- `WorkflowFrontmatter` / `KnowledgeFrontmatter` 等のスキーマ変更
|
||||
- agent-skills 互換規約自体の変更
|
||||
|
||||
## 完了条件
|
||||
|
||||
- 新 crate が `Slug` / `is_valid_slug` / `split_frontmatter` / 共通エラー型 / `Frontmatter` trait を提供している
|
||||
- `crates/memory/src/slug.rs` と `crates/workflow/src/slug.rs` の重複コードが消えている(少なくとも一方からは)
|
||||
- `split_frontmatter` の実装が 1 箇所に集約されている
|
||||
- `WorkflowFrontmatter` が `Frontmatter` trait を実装している
|
||||
- 既存テスト(memory / workflow / pod)が新構造で通る
|
||||
- 循環依存が無い
|
||||
|
||||
## 参照
|
||||
|
||||
- 直前: `tickets/workflow-crate-extraction.md`(git log、`workflow-crate-extraction.review.md` で本件が見落とされた経緯あり)
|
||||
- 関連: `tickets/internal-worker-workflow.md`(本チケット完了後に着手すると共通基盤が揃った状態で進められる)
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
# Manifest: Tool Output / File Upload 上限の分離とデフォルト緩和
|
||||
|
||||
## 背景
|
||||
|
||||
現在、tool result の本文上限は `manifest::defaults::TOOL_OUTPUT_MAX_BYTES` に集約され、`worker.tool_output.default_max_bytes` として manifest から設定できる。一方で submit 時の `FileRef` 添付(`@<path>` を `[File: <path>]` system message に展開する経路)も同じ `TOOL_OUTPUT_MAX_BYTES` を直接使っており、upload / attachment 用の上限として独立して設定できない。
|
||||
|
||||
このため、tool output の安全な truncation と、ユーザーが明示的に添付したファイル本文の取り込み量を別々に調整できない。また現在の既定値 16 KiB は、ファイル添付・tool output の双方で実運用上やや厳しい。
|
||||
|
||||
## ゴール
|
||||
|
||||
Tool Output と submit 時 FileRef upload / attachment の上限を manifest でそれぞれ設定できるようにし、既定値を現在の 16 KiB より緩和する。
|
||||
|
||||
## 要件
|
||||
|
||||
- Tool Output の上限は引き続き manifest から設定できること
|
||||
- FileRef upload / attachment の上限を Tool Output とは別の manifest field として設定できること
|
||||
- FileRef resolver は hard-coded な `manifest::defaults::TOOL_OUTPUT_MAX_BYTES` ではなく、解決済み manifest の upload / attachment 上限を使うこと
|
||||
- Tool Output と FileRef upload / attachment の既定値を、現在の 16 KiB から引き上げること
|
||||
- 正確な値は実装時に決めてよいが、docs / tests / manifest defaults の説明と一致させること
|
||||
- manifest cascade / overlay / serde default のいずれの経路でも同じ既定値・同じ field semantics になること
|
||||
- 既存 manifest で新 field が未指定の場合は、新しい既定値で動作すること
|
||||
- 既存の per-tool override の挙動を壊さないこと
|
||||
|
||||
## 完了条件
|
||||
|
||||
- Tool Output と FileRef upload / attachment を別々に manifest で設定できる
|
||||
- FileRef upload / attachment の truncate テストが、新 field の値を使うことを検証している
|
||||
- Tool Output の既存テストが、新しい既定値・既存 override semantics に合わせて更新されている
|
||||
- `docs/pod-factory.md` など manifest 設定のドキュメントが更新されている
|
||||
- 16 KiB を前提にしたコメント・テスト値・ドキュメントが残っていない
|
||||
|
||||
## 範囲外
|
||||
|
||||
- 正確な token counting による上限管理への移行
|
||||
- UI 側で添付ファイルサイズを事前表示・警告する機能
|
||||
- compact / auto-read の token budget 設計変更
|
||||
Loading…
Reference in New Issue
Block a user