update: SystemItem1本化

This commit is contained in:
2026-05-14 14:36:29 +09:00
parent e4b66345aa
commit f9def2d5bb
14 changed files with 752 additions and 379 deletions
+72 -19
View File
@@ -22,11 +22,15 @@ use tracing::info;
use tracing::warn;
use crate::compact::state::CompactState;
use session_store::SystemItem;
use tokio::sync::mpsc;
use crate::hook::{
AbortInfo, HookPromptAction, HookRegistry, PreRequestInfo, PromptSubmitInfo, ToolCallSummary,
ToolResultSummary, TurnEndInfo,
};
use crate::ipc::notify_buffer::{NotifyBuffer, format_notify};
use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item};
use crate::pod::LogCommand;
use crate::prompt::catalog::PromptCatalog;
use llm_worker::token_counter::total_tokens;
@@ -45,13 +49,20 @@ pub(crate) struct PodInterceptor {
/// request. The Worker `extend`s these into its persistent history
/// so the LLM has a visible trigger for any reaction it commits.
pending_notifies: NotifyBuffer,
/// Submit-scoped stash of resolver-produced system messages.
/// Drained inside `on_prompt_submit` and returned via
/// `PromptAction::ContinueWith`. Populated by `Pod::run` immediately
/// before handing off to the worker.
pending_attachments: Arc<Mutex<Vec<Item>>>,
/// Submit-scoped stash of resolver-produced typed system items.
/// Drained inside `on_prompt_submit`, committed as a
/// `LogEntry::SystemItems` through `log_cmd_tx`, and returned to
/// the worker as `Item::system_message` via
/// `PromptAction::ContinueWith`. Populated by `Pod::run`
/// immediately before handing off to the worker.
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
/// Prompt catalog used to render the injected notification wrapper.
prompts: Arc<PromptCatalog>,
/// Sender into the Pod's history-drain task. The interceptor uses
/// it to commit `LogCommand::SystemItems` batches before returning
/// the corresponding `Item::system_message`s up to the worker.
/// `None` in tests / `Pod::new` paths where no drain is wired.
log_cmd_tx: Option<mpsc::UnboundedSender<LogCommand>>,
/// Next turn index assigned by `on_prompt_submit`.
next_turn_index: AtomicUsize,
/// Tool calls observed in the current turn (reset on each new prompt).
@@ -64,8 +75,9 @@ impl PodInterceptor {
compact_state: Option<Arc<CompactState>>,
usage_history: Option<Arc<Mutex<Vec<UsageRecord>>>>,
pending_notifies: NotifyBuffer,
pending_attachments: Arc<Mutex<Vec<Item>>>,
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
prompts: Arc<PromptCatalog>,
log_cmd_tx: Option<mpsc::UnboundedSender<LogCommand>>,
) -> Self {
Self {
registry,
@@ -74,11 +86,26 @@ impl PodInterceptor {
pending_notifies,
pending_attachments,
prompts,
log_cmd_tx,
next_turn_index: AtomicUsize::new(0),
tool_calls_this_turn: AtomicUsize::new(0),
}
}
/// Send a `LogCommand::SystemItems` batch down the drain channel
/// (no-op if no drain is wired). The drain task commits the entry
/// before the corresponding `Item::system_message`s reach the
/// worker via `ContinueWith` / `pending_history_appends`, so the
/// drain barrier in `persist_turn` covers system commits too.
fn send_system_items(&self, items: Vec<SystemItem>) {
if items.is_empty() {
return;
}
if let Some(tx) = self.log_cmd_tx.as_ref() {
let _ = tx.send(LogCommand::SystemItems(items));
}
}
fn current_turn_index(&self) -> usize {
self.next_turn_index
.load(Ordering::Relaxed)
@@ -111,7 +138,7 @@ impl Interceptor for PodInterceptor {
return action.into();
}
}
let extras = std::mem::take(
let extras: Vec<SystemItem> = std::mem::take(
&mut *self
.pending_attachments
.lock()
@@ -120,7 +147,14 @@ impl Interceptor for PodInterceptor {
if extras.is_empty() {
PromptAction::Continue
} else {
PromptAction::ContinueWith(extras)
// Commit the typed system items first, then hand the
// matching `Item::system_message`s to the worker. The
// drain task processes the `SystemItems` command BEFORE
// any subsequent `Item` commands from `on_history_append`,
// so on-disk order matches worker-history order.
let items: Vec<Item> = extras.iter().map(SystemItem::to_history_item).collect();
self.send_system_items(extras);
PromptAction::ContinueWith(items)
}
}
@@ -129,19 +163,31 @@ impl Interceptor for PodInterceptor {
if drained.is_empty() {
return Vec::new();
}
let mut items = Vec::with_capacity(drained.len());
for n in drained {
match format_notify(&n, &self.prompts) {
Ok(item) => items.push(item),
let mut system_items: Vec<SystemItem> = Vec::with_capacity(drained.len());
let mut items: Vec<Item> = Vec::with_capacity(drained.len());
for entry in drained {
match build_system_item(&entry, &self.prompts) {
Ok(system_item) => {
items.push(system_item.to_history_item());
system_items.push(system_item);
}
Err(e) => {
// A render failure here would starve the LLM of
// the notify text. Fall back to the raw message
// so the trigger still lands in history.
// the notify text. Fall back to a raw item so the
// trigger still lands in history; the entry will
// simply be skipped from the SystemItems batch.
warn!(error = %e, "failed to render notify_wrapper; using raw message");
items.push(Item::system_message(n.message.clone()));
let fallback = match &entry {
super::notify_buffer::PendingNotify::Notify { message } => message.clone(),
super::notify_buffer::PendingNotify::PodEvent { event } => {
session_store::render_pod_event(event)
}
};
items.push(Item::system_message(fallback));
}
}
}
self.send_system_items(system_items);
items
}
@@ -321,6 +367,7 @@ mod tests {
NotifyBuffer::new(),
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
@@ -346,6 +393,7 @@ mod tests {
NotifyBuffer::new(),
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
@@ -372,6 +420,7 @@ mod tests {
NotifyBuffer::new(),
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
@@ -392,6 +441,7 @@ mod tests {
NotifyBuffer::new(),
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
);
let mut ctx: Vec<Item> = Vec::new();
let action = interceptor.pre_llm_request(&mut ctx).await;
@@ -414,8 +464,8 @@ mod tests {
async fn pending_history_appends_drains_buffer_into_items() {
let registry = Arc::new(HookRegistryBuilder::new().build());
let buffer = NotifyBuffer::new();
buffer.push("first".into());
buffer.push("second".into());
buffer.push_notify("first".into());
buffer.push_notify("second".into());
let interceptor = PodInterceptor::new(
registry,
@@ -424,6 +474,7 @@ mod tests {
buffer.clone(),
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
);
let items = interceptor.pending_history_appends().await;
@@ -451,7 +502,7 @@ mod tests {
// anything itself.
let registry = Arc::new(HookRegistryBuilder::new().build());
let buffer = NotifyBuffer::new();
buffer.push("msg".into());
buffer.push_notify("msg".into());
let interceptor = PodInterceptor::new(
registry,
@@ -460,6 +511,7 @@ mod tests {
buffer.clone(),
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
);
let mut ctx: Vec<Item> = vec![Item::user_message("hi")];
let action = interceptor.pre_llm_request(&mut ctx).await;
@@ -489,6 +541,7 @@ mod tests {
NotifyBuffer::new(),
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
);
let mut ctx: Vec<Item> = Vec::new();
let action = interceptor.pre_llm_request(&mut ctx).await;
+105 -50
View File
@@ -3,39 +3,48 @@
//! Entries are queued here by the Controller (on receipt of the
//! corresponding IPC method) and drained by
//! `PodInterceptor::pending_history_appends`, which the Worker calls
//! at the head of each turn loop iteration to `extend` them into the
//! persistent `worker.history`. Each queued entry becomes one
//! `Item::system_message`.
//! at the head of each turn loop iteration. The drain renders each
//! pending entry into a typed `SystemItem` (with the `notify_wrapper`
//! prompt applied), commits a `LogEntry::SystemItems` through the
//! session-log sink, and returns the corresponding
//! `Item::system_message`s for the worker to append to its
//! persistent history.
//!
//! This is the **single lane** for "system messages produced by Pod
//! state that should land in the next LLM request": Notify, PodEvent,
//! and any future `<system-reminder>` injection all ride this queue
//! (or a sibling queue with the same lifecycle). Per
//! `tickets/notify-history-persist.md` and `AGENTS.md` (LLM コンテキスト
//! の加工原則), there is **no** "transient, history-skipping" lane —
//! everything injected into a request is also committed to history so
//! that any LLM reaction has a visible trigger across turns, resume,
//! and compaction, and so the Anthropic prompt cache prefix stays
//! stable across requests.
//! and any future `<system-reminder>` injection all ride this queue.
//! Per `tickets/notify-history-persist.md` and `AGENTS.md` (LLM
//! context の加工原則), there is **no** "transient, history-skipping"
//! lane — everything injected into a request is also committed to
//! history so any LLM reaction has a visible trigger across turns,
//! resume, and compaction, and so the Anthropic prompt cache prefix
//! stays stable across requests.
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use llm_worker::Item;
use protocol::PodEvent;
use session_store::SystemItem;
use tracing::warn;
use crate::prompt::catalog::{CatalogError, PromptCatalog};
/// Maximum queued notify entries. Oldest entries are dropped beyond this.
/// Maximum queued pending entries. Oldest entries are dropped beyond this.
const CAPACITY: usize = 128;
/// One pending notify entry awaiting injection into the next LLM request.
/// One pending entry awaiting drain into the next LLM request.
///
/// The buffer keeps the raw input shape so the drain step can decide
/// the right `SystemItem` kind (and apply `notify_wrapper` to the
/// rendered body) at the moment of commit, when the prompt catalog
/// is available.
#[derive(Debug, Clone)]
pub struct PendingNotify {
pub message: String,
pub enum PendingNotify {
Notify { message: String },
PodEvent { event: PodEvent },
}
/// Shared, mutex-guarded buffer of pending notify entries.
/// Shared, mutex-guarded buffer of pending entries.
///
/// Cloned between the Pod (producer) and PodInterceptor (consumer).
#[derive(Clone, Default)]
@@ -51,26 +60,35 @@ impl NotifyBuffer {
/// Push a notify entry onto the queue. If the queue is full, the
/// oldest entry is dropped and a `tracing::warn` is emitted — the
/// caller should never hit this in normal operation.
pub fn push(&self, message: String) {
pub fn push_notify(&self, message: String) {
self.push_entry(PendingNotify::Notify { message });
}
/// Push a typed pod-event entry onto the queue.
pub fn push_pod_event(&self, event: PodEvent) {
self.push_entry(PendingNotify::PodEvent { event });
}
fn push_entry(&self, entry: PendingNotify) {
let mut q = self.inner.lock().expect("notify buffer poisoned");
if q.len() >= CAPACITY {
let dropped = q.pop_front();
warn!(
capacity = CAPACITY,
dropped_message = dropped.as_ref().map(|n| n.message.as_str()),
dropped = ?dropped,
"notify buffer overflow; dropped oldest"
);
}
q.push_back(PendingNotify { message });
q.push_back(entry);
}
/// Remove and return all pending notify entries in FIFO order.
/// Remove and return all pending entries in FIFO order.
pub fn drain(&self) -> Vec<PendingNotify> {
let mut q = self.inner.lock().expect("notify buffer poisoned");
q.drain(..).collect()
}
/// Number of pending notify entries. Primarily for tests.
/// Number of pending entries. Primarily for tests.
pub fn len(&self) -> usize {
self.inner.lock().expect("notify buffer poisoned").len()
}
@@ -80,17 +98,30 @@ impl NotifyBuffer {
}
}
/// Format a single pending notify entry into the `Item::system_message`
/// that gets appended to `worker.history` just before the next LLM
/// request. The wrapper body comes from `PodPrompt::NotifyWrapper` so
/// the surrounding phrasing can be customised via a prompt pack
/// (translation, tone, ...).
pub(crate) fn format_notify(
n: &PendingNotify,
/// Render one pending entry into a typed `SystemItem`. The
/// `notify_wrapper` prompt produces the LLM-context body for both
/// `Notify` (raw message) and `PodEvent` (rendered event line).
pub(crate) fn build_system_item(
entry: &PendingNotify,
prompts: &PromptCatalog,
) -> Result<Item, CatalogError> {
let text = prompts.notify_wrapper(&n.message)?;
Ok(Item::system_message(text))
) -> Result<SystemItem, CatalogError> {
match entry {
PendingNotify::Notify { message } => {
let body = prompts.notify_wrapper(message)?;
Ok(SystemItem::Notification {
message: message.clone(),
body,
})
}
PendingNotify::PodEvent { event } => {
let rendered = session_store::render_pod_event(event);
let body = prompts.notify_wrapper(&rendered)?;
Ok(SystemItem::PodEvent {
event: event.clone(),
body,
})
}
}
}
#[cfg(test)]
@@ -100,12 +131,14 @@ mod tests {
#[test]
fn push_then_drain_preserves_order() {
let buf = NotifyBuffer::new();
buf.push("one".into());
buf.push("two".into());
buf.push_notify("one".into());
buf.push_notify("two".into());
let drained = buf.drain();
assert_eq!(drained.len(), 2);
assert_eq!(drained[0].message, "one");
assert_eq!(drained[1].message, "two");
match &drained[0] {
PendingNotify::Notify { message } => assert_eq!(message, "one"),
other => panic!("unexpected: {other:?}"),
}
assert!(buf.is_empty());
}
@@ -113,28 +146,50 @@ mod tests {
fn capacity_drops_oldest() {
let buf = NotifyBuffer::new();
for i in 0..(CAPACITY + 5) {
buf.push(format!("msg{i}"));
buf.push_notify(format!("msg{i}"));
}
let drained = buf.drain();
assert_eq!(drained.len(), CAPACITY);
// Oldest 5 were dropped; first retained is msg5.
assert_eq!(drained[0].message, "msg5");
assert_eq!(
drained[CAPACITY - 1].message,
format!("msg{}", CAPACITY + 4)
);
match &drained[0] {
PendingNotify::Notify { message } => assert_eq!(message, "msg5"),
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn format_notify_includes_message_and_nonblocking_hint() {
let n = PendingNotify {
fn build_system_item_for_notify_carries_wrapper_body() {
let entry = PendingNotify::Notify {
message: "hello".into(),
};
let catalog = PromptCatalog::builtins_only().unwrap();
let item = format_notify(&n, &catalog).unwrap();
let text = item.as_text().unwrap_or_default().to_string();
assert!(text.contains("[Notification]"));
assert!(text.contains("hello"));
assert!(text.contains("not a blocking request"));
let item = build_system_item(&entry, &catalog).unwrap();
match item {
SystemItem::Notification { message, body } => {
assert_eq!(message, "hello");
assert!(body.contains("[Notification]"));
assert!(body.contains("hello"));
assert!(body.contains("not a blocking request"));
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn build_system_item_for_pod_event_wraps_rendered_event_text() {
let entry = PendingNotify::PodEvent {
event: PodEvent::TurnEnded {
pod_name: "child".into(),
},
};
let catalog = PromptCatalog::builtins_only().unwrap();
let item = build_system_item(&entry, &catalog).unwrap();
match item {
SystemItem::PodEvent { event, body } => {
assert!(matches!(event, PodEvent::TurnEnded { ref pod_name } if pod_name == "child"));
assert!(body.contains("[Notification]"));
assert!(body.contains("`child`"));
}
other => panic!("unexpected: {other:?}"),
}
}
}
+27 -10
View File
@@ -104,22 +104,39 @@ async fn handle_connection(stream: tokio::net::UnixStream, handle: PodHandle) {
entry = entry_rx.recv() => {
match entry {
Ok(entry) => {
let value = serde_json::to_value(&entry)
.expect("LogEntry is Serialize");
let outbound = match &entry {
let outbound = match entry {
session_store::LogEntry::SessionStart { .. } => {
Some(Event::SessionRotated { entry: value })
let value = serde_json::to_value(&entry)
.expect("LogEntry is Serialize");
vec![Event::SessionRotated { entry: value }]
}
session_store::LogEntry::HookInjectedItems { .. } => {
Some(Event::HookInjectedItems { entry: value })
session_store::LogEntry::SystemItems { items, .. } => {
// Fan out per-item so each `SystemItem`
// arrives as its own `Event::SystemItem`
// on the wire. Batching on disk is an
// implementation detail of the drain
// task; clients see them one at a time.
items
.into_iter()
.map(|si| {
let value = serde_json::to_value(&si)
.expect("SystemItem is Serialize");
Event::SystemItem { item: value }
})
.collect()
}
// Defensive: should never reach here per
// `SessionLogSink::is_live_relevant`.
_ => None,
_ => Vec::new(),
};
if let Some(event) = outbound
&& writer.write(&event).await.is_err()
{
let mut hit_error = false;
for event in outbound {
if writer.write(&event).await.is_err() {
hit_error = true;
break;
}
}
if hit_error {
break;
}
}