Compare commits

...

3 Commits

21 changed files with 315 additions and 52 deletions

View File

@ -47,7 +47,9 @@ pub enum SpawnError {
/// runtime ディレクトリが解決できなかった (環境変数未設定等)。 /// runtime ディレクトリが解決できなかった (環境変数未設定等)。
RuntimeDirUnavailable, RuntimeDirUnavailable,
PodLaunchFailed(io::Error), PodLaunchFailed(io::Error),
PodExitedEarly { stderr_tail: String }, PodExitedEarly {
stderr_tail: String,
},
Timeout, Timeout,
} }
@ -88,10 +90,7 @@ impl From<io::Error> for SpawnError {
/// ///
/// `progress` は ready 行を見つけるまでに観測した stderr の各行で呼ばれる /// `progress` は ready 行を見つけるまでに観測した stderr の各行で呼ばれる
/// (ready 行自体は除外される)。UI の表示更新や E2E ログ取得に使う。 /// (ready 行自体は除外される)。UI の表示更新や E2E ログ取得に使う。
pub async fn spawn_pod<F>( pub async fn spawn_pod<F>(config: SpawnConfig, mut progress: F) -> Result<SpawnReady, SpawnError>
config: SpawnConfig,
mut progress: F,
) -> Result<SpawnReady, SpawnError>
where where
F: FnMut(&str), F: FnMut(&str),
{ {

View File

@ -316,7 +316,8 @@ impl AnthropicScheme {
}); });
match &raw.content_block { match &raw.content_block {
ContentBlock::Thinking { ContentBlock::Thinking {
thinking, signature, thinking,
signature,
} => { } => {
state.pending_thinking = Some(PendingThinking { state.pending_thinking = Some(PendingThinking {
text: thinking.clone(), text: thinking.clone(),
@ -372,10 +373,7 @@ impl AnthropicScheme {
} }
AnthropicEventType::ContentBlockStop => { AnthropicEventType::ContentBlockStop => {
let raw: ContentBlockStopEvent = serde_json::from_str(data)?; let raw: ContentBlockStopEvent = serde_json::from_str(data)?;
let block_type = state let block_type = state.current_block_type.take().unwrap_or(BlockType::Text);
.current_block_type
.take()
.unwrap_or(BlockType::Text);
emitted.push(Event::BlockStop(BlockStop { emitted.push(Event::BlockStop(BlockStop {
index: raw.index, index: raw.index,
block_type, block_type,

View File

@ -458,7 +458,10 @@ pub(crate) fn parse_sse(
"response.reasoning_text.delta" => { "response.reasoning_text.delta" => {
let ev: ReasoningTextDelta = from_json(data)?; let ev: ReasoningTextDelta = from_json(data)?;
// round-trip 用に蓄積 // round-trip 用に蓄積
state.ensure_reasoning(ev.output_index).text.push_str(&ev.delta); state
.ensure_reasoning(ev.output_index)
.text
.push_str(&ev.delta);
Ok(ensure_and_delta( Ok(ensure_and_delta(
state, state,
SlotKey::ContentPart { SlotKey::ContentPart {

View File

@ -16,9 +16,7 @@ mod common;
use common::MockLlmClient; use common::MockLlmClient;
use llm_worker::Item; use llm_worker::Item;
use llm_worker::Worker; use llm_worker::Worker;
use llm_worker::llm_client::event::{ use llm_worker::llm_client::event::{Event, ReasoningItemEvent, ResponseStatus, StatusEvent};
Event, ReasoningItemEvent, ResponseStatus, StatusEvent,
};
/// Anthropic 風: thinking ブロック → text → 終了 のシーケンス。 /// Anthropic 風: thinking ブロック → text → 終了 のシーケンス。
/// Worker history に Reasoning(signature 付き) → assistant_message が並ぶ。 /// Worker history に Reasoning(signature 付き) → assistant_message が並ぶ。

View File

@ -66,6 +66,8 @@ pub struct WorkerManifestConfig {
#[serde(default)] #[serde(default)]
pub instruction: Option<String>, pub instruction: Option<String>,
#[serde(default)] #[serde(default)]
pub language: Option<String>,
#[serde(default)]
pub max_tokens: Option<u32>, pub max_tokens: Option<u32>,
#[serde(default)] #[serde(default)]
pub max_turns: Option<NonZeroU32>, pub max_turns: Option<NonZeroU32>,
@ -258,6 +260,7 @@ impl MemoryConfig {
workspace_root: upper.workspace_root.or(self.workspace_root), workspace_root: upper.workspace_root.or(self.workspace_root),
query_result_limit: upper.query_result_limit.or(self.query_result_limit), query_result_limit: upper.query_result_limit.or(self.query_result_limit),
query_excerpt_lines: upper.query_excerpt_lines.or(self.query_excerpt_lines), query_excerpt_lines: upper.query_excerpt_lines.or(self.query_excerpt_lines),
language: upper.language.or(self.language),
extract_model: upper.extract_model.or(self.extract_model), extract_model: upper.extract_model.or(self.extract_model),
extract_threshold: upper.extract_threshold.or(self.extract_threshold), extract_threshold: upper.extract_threshold.or(self.extract_threshold),
extract_worker_max_input_tokens: upper extract_worker_max_input_tokens: upper
@ -290,6 +293,7 @@ impl WorkerManifestConfig {
fn merge(self, upper: Self) -> Self { fn merge(self, upper: Self) -> Self {
Self { Self {
instruction: upper.instruction.or(self.instruction), instruction: upper.instruction.or(self.instruction),
language: upper.language.or(self.language),
max_tokens: upper.max_tokens.or(self.max_tokens), max_tokens: upper.max_tokens.or(self.max_tokens),
max_turns: upper.max_turns.or(self.max_turns), max_turns: upper.max_turns.or(self.max_turns),
temperature: upper.temperature.or(self.temperature), temperature: upper.temperature.or(self.temperature),
@ -428,6 +432,10 @@ impl TryFrom<PodManifestConfig> for PodManifest {
.worker .worker
.instruction .instruction
.unwrap_or_else(|| defaults::DEFAULT_INSTRUCTION.to_string()), .unwrap_or_else(|| defaults::DEFAULT_INSTRUCTION.to_string()),
language: cfg
.worker
.language
.unwrap_or_else(|| defaults::WORKER_LANGUAGE.to_string()),
max_tokens: cfg.worker.max_tokens, max_tokens: cfg.worker.max_tokens,
max_turns: cfg.worker.max_turns, max_turns: cfg.worker.max_turns,
temperature: cfg.worker.temperature, temperature: cfg.worker.temperature,

View File

@ -33,6 +33,11 @@ pub const COMPACT_RETAINED_TOKENS: u64 = 8000;
/// `$insomnia/` / `$user/` / `$workspace/` namespaces. /// `$insomnia/` / `$user/` / `$workspace/` namespaces.
pub const DEFAULT_INSTRUCTION: &str = "$insomnia/default"; pub const DEFAULT_INSTRUCTION: &str = "$insomnia/default";
/// Default language policy used by the main worker for normal prose
/// responses. See [`crate::WorkerManifest::language`].
pub const WORKER_LANGUAGE: &str =
"match the user's language unless they explicitly request another language";
/// Token budget for auto-read file contents injected into the new /// Token budget for auto-read file contents injected into the new
/// session after compaction. Limits how much raw file text the /// session after compaction. Limits how much raw file text the
/// compact worker can pull into the compacted context via /// compact worker can pull into the compacted context via
@ -62,3 +67,7 @@ pub const MEMORY_EXTRACT_WORKER_MAX_INPUT_TOKENS: u64 = 30_000;
/// Optional maximum extract-worker tool-loop depth. `None` means unlimited. /// Optional maximum extract-worker tool-loop depth. `None` means unlimited.
/// See [`crate::MemoryConfig::extract_worker_max_turns`]. /// See [`crate::MemoryConfig::extract_worker_max_turns`].
pub const MEMORY_EXTRACT_WORKER_MAX_TURNS: Option<u32> = Some(8); pub const MEMORY_EXTRACT_WORKER_MAX_TURNS: Option<u32> = Some(8);
/// Default language used by memory extraction / consolidation workers for
/// durable memory and knowledge text. See [`crate::MemoryConfig::language`].
pub const MEMORY_LANGUAGE: &str = "English";

View File

@ -96,6 +96,12 @@ pub struct MemoryConfig {
/// Ignored when the request omits `query`. `None` ⇒ tool default (3). /// Ignored when the request omits `query`. `None` ⇒ tool default (3).
#[serde(default)] #[serde(default)]
pub query_excerpt_lines: Option<usize>, pub query_excerpt_lines: Option<usize>,
/// Language used by memory extraction / consolidation workers for durable
/// memory and knowledge text. Free-form so workspaces can use names like
/// `English`, `Japanese`, or locale tags. `None` ⇒
/// [`defaults::MEMORY_LANGUAGE`].
#[serde(default)]
pub language: Option<String>,
/// Optional model for the extract worker. When `None`, /// Optional model for the extract worker. When `None`,
/// the main pod model is cloned via `clone_boxed()`. Lightweight /// the main pod model is cloned via `clone_boxed()`. Lightweight
/// reasoning-capable models (Haiku / 4o-mini / Flash class) are /// reasoning-capable models (Haiku / 4o-mini / Flash class) are
@ -167,6 +173,12 @@ pub struct WorkerManifest {
/// unset manifests fall through to [`defaults::DEFAULT_INSTRUCTION`]. /// unset manifests fall through to [`defaults::DEFAULT_INSTRUCTION`].
#[serde(default = "default_instruction")] #[serde(default = "default_instruction")]
pub instruction: String, pub instruction: String,
/// Language policy used by the main worker for normal prose responses.
/// Free-form so workspaces can use names like `English`, `Japanese`,
/// locale tags, or a policy phrase. Unset manifests fall through to
/// [`defaults::WORKER_LANGUAGE`].
#[serde(default = "default_worker_language")]
pub language: String,
#[serde(default)] #[serde(default)]
pub max_tokens: Option<u32>, pub max_tokens: Option<u32>,
#[serde(default)] #[serde(default)]
@ -235,6 +247,10 @@ fn default_instruction() -> String {
defaults::DEFAULT_INSTRUCTION.to_string() defaults::DEFAULT_INSTRUCTION.to_string()
} }
fn default_worker_language() -> String {
defaults::WORKER_LANGUAGE.to_string()
}
impl Default for ToolOutputLimits { impl Default for ToolOutputLimits {
fn default() -> Self { fn default() -> Self {
Self { Self {
@ -656,6 +672,14 @@ model_id = "claude-sonnet-4-20250514"
); );
} }
#[test]
fn memory_section_with_language() {
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n");
let manifest = PodManifest::from_toml(&toml).unwrap();
let mem = manifest.memory.unwrap();
assert_eq!(mem.language.as_deref(), Some("Japanese"));
}
#[test] #[test]
fn reject_unknown_scheme() { fn reject_unknown_scheme() {
let toml = let toml =
@ -675,6 +699,16 @@ model_id = "claude-sonnet-4-20250514"
); );
} }
#[test]
fn worker_language_defaults_and_parses() {
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
assert_eq!(manifest.worker.language, defaults::WORKER_LANGUAGE);
let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nlanguage = \"Japanese\"\n");
let manifest = PodManifest::from_toml(&toml).unwrap();
assert_eq!(manifest.worker.language, "Japanese");
}
#[test] #[test]
fn parse_worker_output_limits() { fn parse_worker_output_limits() {
let toml = MINIMAL_REQUIRED.replace( let toml = MINIMAL_REQUIRED.replace(

View File

@ -47,22 +47,71 @@ fn split_for_retained_impl(history: &[Item], records: &[UsageRecord], retained:
// (内部で毎回再計算すると O(n²) になる)。将来ボトルネックになれば // (内部で毎回再計算すると O(n²) になる)。将来ボトルネックになれば
// record 境界で二分探索に置き換える。 // record 境界で二分探索に置き換える。
let mut chosen_source = current.source; let mut chosen_source = current.source;
let mut cut_index = history.len();
for idx in 1..=history.len() { for idx in 1..=history.len() {
let est = tokens_at(history, records, idx, &prefix); let est = tokens_at(history, records, idx, &prefix);
if est.tokens >= target { if est.tokens >= target {
chosen_source = est.source; chosen_source = est.source;
return SplitPoint { cut_index = idx;
index: idx, break;
source: chosen_source,
};
} }
} }
SplitPoint { SplitPoint {
index: history.len(), index: balance_to_pair_boundary(history, cut_index),
source: chosen_source, source: chosen_source,
} }
} }
/// `history[cut..]` が `ToolCall` / `ToolResult` のペア境界を尊重するよう
/// `cut` を後退させる。
///
/// LLM API は「`ToolResult` を送るならその `ToolCall` も同じ request に
/// 含まれていなければならない」というバリデーションを持つ。トークン数
/// だけで切った `cut` は並列 tool 呼び出しの途中に落ちうるので、retained
/// 側の先頭に対応 `ToolCall` を持たない `ToolResult`orphanが残ると
/// 次セッション初回 request が API バリデーションで弾かれる。
///
/// 対策は「retained に入る `ToolResult` について、対応 `ToolCall` も
/// retained に含まれる位置まで `cut` を引き下げる」こと。retained_tokens
/// 予算は超えうるが、ここでは直接 LLM に投げる訳ではなく次の
/// `pre_llm_request` で再評価されるだけなので safe。
///
/// アルゴリズム: history を末尾から走査し、retained 範囲内の `ToolResult`
/// に出会うたびに対応 `ToolCall` の位置で `cut` を min 更新する。`cut` が
/// 下がると以前は要約側だった位置が retained に入るので、後続走査で連鎖的
/// に正しい位置まで引き下がる。`ToolCall` の `call_id` はユニークなので
/// 事前にマップ化して O(n) で済ます。
fn balance_to_pair_boundary(history: &[Item], cut: usize) -> usize {
let mut idx = cut.min(history.len());
if idx == 0 {
return 0;
}
let call_positions: std::collections::HashMap<&str, usize> = history
.iter()
.enumerate()
.filter_map(|(i, item)| match item {
Item::ToolCall { call_id, .. } => Some((call_id.as_str(), i)),
_ => None,
})
.collect();
let mut k = history.len();
while k > 0 {
k -= 1;
if k >= idx {
if let Item::ToolResult { call_id, .. } = &history[k] {
if let Some(&call_pos) = call_positions.get(call_id.as_str()) {
if call_pos < idx {
idx = call_pos;
}
}
}
}
}
idx
}
/// 1 つの ToolResult 項目について、`content` を `None` に射影したとき /// 1 つの ToolResult 項目について、`content` を `None` に射影したとき
/// 減少するシリアライズ後バイト数。ToolResult 以外や既に content=None /// 減少するシリアライズ後バイト数。ToolResult 以外や既に content=None
/// の item は 0 を返す。 /// の item は 0 を返す。
@ -305,4 +354,109 @@ mod tests {
let est = savings_for_prune_impl(&history, &[record(1, 100)], &[99]); let est = savings_for_prune_impl(&history, &[record(1, 100)], &[99]);
assert_eq!(est.tokens, 0); assert_eq!(est.tokens, 0);
} }
fn tc(call_id: &str) -> Item {
Item::tool_call(call_id, "Read", "{}")
}
fn tr(call_id: &str) -> Item {
Item::tool_result(call_id, "summary")
}
#[test]
fn balance_noop_on_clean_message_boundary() {
let history = vec![msg("a"), msg("b"), msg("c")];
assert_eq!(balance_to_pair_boundary(&history, 2), 2);
assert_eq!(balance_to_pair_boundary(&history, 0), 0);
assert_eq!(balance_to_pair_boundary(&history, 3), 3);
}
#[test]
fn balance_retreats_from_inside_parallel_tool_results() {
// [Msg, TC_a, TC_b, TC_c, TR_a, TR_b, TR_c]
// cut=5 → retained=[TR_b, TR_c]。TR_c の TC は idx=3、TR_b は idx=2 →
// idx=2 まで後退。だが retained に TR_a (idx=4) が新たに入り、その TC_a
// は idx=1 でまだ外 → 連鎖後退で最終的に idx=1。retained は
// [TC_a, TC_b, TC_c, TR_a, TR_b, TR_c]。
let history = vec![
msg("u"),
tc("a"),
tc("b"),
tc("c"),
tr("a"),
tr("b"),
tr("c"),
];
assert_eq!(balance_to_pair_boundary(&history, 5), 1);
}
#[test]
fn balance_retreats_between_call_and_result() {
// [TC_a, TR_a, TC_b, TR_b]。cut=3 → retained=[TR_b] orphan。
// TC_b は idx=2 → cut=2。retained=[TC_b, TR_b]。
let history = vec![tc("a"), tr("a"), tc("b"), tr("b")];
assert_eq!(balance_to_pair_boundary(&history, 3), 2);
}
#[test]
fn balance_cascades_through_nested_pairs() {
// [TC_a, TC_b, TR_b, TR_a, TC_c, TR_c]。cut=3 → retained=[TR_a, TC_c, TR_c]。
// TR_a の TC は idx=0 → cut=0。retained=full。
let history = vec![tc("a"), tc("b"), tr("b"), tr("a"), tc("c"), tr("c")];
assert_eq!(balance_to_pair_boundary(&history, 3), 0);
}
#[test]
fn balance_noop_when_cut_at_pair_boundary() {
// [TC_a, TR_a, Msg, TC_b, TR_b]。cut=2 → retained=[Msg, TC_b, TR_b] balanced。
let history = vec![tc("a"), tr("a"), msg("u"), tc("b"), tr("b")];
assert_eq!(balance_to_pair_boundary(&history, 2), 2);
}
#[test]
fn balance_handles_orphan_result_without_matching_call() {
// ToolCall がそもそも存在しない ToolResult は触らない(壊れた history は
// ここでは直しようがない。cut=1 → そのまま 1 を返す。
let history = vec![msg("u"), tr("zombie")];
assert_eq!(balance_to_pair_boundary(&history, 1), 1);
}
#[test]
fn balance_keeps_cut_when_call_is_inside_retained() {
// [Msg, TC_a, TR_a]。cut=1 → retained=[TC_a, TR_a]。TR_a の call_pos=1 >= idx=1。OK。
let history = vec![msg("u"), tc("a"), tr("a")];
assert_eq!(balance_to_pair_boundary(&history, 1), 1);
}
#[test]
fn split_for_retained_aligns_to_pair_boundary() {
// 並列 TC*3 / TR*3 ターン後に Msg を 1 件足し、retained=Msg のサイズ相当に
// 設定。トークン的には cut=末尾近くだが、orphan を避けるため TC 群の手前
// まで後退するはず。
let history = vec![
msg("user"),
tc("a"),
tc("b"),
tc("c"),
tr("a"),
tr("b"),
tr("c"),
msg("tail"),
];
let total_bytes: u64 = history.iter().map(item_bytes).sum();
let records = vec![record(history.len(), total_bytes)]; // rate = 1 tok/byte
// tail の item_bytes 相当のみ retain したい。
let tail_tokens = item_bytes(&history[7]);
let cut = split_for_retained_impl(&history, &records, tail_tokens);
// token 単独だと cut は 7tail のみ retainedになるが、retained 先頭が
// Msg なら balance しなくて OK。balance helper の no-op を確認する意味も込めて
// index == 7 を期待する。
assert_eq!(cut.index, 7);
// 逆に retained をやや増やしてトークン的に cut=6TR_c のみ retained
// させると、TR_c は orphan なので balance が 1 まで後退するはず。
let big_retain = tail_tokens + item_bytes(&history[6]);
let cut = split_for_retained_impl(&history, &records, big_retain);
assert_eq!(cut.index, 1);
}
} }

View File

@ -643,6 +643,7 @@ permission = "write"
let ctx = SystemPromptContext { let ctx = SystemPromptContext {
now: chrono::Utc::now(), now: chrono::Utc::now(),
cwd: &root, cwd: &root,
language: manifest::defaults::WORKER_LANGUAGE,
scope: &scope, scope: &scope,
tool_names: Vec::new(), tool_names: Vec::new(),
agents_md: None, agents_md: None,

View File

@ -879,10 +879,12 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
}; };
let resident_exposure_snapshots = let resident_exposure_snapshots =
self.resident_exposure_snapshots(&resident, &resident_workflows); self.resident_exposure_snapshots(&resident, &resident_workflows);
let worker_language = worker_language(&self.manifest.worker);
let scope_snapshot = self.scope.snapshot(); let scope_snapshot = self.scope.snapshot();
let ctx = SystemPromptContext { let ctx = SystemPromptContext {
now: chrono::Utc::now(), now: chrono::Utc::now(),
cwd: &self.pwd, cwd: &self.pwd,
language: worker_language,
scope: &scope_snapshot, scope: &scope_snapshot,
tool_names, tool_names,
agents_md: agents_md_read.body, agents_md: agents_md_read.body,
@ -2075,9 +2077,10 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
.or(manifest::defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS); .or(manifest::defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS);
let client = self.build_extractor_client(memory_cfg)?; let client = self.build_extractor_client(memory_cfg)?;
let memory_language = memory_language(memory_cfg);
let extract_system_prompt = self let extract_system_prompt = self
.prompts .prompts
.memory_extract_system() .memory_extract_system(memory_language)
.map_err(PodError::PromptCatalog)?; .map_err(PodError::PromptCatalog)?;
let mut extract_worker = Worker::new(client).system_prompt(extract_system_prompt); let mut extract_worker = Worker::new(client).system_prompt(extract_system_prompt);
extract_worker.set_cache_key(Some(self.session_id.to_string())); extract_worker.set_cache_key(Some(self.session_id.to_string()));
@ -2276,13 +2279,15 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
return Err(e); return Err(e);
} }
}; };
let consolidation_system_prompt = match self.prompts.memory_consolidation_system() { let memory_language = memory_language(memory_cfg);
Ok(p) => p, let consolidation_system_prompt =
Err(e) => { match self.prompts.memory_consolidation_system(memory_language) {
lock.release_only(); Ok(p) => p,
return Err(PodError::PromptCatalog(e)); Err(e) => {
} lock.release_only();
}; return Err(PodError::PromptCatalog(e));
}
};
let mut worker = Worker::new(client).system_prompt(consolidation_system_prompt); let mut worker = Worker::new(client).system_prompt(consolidation_system_prompt);
worker.set_cache_key(Some(self.session_id.to_string())); worker.set_cache_key(Some(self.session_id.to_string()));
@ -2331,6 +2336,23 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
} }
} }
fn memory_language(cfg: &manifest::MemoryConfig) -> &str {
cfg.language
.as_deref()
.map(str::trim)
.filter(|language| !language.is_empty())
.unwrap_or(manifest::defaults::MEMORY_LANGUAGE)
}
fn worker_language(cfg: &manifest::WorkerManifest) -> &str {
let language = cfg.language.trim();
if language.is_empty() {
manifest::defaults::WORKER_LANGUAGE
} else {
language
}
}
/// Outcome of a single extract iteration. Internal to /// Outcome of a single extract iteration. Internal to
/// `try_post_run_extract` / `run_extract_once`. /// `try_post_run_extract` / `run_extract_once`.
enum ExtractDecision { enum ExtractDecision {
@ -3141,6 +3163,7 @@ mod build_summary_prompt_tests {
fn worker_manifest_generation_settings_become_request_config() { fn worker_manifest_generation_settings_become_request_config() {
let manifest = WorkerManifest { let manifest = WorkerManifest {
instruction: "unused".into(), instruction: "unused".into(),
language: manifest::defaults::WORKER_LANGUAGE.into(),
max_tokens: Some(1024), max_tokens: Some(1024),
max_turns: None, max_turns: None,
temperature: Some(0.2), temperature: Some(0.2),

View File

@ -311,14 +311,17 @@ impl PromptCatalog {
self.render(PodPrompt::CompactSystem, Value::UNDEFINED) self.render(PodPrompt::CompactSystem, Value::UNDEFINED)
} }
/// Render `PodPrompt::MemoryExtractSystem` (no inputs). /// Render `PodPrompt::MemoryExtractSystem` with `{{ language }}`.
pub fn memory_extract_system(&self) -> Result<String, CatalogError> { pub fn memory_extract_system(&self, language: &str) -> Result<String, CatalogError> {
self.render(PodPrompt::MemoryExtractSystem, Value::UNDEFINED) self.render(PodPrompt::MemoryExtractSystem, single("language", language))
} }
/// Render `PodPrompt::MemoryConsolidationSystem` (no inputs). /// Render `PodPrompt::MemoryConsolidationSystem` with `{{ language }}`.
pub fn memory_consolidation_system(&self) -> Result<String, CatalogError> { pub fn memory_consolidation_system(&self, language: &str) -> Result<String, CatalogError> {
self.render(PodPrompt::MemoryConsolidationSystem, Value::UNDEFINED) self.render(
PodPrompt::MemoryConsolidationSystem,
single("language", language),
)
} }
/// Render `PodPrompt::NotifyWrapper` with `{{ message }}`. /// Render `PodPrompt::NotifyWrapper` with `{{ message }}`.
@ -488,6 +491,15 @@ mod tests {
assert!(rendered.contains("mark_read_required")); assert!(rendered.contains("mark_read_required"));
} }
#[test]
fn memory_worker_prompts_include_language() {
let cat = PromptCatalog::builtins_only().unwrap();
let extract = cat.memory_extract_system("Japanese").unwrap();
let consolidate = cat.memory_consolidation_system("Japanese").unwrap();
assert!(extract.contains("`language`: `Japanese`"));
assert!(consolidate.contains("`language`: `Japanese`"));
}
#[test] #[test]
fn notify_wrapper_interpolates_message() { fn notify_wrapper_interpolates_message() {
let cat = PromptCatalog::builtins_only().unwrap(); let cat = PromptCatalog::builtins_only().unwrap();

View File

@ -144,6 +144,8 @@ impl std::fmt::Debug for SystemPromptTemplate {
pub struct SystemPromptContext<'a> { pub struct SystemPromptContext<'a> {
pub now: DateTime<Utc>, pub now: DateTime<Utc>,
pub cwd: &'a Path, pub cwd: &'a Path,
/// Language policy exposed to instruction templates as `{{ language }}`.
pub language: &'a str,
pub scope: &'a Scope, pub scope: &'a Scope,
pub tool_names: Vec<String>, pub tool_names: Vec<String>,
/// Project-level instructions read from the nearest `AGENTS.md`. /// Project-level instructions read from the nearest `AGENTS.md`.
@ -181,6 +183,7 @@ impl<'a> SystemPromptContext<'a> {
Value::from(self.now.to_rfc3339_opts(SecondsFormat::Secs, true)), Value::from(self.now.to_rfc3339_opts(SecondsFormat::Secs, true)),
); );
root.insert("cwd".into(), Value::from(self.cwd.display().to_string())); root.insert("cwd".into(), Value::from(self.cwd.display().to_string()));
root.insert("language".into(), Value::from(self.language));
root.insert( root.insert(
"tools".into(), "tools".into(),
Value::from( Value::from(
@ -328,6 +331,7 @@ mod tests {
SystemPromptContext { SystemPromptContext {
now: fixed_now(), now: fixed_now(),
cwd, cwd,
language: manifest::defaults::WORKER_LANGUAGE,
scope, scope,
tool_names: tools, tool_names: tools,
agents_md, agents_md,
@ -345,6 +349,7 @@ mod tests {
SystemPromptContext { SystemPromptContext {
now: fixed_now(), now: fixed_now(),
cwd, cwd,
language: manifest::defaults::WORKER_LANGUAGE,
scope, scope,
tool_names: Vec::new(), tool_names: Vec::new(),
agents_md: None, agents_md: None,
@ -380,6 +385,9 @@ mod tests {
let rendered = tmpl let rendered = tmpl
.render(&ctx(dir.path(), &scope, vec!["Read".into()], None)) .render(&ctx(dir.path(), &scope, vec!["Read".into()], None))
.unwrap(); .unwrap();
// Builtin default body must expose the language policy.
assert!(rendered.contains("## Language"));
assert!(rendered.contains("`language`: `match the user's language"));
// Trailing section must be present. // Trailing section must be present.
assert!(rendered.contains("## Working boundaries")); assert!(rendered.contains("## Working boundaries"));
assert!(rendered.contains("Readable:")); assert!(rendered.contains("Readable:"));

View File

@ -280,10 +280,7 @@ mod tests {
assert_eq!(all.len(), 3); assert_eq!(all.len(), 3);
let alpha = state.list_knowledge_completions("alpha"); let alpha = state.list_knowledge_completions("alpha");
assert_eq!( assert_eq!(
alpha alpha.iter().map(|c| c.slug.as_str()).collect::<Vec<_>>(),
.iter()
.map(|c| c.slug.as_str())
.collect::<Vec<_>>(),
vec!["alpha", "alphabet"] vec!["alpha", "alphabet"]
); );
assert!(state.list_knowledge_completions("zzz").is_empty()); assert!(state.list_knowledge_completions("zzz").is_empty());

View File

@ -325,7 +325,8 @@ mod tests {
fn legacy_reasoning_without_signature_field_deserializes() { fn legacy_reasoning_without_signature_field_deserializes() {
// signature フィールドが無い旧形式の history.json を読み込んでも // signature フィールドが無い旧形式の history.json を読み込んでも
// None としてロードできる(後方互換性)。 // None としてロードできる(後方互換性)。
let legacy_json = r#"{"kind":"reasoning","text":"old","summary":[],"encrypted_content":null}"#; let legacy_json =
r#"{"kind":"reasoning","text":"old","summary":[],"encrypted_content":null}"#;
let parsed: LoggedItem = serde_json::from_str(legacy_json).unwrap(); let parsed: LoggedItem = serde_json::from_str(legacy_json).unwrap();
match Item::from(parsed) { match Item::from(parsed) {
Item::Reasoning { Item::Reasoning {

View File

@ -69,7 +69,9 @@ impl Renderer {
fn span_style(&self) -> Style { fn span_style(&self) -> Style {
if self.in_inline_code > 0 { if self.in_inline_code > 0 {
return Style::default().fg(Color::Yellow).bg(Color::Rgb(40, 40, 40)); return Style::default()
.fg(Color::Yellow)
.bg(Color::Rgb(40, 40, 40));
} }
if self.in_code_block { if self.in_code_block {
return Style::default().fg(Color::Cyan); return Style::default().fg(Color::Cyan);
@ -211,10 +213,8 @@ impl Renderer {
} }
Tag::BlockQuote(_) => { Tag::BlockQuote(_) => {
self.emit_blank(out); self.emit_blank(out);
self.line_prefix.push(Span::styled( self.line_prefix
"", .push(Span::styled("", Style::default().fg(Color::DarkGray)));
Style::default().fg(Color::DarkGray),
));
} }
Tag::Strong => self.bold += 1, Tag::Strong => self.bold += 1,
Tag::Emphasis => self.italic += 1, Tag::Emphasis => self.italic += 1,

View File

@ -309,10 +309,7 @@ mod tests {
s.apply_system_message_text(&text); s.apply_system_message_text(&text);
let t = &s.tasks()[0]; let t = &s.tasks()[0];
assert_eq!(t.subject, "subject with\nembedded newline"); assert_eq!(t.subject, "subject with\nembedded newline");
assert_eq!( assert_eq!(t.description, "desc:\n status: not-actually-a-field");
t.description,
"desc:\n status: not-actually-a-field"
);
} }
} }

View File

@ -165,10 +165,7 @@ fn mini_view_summary_line(counts: TaskCounts, width: u16) -> Line<'static> {
counts.deleted, counts.deleted,
); );
let shown = truncate_with_ellipsis(&text, width as usize); let shown = truncate_with_ellipsis(&text, width as usize);
Line::from(Span::styled( Line::from(Span::styled(shown, Style::default().fg(Color::DarkGray)))
shown,
Style::default().fg(Color::DarkGray),
))
} }
/// Two-character status marker + the style to render it with. Mirrors /// Two-character status marker + the style to render it with. Mirrors
@ -591,7 +588,10 @@ fn render_block_into(lines: &mut Vec<Line<'static>>, block: &Block, width: u16,
} }
Block::AssistantText { text } => match mode { Block::AssistantText { text } => match mode {
Mode::Overview => push_overview_line(lines, text, width, MessageKind::Assistant, ""), Mode::Overview => push_overview_line(lines, text, width, MessageKind::Assistant, ""),
_ => lines.extend(crate::markdown::render(text, kind_style(MessageKind::Assistant))), _ => lines.extend(crate::markdown::render(
text,
kind_style(MessageKind::Assistant),
)),
}, },
Block::Thinking(t) => render_thinking(lines, t, width, mode), Block::Thinking(t) => render_thinking(lines, t, width, mode),
// ToolCall is dispatched in `compute_history` via `tool::render_tool` // ToolCall is dispatched in `compute_history` via `tool::render_tool`

View File

@ -0,0 +1,6 @@
## Language
- `language`: `{{ language }}`.
- Follow this language policy for normal prose responses unless the user explicitly requests another language.
- Preserve code identifiers, file paths, command names, logs, quoted text, and external proper nouns when translation would reduce fidelity.
- Do not translate file contents, command output, or protocol literals unless the user asks for translation.

View File

@ -6,4 +6,6 @@ Stay precise, edit code directly when asked, and avoid speculative refactoring.
{% include "common/tool-usage" %} {% include "common/tool-usage" %}
{% include "common/language" %}
{% include "common/writing" %} {% include "common/writing" %}

View File

@ -12,6 +12,13 @@ You have:
Your initial user message contains the staging entries, the full memory records, the knowledge candidate report, and the tidy hints. Existing knowledge bodies are NOT in the prompt; pull them through `KnowledgeQuery` + `MemoryRead` when relevant. Your initial user message contains the staging entries, the full memory records, the knowledge candidate report, and the tidy hints. Existing knowledge bodies are NOT in the prompt; pull them through `KnowledgeQuery` + `MemoryRead` when relevant.
# Memory language
- `language`: `{{ language }}`.
- Write durable memory and knowledge prose in this language, including frontmatter descriptions and record bodies.
- Existing records in another language may be rewritten into this language when you touch them for integration or tidy work; do not rewrite untouched records only for language normalization.
- Preserve code identifiers, paths, command names, quoted user text, logs, and external proper nouns when translation would reduce fidelity.
# Common rules (both steps) # Common rules (both steps)
- **Do not invent provenance.** Decisions / Requests `sources` arrays MUST be copied from the staging `source` field for the originating activity log entries. Do not synthesise `session_id` or entry ranges. Do not fabricate `last_sources` for Knowledge. - **Do not invent provenance.** Decisions / Requests `sources` arrays MUST be copied from the staging `source` field for the originating activity log entries. Do not synthesise `session_id` or entry ranges. Do not fabricate `last_sources` for Knowledge.

View File

@ -2,6 +2,12 @@ You are the activity extractor for an INSOMNIA memory subsystem.
Your single job: read the supplied conversation slice and emit a structured JSON record of "what happened" via the `write_extracted` tool. You are not consolidating, summarising, or generating knowledge — that is the consolidation worker's job. Your single job: read the supplied conversation slice and emit a structured JSON record of "what happened" via the `write_extracted` tool. You are not consolidating, summarising, or generating knowledge — that is the consolidation worker's job.
# Memory language
- `language`: `{{ language }}`.
- Write extracted fact strings (`rationale`, `topic`, `points`, `action`, `result`, `intent`, `summary`, etc.) in this language.
- Preserve code identifiers, paths, command names, quoted user text, logs, and external proper nouns when translation would reduce fidelity.
# Hard rules # Hard rules
- Call `write_extracted` exactly once. Do not narrate, ask questions, or send any other tool output. - Call `write_extracted` exactly once. Do not narrate, ask questions, or send any other tool output.