update: SystemItem1本化
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user