Compare commits
11 Commits
36c24a4c7e
...
2abb31f0b9
| Author | SHA1 | Date | |
|---|---|---|---|
| 2abb31f0b9 | |||
| e0caf7d17e | |||
| c7272ce01f | |||
| 63a16b37d5 | |||
| 4c3b09e789 | |||
| e5f0107fa8 | |||
| 4f1b17b9bf | |||
| 1c183dae00 | |||
| 61e90d891b | |||
| 18eef116e6 | |||
| 0c32f75df8 |
|
|
@ -22,7 +22,8 @@ use tracing::info;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::compact::state::CompactState;
|
use crate::compact::state::CompactState;
|
||||||
use session_store::SystemItem;
|
use session_store::{SystemItem, SystemReminder};
|
||||||
|
use tools::{TaskEntry, TaskStatus, TaskStore};
|
||||||
|
|
||||||
use crate::hook::{
|
use crate::hook::{
|
||||||
AbortInfo, HookPromptAction, HookRegistry, PreRequestInfo, PromptSubmitInfo, ToolCallSummary,
|
AbortInfo, HookPromptAction, HookRegistry, PreRequestInfo, PromptSubmitInfo, ToolCallSummary,
|
||||||
|
|
@ -36,6 +37,53 @@ use llm_worker::token_counter::total_tokens;
|
||||||
/// Maximum number of bytes copied into `TurnEndInfo::final_text_preview`.
|
/// Maximum number of bytes copied into `TurnEndInfo::final_text_preview`.
|
||||||
const FINAL_TEXT_PREVIEW_LIMIT: usize = 512;
|
const FINAL_TEXT_PREVIEW_LIMIT: usize = 512;
|
||||||
|
|
||||||
|
const TASK_REMINDER_REQUEST_THRESHOLD: usize = 8;
|
||||||
|
const TASK_REMINDER_COOLDOWN_REQUESTS: usize = 8;
|
||||||
|
const TASK_MANAGEMENT_TOOL_NAMES: [&str; 2] = ["TaskCreate", "TaskUpdate"];
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct TaskReminderState {
|
||||||
|
requests_since_last_task_management: AtomicUsize,
|
||||||
|
requests_since_last_reminder: AtomicUsize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TaskReminderState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
requests_since_last_task_management: AtomicUsize::new(0),
|
||||||
|
requests_since_last_reminder: AtomicUsize::new(TASK_REMINDER_COOLDOWN_REQUESTS),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskReminderState {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn note_request(&self) -> (usize, usize) {
|
||||||
|
let since_task_management = self
|
||||||
|
.requests_since_last_task_management
|
||||||
|
.fetch_add(1, Ordering::Relaxed)
|
||||||
|
.saturating_add(1);
|
||||||
|
let since_reminder = self
|
||||||
|
.requests_since_last_reminder
|
||||||
|
.fetch_add(1, Ordering::Relaxed)
|
||||||
|
.saturating_add(1);
|
||||||
|
(since_task_management, since_reminder)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn note_task_management(&self) {
|
||||||
|
self.requests_since_last_task_management
|
||||||
|
.store(0, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn note_reminder(&self) {
|
||||||
|
self.requests_since_last_reminder
|
||||||
|
.store(0, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) struct PodInterceptor {
|
pub(crate) struct PodInterceptor {
|
||||||
registry: Arc<HookRegistry>,
|
registry: Arc<HookRegistry>,
|
||||||
compact_state: Option<Arc<CompactState>>,
|
compact_state: Option<Arc<CompactState>>,
|
||||||
|
|
@ -55,7 +103,12 @@ pub(crate) struct PodInterceptor {
|
||||||
/// `PromptAction::ContinueWith`. Populated by `Pod::run`
|
/// `PromptAction::ContinueWith`. Populated by `Pod::run`
|
||||||
/// immediately before handing off to the worker.
|
/// immediately before handing off to the worker.
|
||||||
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
|
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
|
||||||
/// Prompt catalog used to render the injected notification wrapper.
|
/// Task state observed by built-in task tools. Used to nudge the main
|
||||||
|
/// worker when active tasks have gone unmentioned for several requests.
|
||||||
|
task_store: TaskStore,
|
||||||
|
task_reminder_state: Arc<TaskReminderState>,
|
||||||
|
/// Prompt catalog used to render pending notification entries into the
|
||||||
|
/// same system-message text that will be persisted in history.
|
||||||
prompts: Arc<PromptCatalog>,
|
prompts: Arc<PromptCatalog>,
|
||||||
/// Type-erased commit handle. The interceptor uses it to commit
|
/// Type-erased commit handle. The interceptor uses it to commit
|
||||||
/// `LogEntry::SystemItem` entries directly (sync) before
|
/// `LogEntry::SystemItem` entries directly (sync) before
|
||||||
|
|
@ -76,6 +129,8 @@ impl PodInterceptor {
|
||||||
usage_history: Option<Arc<Mutex<Vec<UsageRecord>>>>,
|
usage_history: Option<Arc<Mutex<Vec<UsageRecord>>>>,
|
||||||
pending_notifies: NotifyBuffer,
|
pending_notifies: NotifyBuffer,
|
||||||
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
|
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
|
||||||
|
task_store: TaskStore,
|
||||||
|
task_reminder_state: Arc<TaskReminderState>,
|
||||||
prompts: Arc<PromptCatalog>,
|
prompts: Arc<PromptCatalog>,
|
||||||
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
|
@ -85,6 +140,8 @@ impl PodInterceptor {
|
||||||
usage_history,
|
usage_history,
|
||||||
pending_notifies,
|
pending_notifies,
|
||||||
pending_attachments,
|
pending_attachments,
|
||||||
|
task_store,
|
||||||
|
task_reminder_state,
|
||||||
prompts,
|
prompts,
|
||||||
log_writer,
|
log_writer,
|
||||||
next_turn_index: AtomicUsize::new(0),
|
next_turn_index: AtomicUsize::new(0),
|
||||||
|
|
@ -121,6 +178,48 @@ impl PodInterceptor {
|
||||||
let records = handle.lock().expect("usage_history poisoned").clone();
|
let records = handle.lock().expect("usage_history poisoned").clone();
|
||||||
Some(total_tokens(context, &records).tokens)
|
Some(total_tokens(context, &records).tokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn task_reminder_system_item(&self) -> Option<SystemItem> {
|
||||||
|
let active_tasks: Vec<TaskEntry> = self
|
||||||
|
.task_store
|
||||||
|
.list()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Inprogress))
|
||||||
|
.collect();
|
||||||
|
if active_tasks.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (since_task_management, since_reminder) = self.task_reminder_state.note_request();
|
||||||
|
if since_task_management < TASK_REMINDER_REQUEST_THRESHOLD
|
||||||
|
|| since_reminder < TASK_REMINDER_COOLDOWN_REQUESTS
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.task_reminder_state.note_reminder();
|
||||||
|
Some(
|
||||||
|
SystemReminder::task_inactivity(render_task_reminder_body(&active_tasks))
|
||||||
|
.into_system_item(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_task_management_tool(name: &str) -> bool {
|
||||||
|
TASK_MANAGEMENT_TOOL_NAMES.contains(&name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_task_reminder_body(active_tasks: &[TaskEntry]) -> String {
|
||||||
|
let mut body = String::from(
|
||||||
|
"Active session tasks are still open. If progress changed, call TaskUpdate.\n",
|
||||||
|
);
|
||||||
|
for task in active_tasks {
|
||||||
|
body.push_str(&format!(
|
||||||
|
"- taskid {} ({}) {}\n",
|
||||||
|
task.taskid, task.status, task.subject
|
||||||
|
));
|
||||||
|
}
|
||||||
|
body.trim_end_matches('\n').to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|
@ -161,11 +260,13 @@ impl Interceptor for PodInterceptor {
|
||||||
|
|
||||||
async fn pending_history_appends(&self) -> Vec<Item> {
|
async fn pending_history_appends(&self) -> Vec<Item> {
|
||||||
let drained = self.pending_notifies.drain();
|
let drained = self.pending_notifies.drain();
|
||||||
if drained.is_empty() {
|
let task_reminder = self.task_reminder_system_item();
|
||||||
|
if drained.is_empty() && task_reminder.is_none() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let mut system_items: Vec<SystemItem> = Vec::with_capacity(drained.len());
|
|
||||||
let mut items: Vec<Item> = Vec::with_capacity(drained.len());
|
let mut system_items: Vec<SystemItem> = Vec::with_capacity(drained.len() + 1);
|
||||||
|
let mut items: Vec<Item> = Vec::with_capacity(drained.len() + 1);
|
||||||
for entry in drained {
|
for entry in drained {
|
||||||
match build_system_item(&entry, &self.prompts) {
|
match build_system_item(&entry, &self.prompts) {
|
||||||
Ok(system_item) => {
|
Ok(system_item) => {
|
||||||
|
|
@ -188,6 +289,10 @@ impl Interceptor for PodInterceptor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(system_item) = task_reminder {
|
||||||
|
items.push(system_item.to_history_item());
|
||||||
|
system_items.push(system_item);
|
||||||
|
}
|
||||||
self.commit_system_items(&system_items);
|
self.commit_system_items(&system_items);
|
||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
|
@ -237,6 +342,9 @@ impl Interceptor for PodInterceptor {
|
||||||
return action;
|
return action;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if is_task_management_tool(&info.call.name) {
|
||||||
|
self.task_reminder_state.note_task_management();
|
||||||
|
}
|
||||||
self.tool_calls_this_turn.fetch_add(1, Ordering::Relaxed);
|
self.tool_calls_this_turn.fetch_add(1, Ordering::Relaxed);
|
||||||
PreToolAction::Continue
|
PreToolAction::Continue
|
||||||
}
|
}
|
||||||
|
|
@ -322,6 +430,7 @@ mod tests {
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::hook::{Hook, HookRegistryBuilder, PreLlmRequest};
|
use crate::hook::{Hook, HookRegistryBuilder, PreLlmRequest};
|
||||||
|
use session_store::SystemReminderSource;
|
||||||
|
|
||||||
struct CountingHook(Arc<AtomicUsize>);
|
struct CountingHook(Arc<AtomicUsize>);
|
||||||
|
|
||||||
|
|
@ -333,12 +442,51 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn registry_with_pre_llm_hook(counter: Arc<AtomicUsize>) -> Arc<HookRegistry> {
|
fn registry_with_pre_llm_hook(count: Arc<AtomicUsize>) -> Arc<HookRegistry> {
|
||||||
let mut builder = HookRegistryBuilder::new();
|
let mut builder = HookRegistryBuilder::new();
|
||||||
builder.add_pre_llm_request(CountingHook(counter));
|
builder.add_pre_llm_request(CountingHook(count));
|
||||||
Arc::new(builder.build())
|
Arc::new(builder.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn interceptor_for_task_reminders(
|
||||||
|
task_store: TaskStore,
|
||||||
|
task_reminder_state: Arc<TaskReminderState>,
|
||||||
|
) -> PodInterceptor {
|
||||||
|
PodInterceptor::new(
|
||||||
|
Arc::new(HookRegistryBuilder::new().build()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
NotifyBuffer::new(),
|
||||||
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
|
task_store,
|
||||||
|
task_reminder_state,
|
||||||
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call_pre_tool(interceptor: &PodInterceptor, name: &str) {
|
||||||
|
let def = tools::task_tools(TaskStore::new())
|
||||||
|
.into_iter()
|
||||||
|
.find(|def| {
|
||||||
|
let (meta, _) = def();
|
||||||
|
meta.name == name
|
||||||
|
})
|
||||||
|
.expect("task tool definition");
|
||||||
|
let (meta, tool) = def();
|
||||||
|
let mut info = ToolCallInfo {
|
||||||
|
call: llm_worker::tool::ToolCall {
|
||||||
|
id: "call-id".into(),
|
||||||
|
name: name.into(),
|
||||||
|
input: serde_json::json!({}),
|
||||||
|
},
|
||||||
|
meta,
|
||||||
|
tool,
|
||||||
|
};
|
||||||
|
let action = interceptor.pre_tool_call(&mut info).await;
|
||||||
|
assert!(matches!(action, PreToolAction::Continue));
|
||||||
|
}
|
||||||
|
|
||||||
/// Build a usage_history handle with a single record pinned at the
|
/// Build a usage_history handle with a single record pinned at the
|
||||||
/// current `context_len` so that `total_tokens` returns exactly
|
/// current `context_len` so that `total_tokens` returns exactly
|
||||||
/// `tokens` (Measured, no interpolation or byte-based fallback).
|
/// `tokens` (Measured, no interpolation or byte-based fallback).
|
||||||
|
|
@ -367,6 +515,8 @@ mod tests {
|
||||||
Some(history),
|
Some(history),
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
|
TaskStore::new(),
|
||||||
|
Arc::new(TaskReminderState::new()),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|
@ -393,6 +543,8 @@ mod tests {
|
||||||
Some(history),
|
Some(history),
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
|
TaskStore::new(),
|
||||||
|
Arc::new(TaskReminderState::new()),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|
@ -420,6 +572,8 @@ mod tests {
|
||||||
Some(history),
|
Some(history),
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
|
TaskStore::new(),
|
||||||
|
Arc::new(TaskReminderState::new()),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|
@ -441,6 +595,8 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
|
TaskStore::new(),
|
||||||
|
Arc::new(TaskReminderState::new()),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|
@ -474,6 +630,8 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
buffer.clone(),
|
buffer.clone(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
|
TaskStore::new(),
|
||||||
|
Arc::new(TaskReminderState::new()),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|
@ -496,6 +654,207 @@ mod tests {
|
||||||
assert!(again.is_empty());
|
assert!(again.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn task_reminder_appends_after_inactive_request_threshold() {
|
||||||
|
let task_store = TaskStore::new();
|
||||||
|
task_store.create("keep going".into(), "long task description".into());
|
||||||
|
let interceptor =
|
||||||
|
interceptor_for_task_reminders(task_store, Arc::new(TaskReminderState::new()));
|
||||||
|
|
||||||
|
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD - 1 {
|
||||||
|
assert!(interceptor.pending_history_appends().await.is_empty());
|
||||||
|
}
|
||||||
|
let items = interceptor.pending_history_appends().await;
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
let body = items[0].as_text().unwrap_or_default();
|
||||||
|
assert_eq!(body.matches("<system-reminder>").count(), 1);
|
||||||
|
assert_eq!(body.matches("</system-reminder>").count(), 1);
|
||||||
|
assert!(body.contains("taskid 1"));
|
||||||
|
assert!(body.contains("pending"));
|
||||||
|
assert!(body.contains("keep going"));
|
||||||
|
assert!(!body.contains("long task description"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn task_reminder_system_item_retains_source() {
|
||||||
|
let task_store = TaskStore::new();
|
||||||
|
task_store.create("typed".into(), String::new());
|
||||||
|
let interceptor =
|
||||||
|
interceptor_for_task_reminders(task_store, Arc::new(TaskReminderState::new()));
|
||||||
|
|
||||||
|
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD - 1 {
|
||||||
|
assert!(interceptor.task_reminder_system_item().is_none());
|
||||||
|
}
|
||||||
|
let item = interceptor.task_reminder_system_item().unwrap();
|
||||||
|
match item {
|
||||||
|
SystemItem::TaskReminder { source, body } => {
|
||||||
|
assert_eq!(source, SystemReminderSource::TaskInactivity);
|
||||||
|
assert_eq!(body.matches("<system-reminder>").count(), 1);
|
||||||
|
assert_eq!(body.matches("</system-reminder>").count(), 1);
|
||||||
|
assert!(body.contains("typed"));
|
||||||
|
}
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_task_reminder_body_is_unwrapped_for_system_reminder_helper() {
|
||||||
|
let task_store = TaskStore::new();
|
||||||
|
let task = task_store.create("body".into(), String::new());
|
||||||
|
let body = render_task_reminder_body(&[task]);
|
||||||
|
|
||||||
|
assert!(!body.contains("<system-reminder>"));
|
||||||
|
assert!(!body.contains("</system-reminder>"));
|
||||||
|
assert!(body.contains("TaskUpdate"));
|
||||||
|
assert!(body.contains("taskid 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn task_reminder_state_starts_with_initial_cooldown_elapsed() {
|
||||||
|
let state = TaskReminderState::new();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
state.requests_since_last_reminder.load(Ordering::Relaxed),
|
||||||
|
TASK_REMINDER_COOLDOWN_REQUESTS
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state
|
||||||
|
.requests_since_last_task_management
|
||||||
|
.load(Ordering::Relaxed),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn task_management_tool_call_resets_reminder_inactivity_counter() {
|
||||||
|
let task_store = TaskStore::new();
|
||||||
|
task_store.create("track me".into(), String::new());
|
||||||
|
let interceptor =
|
||||||
|
interceptor_for_task_reminders(task_store, Arc::new(TaskReminderState::new()));
|
||||||
|
|
||||||
|
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD - 1 {
|
||||||
|
assert!(interceptor.pending_history_appends().await.is_empty());
|
||||||
|
}
|
||||||
|
call_pre_tool(&interceptor, "TaskUpdate").await;
|
||||||
|
|
||||||
|
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD - 1 {
|
||||||
|
assert!(interceptor.pending_history_appends().await.is_empty());
|
||||||
|
}
|
||||||
|
assert_eq!(interceptor.pending_history_appends().await.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn task_reminder_respects_cooldown_after_reminder() {
|
||||||
|
let task_store = TaskStore::new();
|
||||||
|
task_store.create("cooldown".into(), String::new());
|
||||||
|
let interceptor =
|
||||||
|
interceptor_for_task_reminders(task_store, Arc::new(TaskReminderState::new()));
|
||||||
|
|
||||||
|
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD {
|
||||||
|
let _ = interceptor.pending_history_appends().await;
|
||||||
|
}
|
||||||
|
for _ in 0..TASK_REMINDER_COOLDOWN_REQUESTS - 1 {
|
||||||
|
assert!(interceptor.pending_history_appends().await.is_empty());
|
||||||
|
}
|
||||||
|
assert_eq!(interceptor.pending_history_appends().await.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn task_reminder_is_silent_when_no_active_tasks_exist() {
|
||||||
|
let task_store = TaskStore::new();
|
||||||
|
let done = task_store.create("done".into(), String::new()).taskid;
|
||||||
|
task_store
|
||||||
|
.update(done, Some(TaskStatus::Completed), None, None)
|
||||||
|
.expect("complete task");
|
||||||
|
let interceptor =
|
||||||
|
interceptor_for_task_reminders(task_store, Arc::new(TaskReminderState::new()));
|
||||||
|
|
||||||
|
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD * 2 {
|
||||||
|
assert!(interceptor.pending_history_appends().await.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn inactive_requests_without_active_tasks_do_not_prime_task_reminder() {
|
||||||
|
let task_store = TaskStore::new();
|
||||||
|
let interceptor =
|
||||||
|
interceptor_for_task_reminders(task_store.clone(), Arc::new(TaskReminderState::new()));
|
||||||
|
|
||||||
|
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD * 2 {
|
||||||
|
assert!(interceptor.pending_history_appends().await.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
task_store.create("new active".into(), String::new());
|
||||||
|
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD - 1 {
|
||||||
|
assert!(interceptor.pending_history_appends().await.is_empty());
|
||||||
|
}
|
||||||
|
assert_eq!(interceptor.pending_history_appends().await.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn task_create_reset_does_not_block_first_reminder_cooldown() {
|
||||||
|
let task_store = TaskStore::new();
|
||||||
|
let state = Arc::new(TaskReminderState::new());
|
||||||
|
let interceptor = interceptor_for_task_reminders(task_store.clone(), state.clone());
|
||||||
|
|
||||||
|
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD * 2 {
|
||||||
|
assert!(interceptor.pending_history_appends().await.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
call_pre_tool(&interceptor, "TaskCreate").await;
|
||||||
|
task_store.create("created after idle".into(), String::new());
|
||||||
|
assert_eq!(
|
||||||
|
state.requests_since_last_reminder.load(Ordering::Relaxed),
|
||||||
|
TASK_REMINDER_COOLDOWN_REQUESTS,
|
||||||
|
"TaskCreate reset must not clear the initial reminder cooldown"
|
||||||
|
);
|
||||||
|
|
||||||
|
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD - 1 {
|
||||||
|
assert!(interceptor.pending_history_appends().await.is_empty());
|
||||||
|
}
|
||||||
|
assert_eq!(interceptor.pending_history_appends().await.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn task_reminder_lands_in_pending_history_appends_lane() {
|
||||||
|
let task_store = TaskStore::new();
|
||||||
|
task_store.create("lane".into(), String::new());
|
||||||
|
let interceptor =
|
||||||
|
interceptor_for_task_reminders(task_store, Arc::new(TaskReminderState::new()));
|
||||||
|
let mut ctx = vec![Item::user_message("hi")];
|
||||||
|
|
||||||
|
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD {
|
||||||
|
let _ = interceptor.pending_history_appends().await;
|
||||||
|
}
|
||||||
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
||||||
|
assert!(matches!(action, PreRequestAction::Continue));
|
||||||
|
assert_eq!(ctx.len(), 1, "pre_llm_request must not inject reminders");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pre_llm_request_does_not_touch_task_reminder_lane() {
|
||||||
|
let task_store = TaskStore::new();
|
||||||
|
task_store.create("lane".into(), String::new());
|
||||||
|
let interceptor =
|
||||||
|
interceptor_for_task_reminders(task_store, Arc::new(TaskReminderState::new()));
|
||||||
|
let mut ctx = vec![Item::user_message("hi")];
|
||||||
|
|
||||||
|
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD - 1 {
|
||||||
|
assert!(interceptor.pending_history_appends().await.is_empty());
|
||||||
|
}
|
||||||
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
||||||
|
assert!(matches!(action, PreRequestAction::Continue));
|
||||||
|
assert_eq!(ctx.len(), 1, "pre_llm_request must not inject reminders");
|
||||||
|
let pending = interceptor.pending_history_appends().await;
|
||||||
|
assert_eq!(
|
||||||
|
pending.len(),
|
||||||
|
1,
|
||||||
|
"reminders stay in pending_history_appends"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pre_llm_request_does_not_touch_pending_notifies() {
|
async fn pre_llm_request_does_not_touch_pending_notifies() {
|
||||||
// The drain lane has moved to `pending_history_appends`;
|
// The drain lane has moved to `pending_history_appends`;
|
||||||
|
|
@ -511,6 +870,8 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
buffer.clone(),
|
buffer.clone(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
|
TaskStore::new(),
|
||||||
|
Arc::new(TaskReminderState::new()),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|
@ -541,6 +902,8 @@ mod tests {
|
||||||
None,
|
None,
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
|
TaskStore::new(),
|
||||||
|
Arc::new(TaskReminderState::new()),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ use crate::hook::{
|
||||||
PreRequestInfo, PreToolCall,
|
PreRequestInfo, PreToolCall,
|
||||||
};
|
};
|
||||||
use crate::ipc::alerter::Alerter;
|
use crate::ipc::alerter::Alerter;
|
||||||
use crate::ipc::interceptor::PodInterceptor;
|
use crate::ipc::interceptor::{PodInterceptor, TaskReminderState};
|
||||||
use crate::ipc::notify_buffer::NotifyBuffer;
|
use crate::ipc::notify_buffer::NotifyBuffer;
|
||||||
use crate::prompt::agents_md::read_agents_md;
|
use crate::prompt::agents_md::read_agents_md;
|
||||||
use crate::prompt::catalog::{CatalogError, PromptCatalog};
|
use crate::prompt::catalog::{CatalogError, PromptCatalog};
|
||||||
|
|
@ -272,6 +272,10 @@ pub struct Pod<C: LlmClient, St: Store> {
|
||||||
/// compaction by keeping the same handle while the Worker history is
|
/// compaction by keeping the same handle while the Worker history is
|
||||||
/// replaced. Restored Pods reconstruct it by replaying Task* tool calls.
|
/// replaced. Restored Pods reconstruct it by replaying Task* tool calls.
|
||||||
task_store: tools::TaskStore,
|
task_store: tools::TaskStore,
|
||||||
|
/// Session-lifetime counters for active-Task reminder nudges.
|
||||||
|
/// Restored Pods start these at zero; the only consequence is a delayed
|
||||||
|
/// first reminder after resume.
|
||||||
|
task_reminder_state: Arc<TaskReminderState>,
|
||||||
/// Parsed system-prompt template awaiting first-turn materialisation.
|
/// Parsed system-prompt template awaiting first-turn materialisation.
|
||||||
/// `Some` until `ensure_system_prompt_materialized` renders it once,
|
/// `Some` until `ensure_system_prompt_materialized` renders it once,
|
||||||
/// then `None` forever — including after compaction.
|
/// then `None` forever — including after compaction.
|
||||||
|
|
@ -431,6 +435,7 @@ impl<C: LlmClient + Clone + 'static, St: Store + Clone + 'static> Pod<C, St> {
|
||||||
usage_history: self.usage_history.clone(),
|
usage_history: self.usage_history.clone(),
|
||||||
tracker: None,
|
tracker: None,
|
||||||
task_store: self.task_store.clone(),
|
task_store: self.task_store.clone(),
|
||||||
|
task_reminder_state: self.task_reminder_state.clone(),
|
||||||
system_prompt_template: None,
|
system_prompt_template: None,
|
||||||
alerter: self.alerter.clone(),
|
alerter: self.alerter.clone(),
|
||||||
event_tx: self.event_tx.clone(),
|
event_tx: self.event_tx.clone(),
|
||||||
|
|
@ -610,6 +615,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
usage_history: Arc::new(Mutex::new(Vec::<UsageRecord>::new())),
|
usage_history: Arc::new(Mutex::new(Vec::<UsageRecord>::new())),
|
||||||
tracker: None,
|
tracker: None,
|
||||||
task_store: tools::TaskStore::new(),
|
task_store: tools::TaskStore::new(),
|
||||||
|
task_reminder_state: Arc::new(TaskReminderState::new()),
|
||||||
system_prompt_template: None,
|
system_prompt_template: None,
|
||||||
alerter: None,
|
alerter: None,
|
||||||
event_tx: None,
|
event_tx: None,
|
||||||
|
|
@ -1260,6 +1266,8 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
usage_history_handle,
|
usage_history_handle,
|
||||||
self.pending_notifies.clone(),
|
self.pending_notifies.clone(),
|
||||||
self.pending_attachments.clone(),
|
self.pending_attachments.clone(),
|
||||||
|
self.task_store.clone(),
|
||||||
|
self.task_reminder_state.clone(),
|
||||||
self.prompts.clone(),
|
self.prompts.clone(),
|
||||||
self.log_writer.clone(),
|
self.log_writer.clone(),
|
||||||
);
|
);
|
||||||
|
|
@ -3797,6 +3805,7 @@ where
|
||||||
usage_history: Arc::new(Mutex::new(Vec::new())),
|
usage_history: Arc::new(Mutex::new(Vec::new())),
|
||||||
tracker: None,
|
tracker: None,
|
||||||
task_store: tools::TaskStore::new(),
|
task_store: tools::TaskStore::new(),
|
||||||
|
task_reminder_state: Arc::new(TaskReminderState::new()),
|
||||||
system_prompt_template: common.system_prompt_template,
|
system_prompt_template: common.system_prompt_template,
|
||||||
alerter: None,
|
alerter: None,
|
||||||
event_tx: None,
|
event_tx: None,
|
||||||
|
|
@ -3876,6 +3885,7 @@ where
|
||||||
usage_history: Arc::new(Mutex::new(Vec::new())),
|
usage_history: Arc::new(Mutex::new(Vec::new())),
|
||||||
tracker: None,
|
tracker: None,
|
||||||
task_store: tools::TaskStore::new(),
|
task_store: tools::TaskStore::new(),
|
||||||
|
task_reminder_state: Arc::new(TaskReminderState::new()),
|
||||||
system_prompt_template: common.system_prompt_template,
|
system_prompt_template: common.system_prompt_template,
|
||||||
alerter: None,
|
alerter: None,
|
||||||
event_tx: None,
|
event_tx: None,
|
||||||
|
|
@ -4052,6 +4062,7 @@ where
|
||||||
usage_history: Arc::new(Mutex::new(state.usage_history)),
|
usage_history: Arc::new(Mutex::new(state.usage_history)),
|
||||||
tracker: None,
|
tracker: None,
|
||||||
task_store,
|
task_store,
|
||||||
|
task_reminder_state: Arc::new(TaskReminderState::new()),
|
||||||
// Restore replays the saved system_prompt verbatim — no
|
// Restore replays the saved system_prompt verbatim — no
|
||||||
// template re-render on resume.
|
// template re-render on resume.
|
||||||
system_prompt_template: None,
|
system_prompt_template: None,
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ pub use segment_log::{
|
||||||
collect_state,
|
collect_state,
|
||||||
};
|
};
|
||||||
pub use store::{Store, StoreError};
|
pub use store::{Store, StoreError};
|
||||||
pub use system_item::{SystemItem, render_pod_event};
|
pub use system_item::{SystemItem, SystemReminder, SystemReminderSource, render_pod_event};
|
||||||
|
|
||||||
/// Session identifier — the fork-tree root. UUID v7 (time-ordered).
|
/// Session identifier — the fork-tree root. UUID v7 (time-ordered).
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,86 @@ use llm_worker::llm_client::types::Item;
|
||||||
use protocol::PodEvent;
|
use protocol::PodEvent;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
const SYSTEM_REMINDER_OPEN: &str = "<system-reminder>";
|
||||||
|
const SYSTEM_REMINDER_CLOSE: &str = "</system-reminder>";
|
||||||
|
|
||||||
|
/// Source policy that produced a durable `<system-reminder>` input.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SystemReminderSource {
|
||||||
|
/// Session task inactivity reminder emitted after task-management tools
|
||||||
|
/// have gone unused for the configured request threshold.
|
||||||
|
TaskInactivity,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_task_reminder_source() -> SystemReminderSource {
|
||||||
|
SystemReminderSource::TaskInactivity
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typed pending system reminder before it is committed as a [`SystemItem`].
|
||||||
|
///
|
||||||
|
/// System reminders are durable input: producers must append the rendered
|
||||||
|
/// `SystemItem` through worker history before the next LLM request. They are
|
||||||
|
/// not transient UI notices and must not be prompt-cache/context-only
|
||||||
|
/// injections, because then the model could react to input that is absent from
|
||||||
|
/// persisted history on later turns.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SystemReminder {
|
||||||
|
source: SystemReminderSource,
|
||||||
|
body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SystemReminder {
|
||||||
|
/// Build a task-inactivity reminder from an unwrapped body.
|
||||||
|
pub fn task_inactivity(body: impl Into<String>) -> Self {
|
||||||
|
Self::new(SystemReminderSource::TaskInactivity, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a reminder from an unwrapped body. If a caller passes a body that
|
||||||
|
/// is already exactly wrapped in `<system-reminder>` tags, normalize it back
|
||||||
|
/// to the inner body so rendering still wraps exactly once.
|
||||||
|
pub fn new(source: SystemReminderSource, body: impl Into<String>) -> Self {
|
||||||
|
let body = normalize_unwrapped_system_reminder_body(body.into());
|
||||||
|
Self { source, body }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn source(&self) -> SystemReminderSource {
|
||||||
|
self.source
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn body(&self) -> &str {
|
||||||
|
&self.body
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rendered_body(&self) -> String {
|
||||||
|
render_system_reminder(&self.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_system_item(self) -> SystemItem {
|
||||||
|
match self.source {
|
||||||
|
SystemReminderSource::TaskInactivity => SystemItem::TaskReminder {
|
||||||
|
source: self.source,
|
||||||
|
body: self.rendered_body(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_unwrapped_system_reminder_body(body: String) -> String {
|
||||||
|
let trimmed = body.trim();
|
||||||
|
if let Some(inner) = trimmed
|
||||||
|
.strip_prefix(SYSTEM_REMINDER_OPEN)
|
||||||
|
.and_then(|rest| rest.strip_suffix(SYSTEM_REMINDER_CLOSE))
|
||||||
|
{
|
||||||
|
return inner.trim_matches('\n').to_string();
|
||||||
|
}
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_system_reminder(body: &str) -> String {
|
||||||
|
format!("{SYSTEM_REMINDER_OPEN}\n{body}\n{SYSTEM_REMINDER_CLOSE}")
|
||||||
|
}
|
||||||
|
|
||||||
/// One agent-injected system item, tagged by origin.
|
/// One agent-injected system item, tagged by origin.
|
||||||
///
|
///
|
||||||
/// Each variant carries the kind-specific raw data clients use for
|
/// Each variant carries the kind-specific raw data clients use for
|
||||||
|
|
@ -35,7 +115,7 @@ use serde::{Deserialize, Serialize};
|
||||||
/// resume.
|
/// resume.
|
||||||
///
|
///
|
||||||
/// New variants get added here as fresh injection kinds come online
|
/// New variants get added here as fresh injection kinds come online
|
||||||
/// (e.g. `Reminder`). The `kind` JSON tag is the snake_case form of
|
/// (e.g. `TaskReminder`). The `kind` JSON tag is the snake_case form of
|
||||||
/// the variant name.
|
/// the variant name.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
|
@ -68,6 +148,15 @@ pub enum SystemItem {
|
||||||
/// prompt body materialized into the LLM context.
|
/// prompt body materialized into the LLM context.
|
||||||
Workflow { slug: String, body: String },
|
Workflow { slug: String, body: String },
|
||||||
|
|
||||||
|
/// Task-management inactivity reminder inserted before an LLM request.
|
||||||
|
/// `source` is the policy that produced this durable reminder; `body` is
|
||||||
|
/// the exact LLM-context text wrapped in a `<system-reminder>` block.
|
||||||
|
TaskReminder {
|
||||||
|
#[serde(default = "default_task_reminder_source")]
|
||||||
|
source: SystemReminderSource,
|
||||||
|
body: String,
|
||||||
|
},
|
||||||
|
|
||||||
/// Synthetic note inserted after an interrupted turn before the next
|
/// Synthetic note inserted after an interrupted turn before the next
|
||||||
/// user input. `body` is the exact LLM-context text explaining that the
|
/// user input. `body` is the exact LLM-context text explaining that the
|
||||||
/// previous turn was cut short.
|
/// previous turn was cut short.
|
||||||
|
|
@ -84,6 +173,7 @@ impl SystemItem {
|
||||||
SystemItem::FileAttachment { body, .. } => body.clone(),
|
SystemItem::FileAttachment { body, .. } => body.clone(),
|
||||||
SystemItem::Knowledge { body, .. } => body.clone(),
|
SystemItem::Knowledge { body, .. } => body.clone(),
|
||||||
SystemItem::Workflow { body, .. } => body.clone(),
|
SystemItem::Workflow { body, .. } => body.clone(),
|
||||||
|
SystemItem::TaskReminder { body, .. } => body.clone(),
|
||||||
SystemItem::Interrupt { body } => body.clone(),
|
SystemItem::Interrupt { body } => body.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -103,6 +193,7 @@ impl SystemItem {
|
||||||
SystemItem::FileAttachment { .. } => "file_attachment",
|
SystemItem::FileAttachment { .. } => "file_attachment",
|
||||||
SystemItem::Knowledge { .. } => "knowledge",
|
SystemItem::Knowledge { .. } => "knowledge",
|
||||||
SystemItem::Workflow { .. } => "workflow",
|
SystemItem::Workflow { .. } => "workflow",
|
||||||
|
SystemItem::TaskReminder { .. } => "task_reminder",
|
||||||
SystemItem::Interrupt { .. } => "interrupt",
|
SystemItem::Interrupt { .. } => "interrupt",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -165,6 +256,53 @@ mod tests {
|
||||||
assert_eq!(item.history_text(), "[File: src/main.rs]\nfn main() {}");
|
assert_eq!(item.history_text(), "[File: src/main.rs]\nfn main() {}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn system_reminder_renders_body_once() {
|
||||||
|
let reminder = SystemReminder::task_inactivity("remember tasks");
|
||||||
|
assert_eq!(
|
||||||
|
reminder.rendered_body(),
|
||||||
|
"<system-reminder>\nremember tasks\n</system-reminder>"
|
||||||
|
);
|
||||||
|
|
||||||
|
let already_wrapped = SystemReminder::task_inactivity(
|
||||||
|
"<system-reminder>\nremember tasks\n</system-reminder>",
|
||||||
|
);
|
||||||
|
assert_eq!(already_wrapped.body(), "remember tasks");
|
||||||
|
assert_eq!(
|
||||||
|
already_wrapped.rendered_body(),
|
||||||
|
"<system-reminder>\nremember tasks\n</system-reminder>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn system_reminder_source_is_retained_in_system_item() {
|
||||||
|
let item = SystemReminder::task_inactivity("remember tasks").into_system_item();
|
||||||
|
match item {
|
||||||
|
SystemItem::TaskReminder { source, body } => {
|
||||||
|
assert_eq!(source, SystemReminderSource::TaskInactivity);
|
||||||
|
assert_eq!(
|
||||||
|
body,
|
||||||
|
"<system-reminder>\nremember tasks\n</system-reminder>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn task_reminder_deserialization_defaults_legacy_source() {
|
||||||
|
let parsed: SystemItem = serde_json::from_str(
|
||||||
|
r#"{"kind":"task_reminder","body":"<system-reminder>\nbody\n</system-reminder>"}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
match parsed {
|
||||||
|
SystemItem::TaskReminder { source, .. } => {
|
||||||
|
assert_eq!(source, SystemReminderSource::TaskInactivity);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn round_trip_via_json() {
|
fn round_trip_via_json() {
|
||||||
let item = SystemItem::FileAttachment {
|
let item = SystemItem::FileAttachment {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::time::Instant;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use protocol::{
|
use protocol::{
|
||||||
AlertLevel, AlertSource, CompletionEntry, CompletionKind, Event, Method, PodStatus,
|
AlertLevel, AlertSource, CompletionEntry, CompletionKind, Event, Method, PodStatus,
|
||||||
|
|
@ -107,6 +107,33 @@ impl QueuedInput {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub enum ActionbarNoticeLevel {
|
||||||
|
Info,
|
||||||
|
Warn,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ActionbarNoticeSource {
|
||||||
|
Tui,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ActionbarNotice {
|
||||||
|
pub text: String,
|
||||||
|
pub level: ActionbarNoticeLevel,
|
||||||
|
pub source: ActionbarNoticeSource,
|
||||||
|
pub expires_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionbarNotice {
|
||||||
|
pub fn is_expired(&self, now: Instant) -> bool {
|
||||||
|
now >= self.expires_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub pod_name: String,
|
pub pod_name: String,
|
||||||
pub connected: bool,
|
pub connected: bool,
|
||||||
|
|
@ -134,6 +161,9 @@ pub struct App {
|
||||||
pub latest_llm_wait_event: Option<String>,
|
pub latest_llm_wait_event: Option<String>,
|
||||||
/// Latest memory extract/consolidation lifecycle event for actionbar observability.
|
/// Latest memory extract/consolidation lifecycle event for actionbar observability.
|
||||||
pub latest_memory_worker_event: Option<String>,
|
pub latest_memory_worker_event: Option<String>,
|
||||||
|
/// Current transient actionbar notice. Notices are local UI state only:
|
||||||
|
/// they are never appended to transcript/session history or LLM context.
|
||||||
|
actionbar_notice: Option<ActionbarNotice>,
|
||||||
/// Normal composer input that is submitted as `Method::Run`.
|
/// Normal composer input that is submitted as `Method::Run`.
|
||||||
pub input: InputBuffer,
|
pub input: InputBuffer,
|
||||||
/// Separate command-line input. It is never submitted as a user message.
|
/// Separate command-line input. It is never submitted as a user message.
|
||||||
|
|
@ -200,6 +230,7 @@ impl App {
|
||||||
current_tool: None,
|
current_tool: None,
|
||||||
latest_llm_wait_event: None,
|
latest_llm_wait_event: None,
|
||||||
latest_memory_worker_event: None,
|
latest_memory_worker_event: None,
|
||||||
|
actionbar_notice: None,
|
||||||
input: InputBuffer::new(),
|
input: InputBuffer::new(),
|
||||||
command_input: InputBuffer::new(),
|
command_input: InputBuffer::new(),
|
||||||
input_mode: CommandInputMode::Composer,
|
input_mode: CommandInputMode::Composer,
|
||||||
|
|
@ -472,6 +503,48 @@ impl App {
|
||||||
self.queued_inputs.len()
|
self.queued_inputs.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn flash_actionbar_notice(
|
||||||
|
&mut self,
|
||||||
|
text: impl Into<String>,
|
||||||
|
level: ActionbarNoticeLevel,
|
||||||
|
source: ActionbarNoticeSource,
|
||||||
|
duration: Duration,
|
||||||
|
) {
|
||||||
|
self.flash_actionbar_notice_at(text, level, source, Instant::now(), duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn flash_actionbar_notice_at(
|
||||||
|
&mut self,
|
||||||
|
text: impl Into<String>,
|
||||||
|
level: ActionbarNoticeLevel,
|
||||||
|
source: ActionbarNoticeSource,
|
||||||
|
now: Instant,
|
||||||
|
duration: Duration,
|
||||||
|
) {
|
||||||
|
self.actionbar_notice = Some(ActionbarNotice {
|
||||||
|
text: text.into(),
|
||||||
|
level,
|
||||||
|
source,
|
||||||
|
expires_at: now + duration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_actionbar_notice(&self, now: Instant) -> Option<&ActionbarNotice> {
|
||||||
|
self.actionbar_notice
|
||||||
|
.as_ref()
|
||||||
|
.filter(|notice| !notice.is_expired(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_expired_actionbar_notice(&mut self, now: Instant) {
|
||||||
|
if self
|
||||||
|
.actionbar_notice
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|notice| notice.is_expired(now))
|
||||||
|
{
|
||||||
|
self.actionbar_notice = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn next_queued_input_preview(&self) -> Option<&str> {
|
pub fn next_queued_input_preview(&self) -> Option<&str> {
|
||||||
self.queued_inputs.front().map(QueuedInput::preview)
|
self.queued_inputs.front().map(QueuedInput::preview)
|
||||||
}
|
}
|
||||||
|
|
@ -1754,6 +1827,40 @@ mod llm_wait_event_tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod actionbar_notice_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn actionbar_notice_expires_from_injected_time_source() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
let now = Instant::now();
|
||||||
|
let duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
app.flash_actionbar_notice_at(
|
||||||
|
"Pod keeps running",
|
||||||
|
ActionbarNoticeLevel::Warn,
|
||||||
|
ActionbarNoticeSource::Tui,
|
||||||
|
now,
|
||||||
|
duration,
|
||||||
|
);
|
||||||
|
|
||||||
|
let notice = app.current_actionbar_notice(now).expect("notice is active");
|
||||||
|
assert_eq!(notice.text, "Pod keeps running");
|
||||||
|
assert_eq!(notice.level, ActionbarNoticeLevel::Warn);
|
||||||
|
assert_eq!(notice.source, ActionbarNoticeSource::Tui);
|
||||||
|
assert_eq!(notice.expires_at, now + duration);
|
||||||
|
assert!(
|
||||||
|
app.current_actionbar_notice(now + duration - Duration::from_millis(1))
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
assert!(app.current_actionbar_notice(now + duration).is_none());
|
||||||
|
|
||||||
|
app.clear_expired_actionbar_notice(now + duration);
|
||||||
|
assert!(app.current_actionbar_notice(now).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod completion_flow_tests {
|
mod completion_flow_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ use tokio::sync::mpsc;
|
||||||
|
|
||||||
use client::PodClient;
|
use client::PodClient;
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
|
||||||
use crate::picker::PickerOutcome;
|
use crate::picker::PickerOutcome;
|
||||||
use crate::spawn::{SpawnOutcome, SpawnReady};
|
use crate::spawn::{SpawnOutcome, SpawnReady};
|
||||||
|
|
||||||
|
|
@ -1094,7 +1094,12 @@ fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
app.quit_confirm = Some(std::time::Instant::now());
|
app.quit_confirm = Some(std::time::Instant::now());
|
||||||
app.push_error("Press Ctrl-C again within 3 s to exit the TUI (the Pod keeps running).");
|
app.flash_actionbar_notice(
|
||||||
|
"Press Ctrl-C again within 3 s to exit the TUI (the Pod keeps running).",
|
||||||
|
ActionbarNoticeLevel::Warn,
|
||||||
|
ActionbarNoticeSource::Tui,
|
||||||
|
CONFIRM_TIMEOUT,
|
||||||
|
);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1565,6 +1570,34 @@ mod tests {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_c_quit_guard_uses_actionbar_notice_without_transcript_alert() {
|
||||||
|
let mut app = App::new("agent".to_string());
|
||||||
|
app.set_pod_status(PodStatus::Idle);
|
||||||
|
|
||||||
|
let method = handle_key(
|
||||||
|
&mut app,
|
||||||
|
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(method.is_none());
|
||||||
|
assert!(!app.quit);
|
||||||
|
let notice = app
|
||||||
|
.current_actionbar_notice(std::time::Instant::now())
|
||||||
|
.expect("quit guard notice is active");
|
||||||
|
assert!(notice.text.contains("Pod keeps running"));
|
||||||
|
assert_eq!(notice.level, ActionbarNoticeLevel::Warn);
|
||||||
|
assert_eq!(notice.source, ActionbarNoticeSource::Tui);
|
||||||
|
assert!(!has_alert(&app, "Pod keeps running"));
|
||||||
|
|
||||||
|
let method = handle_key(
|
||||||
|
&mut app,
|
||||||
|
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||||
|
);
|
||||||
|
assert!(method.is_none());
|
||||||
|
assert!(app.quit);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ctrl_r_requests_rewind_picker_when_idle_or_paused() {
|
fn ctrl_r_requests_rewind_picker_when_idle_or_paused() {
|
||||||
let mut app = App::new("agent".to_string());
|
let mut app = App::new("agent".to_string());
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@
|
||||||
//! lines, and render the tail that fits the history area. No
|
//! lines, and render the tail that fits the history area. No
|
||||||
//! `insert_before` use — the terminal scrollback stays untouched.
|
//! `insert_before` use — the terminal scrollback stays untouched.
|
||||||
|
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
use ratatui::layout::{Constraint, Layout, Position, Rect};
|
use ratatui::layout::{Constraint, Layout, Position, Rect};
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
|
@ -25,7 +27,7 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||||
|
|
||||||
use protocol::{AlertLevel, CompletionEntry, Greeting, PodEvent, Segment};
|
use protocol::{AlertLevel, CompletionEntry, Greeting, PodEvent, Segment};
|
||||||
|
|
||||||
use crate::app::{App, CompletionState, alert_source_label, fmt_tokens};
|
use crate::app::{ActionbarNoticeLevel, App, CompletionState, alert_source_label, fmt_tokens};
|
||||||
use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState};
|
use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState};
|
||||||
use crate::command::CommandCandidate;
|
use crate::command::CommandCandidate;
|
||||||
use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore};
|
use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore};
|
||||||
|
|
@ -1334,31 +1336,61 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
frame.render_widget(Paragraph::new(right_line), area);
|
frame.render_widget(Paragraph::new(right_line), area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_actionbar(frame: &mut Frame, app: &App, area: Rect) {
|
fn actionbar_left_item(app: &App, now: Instant) -> Option<(String, Style)> {
|
||||||
let mut left: Vec<Span<'static>> = Vec::new();
|
// Priority is deliberately actionable UI state first, then transient notices,
|
||||||
|
// then lower-priority lifecycle status. Right-side scroll/view labels are
|
||||||
|
// rendered independently below.
|
||||||
if app.is_command_mode() {
|
if app.is_command_mode() {
|
||||||
left.push(Span::styled(
|
return Some((
|
||||||
"COMMAND",
|
"COMMAND".to_string(),
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::Yellow)
|
.fg(Color::Yellow)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
));
|
));
|
||||||
} else if app.queued_input_count() > 0 {
|
}
|
||||||
left.push(Span::styled(
|
if app.queued_input_count() > 0 {
|
||||||
"Alt-q edit queued Alt-c clear queued",
|
return Some((
|
||||||
|
"Alt-q edit queued Alt-c clear queued".to_string(),
|
||||||
Style::default().fg(Color::DarkGray),
|
Style::default().fg(Color::DarkGray),
|
||||||
));
|
));
|
||||||
} else if let Some(llm_event) = app.latest_llm_wait_event.as_deref() {
|
}
|
||||||
left.push(Span::styled(
|
if let Some(notice) = app.current_actionbar_notice(now) {
|
||||||
|
return Some((
|
||||||
|
truncate_with_ellipsis(¬ice.text, 96),
|
||||||
|
actionbar_notice_style(notice.level),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(llm_event) = app.latest_llm_wait_event.as_deref() {
|
||||||
|
return Some((
|
||||||
truncate_with_ellipsis(llm_event, 96),
|
truncate_with_ellipsis(llm_event, 96),
|
||||||
Style::default().fg(Color::Yellow),
|
Style::default().fg(Color::Yellow),
|
||||||
));
|
));
|
||||||
} else if let Some(memory_event) = app.latest_memory_worker_event.as_deref() {
|
}
|
||||||
left.push(Span::styled(
|
if let Some(memory_event) = app.latest_memory_worker_event.as_deref() {
|
||||||
|
return Some((
|
||||||
truncate_with_ellipsis(memory_event, 72),
|
truncate_with_ellipsis(memory_event, 72),
|
||||||
Style::default().fg(Color::Blue),
|
Style::default().fg(Color::Blue),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn actionbar_notice_style(level: ActionbarNoticeLevel) -> Style {
|
||||||
|
match level {
|
||||||
|
ActionbarNoticeLevel::Info => Style::default().fg(Color::Cyan),
|
||||||
|
ActionbarNoticeLevel::Warn => Style::default().fg(Color::Yellow),
|
||||||
|
ActionbarNoticeLevel::Error => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_actionbar(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||||
|
let now = Instant::now();
|
||||||
|
app.clear_expired_actionbar_notice(now);
|
||||||
|
|
||||||
|
let mut left: Vec<Span<'static>> = Vec::new();
|
||||||
|
if let Some((text, style)) = actionbar_left_item(app, now) {
|
||||||
|
left.push(Span::styled(text, style));
|
||||||
|
}
|
||||||
|
|
||||||
let mut right: Vec<Span<'static>> = Vec::new();
|
let mut right: Vec<Span<'static>> = Vec::new();
|
||||||
if !app.scroll.follow_tail {
|
if !app.scroll.follow_tail {
|
||||||
|
|
@ -1569,8 +1601,9 @@ fn format_pod_event(event: &PodEvent) -> String {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::app::App;
|
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
|
||||||
use protocol::PodStatus;
|
use protocol::PodStatus;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn queue_status_text_includes_count_and_preview() {
|
fn queue_status_text_includes_count_and_preview() {
|
||||||
|
|
@ -1593,4 +1626,59 @@ mod tests {
|
||||||
|
|
||||||
assert_eq!(queue_status_text(&app), None);
|
assert_eq!(queue_status_text(&app), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn actionbar_notice_priority_sits_below_actionable_hints_and_above_lifecycle_status() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
let now = Instant::now();
|
||||||
|
app.latest_llm_wait_event = Some("retrying LLM request".into());
|
||||||
|
app.latest_memory_worker_event = Some("memory extract running".into());
|
||||||
|
app.flash_actionbar_notice_at(
|
||||||
|
"Pod keeps running. Press Ctrl-C again to exit TUI.",
|
||||||
|
ActionbarNoticeLevel::Warn,
|
||||||
|
ActionbarNoticeSource::Tui,
|
||||||
|
now,
|
||||||
|
Duration::from_secs(3),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
actionbar_left_item(&app, now).map(|(text, _)| text),
|
||||||
|
Some("Pod keeps running. Press Ctrl-C again to exit TUI.".into())
|
||||||
|
);
|
||||||
|
|
||||||
|
app.set_pod_status(PodStatus::Running);
|
||||||
|
for c in "queued turn".chars() {
|
||||||
|
app.insert_char(c);
|
||||||
|
}
|
||||||
|
assert!(app.submit_input().is_none());
|
||||||
|
assert_eq!(
|
||||||
|
actionbar_left_item(&app, now).map(|(text, _)| text),
|
||||||
|
Some("Alt-q edit queued Alt-c clear queued".into())
|
||||||
|
);
|
||||||
|
|
||||||
|
app.enter_command_mode();
|
||||||
|
assert_eq!(
|
||||||
|
actionbar_left_item(&app, now).map(|(text, _)| text),
|
||||||
|
Some("COMMAND".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expired_actionbar_notice_is_skipped_for_lifecycle_status() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
let now = Instant::now();
|
||||||
|
app.latest_llm_wait_event = Some("retrying LLM request".into());
|
||||||
|
app.flash_actionbar_notice_at(
|
||||||
|
"expired",
|
||||||
|
ActionbarNoticeLevel::Info,
|
||||||
|
ActionbarNoticeSource::Tui,
|
||||||
|
now,
|
||||||
|
Duration::from_secs(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
actionbar_left_item(&app, now + Duration::from_secs(1)).map(|(text, _)| text),
|
||||||
|
Some("retrying LLM request".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
id: 20260527-000011-session-todo-reminder
|
id: 20260527-000011-session-todo-reminder
|
||||||
slug: session-todo-reminder
|
slug: session-todo-reminder
|
||||||
title: セッション内 Task ツールの注意機構
|
title: セッション内 Task ツールの注意機構
|
||||||
status: open
|
status: closed
|
||||||
kind: task
|
kind: task
|
||||||
priority: P2
|
priority: P2
|
||||||
labels: [migrated]
|
labels: [migrated]
|
||||||
created_at: 2026-05-27T00:00:11Z
|
created_at: 2026-05-27T00:00:11Z
|
||||||
updated_at: 2026-05-27T00:00:11Z
|
updated_at: 2026-05-29T04:31:10Z
|
||||||
assignee: null
|
assignee: null
|
||||||
legacy_ticket: tickets/session-todo-reminder.md
|
legacy_ticket: tickets/session-todo-reminder.md
|
||||||
---
|
---
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
---
|
||||||
|
id: 20260527-000011-session-todo-reminder
|
||||||
|
slug: session-todo-reminder
|
||||||
|
title: セッション内 Task ツールの注意機構
|
||||||
|
status: closed
|
||||||
|
kind: task
|
||||||
|
priority: P2
|
||||||
|
labels: [migrated]
|
||||||
|
created_at: 2026-05-27T00:00:11Z
|
||||||
|
updated_at: 2026-05-29T04:31:10Z
|
||||||
|
assignee: null
|
||||||
|
legacy_ticket: tickets/session-todo-reminder.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration reference
|
||||||
|
|
||||||
|
- legacy_ticket: tickets/session-todo-reminder.md
|
||||||
|
- migrated_from: TODO.md / tickets directory migration on 2026-05-27
|
||||||
|
|
||||||
|
# セッション内 Task ツールの注意機構
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
`tickets/session-todo.md` で導入した Task ツール群があっても、LLM はそれを使わずに作業を続け得る。ツールを呼ばないまま会話が長引くと、
|
||||||
|
|
||||||
|
- 開始した作業の `inprogress` がずっと放置されたままになる
|
||||||
|
- 「やったつもり」になって `completed` への更新を忘れる
|
||||||
|
- そもそも TaskStore の存在を忘れて、構造化を諦めて自由記述に回帰する
|
||||||
|
|
||||||
|
OpenCode の todo は専用の注意機構を持たない(汎用 reminder 経由)。一方、一部の既存エージェント実装では todo reminder を「N リクエスト無アクティビティで初めて発火するナッジ型」として扱い、毎リクエスト押し戻しはしない。
|
||||||
|
|
||||||
|
|
||||||
|
Insomnia でも同方針を採り、active Task が残っているのに `TaskCreate` / `TaskUpdate` が一定リクエスト呼ばれていない場合に限り、`<system-reminder>` Item を 1件 history に append する。「やったつもり」抑止と、トークン浪費・LLM の自律性侵害のバランスを取るため、毎リクエスト押し戻しはしない。
|
||||||
|
|
||||||
|
## 前提
|
||||||
|
|
||||||
|
- `tickets/session-todo.md` の TaskStore と `TaskCreate` / `TaskUpdate` / `TaskList` / `TaskGet` ツールが利用可能
|
||||||
|
- `Interceptor::pending_history_appends` レーンが利用可能(`tickets/notify-history-persist.md` で導入済み)
|
||||||
|
|
||||||
|
## 方針
|
||||||
|
|
||||||
|
- **`pending_history_appends` で実装**。発火時に `<system-reminder>` ブロックを含む新規 system message Item を返し、Worker が `worker.history` に append する。Notify / PodEvent と同じレーンで永続化・resume・compaction が自動で揃う
|
||||||
|
- **揮発的注入は採らない**(`AGENTS.md` 「LLM コンテキストの加工原則」で禁止。history に commit せずに context を変えると、resume 時に LLM の発言の根拠が再現できなくなる)
|
||||||
|
- **system-reminder 注入機構の汎用化はやらない**。利用者が Task 1機構しかない段階で抽象を立てない(`AGENTS.md`「概念の追加は不在が問題になってから」)。タグ形式 `<system-reminder>...</system-reminder>` の規約は本実装で踏襲する
|
||||||
|
- **発火はナッジ型**。N リクエスト無アクティビティで初めて発火し、cooldown も持つ
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
### Interceptor
|
||||||
|
|
||||||
|
- `pending_history_appends` で以下を **AND** で満たす場合のみ発動し、`<system-reminder>` ブロックを含む `Item::system_message` を 1件返す。条件外なら空 `Vec<Item>` を返す
|
||||||
|
- active Task(`pending` または `inprogress`)が 1件以上存在する
|
||||||
|
- 直近 N リクエスト(暫定 N=8)`TaskCreate` / `TaskUpdate` のいずれも呼ばれていない
|
||||||
|
- 前回 reminder Item の append から M リクエスト(暫定 M=8)以上経過
|
||||||
|
- ここで言う「リクエスト」は LLM への 1回の推論呼び出し(assistant 応答 1回)の単位。1ユーザー発火内で tool ループが回れば、`tool_result` を受けて発火する次のリクエストもそれぞれ 1としてカウントする
|
||||||
|
- カウンタは Pod 側の session-lifetime 状態として保持する(`requests_since_last_task_management` / `requests_since_last_reminder`)。resume 時は worker.history の逆走査で再計算するか 0 リセットで再開する。後者でも「初回ナッジが最大 N リクエスト遅れる」だけで挙動として致命ではない
|
||||||
|
- 返す Item の本文は `<system-reminder>` で囲み、現在の active Task を `taskid` / `status` / `subject` を含む簡潔な形式で列挙する。`description` は長大化を避けるため省略してよい
|
||||||
|
- active Task が空の場合は何も append しない(思い出させる対象が無いなら不要)
|
||||||
|
|
||||||
|
## 完了条件
|
||||||
|
|
||||||
|
- 直近 N リクエスト連続で `TaskCreate` / `TaskUpdate` が呼ばれず、かつ active Task が残っている場合に限り、`pending_history_appends` が `<system-reminder>` を含む `Item::system_message` を 1件返す
|
||||||
|
- 返された Item が `worker.history` に append され、その後のリクエスト・`history.json`・resume 後の `get_history` でも同じ Item が見える(揮発レーンは持たない)
|
||||||
|
- `TaskCreate` / `TaskUpdate` のいずれかが呼ばれるとカウンタがリセットされ、再び N リクエスト経過するまでは reminder が出ない
|
||||||
|
- reminder が一度出たあとは、cooldown M リクエストが経過するまで再注入されない
|
||||||
|
- active Task が 0件の場合は reminder が出ない
|
||||||
|
- 単体テストで Interceptor の発火条件(リクエスト回数閾値、active 0件、cooldown)がカバーされる
|
||||||
|
|
||||||
|
## 範囲外
|
||||||
|
|
||||||
|
- inprogress 滞留検出 / 多重 inprogress 検出など、状態異常ベースの追加トリガ(必要になれば別チケットで追加)
|
||||||
|
- system-reminder 注入機構の汎用化(`TODO.md` に立項済み、別途検討)
|
||||||
|
- `TaskCreate` / `TaskUpdate` の戻り値に active Task 全件を埋め込む強化(必要に応じて Tool ticket 側で対応)
|
||||||
|
- サブエージェント / sidechain での独自 reminder 発火(main Pod の interceptor から動く構造のため自然に対象外)
|
||||||
|
|
||||||
|
## 参照
|
||||||
|
|
||||||
|
- 設計指針: `AGENTS.md`(LLM コンテキストの加工原則。揮発的注入は禁止、history に append してから commit する)
|
||||||
|
- 前提: `tickets/session-todo.md`(Tool 群と TaskStore)、`tickets/notify-history-persist.md`(`pending_history_appends` レーン)
|
||||||
|
- 参考: 一部エージェント実装の todo reminder は、一定リクエスト無アクティビティ後に発火し、再通知にも cooldown を置くナッジ型として扱われている
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:11Z -->
|
||||||
|
|
||||||
|
## Migrated
|
||||||
|
|
||||||
|
Migrated from tickets/session-todo-reminder.md. No legacy review file was present at migration time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-05-29T04:31:10Z status: closed -->
|
||||||
|
|
||||||
|
## Closed
|
||||||
|
|
||||||
|
---
|
||||||
|
id: 20260527-000011-session-todo-reminder
|
||||||
|
slug: session-todo-reminder
|
||||||
|
title: セッション内 Task ツールの注意機構
|
||||||
|
status: closed
|
||||||
|
kind: task
|
||||||
|
priority: P2
|
||||||
|
labels: [migrated]
|
||||||
|
created_at: 2026-05-27T00:00:11Z
|
||||||
|
updated_at: 2026-05-29T04:31:10Z
|
||||||
|
assignee: null
|
||||||
|
legacy_ticket: tickets/session-todo-reminder.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration reference
|
||||||
|
|
||||||
|
- legacy_ticket: tickets/session-todo-reminder.md
|
||||||
|
- migrated_from: TODO.md / tickets directory migration on 2026-05-27
|
||||||
|
|
||||||
|
# セッション内 Task ツールの注意機構
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
`tickets/session-todo.md` で導入した Task ツール群があっても、LLM はそれを使わずに作業を続け得る。ツールを呼ばないまま会話が長引くと、
|
||||||
|
|
||||||
|
- 開始した作業の `inprogress` がずっと放置されたままになる
|
||||||
|
- 「やったつもり」になって `completed` への更新を忘れる
|
||||||
|
- そもそも TaskStore の存在を忘れて、構造化を諦めて自由記述に回帰する
|
||||||
|
|
||||||
|
OpenCode の todo は専用の注意機構を持たない(汎用 reminder 経由)。一方、一部の既存エージェント実装では todo reminder を「N リクエスト無アクティビティで初めて発火するナッジ型」として扱い、毎リクエスト押し戻しはしない。
|
||||||
|
|
||||||
|
|
||||||
|
Insomnia でも同方針を採り、active Task が残っているのに `TaskCreate` / `TaskUpdate` が一定リクエスト呼ばれていない場合に限り、`<system-reminder>` Item を 1件 history に append する。「やったつもり」抑止と、トークン浪費・LLM の自律性侵害のバランスを取るため、毎リクエスト押し戻しはしない。
|
||||||
|
|
||||||
|
## 前提
|
||||||
|
|
||||||
|
- `tickets/session-todo.md` の TaskStore と `TaskCreate` / `TaskUpdate` / `TaskList` / `TaskGet` ツールが利用可能
|
||||||
|
- `Interceptor::pending_history_appends` レーンが利用可能(`tickets/notify-history-persist.md` で導入済み)
|
||||||
|
|
||||||
|
## 方針
|
||||||
|
|
||||||
|
- **`pending_history_appends` で実装**。発火時に `<system-reminder>` ブロックを含む新規 system message Item を返し、Worker が `worker.history` に append する。Notify / PodEvent と同じレーンで永続化・resume・compaction が自動で揃う
|
||||||
|
- **揮発的注入は採らない**(`AGENTS.md` 「LLM コンテキストの加工原則」で禁止。history に commit せずに context を変えると、resume 時に LLM の発言の根拠が再現できなくなる)
|
||||||
|
- **system-reminder 注入機構の汎用化はやらない**。利用者が Task 1機構しかない段階で抽象を立てない(`AGENTS.md`「概念の追加は不在が問題になってから」)。タグ形式 `<system-reminder>...</system-reminder>` の規約は本実装で踏襲する
|
||||||
|
- **発火はナッジ型**。N リクエスト無アクティビティで初めて発火し、cooldown も持つ
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
### Interceptor
|
||||||
|
|
||||||
|
- `pending_history_appends` で以下を **AND** で満たす場合のみ発動し、`<system-reminder>` ブロックを含む `Item::system_message` を 1件返す。条件外なら空 `Vec<Item>` を返す
|
||||||
|
- active Task(`pending` または `inprogress`)が 1件以上存在する
|
||||||
|
- 直近 N リクエスト(暫定 N=8)`TaskCreate` / `TaskUpdate` のいずれも呼ばれていない
|
||||||
|
- 前回 reminder Item の append から M リクエスト(暫定 M=8)以上経過
|
||||||
|
- ここで言う「リクエスト」は LLM への 1回の推論呼び出し(assistant 応答 1回)の単位。1ユーザー発火内で tool ループが回れば、`tool_result` を受けて発火する次のリクエストもそれぞれ 1としてカウントする
|
||||||
|
- カウンタは Pod 側の session-lifetime 状態として保持する(`requests_since_last_task_management` / `requests_since_last_reminder`)。resume 時は worker.history の逆走査で再計算するか 0 リセットで再開する。後者でも「初回ナッジが最大 N リクエスト遅れる」だけで挙動として致命ではない
|
||||||
|
- 返す Item の本文は `<system-reminder>` で囲み、現在の active Task を `taskid` / `status` / `subject` を含む簡潔な形式で列挙する。`description` は長大化を避けるため省略してよい
|
||||||
|
- active Task が空の場合は何も append しない(思い出させる対象が無いなら不要)
|
||||||
|
|
||||||
|
## 完了条件
|
||||||
|
|
||||||
|
- 直近 N リクエスト連続で `TaskCreate` / `TaskUpdate` が呼ばれず、かつ active Task が残っている場合に限り、`pending_history_appends` が `<system-reminder>` を含む `Item::system_message` を 1件返す
|
||||||
|
- 返された Item が `worker.history` に append され、その後のリクエスト・`history.json`・resume 後の `get_history` でも同じ Item が見える(揮発レーンは持たない)
|
||||||
|
- `TaskCreate` / `TaskUpdate` のいずれかが呼ばれるとカウンタがリセットされ、再び N リクエスト経過するまでは reminder が出ない
|
||||||
|
- reminder が一度出たあとは、cooldown M リクエストが経過するまで再注入されない
|
||||||
|
- active Task が 0件の場合は reminder が出ない
|
||||||
|
- 単体テストで Interceptor の発火条件(リクエスト回数閾値、active 0件、cooldown)がカバーされる
|
||||||
|
|
||||||
|
## 範囲外
|
||||||
|
|
||||||
|
- inprogress 滞留検出 / 多重 inprogress 検出など、状態異常ベースの追加トリガ(必要になれば別チケットで追加)
|
||||||
|
- system-reminder 注入機構の汎用化(`TODO.md` に立項済み、別途検討)
|
||||||
|
- `TaskCreate` / `TaskUpdate` の戻り値に active Task 全件を埋め込む強化(必要に応じて Tool ticket 側で対応)
|
||||||
|
- サブエージェント / sidechain での独自 reminder 発火(main Pod の interceptor から動く構造のため自然に対象外)
|
||||||
|
|
||||||
|
## 参照
|
||||||
|
|
||||||
|
- 設計指針: `AGENTS.md`(LLM コンテキストの加工原則。揮発的注入は禁止、history に append してから commit する)
|
||||||
|
- 前提: `tickets/session-todo.md`(Tool 群と TaskStore)、`tickets/notify-history-persist.md`(`pending_history_appends` レーン)
|
||||||
|
- 参考: 一部エージェント実装の todo reminder は、一定リクエスト無アクティビティ後に発火し、再通知にも cooldown を置くナッジ型として扱われている
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
id: 20260527-000014-tui-actionbar-transient-notice-api
|
id: 20260527-000014-tui-actionbar-transient-notice-api
|
||||||
slug: tui-actionbar-transient-notice-api
|
slug: tui-actionbar-transient-notice-api
|
||||||
title: TUI: actionbar transient notice API
|
title: TUI: actionbar transient notice API
|
||||||
status: open
|
status: closed
|
||||||
kind: task
|
kind: task
|
||||||
priority: P2
|
priority: P2
|
||||||
labels: [migrated]
|
labels: [migrated]
|
||||||
created_at: 2026-05-27T00:00:14Z
|
created_at: 2026-05-27T00:00:14Z
|
||||||
updated_at: 2026-05-27T00:00:14Z
|
updated_at: 2026-05-29T03:57:35Z
|
||||||
assignee: null
|
assignee: null
|
||||||
legacy_ticket: tickets/tui-actionbar-transient-notice-api.md
|
legacy_ticket: tickets/tui-actionbar-transient-notice-api.md
|
||||||
---
|
---
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
---
|
||||||
|
id: 20260527-000014-tui-actionbar-transient-notice-api
|
||||||
|
slug: tui-actionbar-transient-notice-api
|
||||||
|
title: TUI: actionbar transient notice API
|
||||||
|
status: closed
|
||||||
|
kind: task
|
||||||
|
priority: P2
|
||||||
|
labels: [migrated]
|
||||||
|
created_at: 2026-05-27T00:00:14Z
|
||||||
|
updated_at: 2026-05-29T03:57:34Z
|
||||||
|
assignee: null
|
||||||
|
legacy_ticket: tickets/tui-actionbar-transient-notice-api.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration reference
|
||||||
|
|
||||||
|
- legacy_ticket: tickets/tui-actionbar-transient-notice-api.md
|
||||||
|
- migrated_from: TODO.md / tickets directory migration on 2026-05-27
|
||||||
|
|
||||||
|
# TUI: actionbar transient notice API
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
TUI の actionbar は最下部の補助表示行として、現在の mode や一時的な操作フィードバックを出す場所になりつつある。
|
||||||
|
|
||||||
|
一方で、現在は `Ctrl-C` の二段階終了 guard のような一時通知も `app.push_error(...)` 等で view 上に残る message として扱われている。これは後から見返すログではなく、数秒だけ見えれば十分な操作フィードバックである。
|
||||||
|
|
||||||
|
また、memory audit log 実装では extract / consolidation worker の直近 event を actionbar に表示する予定であり、個別機能ごとに ad hoc な actionbar 表示を増やすと優先順位・寿命・表示競合の扱いが散らばる。
|
||||||
|
|
||||||
|
## 方針
|
||||||
|
|
||||||
|
Actionbar を「history / transcript に残さない transient UI state」の共通表示面として扱う API を App 側に用意する。
|
||||||
|
|
||||||
|
永続的に残すべき Pod event / model output / tool result / user-visible error と、一時的な操作フィードバックを分離する。actionbar notice は UI の補助表示であり、LLM context や session history へ暗黙注入しない。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- App に actionbar transient notice を設定・期限切れ・取得するための API を追加する。
|
||||||
|
- 例: `flash_actionbar_notice(text, duration)` または `set_actionbar_notice(...)`
|
||||||
|
- notice には最低限 `text`, `level`, `source`, `expires_at` 相当を持たせる。
|
||||||
|
- time source はテストしやすい形にする。
|
||||||
|
- actionbar rendering は transient notice を優先表示できる。
|
||||||
|
- 既存の command mode marker、queued input hint、scroll indicator、view mode label と競合しない優先順位を定義する。
|
||||||
|
- notice が期限切れなら表示しない。
|
||||||
|
- `Ctrl-C` の二段階終了 guard の表示を view log から actionbar notice に移す。
|
||||||
|
- `Pod keeps running` などの一時説明は transcript/view 上に残さない。
|
||||||
|
- 二度押しの挙動自体は変えない。
|
||||||
|
- memory worker の actionbar 表示が既に実装済みの場合、可能な範囲でこの API に寄せる。
|
||||||
|
- 未実装・別 branch 上の場合は、この ticket の範囲では API 設計が衝突しないようにする。
|
||||||
|
- actionbar notice は通常の LLM context に暗黙注入しない。
|
||||||
|
- 必要な正本ログは各機能の audit/session log に残す。
|
||||||
|
|
||||||
|
## 完了条件
|
||||||
|
|
||||||
|
- actionbar transient notice 用 API が App/UI に追加されている。
|
||||||
|
- `Ctrl-C` 二段階終了 guard の一時メッセージが actionbar に表示され、view log には残らない。
|
||||||
|
- notice の期限切れと優先表示の挙動がテストされている。
|
||||||
|
- 既存の command mode / queued input / scroll / view mode actionbar 表示が破綻していない。
|
||||||
|
- `cargo fmt --check` と関連 TUI テストが通る。
|
||||||
|
|
||||||
|
## 範囲外
|
||||||
|
|
||||||
|
- actionbar の複数行化。
|
||||||
|
- 汎用 notification center / viewer UI。
|
||||||
|
- Pod / worker の正本ログ形式の変更。
|
||||||
|
- memory audit log 本体の実装。
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:14Z -->
|
||||||
|
|
||||||
|
## Migrated
|
||||||
|
|
||||||
|
Migrated from tickets/tui-actionbar-transient-notice-api.md. No legacy review file was present at migration time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-05-29T03:57:35Z status: closed -->
|
||||||
|
|
||||||
|
## Closed
|
||||||
|
|
||||||
|
---
|
||||||
|
id: 20260527-000014-tui-actionbar-transient-notice-api
|
||||||
|
slug: tui-actionbar-transient-notice-api
|
||||||
|
title: TUI: actionbar transient notice API
|
||||||
|
status: closed
|
||||||
|
kind: task
|
||||||
|
priority: P2
|
||||||
|
labels: [migrated]
|
||||||
|
created_at: 2026-05-27T00:00:14Z
|
||||||
|
updated_at: 2026-05-29T03:57:34Z
|
||||||
|
assignee: null
|
||||||
|
legacy_ticket: tickets/tui-actionbar-transient-notice-api.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration reference
|
||||||
|
|
||||||
|
- legacy_ticket: tickets/tui-actionbar-transient-notice-api.md
|
||||||
|
- migrated_from: TODO.md / tickets directory migration on 2026-05-27
|
||||||
|
|
||||||
|
# TUI: actionbar transient notice API
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
TUI の actionbar は最下部の補助表示行として、現在の mode や一時的な操作フィードバックを出す場所になりつつある。
|
||||||
|
|
||||||
|
一方で、現在は `Ctrl-C` の二段階終了 guard のような一時通知も `app.push_error(...)` 等で view 上に残る message として扱われている。これは後から見返すログではなく、数秒だけ見えれば十分な操作フィードバックである。
|
||||||
|
|
||||||
|
また、memory audit log 実装では extract / consolidation worker の直近 event を actionbar に表示する予定であり、個別機能ごとに ad hoc な actionbar 表示を増やすと優先順位・寿命・表示競合の扱いが散らばる。
|
||||||
|
|
||||||
|
## 方針
|
||||||
|
|
||||||
|
Actionbar を「history / transcript に残さない transient UI state」の共通表示面として扱う API を App 側に用意する。
|
||||||
|
|
||||||
|
永続的に残すべき Pod event / model output / tool result / user-visible error と、一時的な操作フィードバックを分離する。actionbar notice は UI の補助表示であり、LLM context や session history へ暗黙注入しない。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- App に actionbar transient notice を設定・期限切れ・取得するための API を追加する。
|
||||||
|
- 例: `flash_actionbar_notice(text, duration)` または `set_actionbar_notice(...)`
|
||||||
|
- notice には最低限 `text`, `level`, `source`, `expires_at` 相当を持たせる。
|
||||||
|
- time source はテストしやすい形にする。
|
||||||
|
- actionbar rendering は transient notice を優先表示できる。
|
||||||
|
- 既存の command mode marker、queued input hint、scroll indicator、view mode label と競合しない優先順位を定義する。
|
||||||
|
- notice が期限切れなら表示しない。
|
||||||
|
- `Ctrl-C` の二段階終了 guard の表示を view log から actionbar notice に移す。
|
||||||
|
- `Pod keeps running` などの一時説明は transcript/view 上に残さない。
|
||||||
|
- 二度押しの挙動自体は変えない。
|
||||||
|
- memory worker の actionbar 表示が既に実装済みの場合、可能な範囲でこの API に寄せる。
|
||||||
|
- 未実装・別 branch 上の場合は、この ticket の範囲では API 設計が衝突しないようにする。
|
||||||
|
- actionbar notice は通常の LLM context に暗黙注入しない。
|
||||||
|
- 必要な正本ログは各機能の audit/session log に残す。
|
||||||
|
|
||||||
|
## 完了条件
|
||||||
|
|
||||||
|
- actionbar transient notice 用 API が App/UI に追加されている。
|
||||||
|
- `Ctrl-C` 二段階終了 guard の一時メッセージが actionbar に表示され、view log には残らない。
|
||||||
|
- notice の期限切れと優先表示の挙動がテストされている。
|
||||||
|
- 既存の command mode / queued input / scroll / view mode actionbar 表示が破綻していない。
|
||||||
|
- `cargo fmt --check` と関連 TUI テストが通る。
|
||||||
|
|
||||||
|
## 範囲外
|
||||||
|
|
||||||
|
- actionbar の複数行化。
|
||||||
|
- 汎用 notification center / viewer UI。
|
||||||
|
- Pod / worker の正本ログ形式の変更。
|
||||||
|
- memory audit log 本体の実装。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:11Z -->
|
|
||||||
|
|
||||||
## Migrated
|
|
||||||
|
|
||||||
Migrated from tickets/session-todo-reminder.md. No legacy review file was present at migration time.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:14Z -->
|
|
||||||
|
|
||||||
## Migrated
|
|
||||||
|
|
||||||
Migrated from tickets/tui-actionbar-transient-notice-api.md. No legacy review file was present at migration time.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
@ -1,28 +1,69 @@
|
||||||
---
|
---
|
||||||
id: 20260527-000020-system-reminder-injection-generalization
|
id: 20260527-000020-system-reminder-injection-generalization
|
||||||
slug: system-reminder-injection-generalization
|
slug: system-reminder-injection-generalization
|
||||||
title: system-reminder 注入機構の汎用化(2件目の利用者が出た時に検討。タグ形式 `<system-reminder>...</system-reminder>` の規約は session-todo-reminder で先行確立。注入された Item は worker.history に append する方針)
|
title: Generalize system-reminder history append lane
|
||||||
status: open
|
status: open
|
||||||
kind: task
|
kind: task
|
||||||
priority: P2
|
priority: P2
|
||||||
labels: [migrated]
|
labels: [pod, llm-worker, history, system-reminder]
|
||||||
created_at: 2026-05-27T00:00:20Z
|
created_at: 2026-05-27T00:00:20Z
|
||||||
updated_at: 2026-05-27T00:00:20Z
|
updated_at: 2026-05-29T04:46:00Z
|
||||||
assignee: null
|
assignee: null
|
||||||
legacy_ticket: null
|
legacy_ticket: null
|
||||||
---
|
---
|
||||||
|
|
||||||
## Migration reference
|
|
||||||
|
|
||||||
- legacy_ticket: null
|
|
||||||
- migrated_from: TODO.md / tickets directory migration on 2026-05-27
|
|
||||||
|
|
||||||
# system-reminder 注入機構の汎用化(2件目の利用者が出た時に検討。タグ形式 `<system-reminder>...</system-reminder>` の規約は session-todo-reminder で先行確立。注入された Item は worker.history に append する方針)
|
|
||||||
|
|
||||||
## Background
|
## Background
|
||||||
|
|
||||||
This work item was migrated from an unfinished TODO.md entry that did not have a dedicated legacy ticket file.
|
`session-todo-reminder` established the first concrete `<system-reminder>...</system-reminder>` user: Task inactivity reminders are appended through `pending_history_appends` so the reminder is persisted in `worker.history` before the next LLM request. This follows the context-processing rule that new non-volatile input must be appended to history rather than injected only into request context.
|
||||||
|
|
||||||
|
The current implementation should now be generalized so future reminder producers do not each hand-roll XML tags, `SystemItem` construction, source labeling, cooldown/priority plumbing, or history-append integration.
|
||||||
|
|
||||||
|
This ticket is about making the system-reminder append lane a small typed facility. It is not about adding new reminder policies beyond existing Task reminders.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Introduce a typed internal representation for pending system reminders.
|
||||||
|
- text/body
|
||||||
|
- source/kind, e.g. task inactivity
|
||||||
|
- optional priority/order key if needed
|
||||||
|
- helper that renders the body inside `<system-reminder>...</system-reminder>` exactly once
|
||||||
|
- Route reminders through the existing `Interceptor::pending_history_appends` lane.
|
||||||
|
- The final result must still be `Item::System(SystemItem { kind: InvokeKind::SystemReminder, ... })` or equivalent current protocol type.
|
||||||
|
- The reminder must be appended to `worker.history`; do not introduce hidden request-only context injection.
|
||||||
|
- Refactor `session-todo-reminder` to use this typed helper/facility.
|
||||||
|
- Task reminder behavior, thresholds, cooldown, and tests should remain unchanged.
|
||||||
|
- The helper should prevent double-wrapping if the body is already tagged, or the API should make double-wrapping impossible.
|
||||||
|
- Keep `Notify` / `PodEvent` behavior unchanged.
|
||||||
|
- Do not merge raw notify and system reminder semantics.
|
||||||
|
- If they share buffering mechanics, keep the public behavior and rendered tags distinct.
|
||||||
|
- Keep ordering deterministic.
|
||||||
|
- If multiple reminder producers are added later, ordering should be explicit or stable.
|
||||||
|
- For now, existing Task reminder order relative to Notify/PodEvent should be preserved unless there is a clear reason to change it.
|
||||||
|
- Add docs/comments near the facility explaining the rule:
|
||||||
|
- system reminders are durable input and must be appended through history.
|
||||||
|
- they are not transient UI notices.
|
||||||
|
- they are not prompt-cache/context-only injections.
|
||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- Define the concrete requirements before implementation.
|
- There is a typed system-reminder helper/facility rather than ad-hoc string construction in Task reminder code.
|
||||||
|
- Task inactivity reminders still appear as `<system-reminder>...</system-reminder>` in `pending_history_appends` output.
|
||||||
|
- The helper emits `InvokeKind::SystemReminder` / current system-reminder item kind.
|
||||||
|
- Existing Task reminder tests continue to pass.
|
||||||
|
- New focused tests cover:
|
||||||
|
- rendering wraps body once.
|
||||||
|
- source/kind is retained or observable where appropriate.
|
||||||
|
- Task reminder uses the helper and remains history-append based.
|
||||||
|
- no hidden context-only injection path is introduced.
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- `cargo check -p pod -p llm-worker -p session-store`
|
||||||
|
- Relevant focused tests, e.g. `cargo test -p pod reminder --no-default-features`.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Adding a second reminder policy.
|
||||||
|
- Changing Task reminder thresholds/cooldown.
|
||||||
|
- Changing Notify/PodEvent user-visible behavior.
|
||||||
|
- UI actionbar notices.
|
||||||
|
- Prompt text changes.
|
||||||
|
- Generic notification center or reminder scheduling service.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
---
|
||||||
|
id: 20260529-041911-llm-worker-standalone-publication-audit
|
||||||
|
slug: llm-worker-standalone-publication-audit
|
||||||
|
title: Prepare LLM-Worker for standalone publication
|
||||||
|
status: open
|
||||||
|
kind: audit
|
||||||
|
priority: P2
|
||||||
|
labels: [llm-worker, docs, api, release]
|
||||||
|
created_at: 2026-05-29T04:19:11Z
|
||||||
|
updated_at: 2026-05-29T04:19:11Z
|
||||||
|
assignee: null
|
||||||
|
legacy_ticket: null
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
`llm-worker` is currently developed as part of the Insomnia workspace, but it is intended to be useful as a standalone library for building autonomous LLM-powered systems.
|
||||||
|
|
||||||
|
Before publishing or presenting it independently, audit and polish the crate's public surface, documentation, examples, and wording so it can stand on its own without assuming Insomnia-specific context.
|
||||||
|
|
||||||
|
This is primarily an audit/preparation ticket. Implementation changes should be limited to documentation polish, small API text/name cleanups, metadata fixes, and clearly safe public-surface adjustments. If the audit finds larger API redesign needs, record them as follow-up tickets rather than mixing a broad refactor into this work.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Inspect and update, at minimum:
|
||||||
|
|
||||||
|
- `crates/llm-worker/Cargo.toml`
|
||||||
|
- description
|
||||||
|
- version readiness
|
||||||
|
- license/categories/keywords/repository/readme/include/exclude metadata where appropriate
|
||||||
|
- dependency choices and feature flags that matter for standalone consumers
|
||||||
|
- `crates/llm-worker/README.md`
|
||||||
|
- standalone crate overview
|
||||||
|
- core concepts
|
||||||
|
- minimal usage example
|
||||||
|
- provider/model configuration assumptions
|
||||||
|
- tool/interceptor concepts
|
||||||
|
- streaming/retry/continuation limitations
|
||||||
|
- `crates/llm-worker/docs/*`
|
||||||
|
- architecture and requirements accuracy
|
||||||
|
- wording that assumes the full Insomnia application
|
||||||
|
- public Rust API docs
|
||||||
|
- crate-level docs (`lib.rs`)
|
||||||
|
- public structs/enums/functions/traits likely to appear in rustdoc
|
||||||
|
- examples should compile or be marked clearly as illustrative
|
||||||
|
- examples/tests under `crates/llm-worker`
|
||||||
|
- ensure they are understandable to external users
|
||||||
|
- avoid leaking local project assumptions or private operational names
|
||||||
|
|
||||||
|
## Audit questions
|
||||||
|
|
||||||
|
- Can a reader understand what `llm-worker` does without knowing Insomnia Pod/TUI internals?
|
||||||
|
- Is the public API coherent as a standalone library boundary?
|
||||||
|
- Are names and docs provider-neutral where possible?
|
||||||
|
- Are Insomnia-specific terms either absent from public docs or explicitly framed as one possible host application?
|
||||||
|
- Are error types, interceptors, tool traits, retry/continuation behavior, and history handling documented enough for external use?
|
||||||
|
- Does crate metadata look publishable?
|
||||||
|
- Are there obvious public APIs that should be hidden, renamed, or documented before publication?
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Produce a written audit summary in the ticket artifacts directory.
|
||||||
|
- Apply small documentation/metadata/API wording fixes that are clearly safe.
|
||||||
|
- Do not perform broad API redesign in this ticket.
|
||||||
|
- Do not change runtime behavior unless a tiny fix is required to make docs/examples accurate.
|
||||||
|
- If API redesign is needed, propose follow-up tickets with concrete scope.
|
||||||
|
- Keep Insomnia workspace behavior unchanged.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- `crates/llm-worker` has standalone-oriented README / docs wording.
|
||||||
|
- Crate metadata is reviewed and updated where appropriate.
|
||||||
|
- Public rustdoc entry points are checked for missing or Insomnia-specific wording.
|
||||||
|
- An audit artifact records:
|
||||||
|
- reviewed files/commands
|
||||||
|
- public API concerns
|
||||||
|
- docs wording concerns
|
||||||
|
- metadata/readiness concerns
|
||||||
|
- recommended follow-up tickets
|
||||||
|
- Any small fixes are committed with the audit.
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- `cargo check -p llm-worker`
|
||||||
|
- Relevant `cargo test -p llm-worker` or focused tests/examples where practical.
|
||||||
|
- `cargo doc -p llm-worker --no-deps` succeeds or known warnings/issues are recorded.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Publishing to crates.io.
|
||||||
|
- Renaming the crate.
|
||||||
|
- Extracting the crate into a separate repository.
|
||||||
|
- Large public API redesign.
|
||||||
|
- Reworking provider implementations.
|
||||||
|
- Changing Insomnia Pod/TUI behavior.
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<!-- event: create author: tickets.sh at: 2026-05-29T04:19:11Z -->
|
||||||
|
|
||||||
|
## Created
|
||||||
|
|
||||||
|
Created by tickets.sh create.
|
||||||
|
|
||||||
|
---
|
||||||
Loading…
Reference in New Issue
Block a user