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
+2
View File
@@ -32,6 +32,7 @@ pub mod logged_item;
pub mod session;
pub mod session_log;
pub mod store;
pub mod system_item;
pub use event_trace::TraceEntry;
pub use fs_store::FsStore;
@@ -48,6 +49,7 @@ pub use session_log::{
EntryHash, HashedEntry, LogEntry, POD_SCOPE_EXTENSION_DOMAIN, PodScopeSnapshot, RestoredState,
SessionOrigin, build_chain, collect_state, compute_hash,
};
pub use system_item::{SystemItem, render_pod_event};
pub use store::{Store, StoreError};
/// Session identifier. UUID v7 (time-ordered, lexicographically sortable).
+18 -1
View File
@@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::logged_item::LoggedItem;
use crate::system_item::SystemItem;
/// SHA-256 hash identifying a specific log entry in the chain.
///
@@ -125,7 +126,18 @@ pub enum LogEntry {
/// Tool execution results added to history (worker.rs:897-900, 1072-1076).
ToolResults { ts: u64, items: Vec<LoggedItem> },
/// Items injected by `on_turn_end` hook via `ContinueWithMessages` (worker.rs:1055).
/// Typed agent-injected system items: notifications, child-Pod
/// lifecycle events, `@<path>` / `#<slug>` / `/<slug>` resolution
/// payloads. Each `SystemItem` carries kind metadata that the LLM
/// itself never sees (the LLM gets `Item::system_message` with the
/// item's `history_text()`), but live clients and replay paths
/// dispatch on `kind` for typed rendering.
SystemItems { ts: u64, items: Vec<SystemItem> },
/// Legacy pre-`SystemItems` form. Deserialize-only — new writes
/// always use `SystemItems`. Items are flattened to
/// `Item::system_message` on replay, matching how the original
/// path worked.
HookInjectedItems { ts: u64, items: Vec<LoggedItem> },
/// Turn boundary. Records the turn count after increment.
@@ -276,6 +288,11 @@ pub fn collect_state(entries: &[HashedEntry]) -> RestoredState {
LogEntry::ToolResults { items, .. } => {
state.history.extend(items.iter().cloned().map(Item::from));
}
LogEntry::SystemItems { items, .. } => {
state
.history
.extend(items.iter().map(|si| si.to_history_item()));
}
LogEntry::HookInjectedItems { items, .. } => {
state.history.extend(items.iter().cloned().map(Item::from));
}
+198
View File
@@ -0,0 +1,198 @@
//! Typed system-message items injected by the agent system.
//!
//! Items in worker history with `role:system` are never produced by the
//! LLM — they are always inserted by the Pod itself (notifications,
//! file/knowledge/workflow ref resolutions, child-pod lifecycle events,
//! future `<system-reminder>` tags, …). [`SystemItem`] carries the
//! typed shape of each such injection so clients can dispatch on
//! `kind` instead of parsing text prefixes like `[Notification] …` or
//! `[File: …]`.
//!
//! Persisted as the payload of [`crate::LogEntry::SystemItems`], and
//! broadcast live as the payload of `Event::SystemItem` on the wire.
//!
//! For LLM context replay, each `SystemItem` reduces to an
//! `Item::system_message(...)` whose body matches the legacy free-text
//! shape (see [`SystemItem::history_text`]). The kind metadata is
//! preserved only on the log/wire side; the LLM still sees plain
//! system-message text.
use llm_worker::llm_client::types::Item;
use protocol::PodEvent;
use serde::{Deserialize, Serialize};
/// One agent-injected system item, tagged by origin.
///
/// Each variant carries the kind-specific raw data clients use for
/// typed rendering (`Notification.message`, `PodEvent.event`, file
/// path / knowledge slug / workflow slug / etc.), plus a pre-rendered
/// `body` (where applicable) that is the exact `role:system` text the
/// LLM actually saw at commit time. `body` is denormalised so that
/// session log replay reconstructs worker history byte-identical to
/// what was on the wire — even when prompt overrides (e.g. custom
/// `notify_wrapper` template) re-shape the live rendering on a later
/// resume.
///
/// New variants get added here as fresh injection kinds come online
/// (e.g. `Reminder`). The `kind` JSON tag is the snake_case form of
/// the variant name.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SystemItem {
/// Free-form notification sent in by an external caller via
/// `Method::Notify`. `message` is the raw caller-supplied text;
/// `body` is the wrapped LLM-context form (Pod renders it via
/// `notify_wrapper` at commit time).
Notification { message: String, body: String },
/// Lifecycle event reported by a child Pod via `Method::PodEvent`.
/// `event` is the typed payload (so the TUI can render per-child
/// banners without re-parsing); `body` is the wrapped LLM-context
/// form (same `notify_wrapper` path as `Notification`).
PodEvent { event: PodEvent, body: String },
/// `@<path>` file reference resolution. `body` is the rendered
/// LLM-context text (`[File: <path>]\n…` for regular files,
/// `[Dir: <path>]\n…` for directory listings, possibly with a
/// truncation hint) so replay reconstructs worker history
/// byte-identical to what was sent.
FileAttachment { path: String, body: String },
/// `#<slug>` Knowledge reference resolution. `body` is the
/// rendered text the LLM saw (Pod composes the `[Knowledge: …]`
/// header + body).
Knowledge { slug: String, body: String },
/// `/<slug>` Workflow invocation. `body` is the workflow's
/// prompt body materialized into the LLM context.
Workflow { slug: String, body: String },
}
impl SystemItem {
/// Free-text body the LLM sees inside its `role:system` message
/// for this item. Returns the variant's stored `body` verbatim.
pub fn history_text(&self) -> String {
match self {
SystemItem::Notification { body, .. } => body.clone(),
SystemItem::PodEvent { body, .. } => body.clone(),
SystemItem::FileAttachment { body, .. } => body.clone(),
SystemItem::Knowledge { body, .. } => body.clone(),
SystemItem::Workflow { body, .. } => body.clone(),
}
}
/// Materialize this `SystemItem` as the `Item::system_message`
/// form that lands in worker history.
pub fn to_history_item(&self) -> Item {
Item::system_message(self.history_text())
}
/// Short human-readable label used for diagnostics. Not on the
/// wire — keep flexible.
pub fn kind_label(&self) -> &'static str {
match self {
SystemItem::Notification { .. } => "notification",
SystemItem::PodEvent { .. } => "pod_event",
SystemItem::FileAttachment { .. } => "file_attachment",
SystemItem::Knowledge { .. } => "knowledge",
SystemItem::Workflow { .. } => "workflow",
}
}
}
/// Render a `PodEvent` as the one-line notification text the agent
/// sees. Centralised here (rather than at the controller's render
/// site) so persistence and broadcast share the same rendering.
pub fn render_pod_event(event: &PodEvent) -> String {
match event {
PodEvent::TurnEnded { pod_name } => format!("pod `{pod_name}` finished a turn"),
PodEvent::Errored { pod_name, message } => {
format!("pod `{pod_name}` errored: {message}")
}
PodEvent::ShutDown { pod_name } => format!("pod `{pod_name}` shut down"),
PodEvent::ScopeSubDelegated {
parent_pod,
sub_pod,
..
} => {
format!("pod `{parent_pod}` sub-delegated scope to `{sub_pod}`")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn notification_history_text_returns_stored_body() {
let item = SystemItem::Notification {
message: "child done".into(),
body: "[Notification]\nchild done\n\n(non-blocking hint…)".into(),
};
assert_eq!(
item.history_text(),
"[Notification]\nchild done\n\n(non-blocking hint…)"
);
}
#[test]
fn pod_event_history_text_returns_stored_body() {
let item = SystemItem::PodEvent {
event: PodEvent::TurnEnded {
pod_name: "child".into(),
},
body: "[Notification]\npod `child` finished a turn\n\n(non-blocking hint…)".into(),
};
assert!(item.history_text().starts_with("[Notification]\n"));
assert!(item.history_text().contains("`child`"));
}
#[test]
fn file_attachment_history_text_returns_stored_body() {
let item = SystemItem::FileAttachment {
path: "src/main.rs".into(),
body: "[File: src/main.rs]\nfn main() {}".into(),
};
assert_eq!(item.history_text(), "[File: src/main.rs]\nfn main() {}");
}
#[test]
fn round_trip_via_json() {
let item = SystemItem::FileAttachment {
path: "src/main.rs".into(),
body: "[File: src/main.rs]\nfn main() {}".into(),
};
let json = serde_json::to_string(&item).unwrap();
let parsed: SystemItem = serde_json::from_str(&json).unwrap();
match parsed {
SystemItem::FileAttachment { path, body } => {
assert_eq!(path, "src/main.rs");
assert_eq!(body, "[File: src/main.rs]\nfn main() {}");
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn round_trip_pod_event() {
let item = SystemItem::PodEvent {
event: PodEvent::TurnEnded {
pod_name: "child".into(),
},
body: "[Notification] pod `child` finished a turn".into(),
};
let json = serde_json::to_string(&item).unwrap();
let parsed: SystemItem = serde_json::from_str(&json).unwrap();
match parsed {
SystemItem::PodEvent {
event: PodEvent::TurnEnded { pod_name },
body,
} => {
assert_eq!(pod_name, "child");
assert!(body.contains("`child`"));
}
other => panic!("unexpected: {other:?}"),
}
}
}