Compare commits
42 Commits
9753b34fb9
...
1bf212e225
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bf212e225 | |||
| 61e9a18942 | |||
| e25b31ecd8 | |||
| c2ed71a388 | |||
| bab30952b8 | |||
| 5469335de9 | |||
| 7c604f37dd | |||
| 5de4156147 | |||
| 6cc2e9ade7 | |||
| 9eceb189ba | |||
| 8f1cb1b8c2 | |||
| 960f2a305e | |||
| 9b07cd21c0 | |||
| c9cb2edc7e | |||
| d92a29d63c | |||
| 52fa401725 | |||
| 7d71507688 | |||
| 9eee15ac3a | |||
| 04a4a730fb | |||
| 3cc3134386 | |||
| d5b919e0d6 | |||
| 4b90fe91bf | |||
| 6908bb5947 | |||
| 2b36c261b8 | |||
| f394f15ba5 | |||
| 9721c81eb0 | |||
| 06c5cb7652 | |||
| d8ba6c39f7 | |||
| eea94ea4ba | |||
| e3cb29982f | |||
| c1c8a21f9a | |||
| 6fa08f8917 | |||
| 6ed8af8764 | |||
| 98bbd6f185 | |||
| 5934d043ac | |||
| b1ebd77f8a | |||
| 40701760eb | |||
| 72c68dad84 | |||
| a8ae6ca2f8 | |||
| cb642b0d37 | |||
| 0f2ec7c263 | |||
| d7e416982f |
|
|
@ -6,7 +6,7 @@ requires: []
|
|||
---
|
||||
# Auto Maintain Workflow (WIP)
|
||||
|
||||
insomnia を AI maintainer として運用するための半自動 loop。TODO / tickets から「今進められそうな作業」を選ぶだけでなく、課題の発見、設計判断の切り分け、次に人間へ戻すべき問いの整理までを扱う。
|
||||
yoi を AI maintainer として運用するための半自動 loop。TODO / tickets から「今進められそうな作業」を選ぶだけでなく、課題の発見、設計判断の切り分け、次に人間へ戻すべき問いの整理までを扱う。
|
||||
|
||||
これは unattended 自動開発ではない。実装の並列委譲は `multi-agent-workflow`、worktree の機械的作成は `worktree-workflow` に任せる。本 Workflow はその前段として、何を進めるべきか、何をまだ決めるべきか、下位 orchestrator にどの intent packet を渡すべきかを整理する。
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ requires: []
|
|||
---
|
||||
# Multi-agent Worktree Workflow
|
||||
|
||||
insomnia を insomnia で開発する際の、worktree + coder Pod + 外部 reviewer Pod + orchestrator Pod の標準フロー。これは **最上位 Pod が細かい code review を抱えず、下位 orchestrator が実装と外部レビューの loop を完了状態まで運ぶためのフロー** である。
|
||||
yoi を yoi で開発する際の、worktree + coder Pod + 外部 reviewer Pod + orchestrator Pod の標準フロー。これは **最上位 Pod が細かい code review を抱えず、下位 orchestrator が実装と外部レビューの loop を完了状態まで運ぶためのフロー** である。
|
||||
|
||||
worktree の機械的作成手順は `$user/worktree-workflow`、実装前の要件同期・反証 preflight は `$user/ticket-preflight-workflow`、ticket 候補選定や方針探索の半自動 loop は `$user/auto-maintain` に分ける。
|
||||
|
||||
|
|
@ -106,7 +106,7 @@ reviewer には coder の実装方針ではなく、この intent packet と dif
|
|||
|
||||
2. worktree 作成
|
||||
- `$user/worktree-workflow` に従い `./.worktree/<task-name>` を作る。
|
||||
- `.insomnia` を sparse checkout で除外する。
|
||||
- `.yoi` を sparse checkout で除外する。
|
||||
|
||||
3. coder Pod spawn
|
||||
- read scope: main workspace 全体。
|
||||
|
|
@ -116,7 +116,7 @@ reviewer には coder の実装方針ではなく、この intent packet と dif
|
|||
- 対象 ticket path
|
||||
- intent packet
|
||||
- Bash は必ず child worktree に `cd` すること
|
||||
- main workspace の `TODO.md` / `tickets/` / `docs/report/` / `.insomnia` は編集しないこと
|
||||
- main workspace の `TODO.md` / `tickets/` / `docs/report/` / `.yoi` は編集しないこと
|
||||
- 範囲外事項
|
||||
- 実行すべき build / test / format
|
||||
- 完了報告項目
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ requires: []
|
|||
---
|
||||
# Ticket Preflight Workflow
|
||||
|
||||
insomnia プロジェクトで ticket を実装に渡す前に、要件・前提・設計境界・反証観点を同期するための Workflow。これは **実装前の gate** であり、worktree 作成や coder / reviewer Pod の起動は `multi-agent-workflow` / `worktree-workflow` 側で扱う。
|
||||
yoi プロジェクトで ticket を実装に渡す前に、要件・前提・設計境界・反証観点を同期するための Workflow。これは **実装前の gate** であり、worktree 作成や coder / reviewer Pod の起動は `multi-agent-workflow` / `worktree-workflow` 側で扱う。
|
||||
|
||||
目的は「ticket があるから実装する」状態を避け、ticket が **実装可能な仕様** なのか、**調査 ticket** なのか、**人間との仕様同期が必要な未決定 ticket** なのかを明確にすることである。
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
---
|
||||
description: insomnia プロジェクトで child git worktree を作成・管理するための機械的手順。coder Pod に作らせず、orchestrator Pod が main workspace で実行する。
|
||||
description: yoi プロジェクトで child git worktree を作成・管理するための機械的手順。coder Pod に作らせず、orchestrator Pod が main workspace で実行する。
|
||||
model_invokation: true
|
||||
user_invocable: true
|
||||
requires: []
|
||||
---
|
||||
# Worktree Workflow
|
||||
|
||||
insomnia プロジェクトで実装差分を main workspace から分離するため、`./.worktree/<task-name>` に child git worktree を作る。これは **worktree の扱い方だけ** を定める Workflow であり、ticket 選定、coder / reviewer sibling の起動、外部レビュー、merge の運用は `$user/multi-agent-workflow` 側で扱う。
|
||||
yoi プロジェクトで実装差分を main workspace から分離するため、`./.worktree/<task-name>` に child git worktree を作る。これは **worktree の扱い方だけ** を定める Workflow であり、ticket 選定、coder / reviewer sibling の起動、外部レビュー、merge の運用は `$user/multi-agent-workflow` 側で扱う。
|
||||
|
||||
insomnia では Pod の write scope が排他的に委譲されるため、child worktree に `.insomnia` を置かない。main workspace は orchestration / ticket / docs / memory / workflow 管理の場所として残し、child worktree はコード差分専用の作業面として扱う。
|
||||
yoi では Pod の write scope が排他的に委譲されるため、child worktree に `.yoi` を置かない。main workspace は orchestration / ticket / docs / memory / workflow 管理の場所として残し、child worktree はコード差分専用の作業面として扱う。
|
||||
|
||||
## 適用範囲
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ insomnia では Pod の write scope が排他的に委譲されるため、child
|
|||
- 複数 ticket を下位 orchestrator に任せる場合も、実装差分は ticket / bounded task ごとに worktree を分ける。
|
||||
- worktree path は `./.worktree/<task-name>`。
|
||||
- branch 名は原則 `<task-name>` と同じ kebab-case。
|
||||
- child worktree には `.insomnia` を出さない。
|
||||
- child worktree には `.yoi` を出さない。
|
||||
- child worktree は実装差分用。`TODO.md` / `tickets/` / `docs/report/` / workflow / memory は原則 main workspace 側で扱う。
|
||||
- push はしない。
|
||||
|
||||
|
|
@ -52,15 +52,15 @@ git worktree add .worktree/<task-name> -b <task-name>
|
|||
git -C .worktree/<task-name> sparse-checkout init --no-cone
|
||||
git -C .worktree/<task-name> sparse-checkout set --no-cone \
|
||||
'/*' \
|
||||
'!/.insomnia/' \
|
||||
'!/.insomnia/**'
|
||||
'!/.yoi/' \
|
||||
'!/.yoi/**'
|
||||
```
|
||||
|
||||
確認する。
|
||||
|
||||
```bash
|
||||
git -C .worktree/<task-name> status --short --branch
|
||||
test ! -e .worktree/<task-name>/.insomnia
|
||||
test ! -e .worktree/<task-name>/.yoi
|
||||
```
|
||||
|
||||
失敗した場合は、worktree / branch / lock の状態を確認し、勝手に cleanup せず人間へ報告する。
|
||||
|
|
@ -89,7 +89,7 @@ reviewer は原則 write scope を持たない。review artifact を書かせる
|
|||
|
||||
## child worktree 内の禁止事項
|
||||
|
||||
- `.insomnia` を作らない / コピーしない。
|
||||
- `.yoi` を作らない / コピーしない。
|
||||
- main workspace の `TODO.md` / `tickets/` / `docs/report/` を編集しない。
|
||||
- merge / push / branch deletion / worktree remove をしない。
|
||||
- scope / permission / history persistence / prompt context 加工原則に関わる設計変更を無断で行わない。
|
||||
|
|
|
|||
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -3937,7 +3937,6 @@ dependencies = [
|
|||
"tempfile",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tools",
|
||||
"unicode-width",
|
||||
"uuid",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -42,6 +42,12 @@ pub enum PreRequestAction {
|
|||
/// to: the items are committed before the request so later turns can see
|
||||
/// why the worker changed course.
|
||||
ContinueWith(Vec<Item>),
|
||||
/// Yield after appending these items to durable worker history.
|
||||
///
|
||||
/// This is for host-mediated pre-request appends that must be visible to
|
||||
/// usage accounting and compaction checks before the current LLM request is
|
||||
/// allowed to proceed.
|
||||
YieldWith(Vec<Item>),
|
||||
/// Cancel with a reason (treated as an error).
|
||||
Cancel(String),
|
||||
/// Yield control to the caller for external processing.
|
||||
|
|
|
|||
|
|
@ -1177,6 +1177,16 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
|||
self.last_run_interrupted = true;
|
||||
return Err(WorkerError::Aborted(reason));
|
||||
}
|
||||
PreRequestAction::YieldWith(items) => {
|
||||
self.append_history_items(items.clone());
|
||||
request_context.extend(items);
|
||||
info!("Yielded by interceptor after pre-request history append");
|
||||
for cb in &self.turn_end_cbs {
|
||||
cb(current_turn);
|
||||
}
|
||||
self.last_run_interrupted = true;
|
||||
return Ok(WorkerResult::Yielded);
|
||||
}
|
||||
PreRequestAction::Yield => {
|
||||
info!("Yielded by interceptor");
|
||||
for cb in &self.turn_end_cbs {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use session_store::Store;
|
|||
use tokio::sync::{broadcast, mpsc, oneshot};
|
||||
|
||||
use crate::discovery::{PodDiscovery, list_pods_tool, restore_pod_tool, send_to_peer_pod_tool};
|
||||
use crate::feature::FeatureRegistryBuilder;
|
||||
use crate::ipc::alerter::Alerter;
|
||||
use crate::ipc::notify_buffer::NotifyBuffer;
|
||||
use crate::ipc::server::SocketServer;
|
||||
|
|
@ -492,7 +493,7 @@ where
|
|||
// below so the worker borrow doesn't conflict with reads on `pod`.
|
||||
let scope_handle = pod.scope().clone();
|
||||
let pwd = pod.pwd().to_path_buf();
|
||||
let task_store = pod.task_store();
|
||||
let task_feature = pod.task_feature();
|
||||
let session_id_for_usage = pod.segment_id().to_string();
|
||||
let memory_config = pod.manifest().memory.clone();
|
||||
let web_config = pod.manifest().web.clone();
|
||||
|
|
@ -502,8 +503,6 @@ where
|
|||
let pod_store = pod.store().clone();
|
||||
let self_parent_socket = pod.callback_socket().cloned();
|
||||
|
||||
let worker = pod.worker_mut();
|
||||
|
||||
// The Pod's SharedScope (already augmented with the bash-output
|
||||
// Read rule by the caller) is the single source of truth — every
|
||||
// ScopedFs (builtin tools, fs_view, compact worker) reads from it,
|
||||
|
|
@ -515,14 +514,19 @@ where
|
|||
// a clone for the FS view we attach below, since the tools consume
|
||||
// `fs` itself.
|
||||
let fs_for_view = fs.clone();
|
||||
worker.register_tools(tools::builtin_tools(
|
||||
pod.worker_mut().register_tools(tools::core_builtin_tools(
|
||||
fs,
|
||||
tracker.clone(),
|
||||
task_store,
|
||||
bash_output_dir,
|
||||
web_config,
|
||||
));
|
||||
|
||||
let mut feature_registry = FeatureRegistryBuilder::new();
|
||||
feature_registry.add_module(task_feature);
|
||||
let _feature_install_report = pod.install_features(feature_registry);
|
||||
|
||||
let worker = pod.worker_mut();
|
||||
|
||||
// Memory subsystem opt-in. When `[memory]` is present in the
|
||||
// manifest, register the memory-specific Read/Write/Edit tools that
|
||||
// target `<workspace>/memory/` and `<workspace>/knowledge/` with
|
||||
|
|
|
|||
1849
crates/pod/src/feature.rs
Normal file
1849
crates/pod/src/feature.rs
Normal file
File diff suppressed because it is too large
Load Diff
9
crates/pod/src/feature/builtin.rs
Normal file
9
crates/pod/src/feature/builtin.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
//! Built-in internal feature modules.
|
||||
//!
|
||||
//! These modules are compiled into the Pod host and contribute through the
|
||||
//! same descriptor-approved registry path used by feature modules. They are not
|
||||
//! an external plugin-loading surface.
|
||||
|
||||
pub mod task;
|
||||
|
||||
pub use task::{TaskFeature, task_tools_feature};
|
||||
574
crates/pod/src/feature/builtin/task/mod.rs
Normal file
574
crates/pod/src/feature/builtin/task/mod.rs
Normal file
|
|
@ -0,0 +1,574 @@
|
|||
//! Task tools built-in feature module.
|
||||
//!
|
||||
//! The built-in Task feature owns the session-lifetime [`TaskStore`] shared by
|
||||
//! the Task tools and reminder hooks. Pod hosts install this module through the
|
||||
//! feature contribution boundary and use its narrow snapshot surface for
|
||||
//! restore/rewind/compaction compatibility.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_worker::Item;
|
||||
|
||||
mod store;
|
||||
mod tool_impl;
|
||||
|
||||
pub(crate) use self::tool_impl::task_tools;
|
||||
use store::snapshot_overview;
|
||||
pub(crate) use store::{TaskEntry, TaskStatus, TaskStore};
|
||||
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule,
|
||||
HookDeclaration, ToolContribution, ToolDeclaration,
|
||||
};
|
||||
use crate::hook::{
|
||||
Hook, HookPreRequestAction, HookPreToolAction, PreLlmRequest, PreRequestContext, PreToolCall,
|
||||
ToolCallSummary,
|
||||
};
|
||||
|
||||
const TASK_REMINDER_REQUEST_THRESHOLD: usize = 24;
|
||||
const TASK_REMINDER_COOLDOWN_REQUESTS: usize = 24;
|
||||
const TASK_MANAGEMENT_TOOL_NAMES: [&str; 2] = ["TaskCreate", "TaskUpdate"];
|
||||
|
||||
/// Construct the built-in Task feature module with a fresh session store.
|
||||
///
|
||||
/// The returned module contributes `TaskCreate`, `TaskUpdate`, `TaskGet`, and
|
||||
/// `TaskList` through descriptor-approved tool registration, plus built-in hooks
|
||||
/// that maintain Task-reminder state. It does not request sandbox/external-plugin
|
||||
/// host authorities; normal ToolRegistry and PreToolCall permission policy still
|
||||
/// applies at call time.
|
||||
pub fn task_tools_feature() -> TaskFeature {
|
||||
TaskFeature::new()
|
||||
}
|
||||
|
||||
/// Built-in Task feature state and contribution module.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TaskFeature {
|
||||
state: Arc<TaskFeatureState>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TaskFeatureState {
|
||||
task_store: TaskStore,
|
||||
reminder_state: TaskReminderState,
|
||||
}
|
||||
|
||||
impl TaskFeature {
|
||||
pub fn new() -> Self {
|
||||
Self::from_store(TaskStore::new())
|
||||
}
|
||||
|
||||
pub fn from_history(history: &[Item]) -> Self {
|
||||
Self::from_store(TaskStore::from_history(history))
|
||||
}
|
||||
|
||||
fn from_store(task_store: TaskStore) -> Self {
|
||||
Self {
|
||||
state: Arc::new(TaskFeatureState {
|
||||
task_store,
|
||||
reminder_state: TaskReminderState::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore the feature-owned store by replaying durable history into the
|
||||
/// existing shared store handle. Existing Task tool instances and hooks keep
|
||||
/// pointing at the same feature-owned store after rewind.
|
||||
pub fn restore_from_history(&self, history: &[Item]) {
|
||||
let restored = TaskStore::from_history(history);
|
||||
self.state.task_store.replace_with(restored.list());
|
||||
}
|
||||
|
||||
/// Feature-owned snapshot text used by compaction to preserve Task state.
|
||||
pub fn snapshot_text(&self) -> String {
|
||||
self.state.task_store.snapshot_text()
|
||||
}
|
||||
|
||||
/// Feature-owned compact summary used for the synthetic TaskList result.
|
||||
pub fn snapshot_overview(&self) -> String {
|
||||
snapshot_overview(&self.state.task_store.list())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn task_store(&self) -> TaskStore {
|
||||
self.state.task_store.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TaskFeature {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureModule for TaskFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
FeatureDescriptor::builtin("task-tools", "Task tools")
|
||||
.with_description("Session-lifetime task tracking builtin tools")
|
||||
.with_tool(ToolDeclaration::new(
|
||||
"TaskCreate",
|
||||
"Create a session-lifetime user-visible task",
|
||||
))
|
||||
.with_tool(ToolDeclaration::new(
|
||||
"TaskUpdate",
|
||||
"Update a session-lifetime user-visible task",
|
||||
))
|
||||
.with_tool(ToolDeclaration::new(
|
||||
"TaskGet",
|
||||
"Get one session-lifetime user-visible task",
|
||||
))
|
||||
.with_tool(ToolDeclaration::new(
|
||||
"TaskList",
|
||||
"List session-lifetime user-visible tasks",
|
||||
))
|
||||
.with_hook(HookDeclaration::new(
|
||||
"task-reminder-pre-request",
|
||||
FeatureHookPoint::PreRequest,
|
||||
))
|
||||
.with_hook(HookDeclaration::new(
|
||||
"task-reminder-tool-usage",
|
||||
FeatureHookPoint::PreToolCall,
|
||||
))
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
let names = ["TaskCreate", "TaskList", "TaskGet", "TaskUpdate"];
|
||||
for (name, definition) in names
|
||||
.into_iter()
|
||||
.zip(task_tools(self.state.task_store.clone()))
|
||||
{
|
||||
context
|
||||
.tools()
|
||||
.register(ToolContribution::new(name, definition))?;
|
||||
}
|
||||
|
||||
context.hooks().add_pre_request(
|
||||
"task-reminder-pre-request",
|
||||
TaskReminderPreRequestHook {
|
||||
state: Arc::clone(&self.state),
|
||||
},
|
||||
)?;
|
||||
context.hooks().add_pre_tool_call(
|
||||
"task-reminder-tool-usage",
|
||||
TaskReminderToolUsageHook {
|
||||
state: Arc::clone(&self.state),
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
struct TaskReminderPreRequestHook {
|
||||
state: Arc<TaskFeatureState>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hook<PreLlmRequest> for TaskReminderPreRequestHook {
|
||||
async fn call(&self, input: &PreRequestContext) -> HookPreRequestAction {
|
||||
let active_tasks: Vec<TaskEntry> = self
|
||||
.state
|
||||
.task_store
|
||||
.list()
|
||||
.into_iter()
|
||||
.filter(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Inprogress))
|
||||
.collect();
|
||||
if active_tasks.is_empty() {
|
||||
return HookPreRequestAction::Continue;
|
||||
}
|
||||
|
||||
let (since_task_management, since_reminder) = self.state.reminder_state.note_request();
|
||||
if since_task_management < TASK_REMINDER_REQUEST_THRESHOLD
|
||||
|| since_reminder < TASK_REMINDER_COOLDOWN_REQUESTS
|
||||
{
|
||||
return HookPreRequestAction::Continue;
|
||||
}
|
||||
|
||||
if let Some(system_items) = input.system_items() {
|
||||
self.state.reminder_state.note_reminder();
|
||||
system_items.append_task_reminder(render_task_reminder_body(&active_tasks));
|
||||
}
|
||||
HookPreRequestAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
struct TaskReminderToolUsageHook {
|
||||
state: Arc<TaskFeatureState>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hook<PreToolCall> for TaskReminderToolUsageHook {
|
||||
async fn call(&self, input: &ToolCallSummary) -> HookPreToolAction {
|
||||
if is_task_management_tool(&input.tool_name) {
|
||||
self.state.reminder_state.note_task_management();
|
||||
}
|
||||
HookPreToolAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use session_store::{SystemItem, SystemReminderSource};
|
||||
|
||||
use super::*;
|
||||
use crate::hook::{PreRequestInfo, SystemItemAppendHandle};
|
||||
|
||||
fn pre_request_context(pending: Arc<Mutex<Vec<SystemItem>>>) -> PreRequestContext {
|
||||
PreRequestContext::new(
|
||||
PreRequestInfo {
|
||||
item_count: 1,
|
||||
estimated_tokens: None,
|
||||
turn_index: 0,
|
||||
tool_calls_this_turn: 0,
|
||||
},
|
||||
Some(SystemItemAppendHandle::new(pending)),
|
||||
)
|
||||
}
|
||||
|
||||
fn tool_summary(name: &str) -> ToolCallSummary {
|
||||
ToolCallSummary {
|
||||
call_id: "call-id".into(),
|
||||
tool_name: name.into(),
|
||||
arguments: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_reminder_hook_appends_after_inactive_request_threshold() {
|
||||
let feature = TaskFeature::new();
|
||||
feature
|
||||
.task_store()
|
||||
.create("keep going".into(), "long task description".into());
|
||||
let hook = TaskReminderPreRequestHook {
|
||||
state: Arc::clone(&feature.state),
|
||||
};
|
||||
let pending = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD - 1 {
|
||||
let _ = hook.call(&pre_request_context(Arc::clone(&pending))).await;
|
||||
assert!(pending.lock().expect("pending queue poisoned").is_empty());
|
||||
}
|
||||
let _ = hook.call(&pre_request_context(Arc::clone(&pending))).await;
|
||||
|
||||
let queued = pending.lock().expect("pending queue poisoned");
|
||||
assert_eq!(queued.len(), 1);
|
||||
let SystemItem::TaskReminder { body, .. } = &queued[0] else {
|
||||
panic!("unexpected system item: {:?}", queued[0]);
|
||||
};
|
||||
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"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_reminder_hook_retains_source() {
|
||||
let feature = TaskFeature::new();
|
||||
feature.task_store().create("typed".into(), String::new());
|
||||
let hook = TaskReminderPreRequestHook {
|
||||
state: Arc::clone(&feature.state),
|
||||
};
|
||||
let pending = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD {
|
||||
let _ = hook.call(&pre_request_context(Arc::clone(&pending))).await;
|
||||
}
|
||||
|
||||
let queued = pending.lock().expect("pending queue poisoned");
|
||||
let SystemItem::TaskReminder { source, body } = &queued[0] else {
|
||||
panic!("unexpected system item: {:?}", queued[0]);
|
||||
};
|
||||
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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_task_reminder_body_is_unwrapped_for_system_reminder_helper() {
|
||||
let feature = TaskFeature::new();
|
||||
let task = feature.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 feature = TaskFeature::new();
|
||||
feature
|
||||
.task_store()
|
||||
.create("track me".into(), String::new());
|
||||
let pre_request = TaskReminderPreRequestHook {
|
||||
state: Arc::clone(&feature.state),
|
||||
};
|
||||
let pre_tool = TaskReminderToolUsageHook {
|
||||
state: Arc::clone(&feature.state),
|
||||
};
|
||||
let pending = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD - 1 {
|
||||
let _ = pre_request
|
||||
.call(&pre_request_context(Arc::clone(&pending)))
|
||||
.await;
|
||||
assert!(pending.lock().expect("pending queue poisoned").is_empty());
|
||||
}
|
||||
let _ = pre_tool.call(&tool_summary("TaskUpdate")).await;
|
||||
|
||||
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD - 1 {
|
||||
let _ = pre_request
|
||||
.call(&pre_request_context(Arc::clone(&pending)))
|
||||
.await;
|
||||
assert!(pending.lock().expect("pending queue poisoned").is_empty());
|
||||
}
|
||||
let _ = pre_request
|
||||
.call(&pre_request_context(Arc::clone(&pending)))
|
||||
.await;
|
||||
assert_eq!(pending.lock().expect("pending queue poisoned").len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_reminder_respects_cooldown_after_reminder() {
|
||||
let feature = TaskFeature::new();
|
||||
feature
|
||||
.task_store()
|
||||
.create("cooldown".into(), String::new());
|
||||
let hook = TaskReminderPreRequestHook {
|
||||
state: Arc::clone(&feature.state),
|
||||
};
|
||||
let pending = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD {
|
||||
let _ = hook.call(&pre_request_context(Arc::clone(&pending))).await;
|
||||
}
|
||||
pending.lock().expect("pending queue poisoned").clear();
|
||||
for _ in 0..TASK_REMINDER_COOLDOWN_REQUESTS - 1 {
|
||||
let _ = hook.call(&pre_request_context(Arc::clone(&pending))).await;
|
||||
assert!(pending.lock().expect("pending queue poisoned").is_empty());
|
||||
}
|
||||
let _ = hook.call(&pre_request_context(Arc::clone(&pending))).await;
|
||||
assert_eq!(pending.lock().expect("pending queue poisoned").len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_reminder_is_silent_when_no_active_tasks_exist() {
|
||||
let feature = TaskFeature::new();
|
||||
let done = feature
|
||||
.task_store()
|
||||
.create("done".into(), String::new())
|
||||
.taskid;
|
||||
feature
|
||||
.task_store()
|
||||
.update(done, Some(TaskStatus::Completed), None, None)
|
||||
.expect("complete task");
|
||||
let hook = TaskReminderPreRequestHook {
|
||||
state: Arc::clone(&feature.state),
|
||||
};
|
||||
let pending = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD * 2 {
|
||||
let _ = hook.call(&pre_request_context(Arc::clone(&pending))).await;
|
||||
assert!(pending.lock().expect("pending queue poisoned").is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inactive_requests_without_active_tasks_do_not_prime_task_reminder() {
|
||||
let feature = TaskFeature::new();
|
||||
let hook = TaskReminderPreRequestHook {
|
||||
state: Arc::clone(&feature.state),
|
||||
};
|
||||
let pending = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD * 2 {
|
||||
let _ = hook.call(&pre_request_context(Arc::clone(&pending))).await;
|
||||
assert!(pending.lock().expect("pending queue poisoned").is_empty());
|
||||
}
|
||||
|
||||
feature
|
||||
.task_store()
|
||||
.create("new active".into(), String::new());
|
||||
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD - 1 {
|
||||
let _ = hook.call(&pre_request_context(Arc::clone(&pending))).await;
|
||||
assert!(pending.lock().expect("pending queue poisoned").is_empty());
|
||||
}
|
||||
let _ = hook.call(&pre_request_context(Arc::clone(&pending))).await;
|
||||
assert_eq!(pending.lock().expect("pending queue poisoned").len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_create_reset_does_not_block_first_reminder_cooldown() {
|
||||
let feature = TaskFeature::new();
|
||||
let pre_request = TaskReminderPreRequestHook {
|
||||
state: Arc::clone(&feature.state),
|
||||
};
|
||||
let pre_tool = TaskReminderToolUsageHook {
|
||||
state: Arc::clone(&feature.state),
|
||||
};
|
||||
let pending = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD * 2 {
|
||||
let _ = pre_request
|
||||
.call(&pre_request_context(Arc::clone(&pending)))
|
||||
.await;
|
||||
assert!(pending.lock().expect("pending queue poisoned").is_empty());
|
||||
}
|
||||
|
||||
let _ = pre_tool.call(&tool_summary("TaskCreate")).await;
|
||||
feature
|
||||
.task_store()
|
||||
.create("created after idle".into(), String::new());
|
||||
assert_eq!(
|
||||
feature
|
||||
.state
|
||||
.reminder_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 {
|
||||
let _ = pre_request
|
||||
.call(&pre_request_context(Arc::clone(&pending)))
|
||||
.await;
|
||||
assert!(pending.lock().expect("pending queue poisoned").is_empty());
|
||||
}
|
||||
let _ = pre_request
|
||||
.call(&pre_request_context(Arc::clone(&pending)))
|
||||
.await;
|
||||
assert_eq!(pending.lock().expect("pending queue poisoned").len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_system_item_handle_does_not_mark_reminder_sent() {
|
||||
let feature = TaskFeature::new();
|
||||
feature.task_store().create("handle".into(), String::new());
|
||||
let hook = TaskReminderPreRequestHook {
|
||||
state: Arc::clone(&feature.state),
|
||||
};
|
||||
let no_handle = PreRequestContext::new(
|
||||
PreRequestInfo {
|
||||
item_count: 1,
|
||||
estimated_tokens: None,
|
||||
turn_index: 0,
|
||||
tool_calls_this_turn: 0,
|
||||
},
|
||||
None,
|
||||
);
|
||||
|
||||
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD {
|
||||
let _ = hook.call(&no_handle).await;
|
||||
}
|
||||
assert_eq!(
|
||||
feature
|
||||
.state
|
||||
.reminder_state
|
||||
.requests_since_last_reminder
|
||||
.load(Ordering::Relaxed),
|
||||
TASK_REMINDER_COOLDOWN_REQUESTS + TASK_REMINDER_REQUEST_THRESHOLD,
|
||||
"without a handle the hook must not record a reminder as emitted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_from_history_keeps_existing_store_handle_for_installed_tools() {
|
||||
let feature = TaskFeature::new();
|
||||
let handle = feature.task_store();
|
||||
handle.create("old".into(), String::new());
|
||||
let history = vec![Item::tool_call(
|
||||
"c1",
|
||||
"TaskCreate",
|
||||
r#"{"subject":"restored","description":"from history"}"#,
|
||||
)];
|
||||
|
||||
feature.restore_from_history(&history);
|
||||
|
||||
let tasks = handle.list();
|
||||
assert_eq!(tasks.len(), 1);
|
||||
assert_eq!(tasks[0].subject, "restored");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,12 @@
|
|||
//! Session-lifetime TaskStore and builtin task tools.
|
||||
//! Task domain state and snapshot/replay support.
|
||||
//!
|
||||
//! The store survives compaction and Pod restart — it is reconstructed
|
||||
//! on resume by replaying TaskCreate / TaskUpdate tool-call arguments
|
||||
//! from persisted history, so its effective lifetime is the
|
||||
//! [`session_store::SessionId`] (the conversation), not the Pod process.
|
||||
//! The store survives compaction and Pod restart by replaying TaskCreate /
|
||||
//! TaskUpdate tool-call arguments and compacted TaskStore snapshots from
|
||||
//! persisted history.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_worker::Item;
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
|
|
@ -145,12 +142,16 @@ impl TaskStore {
|
|||
name, arguments, ..
|
||||
} => match name.as_str() {
|
||||
"TaskCreate" => {
|
||||
if let Ok(params) = serde_json::from_str::<TaskCreateParams>(arguments) {
|
||||
if let Ok(params) =
|
||||
serde_json::from_str::<ReplayTaskCreateParams>(arguments)
|
||||
{
|
||||
let _ = self.create(params.subject, params.description);
|
||||
}
|
||||
}
|
||||
"TaskUpdate" => {
|
||||
if let Ok(params) = serde_json::from_str::<TaskUpdateParams>(arguments) {
|
||||
if let Ok(params) =
|
||||
serde_json::from_str::<ReplayTaskUpdateParams>(arguments)
|
||||
{
|
||||
let _ = self.update(
|
||||
params.taskid,
|
||||
params.status,
|
||||
|
|
@ -186,7 +187,8 @@ impl TaskStore {
|
|||
}
|
||||
|
||||
pub fn snapshot_text(&self) -> String {
|
||||
render_snapshot(&self.list())
|
||||
let snapshot = self.snapshot();
|
||||
render_snapshot(&snapshot.tasks)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -209,24 +211,14 @@ impl std::fmt::Display for TaskStoreError {
|
|||
|
||||
impl std::error::Error for TaskStoreError {}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TaskCreateParams {
|
||||
/// One-line task subject.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReplayTaskCreateParams {
|
||||
subject: String,
|
||||
/// Detailed task description.
|
||||
description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TaskListParams {}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TaskGetParams {
|
||||
taskid: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TaskUpdateParams {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReplayTaskUpdateParams {
|
||||
taskid: u64,
|
||||
#[serde(default)]
|
||||
status: Option<TaskStatus>,
|
||||
|
|
@ -236,130 +228,6 @@ struct TaskUpdateParams {
|
|||
description: Option<String>,
|
||||
}
|
||||
|
||||
struct TaskCreateTool {
|
||||
store: TaskStore,
|
||||
}
|
||||
|
||||
struct TaskListTool {
|
||||
store: TaskStore,
|
||||
}
|
||||
|
||||
struct TaskGetTool {
|
||||
store: TaskStore,
|
||||
}
|
||||
|
||||
struct TaskUpdateTool {
|
||||
store: TaskStore,
|
||||
}
|
||||
|
||||
const CREATE_DESCRIPTION: &str = "Create a session-lifetime task only when user-visible \
|
||||
progress tracking is genuinely useful: multiple active tasks must be remembered, or the work \
|
||||
will involve long edits, long-running commands, extended investigation, or interruption-prone \
|
||||
coordination. Do not create a task just because a request has several steps, and do not create \
|
||||
one for short questions, quick checks, single reviews, or one-off commands. Prefer updating an \
|
||||
existing active task over creating a duplicate. Input only `subject` and `description`; `taskid` \
|
||||
is assigned automatically and initial `status` is `pending`.";
|
||||
const LIST_DESCRIPTION: &str = "List every session-lifetime task, including completed and \
|
||||
deleted entries. Tasks are user-visible real-time status for short-term current-work tracking. \
|
||||
Takes an empty object as input.";
|
||||
const GET_DESCRIPTION: &str = "Get one session-lifetime task by `taskid`. Tasks are \
|
||||
user-visible real-time status for short-term current-work tracking. Returns an error if the task \
|
||||
does not exist.";
|
||||
const UPDATE_DESCRIPTION: &str = "Update an existing session-lifetime task when meaningful \
|
||||
progress changes between substantial steps. Tasks are user-visible real-time status, so avoid \
|
||||
churn for trivial substeps. Keep status current with `pending`, `inprogress`, `completed`, or \
|
||||
`deleted`. Provide `taskid` and at least one of `status`, `subject`, or `description`; deletion is \
|
||||
logical (`status = deleted`). If an unexpected problem blocks progress, do not force the next \
|
||||
step: leave the task as-is, summarize the problem to the user, and end the turn.";
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TaskCreateTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
let params: TaskCreateParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid TaskCreate input: {e}")))?;
|
||||
let created = self.store.create(params.subject, params.description);
|
||||
let tasks = self.store.list();
|
||||
Ok(task_output(
|
||||
format!(
|
||||
"Created task {} ({})\n{}",
|
||||
created.taskid,
|
||||
created.status,
|
||||
snapshot_overview(&tasks)
|
||||
),
|
||||
&created,
|
||||
&tasks,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TaskListTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
let _: TaskListParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid TaskList input: {e}")))?;
|
||||
let tasks = self.store.list();
|
||||
Ok(ToolOutput {
|
||||
summary: snapshot_overview(&tasks),
|
||||
content: Some(render_snapshot(&tasks)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TaskGetTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
let params: TaskGetParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid TaskGet input: {e}")))?;
|
||||
let task = self.store.get(params.taskid).ok_or_else(|| {
|
||||
ToolError::ExecutionFailed(format!("taskid {} not found", params.taskid))
|
||||
})?;
|
||||
let content = serde_json::to_string_pretty(&task).unwrap_or_else(|_| format!("{task:?}"));
|
||||
Ok(ToolOutput {
|
||||
summary: format!("Task {} ({}) {}", task.taskid, task.status, task.subject),
|
||||
content: Some(content),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TaskUpdateTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
let params: TaskUpdateParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid TaskUpdate input: {e}")))?;
|
||||
let updated = self
|
||||
.store
|
||||
.update(
|
||||
params.taskid,
|
||||
params.status,
|
||||
params.subject,
|
||||
params.description,
|
||||
)
|
||||
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
||||
let tasks = self.store.list();
|
||||
Ok(task_output(
|
||||
format!(
|
||||
"Updated task {} ({})\n{}",
|
||||
updated.taskid,
|
||||
updated.status,
|
||||
snapshot_overview(&tasks)
|
||||
),
|
||||
&updated,
|
||||
&tasks,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn task_output(summary: String, task: &TaskEntry, tasks: &[TaskEntry]) -> ToolOutput {
|
||||
let content = serde_json::json!({
|
||||
"task": task,
|
||||
"snapshot": { "tasks": tasks },
|
||||
});
|
||||
ToolOutput {
|
||||
summary,
|
||||
content: Some(serde_json::to_string_pretty(&content).unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot_overview(tasks: &[TaskEntry]) -> String {
|
||||
let pending = tasks
|
||||
.iter()
|
||||
|
|
@ -392,7 +260,7 @@ pub fn render_snapshot(tasks: &[TaskEntry]) -> String {
|
|||
format!("{}\n\n```json\n{}\n```\n", snapshot_overview(tasks), json)
|
||||
}
|
||||
|
||||
fn parse_compact_snapshot_text(text: &str) -> Option<Vec<TaskEntry>> {
|
||||
pub(super) fn parse_compact_snapshot_text(text: &str) -> Option<Vec<TaskEntry>> {
|
||||
if !text.starts_with("[Session TaskStore snapshot]") {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -405,131 +273,10 @@ fn parse_compact_snapshot_text(text: &str) -> Option<Vec<TaskEntry>> {
|
|||
Some(snapshot.tasks)
|
||||
}
|
||||
|
||||
fn task_create_tool(store: TaskStore) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(TaskCreateParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("TaskCreate")
|
||||
.description(CREATE_DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(TaskCreateTool {
|
||||
store: store.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
fn task_list_tool(store: TaskStore) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(TaskListParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("TaskList")
|
||||
.description(LIST_DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(TaskListTool {
|
||||
store: store.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
fn task_get_tool(store: TaskStore) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(TaskGetParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("TaskGet")
|
||||
.description(GET_DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(TaskGetTool {
|
||||
store: store.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
fn task_update_tool(store: TaskStore) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(TaskUpdateParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("TaskUpdate")
|
||||
.description(UPDATE_DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(TaskUpdateTool {
|
||||
store: store.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn task_tools(store: TaskStore) -> Vec<ToolDefinition> {
|
||||
vec![
|
||||
task_create_tool(store.clone()),
|
||||
task_list_tool(store.clone()),
|
||||
task_get_tool(store.clone()),
|
||||
task_update_tool(store),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tool(def: ToolDefinition) -> Arc<dyn Tool> {
|
||||
let (_, tool) = def();
|
||||
tool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_tools_create_list_get_update() {
|
||||
let store = TaskStore::new();
|
||||
let create = tool(task_create_tool(store.clone()));
|
||||
let list = tool(task_list_tool(store.clone()));
|
||||
let get = tool(task_get_tool(store.clone()));
|
||||
let update = tool(task_update_tool(store.clone()));
|
||||
|
||||
let out = create
|
||||
.execute(r#"{"subject":"implement","description":"write code"}"#)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.summary.contains("Created task 1"));
|
||||
assert_eq!(store.get(1).unwrap().status, TaskStatus::Pending);
|
||||
|
||||
let out = update
|
||||
.execute(r#"{"taskid":1,"status":"inprogress","subject":"implement tasks"}"#)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.summary.contains("Updated task 1"));
|
||||
let task = store.get(1).unwrap();
|
||||
assert_eq!(task.status, TaskStatus::Inprogress);
|
||||
assert_eq!(task.subject, "implement tasks");
|
||||
|
||||
let out = get.execute(r#"{"taskid":1}"#).await.unwrap();
|
||||
assert!(out.summary.contains("Task 1 (inprogress)"));
|
||||
assert!(out.content.unwrap().contains("implement tasks"));
|
||||
|
||||
let out = list.execute("{}").await.unwrap();
|
||||
assert!(out.summary.contains("1 task(s)"));
|
||||
let content = out.content.unwrap();
|
||||
assert!(content.contains("\"taskid\": 1"));
|
||||
assert!(content.contains("```json"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_update_validates_existing_and_at_least_one_field() {
|
||||
let store = TaskStore::new();
|
||||
store.create("s".into(), "d".into());
|
||||
let update = tool(task_update_tool(store));
|
||||
|
||||
let err = update.execute(r#"{"taskid":1}"#).await.unwrap_err();
|
||||
assert!(err.to_string().contains("at least one"));
|
||||
|
||||
let err = update
|
||||
.execute(r#"{"taskid":99,"status":"deleted"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("taskid 99 not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_history_reconstructs_store_and_ignores_malformed_calls() {
|
||||
let history = vec![
|
||||
285
crates/pod/src/feature/builtin/task/tool_impl.rs
Normal file
285
crates/pod/src/feature/builtin/task/tool_impl.rs
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
//! Task built-in tool implementations.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::store::{TaskEntry, TaskStatus, TaskStore, render_snapshot, snapshot_overview};
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TaskCreateParams {
|
||||
/// One-line task subject.
|
||||
subject: String,
|
||||
/// Detailed task description.
|
||||
description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TaskListParams {}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TaskGetParams {
|
||||
taskid: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TaskUpdateParams {
|
||||
taskid: u64,
|
||||
#[serde(default)]
|
||||
status: Option<TaskStatus>,
|
||||
#[serde(default)]
|
||||
subject: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
struct TaskCreateTool {
|
||||
store: TaskStore,
|
||||
}
|
||||
|
||||
struct TaskListTool {
|
||||
store: TaskStore,
|
||||
}
|
||||
|
||||
struct TaskGetTool {
|
||||
store: TaskStore,
|
||||
}
|
||||
|
||||
struct TaskUpdateTool {
|
||||
store: TaskStore,
|
||||
}
|
||||
|
||||
const CREATE_DESCRIPTION: &str = "Create a session-lifetime task only when user-visible \
|
||||
progress tracking is genuinely useful: multiple active tasks must be remembered, or the work \
|
||||
will involve long edits, long-running commands, extended investigation, or interruption-prone \
|
||||
coordination. Do not create a task just because a request has several steps, and do not create \
|
||||
one for short questions, quick checks, single reviews, or one-off commands. Prefer updating an \
|
||||
existing active task over creating a duplicate. Input only `subject` and `description`; `taskid` \
|
||||
is assigned automatically and initial `status` is `pending`.";
|
||||
const LIST_DESCRIPTION: &str = "List every session-lifetime task, including completed and \
|
||||
deleted entries. Tasks are user-visible real-time status for short-term current-work tracking. \
|
||||
Takes an empty object as input.";
|
||||
const GET_DESCRIPTION: &str = "Get one session-lifetime task by `taskid`. Tasks are \
|
||||
user-visible real-time status for short-term current-work tracking. Returns an error if the task \
|
||||
does not exist.";
|
||||
const UPDATE_DESCRIPTION: &str = "Update an existing session-lifetime task when meaningful \
|
||||
progress changes between substantial steps. Tasks are user-visible real-time status, so avoid \
|
||||
churn for trivial substeps. Keep status current with `pending`, `inprogress`, `completed`, or \
|
||||
`deleted`. Provide `taskid` and at least one of `status`, `subject`, or `description`; deletion is \
|
||||
logical (`status = deleted`). If an unexpected problem blocks progress, do not force the next \
|
||||
step: leave the task as-is, summarize the problem to the user, and end the turn.";
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TaskCreateTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
let params: TaskCreateParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid TaskCreate input: {e}")))?;
|
||||
let created = self.store.create(params.subject, params.description);
|
||||
let tasks = self.store.list();
|
||||
Ok(task_output(
|
||||
format!(
|
||||
"Created task {} ({})\n{}",
|
||||
created.taskid,
|
||||
created.status,
|
||||
snapshot_overview(&tasks)
|
||||
),
|
||||
&created,
|
||||
&tasks,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TaskListTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
let _: TaskListParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid TaskList input: {e}")))?;
|
||||
let tasks = self.store.list();
|
||||
Ok(ToolOutput {
|
||||
summary: snapshot_overview(&tasks),
|
||||
content: Some(render_snapshot(&tasks)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TaskGetTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
let params: TaskGetParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid TaskGet input: {e}")))?;
|
||||
let task = self.store.get(params.taskid).ok_or_else(|| {
|
||||
ToolError::ExecutionFailed(format!("taskid {} not found", params.taskid))
|
||||
})?;
|
||||
let content = serde_json::to_string_pretty(&task).unwrap_or_else(|_| format!("{task:?}"));
|
||||
Ok(ToolOutput {
|
||||
summary: format!("Task {} ({}) {}", task.taskid, task.status, task.subject),
|
||||
content: Some(content),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TaskUpdateTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
let params: TaskUpdateParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid TaskUpdate input: {e}")))?;
|
||||
let updated = self
|
||||
.store
|
||||
.update(
|
||||
params.taskid,
|
||||
params.status,
|
||||
params.subject,
|
||||
params.description,
|
||||
)
|
||||
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
||||
let tasks = self.store.list();
|
||||
Ok(task_output(
|
||||
format!(
|
||||
"Updated task {} ({})\n{}",
|
||||
updated.taskid,
|
||||
updated.status,
|
||||
snapshot_overview(&tasks)
|
||||
),
|
||||
&updated,
|
||||
&tasks,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn task_output(summary: String, task: &TaskEntry, tasks: &[TaskEntry]) -> ToolOutput {
|
||||
let content = serde_json::json!({
|
||||
"task": task,
|
||||
"snapshot": { "tasks": tasks },
|
||||
});
|
||||
ToolOutput {
|
||||
summary,
|
||||
content: Some(serde_json::to_string_pretty(&content).unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
fn task_create_tool(store: TaskStore) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(TaskCreateParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("TaskCreate")
|
||||
.description(CREATE_DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(TaskCreateTool {
|
||||
store: store.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
fn task_list_tool(store: TaskStore) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(TaskListParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("TaskList")
|
||||
.description(LIST_DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(TaskListTool {
|
||||
store: store.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
fn task_get_tool(store: TaskStore) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(TaskGetParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("TaskGet")
|
||||
.description(GET_DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(TaskGetTool {
|
||||
store: store.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
fn task_update_tool(store: TaskStore) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(TaskUpdateParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("TaskUpdate")
|
||||
.description(UPDATE_DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(TaskUpdateTool {
|
||||
store: store.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn task_tools(store: TaskStore) -> Vec<ToolDefinition> {
|
||||
vec![
|
||||
task_create_tool(store.clone()),
|
||||
task_list_tool(store.clone()),
|
||||
task_get_tool(store.clone()),
|
||||
task_update_tool(store),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tool(def: ToolDefinition) -> Arc<dyn Tool> {
|
||||
let (_, tool) = def();
|
||||
tool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_tools_create_list_get_update() {
|
||||
let store = TaskStore::new();
|
||||
let create = tool(task_create_tool(store.clone()));
|
||||
let list = tool(task_list_tool(store.clone()));
|
||||
let get = tool(task_get_tool(store.clone()));
|
||||
let update = tool(task_update_tool(store.clone()));
|
||||
|
||||
let out = create
|
||||
.execute(r#"{"subject":"implement","description":"write code"}"#)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.summary.contains("Created task 1"));
|
||||
assert_eq!(store.get(1).unwrap().status, TaskStatus::Pending);
|
||||
|
||||
let out = update
|
||||
.execute(r#"{"taskid":1,"status":"inprogress","subject":"implement tasks"}"#)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.summary.contains("Updated task 1"));
|
||||
let task = store.get(1).unwrap();
|
||||
assert_eq!(task.status, TaskStatus::Inprogress);
|
||||
assert_eq!(task.subject, "implement tasks");
|
||||
|
||||
let out = get.execute(r#"{"taskid":1}"#).await.unwrap();
|
||||
assert!(out.summary.contains("Task 1 (inprogress)"));
|
||||
assert!(out.content.unwrap().contains("implement tasks"));
|
||||
|
||||
let out = list.execute("{}").await.unwrap();
|
||||
assert!(out.summary.contains("1 task(s)"));
|
||||
let content = out.content.unwrap();
|
||||
assert!(content.contains("\"taskid\": 1"));
|
||||
assert!(content.contains("```json"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_update_validates_existing_and_at_least_one_field() {
|
||||
let store = TaskStore::new();
|
||||
store.create("s".into(), "d".into());
|
||||
let update = tool(task_update_tool(store));
|
||||
|
||||
let err = update.execute(r#"{"taskid":1}"#).await.unwrap_err();
|
||||
assert!(err.to_string().contains("at least one"));
|
||||
|
||||
let err = update
|
||||
.execute(r#"{"taskid":99,"status":"deleted"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("taskid 99 not found"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
//! Pod-layer hook infrastructure
|
||||
//!
|
||||
//! Hooks are the **public** orchestration extension point. They receive
|
||||
//! read-only summary information about each event in the Worker
|
||||
//! execution loop and return a safe public control-flow action.
|
||||
//! event-specific context values about each event in the Worker execution loop
|
||||
//! and return a safe public control-flow action. Contexts may carry narrow
|
||||
//! host-created handles for approved side effects; hook return values remain
|
||||
//! flow-control decisions only.
|
||||
//!
|
||||
//! Hooks intentionally cannot mutate the Worker's context, history, tool
|
||||
//! call, or tool result. Internal mechanisms that need such access (e.g.
|
||||
|
|
@ -13,12 +15,16 @@
|
|||
//! extension surfaces (scripting, plugins) in the future without
|
||||
//! exposing the underlying mutable state.
|
||||
|
||||
use std::ops::Deref;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_worker::interceptor::{
|
||||
PostToolAction, PreRequestAction, PreToolAction, PromptAction, TurnEndAction,
|
||||
};
|
||||
use llm_worker::tool::{ToolOutput, ToolResult};
|
||||
use serde_json::Value;
|
||||
use session_store::{SystemItem, SystemReminder};
|
||||
|
||||
/// Hook-facing prompt-submit action.
|
||||
///
|
||||
|
|
@ -148,7 +154,42 @@ impl From<HookTurnEndAction> for TurnEndAction {
|
|||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hook input summary types (read-only)
|
||||
// Hook context handles
|
||||
// =============================================================================
|
||||
|
||||
/// Host-created handle for appending approved durable [`SystemItem`] requests.
|
||||
///
|
||||
/// Hook code can use this handle only when the Pod host includes it in an
|
||||
/// event-specific context. The handle queues typed requests; the host drains the
|
||||
/// queue, commits each entry through `LogEntry::SystemItem`, and only then makes
|
||||
/// the matching system message visible to the model. It deliberately exposes no
|
||||
/// raw `llm_worker::Item`, history writer, event sender, `Pod`, `Worker`, or
|
||||
/// notification buffer.
|
||||
pub struct SystemItemAppendHandle {
|
||||
pending: Arc<Mutex<Vec<SystemItem>>>,
|
||||
}
|
||||
|
||||
impl SystemItemAppendHandle {
|
||||
pub(crate) fn new(pending: Arc<Mutex<Vec<SystemItem>>>) -> Self {
|
||||
Self { pending }
|
||||
}
|
||||
|
||||
/// Queue a task-inactivity reminder for durable model-visible append.
|
||||
///
|
||||
/// The body should be the unwrapped reminder text; the host-side
|
||||
/// `SystemReminder` renderer wraps it exactly once in `<system-reminder>`
|
||||
/// tags before commit.
|
||||
pub fn append_task_reminder(&self, body: impl Into<String>) {
|
||||
let item = SystemReminder::task_inactivity(body).into_system_item();
|
||||
self.pending
|
||||
.lock()
|
||||
.expect("system-item append queue poisoned")
|
||||
.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hook input summary/context types (read-only)
|
||||
// =============================================================================
|
||||
|
||||
/// Information passed to `OnPromptSubmit` hooks.
|
||||
|
|
@ -159,7 +200,7 @@ pub struct PromptSubmitInfo {
|
|||
pub turn_index: usize,
|
||||
}
|
||||
|
||||
/// Information passed to `PreLlmRequest` hooks.
|
||||
/// Summary information included in `PreLlmRequest` contexts.
|
||||
pub struct PreRequestInfo {
|
||||
/// Number of items currently in the Worker context.
|
||||
pub item_count: usize,
|
||||
|
|
@ -173,6 +214,41 @@ pub struct PreRequestInfo {
|
|||
pub tool_calls_this_turn: usize,
|
||||
}
|
||||
|
||||
/// Context passed to `PreLlmRequest` hooks.
|
||||
///
|
||||
/// The summary remains read-only. When the host grants durable system-item
|
||||
/// append authority for this request, `system_items()` exposes a typed append
|
||||
/// handle; otherwise it returns `None` and hooks cannot produce model-visible
|
||||
/// additions.
|
||||
pub struct PreRequestContext {
|
||||
info: PreRequestInfo,
|
||||
system_items: Option<SystemItemAppendHandle>,
|
||||
}
|
||||
|
||||
impl PreRequestContext {
|
||||
pub(crate) fn new(info: PreRequestInfo, system_items: Option<SystemItemAppendHandle>) -> Self {
|
||||
Self { info, system_items }
|
||||
}
|
||||
|
||||
/// Read-only request summary.
|
||||
pub fn info(&self) -> &PreRequestInfo {
|
||||
&self.info
|
||||
}
|
||||
|
||||
/// Host-provided durable system-item append handle, when available.
|
||||
pub fn system_items(&self) -> Option<&SystemItemAppendHandle> {
|
||||
self.system_items.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for PreRequestContext {
|
||||
type Target = PreRequestInfo;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.info
|
||||
}
|
||||
}
|
||||
|
||||
/// Information passed to `PreToolCall` hooks.
|
||||
pub struct ToolCallSummary {
|
||||
/// Provider-assigned tool call id.
|
||||
|
|
@ -252,7 +328,7 @@ impl HookEventKind for OnPromptSubmit {
|
|||
}
|
||||
|
||||
impl HookEventKind for PreLlmRequest {
|
||||
type Input = PreRequestInfo;
|
||||
type Input = PreRequestContext;
|
||||
type Output = HookPreRequestAction;
|
||||
}
|
||||
|
||||
|
|
@ -365,6 +441,39 @@ pub struct HookRegistry {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn system_item_append_handle_queues_only_approved_task_reminder_items() {
|
||||
let pending = Arc::new(Mutex::new(Vec::new()));
|
||||
let handle = SystemItemAppendHandle::new(Arc::clone(&pending));
|
||||
|
||||
handle.append_task_reminder("remember tasks");
|
||||
|
||||
let queued = pending.lock().expect("pending queue poisoned");
|
||||
assert_eq!(queued.len(), 1);
|
||||
match &queued[0] {
|
||||
SystemItem::TaskReminder { body, .. } => {
|
||||
assert_eq!(body.matches("<system-reminder>").count(), 1);
|
||||
assert!(body.contains("remember tasks"));
|
||||
}
|
||||
other => panic!("unexpected system item: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_request_context_exposes_handle_only_when_host_supplies_one() {
|
||||
let info = PreRequestInfo {
|
||||
item_count: 3,
|
||||
estimated_tokens: Some(42),
|
||||
turn_index: 1,
|
||||
tool_calls_this_turn: 2,
|
||||
};
|
||||
let context = PreRequestContext::new(info, None);
|
||||
|
||||
assert_eq!(context.item_count, 3);
|
||||
assert_eq!(context.info().estimated_tokens, Some(42));
|
||||
assert!(context.system_items().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_pre_tool_hook_actions_cannot_emit_internal_no_result_skip() {
|
||||
let continue_action = HookPreToolAction::Continue.into_worker_action("call_1".into());
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@
|
|||
//! notification injection / output truncation in the future) and the
|
||||
//! public `HookRegistry`. Internal mechanisms run first and have full
|
||||
//! mutable access via the `Interceptor` trait. Hooks then receive
|
||||
//! read-only summary information and only return control-flow
|
||||
//! event-specific read-only contexts and only return control-flow
|
||||
//! decisions (continue / skip / abort / pause).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
|
|
@ -23,13 +24,12 @@ use tracing::warn;
|
|||
|
||||
use crate::compact::state::CompactState;
|
||||
use crate::compact::usage_tracker::UsageTracker;
|
||||
use session_store::{SystemItem, SystemReminder};
|
||||
use tools::{TaskEntry, TaskStatus, TaskStore};
|
||||
use session_store::SystemItem;
|
||||
|
||||
use crate::hook::{
|
||||
AbortInfo, HookPostToolAction, HookPreRequestAction, HookPreToolAction, HookPromptAction,
|
||||
HookRegistry, HookTurnEndAction, PreRequestInfo, PromptSubmitInfo, ToolCallSummary,
|
||||
ToolResultSummary, TurnEndInfo,
|
||||
HookRegistry, HookTurnEndAction, PreRequestContext, PreRequestInfo, PromptSubmitInfo,
|
||||
SystemItemAppendHandle, ToolCallSummary, ToolResultSummary, TurnEndInfo,
|
||||
};
|
||||
use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item};
|
||||
use crate::pod::SystemItemCommitter;
|
||||
|
|
@ -39,53 +39,6 @@ use llm_worker::token_counter::total_tokens;
|
|||
/// Maximum number of bytes copied into `TurnEndInfo::final_text_preview`.
|
||||
const FINAL_TEXT_PREVIEW_LIMIT: usize = 512;
|
||||
|
||||
const TASK_REMINDER_REQUEST_THRESHOLD: usize = 24;
|
||||
const TASK_REMINDER_COOLDOWN_REQUESTS: usize = 24;
|
||||
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 {
|
||||
registry: Arc<HookRegistry>,
|
||||
compact_state: Option<Arc<CompactState>>,
|
||||
|
|
@ -109,10 +62,6 @@ pub(crate) struct PodInterceptor {
|
|||
/// `PromptAction::ContinueWith`. Populated by `Pod::run`
|
||||
/// immediately before handing off to the worker.
|
||||
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
|
||||
/// 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>,
|
||||
|
|
@ -135,8 +84,6 @@ impl PodInterceptor {
|
|||
usage_history: Option<Arc<Mutex<Vec<UsageRecord>>>>,
|
||||
pending_notifies: NotifyBuffer,
|
||||
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
|
||||
task_store: TaskStore,
|
||||
task_reminder_state: Arc<TaskReminderState>,
|
||||
prompts: Arc<PromptCatalog>,
|
||||
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
||||
) -> Self {
|
||||
|
|
@ -147,8 +94,6 @@ impl PodInterceptor {
|
|||
usage_tracker: None,
|
||||
pending_notifies,
|
||||
pending_attachments,
|
||||
task_store,
|
||||
task_reminder_state,
|
||||
prompts,
|
||||
log_writer,
|
||||
next_turn_index: AtomicUsize::new(0),
|
||||
|
|
@ -194,47 +139,28 @@ impl PodInterceptor {
|
|||
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",
|
||||
fn request_threshold_exceeded(&self, current_tokens: Option<u64>, context: &[Item]) -> bool {
|
||||
if let Some(state) = self.compact_state.as_ref() {
|
||||
if !state.is_disabled() && !state.just_compacted() {
|
||||
let current = current_tokens.unwrap_or(0);
|
||||
if state.exceeds_request(current) {
|
||||
let shape = context_shape(context);
|
||||
info!(
|
||||
input_tokens = current,
|
||||
threshold = state.request_threshold().unwrap_or(0),
|
||||
items_len = shape.items_len,
|
||||
items_json_bytes = shape.items_json_bytes,
|
||||
reasoning_items = shape.reasoning_items,
|
||||
reasoning_encrypted_content_count = shape.reasoning_encrypted_content_count,
|
||||
reasoning_encrypted_content_bytes = shape.reasoning_encrypted_content_bytes,
|
||||
"Between-requests compaction threshold exceeded, yielding"
|
||||
);
|
||||
for task in active_tasks {
|
||||
body.push_str(&format!(
|
||||
"- taskid {} ({}) {}\n",
|
||||
task.taskid, task.status, task.subject
|
||||
));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
body.trim_end_matches('\n').to_string()
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -275,13 +201,12 @@ impl Interceptor for PodInterceptor {
|
|||
|
||||
async fn pending_history_appends(&self) -> Vec<Item> {
|
||||
let drained = self.pending_notifies.drain();
|
||||
let task_reminder = self.task_reminder_system_item();
|
||||
if drained.is_empty() && task_reminder.is_none() {
|
||||
if drained.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut system_items: Vec<SystemItem> = Vec::with_capacity(drained.len() + 1);
|
||||
let mut items: Vec<Item> = Vec::with_capacity(drained.len() + 1);
|
||||
let mut system_items: Vec<SystemItem> = Vec::with_capacity(drained.len());
|
||||
let mut items: Vec<Item> = Vec::with_capacity(drained.len());
|
||||
for entry in drained {
|
||||
match build_system_item(&entry, &self.prompts) {
|
||||
Ok(system_item) => {
|
||||
|
|
@ -304,51 +229,69 @@ 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);
|
||||
items
|
||||
}
|
||||
|
||||
async fn pre_llm_request(&self, context: &mut Vec<Item>) -> PreRequestAction {
|
||||
let current_tokens = self.estimated_tokens(context);
|
||||
|
||||
// Internal mechanism: between-requests compaction trigger (safety net).
|
||||
if let Some(state) = self.compact_state.as_ref() {
|
||||
if !state.is_disabled() && !state.just_compacted() {
|
||||
let current = current_tokens.unwrap_or(0);
|
||||
if state.exceeds_request(current) {
|
||||
let shape = context_shape(context);
|
||||
info!(
|
||||
input_tokens = current,
|
||||
threshold = state.request_threshold().unwrap_or(0),
|
||||
items_len = shape.items_len,
|
||||
items_json_bytes = shape.items_json_bytes,
|
||||
reasoning_items = shape.reasoning_items,
|
||||
reasoning_encrypted_content_count = shape.reasoning_encrypted_content_count,
|
||||
reasoning_encrypted_content_bytes = shape.reasoning_encrypted_content_bytes,
|
||||
"Between-requests compaction threshold exceeded, yielding"
|
||||
);
|
||||
let initial_tokens = self.estimated_tokens(context);
|
||||
if self.request_threshold_exceeded(initial_tokens, context) {
|
||||
return PreRequestAction::Yield;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let info = PreRequestInfo {
|
||||
item_count: context.len(),
|
||||
estimated_tokens: current_tokens,
|
||||
estimated_tokens: initial_tokens,
|
||||
turn_index: self.current_turn_index(),
|
||||
tool_calls_this_turn: self.tool_calls_this_turn.load(Ordering::Relaxed),
|
||||
};
|
||||
let pending_hook_system_items = Arc::new(Mutex::new(Vec::new()));
|
||||
let system_item_sink = self
|
||||
.log_writer
|
||||
.as_ref()
|
||||
.map(|_| SystemItemAppendHandle::new(Arc::clone(&pending_hook_system_items)));
|
||||
let hook_context = PreRequestContext::new(info, system_item_sink);
|
||||
for hook in &self.registry.pre_llm_request {
|
||||
let action = hook.call(&info).await;
|
||||
let action = hook.call(&hook_context).await;
|
||||
if !matches!(action, HookPreRequestAction::Continue) {
|
||||
return action.into();
|
||||
}
|
||||
}
|
||||
PreRequestAction::Continue
|
||||
|
||||
let system_items: Vec<SystemItem> = std::mem::take(
|
||||
&mut *pending_hook_system_items
|
||||
.lock()
|
||||
.expect("pending hook system-item queue poisoned"),
|
||||
);
|
||||
let appended_items: Vec<Item> = system_items
|
||||
.iter()
|
||||
.map(SystemItem::to_history_item)
|
||||
.collect();
|
||||
let effective_context = if appended_items.is_empty() {
|
||||
Cow::Borrowed(context.as_slice())
|
||||
} else {
|
||||
let mut effective = context.clone();
|
||||
effective.extend(appended_items.clone());
|
||||
Cow::Owned(effective)
|
||||
};
|
||||
let current_tokens = self.estimated_tokens(effective_context.as_ref());
|
||||
|
||||
if self.request_threshold_exceeded(current_tokens, effective_context.as_ref()) {
|
||||
self.commit_system_items(&system_items);
|
||||
return if appended_items.is_empty() {
|
||||
PreRequestAction::Yield
|
||||
} else {
|
||||
PreRequestAction::YieldWith(appended_items)
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(usage_tracker) = self.usage_tracker.as_ref() {
|
||||
usage_tracker.note_request(effective_context.len());
|
||||
}
|
||||
if system_items.is_empty() {
|
||||
return PreRequestAction::Continue;
|
||||
}
|
||||
self.commit_system_items(&system_items);
|
||||
PreRequestAction::ContinueWith(appended_items)
|
||||
}
|
||||
|
||||
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
|
||||
|
|
@ -363,9 +306,6 @@ impl Interceptor for PodInterceptor {
|
|||
return action.into_worker_action(summary.call_id.clone());
|
||||
}
|
||||
}
|
||||
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);
|
||||
PreToolAction::Continue
|
||||
}
|
||||
|
|
@ -481,17 +421,18 @@ mod tests {
|
|||
use std::sync::atomic::{AtomicBool, AtomicUsize};
|
||||
|
||||
use super::*;
|
||||
use crate::feature::FeatureRegistryBuilder;
|
||||
use crate::feature::builtin::TaskFeature;
|
||||
use crate::hook::{
|
||||
Hook, HookPostToolAction, HookPreRequestAction, HookPreToolAction, HookRegistryBuilder,
|
||||
HookTurnEndAction, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall,
|
||||
};
|
||||
use session_store::SystemReminderSource;
|
||||
|
||||
struct CountingHook(Arc<AtomicUsize>);
|
||||
|
||||
#[async_trait]
|
||||
impl Hook<PreLlmRequest> for CountingHook {
|
||||
async fn call(&self, _info: &PreRequestInfo) -> HookPreRequestAction {
|
||||
async fn call(&self, _info: &PreRequestContext) -> HookPreRequestAction {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
HookPreRequestAction::Continue
|
||||
}
|
||||
|
|
@ -503,25 +444,38 @@ mod tests {
|
|||
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,
|
||||
)
|
||||
struct RecordingSystemItemCommitter {
|
||||
committed: Arc<Mutex<Vec<SystemItem>>>,
|
||||
}
|
||||
|
||||
impl SystemItemCommitter for RecordingSystemItemCommitter {
|
||||
fn commit_system_item(&self, item: SystemItem) {
|
||||
self.committed
|
||||
.lock()
|
||||
.expect("committed system-item list poisoned")
|
||||
.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
struct AppendingPreRequestHook {
|
||||
saw_handle: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hook<PreLlmRequest> for AppendingPreRequestHook {
|
||||
async fn call(&self, input: &PreRequestContext) -> HookPreRequestAction {
|
||||
if let Some(system_items) = input.system_items() {
|
||||
self.saw_handle.store(true, Ordering::Relaxed);
|
||||
system_items.append_task_reminder("hook reminder");
|
||||
}
|
||||
HookPreRequestAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
fn task_tool_call_info(name: &str, input: serde_json::Value) -> ToolCallInfo {
|
||||
let def = tools::task_tools(TaskStore::new())
|
||||
let def = crate::feature::builtin::task::task_tools(
|
||||
crate::feature::builtin::task::TaskStore::new(),
|
||||
)
|
||||
.into_iter()
|
||||
.find(|def| {
|
||||
let (meta, _) = def();
|
||||
|
|
@ -540,12 +494,6 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
async fn call_pre_tool(interceptor: &PodInterceptor, name: &str) {
|
||||
let mut info = task_tool_call_info(name, serde_json::json!({}));
|
||||
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
|
||||
/// current `context_len` so that `total_tokens` returns exactly
|
||||
/// `tokens` (Measured, no interpolation or byte-based fallback).
|
||||
|
|
@ -574,8 +522,6 @@ mod tests {
|
|||
Some(history),
|
||||
NotifyBuffer::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
TaskStore::new(),
|
||||
Arc::new(TaskReminderState::new()),
|
||||
PromptCatalog::builtins_only().unwrap(),
|
||||
None,
|
||||
);
|
||||
|
|
@ -587,6 +533,41 @@ mod tests {
|
|||
assert_eq!(count.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_llm_request_yields_with_hook_appends_when_post_append_threshold_exceeded() {
|
||||
let saw_handle = Arc::new(AtomicBool::new(false));
|
||||
let mut builder = HookRegistryBuilder::new();
|
||||
builder.add_pre_llm_request(AppendingPreRequestHook {
|
||||
saw_handle: Arc::clone(&saw_handle),
|
||||
});
|
||||
let registry = Arc::new(builder.build());
|
||||
let state = Arc::new(CompactState::new(None, Some(50), 2));
|
||||
let ctx_items = vec![Item::user_message("hi")];
|
||||
let history = usage_handle_with(ctx_items.len(), 50);
|
||||
let committed = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let interceptor = PodInterceptor::new(
|
||||
registry,
|
||||
Some(state),
|
||||
Some(history),
|
||||
NotifyBuffer::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
PromptCatalog::builtins_only().unwrap(),
|
||||
Some(Arc::new(RecordingSystemItemCommitter {
|
||||
committed: Arc::clone(&committed),
|
||||
})),
|
||||
);
|
||||
let mut ctx = ctx_items;
|
||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||
|
||||
match action {
|
||||
PreRequestAction::YieldWith(items) => assert_eq!(items.len(), 1),
|
||||
other => panic!("expected YieldWith queued system item, got {other:?}"),
|
||||
}
|
||||
assert!(saw_handle.load(Ordering::Relaxed));
|
||||
assert_eq!(committed.lock().expect("committed system items").len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_llm_request_counts_in_flight_usage_records() {
|
||||
let registry = Arc::new(HookRegistryBuilder::new().build());
|
||||
|
|
@ -609,8 +590,6 @@ mod tests {
|
|||
Some(history),
|
||||
NotifyBuffer::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
TaskStore::new(),
|
||||
Arc::new(TaskReminderState::new()),
|
||||
PromptCatalog::builtins_only().unwrap(),
|
||||
None,
|
||||
)
|
||||
|
|
@ -636,8 +615,6 @@ mod tests {
|
|||
Some(history),
|
||||
NotifyBuffer::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
TaskStore::new(),
|
||||
Arc::new(TaskReminderState::new()),
|
||||
PromptCatalog::builtins_only().unwrap(),
|
||||
None,
|
||||
);
|
||||
|
|
@ -679,8 +656,6 @@ mod tests {
|
|||
Some(history),
|
||||
NotifyBuffer::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
TaskStore::new(),
|
||||
Arc::new(TaskReminderState::new()),
|
||||
PromptCatalog::builtins_only().unwrap(),
|
||||
None,
|
||||
);
|
||||
|
|
@ -708,8 +683,6 @@ mod tests {
|
|||
Some(history),
|
||||
NotifyBuffer::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
TaskStore::new(),
|
||||
Arc::new(TaskReminderState::new()),
|
||||
PromptCatalog::builtins_only().unwrap(),
|
||||
None,
|
||||
);
|
||||
|
|
@ -731,8 +704,6 @@ mod tests {
|
|||
None,
|
||||
NotifyBuffer::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
TaskStore::new(),
|
||||
Arc::new(TaskReminderState::new()),
|
||||
PromptCatalog::builtins_only().unwrap(),
|
||||
None,
|
||||
);
|
||||
|
|
@ -743,11 +714,87 @@ mod tests {
|
|||
assert_eq!(count.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_llm_request_commits_hook_system_items_before_continue_with() {
|
||||
let saw_handle = Arc::new(AtomicBool::new(false));
|
||||
let mut builder = HookRegistryBuilder::new();
|
||||
builder.add_pre_llm_request(AppendingPreRequestHook {
|
||||
saw_handle: Arc::clone(&saw_handle),
|
||||
});
|
||||
let registry = Arc::new(builder.build());
|
||||
let committed = Arc::new(Mutex::new(Vec::new()));
|
||||
let committer = Arc::new(RecordingSystemItemCommitter {
|
||||
committed: Arc::clone(&committed),
|
||||
});
|
||||
let interceptor = PodInterceptor::new(
|
||||
registry,
|
||||
None,
|
||||
None,
|
||||
NotifyBuffer::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
PromptCatalog::builtins_only().unwrap(),
|
||||
Some(committer),
|
||||
);
|
||||
|
||||
let mut ctx: Vec<Item> = Vec::new();
|
||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||
|
||||
assert!(saw_handle.load(Ordering::Relaxed));
|
||||
let PreRequestAction::ContinueWith(items) = action else {
|
||||
panic!("expected ContinueWith for committed hook system item");
|
||||
};
|
||||
assert_eq!(items.len(), 1);
|
||||
assert!(matches!(
|
||||
&items[0],
|
||||
Item::Message {
|
||||
role: llm_worker::Role::System,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(
|
||||
extract_message_text(&items[0])
|
||||
.expect("system message text")
|
||||
.contains("hook reminder")
|
||||
);
|
||||
let committed = committed
|
||||
.lock()
|
||||
.expect("committed system-item list poisoned");
|
||||
assert_eq!(committed.len(), 1);
|
||||
match &committed[0] {
|
||||
SystemItem::TaskReminder { body, .. } => assert!(body.contains("hook reminder")),
|
||||
other => panic!("unexpected committed system item: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_llm_request_without_log_writer_does_not_expose_system_item_handle() {
|
||||
let saw_handle = Arc::new(AtomicBool::new(false));
|
||||
let mut builder = HookRegistryBuilder::new();
|
||||
builder.add_pre_llm_request(AppendingPreRequestHook {
|
||||
saw_handle: Arc::clone(&saw_handle),
|
||||
});
|
||||
let interceptor = PodInterceptor::new(
|
||||
Arc::new(builder.build()),
|
||||
None,
|
||||
None,
|
||||
NotifyBuffer::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
PromptCatalog::builtins_only().unwrap(),
|
||||
None,
|
||||
);
|
||||
|
||||
let mut ctx: Vec<Item> = Vec::new();
|
||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||
|
||||
assert!(!saw_handle.load(Ordering::Relaxed));
|
||||
assert!(matches!(action, PreRequestAction::Continue));
|
||||
}
|
||||
|
||||
struct AbortingHook(Arc<AtomicBool>);
|
||||
|
||||
#[async_trait]
|
||||
impl Hook<PreLlmRequest> for AbortingHook {
|
||||
async fn call(&self, _info: &PreRequestInfo) -> HookPreRequestAction {
|
||||
async fn call(&self, _info: &PreRequestContext) -> HookPreRequestAction {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
HookPreRequestAction::Cancel("nope".into())
|
||||
}
|
||||
|
|
@ -789,8 +836,6 @@ mod tests {
|
|||
None,
|
||||
NotifyBuffer::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
TaskStore::new(),
|
||||
Arc::new(TaskReminderState::new()),
|
||||
PromptCatalog::builtins_only().unwrap(),
|
||||
None,
|
||||
);
|
||||
|
|
@ -838,8 +883,6 @@ mod tests {
|
|||
None,
|
||||
NotifyBuffer::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
TaskStore::new(),
|
||||
Arc::new(TaskReminderState::new()),
|
||||
PromptCatalog::builtins_only().unwrap(),
|
||||
None,
|
||||
);
|
||||
|
|
@ -888,8 +931,6 @@ mod tests {
|
|||
None,
|
||||
NotifyBuffer::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
TaskStore::new(),
|
||||
Arc::new(TaskReminderState::new()),
|
||||
PromptCatalog::builtins_only().unwrap(),
|
||||
None,
|
||||
);
|
||||
|
|
@ -901,6 +942,75 @@ mod tests {
|
|||
assert_eq!(count.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_reminder_hook_append_is_counted_in_usage_request_len() {
|
||||
let feature = TaskFeature::from_history(&[Item::tool_call(
|
||||
"task-create-call",
|
||||
"TaskCreate",
|
||||
r#"{"subject":"track active work","description":"exercise reminder path"}"#,
|
||||
)]);
|
||||
let mut hook_builder = HookRegistryBuilder::new();
|
||||
let mut pending_tools = Vec::new();
|
||||
FeatureRegistryBuilder::new()
|
||||
.with_module(feature)
|
||||
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
||||
let registry = Arc::new(hook_builder.build());
|
||||
let usage_tracker = Arc::new(UsageTracker::new());
|
||||
let committed = Arc::new(Mutex::new(Vec::new()));
|
||||
let interceptor = PodInterceptor::new(
|
||||
registry,
|
||||
None,
|
||||
None,
|
||||
NotifyBuffer::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
PromptCatalog::builtins_only().unwrap(),
|
||||
Some(Arc::new(RecordingSystemItemCommitter {
|
||||
committed: Arc::clone(&committed),
|
||||
})),
|
||||
)
|
||||
.with_usage_tracker(Arc::clone(&usage_tracker));
|
||||
|
||||
let ctx_items = vec![Item::user_message("hi")];
|
||||
for _ in 0..23 {
|
||||
let mut ctx = ctx_items.clone();
|
||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||
assert!(matches!(action, PreRequestAction::Continue));
|
||||
usage_tracker.record_usage(&llm_worker::event::UsageEvent {
|
||||
input_tokens: Some(10),
|
||||
output_tokens: Some(0),
|
||||
total_tokens: Some(10),
|
||||
cache_read_input_tokens: Some(0),
|
||||
cache_creation_input_tokens: Some(0),
|
||||
});
|
||||
}
|
||||
|
||||
let mut ctx = ctx_items.clone();
|
||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||
let appended_len = match action {
|
||||
PreRequestAction::ContinueWith(items) => items.len(),
|
||||
other => panic!("expected reminder append, got {other:?}"),
|
||||
};
|
||||
assert_eq!(appended_len, 1);
|
||||
usage_tracker.record_usage(&llm_worker::event::UsageEvent {
|
||||
input_tokens: Some(11),
|
||||
output_tokens: Some(0),
|
||||
total_tokens: Some(11),
|
||||
cache_read_input_tokens: Some(0),
|
||||
cache_creation_input_tokens: Some(0),
|
||||
});
|
||||
|
||||
let records = usage_tracker.records();
|
||||
assert_eq!(records.last().expect("usage record").history_len, 2);
|
||||
let committed = committed
|
||||
.lock()
|
||||
.expect("committed system-item list poisoned");
|
||||
assert_eq!(committed.len(), 1);
|
||||
let SystemItem::TaskReminder { body, .. } = &committed[0] else {
|
||||
panic!("expected task reminder, got {:?}", committed[0]);
|
||||
};
|
||||
assert!(body.contains("track active work"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_history_appends_drains_buffer_into_items() {
|
||||
let registry = Arc::new(HookRegistryBuilder::new().build());
|
||||
|
|
@ -914,8 +1024,6 @@ mod tests {
|
|||
None,
|
||||
buffer.clone(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
TaskStore::new(),
|
||||
Arc::new(TaskReminderState::new()),
|
||||
PromptCatalog::builtins_only().unwrap(),
|
||||
None,
|
||||
);
|
||||
|
|
@ -938,207 +1046,6 @@ mod tests {
|
|||
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]
|
||||
async fn pre_llm_request_does_not_touch_pending_notifies() {
|
||||
// The drain lane has moved to `pending_history_appends`;
|
||||
|
|
@ -1154,8 +1061,6 @@ mod tests {
|
|||
None,
|
||||
buffer.clone(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
TaskStore::new(),
|
||||
Arc::new(TaskReminderState::new()),
|
||||
PromptCatalog::builtins_only().unwrap(),
|
||||
None,
|
||||
);
|
||||
|
|
@ -1186,8 +1091,6 @@ mod tests {
|
|||
None,
|
||||
NotifyBuffer::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
TaskStore::new(),
|
||||
Arc::new(TaskReminderState::new()),
|
||||
PromptCatalog::builtins_only().unwrap(),
|
||||
None,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ pub mod compact;
|
|||
pub mod controller;
|
||||
pub mod discovery;
|
||||
pub mod entrypoint;
|
||||
pub mod feature;
|
||||
pub mod fs_view;
|
||||
pub mod hook;
|
||||
pub mod ipc;
|
||||
|
|
|
|||
|
|
@ -28,12 +28,14 @@ use manifest::{
|
|||
|
||||
use crate::compact::state::CompactState;
|
||||
use crate::compact::usage_tracker::UsageTracker;
|
||||
use crate::feature::builtin::TaskFeature;
|
||||
use crate::feature::{FeatureRegistryBuilder, FeatureRegistryInstallReport};
|
||||
use crate::hook::{
|
||||
Hook, HookPreRequestAction, HookRegistryBuilder, OnAbort, OnPromptSubmit, OnTurnEnd,
|
||||
PostToolCall, PreLlmRequest, PreRequestInfo, PreToolCall,
|
||||
Hook, HookRegistryBuilder, OnAbort, OnPromptSubmit, OnTurnEnd, PostToolCall, PreLlmRequest,
|
||||
PreToolCall,
|
||||
};
|
||||
use crate::ipc::alerter::Alerter;
|
||||
use crate::ipc::interceptor::{PodInterceptor, TaskReminderState};
|
||||
use crate::ipc::interceptor::PodInterceptor;
|
||||
use crate::ipc::notify_buffer::NotifyBuffer;
|
||||
use crate::prompt::agents_md::read_agents_md;
|
||||
use crate::prompt::catalog::{CatalogError, PromptCatalog};
|
||||
|
|
@ -42,6 +44,7 @@ use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPrompt
|
|||
use crate::runtime::dir;
|
||||
use crate::runtime::pod_registry::{self, ScopeAllocationGuard, ScopeLockError};
|
||||
use crate::workflow::WorkflowResolveError;
|
||||
#[cfg(test)]
|
||||
use async_trait::async_trait;
|
||||
use protocol::{
|
||||
AlertLevel, AlertSource, Event, RewindSummary, RewindTarget, RewindTargetId, Segment,
|
||||
|
|
@ -211,21 +214,6 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
/// Pre-LLM-request hook that records `history.len()` at send time into a
|
||||
/// shared `UsageTracker`. The on_usage callback later pairs this with the
|
||||
/// aggregated UsageEvent to produce one `UsageRecord` per LLM call.
|
||||
struct UsageTrackingHook {
|
||||
tracker: Arc<UsageTracker>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hook<PreLlmRequest> for UsageTrackingHook {
|
||||
async fn call(&self, info: &PreRequestInfo) -> HookPreRequestAction {
|
||||
self.tracker.note_request(info.item_count);
|
||||
HookPreRequestAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
/// An independent agent execution unit.
|
||||
///
|
||||
/// Holds a [`Worker`] directly and persists session state via
|
||||
|
|
@ -276,15 +264,10 @@ pub struct Pod<C: LlmClient, St: Store> {
|
|||
/// tools so that Pod-owned operations (e.g. compaction) can consult
|
||||
/// the recency of touched files.
|
||||
tracker: Option<tools::Tracker>,
|
||||
/// Pod-lifetime task store from the builtin `tools` crate. Shared by
|
||||
/// TaskCreate / TaskUpdate / TaskList / TaskGet and preserved across
|
||||
/// compaction by keeping the same handle while the Worker history is
|
||||
/// replaced. Restored Pods reconstruct it by replaying Task* tool calls.
|
||||
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>,
|
||||
/// Built-in Task feature state shared by Task tools, reminder hooks, and
|
||||
/// the narrow snapshot/restore surface Pod needs for compaction and rewind.
|
||||
/// Store/reminder ownership stays inside the Task feature module.
|
||||
task_feature: TaskFeature,
|
||||
/// Parsed system-prompt template awaiting first-turn materialisation.
|
||||
/// `Some` until `ensure_system_prompt_materialized` renders it once,
|
||||
/// then `None` forever — including after compaction.
|
||||
|
|
@ -439,8 +422,7 @@ impl<C: LlmClient + Clone + 'static, St: Store + Clone + 'static> Pod<C, St> {
|
|||
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
|
||||
usage_history: self.usage_history.clone(),
|
||||
tracker: None,
|
||||
task_store: self.task_store.clone(),
|
||||
task_reminder_state: self.task_reminder_state.clone(),
|
||||
task_feature: self.task_feature.clone(),
|
||||
system_prompt_template: None,
|
||||
alerter: self.alerter.clone(),
|
||||
event_tx: self.event_tx.clone(),
|
||||
|
|
@ -618,8 +600,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
|
||||
usage_history: Arc::new(Mutex::new(Vec::<UsageRecord>::new())),
|
||||
tracker: None,
|
||||
task_store: tools::TaskStore::new(),
|
||||
task_reminder_state: Arc::new(TaskReminderState::new()),
|
||||
task_feature: TaskFeature::new(),
|
||||
system_prompt_template: None,
|
||||
alerter: None,
|
||||
event_tx: None,
|
||||
|
|
@ -784,6 +765,15 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
self.worker.as_mut().expect("worker taken during run")
|
||||
}
|
||||
|
||||
/// Install enabled feature modules into the Pod host surfaces.
|
||||
pub fn install_features(
|
||||
&mut self,
|
||||
registry: FeatureRegistryBuilder,
|
||||
) -> FeatureRegistryInstallReport {
|
||||
let worker = self.worker.as_mut().expect("worker taken during run");
|
||||
registry.install_into_worker(worker, &mut self.hook_builder)
|
||||
}
|
||||
|
||||
/// Reference to the store.
|
||||
pub fn store(&self) -> &St {
|
||||
&self.store
|
||||
|
|
@ -832,7 +822,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
let tool_side_effect_warning = suffix_has_tool_side_effects(&entries[truncate_entries..]);
|
||||
let state = segment_log::collect_state(&retained);
|
||||
let extract_pointer = memory::extract::fold_pointer(&state.extensions);
|
||||
let task_store = tools::TaskStore::from_history(&state.history);
|
||||
let summary = RewindSummary {
|
||||
truncated_to_entries: truncate_entries,
|
||||
discarded_entries: entries.len().saturating_sub(truncate_entries),
|
||||
|
|
@ -844,6 +833,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
self.segment_state.set_entries_written(truncate_entries);
|
||||
self.sink.truncate_silent(truncate_entries);
|
||||
|
||||
self.task_feature.restore_from_history(&state.history);
|
||||
self.worker_mut().set_history(state.history);
|
||||
self.worker_mut().set_request_config(state.config);
|
||||
self.worker_mut().set_turn_count(state.turn_count);
|
||||
|
|
@ -859,7 +849,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
.extract_pointer
|
||||
.lock()
|
||||
.expect("extract_pointer poisoned") = extract_pointer;
|
||||
self.task_store = task_store;
|
||||
|
||||
Ok(RewindAppliedState {
|
||||
entries: retained,
|
||||
|
|
@ -1003,16 +992,9 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
self.tracker = Some(tracker);
|
||||
}
|
||||
|
||||
/// Attach the session-scoped TaskStore from the builtin `tools` crate.
|
||||
/// Called by the Controller before registering builtin tools so the Pod
|
||||
/// and Worker share one store.
|
||||
pub fn attach_task_store(&mut self, task_store: tools::TaskStore) {
|
||||
self.task_store = task_store;
|
||||
}
|
||||
|
||||
/// Shared TaskStore handle.
|
||||
pub fn task_store(&self) -> tools::TaskStore {
|
||||
self.task_store.clone()
|
||||
/// Built-in Task feature module and snapshot/restore facade.
|
||||
pub(crate) fn task_feature(&self) -> TaskFeature {
|
||||
self.task_feature.clone()
|
||||
}
|
||||
|
||||
/// The attached session-scoped file-operation tracker, if any.
|
||||
|
|
@ -1171,13 +1153,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
/// occupancy through the `UsageRecord` timeline.
|
||||
fn ensure_interceptor_installed(&mut self) {
|
||||
if !self.interceptor_installed {
|
||||
// Pre-LLM-request hook: record the item count at send time
|
||||
// so the on_usage callback can pair it with the measured
|
||||
// input_tokens.
|
||||
self.hook_builder.add_pre_llm_request(UsageTrackingHook {
|
||||
tracker: self.usage_tracker.clone(),
|
||||
});
|
||||
|
||||
let builder = std::mem::take(&mut self.hook_builder);
|
||||
let registry = Arc::new(builder.build());
|
||||
|
||||
|
|
@ -1223,8 +1198,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
usage_history_handle,
|
||||
self.pending_notifies.clone(),
|
||||
self.pending_attachments.clone(),
|
||||
self.task_store.clone(),
|
||||
self.task_reminder_state.clone(),
|
||||
self.prompts.clone(),
|
||||
self.log_writer.clone(),
|
||||
)
|
||||
|
|
@ -2415,7 +2388,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
// Input text fed to the compact worker. Includes the default
|
||||
// references, current TaskStore snapshot, and the (pruned)
|
||||
// conversation text.
|
||||
let task_snapshot_text = self.task_store.snapshot_text();
|
||||
let task_snapshot_text = self.task_feature.snapshot_text();
|
||||
let summary_input = build_summary_input(
|
||||
&items_to_summarise,
|
||||
&default_refs,
|
||||
|
|
@ -2632,7 +2605,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
new_history.push(Item::tool_call("compact-tasklist", "TaskList", "{}"));
|
||||
new_history.push(Item::tool_result_with_content(
|
||||
"compact-tasklist",
|
||||
tools::task::snapshot_overview(&self.task_store.list()),
|
||||
self.task_feature.snapshot_overview(),
|
||||
task_snapshot_text.clone(),
|
||||
));
|
||||
let result_estimate = llm_worker::token_counter::total_tokens(&new_history, &[]);
|
||||
|
|
@ -3758,8 +3731,7 @@ where
|
|||
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
|
||||
usage_history: Arc::new(Mutex::new(Vec::new())),
|
||||
tracker: None,
|
||||
task_store: tools::TaskStore::new(),
|
||||
task_reminder_state: Arc::new(TaskReminderState::new()),
|
||||
task_feature: TaskFeature::new(),
|
||||
system_prompt_template: common.system_prompt_template,
|
||||
alerter: None,
|
||||
event_tx: None,
|
||||
|
|
@ -3837,8 +3809,7 @@ where
|
|||
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
|
||||
usage_history: Arc::new(Mutex::new(Vec::new())),
|
||||
tracker: None,
|
||||
task_store: tools::TaskStore::new(),
|
||||
task_reminder_state: Arc::new(TaskReminderState::new()),
|
||||
task_feature: TaskFeature::new(),
|
||||
system_prompt_template: common.system_prompt_template,
|
||||
alerter: None,
|
||||
event_tx: None,
|
||||
|
|
@ -3997,7 +3968,7 @@ where
|
|||
}
|
||||
|
||||
let extract_pointer = memory::extract::fold_pointer(&state.extensions);
|
||||
let task_store = tools::TaskStore::from_history(&state.history);
|
||||
let task_feature = TaskFeature::from_history(&state.history);
|
||||
let pod_metadata_writer = Some(pod_metadata_writer_for_store(&store));
|
||||
|
||||
let mut pod = Self {
|
||||
|
|
@ -4015,8 +3986,7 @@ where
|
|||
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
|
||||
usage_history: Arc::new(Mutex::new(state.usage_history)),
|
||||
tracker: None,
|
||||
task_store,
|
||||
task_reminder_state: Arc::new(TaskReminderState::new()),
|
||||
task_feature,
|
||||
// Restore replays the saved system_prompt verbatim — no
|
||||
// template re-render on resume.
|
||||
system_prompt_template: None,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
//! Recreated fresh on each Pod start (including resume).
|
||||
//!
|
||||
//! The Pod layer owns both instances and passes them to
|
||||
//! [`builtin_tools`] when registering tools on a `Worker`.
|
||||
//! [`core_builtin_tools`] when registering tools on a `Worker`.
|
||||
//!
|
||||
//! `Bash` is the lone exception — its child processes bypass `ScopedFs`
|
||||
//! entirely. Safety for arbitrary command execution is delegated to the
|
||||
|
|
@ -20,7 +20,6 @@
|
|||
|
||||
pub mod error;
|
||||
pub mod scoped_fs;
|
||||
pub mod task;
|
||||
pub mod tracker;
|
||||
|
||||
mod bash;
|
||||
|
|
@ -38,13 +37,13 @@ pub use glob::glob_tool;
|
|||
pub use grep::grep_tool;
|
||||
pub use read::read_tool;
|
||||
pub use scoped_fs::ScopedFs;
|
||||
pub use task::{TaskEntry, TaskSnapshot, TaskStatus, TaskStore, task_tools};
|
||||
pub use tracker::Tracker;
|
||||
pub use web::{web_fetch_tool, web_search_tool};
|
||||
pub use write::write_tool;
|
||||
|
||||
/// Register all builtin tools, wiring them to a shared `ScopedFs`
|
||||
/// (Pod-process lifetime) and `Tracker` (Pod-process lifetime).
|
||||
/// Register core builtin tools that do not require Pod-local task state,
|
||||
/// wiring them to a shared `ScopedFs` (Pod-process lifetime) and `Tracker`
|
||||
/// (Pod-process lifetime).
|
||||
///
|
||||
/// All returned factories share the same tracker instance so that
|
||||
/// `Read` / `Write` / `Edit` see a consistent history across tool
|
||||
|
|
@ -54,14 +53,13 @@ pub use write::write_tool;
|
|||
/// caller is responsible for adding that path to the readable scope
|
||||
/// (see [`manifest::Scope::with_extra_read`]) so the agent can `Read`
|
||||
/// the saved files.
|
||||
pub fn builtin_tools(
|
||||
pub fn core_builtin_tools(
|
||||
fs: ScopedFs,
|
||||
tracker: Tracker,
|
||||
task_store: TaskStore,
|
||||
bash_output_dir: std::path::PathBuf,
|
||||
web_config: Option<manifest::WebConfig>,
|
||||
) -> Vec<llm_worker::tool::ToolDefinition> {
|
||||
let mut defs = vec![
|
||||
vec![
|
||||
read_tool(fs.clone(), tracker.clone()),
|
||||
write_tool(fs.clone(), tracker.clone()),
|
||||
edit_tool(fs.clone(), tracker),
|
||||
|
|
@ -70,7 +68,5 @@ pub fn builtin_tools(
|
|||
bash_tool(fs, bash_output_dir),
|
||||
web_search_tool(web::WebTools::new(web_config.clone())),
|
||||
web_fetch_tool(web::WebTools::new(web_config)),
|
||||
];
|
||||
defs.extend(task_tools(task_store));
|
||||
defs
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,13 +29,12 @@
|
|||
//! ```no_run
|
||||
//! # use std::path::PathBuf;
|
||||
//! # use manifest::Scope;
|
||||
//! # use tools::{ScopedFs, Tracker, builtin_tools};
|
||||
//! # use tools::{ScopedFs, Tracker, core_builtin_tools};
|
||||
//! let scope = Scope::writable("/workspace").unwrap();
|
||||
//! let fs = ScopedFs::new(scope, PathBuf::from("/workspace")); // pod lifetime
|
||||
//! let tracker = Tracker::new(); // session lifetime
|
||||
//! let bash_outputs = PathBuf::from("/run/yoi/bash-output");
|
||||
//! let task_store = tools::TaskStore::new();
|
||||
//! let defs = builtin_tools(fs, tracker, task_store, bash_outputs, None);
|
||||
//! let defs = core_builtin_tools(fs, tracker, bash_outputs, None);
|
||||
//! ```
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use llm_worker::tool::{Tool, ToolDefinition};
|
|||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tools::{ScopedFs, TaskStore, Tracker, builtin_tools};
|
||||
use tools::{ScopedFs, Tracker, core_builtin_tools};
|
||||
|
||||
struct Registry {
|
||||
entries: Vec<(llm_worker::tool::ToolMeta, Arc<dyn Tool>)>,
|
||||
|
|
@ -43,10 +43,9 @@ fn setup() -> (TempDir, TempDir, Registry) {
|
|||
let scope = Scope::from_config(&config).unwrap();
|
||||
let fs = ScopedFs::new(scope, dir.path().to_path_buf());
|
||||
let tracker = Tracker::new();
|
||||
let reg = Registry::new(builtin_tools(
|
||||
let reg = Registry::new(core_builtin_tools(
|
||||
fs,
|
||||
tracker,
|
||||
TaskStore::new(),
|
||||
spill.path().to_path_buf(),
|
||||
None,
|
||||
));
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! Cross-tool integration tests exercising `builtin_tools()` end-to-end.
|
||||
//! Cross-tool integration tests exercising `core_builtin_tools()` end-to-end.
|
||||
//!
|
||||
//! `ToolServerHandle::register_tool` / `flush_pending` are `pub(crate)` in
|
||||
//! llm-worker, so from here we exercise the factories directly — the same
|
||||
|
|
@ -11,7 +11,7 @@ use llm_worker::tool::{Tool, ToolDefinition, ToolMeta};
|
|||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tools::{ScopedFs, TaskStore, Tracker, builtin_tools};
|
||||
use tools::{ScopedFs, Tracker, core_builtin_tools};
|
||||
|
||||
fn scope_with_spill(workspace: &Path, spill: &Path) -> Scope {
|
||||
let base = Scope::writable(workspace).unwrap();
|
||||
|
|
@ -56,10 +56,9 @@ fn setup() -> (TempDir, TempDir, Registry) {
|
|||
let scope = scope_with_spill(dir.path(), spill.path());
|
||||
let fs = ScopedFs::new(scope, dir.path().to_path_buf());
|
||||
let tracker = Tracker::new();
|
||||
let reg = Registry::new(builtin_tools(
|
||||
let reg = Registry::new(core_builtin_tools(
|
||||
fs,
|
||||
tracker,
|
||||
TaskStore::new(),
|
||||
spill.path().to_path_buf(),
|
||||
None,
|
||||
));
|
||||
|
|
@ -79,7 +78,7 @@ async fn call_err(tool: &Arc<dyn Tool>, input: serde_json::Value) -> llm_worker:
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_tools_registers_full_set() {
|
||||
fn core_builtin_tools_registers_full_set() {
|
||||
let (_dir, _spill, reg) = setup();
|
||||
let mut names = reg.names();
|
||||
names.sort();
|
||||
|
|
@ -91,10 +90,6 @@ fn builtin_tools_registers_full_set() {
|
|||
"Glob",
|
||||
"Grep",
|
||||
"Read",
|
||||
"TaskCreate",
|
||||
"TaskGet",
|
||||
"TaskList",
|
||||
"TaskUpdate",
|
||||
"WebFetch",
|
||||
"WebSearch",
|
||||
"Write"
|
||||
|
|
@ -292,7 +287,7 @@ async fn edit_requires_read_across_tools() {
|
|||
#[tokio::test]
|
||||
async fn deterministic_tool_order_is_registration_order() {
|
||||
let (_dir, _spill, reg) = setup();
|
||||
// Registration order from builtin_tools(): Read, Write, Edit, Glob, Grep, Bash, WebSearch, WebFetch, TaskCreate, TaskList, TaskGet, TaskUpdate
|
||||
// Registration order from core_builtin_tools(): Read, Write, Edit, Glob, Grep, Bash, WebSearch, WebFetch
|
||||
let names: Vec<&str> = reg.entries.iter().map(|(m, _)| m.name.as_str()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
|
|
@ -305,10 +300,6 @@ async fn deterministic_tool_order_is_registration_order() {
|
|||
"Bash",
|
||||
"WebSearch",
|
||||
"WebFetch",
|
||||
"TaskCreate",
|
||||
"TaskList",
|
||||
"TaskGet",
|
||||
"TaskUpdate"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
@ -326,10 +317,6 @@ fn tool_names_match_reference_spec() {
|
|||
"Bash",
|
||||
"WebSearch",
|
||||
"WebFetch",
|
||||
"TaskCreate",
|
||||
"TaskList",
|
||||
"TaskGet",
|
||||
"TaskUpdate",
|
||||
] {
|
||||
assert!(
|
||||
reg.entries.iter().any(|(m, _)| m.name == expected),
|
||||
|
|
@ -346,10 +333,9 @@ async fn tracker_recent_files_tracks_read_write_edit() {
|
|||
let scope = scope_with_spill(dir.path(), spill.path());
|
||||
let fs = ScopedFs::new(scope, dir.path().to_path_buf());
|
||||
let tracker = Tracker::new();
|
||||
let reg = Registry::new(builtin_tools(
|
||||
let reg = Registry::new(core_builtin_tools(
|
||||
fs,
|
||||
tracker.clone(),
|
||||
TaskStore::new(),
|
||||
spill.path().to_path_buf(),
|
||||
None,
|
||||
));
|
||||
|
|
|
|||
|
|
@ -25,4 +25,3 @@ llm-worker.workspace = true
|
|||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
tools = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
//! In-TUI mirror of the session-lifetime task store.
|
||||
//!
|
||||
//! This deliberately does NOT depend on `tools::TaskStore`. The TUI is a
|
||||
//! presentation layer; pulling in `tools` would drag along `llm-worker`
|
||||
//! and the whole tool surface. Instead we mirror the small subset we
|
||||
//! This deliberately does NOT depend on the Pod TaskStore. The TUI is a
|
||||
//! presentation layer; pulling in `pod` would drag along the runtime
|
||||
//! feature surface. Instead we mirror the small subset we
|
||||
//! need:
|
||||
//!
|
||||
//! - `TaskEntry` / `TaskStatus`: shaped to round-trip with `tools`'s JSON
|
||||
//! - `TaskEntry` / `TaskStatus`: shaped to round-trip with Pod Task JSON
|
||||
//! serialization (`#[serde(rename_all = "lowercase")]` on the status,
|
||||
//! matching field names on the entry).
|
||||
//! - Just enough state machine to apply `TaskCreate` / `TaskUpdate`
|
||||
//! tool-call arguments and the `[Session TaskStore snapshot]` system
|
||||
//! message that compaction emits.
|
||||
//!
|
||||
//! The snapshot text format is owned by `tools::render_snapshot`. Since
|
||||
//! `tools` itself parses it back on resume, the shape is a stable
|
||||
//! contract.
|
||||
//! The snapshot text format is owned by the Pod Task feature. The TUI keeps
|
||||
//! local compatibility fixtures for the `[Session TaskStore snapshot]` system
|
||||
//! message shape emitted during compaction and restored on resume.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ impl TaskStore {
|
|||
|
||||
/// Apply a completed `TaskCreate` / `TaskUpdate` tool_call. Other
|
||||
/// tool names and unparseable JSON are silent no-ops, matching the
|
||||
/// resilience of `tools::TaskStore::replay_history`.
|
||||
/// resilience of the Pod TaskStore history replay.
|
||||
pub fn apply_tool_call(&mut self, name: &str, arguments: &str) {
|
||||
match name {
|
||||
"TaskCreate" => {
|
||||
|
|
@ -236,8 +236,8 @@ mod tests {
|
|||
assert_eq!(c.active(), 2);
|
||||
}
|
||||
|
||||
/// Snapshot text matches the wrapping `Pod::try_pre_run_compact` /
|
||||
/// `tools::render_snapshot` produce: header line, blank, overview
|
||||
/// Snapshot text matches the wrapping `Pod::try_pre_run_compact` and the
|
||||
/// Pod Task feature snapshot fixture shape: header line, blank, overview
|
||||
/// line, blank, fenced JSON, trailing prose.
|
||||
fn wrap_snapshot(json_body: &str, overview: &str) -> String {
|
||||
format!(
|
||||
|
|
@ -313,22 +313,15 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// Cross-crate contract tests. The TUI deliberately re-implements a
|
||||
/// stripped-down mirror of `tools::TaskStore` instead of depending on
|
||||
/// the real one (see `tickets/tui-task-display.md`). That decoupling
|
||||
/// means a format change on the tools side — a renamed field on
|
||||
/// `TaskEntry`, a different fence syntax in `render_snapshot`, a new
|
||||
/// JSON wrapper — would silently leave the TUI parsing nothing instead
|
||||
/// of failing loudly.
|
||||
///
|
||||
/// These tests pull `tools` in as a dev-dependency so the contract is
|
||||
/// exercised at CI time. If they fail, either the format genuinely
|
||||
/// changed (update both sides) or the TUI mirror has drifted (re-sync
|
||||
/// it).
|
||||
/// Snapshot format compatibility tests. The TUI deliberately re-implements a
|
||||
/// stripped-down TaskStore mirror instead of depending on the Pod Task feature;
|
||||
/// it only consumes task tool calls and `[Session TaskStore snapshot]` system
|
||||
/// messages. These fixtures encode the Pod-owned Task snapshot JSON/text shape
|
||||
/// so accidental TUI parser drift still fails locally without making `tui`
|
||||
/// depend on `pod` or `tools`.
|
||||
#[cfg(test)]
|
||||
mod cross_format_contract {
|
||||
mod snapshot_format_contract {
|
||||
use super::*;
|
||||
use tools::task::{TaskStatus as ToolsTaskStatus, TaskStore as ToolsTaskStore};
|
||||
|
||||
/// Mirrors the envelope `Pod::try_pre_run_compact` wraps the raw
|
||||
/// snapshot text in. Hand-rolled here so the test fails loudly if
|
||||
|
|
@ -341,16 +334,40 @@ mod cross_format_contract {
|
|||
)
|
||||
}
|
||||
|
||||
fn tools_status_label(s: ToolsTaskStatus) -> &'static str {
|
||||
match s {
|
||||
ToolsTaskStatus::Pending => "pending",
|
||||
ToolsTaskStatus::Inprogress => "inprogress",
|
||||
ToolsTaskStatus::Completed => "completed",
|
||||
ToolsTaskStatus::Deleted => "deleted",
|
||||
fn snapshot_fixture() -> &'static str {
|
||||
r#"TaskStore: 2 task(s) (pending: 0, inprogress: 1, completed: 1, deleted: 0)
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": [
|
||||
{
|
||||
"taskid": 1,
|
||||
"status": "inprogress",
|
||||
"subject": "first",
|
||||
"description": "first desc"
|
||||
},
|
||||
{
|
||||
"taskid": 2,
|
||||
"status": "completed",
|
||||
"subject": "second",
|
||||
"description": "second desc with\nnewline"
|
||||
}
|
||||
]
|
||||
}
|
||||
```"#
|
||||
}
|
||||
|
||||
fn tui_status_label(s: TaskStatus) -> &'static str {
|
||||
fn empty_snapshot_fixture() -> &'static str {
|
||||
r#"TaskStore: 0 task(s) (pending: 0, inprogress: 0, completed: 0, deleted: 0)
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": []
|
||||
}
|
||||
```"#
|
||||
}
|
||||
|
||||
fn status_label(s: TaskStatus) -> &'static str {
|
||||
match s {
|
||||
TaskStatus::Pending => "pending",
|
||||
TaskStatus::Inprogress => "inprogress",
|
||||
|
|
@ -360,61 +377,49 @@ mod cross_format_contract {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn tools_snapshot_text_round_trips_into_tui_store() {
|
||||
let upstream = ToolsTaskStore::new();
|
||||
upstream.create("first".into(), "first desc".into());
|
||||
upstream.create("second".into(), "second desc with\nnewline".into());
|
||||
upstream
|
||||
.update(1, Some(ToolsTaskStatus::Inprogress), None, None)
|
||||
.expect("update 1");
|
||||
upstream
|
||||
.update(2, Some(ToolsTaskStatus::Completed), None, None)
|
||||
.expect("update 2");
|
||||
|
||||
let envelope = wrap_pod_style(&upstream.snapshot_text());
|
||||
fn pod_snapshot_text_round_trips_into_tui_store() {
|
||||
let envelope = wrap_pod_style(snapshot_fixture());
|
||||
|
||||
let mut downstream = TaskStore::new();
|
||||
downstream.apply_system_message_text(&envelope);
|
||||
|
||||
let upstream_tasks = upstream.list();
|
||||
let downstream_tasks = downstream.tasks();
|
||||
assert_eq!(
|
||||
downstream_tasks.len(),
|
||||
upstream_tasks.len(),
|
||||
"TUI parsed wrong number of tasks — `tools::render_snapshot` shape may have shifted"
|
||||
);
|
||||
for (u, d) in upstream_tasks.iter().zip(downstream_tasks.iter()) {
|
||||
assert_eq!(d.taskid, u.taskid);
|
||||
assert_eq!(d.subject, u.subject);
|
||||
assert_eq!(d.description, u.description);
|
||||
assert_eq!(tui_status_label(d.status), tools_status_label(u.status));
|
||||
}
|
||||
let tasks = downstream.tasks();
|
||||
assert_eq!(tasks.len(), 2, "TUI parsed wrong number of tasks");
|
||||
assert_eq!(tasks[0].taskid, 1);
|
||||
assert_eq!(tasks[0].subject, "first");
|
||||
assert_eq!(tasks[0].description, "first desc");
|
||||
assert_eq!(status_label(tasks[0].status), "inprogress");
|
||||
assert_eq!(tasks[1].taskid, 2);
|
||||
assert_eq!(tasks[1].subject, "second");
|
||||
assert_eq!(tasks[1].description, "second desc with\nnewline");
|
||||
assert_eq!(status_label(tasks[1].status), "completed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_taskentry_field_shape_deserializes_into_tui_taskentry() {
|
||||
// A single `tools::TaskEntry` round-tripped through JSON. Field
|
||||
// renames like `taskid` → `task_id` or status case changes on
|
||||
// the tools side would surface here as a serde failure or a
|
||||
// wrong-status assertion.
|
||||
let upstream = ToolsTaskStore::new();
|
||||
let created = upstream.create("subj".into(), "desc".into());
|
||||
let json = serde_json::to_string(&created).expect("serialize tools::TaskEntry");
|
||||
fn taskentry_field_shape_deserializes_into_tui_taskentry() {
|
||||
// A single Pod TaskEntry as JSON. Field renames like `taskid` →
|
||||
// `task_id` or status case changes surface here as serde failures or
|
||||
// wrong-status assertions.
|
||||
let json = r#"{
|
||||
"taskid": 7,
|
||||
"status": "pending",
|
||||
"subject": "subj",
|
||||
"description": "desc"
|
||||
}"#;
|
||||
let parsed: TaskEntry =
|
||||
serde_json::from_str(&json).expect("deserialize into tui::task::TaskEntry");
|
||||
assert_eq!(parsed.taskid, created.taskid);
|
||||
assert_eq!(parsed.subject, created.subject);
|
||||
assert_eq!(parsed.description, created.description);
|
||||
assert_eq!(tui_status_label(parsed.status), "pending");
|
||||
serde_json::from_str(json).expect("deserialize into tui::task::TaskEntry");
|
||||
assert_eq!(parsed.taskid, 7);
|
||||
assert_eq!(parsed.subject, "subj");
|
||||
assert_eq!(parsed.description, "desc");
|
||||
assert_eq!(status_label(parsed.status), "pending");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_tools_store_snapshot_is_recognised_by_tui() {
|
||||
// Edge case: a freshly initialised TaskStore still produces a
|
||||
// valid snapshot envelope. The TUI must parse it as "zero
|
||||
// tasks", not silently fall through to no-op.
|
||||
let upstream = ToolsTaskStore::new();
|
||||
let envelope = wrap_pod_style(&upstream.snapshot_text());
|
||||
fn empty_pod_task_snapshot_is_recognised_by_tui() {
|
||||
// Edge case: a freshly initialised TaskStore still produces a valid
|
||||
// snapshot envelope. The TUI must parse it as "zero tasks", not
|
||||
// silently fall through to no-op.
|
||||
let envelope = wrap_pod_style(empty_snapshot_fixture());
|
||||
|
||||
// Seed the TUI store with stale state to confirm replacement.
|
||||
let mut downstream = TaskStore::new();
|
||||
|
|
|
|||
12
package.nix
12
package.nix
|
|
@ -40,13 +40,13 @@ rustPlatform.buildRustPackage rec {
|
|||
filter = sourceFilter;
|
||||
};
|
||||
|
||||
cargoHash = "sha256-f4/oOuPv4dUiwznX+popMjjDCXZQPBvqWRYmlJDyKkE=";
|
||||
cargoHash = "sha256-iickLtGGmqc0raCZp7giowKajAMLn5+jwtQ9c5hZmhA=";
|
||||
|
||||
depsExtraArgs = {
|
||||
# nixpkgs 25.11's fetchCargoVendor still uses crates.io's API
|
||||
# download endpoint in this environment, which returns 403 while the
|
||||
# immutable static CDN endpoint works. Keep this local package build on
|
||||
# static.crates.io until the upstream fetcher is fixed in our nixpkgs pin.
|
||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||
# which returns 403 in this environment while the immutable static CDN
|
||||
# endpoint works. Newer utilities already use static.crates.io, so patch
|
||||
# only when the legacy endpoint is still present.
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
|
|
@ -56,9 +56,11 @@ rustPlatform.buildRustPackage rec {
|
|||
|
||||
vendor_util="$(command -v fetch-cargo-vendor-util-v2 || command -v fetch-cargo-vendor-util)"
|
||||
cp "$vendor_util" ./fetch-cargo-vendor-util-static
|
||||
if grep -q 'https://crates.io/api/v1/crates/{pkg\["name"\]}/{pkg\["version"\]}/download' ./fetch-cargo-vendor-util-static; then
|
||||
substituteInPlace ./fetch-cargo-vendor-util-static \
|
||||
--replace-fail 'https://crates.io/api/v1/crates/{pkg["name"]}/{pkg["version"]}/download' \
|
||||
'https://static.crates.io/crates/{pkg["name"]}/{pkg["version"]}/download'
|
||||
fi
|
||||
./fetch-cargo-vendor-util-static create-vendor-staging ./Cargo.lock "$out"
|
||||
|
||||
runHook postBuild
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
# Delegation intent: plugin feature contribution registry implementation
|
||||
|
||||
## Intent
|
||||
|
||||
Implement the first behavior-preserving slice of `plugin-feature-contribution-registry`: add a Pod-side Feature/Plugin contribution boundary that can represent built-in and future external capabilities without creating ad hoc Pod insertion paths.
|
||||
|
||||
This implementation should establish the API skeleton and prove the installation path with at least one small built-in capability group. It should not attempt to implement external plugin loading, package distribution, WASM, MCP, WorkItem tools, or broad migration of all built-in tools.
|
||||
|
||||
## Scope for this implementation
|
||||
|
||||
Implement a focused Phase 1/2 slice:
|
||||
|
||||
1. Add `pod::feature` module structure and public types for:
|
||||
- `FeatureId`
|
||||
- `FeatureRuntimeKind`
|
||||
- `FeatureDescriptor`
|
||||
- `FeatureModule`
|
||||
- `FeatureInstallContext`
|
||||
- `FeatureInstallReport`
|
||||
- diagnostics / skipped contributions
|
||||
- capability request/grant data
|
||||
- tool contribution wrapper
|
||||
- safe hook contribution registrar shape, using the already-hardened `pod::hook` surface
|
||||
- background task declaration / contribution skeleton
|
||||
- service declaration / service requirement / service registry skeleton
|
||||
- notification/alert/diagnostic sink skeletons where needed by the install context
|
||||
2. Add a registry/builder/install path that can install enabled feature modules into existing host surfaces.
|
||||
- Tool contributions must end up in the normal Worker/ToolRegistry path.
|
||||
- Hook contributions must go through `HookRegistryBuilder` / safe `pod::hook` APIs.
|
||||
- BackgroundTask and Service APIs may be skeleton/diagnostic-only if full runtime lifecycle would be too large, but their descriptors and install reports must be represented.
|
||||
3. Migrate one small, low-risk built-in tool/capability group through the registry to prove behavior without changing model-visible behavior.
|
||||
- Preserve tool name/schema/permission behavior exactly.
|
||||
- Prefer a group with minimal state and no complicated runtime lifecycle.
|
||||
- If no suitable group is obvious after inspection, implement a no-op built-in diagnostic feature and explicitly explain why; but prefer a real existing built-in registration if feasible.
|
||||
4. Add focused tests for:
|
||||
- descriptor/capability/install report behavior
|
||||
- duplicate tool-name diagnostics/rejection
|
||||
- service requirement resolution basics: required missing -> skip/error diagnostic, optional missing -> degraded diagnostic if represented
|
||||
- installed built-in tool remains registered through the normal path
|
||||
- no direct public exposure of raw `Pod`, `Worker`, `ToolServerHandle`, `Interceptor`, raw history writer, raw event sender, or raw NotifyBuffer through `FeatureInstallContext`
|
||||
|
||||
## Required design constraints
|
||||
|
||||
Follow the current design records:
|
||||
|
||||
- `work-items/open/20260603-122317-plugin-feature-contribution-registry/item.md`
|
||||
- `work-items/open/20260603-122317-plugin-feature-contribution-registry/artifacts/pod-api-design.md`
|
||||
- `work-items/open/20260603-122317-plugin-feature-contribution-registry/artifacts/notification-background-task-revision.md`
|
||||
- `work-items/open/20260603-122317-plugin-feature-contribution-registry/artifacts/service-registry-revision.md`
|
||||
|
||||
Core requirements:
|
||||
|
||||
- Do not create a generic plugin event channel.
|
||||
- Do not implement custom UI/dialog payloads.
|
||||
- Model-visible notifications must use the existing durable Notify/SystemItem/Event::SystemItem concept; do not add hidden context injection.
|
||||
- `Event::Alert`-like output is only transient human-facing text.
|
||||
- BackgroundTask is a first-class contribution concept, but host-managed lifecycle may be staged if needed.
|
||||
- Services are host-mediated provider/consumer APIs; this is not a mandate to extract existing Memory or Pod management out of core.
|
||||
- Feature-to-feature dependency must go through service declarations/requirements and host resolution, not concrete module/private state dependencies.
|
||||
- Public feature API must not expose raw `llm_worker::Item` injection, raw internal interceptor actions, or arbitrary history/context mutation.
|
||||
- Public hooks must use the hardened `pod::hook` safe action surface already merged by `hook-public-surface-hardening`.
|
||||
- Feature capability grants do not replace manifest/tool permission checks.
|
||||
- Existing behavior must remain unchanged except for internal registration plumbing and diagnostics.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- External plugin discovery/loading.
|
||||
- Plugin package format, archives, signing, extraction cache, or distribution.
|
||||
- WASM runtime.
|
||||
- MCP implementation.
|
||||
- WorkItem tools/intake/orchestrator implementation.
|
||||
- Moving Memory or Pod management implementation out of core.
|
||||
- Hot reload / dynamic enable-disable.
|
||||
- Generic UI/event channel or dialog protocol.
|
||||
- Broad migration of all built-in tools in one pass.
|
||||
|
||||
## Suggested files to inspect
|
||||
|
||||
- `crates/pod/src/lib.rs`
|
||||
- `crates/pod/src/controller.rs`
|
||||
- `crates/pod/src/pod.rs`
|
||||
- `crates/pod/src/hook.rs`
|
||||
- `crates/pod/src/permission.rs`
|
||||
- `crates/pod/src/ipc/interceptor.rs`
|
||||
- `crates/llm-worker/src/tool.rs`
|
||||
- `crates/llm-worker/src/tool_server.rs`
|
||||
- `crates/tools/src/lib.rs`
|
||||
- Existing built-in tool registration sites under `crates/pod/src/**`
|
||||
|
||||
## Escalate if
|
||||
|
||||
- Implementing even one real built-in feature migration requires broad rewiring of Worker/Pod construction.
|
||||
- The service registry cannot be represented without committing to external-plugin ABI/proxy details.
|
||||
- BackgroundTask lifecycle requires major runtime architecture decisions beyond a skeleton/descriptor/install-report path.
|
||||
- A required design choice would change model-visible tool names, tool schemas, permission behavior, or history semantics.
|
||||
- You find that the current `pod::hook` hardening is insufficient for a safe feature registrar.
|
||||
|
||||
## Validation
|
||||
|
||||
Run at least:
|
||||
|
||||
- focused tests added/updated for `pod::feature`
|
||||
- `cargo test -p pod --lib`
|
||||
- `cargo test -p llm-worker --lib`
|
||||
- `cargo check --workspace --all-targets`
|
||||
- `cargo fmt --check`
|
||||
- `./tickets.sh doctor`
|
||||
- `git diff --check`
|
||||
|
||||
Run `nix build .#yoi` if feasible. If not, report why.
|
||||
|
||||
## Completion report
|
||||
|
||||
Report:
|
||||
|
||||
- worktree path / branch
|
||||
- commit hash
|
||||
- changed files
|
||||
- implemented feature API surface
|
||||
- which built-in capability group was migrated/proven through the registry
|
||||
- behavior-preservation notes
|
||||
- service/background-task support level: full runtime vs descriptor/skeleton
|
||||
- tests added/updated
|
||||
- validation results
|
||||
- unresolved risks / follow-up recommendations
|
||||
- whether ready for external review
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
# Decision: remove generic event channels and standardize BackgroundTask
|
||||
|
||||
The Plugin/Feature base Pod API should not expose a plugin-defined event-channel mechanism for arbitrary structured client/UI payloads.
|
||||
|
||||
Revised boundary:
|
||||
|
||||
- Model-visible notifications use the existing durable `Method::Notify` / `SystemItem::Notification` / `Event::SystemItem` path. If the model can see it, it must be committed to history and visible to users on replay/inspection.
|
||||
- `Event::Alert`-like output is a short transient human-facing alert only. It is not model-visible, not session history, and not a structured UI extension channel.
|
||||
- Diagnostics/status are host-defined operational records for install/runtime/capability/task reporting, not arbitrary plugin UI messages.
|
||||
- Dialogs/confirmations/custom UI are deferred. If needed later, they should be a separate host-defined interaction protocol, not a generic plugin event channel.
|
||||
- `BackgroundTaskContribution` is a first-class contribution kind. The host starts, tracks, cancels, and reports background tasks; feature modules must not spawn untracked async loops. Task output is limited to granted sinks/services: model notification, alert, diagnostics, and host-granted services.
|
||||
|
||||
This keeps the Plugin API centered on Tools, safe Hooks, host-managed BackgroundTasks, durable model-visible notifications, and bounded host-defined operational reporting.
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# Decision: narrow plugin permissions to host authorities
|
||||
|
||||
Plugin/Feature permission should mean user-approved host authority, not every contribution the feature declares.
|
||||
|
||||
Purpose:
|
||||
|
||||
- Explain to the user what dangerous host authority a plugin receives.
|
||||
- Ensure sandboxed/external plugin code is not given APIs or handles for unapproved actions.
|
||||
- Keep registry contribution integrity separate from authority grants.
|
||||
|
||||
Revised model:
|
||||
|
||||
- Contributions are descriptor/digest-locked declarations:
|
||||
- Tools
|
||||
- Hooks
|
||||
- BackgroundTasks
|
||||
- Service providers
|
||||
- Host authorities are user-approved sandbox/object-capability grants:
|
||||
- filesystem access
|
||||
- network access
|
||||
- secret refs
|
||||
- model-visible durable notification/history append
|
||||
- Pod management façade access where exposed
|
||||
- store/state access such as Memory/WorkItem or persistent plugin state where applicable
|
||||
|
||||
Tool/Hook/BackgroundTask/ServiceProvider declarations should be shown during install and locked by plugin descriptor/package digest. If they change, the plugin requires re-approval. They do not need separate `ContributeTool`, `ContributeHook`, `RunBackgroundTask`, or `ProvideService` authority variants.
|
||||
|
||||
Service consumption is also not a blanket authority by itself. A service requirement is resolved by the host; the authority question depends on what host authority the service handle exposes. Service handles should be narrowed or authority-bound so acquiring one broad service handle cannot become an authority escalation path.
|
||||
|
||||
Tool execution remains governed by the existing per-call tool permission / PreToolCall path. Feature authority grants do not replace manifest/tool permissions.
|
||||
|
|
@ -1,134 +1,8 @@
|
|||
<!-- event: create author: tickets.sh at: 2026-06-03T12:23:17Z -->
|
||||
|
||||
## Created
|
||||
|
||||
Created by tickets.sh create.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: plan author: hare at: 2026-06-03T16:38:46Z -->
|
||||
|
||||
## Plan
|
||||
|
||||
# Delegation intent: Plugin base Pod API design
|
||||
|
||||
## Intent
|
||||
|
||||
Design the public Pod-side API that will serve as the base for Plugin / Feature contributions. The result should make Plugin-provided or built-in extension modules easy to register cleanly without adding ad hoc Pod processing paths.
|
||||
|
||||
This is a design task, not an implementation task. The output should be a concise but concrete design document suitable for turning into implementation tickets or acceptance criteria for `plugin-feature-contribution-registry` and `hook-public-surface-hardening`.
|
||||
|
||||
## Background
|
||||
|
||||
The current direction is that feature state remains owned by the feature/extension module, while interaction with Pod happens through existing durable host surfaces:
|
||||
|
||||
- Tools
|
||||
- Hooks
|
||||
- notifications / events / durable history append paths
|
||||
|
||||
The concern is that adding WorkItem, MCP, memory, plugin, and other capabilities without a common registry will create many unrelated Pod-specific insertion points. The Plugin system should establish a common contribution and authority boundary, even for built-in features.
|
||||
|
||||
`hook-public-surface-hardening` is being implemented separately to make public Hook actions safe before plugin exposure.
|
||||
|
||||
## Design question
|
||||
|
||||
What should the clean public API look like for a feature/plugin module that wants to contribute capabilities to a Pod?
|
||||
|
||||
The design should answer:
|
||||
|
||||
- What API types should extension modules use to declare/register capabilities?
|
||||
- What belongs in a pure descriptor vs a runtime install callback?
|
||||
- How should Tools, Hooks, and notifications be represented in the same public surface?
|
||||
- How should capability request / host grant / diagnostics be expressed?
|
||||
- What state should the feature keep itself, and what state may Pod keep?
|
||||
- What must be impossible through this API?
|
||||
- Where should the API live initially, and what parts should be movable to a future `plugin`/`extension` crate?
|
||||
|
||||
## Required constraints
|
||||
|
||||
- Public API must not let features/plugins mutate prompt context or session history invisibly.
|
||||
- Model-visible additions must go through durable host paths: tool result, committed history append, explicit notification/history append, or user-visible event path.
|
||||
- Public Hook contribution must depend on the safe Hook surface after `hook-public-surface-hardening`.
|
||||
- Tool contributions must use the normal ToolRegistry / PreToolCall permission / history result path.
|
||||
- Feature registry must install into existing Pod/Worker surfaces; it must not create a parallel Pod runtime path.
|
||||
- Capability grant is host-controlled. A feature may request capabilities but must not assume them.
|
||||
- Built-in features and future external plugins should fit the same shape.
|
||||
- Avoid designing package distribution, WASM execution, or MCP implementation details beyond the minimal runtime-kind placeholders needed for the API.
|
||||
- Avoid broad refactors of Pod/Worker crate boundaries unless needed to explain a clean API boundary.
|
||||
|
||||
## Files / records to read
|
||||
|
||||
Tickets:
|
||||
|
||||
- `/home/hare/Projects/yoi/work-items/open/20260603-122317-plugin-feature-contribution-registry/item.md`
|
||||
- `/home/hare/Projects/yoi/work-items/open/20260603-122317-hook-public-surface-hardening/item.md`
|
||||
- `/home/hare/Projects/yoi/work-items/open/20260531-010005-plugin-extension-surface/item.md`
|
||||
- `/home/hare/Projects/yoi/work-items/open/20260601-031252-builtin-work-item-intake-routing/item.md`
|
||||
|
||||
Code:
|
||||
|
||||
- `crates/pod/src/hook.rs`
|
||||
- `crates/pod/src/ipc/interceptor.rs`
|
||||
- `crates/pod/src/controller.rs`
|
||||
- `crates/pod/src/pod.rs`
|
||||
- `crates/pod/src/permission.rs`
|
||||
- `crates/llm-worker/src/tool.rs`
|
||||
- `crates/llm-worker/src/interceptor.rs`
|
||||
- `crates/tools/src/lib.rs`
|
||||
- `crates/pod/src/workflow/mod.rs`
|
||||
|
||||
## Expected output
|
||||
|
||||
Write a design document to:
|
||||
|
||||
`/home/hare/Projects/yoi/work-items/open/20260603-122317-plugin-feature-contribution-registry/artifacts/pod-api-design.md`
|
||||
|
||||
Use this structure:
|
||||
|
||||
1. Summary recommendation
|
||||
2. Current relevant Pod/Worker surfaces
|
||||
3. Proposed public API shape
|
||||
- types/modules
|
||||
- example registration snippet
|
||||
- Tool contribution
|
||||
- Hook contribution
|
||||
- notification/event contribution
|
||||
- capability request/grant/diagnostics
|
||||
4. State ownership model
|
||||
5. Safety invariants / forbidden operations
|
||||
6. Placement and crate-boundary recommendation
|
||||
7. Migration path from current built-in registrations
|
||||
8. Impact on WorkItem / MCP / plugin distribution follow-ups
|
||||
9. Open questions / risks
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not edit source code.
|
||||
- Do not implement tests.
|
||||
- Do not create a worktree.
|
||||
- Do not close or modify tickets except writing the requested design artifact.
|
||||
|
||||
## Completion report
|
||||
|
||||
Report:
|
||||
|
||||
- whether the artifact was written
|
||||
- the recommended API placement
|
||||
- the highest-risk API decision
|
||||
- any blockers that require parent/user decision
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: plan author: hare at: 2026-06-03T16:44:05Z -->
|
||||
|
||||
## Plan
|
||||
|
||||
# Public Pod-side API for Feature / Plugin Contributions
|
||||
|
||||
## 1. Summary recommendation
|
||||
|
||||
Introduce a `pod::feature` public API as the single Pod-side registration layer for built-in features and future external plugins. A feature module should declare its identity, requested capabilities, and contributions, then install those contributions only through typed host registrars for existing Pod/Worker surfaces: `ToolRegistry`, the hardened safe `pod::hook` surface, and host-owned notification/event/history append paths.
|
||||
Introduce a `pod::feature` public API as the single Pod-side registration layer for built-in features and future external plugins. A feature module should declare its identity, contributions, service dependencies/exports, and requested host authorities. Contributions are displayed and locked by descriptor/digest; host authorities are the user-approved sandbox/object-capability grants such as filesystem, network, secrets, Pod management, and model-visible durable notification/history append.
|
||||
|
||||
The registry should not become a second runtime, a plugin dispatcher tool, or a generic `Pod` mutation escape hatch. Feature state remains inside the feature module; the Pod owns only install metadata, diagnostics, granted host handles, and normal durable session/runtime surfaces.
|
||||
|
||||
|
|
@ -158,7 +32,7 @@ The design should build on these existing surfaces rather than bypassing them:
|
|||
|
||||
- `crates/pod/src/permission.rs`
|
||||
- Manifest tool permissions are enforced as a `PreToolCallHook`.
|
||||
- Feature tools must remain subject to the same PreToolCall permission path. Feature capability grants do not replace per-call tool permission.
|
||||
- Feature tools must remain subject to the same PreToolCall permission path. Feature authority grants do not replace per-call tool permission.
|
||||
|
||||
- `crates/llm-worker/src/tool.rs` and `crates/llm-worker/src/tool_server.rs`
|
||||
- `ToolDefinition`, `Tool`, `ToolMeta`, `ToolResult`, `ToolOutput`, and `ToolServerHandle` define the normal tool execution path.
|
||||
|
|
@ -185,16 +59,21 @@ Add a new module under `pod`:
|
|||
|
||||
```rust
|
||||
pub mod feature {
|
||||
pub mod background;
|
||||
pub mod capability;
|
||||
pub mod diagnostic;
|
||||
pub mod event;
|
||||
pub mod hook;
|
||||
pub mod notify;
|
||||
pub mod registry;
|
||||
pub mod service;
|
||||
pub mod tool;
|
||||
|
||||
pub use capability::{CapabilityGrantSet, CapabilityRequest, HostCapability};
|
||||
pub use background::{BackgroundTaskContribution, BackgroundTaskRegistrar};
|
||||
pub use capability::{AuthorityGrantSet, AuthorityRequest, HostAuthority};
|
||||
pub use diagnostic::{FeatureDiagnostic, FeatureInstallReport};
|
||||
pub use registry::{FeatureDescriptor, FeatureId, FeatureInstallContext, FeatureModule, FeatureRegistryBuilder, FeatureRuntimeKind};
|
||||
pub use notify::{FeatureAlertSink, FeatureNotifySink};
|
||||
pub use registry::{FeatureDescriptor, FeatureId, FeatureInstallContext, FeatureModule, FeatureRuntimeKind};
|
||||
pub use service::{FeatureServiceProvider, FeatureServiceRegistrar, ServiceDeclaration, ServiceRequirement, YoiService};
|
||||
pub use tool::ToolContribution;
|
||||
}
|
||||
```
|
||||
|
|
@ -213,10 +92,12 @@ pub struct FeatureDescriptor {
|
|||
pub display_name: String,
|
||||
pub version: Option<String>,
|
||||
pub runtime: FeatureRuntimeKind, // Builtin, ExternalProcess, McpBridge, WasmPlaceholder, DeclarativePlaceholder
|
||||
pub requested_capabilities: Vec<CapabilityRequest>,
|
||||
pub requested_authorities: Vec<AuthorityRequest>,
|
||||
pub provides_services: Vec<ServiceDeclaration>,
|
||||
pub requires_services: Vec<ServiceRequirement>,
|
||||
pub declared_tools: Vec<ToolDeclaration>,
|
||||
pub declared_hooks: Vec<HookDeclaration>,
|
||||
pub declared_event_channels: Vec<EventChannelDeclaration>,
|
||||
pub declared_background_tasks: Vec<BackgroundTaskDeclaration>,
|
||||
}
|
||||
|
||||
pub enum FeatureRuntimeKind {
|
||||
|
|
@ -230,13 +111,15 @@ pub enum FeatureRuntimeKind {
|
|||
pub struct FeatureInstallContext<'a> {
|
||||
// No Pod or Worker reference.
|
||||
pub feature_id: &'a FeatureId,
|
||||
pub grants: &'a CapabilityGrantSet,
|
||||
pub grants: &'a AuthorityGrantSet,
|
||||
pub tools: ToolRegistrar<'a>,
|
||||
pub hooks: PublicHookRegistrar<'a>,
|
||||
pub notify: FeatureNotifySink<'a>,
|
||||
pub events: FeatureEventSink<'a>,
|
||||
pub diagnostics: FeatureDiagnosticSink<'a>,
|
||||
pub services: FeatureServiceProvider<'a>,
|
||||
pub service_exports: FeatureServiceRegistrar<'a>,
|
||||
pub background_tasks: BackgroundTaskRegistrar<'a>,
|
||||
pub notify: FeatureNotifySink<'a>,
|
||||
pub alerts: FeatureAlertSink<'a>,
|
||||
pub diagnostics: FeatureDiagnosticSink<'a>,
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -245,7 +128,8 @@ Important details:
|
|||
- `FeatureDescriptor` is declarative and serializable. It is safe to show in diagnostics, profile previews, and `ListFeatures`-style future tooling.
|
||||
- `FeatureModule::install` is runtime code that wires stateful tool/hook implementations into host registrars.
|
||||
- `FeatureInstallContext` must not expose `Pod`, `Worker`, raw `ToolServerHandle`, raw `Interceptor`, raw `NotifyBuffer`, raw `LogWriter`, raw `event_tx`, or direct history mutation.
|
||||
- `FeatureServiceProvider` returns only host services backed by granted capabilities, for example scoped filesystem access, WorkItem store access, memory access, Pod orchestration handles, web provider handles, or secret references. It should return `Denied`/`Unavailable` diagnostics instead of exposing partial internals.
|
||||
- `FeatureServiceProvider` returns only host-mediated services backed by granted host authorities and resolved service dependencies, for example scoped filesystem access, WorkItem store access, memory access, Pod orchestration handles, web provider handles, secret references, or another feature's declared public service. It should return `Denied`/`Unavailable` diagnostics instead of exposing partial internals.
|
||||
- `FeatureServiceRegistrar` lets a feature expose a narrow public service API to other features. This is an extension boundary for future plugin-to-plugin APIs, not a requirement to move already-implemented core behavior out of the host.
|
||||
|
||||
### Example registration snippet
|
||||
|
||||
|
|
@ -253,8 +137,8 @@ This is illustrative shape, not proposed final exact Rust syntax:
|
|||
|
||||
```rust
|
||||
use pod::feature::{
|
||||
CapabilityRequest, FeatureDescriptor, FeatureId, FeatureInstallContext,
|
||||
FeatureModule, FeatureRuntimeKind, HostCapability, ToolContribution,
|
||||
AuthorityRequest, FeatureDescriptor, FeatureId, FeatureInstallContext,
|
||||
FeatureModule, FeatureRuntimeKind, HostAuthority, ToolContribution,
|
||||
};
|
||||
|
||||
pub struct WorkItemFeature {
|
||||
|
|
@ -266,18 +150,22 @@ impl FeatureModule for WorkItemFeature {
|
|||
FeatureDescriptor::builder(FeatureId::builtin("work-item"))
|
||||
.display_name("WorkItem intake and routing")
|
||||
.runtime(FeatureRuntimeKind::Builtin)
|
||||
.request(CapabilityRequest::required(
|
||||
HostCapability::WorkItemStore { read: true, write: true },
|
||||
.request(AuthorityRequest::required(
|
||||
HostAuthority::WorkItemStore { read: true, write: true },
|
||||
"create and update WorkItem records through host-owned ticket storage",
|
||||
))
|
||||
.request(CapabilityRequest::optional(
|
||||
HostCapability::EmitUserEvent,
|
||||
"surface routing diagnostics to the TUI/actionbar",
|
||||
.request(AuthorityRequest::optional(
|
||||
HostAuthority::ModelNotification,
|
||||
"commit user-action-required notices through the existing Notify/SystemItem path when the model should see them",
|
||||
))
|
||||
.request(AuthorityRequest::optional(
|
||||
HostAuthority::Alert,
|
||||
"surface short transient human-facing warnings when the watcher fails",
|
||||
))
|
||||
.tool("WorkItemCreate")
|
||||
.tool("WorkItemComment")
|
||||
.hook("work_item_intake_pre_tool_audit", pod::hook::HookPoint::PreToolCall)
|
||||
.event_channel("work-item")
|
||||
.background_task("work_item_watch")
|
||||
.build()
|
||||
}
|
||||
|
||||
|
|
@ -294,7 +182,10 @@ impl FeatureModule for WorkItemFeature {
|
|||
WorkItemAuditHook::new(self.state.clone()),
|
||||
)?;
|
||||
|
||||
ctx.events.declare_channel("work-item")?;
|
||||
ctx.background_tasks.register(BackgroundTaskContribution::pod_lifetime(
|
||||
"work_item_watch",
|
||||
WorkItemWatchTask::new(store.clone(), self.state.clone()),
|
||||
))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -311,7 +202,6 @@ pub struct ToolContribution {
|
|||
pub feature_id: FeatureId,
|
||||
pub name: ToolName,
|
||||
pub definition: llm_worker::ToolDefinition,
|
||||
pub required_capabilities: Vec<HostCapability>,
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -323,7 +213,7 @@ Rules:
|
|||
- Duplicate tool names should be rejected during feature registry preflight with a diagnostic, not discovered later through a panic or undefined ordering.
|
||||
- Public feature identity should be source-qualified (`builtin:memory`, `project:foo`, `plugin:<digest>:bar`), while model-visible tool names should remain explicit stable names. Do not auto-prefix model tool names unless the project deliberately chooses a future namespacing policy.
|
||||
- Tool schemas/descriptions must be part of the normal `ToolDefinition` path so model-visible surfaces remain inspectable and bounded.
|
||||
- If a required host service is not granted or configured, the tool should not be registered; the install report should explain the skipped contribution.
|
||||
- If a required host authority or service is not granted or configured, the tool should not be registered; the install report should explain the skipped contribution.
|
||||
|
||||
### Hook contribution
|
||||
|
||||
|
|
@ -354,63 +244,134 @@ pub trait PublicPreToolCallHook: Send + Sync {
|
|||
|
||||
If a hook needs to add model-visible text, it should use `FeatureNotifySink::notify_model(...)` or another host-owned durable append API, not return an `Item`.
|
||||
|
||||
### Notification/event contribution
|
||||
### Notification, alert, and diagnostic surfaces
|
||||
|
||||
Expose two distinct sinks:
|
||||
Do not add a plugin-defined event-channel API. A feature should not be able to publish arbitrary structured UI payloads or ask clients to render feature-specific dialogs through the base Pod API.
|
||||
|
||||
Expose narrowly-scoped host sinks instead:
|
||||
|
||||
```rust
|
||||
pub struct FeatureNotifySink<'a> { /* host-owned */ }
|
||||
pub struct FeatureEventSink<'a> { /* host-owned */ }
|
||||
pub struct FeatureAlertSink<'a> { /* host-owned */ }
|
||||
pub struct FeatureDiagnosticSink<'a> { /* host-owned */ }
|
||||
```
|
||||
|
||||
Recommended behavior:
|
||||
|
||||
- `FeatureNotifySink::notify_model(...)` creates a model-visible notification through the existing durable notification/system-item path. The host commits the corresponding `SystemItem` before it is appended to Worker history.
|
||||
- `FeatureNotifySink::notify_user(...)` or `FeatureEventSink::emit(...)` creates user-visible diagnostics/progress/action events through the existing alert/event path. These are not model-visible unless explicitly routed through `notify_model`.
|
||||
- Event payloads should be typed, bounded, and feature-identified. Avoid arbitrary JSON blobs as the first public API; allow an opaque bounded metadata field only if diagnostics require it.
|
||||
- Notifications and events should require explicit capabilities such as `EmitModelNotification` and `EmitUserEvent`.
|
||||
- Background feature tasks must use these sinks; they must not hold raw log writers or append directly to history.
|
||||
- `FeatureNotifySink::notify_model(...)` is the feature-facing form of the existing `Method::Notify` / `SystemItem::Notification` path. It creates a model-visible, history-backed notification by asking the host to commit a `LogEntry::SystemItem`; clients then see the same committed item through `Event::SystemItem`. Disk-side and wire-side remain 1:1.
|
||||
- `FeatureAlertSink::alert(...)` emits a short human-facing transient alert equivalent to `Event::Alert`. It is UI/person-facing only: not model-visible, not session history, not replayed as an input, and not a structured UI extension channel.
|
||||
- `FeatureDiagnosticSink` records install/runtime/authority/task diagnostics for host logs, future `ListFeatures`, profile validation, and TUI diagnostics. Diagnostics are host-defined records, not arbitrary client-rendered UI payloads.
|
||||
- Dialogs, confirmations, and other interactive client UI are out of scope for the base API. If needed later, add a separate host-defined interaction protocol; do not use a generic event channel for it.
|
||||
- Model-visible notifications require a host authority grant because they become durable model/history-visible input. Transient alerts and diagnostics are host-defined reporting surfaces; normally control them with descriptor approval, host policy, size bounds, and rate limits rather than treating them as sandbox authorities.
|
||||
- Background feature tasks must use these sinks; they must not hold raw log writers, raw event senders, or append directly to history.
|
||||
|
||||
Useful initial event shape:
|
||||
### Background task contribution
|
||||
|
||||
Background tasks should be a first-class feature contribution. Without this, asynchronous features will inevitably create ad hoc `tokio::spawn` loops, private channels, and shutdown/reporting paths outside the registry.
|
||||
|
||||
Initial shape:
|
||||
|
||||
```rust
|
||||
pub struct FeatureEvent {
|
||||
pub struct BackgroundTaskContribution {
|
||||
pub feature_id: FeatureId,
|
||||
pub level: FeatureEventLevel, // Info, Warn, Error
|
||||
pub channel: String, // e.g. "work-item"
|
||||
pub summary: String,
|
||||
pub detail: Option<String>,
|
||||
pub model_visible: bool, // false unless host routes through notify_model
|
||||
pub id: FeatureTaskId,
|
||||
pub description: String,
|
||||
pub activation: TaskActivation,
|
||||
pub restart: RestartPolicy,
|
||||
pub shutdown: ShutdownPolicy,
|
||||
pub requested_authorities: Vec<AuthorityRequest>,
|
||||
}
|
||||
|
||||
pub struct FeatureTaskContext<'a> {
|
||||
pub cancellation: CancellationToken,
|
||||
pub notify: FeatureNotifySink<'a>,
|
||||
pub alerts: FeatureAlertSink<'a>,
|
||||
pub diagnostics: FeatureDiagnosticSink<'a>,
|
||||
pub services: FeatureServiceProvider<'a>,
|
||||
}
|
||||
```
|
||||
|
||||
`model_visible` should be host-controlled in practice: a feature may request model visibility, but the sink decides whether that capability is granted and records the durable append if it is.
|
||||
Rules:
|
||||
|
||||
### Capability request/grant/diagnostics
|
||||
- The host starts, tracks, cancels, and reports background tasks. Feature modules register tasks; they do not spawn untracked runtime loops.
|
||||
- Task output is limited to granted authorities, sinks, and services: model-visible notification through `FeatureNotifySink` only with a model-notification authority grant, human transient alert through `FeatureAlertSink`, diagnostics through `FeatureDiagnosticSink`, and host-granted service operations.
|
||||
- The host records task lifecycle/status in install/runtime diagnostics. Do not expose arbitrary feature-defined event channels for task progress.
|
||||
- Initial activation can be conservative (`PodLifetime` and/or `OnDemand`); restart policy may start with `Never`, but the API should make shutdown/cancellation behavior explicit from the beginning.
|
||||
|
||||
Capabilities are requested by descriptors and granted by the host. A feature may request a capability, but it must not assume the capability exists.
|
||||
### Service contribution and dependency
|
||||
|
||||
Initial capability categories:
|
||||
Add a host-mediated service registry so a feature/plugin can publish a narrow API and another feature/plugin can depend on that API without importing the provider's concrete implementation or bypassing authority policy.
|
||||
|
||||
This is not a plan to move existing implemented core features out of the host immediately. Core-backed APIs may remain core-backed. The service form exists so future detachable features can depend on stable public interfaces rather than private Pod internals or another plugin's concrete state.
|
||||
|
||||
Initial shape:
|
||||
|
||||
```rust
|
||||
pub enum HostCapability {
|
||||
ContributeTool { name: ToolName },
|
||||
ContributeHook { point: pod::hook::HookPoint },
|
||||
EmitUserEvent,
|
||||
EmitModelNotification,
|
||||
ScopedFs { read: bool, write: bool, execute: bool },
|
||||
pub trait YoiService: Send + Sync + 'static {
|
||||
fn service_id(&self) -> ServiceId;
|
||||
fn service_version(&self) -> ServiceVersion;
|
||||
}
|
||||
|
||||
pub struct ServiceDeclaration {
|
||||
pub id: ServiceId, // e.g. yoi.memory.v1
|
||||
pub version: ServiceVersion,
|
||||
pub description: String,
|
||||
pub operations: Vec<ServiceOperationDeclaration>,
|
||||
}
|
||||
|
||||
pub struct ServiceRequirement {
|
||||
pub id: ServiceId,
|
||||
pub version: ServiceVersionReq,
|
||||
pub optional: bool,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
pub struct FeatureServiceRegistrar<'a> { /* host-owned */ }
|
||||
pub struct FeatureServiceProvider<'a> { /* host-owned */ }
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- A feature may provide a service through `FeatureServiceRegistrar`, and consumers may obtain the service only through `FeatureServiceProvider` after host dependency resolution and authority grant checks.
|
||||
- Features must not directly hold another feature's concrete module/state unless that handle was returned by the host service registry.
|
||||
- Service identity is source-independent interface identity (`yoi.memory.v1`, `yoi.pod-management.v1`, `project.issue-tracker.v1`), while provider identity remains a `FeatureId`.
|
||||
- Service dependencies are resolved during feature registry preflight. Required missing services skip the consumer feature with diagnostics; optional missing services install the consumer in a degraded mode if its installer supports that.
|
||||
- Service dependency cycles are rejected initially. Late-bound/cyclic service handles are out of scope until a real need appears.
|
||||
- In-process built-in services may use Rust trait objects internally. External plugin/WASM/MCP services should be represented by host-side proxies that implement the same service interface boundary; do not expose raw foreign runtime handles.
|
||||
- Service handles should be authority-bound. Prefer narrowed interfaces such as read-only vs write-capable services, or enforce caller/grant checks through a host-provided service call context.
|
||||
|
||||
Examples:
|
||||
|
||||
- `builtin:memory` may provide `yoi.memory.v1` and contribute memory tools. A WorkItem intake feature may require `yoi.memory.v1` optionally for contextual lookup.
|
||||
- `builtin:pod-orchestration` may provide `yoi.pod-management.v1` and contribute Pod management tools. The actual Pod lifecycle/scope authority remains host-owned; the service is a controlled façade.
|
||||
- A future issue-tracker plugin may provide `project.issue-tracker.v1`, while WorkItem tooling consumes that service without knowing the concrete plugin package.
|
||||
|
||||
### Host authority request/grant/diagnostics
|
||||
|
||||
Authority grants are the user-approved sandbox/object-capability boundary. They control which host-provided APIs or handles a feature receives. They are not a mirror of every contribution a feature declares.
|
||||
|
||||
Contributions such as tools, hooks, background tasks, and service providers should be displayed and locked by descriptor/digest at install time. They do not need separate `ContributeTool` / `ContributeHook` / `RunBackgroundTask` / `ProvideService` authority variants. If a plugin changes its contributed tools/hooks/tasks/services, the descriptor or package hash changes and the user must re-approve the changed plugin.
|
||||
|
||||
Initial authority categories:
|
||||
|
||||
```rust
|
||||
pub enum HostAuthority {
|
||||
Fs(FsGrant),
|
||||
Network(NetworkGrant),
|
||||
SecretRef { id: String },
|
||||
ModelNotification, // durable Notify/SystemItem/Event::SystemItem path
|
||||
WorkItemStore { read: bool, write: bool },
|
||||
MemoryStore { read: bool, write: bool },
|
||||
PodManagement { spawn: bool, message: bool, restore: bool },
|
||||
Network { purpose: NetworkPurpose },
|
||||
SecretRef { id: String },
|
||||
PersistentPluginState { read: bool, write: bool },
|
||||
}
|
||||
```
|
||||
|
||||
Important separation:
|
||||
|
||||
- Capability grants decide whether a feature may install and receive host services.
|
||||
- Tool permissions decide whether an installed tool call may execute for a specific Pod/run.
|
||||
- Descriptor/contribution approval decides whether a feature with this digest may be installed with its declared tools/hooks/tasks/services.
|
||||
- Authority grants decide whether a feature may receive dangerous host handles such as filesystem, network, secret, Pod-management, store, or model-visible notification access.
|
||||
- Tool permissions decide whether an installed model-visible tool call may execute for a specific Pod/run.
|
||||
- Scope permissions decide which filesystem paths or delegated Pod capabilities a host service may touch.
|
||||
|
||||
Diagnostics should be first-class:
|
||||
|
|
@ -419,10 +380,13 @@ Diagnostics should be first-class:
|
|||
pub struct FeatureInstallReport {
|
||||
pub feature_id: FeatureId,
|
||||
pub enabled: bool,
|
||||
pub granted: Vec<HostCapability>,
|
||||
pub denied: Vec<CapabilityDenial>,
|
||||
pub granted: Vec<HostAuthority>,
|
||||
pub denied: Vec<AuthorityDenial>,
|
||||
pub installed_tools: Vec<ToolName>,
|
||||
pub installed_hooks: Vec<String>,
|
||||
pub installed_background_tasks: Vec<FeatureTaskId>,
|
||||
pub provided_services: Vec<ServiceId>,
|
||||
pub resolved_services: Vec<ServiceResolutionReport>,
|
||||
pub skipped_contributions: Vec<SkippedContribution>,
|
||||
pub diagnostics: Vec<FeatureDiagnostic>,
|
||||
}
|
||||
|
|
@ -434,10 +398,10 @@ Diagnostics must avoid secrets and must be safe for session logs, TUI display, a
|
|||
|
||||
Feature state belongs to the feature module.
|
||||
|
||||
- A feature may own `Arc<State>` and clone it into contributed tools, hooks, and background tasks.
|
||||
- The Pod registry stores descriptors, install reports, enabled/disabled status, and host-owned handles. It does not store feature business state.
|
||||
- A feature may own `Arc<State>` and clone it into contributed tools, hooks, background tasks, and service implementations.
|
||||
- The Pod registry stores descriptors, service resolution records, install reports, enabled/disabled status, and host-owned handles. It does not store feature business state.
|
||||
- Durable feature data must live in a feature-owned or host-granted store with an explicit API: WorkItem files through a WorkItem service, memory records through memory APIs, plugin config/state through a future plugin-state service, etc.
|
||||
- Session history is not feature storage. It is an audit/replay record of model-visible interactions and host-visible events.
|
||||
- Session history is not feature storage. It is an audit/replay record of model-visible interactions and committed system items.
|
||||
- A feature that needs restoration after process restart should reconstruct itself from its own durable store/config plus normal Pod metadata, not from private data hidden in Worker context.
|
||||
- Background tasks are allowed only if they communicate through granted sinks/services and have a defined shutdown/lifecycle policy owned by the host.
|
||||
|
||||
|
|
@ -454,10 +418,12 @@ Public features/plugins must not be able to perform these operations:
|
|||
- Access raw `Worker`, raw `Pod`, raw `ToolServerHandle`, raw `llm_worker::Interceptor`, raw `NotifyBuffer`, raw session log writer, or raw event sender.
|
||||
- Register tools outside `ToolRegistry` or bypass normal tool-result history recording.
|
||||
- Bypass `PreToolCall` permission policy.
|
||||
- Grant themselves capabilities or infer grants from successful construction.
|
||||
- Grant themselves host authorities or infer grants from successful construction.
|
||||
- Mutate manifest/profile/scope state directly.
|
||||
- Perform filesystem/process/network/secret access outside granted host services.
|
||||
- Emit unbounded tool outputs, event payloads, diagnostics, or notification bodies.
|
||||
- Depend on another feature/plugin by concrete implementation, private state, raw process handle, or direct module pointer instead of a host-resolved service interface.
|
||||
- Emit unbounded tool outputs, diagnostics, alerts, task-status reports, or notification bodies.
|
||||
- Emit arbitrary plugin-defined UI payloads, dialogs, or event-channel messages.
|
||||
- Put secrets into diagnostics, session logs, model context, TUI output, or feature install reports.
|
||||
- Depend on MCP/WASM/package-distribution mechanics in the base Pod API.
|
||||
|
||||
|
|
@ -471,8 +437,9 @@ Recommended placement:
|
|||
- public feature traits/types
|
||||
- feature registry builder
|
||||
- install reports/diagnostics
|
||||
- capability request/grant model
|
||||
- authority request/grant model
|
||||
- typed registrars/sinks
|
||||
- service registry, service declarations, and dependency resolution reports
|
||||
|
||||
- `crates/pod/src/hook.rs`
|
||||
- remains the public hook module after hardening
|
||||
|
|
@ -493,12 +460,13 @@ Recommended placement:
|
|||
|
||||
Install location in Pod startup:
|
||||
|
||||
1. Resolve manifest/profile and host capability policy.
|
||||
1. Resolve manifest/profile and host authority policy.
|
||||
2. Construct `Pod` and internal safety surfaces.
|
||||
3. Install host/internal hooks such as manifest permission enforcement.
|
||||
4. Build and install enabled feature modules through `FeatureRegistryBuilder`.
|
||||
5. Flush/register tools through the existing Worker tool registry.
|
||||
6. Freeze/install the Pod interceptor and start normal run/attach behavior.
|
||||
4. Build enabled feature descriptors, collect declared service providers/requirements, resolve the service dependency DAG, and compute authority grants.
|
||||
5. Install enabled feature modules through `FeatureRegistryBuilder`, including service exports, tools, hooks, and background task declarations.
|
||||
6. Flush/register tools through the existing Worker tool registry.
|
||||
7. Freeze/install the Pod interceptor, start host-managed background tasks at their activation point, and start normal run/attach behavior.
|
||||
|
||||
The exact sequencing can be adjusted to match current construction, but the invariant should hold: public feature hooks cannot precede host safety hooks, and feature tools must exist before the model receives the final tool schema for a run.
|
||||
|
||||
|
|
@ -511,45 +479,57 @@ Recommended migration is incremental and behavior-preserving:
|
|||
- Define which hook decisions are safe for external contributors.
|
||||
|
||||
2. Add `pod::feature` with no behavior change.
|
||||
- Implement descriptors, capability grants, install reports, and registrars.
|
||||
- Implement descriptors, service declarations/requirements, authority grants, install reports, and registrars.
|
||||
- Initially register no external plugins.
|
||||
|
||||
3. Wrap current built-in tool registration as built-in feature modules.
|
||||
3. Add the service registry as a host-mediated boundary.
|
||||
- Start with core-backed services or a trivial built-in service provider to validate provider/consumer resolution.
|
||||
- Do not move existing Memory or Pod management implementation solely for this ticket.
|
||||
- Use diagnostics for missing required services, optional degraded service dependencies, and service cycles.
|
||||
|
||||
4. Wrap current built-in tool registration as built-in feature modules.
|
||||
- Start with a small built-in feature whose state/services are already cleanly bounded.
|
||||
- Preserve existing tool names, schemas, and permission behavior.
|
||||
- Convert duplicate-name failures into registry diagnostics before flushing tools.
|
||||
|
||||
4. Move larger built-in groups behind feature modules.
|
||||
5. Move larger built-in groups behind feature modules.
|
||||
- Filesystem/process tools from `crates/tools`.
|
||||
- Memory tools.
|
||||
- Pod orchestration tools.
|
||||
- Task/WorkItem tools once their stores and hooks have explicit capabilities.
|
||||
- Web tools as configured provider-backed features.
|
||||
|
||||
5. Move built-in hook contributions only after safe hook semantics are stable.
|
||||
6. Move built-in hook contributions only after safe hook semantics are stable.
|
||||
- Keep manifest permission enforcement as an internal host hook, not a feature hook.
|
||||
- Keep accounting/usage hooks internal unless they become genuine feature behavior.
|
||||
|
||||
6. Treat workflow/user-input expansion separately.
|
||||
7. Treat workflow/user-input expansion separately.
|
||||
- Workflow invocation already uses a durable system-item attachment pattern.
|
||||
- Do not expose arbitrary workflow-like context injection to plugins until there is a safe typed command/input-contribution API with durable append semantics.
|
||||
|
||||
7. Add profile/manifest enablement after built-ins work through the same registry.
|
||||
- Built-ins and external plugins should share descriptor/capability/install-report mechanics.
|
||||
8. Add profile/manifest enablement after built-ins work through the same registry.
|
||||
- Built-ins and external plugins should share descriptor/authority/install-report mechanics.
|
||||
- Host policy may grant built-ins by default, but built-ins should still declare what they use.
|
||||
|
||||
## 8. Impact on WorkItem / MCP / plugin distribution follow-ups
|
||||
|
||||
WorkItem / intake routing:
|
||||
|
||||
- WorkItem routing can become a built-in feature that contributes WorkItem tools, optional routing hooks, and user-visible action events.
|
||||
- It should request `WorkItemStore` and event/notification capabilities instead of reaching into ticket files ad hoc.
|
||||
- WorkItem routing can become a built-in feature that contributes WorkItem tools, optional routing hooks, and host-managed background tasks such as a watcher.
|
||||
- It should request `WorkItemStore` and model-notification authority instead of reaching into ticket files or prompt context ad hoc. Its background task is a declared contribution, not a separate sandbox authority.
|
||||
- Model-visible routing hints or intake results must be committed through notification/history append paths.
|
||||
- This registry gives the WorkItem feature a clean way to install without making WorkItem a special Pod runtime mode.
|
||||
- If WorkItem, Memory, or Pod orchestration are split into smaller feature modules later, they should communicate through declared services such as `yoi.work-item-store.v1`, `yoi.memory.v1`, or `yoi.pod-management.v1` rather than private module references.
|
||||
|
||||
Feature-to-feature service APIs:
|
||||
|
||||
- The service registry lets plugins expose stable APIs without requiring the host to make every domain service a permanent core API.
|
||||
- This does not force existing Memory or Pod management implementations to be extracted immediately. They may stay core-backed while still being representable as service façades for consumers.
|
||||
- External plugins should consume service proxies provided by the host, not another plugin's raw process/WASM/MCP handle.
|
||||
|
||||
MCP:
|
||||
|
||||
- MCP should be an adapter/runtime kind that produces normal `ToolContribution`s and possibly safe event diagnostics.
|
||||
- MCP should be an adapter/runtime kind that produces normal `ToolContribution`s and, when needed, host-managed background tasks for connection/session supervision plus bounded diagnostics/alerts.
|
||||
- MCP tool calls must still pass through `ToolRegistry`, PreToolCall permission, output bounding, and history result recording.
|
||||
- MCP resources/prompts should not become invisible prompt injection. If exposed later, they should be explicit tools, user-invoked attachments, or durable notification/history appends.
|
||||
- MCP transport/session details are out of scope for the base API beyond the `FeatureRuntimeKind::McpBridge` placeholder.
|
||||
|
|
@ -557,8 +537,8 @@ MCP:
|
|||
Plugin distribution:
|
||||
|
||||
- Archive validation, cache extraction, signing/trust, WASM execution, external process supervision, and package update policy should remain separate follow-up designs.
|
||||
- Distribution mechanisms should eventually produce the same descriptor/capability/contribution objects as built-ins.
|
||||
- Capability grants are the host trust boundary; package installation alone must not grant runtime authority.
|
||||
- Distribution mechanisms should eventually produce the same descriptor/authority/contribution objects as built-ins.
|
||||
- Authority grants are the host trust boundary; package installation alone must not grant runtime authority.
|
||||
|
||||
## 9. Open questions / risks
|
||||
|
||||
|
|
@ -569,11 +549,13 @@ Plugin distribution:
|
|||
2. The exact safe hook action set must be settled by `hook-public-surface-hardening`.
|
||||
- Especially important: whether public pre-tool hooks may synthesize denials/results, and how durable append requests are represented.
|
||||
|
||||
3. Notification/event durability needs precise semantics.
|
||||
- User-visible events may be live-only, while model-visible notifications must be durable. The public API should make this distinction impossible to miss.
|
||||
3. Notification, alert, and diagnostic semantics need precise names and capabilities.
|
||||
- Model-visible notifications must be durable and should use the existing `Notify` / `SystemItem` / `Event::SystemItem` path.
|
||||
- `Alert`-like output is transient human-facing text only.
|
||||
- Diagnostics/status are host-defined operational records, not plugin-defined UI channels.
|
||||
|
||||
4. Capability granularity can easily become either too coarse or too noisy.
|
||||
- Start with coarse host-service capabilities plus normal tool permissions, then split only when real features need finer grants.
|
||||
4. Authority granularity can easily become either too coarse or too noisy.
|
||||
- Start with coarse host authorities plus normal tool permissions, then split only when real features need finer grants.
|
||||
|
||||
5. Runtime enable/disable is not designed here.
|
||||
- Initial registry should be install-at-startup. Hot reload or dynamic plugin enablement needs separate lifecycle, cleanup, and schema-refresh design.
|
||||
|
|
@ -584,8 +566,9 @@ Plugin distribution:
|
|||
7. Background tasks need lifecycle policy.
|
||||
- If external plugins can spawn tasks, the host must define shutdown, cancellation, panic handling, diagnostic routing, and whether task output may become model-visible.
|
||||
|
||||
8. Existing workflow/input expansion is close to the forbidden boundary.
|
||||
8. Service API design needs an in-process vs external-plugin boundary.
|
||||
- Built-in services can use Rust traits or typed handles, but external plugins need host-side proxies and operation schemas. The first implementation should avoid baking in Rust trait-object assumptions as the only service representation.
|
||||
- Service handles must be authority-bound, or one broad service handle can accidentally become an authority escalation path.
|
||||
|
||||
9. Existing workflow/input expansion is close to the forbidden boundary.
|
||||
- It is safe only because it commits system items before model visibility. Any future plugin command/input contribution must preserve that durable replay property.
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
# Rereview 2: plugin-feature-contribution-registry
|
||||
|
||||
## 1. Result: request changes
|
||||
|
||||
Request changes. The prior ToolDefinition materialization blocker is fixed, and the authority enum no longer treats contribution kinds as sandbox authorities. However, the updated authority-boundary design now relies on descriptor/digest approval to control Tool/Hook/BackgroundTask/ServiceProvider contributions, and the implementation does not enforce that install-time contributions match the descriptor-declared contributions. That leaves a merge-blocking boundary gap before this registry becomes the common built-in/future-plugin contribution boundary.
|
||||
|
||||
## 2. Summary of implementation
|
||||
|
||||
Reviewed commits:
|
||||
|
||||
- `a8ae6ca feat: add pod feature registry slice`
|
||||
- `4070176 fix: harden feature contribution gates`
|
||||
- `98bbd6f fix: align feature authority boundaries`
|
||||
|
||||
The implementation adds `crates/pod/src/feature.rs` with:
|
||||
|
||||
- feature identity/runtime metadata, descriptors, contribution declarations, host authority requests/grants, install reports, diagnostics, and skipped-contribution reporting;
|
||||
- `FeatureModule` / `FeatureInstallContext` / `FeatureRegistryBuilder` install mechanics;
|
||||
- tool contribution registration into the normal `Worker` pending-tool path;
|
||||
- hook contribution registration through `HookRegistryBuilder` and the already-safe public `pod::hook` action types;
|
||||
- descriptor/report skeletons for background tasks and service provider/requirement resolution;
|
||||
- model-notification, alert, and diagnostic sink skeletons;
|
||||
- a migrated builtin `task_feature` proving `TaskCreate`, `TaskUpdate`, `TaskGet`, and `TaskList` registration through the registry.
|
||||
|
||||
The follow-up fixes changed the previous tool materialization path so `ToolContributionRegistrar::register` materializes a `ToolDefinition` once, checks the materialized `ToolMeta.name`, records/report-checks that same name, and queues a frozen closure returning the same `ToolMeta` and `Tool` instance for Worker registration.
|
||||
|
||||
## 3. Requirement-by-requirement assessment
|
||||
|
||||
### 1. Previous ToolDefinition materialization blocker
|
||||
|
||||
Status: fixed.
|
||||
|
||||
- `ToolContributionRegistrar::register` materializes the contributed `ToolDefinition` once at `feature.rs:679-681`.
|
||||
- The contribution name is compared with the materialized model-visible `ToolMeta.name` at `feature.rs:681-693`.
|
||||
- Duplicate checking and install reporting use that same materialized `model_visible_name` at `feature.rs:705-721`.
|
||||
- Worker registration receives a frozen closure over the already-materialized `tool_meta` and `tool` at `feature.rs:722-723`, so a stateful/non-idempotent definition cannot later register a different name after validation.
|
||||
- Regression coverage exists in `stateful_tool_definition_is_materialized_once_for_report_and_worker` at `feature.rs:1309-1360`; it verifies the report and Worker both see `First` and that the stateful definition was called only once.
|
||||
|
||||
One minor caveat remains in the builtin task migration: `TaskFeature::install` derives each contribution name by calling the task `ToolDefinition` once before passing it to `ToolContribution::new` (`feature.rs:1144-1148`), then the registrar materializes again. The task tools are stable/idempotent, and the registrar would reject a later name mismatch, so this is not the previous blocker. A future helper that constructs `ToolContribution` from a `ToolDefinition` after one materialization would make this harder to misuse.
|
||||
|
||||
### 2. Updated authority-boundary design
|
||||
|
||||
Status: mostly implemented, with one blocker on descriptor/contribution reconciliation.
|
||||
|
||||
Implemented correctly:
|
||||
|
||||
- The public `HostAuthority` variants are dangerous host handles/APIs only: filesystem, network, secret ref, model notification, Pod management, state store, and service access (`feature.rs:81-89`).
|
||||
- No `ContributeTool`, `ContributeHook`, `DeclareBackgroundTask`, `ProvideService`, `RequireService`, `EmitAlert`, `EmitDiagnostic`, or similar contribution-capability variants were found in the changed Pod feature API.
|
||||
- Background tasks and service providers are represented as contributions/report data, not sandbox authority grants (`feature.rs:236-260`, `feature.rs:306-353`, `feature.rs:780-804`).
|
||||
- Model-visible notification remains gated on `HostAuthority::ModelNotification` via `FeatureNotificationSink::notify_model` (`feature.rs:605-623`). The current implementation still only records a skipped diagnostic because no durable Notify/SystemItem host is attached during install, so it does not introduce a hidden context/history path.
|
||||
- Alert/diagnostic sinks are install-report/diagnostic surfaces, not a generic event or UI channel (`feature.rs:627-667`).
|
||||
|
||||
Blocking gap:
|
||||
|
||||
- The design says contribution approval is the descriptor/digest boundary: tools, hooks, background tasks, and service providers are displayed and locked by descriptor/digest, while authority grants are only for dangerous host handles. Because contribution-capability variants were correctly removed, descriptor enforcement becomes the control point.
|
||||
- The current registrars do not enforce that install-time contributions match `FeatureDescriptor` declarations:
|
||||
- tools check only `ToolContribution.name == ToolMeta.name` and cross-feature duplicates (`feature.rs:679-724`), not that the name exists in `descriptor.tools`;
|
||||
- hooks append any runtime `HookDeclaration` to the report (`feature.rs:734-777`), not that the `(name, point)` was declared in `descriptor.hooks`;
|
||||
- background tasks append any runtime declaration (`feature.rs:785-787`), not that it was declared in `descriptor.background_tasks`;
|
||||
- services can provide any runtime `ServiceDeclaration` (`feature.rs:798-803`), not that it was declared in `descriptor.provides_services`.
|
||||
- As a result, a future external plugin could present an approved descriptor/digest with one contribution set and install a different/additional tool, hook, background task, or service provider without a changed descriptor. That violates the updated authority-boundary design at the point where the implementation intentionally removed contribution authorities.
|
||||
|
||||
### 3. No generic event/UI/context/history/raw internals
|
||||
|
||||
Status: passes for this slice.
|
||||
|
||||
- No generic plugin event channel, custom UI/dialog protocol, or arbitrary plugin UI payload was introduced.
|
||||
- The notification sink does not expose raw `NotifyBuffer`, raw `Event`, `SystemItem`, or history/context mutation; it currently records a skipped diagnostic.
|
||||
- `FeatureInstallContext` exposes typed registrars/sinks only. It does not expose raw `Pod`, `ToolServerHandle`, `Interceptor`, raw history writer, raw event sender, or raw `NotifyBuffer`.
|
||||
- Hook contributions use the safe `pod::hook` public action surface. The hook module keeps public hooks read-only and prevents `ContinueWith(Vec<Item>)`, arbitrary `ToolResult` construction, no-result skipping, and history/context injection.
|
||||
- `feature.rs` imports `llm_worker::Worker` only for the crate-private `install_into_worker` bridge; the public install context does not hand `Worker` to feature modules.
|
||||
|
||||
### 4. Task tools preserve model-visible names/schemas/behavior and per-call permission path
|
||||
|
||||
Status: passes.
|
||||
|
||||
- Core builtin tools remain registered through `core_builtin_tools`; task tools are split into the new builtin feature and installed by `controller.rs:524-526` through `pod.install_features(...)`.
|
||||
- `builtin_task_feature_installs_through_worker_tool_path` verifies the Worker-visible names are `TaskCreate`, `TaskGet`, `TaskList`, and `TaskUpdate` (`feature.rs:1472-1498`).
|
||||
- The task feature uses existing `tools::task_tools(task_store)` definitions; no tool schema or execution implementation was rewritten in this slice.
|
||||
- Tool calls still enter the normal Worker/ToolRegistry path, so existing PreToolCall permission policy remains the per-call enforcement point.
|
||||
|
||||
### 5. Tests/validation sufficiency
|
||||
|
||||
Status: not sufficient until the blocker is covered.
|
||||
|
||||
Good focused coverage exists for:
|
||||
|
||||
- descriptor/report basics;
|
||||
- duplicate tool-name rejection;
|
||||
- mismatched contribution name vs model-visible `ToolMeta.name` rejection;
|
||||
- stateful ToolDefinition materialization once for report and Worker registration;
|
||||
- service requirement resolution;
|
||||
- background task and service provider not being sandbox-authority-gated;
|
||||
- builtin task feature registration through the Worker tool path.
|
||||
|
||||
Missing coverage for the blocking boundary:
|
||||
|
||||
- undeclared install-time tool is rejected;
|
||||
- undeclared hook `(name, point)` is rejected;
|
||||
- undeclared background task declaration is rejected or reported as skipped;
|
||||
- undeclared service provider declaration is rejected;
|
||||
- descriptor-declared but uninstalled required contribution behavior is intentionally defined/reported, if required by the registry semantics.
|
||||
|
||||
## 4. Blockers
|
||||
|
||||
### Blocker 1: install-time contributions are not locked to descriptor-declared contributions
|
||||
|
||||
The registry removes contribution-capability authorities, which is correct, but then must enforce descriptor/digest approval as the contribution boundary. It currently does not. Runtime install code can register/report contributions not present in the descriptor for tools, hooks, background tasks, and service providers.
|
||||
|
||||
Required fix before merge:
|
||||
|
||||
- Carry descriptor-declared contribution sets into the install context/registrars.
|
||||
- Reject or explicitly skip/report any install-time tool/hook/background-task/service-provider contribution not declared by the descriptor.
|
||||
- Ensure duplicate checks and Worker registration still use the materialized model-visible tool name after declaration membership is validated.
|
||||
- Add regression tests for undeclared contribution rejection across at least tools and one non-tool contribution; ideally cover all four contribution kinds because the updated authority-boundary design depends on this separation.
|
||||
|
||||
## 5. Non-blockers / follow-ups
|
||||
|
||||
- Add an ergonomic `ToolContribution` constructor/helper that materializes a `ToolDefinition` once and uses the materialized `ToolMeta.name`, so future feature authors do not repeat the `TaskFeature::install` two-call pattern.
|
||||
- Before enabling non-builtin/external feature sources, replace the current `AuthorityGrantSet::grant_all(&descriptor.requested_authorities)` scaffold with an actual host policy/user-approval grant resolver. This is acceptable as a scaffold for the current builtin-only slice, but it is not a real external-plugin authority gate.
|
||||
- Add explicit size/rate/secrecy bounds for feature diagnostics and alert messages before exposing these sinks to untrusted plugins. The current implementation avoids generic UI/event channels, but message strings are not bounded at this API layer.
|
||||
- Consider documenting ordering/requiredness semantics for descriptor-declared but not actually installed contributions, especially hooks/background tasks/services.
|
||||
|
||||
## 6. Validation assessed or rerun
|
||||
|
||||
Read/assessed:
|
||||
|
||||
- ticket, delegation intent, API design, permission-boundary revision, prior review, and prior rereview artifacts;
|
||||
- changed files: `crates/pod/src/feature.rs`, `crates/pod/src/controller.rs`, `crates/pod/src/pod.rs`, `crates/pod/src/lib.rs`, and `crates/tools/src/lib.rs`;
|
||||
- focused searches for removed contribution-capability variant names and raw internal exposure terms.
|
||||
|
||||
Commands run from `/home/hare/Projects/yoi/.worktree/plugin-feature-contribution-registry`:
|
||||
|
||||
- `git diff --name-status develop...HEAD`
|
||||
- `git diff --check develop...HEAD`
|
||||
- focused `grep` searches over the changed Pod feature API and registration sites
|
||||
|
||||
`git diff --check develop...HEAD` exited successfully. I did not rerun `cargo test`, `cargo check`, or `nix build` because this rereview was requested as a no-source-modification review with only focused read-only validation commands; those build/test commands write target/build outputs.
|
||||
|
||||
## 7. Residual risk
|
||||
|
||||
Once the descriptor/contribution reconciliation blocker is fixed, the remaining risk is mostly staged-skeleton risk: service/background/notification/alert authorities are represented but not fully connected to host-managed lifecycles or durable hosts. That is acceptable for this first builtin-only registry slice if kept explicit in follow-up tickets and not treated as external-plugin-ready authority enforcement.
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
# Rereview 3: plugin-feature-contribution-registry
|
||||
|
||||
## 1. Result: approve
|
||||
|
||||
Approve. The remaining descriptor/contribution boundary blocker from `rereview-2.md` is fixed in `6fa08f8 fix: enforce feature descriptor contributions`, and I did not find new blockers in the final review scope.
|
||||
|
||||
## 2. Summary of implementation
|
||||
|
||||
The final fix adds descriptor-derived contribution membership checks in `crates/pod/src/feature.rs`:
|
||||
|
||||
- `FeatureContributionDeclarations` is built from each `FeatureDescriptor` before install.
|
||||
- `FeatureInstallContext` passes those descriptor-approved sets to the typed registrars.
|
||||
- Tool, Hook, BackgroundTask, and ServiceProvider install-time contributions are rejected with `FeatureInstallError::UndeclaredContribution` and a skipped contribution report entry when they are not present in the descriptor-approved set.
|
||||
- The previous once-materialized tool registration hardening remains intact: the materialized `ToolMeta.name` is still the identity used for descriptor membership, duplicate checking, install reporting, and Worker registration.
|
||||
|
||||
The implementation remains a first Pod-layer feature registry slice: task tools are the migrated builtin proof, services/background tasks are still descriptor/report skeletons, and external plugin discovery/loading is not implemented.
|
||||
|
||||
## 3. Requirement-by-requirement assessment
|
||||
|
||||
### Remaining blocker: descriptor-approved contribution set enforcement
|
||||
|
||||
Status: fixed.
|
||||
|
||||
- Descriptor contribution sets are derived in `FeatureContributionDeclarations::from_descriptor` (`feature.rs:582-633`) for:
|
||||
- tools by model-visible name;
|
||||
- hooks by `(name, FeatureHookPoint)`;
|
||||
- background tasks by task name;
|
||||
- service providers by `(ServiceId, version)`.
|
||||
- The install loop constructs those declarations per descriptor and passes them through `FeatureInstallContext` (`feature.rs:1104-1196`).
|
||||
- Undeclared contributions are recorded through `reject_undeclared_contribution`, which marks the contribution as skipped and returns `FeatureInstallError::UndeclaredContribution` (`feature.rs:635-649`, `feature.rs:1217-1244`).
|
||||
|
||||
### Tools: undeclared rejection and once-materialized identity
|
||||
|
||||
Status: fixed.
|
||||
|
||||
- `ToolContributionRegistrar::register` materializes the `ToolDefinition` exactly once at registration time (`feature.rs:749-751`).
|
||||
- It compares the explicit contribution name with the materialized model-visible `ToolMeta.name` (`feature.rs:752-763`).
|
||||
- It checks descriptor membership using that same materialized `model_visible_name` before authority or duplicate checks (`feature.rs:765-772`).
|
||||
- Duplicate checks and install reporting still use the same `model_visible_name` (`feature.rs:784-800`).
|
||||
- Worker registration receives a frozen closure over the already-materialized `tool_meta` and `tool` (`feature.rs:801-802`), so a stateful/non-idempotent `ToolDefinition` cannot register a different name after checks.
|
||||
- `undeclared_tool_contribution_is_rejected` verifies an undeclared tool is not queued into `pending_tools`, is not marked installed, and is reported/skipped (`feature.rs:1615-1640`).
|
||||
- `stateful_tool_definition_is_materialized_once_for_report_and_worker` still covers the previous materialization regression (`feature.rs:1466-1519`).
|
||||
|
||||
### Hooks: `(name, hook point)` descriptor check
|
||||
|
||||
Status: fixed.
|
||||
|
||||
- `HookContributionRegistrar::require_declared` checks the descriptor-approved `(name, FeatureHookPoint)` before adding any hook to `HookRegistryBuilder` (`feature.rs:815-829`).
|
||||
- Each hook registrar method calls `require_declared` before installing the hook (`feature.rs:831-877`).
|
||||
- `undeclared_hook_contribution_is_rejected` verifies the feature is not installed, `installed_hooks` remains empty, and a skipped Hook contribution is reported (`feature.rs:1642-1661`).
|
||||
|
||||
### Background tasks
|
||||
|
||||
Status: fixed for this descriptor/report-only slice.
|
||||
|
||||
- `BackgroundTaskRegistrar::declare` rejects undeclared task names before adding to the report (`feature.rs:887-909`).
|
||||
- `undeclared_background_task_contribution_is_rejected` verifies the feature is not installed, no background task is reported as declared, and a skipped BackgroundTask contribution is recorded (`feature.rs:1663-1683`).
|
||||
- Declared background tasks remain descriptor/report contributions and are not modeled as sandbox authorities.
|
||||
|
||||
### Service providers
|
||||
|
||||
Status: fixed for this descriptor/report-only slice.
|
||||
|
||||
- `FeatureServiceRegistrar::provide` rejects undeclared `(ServiceId, version)` provider declarations before registering them with `FeatureServiceRegistry` (`feature.rs:920-941`).
|
||||
- `undeclared_service_provider_contribution_is_rejected` verifies the feature is not installed, the service registry does not provide the hidden service, the report has no provided service, and a skipped Service contribution is recorded (`feature.rs:1685-1706`).
|
||||
- Declared service providers remain descriptor/report metadata in this slice; no broad service handle or raw provider API is exposed.
|
||||
|
||||
### No contribution-capability variants reintroduced
|
||||
|
||||
Status: passes.
|
||||
|
||||
Focused searches found no reintroduced public feature authority variants such as `ContributeTool`, `ContributeHook`, `DeclareBackgroundTask`, `ProvideService`, `RequireService`, `EmitAlert`, `EmitDiagnostic`, `RunBackgroundTask`, `FeatureCapability`, or `HostCapability`.
|
||||
|
||||
`HostAuthority` remains limited to host APIs/handles: filesystem, network, secret refs, model notification, Pod management, state store, and service access (`feature.rs:81-89`). This matches the permission-boundary decision: contributions are descriptor/digest-locked declarations, while host authorities represent dangerous host access.
|
||||
|
||||
### No generic event channel, UI/dialog protocol, hidden history/context injection, or raw internal handles
|
||||
|
||||
Status: passes.
|
||||
|
||||
I found no new generic plugin event channel, custom UI/dialog protocol, hidden context/history injection path, or broad unrelated refactor. `FeatureInstallContext` still exposes typed registrars/sinks only, not raw `Pod`, `ToolServerHandle`, `Interceptor`, raw history writer, raw event sender, or raw `NotifyBuffer`.
|
||||
|
||||
`FeatureNotificationSink::notify_model` remains gated by `HostAuthority::ModelNotification` and only records a skipped diagnostic because no durable Notify/SystemItem host is attached during install (`feature.rs:667-693`). Alert and diagnostic sinks remain bounded install-report style surfaces, not generic UI/event channels (`feature.rs:696-736`).
|
||||
|
||||
### Task tools behavior and normal PreToolCall path
|
||||
|
||||
Status: passes.
|
||||
|
||||
- Core builtin tools are still registered directly through `core_builtin_tools`; task tools are installed via the feature registry (`controller.rs:517-526`).
|
||||
- The task feature uses existing `tools::task_tools` definitions, so schemas and execution behavior remain owned by the existing task tool implementations.
|
||||
- `builtin_task_feature_installs_through_worker_tool_path` verifies Worker-visible task tool names are unchanged: `TaskCreate`, `TaskGet`, `TaskList`, `TaskUpdate` (`feature.rs:1800-1825`).
|
||||
- Because task tools still register into the normal Worker tool registry, model calls continue through the existing tool execution and PreToolCall permission path.
|
||||
|
||||
## 4. Blockers
|
||||
|
||||
None.
|
||||
|
||||
## 5. Non-blockers / follow-ups
|
||||
|
||||
- Keep `AuthorityGrantSet::grant_all(...)` scoped to this builtin-only scaffold. Before enabling external/untrusted feature sources, add the real host policy/user approval grant resolver.
|
||||
- The descriptor membership checks currently lock tool contributions by model-visible tool name. If future external-plugin approval wants to display/lock exact model-visible tool schema/description too, extend `ToolDeclaration` and tests accordingly.
|
||||
- Add explicit size/rate/secrecy bounds for feature diagnostic and alert strings before exposing these sinks to untrusted plugins.
|
||||
- Clarify future semantics for partially installed features if a module registers one approved contribution and then fails on a later contribution. The current slice prevents undeclared contributions from being installed, but approved earlier contributions can remain queued before the install error is reported.
|
||||
|
||||
## 6. Validation assessed or rerun
|
||||
|
||||
Read/assessed:
|
||||
|
||||
- `rereview-2.md`, ticket, and permission-boundary decision;
|
||||
- final `6fa08f8` changes in `crates/pod/src/feature.rs`;
|
||||
- task tool registration path in `crates/pod/src/controller.rs`, `crates/pod/src/pod.rs`, and `crates/tools/src/lib.rs`;
|
||||
- focused searches for removed contribution-capability names and raw internal exposure terms.
|
||||
|
||||
Commands run from `/home/hare/Projects/yoi/.worktree/plugin-feature-contribution-registry`:
|
||||
|
||||
- `git status --short`
|
||||
- `git log --oneline --decorate -5`
|
||||
- `git diff --name-status develop...HEAD`
|
||||
- `git diff --check develop...HEAD`
|
||||
- `git show --stat --oneline --decorate --no-renames 6fa08f8`
|
||||
- `git show --name-only --format='%H %s' 6fa08f8`
|
||||
- focused grep/search/read checks over the changed feature API and registration paths
|
||||
|
||||
`git diff --check develop...HEAD` exited successfully. I did not rerun cargo or Nix builds in this review turn because the available write scope is limited to the review artifact path and those validations would write build outputs.
|
||||
|
||||
## 7. Residual risk
|
||||
|
||||
Residual risk is acceptable for this slice. The registry is still a skeleton for external plugins: background tasks, services, notification sinks, alert sinks, and authority grant resolution are represented but not fully connected to production host lifecycles or approval policy. That is consistent with the ticket’s first-slice scope as long as follow-up work does not treat this as external-plugin-ready without adding the real host policy, lifecycle, and bounded-output enforcement.
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
# External sibling rereview: plugin-feature-contribution-registry
|
||||
|
||||
## 1. Result: request changes
|
||||
|
||||
Request changes. The second blocker from the original review is addressed, but the first blocker is only partially fixed: the registry now compares the wrapper name against one materialization of the `ToolDefinition`, then queues the original factory for a second Worker materialization, so the checked name can still diverge from the actual model-visible registered tool name.
|
||||
|
||||
## 2. Summary of rereview
|
||||
|
||||
Reviewed the existing worktree/branch:
|
||||
|
||||
- Worktree: `/home/hare/Projects/yoi/.worktree/plugin-feature-contribution-registry`
|
||||
- Branch: `work/plugin-feature-contribution-registry`
|
||||
- Commits reviewed:
|
||||
- `a8ae6ca feat: add pod feature registry slice`
|
||||
- `4070176 fix: harden feature contribution gates`
|
||||
|
||||
The fix commit changes only `crates/pod/src/feature.rs`. It adds capability checks for background task, service, notification, alert, and diagnostic surfaces; adds service requirement capability checks; adds tool-name mismatch diagnostics; and expands focused tests.
|
||||
|
||||
## 3. Prior blocker assessment
|
||||
|
||||
### Prior blocker 1: Tool wrapper name vs actual model-visible tool name
|
||||
|
||||
Status: **not fully resolved**.
|
||||
|
||||
The new code in `ToolContributionRegistrar::register` does verify the wrapper name against a `ToolDefinition` materialization:
|
||||
|
||||
```rust
|
||||
let model_visible_name = (contribution.definition)().0.name;
|
||||
if contribution.name != model_visible_name { ... }
|
||||
```
|
||||
|
||||
It then performs capability checks, duplicate checks, skipped/report entries, and `installed_tools` against that `model_visible_name`, which fixes the simple stable-factory mismatch case and is covered by the new test `mismatched_tool_contribution_name_is_rejected_before_queueing`.
|
||||
|
||||
However, after that validation it queues the original `ToolDefinition` factory unchanged:
|
||||
|
||||
```rust
|
||||
self.pending_tools.push(contribution.definition);
|
||||
```
|
||||
|
||||
`ToolDefinition` is an arbitrary `Arc<dyn Fn() -> (ToolMeta, Arc<dyn Tool>) + Send + Sync>`, and its own documentation says it is called once during Worker registration. This implementation now calls it once for registry validation and then lets Worker call it again during `flush_pending`. A non-idempotent or stateful factory can return `ToolMeta.name = "Checked"` during registry validation and `ToolMeta.name = "ActuallyRegistered"` during Worker registration. In that case capability checks, duplicate checks, skipped/report entries, and installed tool reports are keyed to the first name, while the model-visible Worker tool is registered under the second name.
|
||||
|
||||
This keeps the original boundary hole open for the actual Worker queueing path. It is also a behavior risk for factories whose construction has side effects, since the first materialized tool instance is discarded.
|
||||
|
||||
Before merge, the registry should make the validated tool identity the same materialization that Worker registers. Acceptable shapes include:
|
||||
|
||||
- materialize `(ToolMeta, Arc<dyn Tool>)` once in the feature registry, validate `ToolMeta.name`, then queue a stable factory that returns the already validated metadata/tool instance; or
|
||||
- change the lower Worker/tool registration path to accept a materialized tool registration type; or
|
||||
- otherwise enforce a type-level/single-materialization invariant so the registry-checked name cannot differ from the Worker-registered name.
|
||||
|
||||
Add a regression test with a `ToolDefinition` that returns different names across calls; it should not be possible for the report to say one name while the Worker registers another.
|
||||
|
||||
### Prior blocker 2: Capability grants for non-tool/hook surfaces
|
||||
|
||||
Status: **resolved for this Phase 1/2 slice**.
|
||||
|
||||
The fix adds capability enforcement or explicit denial/skipping for the previously uncovered surfaces:
|
||||
|
||||
- `BackgroundTaskRegistrar::declare` now checks `DeclareBackgroundTask` through `require_background_task_capability`.
|
||||
- Descriptor-declared background tasks are also checked before being recorded in `declared_background_tasks`.
|
||||
- `FeatureServiceRegistrar::provide` now checks `ProvideService` through `require_service_provider_capability`.
|
||||
- Descriptor-declared service providers are also checked before registration in `FeatureServiceRegistry`.
|
||||
- Service requirements are checked against `RequireService`; required missing/denied requirements block installation, while optional missing/denied requirements are skipped/degraded.
|
||||
- `FeatureNotificationSink`, `FeatureAlertSink`, and `FeatureDiagnosticSink` now require `EmitNotification`, `EmitAlert`, and `EmitDiagnostic` respectively before recording their skeleton/report-only output.
|
||||
|
||||
The install path still uses `CapabilityGrantSet::grant_all(&descriptor.requested_capabilities)`, so this slice is not a real host policy resolver yet. That is acceptable for the focused registry skeleton as long as absent/unrequested capabilities are denied/skipped, which the updated code now does.
|
||||
|
||||
## 4. Blockers
|
||||
|
||||
### Blocker — ToolDefinition is validated once but registered from a second factory call
|
||||
|
||||
The registry must not validate/report one tool name while Worker can register another. The current fix validates one call to the `ToolDefinition` but queues the same factory for a later Worker call, so `ToolMeta.name` can still diverge between registry checks and actual model-visible registration.
|
||||
|
||||
This must be fixed before merge because it directly affects the requested invariant: capability checks, duplicate checks, skipped/reports, and Worker queueing must share the same model-visible tool identity.
|
||||
|
||||
## 5. Non-blockers / follow-ups
|
||||
|
||||
- The new tests cover the stable-factory mismatch case, background-task capability denial, service-provider capability denial, service requirement basics, duplicate tool names, and Task tool registration. They do not yet cover notification/alert/diagnostic sink capability denial. The code path is straightforward, so I do not classify this as a blocker, but adding a small sink-denial test would make the capability-surface coverage more complete.
|
||||
- The service resolution remains installation-order based. This was already noted in the original review and remains acceptable as a follow-up for this descriptor/skeleton slice.
|
||||
- The install report is still not surfaced outside local `_feature_install_report` plumbing. This remains acceptable for the small built-in proof but should be addressed when discovery/enablement diagnostics become user-visible.
|
||||
|
||||
## 6. Validation assessed or rerun
|
||||
|
||||
Reran:
|
||||
|
||||
- `git diff --check develop...HEAD` — passed.
|
||||
- `cargo test -p pod feature::tests --lib` — passed: 7 tests, 0 failures.
|
||||
|
||||
Assessed by inspection:
|
||||
|
||||
- Original review artifact.
|
||||
- Ticket item and delegation intent.
|
||||
- `git diff a8ae6ca..HEAD -- crates/pod/src/feature.rs`.
|
||||
- Current `crates/pod/src/feature.rs` around tool registration, capability gates, service/background handling, notification/alert/diagnostic sinks, and tests.
|
||||
- Search for generic event/UI/context/history surfaces in `crates/pod/src/feature.rs`.
|
||||
|
||||
I did not rerun full `cargo test -p pod --lib`, workspace check, or `nix build .#yoi` in this rereview because the remaining issue is visible by focused inspection and the requested scope was blocker re-review.
|
||||
|
||||
## 7. Residual risk
|
||||
|
||||
No new generic event channel, custom UI/dialog path, hidden context/history injection, raw `Pod`, raw `ToolServerHandle`, raw `Interceptor`, raw history writer, raw event sender, or raw `NotifyBuffer` exposure was found in the feature API changes. Once the tool materialization/registration identity issue is fixed, the remaining risk looks appropriate for the intended descriptor-first Phase 1/2 slice.
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
# External sibling review: plugin-feature-contribution-registry
|
||||
|
||||
## 1. Result: request changes
|
||||
|
||||
Request changes. The implementation is a good focused first slice structurally, and the migrated Task tool group appears low-risk, but the public `pod::feature` boundary does not yet enforce its advertised authority/capability invariants consistently enough to merge as the plugin/feature registration boundary.
|
||||
|
||||
## 2. Summary of implementation
|
||||
|
||||
The coder commit `a8ae6ca feat: add pod feature registry slice` adds `crates/pod/src/feature.rs`, exposes it as `pod::feature`, and wires a `FeatureRegistryBuilder` into Pod tool registration. The existing generic builtin tool registration was split into:
|
||||
|
||||
- `tools::core_builtin_tools(...)` for filesystem/bash/web tools; and
|
||||
- `tools::builtin_tools(...)` retaining the old all-tools helper for non-registry callers.
|
||||
|
||||
`TaskCreate`, `TaskUpdate`, `TaskGet`, and `TaskList` are migrated through a built-in `task_feature(...)` module. The new feature module includes descriptor/capability/report types, safe hook registrar shape, descriptor/report-only background tasks, descriptor/report-only services, and skeleton notification/alert/diagnostic sinks. Focused unit tests were added in `pod::feature` for install reporting, duplicate feature-tool names, service requirement basics, and Task tool registration through the Worker path.
|
||||
|
||||
## 3. Requirement-by-requirement assessment
|
||||
|
||||
- Feature identity/runtime metadata: mostly satisfied for the first slice. `FeatureId` and `FeatureRuntimeKind` exist, though runtime/source kinds are still coarse and can be extended later.
|
||||
- Contribution descriptors for Tools/Hooks/BackgroundTasks/Services/notification-alert-diagnostic: partially satisfied. The types exist, but capability enforcement is inconsistent outside tool/hook registration.
|
||||
- Capability request/grant data structures: partially satisfied. The data structures exist, but the install path currently grants all requested capabilities and several registrars/descriptors bypass the grant set entirely.
|
||||
- Registry/builder/install path into existing host surfaces: mostly satisfied for tools and hooks. Tools are queued into the normal Worker registration path, and hooks go through `HookRegistryBuilder` with the hardened `pod::hook` action surface.
|
||||
- Behavior preservation / migrated built-in proof: likely satisfied for the selected Task tools. Tool names are preserved in the focused test, and the migration is narrow.
|
||||
- No raw Pod/Worker/ToolServerHandle/Interceptor/history/event/NotifyBuffer exposure through `FeatureInstallContext`: satisfied by code inspection for the public context surface. `Worker` is only used by a crate-private installer and tests.
|
||||
- No generic plugin event channel or custom UI/dialog payload: satisfied. Notification/alert/diagnostic surfaces are skeleton/report-only and do not add a UI/event channel.
|
||||
- Notification/background/service scope: mostly aligned as skeleton-level, but the service/capability boundaries need tightening before merge.
|
||||
- Tests: useful but not sufficient for the safety boundary. Missing coverage for mismatched tool wrapper name vs model-visible `ToolMeta.name`, non-tool/hook capability denial, and code-level no-raw-handle exposure.
|
||||
|
||||
## 4. Blockers
|
||||
|
||||
### Blocker 1 — Tool capability/duplicate checks are keyed to a wrapper name that can diverge from the actual model-visible tool name
|
||||
|
||||
`ToolContribution::new(name, definition)` stores a caller-supplied `name` separately from the `ToolDefinition`'s eventual `ToolMeta.name` (`crates/pod/src/feature.rs:194-221`). `ToolContributionRegistrar::register` checks grants, duplicate names, and install reports only against that wrapper name, then pushes the uninspected `ToolDefinition` to the Worker (`crates/pod/src/feature.rs:666-698`).
|
||||
|
||||
That means a feature can be granted and reported for `SafeName` while the factory actually registers model-visible `OtherName` (or a duplicate of an existing/core tool) when the Worker flushes pending tools. This breaks the registry boundary's ability to preserve model-visible tool names/schemas and to diagnose/reject duplicate or ungranted tool contributions. It also leaves duplicate detection to the lower Worker flush panic for cases the registry should reject cleanly.
|
||||
|
||||
Before merge, the registry should make the tool name used for capability checks and reports the same authority as the model-visible `ToolMeta.name`, or otherwise validate the invariant before queuing the tool. The fix should include a focused regression test for a mismatched contribution name/factory name.
|
||||
|
||||
### Blocker 2 — Capability grants are not enforced for several advertised contribution surfaces
|
||||
|
||||
The implementation enforces grants for tools and hooks, but not for the rest of the public install context:
|
||||
|
||||
- `BackgroundTaskRegistrar::declare` records a background task without checking `HostCapability::DeclareBackgroundTask` (`crates/pod/src/feature.rs:777-785`).
|
||||
- `FeatureServiceRegistrar::provide` registers a service without checking `HostCapability::ProvideService` (`crates/pod/src/feature.rs:788-801`).
|
||||
- Descriptor-declared background tasks and provided services are copied/registered before `module.install(...)` without capability checks (`crates/pod/src/feature.rs:1012-1029`).
|
||||
- `FeatureNotificationSink::notify_model`, `FeatureAlertSink::alert`, and `FeatureDiagnosticSink` are exposed without checking `EmitNotification`, `EmitAlert`, or `EmitDiagnostic` grants (`crates/pod/src/feature.rs:595-654`, `856-870`). They are skeleton/report-only today, but they still advertise host capabilities and should be denied/skipped consistently when not granted.
|
||||
|
||||
This makes `CapabilityGrantSet` unreliable as a host-mediated boundary: a feature can publish service/provider metadata or background task declarations without requesting/receiving the corresponding capability. Before merge, all advertised contribution/sink surfaces should either enforce the relevant grant or be explicitly non-capability-gated with the capability variants removed/deferred. Tests should cover denial/skipping for at least service and background-task contributions.
|
||||
|
||||
## 5. Non-blockers / follow-ups
|
||||
|
||||
- Service resolution is order-sensitive: required services only resolve against providers already registered by earlier modules (`missing_required_service` checks the current registry only). For this first descriptor skeleton it may be acceptable, but the design record describes host preflight/dependency resolution and initial cycle rejection. A follow-up should either topologically resolve against all descriptors or document that installation order is the dependency order for this slice.
|
||||
- Existing/core Worker pending tool names are not incorporated into feature duplicate diagnostics. A future feature that collides with a core tool name would likely be caught only by Worker flush panic. This is related to Blocker 1 but also matters independently once more built-ins move behind the registry.
|
||||
- The install report is currently assigned to `_feature_install_report` and discarded in `controller.rs`. That is acceptable for a tiny built-in migration, but discovery/enablement diagnostics should eventually be surfaced through a host-defined diagnostic path.
|
||||
- Tests would be stronger with explicit compile/runtime checks that `FeatureInstallContext` does not expose raw `Pod`, `Worker`, `ToolServerHandle`, `Interceptor`, raw history/event/notify handles, or arbitrary `llm_worker::Item` injection.
|
||||
|
||||
## 6. Validation assessed or rerun
|
||||
|
||||
Reran:
|
||||
|
||||
- `git diff --check develop...HEAD` — passed.
|
||||
- `git show --stat --oneline --decorate --no-renames a8ae6ca` — confirmed the reviewed commit and changed-file set.
|
||||
|
||||
Assessed by inspection:
|
||||
|
||||
- `git diff develop...HEAD`
|
||||
- `crates/pod/src/feature.rs`
|
||||
- `crates/pod/src/controller.rs`
|
||||
- `crates/pod/src/pod.rs`
|
||||
- `crates/tools/src/lib.rs`
|
||||
- `crates/pod/src/hook.rs`
|
||||
- ticket item and supplied design/revision artifacts
|
||||
|
||||
I did not rerun `cargo test`, `cargo check`, or `nix build` in this external reviewer pass because the requested scope was source-read plus one artifact write, and the blockers above are API-boundary issues visible by inspection.
|
||||
|
||||
## 7. Residual risk
|
||||
|
||||
After the blockers are fixed, the main residual risk is that this first registry slice remains descriptor-heavy while actual host policy resolution is still minimal. That is acceptable for Phase 1/2 if the public API cannot misrepresent model-visible tools and if every exposed contribution/sink consistently goes through capability checks or is explicitly deferred.
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# Decision: add host-mediated Feature services
|
||||
|
||||
Add Service provider/consumer support to the Plugin/Feature base Pod API.
|
||||
|
||||
This is not a decision to extract currently implemented core features such as Memory or Pod management immediately. Existing implementations may remain core-backed. The new service form exists so future built-in features and plugins can expose stable APIs to other features without direct concrete dependencies or ad hoc Pod internals access.
|
||||
|
||||
Revised contribution/dependency model:
|
||||
|
||||
- Contributions:
|
||||
- ToolContribution
|
||||
- HookContribution
|
||||
- BackgroundTaskContribution
|
||||
- ServiceProvider / ServiceDeclaration
|
||||
- Dependencies:
|
||||
- ServiceRequirement, resolved by the host registry before feature installation
|
||||
|
||||
Rules:
|
||||
|
||||
- A feature/plugin may provide a public service through a host-owned service registry.
|
||||
- Another feature/plugin may acquire that service only through the host, after dependency resolution and capability grant checks.
|
||||
- Consumers do not import provider concrete types, private state, raw process handles, raw WASM/MCP handles, or plugin-specific modules.
|
||||
- Required missing services skip the consuming feature with diagnostics; optional missing services allow degraded installation when supported.
|
||||
- Service cycles are rejected initially.
|
||||
- In-process built-ins may use Rust trait-object handles internally, but the public design must leave room for external plugin service proxies.
|
||||
- Service handles must be capability-bound so acquiring a broad service does not become an authority escalation path.
|
||||
|
||||
Examples:
|
||||
|
||||
- `builtin:memory` may provide `yoi.memory.v1`; other features can optionally consume read-only memory lookup without depending on Memory internals.
|
||||
- `builtin:pod-orchestration` may provide `yoi.pod-management.v1` as a controlled façade while the actual Pod lifecycle/scope authority remains host-owned.
|
||||
- Future issue-tracker plugins may provide `project.issue-tracker.v1` for WorkItem integration.
|
||||
|
|
@ -2,12 +2,12 @@
|
|||
id: 20260603-122317-plugin-feature-contribution-registry
|
||||
slug: plugin-feature-contribution-registry
|
||||
title: Plugin: feature contribution registry for built-in and external capabilities
|
||||
status: open
|
||||
status: closed
|
||||
kind: feature
|
||||
priority: P1
|
||||
labels: [plugin, registry, tools, hooks, orchestration]
|
||||
created_at: 2026-06-03T12:23:17Z
|
||||
updated_at: 2026-06-03T16:44:05Z
|
||||
updated_at: 2026-06-04T22:26:37Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
|
@ -16,17 +16,20 @@ legacy_ticket: null
|
|||
|
||||
Yoi already has many capability surfaces: built-in tools, memory tools, Pod management tools, manifest permission hooks, workflow assets, notifications, and planned WorkItem / MCP / plugin features. If new features keep registering themselves through ad hoc Pod/Worker code paths, Plugin system work will not produce a single management boundary and later features such as WorkItem intake will be hard to detach.
|
||||
|
||||
The immediate need is not package distribution or WASM execution. The immediate need is a runtime feature contribution registry that lets built-in features and future external plugins contribute through the same existing host surfaces: Tools, Hooks, and notification/event paths.
|
||||
The immediate need is not package distribution or WASM execution. The immediate need is a runtime feature contribution registry that lets built-in features and future external plugins contribute through the same existing host surfaces: Tools, Hooks, host-managed BackgroundTasks, host-mediated Services, and durable notification/history plus alert/diagnostic paths.
|
||||
|
||||
## Direction
|
||||
|
||||
Introduce a feature registry boundary for Pod runtime capability installation.
|
||||
Introduce a feature registry boundary for Pod runtime contribution installation and host-authority grants.
|
||||
|
||||
- Feature state remains owned by the feature/extension module, not by Pod history or prompt context.
|
||||
- Pod interaction happens through existing surfaces:
|
||||
- Tool contributions registered into the normal ToolRegistry / permission / history path.
|
||||
- Hook contributions registered through the public Pod Hook boundary.
|
||||
- Notification/event contributions use existing durable Notify / Event paths rather than invisible context injection.
|
||||
- BackgroundTask contributions are host-managed for async feature work; feature modules must not create untracked runtime loops.
|
||||
- Service provider/consumer declarations allow a feature/plugin to expose a narrow public API and another feature/plugin to acquire it through host dependency resolution and host-authority grants where the service exposes host authority.
|
||||
- Model-visible notifications use the existing durable Notify / SystemItem / Event::SystemItem path rather than invisible context injection.
|
||||
- Transient human-facing alerts and diagnostics are separate host-defined outputs, not arbitrary plugin UI channels.
|
||||
- The registry is responsible for discovery/enablement diagnostics and installation into existing surfaces; it must not create a parallel execution path.
|
||||
- Built-in features should be expressible as feature contributions first. External plugin runtimes can be added later.
|
||||
|
||||
|
|
@ -44,8 +47,10 @@ Pure descriptor types may later move to a separate `plugin` / `extension` crate
|
|||
- Define contribution descriptors or install abstractions for:
|
||||
- Tools
|
||||
- Hooks
|
||||
- Notification/event-facing capabilities where needed
|
||||
- Define capability request / host grant data structures suitable for policy diagnostics.
|
||||
- BackgroundTasks
|
||||
- Service providers and service requirements
|
||||
- Model-visible notification, transient alert, and diagnostic surfaces where needed
|
||||
- Define host-authority request / grant data structures suitable for user approval and policy diagnostics. Tool/Hook/BackgroundTask/ServiceProvider declarations are contributions to display and lock by descriptor/digest, not separate sandbox authorities.
|
||||
- Add a registry/builder/install context that can install enabled feature contributions into existing Pod/Worker surfaces.
|
||||
- Preserve current behavior while moving registration toward the registry.
|
||||
- Existing built-in tool registration must still work.
|
||||
|
|
@ -67,7 +72,7 @@ Pure descriptor types may later move to a separate `plugin` / `extension` crate
|
|||
## Suggested phases
|
||||
|
||||
1. **Registry design**
|
||||
- Define feature descriptor, source, runtime kind, contribution kinds, capability request/grant, and diagnostics.
|
||||
- Define feature descriptor, source, runtime kind, contribution kinds, host-authority request/grant, and diagnostics.
|
||||
2. **Pod runtime registry skeleton**
|
||||
- Add a Pod-layer feature registry/builder and install context.
|
||||
- Keep behavior unchanged initially.
|
||||
|
|
@ -82,8 +87,8 @@ Pure descriptor types may later move to a separate `plugin` / `extension` crate
|
|||
## Acceptance criteria
|
||||
|
||||
- The codebase has a first-class feature contribution registry boundary for Pod runtime installation.
|
||||
- At least one built-in capability group is registered through the new registry without changing behavior.
|
||||
- The registry can describe Tool and Hook contributions and records source/runtime/capability diagnostics.
|
||||
- Feature installation uses existing ToolRegistry, HookRegistry, and notification/history paths; no parallel Pod context injection path is introduced.
|
||||
- At least one built-in contribution group is registered through the new registry without changing behavior.
|
||||
- The registry can describe Tool, Hook, BackgroundTask, and Service provider/requirement contributions and records source/runtime/authority diagnostics.
|
||||
- Feature installation uses existing ToolRegistry, HookRegistry, host-managed BackgroundTask lifecycle, host-mediated Service resolution, and notification/history paths; no parallel Pod context injection path or arbitrary plugin UI channel is introduced.
|
||||
- WorkItem and MCP follow-up tickets can target this registry instead of adding ad hoc registration code.
|
||||
- Focused tests cover the migrated built-in registration and capability/diagnostic behavior.
|
||||
- Focused tests cover the migrated built-in registration and authority/diagnostic behavior.
|
||||
|
|
@ -0,0 +1 @@
|
|||
Implemented the first Pod-layer feature contribution registry slice. Added pod::feature with descriptors, contribution registrars, host-authority model, service/background descriptor skeletons, notification/alert/diagnostic sinks, descriptor-approved contribution checks, once-materialized tool registration, and installed Task tools through the normal Worker tool path. External plugin loading, real approval resolver, full background/service runtime, and WorkItem/MCP integrations remain follow-up scope. Final external review approved. Merge validation passed: cargo test -p pod --lib feature::tests --no-fail-fast, cargo test -p pod --lib, cargo test -p llm-worker --lib, cargo fmt --check, cargo check --workspace --all-targets, ./tickets.sh doctor, git diff --check, nix build .#yoi, ./result/bin/yoi pod --help.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,11 @@
|
|||
# Decision: Task tools as trial built-in feature module
|
||||
|
||||
Use the Task tool group as a trial of the Feature API boundary, but keep the scope to an internal built-in module.
|
||||
|
||||
Rationale:
|
||||
|
||||
- `TaskCreate` / `TaskUpdate` / `TaskGet` / `TaskList` are already small and state-bounded around `TaskStore`.
|
||||
- They do not need external plugin loading, network, filesystem, secrets, model notification authority, or custom UI.
|
||||
- The feature registry slice already routes them through the registry; the remaining useful trial is to extract the concrete Task feature into a clean module boundary that future built-in modules can copy.
|
||||
|
||||
This ticket should not introduce a package loader, sandbox, or external-plugin permission model. It should validate the public API shape by making Task tools look like a normal built-in feature contribution: descriptor-declared tools, no host authorities, install through registry, normal ToolRegistry/PreToolCall behavior preserved.
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
# Delegation intent: Task tools built-in feature module extraction
|
||||
|
||||
## Intent
|
||||
|
||||
Extract the Task tool group from the inline `pod::feature` registry implementation into a clean built-in internal feature module. This is a trial of the contribution-only built-in module path, not external plugin loading and not the external-plugin authority model.
|
||||
|
||||
## Worktree / branch
|
||||
|
||||
Use the prepared task name:
|
||||
|
||||
- worktree: `/home/hare/Projects/yoi/.worktree/task-tools-builtin-module`
|
||||
- branch: `work/task-tools-builtin-module`
|
||||
|
||||
## Requirements
|
||||
|
||||
- Move the concrete Task feature implementation out of the generic registry/types file.
|
||||
- Current implementation is in `crates/pod/src/feature.rs` under `pub mod builtin`.
|
||||
- Candidate target: `crates/pod/src/feature/builtin/task.rs` or equivalent.
|
||||
- If converting `feature.rs` into a module directory is too large, use a small adjacent module with a clean public boundary, but keep the registry/types file focused on generic feature machinery.
|
||||
- Expose a clear construction function such as:
|
||||
- `task_tools_feature(task_store: tools::TaskStore) -> impl FeatureModule`
|
||||
- Preserve TaskStore sharing semantics.
|
||||
- The Pod host still creates/owns the Pod-lifetime/session `TaskStore`.
|
||||
- The Task feature receives the handle from the host constructor.
|
||||
- No TaskStore persistence/lifecycle change.
|
||||
- Preserve exact tool behavior and model-visible metadata for:
|
||||
- `TaskCreate`
|
||||
- `TaskUpdate`
|
||||
- `TaskGet`
|
||||
- `TaskList`
|
||||
- Ensure the Task feature descriptor declares exactly those four tools and requests no host authorities.
|
||||
- Preserve descriptor-approved contribution reconciliation and once-materialized tool identity behavior.
|
||||
- Keep normal ToolRegistry / PreToolCall permission behavior.
|
||||
- Update imports/call sites so `Controller` uses the extracted built-in module.
|
||||
- Add/update focused tests that prove Task tools install through the extracted built-in feature module and require no host authorities.
|
||||
- Keep code comments/test names clear that this is a built-in/internal feature module reference pattern, not external plugin loading.
|
||||
|
||||
## Important boundary
|
||||
|
||||
Do not solve the broader authority-model split in this ticket. That is tracked separately by `feature-api-authority-separation`.
|
||||
|
||||
For this ticket:
|
||||
|
||||
- requested authorities for Task tools should stay empty;
|
||||
- do not add or remove host authorities;
|
||||
- do not reintroduce contribution-authority variants such as `ContributeTool`, `RunBackgroundTask`, or `ProvideService`;
|
||||
- do not implement descriptor/package approval lock files.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- External plugin loading.
|
||||
- Plugin package format.
|
||||
- WASM/runtime sandboxing.
|
||||
- WorkItem or MCP integration.
|
||||
- Moving Memory or Pod management.
|
||||
- Reworking all built-in tool groups.
|
||||
- Changing Task tool names, schemas, descriptions, outputs, or task reminder/compaction behavior.
|
||||
|
||||
## Suggested files
|
||||
|
||||
- `crates/pod/src/feature.rs`
|
||||
- `crates/pod/src/lib.rs`
|
||||
- `crates/pod/src/controller.rs`
|
||||
- `crates/tools/src/task.rs`
|
||||
- `crates/tools/src/lib.rs`
|
||||
- existing feature tests in `crates/pod/src/feature.rs`
|
||||
|
||||
## Validation
|
||||
|
||||
Run at least:
|
||||
|
||||
- focused Task feature tests added/updated
|
||||
- `cargo test -p pod --lib feature::tests --no-fail-fast` or equivalent focused feature tests
|
||||
- `cargo test -p pod --lib`
|
||||
- `cargo test -p llm-worker --lib`
|
||||
- `cargo check --workspace --all-targets`
|
||||
- `cargo fmt --check`
|
||||
- `./tickets.sh doctor`
|
||||
- `git diff --check`
|
||||
|
||||
Run `nix build .#yoi` if feasible.
|
||||
|
||||
## Completion report
|
||||
|
||||
Report:
|
||||
|
||||
- worktree path / branch
|
||||
- commit hash
|
||||
- changed files
|
||||
- new module path / public constructor
|
||||
- evidence Task feature descriptor has exactly four tools and no requested authorities
|
||||
- behavior preservation notes
|
||||
- tests/validation results
|
||||
- unresolved risks/follow-ups
|
||||
- whether ready for external review
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
# Review: task-tools-builtin-plugin
|
||||
|
||||
## 1. Result
|
||||
|
||||
approve
|
||||
|
||||
## 2. Summary of implementation
|
||||
|
||||
The implementation extracts the Task tool built-in wiring out of `pod::feature` generic machinery into `pod::feature::builtin::task`. The new module exposes `task_tools_feature(task_store: tools::TaskStore) -> impl FeatureModule`, declares the four Task tools through a builtin `FeatureDescriptor`, requests no host authorities, and constructs the concrete `ToolContribution`s from the host-provided `TaskStore` via the existing `tools::task_tools` constructor.
|
||||
|
||||
Controller registration now builds a `FeatureRegistry` with `builtin::task::task_tools_feature(pod.task_store())` and installs it through the existing `Pod::install_features` path, preserving normal ToolRegistry installation and PreToolCall behavior.
|
||||
|
||||
## 3. Requirement-by-requirement assessment
|
||||
|
||||
- Task tools are extracted from generic registry/types code into a clean built-in internal feature module: satisfied. `crates/pod/src/feature.rs` no longer contains the concrete Task feature constructor; the concrete implementation lives under `crates/pod/src/feature/builtin/task.rs`.
|
||||
- New module path / constructor is clean and usable as a reference internal module pattern: satisfied. `pod::feature::builtin::task::task_tools_feature` is small, explicit, and uses the existing descriptor/contribution API without special cases.
|
||||
- `pod::feature` remains focused on generic feature machinery, not concrete Task feature implementation: satisfied. The generic file retains registry/descriptor/contribution mechanics and tests; concrete Task wiring is moved out.
|
||||
- Descriptor declares exactly `TaskCreate`, `TaskUpdate`, `TaskGet`, `TaskList`: satisfied. The descriptor declaration contains exactly those four tool names.
|
||||
- Requested host authorities are empty: satisfied. The feature uses `FeatureDescriptor::builtin(...)` and does not add requested authorities; tests assert the list is empty.
|
||||
- TaskStore sharing semantics are preserved: satisfied. The Pod/session `TaskStore` is still obtained from `pod.task_store()` at registration time and passed into the feature constructor; the feature passes that same store into the existing `tools::task_tools` constructor. No lifecycle, persistence, or reminder behavior is changed.
|
||||
- Task tool names, schemas, descriptions, outputs, and normal ToolRegistry / PreToolCall path are unchanged: satisfied. Concrete tool construction remains in `tools::task_tools`; registration still materializes `ToolContribution`s into the same worker ToolRegistry path via `Pod::install_features`.
|
||||
- Descriptor-approved contribution reconciliation and once-materialized tool identity behavior remain intact: satisfied. The extraction uses the existing `FeatureRegistryBuilder` / `FeatureContribution` reconciliation path. Added tests cover the descriptor/tool-name reconciliation and installed tool identity for the Task feature.
|
||||
- No contribution-authority variants or broader authority model changes are introduced: satisfied. No new authority variants or authority model changes appear in the diff.
|
||||
- No external plugin loading/package/WASM/WorkItem/MCP/UI/dialog changes are introduced: satisfied. The diff is limited to the Pod feature module structure, controller registration, and related tests.
|
||||
- Tests cover the extraction adequately: satisfied for the intended extraction scope. Tests assert the builtin descriptor, empty authorities, installed tool identities/order, and descriptor rejection for undeclared tools. Existing task tool behavior remains owned by the unchanged `tools` crate implementation.
|
||||
|
||||
## 4. Blockers
|
||||
|
||||
None.
|
||||
|
||||
## 5. Non-blockers / follow-ups
|
||||
|
||||
None.
|
||||
|
||||
## 6. Validation assessed or rerun
|
||||
|
||||
Assessed:
|
||||
|
||||
- Read ticket, delegation intent, and authority-split context.
|
||||
- Reviewed `git diff develop...HEAD` for coder commit `f394f15`.
|
||||
- Inspected the new builtin Task feature module, controller registration, generic feature registry code, and relevant tests.
|
||||
|
||||
Rerun:
|
||||
|
||||
- `cd /home/hare/Projects/yoi/.worktree/task-tools-builtin-module && git diff --check develop...HEAD` — passed.
|
||||
- `cd /home/hare/Projects/yoi/.worktree/task-tools-builtin-module && cargo fmt --check` — passed.
|
||||
- `cd /home/hare/Projects/yoi/.worktree/task-tools-builtin-module && ./tickets.sh doctor` — passed.
|
||||
|
||||
Not rerun:
|
||||
|
||||
- Full `cargo test` / `nix build .#yoi` were not rerun during this external sibling review; this review stayed within focused read-only validation commands.
|
||||
|
||||
## 7. Residual risk
|
||||
|
||||
Residual risk is low. The main unverified risk is ordinary integration/build regression outside the focused extraction surface because full test and Nix package validation were not rerun here. The code diff itself preserves the existing Task tool constructor and installation path, so behavioral risk appears minimal.
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
---
|
||||
id: 20260604-223500-task-tools-builtin-plugin
|
||||
slug: task-tools-builtin-plugin
|
||||
title: Feature: extract Task tools as builtin module
|
||||
status: closed
|
||||
kind: task
|
||||
priority: P1
|
||||
labels: [plugin, feature-registry, tasks]
|
||||
created_at: 2026-06-04T22:35:00Z
|
||||
updated_at: 2026-06-05T00:05:55Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Issue
|
||||
|
||||
The feature contribution registry slice proved the registry path by registering `TaskCreate` / `TaskUpdate` / `TaskGet` / `TaskList` through a built-in `FeatureModule`. That is a useful proof, but the Task tool group still lives as an inline built-in helper inside the registry implementation rather than as a clean internal module.
|
||||
|
||||
As a trial of the Feature API boundary, extract the Task tool group into a first-class built-in internal module using the same public-ish Feature API shape that future built-in modules and external plugins should use. This should validate whether the registry API is usable without giving Task tools special Pod-side wiring or treating internal module extraction as an external-plugin permission problem.
|
||||
|
||||
## Direction
|
||||
|
||||
Treat Task tools as a built-in feature/internal module, not as an ad hoc registration branch.
|
||||
|
||||
- The module contributes only the existing Task tools:
|
||||
- `TaskCreate`
|
||||
- `TaskUpdate`
|
||||
- `TaskGet`
|
||||
- `TaskList`
|
||||
- The module owns or receives the existing `TaskStore` handle from the Pod host.
|
||||
- Tool names, schemas, descriptions, behavior, task reminder/compaction behavior, and model-visible history behavior must not change.
|
||||
- Task tools remain subject to the normal ToolRegistry and PreToolCall permission path.
|
||||
- Contributions are descriptor-declared and registry-validated; no contribution-capability gates are reintroduced.
|
||||
- No external plugin loading, package format, WASM, MCP, WorkItem, or UI/dialog system is in scope.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Move the Task built-in feature out of the generic registry implementation into a clean module boundary suitable as a reference pattern for built-in feature modules.
|
||||
- Candidate placement: `crates/pod/src/feature/builtin/task.rs` or equivalent.
|
||||
- Keep `pod::feature` focused on registry/types rather than concrete built-in feature implementations.
|
||||
- Expose a clear construction function such as `task_tools_feature(task_store: tools::TaskStore) -> impl FeatureModule` from the built-in feature module.
|
||||
- Preserve the existing TaskStore sharing semantics:
|
||||
- one Pod-lifetime/session TaskStore shared by all four Task tools;
|
||||
- current TaskStore snapshot/reminder/compaction behavior remains valid.
|
||||
- Ensure the Task feature descriptor exactly declares the four Task tools and no host authorities.
|
||||
- Keep descriptor-approved contribution checks active for Task tools.
|
||||
- Keep once-materialized tool identity behavior from the registry slice.
|
||||
- Add/update focused tests proving:
|
||||
- Task tools install through the built-in feature module;
|
||||
- descriptor declarations match the installed tool names;
|
||||
- normal TaskCreate/TaskUpdate/TaskGet/TaskList behavior still works or existing tests continue to cover it;
|
||||
- no contribution authority variants are reintroduced.
|
||||
- Document, in code comments or test names where appropriate, that this is the reference built-in feature module pattern.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- External plugin discovery/loading.
|
||||
- Plugin package format or descriptor lock files.
|
||||
- WASM/runtime sandboxing.
|
||||
- WorkItem/MCP integration.
|
||||
- Moving TaskStore persistence/lifecycle semantics.
|
||||
- Changing Task tool names/schemas/descriptions.
|
||||
- Resolving the broader internal-module vs external-plugin authority split; that is tracked separately by `feature-api-authority-separation`.
|
||||
- Adding/removing Task tools.
|
||||
- Reworking all built-in tool groups.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Task tool registration is represented as a clean built-in feature module rather than inline registry implementation detail.
|
||||
- Behavior and model-visible tool metadata for `TaskCreate` / `TaskUpdate` / `TaskGet` / `TaskList` are unchanged.
|
||||
- Feature descriptor declarations and install-time contributions are reconciled through the existing registry boundary.
|
||||
- No new host authority grants are required for Task tools.
|
||||
- Focused tests and workspace checks pass.
|
||||
|
|
@ -0,0 +1 @@
|
|||
Extracted TaskCreate/TaskUpdate/TaskGet/TaskList into a clean built-in internal feature module at pod::feature::builtin::task with task_tools_feature(task_store). The descriptor declares exactly the four Task tools and no requested host authorities; TaskStore sharing, Task tool metadata/behavior, system-reminder behavior, ToolRegistry installation, and PreToolCall policy remain unchanged. External review approved. Merge validation passed: cargo test -p pod --lib feature::tests --no-fail-fast, cargo test -p pod --lib, cargo test -p llm-worker --lib, cargo fmt --check, cargo check --workspace --all-targets, ./tickets.sh doctor, git diff --check, nix build .#yoi, ./result/bin/yoi pod --help.
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
<!-- event: create author: tickets.sh at: 2026-06-04T22:35:00Z -->
|
||||
|
||||
## Created
|
||||
|
||||
Created by tickets.sh create.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: decision author: hare at: 2026-06-04T22:35:48Z -->
|
||||
|
||||
## Decision
|
||||
|
||||
# Decision: Task tools as trial built-in plugin
|
||||
|
||||
Use the Task tool group as a trial of the Plugin/Feature boundary, but keep the scope to a built-in plugin module.
|
||||
|
||||
Rationale:
|
||||
|
||||
- `TaskCreate` / `TaskUpdate` / `TaskGet` / `TaskList` are already small and state-bounded around `TaskStore`.
|
||||
- They do not need external plugin loading, network, filesystem, secrets, model notification authority, or custom UI.
|
||||
- The feature registry slice already routes them through the registry; the remaining useful trial is to extract the concrete Task feature into a clean module boundary that future built-in plugins can copy.
|
||||
|
||||
This ticket should not introduce a package loader or sandbox. It should validate the public API shape by making Task tools look like a normal built-in plugin/feature contribution: descriptor-declared tools, no host authorities, install through registry, normal ToolRegistry/PreToolCall behavior preserved.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: plan author: hare at: 2026-06-04T23:50:50Z -->
|
||||
|
||||
## Plan
|
||||
|
||||
# Delegation intent: Task tools built-in feature module extraction
|
||||
|
||||
## Intent
|
||||
|
||||
Extract the Task tool group from the inline `pod::feature` registry implementation into a clean built-in internal feature module. This is a trial of the contribution-only built-in module path, not external plugin loading and not the external-plugin authority model.
|
||||
|
||||
## Worktree / branch
|
||||
|
||||
Use the prepared task name:
|
||||
|
||||
- worktree: `/home/hare/Projects/yoi/.worktree/task-tools-builtin-module`
|
||||
- branch: `work/task-tools-builtin-module`
|
||||
|
||||
## Requirements
|
||||
|
||||
- Move the concrete Task feature implementation out of the generic registry/types file.
|
||||
- Current implementation is in `crates/pod/src/feature.rs` under `pub mod builtin`.
|
||||
- Candidate target: `crates/pod/src/feature/builtin/task.rs` or equivalent.
|
||||
- If converting `feature.rs` into a module directory is too large, use a small adjacent module with a clean public boundary, but keep the registry/types file focused on generic feature machinery.
|
||||
- Expose a clear construction function such as:
|
||||
- `task_tools_feature(task_store: tools::TaskStore) -> impl FeatureModule`
|
||||
- Preserve TaskStore sharing semantics.
|
||||
- The Pod host still creates/owns the Pod-lifetime/session `TaskStore`.
|
||||
- The Task feature receives the handle from the host constructor.
|
||||
- No TaskStore persistence/lifecycle change.
|
||||
- Preserve exact tool behavior and model-visible metadata for:
|
||||
- `TaskCreate`
|
||||
- `TaskUpdate`
|
||||
- `TaskGet`
|
||||
- `TaskList`
|
||||
- Ensure the Task feature descriptor declares exactly those four tools and requests no host authorities.
|
||||
- Preserve descriptor-approved contribution reconciliation and once-materialized tool identity behavior.
|
||||
- Keep normal ToolRegistry / PreToolCall permission behavior.
|
||||
- Update imports/call sites so `Controller` uses the extracted built-in module.
|
||||
- Add/update focused tests that prove Task tools install through the extracted built-in feature module and require no host authorities.
|
||||
- Keep code comments/test names clear that this is a built-in/internal feature module reference pattern, not external plugin loading.
|
||||
|
||||
## Important boundary
|
||||
|
||||
Do not solve the broader authority-model split in this ticket. That is tracked separately by `feature-api-authority-separation`.
|
||||
|
||||
For this ticket:
|
||||
|
||||
- requested authorities for Task tools should stay empty;
|
||||
- do not add or remove host authorities;
|
||||
- do not reintroduce contribution-authority variants such as `ContributeTool`, `RunBackgroundTask`, or `ProvideService`;
|
||||
- do not implement descriptor/package approval lock files.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- External plugin loading.
|
||||
- Plugin package format.
|
||||
- WASM/runtime sandboxing.
|
||||
- WorkItem or MCP integration.
|
||||
- Moving Memory or Pod management.
|
||||
- Reworking all built-in tool groups.
|
||||
- Changing Task tool names, schemas, descriptions, outputs, or task reminder/compaction behavior.
|
||||
|
||||
## Suggested files
|
||||
|
||||
- `crates/pod/src/feature.rs`
|
||||
- `crates/pod/src/lib.rs`
|
||||
- `crates/pod/src/controller.rs`
|
||||
- `crates/tools/src/task.rs`
|
||||
- `crates/tools/src/lib.rs`
|
||||
- existing feature tests in `crates/pod/src/feature.rs`
|
||||
|
||||
## Validation
|
||||
|
||||
Run at least:
|
||||
|
||||
- focused Task feature tests added/updated
|
||||
- `cargo test -p pod --lib feature::tests --no-fail-fast` or equivalent focused feature tests
|
||||
- `cargo test -p pod --lib`
|
||||
- `cargo test -p llm-worker --lib`
|
||||
- `cargo check --workspace --all-targets`
|
||||
- `cargo fmt --check`
|
||||
- `./tickets.sh doctor`
|
||||
- `git diff --check`
|
||||
|
||||
Run `nix build .#yoi` if feasible.
|
||||
|
||||
## Completion report
|
||||
|
||||
Report:
|
||||
|
||||
- worktree path / branch
|
||||
- commit hash
|
||||
- changed files
|
||||
- new module path / public constructor
|
||||
- evidence Task feature descriptor has exactly four tools and no requested authorities
|
||||
- behavior preservation notes
|
||||
- tests/validation results
|
||||
- unresolved risks/follow-ups
|
||||
- whether ready for external review
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: review author: hare at: 2026-06-05T00:04:53Z status: approve -->
|
||||
|
||||
## Review: approve
|
||||
|
||||
# Review: task-tools-builtin-plugin
|
||||
|
||||
## 1. Result
|
||||
|
||||
approve
|
||||
|
||||
## 2. Summary of implementation
|
||||
|
||||
The implementation extracts the Task tool built-in wiring out of `pod::feature` generic machinery into `pod::feature::builtin::task`. The new module exposes `task_tools_feature(task_store: tools::TaskStore) -> impl FeatureModule`, declares the four Task tools through a builtin `FeatureDescriptor`, requests no host authorities, and constructs the concrete `ToolContribution`s from the host-provided `TaskStore` via the existing `tools::task_tools` constructor.
|
||||
|
||||
Controller registration now builds a `FeatureRegistry` with `builtin::task::task_tools_feature(pod.task_store())` and installs it through the existing `Pod::install_features` path, preserving normal ToolRegistry installation and PreToolCall behavior.
|
||||
|
||||
## 3. Requirement-by-requirement assessment
|
||||
|
||||
- Task tools are extracted from generic registry/types code into a clean built-in internal feature module: satisfied. `crates/pod/src/feature.rs` no longer contains the concrete Task feature constructor; the concrete implementation lives under `crates/pod/src/feature/builtin/task.rs`.
|
||||
- New module path / constructor is clean and usable as a reference internal module pattern: satisfied. `pod::feature::builtin::task::task_tools_feature` is small, explicit, and uses the existing descriptor/contribution API without special cases.
|
||||
- `pod::feature` remains focused on generic feature machinery, not concrete Task feature implementation: satisfied. The generic file retains registry/descriptor/contribution mechanics and tests; concrete Task wiring is moved out.
|
||||
- Descriptor declares exactly `TaskCreate`, `TaskUpdate`, `TaskGet`, `TaskList`: satisfied. The descriptor declaration contains exactly those four tool names.
|
||||
- Requested host authorities are empty: satisfied. The feature uses `FeatureDescriptor::builtin(...)` and does not add requested authorities; tests assert the list is empty.
|
||||
- TaskStore sharing semantics are preserved: satisfied. The Pod/session `TaskStore` is still obtained from `pod.task_store()` at registration time and passed into the feature constructor; the feature passes that same store into the existing `tools::task_tools` constructor. No lifecycle, persistence, or reminder behavior is changed.
|
||||
- Task tool names, schemas, descriptions, outputs, and normal ToolRegistry / PreToolCall path are unchanged: satisfied. Concrete tool construction remains in `tools::task_tools`; registration still materializes `ToolContribution`s into the same worker ToolRegistry path via `Pod::install_features`.
|
||||
- Descriptor-approved contribution reconciliation and once-materialized tool identity behavior remain intact: satisfied. The extraction uses the existing `FeatureRegistryBuilder` / `FeatureContribution` reconciliation path. Added tests cover the descriptor/tool-name reconciliation and installed tool identity for the Task feature.
|
||||
- No contribution-authority variants or broader authority model changes are introduced: satisfied. No new authority variants or authority model changes appear in the diff.
|
||||
- No external plugin loading/package/WASM/WorkItem/MCP/UI/dialog changes are introduced: satisfied. The diff is limited to the Pod feature module structure, controller registration, and related tests.
|
||||
- Tests cover the extraction adequately: satisfied for the intended extraction scope. Tests assert the builtin descriptor, empty authorities, installed tool identities/order, and descriptor rejection for undeclared tools. Existing task tool behavior remains owned by the unchanged `tools` crate implementation.
|
||||
|
||||
## 4. Blockers
|
||||
|
||||
None.
|
||||
|
||||
## 5. Non-blockers / follow-ups
|
||||
|
||||
None.
|
||||
|
||||
## 6. Validation assessed or rerun
|
||||
|
||||
Assessed:
|
||||
|
||||
- Read ticket, delegation intent, and authority-split context.
|
||||
- Reviewed `git diff develop...HEAD` for coder commit `f394f15`.
|
||||
- Inspected the new builtin Task feature module, controller registration, generic feature registry code, and relevant tests.
|
||||
|
||||
Rerun:
|
||||
|
||||
- `cd /home/hare/Projects/yoi/.worktree/task-tools-builtin-module && git diff --check develop...HEAD` — passed.
|
||||
- `cd /home/hare/Projects/yoi/.worktree/task-tools-builtin-module && cargo fmt --check` — passed.
|
||||
- `cd /home/hare/Projects/yoi/.worktree/task-tools-builtin-module && ./tickets.sh doctor` — passed.
|
||||
|
||||
Not rerun:
|
||||
|
||||
- Full `cargo test` / `nix build .#yoi` were not rerun during this external sibling review; this review stayed within focused read-only validation commands.
|
||||
|
||||
## 7. Residual risk
|
||||
|
||||
Residual risk is low. The main unverified risk is ordinary integration/build regression outside the focused extraction surface because full test and Nix package validation were not rerun here. The code diff itself preserves the existing Task tool constructor and installation path, so behavioral risk appears minimal.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-06-05T00:05:55Z status: closed -->
|
||||
|
||||
## Closed
|
||||
|
||||
Extracted TaskCreate/TaskUpdate/TaskGet/TaskList into a clean built-in internal feature module at pod::feature::builtin::task with task_tools_feature(task_store). The descriptor declares exactly the four Task tools and no requested host authorities; TaskStore sharing, Task tool metadata/behavior, system-reminder behavior, ToolRegistry installation, and PreToolCall policy remain unchanged. External review approved. Merge validation passed: cargo test -p pod --lib feature::tests --no-fail-fast, cargo test -p pod --lib, cargo test -p llm-worker --lib, cargo fmt --check, cargo check --workspace --all-targets, ./tickets.sh doctor, git diff --check, nix build .#yoi, ./result/bin/yoi pod --help.
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
# Delegation intent: Hook context SystemItem append sink
|
||||
|
||||
## Intent
|
||||
|
||||
Implement the first step in the Task state/reminder cleanup sequence: add event-specific Hook context support with a host-mediated durable `SystemItem` append handle, while keeping Hook return actions as per-hook-point flow-control actions.
|
||||
|
||||
This is the prerequisite for moving TaskStore/reminder logic into the Task feature. Do not move TaskStore or Task reminders in this ticket.
|
||||
|
||||
## Worktree / branch
|
||||
|
||||
- worktree: `/home/hare/Projects/yoi/.worktree/hook-context-system-item-sink`
|
||||
- branch: `work/hook-context-system-item-sink`
|
||||
|
||||
## Requirements
|
||||
|
||||
- Evolve the public Pod Hook API so Hook handlers receive event-specific context values rather than only bare summary inputs where necessary.
|
||||
- Preserve the existing per-hook-point action/output types.
|
||||
- Do not collapse actions into one global `HookDecision`.
|
||||
- Add a host-created typed handle for durable model-visible `SystemItem` append.
|
||||
- Suggested naming: `SystemItemAppendHandle`, `SystemItemSink`, or equivalent.
|
||||
- Constructors/fields must stay host-private; feature/hook code can only use handles the host provides.
|
||||
- The handle must not expose raw `llm_worker::Item`, raw history writers, raw event senders, raw `Pod`, raw `Worker`, or `NotifyBuffer`.
|
||||
- The append path must use existing durable history semantics:
|
||||
- host-controlled pending append / commit path;
|
||||
- `LogEntry::SystemItem`;
|
||||
- `Event::SystemItem`;
|
||||
- model context visibility only after durable commit.
|
||||
- The initial approved system-item requests should be narrow.
|
||||
- Support what is needed for a future `TaskReminder` hook, and notification-like system items only if this falls out naturally from existing `SystemItem` machinery.
|
||||
- Do not introduce arbitrary `llm_worker::Item` append or generic plugin event channels.
|
||||
- Keep built-in internal modules distinct from external-plugin authority approval.
|
||||
- It is okay to add scaffolding so internal hooks can receive the handle by host policy.
|
||||
- Do not implement external-plugin approval or WASM imports in this ticket.
|
||||
- Preserve current observable behavior.
|
||||
- Current Task reminders should continue to be emitted by existing `PodInterceptor` logic until the follow-up ticket moves them.
|
||||
- Existing Hook tests and permission hook behavior must continue to pass.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Moving `TaskStore` ownership into the Task feature.
|
||||
- Moving `TaskReminderState` or `PodInterceptor` reminder logic.
|
||||
- TUI UI/dialog work.
|
||||
- Generic event channel / arbitrary UI payloads.
|
||||
- External plugin loading or package approval.
|
||||
- Changing ToolRegistry / PreToolCall permission behavior.
|
||||
- Reintroducing raw `Item` injection, `ContinueWith(Vec<Item>)`, no-result tool skip, or arbitrary `ToolResult` construction.
|
||||
|
||||
## Suggested files
|
||||
|
||||
- `crates/pod/src/hook.rs`
|
||||
- `crates/pod/src/ipc/interceptor.rs`
|
||||
- `crates/pod/src/pod.rs`
|
||||
- `crates/pod/src/controller.rs`
|
||||
- `crates/session-store/src/system_item.rs` or wherever `SystemItem` request/serialization is defined
|
||||
- existing Hook tests in `crates/pod/src/**`
|
||||
|
||||
## Validation
|
||||
|
||||
Run at least:
|
||||
|
||||
- focused Hook context / SystemItem sink tests added by this ticket
|
||||
- `cargo test -p pod hook --lib`
|
||||
- `cargo test -p pod --lib`
|
||||
- `cargo test -p llm-worker --lib`
|
||||
- `cargo check --workspace --all-targets`
|
||||
- `cargo fmt --check`
|
||||
- `./tickets.sh doctor`
|
||||
- `git diff --check`
|
||||
|
||||
Run `nix build .#yoi` if feasible.
|
||||
|
||||
## Escalate if
|
||||
|
||||
- Implementing the handle requires changing session/history commit semantics.
|
||||
- The only easy path is to expose raw `Item`, raw history writers, raw event senders, or `Pod`/`Worker` internals.
|
||||
- Hook action types would need to be merged into one generic return type.
|
||||
- Current Task reminder behavior would change before the Task ownership follow-up.
|
||||
|
||||
## Completion report
|
||||
|
||||
Report:
|
||||
|
||||
- worktree path / branch
|
||||
- commit hash
|
||||
- changed files
|
||||
- Hook context/action API changes
|
||||
- SystemItem append handle design and path to durable commit
|
||||
- tests added/updated
|
||||
- validation results
|
||||
- unresolved risks/follow-ups
|
||||
- whether ready for external review
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
# Review: hook-context-system-item-sink
|
||||
|
||||
## 1. Result: approve
|
||||
|
||||
Approve. I found no blockers for merging `04a4a73 pod: add hook system item sink` against the stated prerequisite scope.
|
||||
|
||||
## 2. Summary of implementation
|
||||
|
||||
The implementation changes the `PreLlmRequest` hook input from bare `PreRequestInfo` to `PreRequestContext`, while leaving hook return types event-specific and flow-control-only. `PreRequestContext` carries the existing request summary plus an optional `SystemItemAppendHandle`.
|
||||
|
||||
`SystemItemAppendHandle` is host-constructed (`pub(crate) fn new`), has private storage, and exposes only `append_task_reminder(...)`, which queues a typed `SystemItem::TaskReminder`. `PodInterceptor::pre_llm_request` creates a per-request queue only when a log writer exists, passes the handle through the hook context, and after all pre-request hooks continue successfully drains the queue, commits each item through the existing `SystemItemCommitter` / `LogEntry::SystemItem` path, and returns the rendered history items via the internal `PreRequestAction::ContinueWith` lane.
|
||||
|
||||
The existing Task reminder implementation remains in `PodInterceptor::pending_history_appends()` and Task ownership is not moved in this ticket.
|
||||
|
||||
## 3. Requirement-by-requirement assessment
|
||||
|
||||
- **Hook return actions remain per-hook-point flow-control types:** Satisfied. `HookPromptAction`, `HookPreRequestAction`, `HookPreToolAction`, `HookPostToolAction`, and `HookTurnEndAction` remain distinct. `HookPreRequestAction` still exposes only `Continue`, `Cancel`, and `Yield`; it does not expose `ContinueWith(Vec<Item>)`.
|
||||
- **Public API avoids raw internals and generic effects:** Satisfied for the changed Hook surface. The new handle does not expose raw `llm_worker::Item`, history writers, event senders, `Pod`, `Worker`, `NotifyBuffer`, internal interceptor actions, no-result skip, arbitrary `ToolResult` construction, or a generic event/UI channel. Existing pre-tool denial still maps an error string to an internal synthetic result, not an arbitrary public `ToolResult`.
|
||||
- **`SystemItemAppendHandle` host-private construction and narrow approved surface:** Satisfied. The constructor and backing queue are crate-private/private, and public hook code can only request a task-reminder system item through `append_task_reminder`.
|
||||
- **Durable model-visible append path:** Satisfied within existing semantics. Hook-queued items are committed through the existing `SystemItemCommitter` path as `LogEntry::SystemItem`, which in production publishes through the segment log sink as `Event::SystemItem`; only then are corresponding model-visible history items returned to the worker through the internal interceptor action.
|
||||
- **Successful-continue-only injection:** Satisfied by code. `pre_llm_request` returns immediately on the first non-continue hook action before draining the local queue, so queued hook items are dropped on cancel/yield and are committed/injected only when all hooks continue.
|
||||
- **Task reminder behavior preserved:** Satisfied. `TaskStore`, `TaskReminderState`, `task_reminder_system_item`, `pending_history_appends`, and task-tool reset behavior remain in `PodInterceptor`; the follow-up ticket remains responsible for moving Task ownership into the Task feature.
|
||||
- **Existing permission/hook behavior remains conceptually valid:** Satisfied. Tool hook contexts remain bounded summaries; public pre-tool actions still provide only continue/deny/abort/pause, with no no-result skip or arbitrary tool result path.
|
||||
- **Tests:** Mostly satisfied. Added tests cover handle queueing of an approved task reminder, context handle absence, commit plus returned system message behavior, and no-log-writer behavior. Existing tests continue to cover public hook action restrictions and current task reminder lanes. The only test gap I noted is explicit append-then-cancel/yield no-leak coverage, but the implementation path is straightforward enough that I do not consider this a merge blocker.
|
||||
- **No broad refactor / no external-plugin approval implementation:** Satisfied. The diff is limited to `crates/pod/src/hook.rs`, `crates/pod/src/ipc/interceptor.rs`, and a small `PreRequestContext` type update in `crates/pod/src/pod.rs`.
|
||||
|
||||
## 4. Blockers
|
||||
|
||||
None.
|
||||
|
||||
## 5. Non-blockers / follow-ups
|
||||
|
||||
- Add an explicit regression test for a pre-request hook that appends through `SystemItemAppendHandle` followed by a later hook returning `Cancel` or `Yield`, asserting that no commit and no `ContinueWith` occur. The code already behaves this way, but the intended no-leak invariant is important enough to lock down.
|
||||
- Before any external plugin/user-script hook surface is enabled, add per-hook or per-feature authority policy for whether `PreRequestContext::system_items()` is present. This ticket intentionally avoids external-plugin approval, and current built-in/internal hook usage is acceptable.
|
||||
|
||||
## 6. Validation assessed or rerun
|
||||
|
||||
Assessed:
|
||||
|
||||
- Reviewed ticket, delegation intent, investigation note, authority-context decision, and dependent Task ownership follow-up.
|
||||
- Reviewed `git diff develop...HEAD`; changed files are limited to:
|
||||
- `crates/pod/src/hook.rs`
|
||||
- `crates/pod/src/ipc/interceptor.rs`
|
||||
- `crates/pod/src/pod.rs`
|
||||
- Inspected the relevant public hook API, handle construction/surface, interceptor commit path, and task reminder preservation tests.
|
||||
|
||||
Rerun:
|
||||
|
||||
- `cd /home/hare/Projects/yoi/.worktree/hook-context-system-item-sink && git diff --check develop...HEAD` — passed with no output.
|
||||
|
||||
Not rerun:
|
||||
|
||||
- I did not rerun `cargo test`, `cargo check`, or `nix build` from this external-reviewer turn because the requested validation allowance was read-only and those commands would write build artifacts. I did not find a coder validation report in the ticket thread.
|
||||
|
||||
## 7. Residual risk
|
||||
|
||||
The remaining risk is in future authority granularity, not this prerequisite implementation: today the handle is present for pre-request hooks when a log writer exists, rather than being selected per installed feature/hook. That should be tightened before exposing hooks to untrusted external plugins. The existing `SystemItemCommitter` API also logs and drops commit failures rather than returning a result; this is pre-existing behavior shared by other SystemItem lanes, but it means disk-write failure handling is not made stronger by this ticket.
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# Investigation: Task state and Hook side-effect boundary
|
||||
|
||||
## Findings
|
||||
|
||||
- Hook action types are already separated per hook point after Hook hardening. The next design should preserve that: flow-control actions stay event-specific rather than becoming one global `HookDecision`.
|
||||
- Hook inputs are still summary structs, not contexts with host-created handles. That is the missing abstraction for feature-owned behavior that needs durable host side effects.
|
||||
- `TaskStore` is still owned by `Pod`, and `TaskReminderState`/reminder emission is still owned by `PodInterceptor`.
|
||||
- The Task built-in feature module currently contributes only the four Task tools and receives `TaskStore` from Pod. This is an incomplete internal-module boundary: Task-specific state still remains on the Pod side.
|
||||
- `SystemItem::TaskReminder` is currently appended through `PodInterceptor::pending_history_appends()`, which is the correct durable history direction but the wrong ownership location for Task-specific logic.
|
||||
|
||||
## Decision
|
||||
|
||||
Split follow-up into two steps:
|
||||
|
||||
1. Add Hook context host handles, especially a durable `SystemItem` append handle. Hook returns remain per-hook-point flow-control actions. No raw `Item` injection and no generic effect/event channel.
|
||||
2. Move TaskStore and Task reminder logic into the Task feature module, implemented as Task-owned tools plus hooks that use the host-provided SystemItem append handle.
|
||||
|
||||
This keeps Pod responsible for generic host surfaces and history authority, while Task owns Task-specific state and policy.
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
---
|
||||
id: 20260605-004807-hook-context-system-item-sink
|
||||
slug: hook-context-system-item-sink
|
||||
title: Hook: add context handles for host-mediated SystemItem append
|
||||
status: closed
|
||||
kind: feature
|
||||
priority: P1
|
||||
labels: [hooks, feature-registry, history, task-reminder]
|
||||
created_at: 2026-06-05T00:48:07Z
|
||||
updated_at: 2026-06-05T01:26:06Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Issue
|
||||
|
||||
Built-in feature modules need a closed way to influence Pod-side behavior without adding feature-specific logic back into `Pod` / `PodInterceptor`. Task reminders are the immediate example: a Task feature should be able to observe request/tool activity and append a `SystemItem::TaskReminder`, but it should not receive `Pod`, `Worker`, raw history writers, raw `llm_worker::Item` injection, or a generic effect channel.
|
||||
|
||||
The current Hook surface already has per-hook-point action types after `hook-public-surface-hardening`, and those action types should remain flow-control oriented. The missing piece is event-specific Hook context carrying host-owned handles for allowed side effects, especially a durable `SystemItem` append path.
|
||||
|
||||
## Current findings
|
||||
|
||||
- `crates/pod/src/hook.rs` already distinguishes hook point output types such as `HookPromptAction`, `HookPreRequestAction`, `HookPreToolAction`, `HookPostToolAction`, and `HookTurnEndAction`.
|
||||
- `PreLlmRequest` / `OnTurnEnd` public actions no longer expose raw `Item` injection.
|
||||
- Hook inputs are currently summary structs (`PreRequestInfo`, `ToolCallSummary`, etc.), not context objects with host handles.
|
||||
- `TaskReminder` emission currently lives in `PodInterceptor::pending_history_appends()` and uses Pod-owned `TaskStore` / `TaskReminderState`.
|
||||
- Existing durable model-visible path is `SystemItem` commit -> `LogEntry::SystemItem` -> `Event::SystemItem` / model context, and this path must remain the only model-visible append mechanism.
|
||||
|
||||
## Direction
|
||||
|
||||
Introduce event-specific Hook context objects that carry narrow, host-created handles. Keep Hook return values as per-hook-point flow-control actions.
|
||||
|
||||
- Side effects are not returned as arbitrary `HookEffect` enums.
|
||||
- Side effects are requested through typed handles on the Hook context.
|
||||
- Handles have private constructors and are created only by the Pod host.
|
||||
- Missing authority/handle should prevent installation or make the handle unavailable; runtime rejection is defense-in-depth, not the normal API path.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Define event-specific Hook context types, or evolve `HookEventKind::Input` into context values without losing the existing per-event action types.
|
||||
- `PreLlmRequest` context should be able to carry request summary plus a SystemItem append handle where the host grants it.
|
||||
- Tool hook contexts should continue to expose bounded call/result summaries.
|
||||
- Add a host-owned `SystemItemAppendHandle` / sink concept for model-visible durable system items.
|
||||
- It must not expose raw `llm_worker::Item` or raw history writer access.
|
||||
- It should append/push only approved `SystemItem` request kinds, initially including TaskReminder/Notification-like system items as needed.
|
||||
- Actual commit timing remains host-controlled through the existing pending history append / `LogEntry::SystemItem` / `Event::SystemItem` path.
|
||||
- Preserve per-hook-point flow-control action types.
|
||||
- Do not collapse every hook into one generic `HookDecision`.
|
||||
- Do not reintroduce `ContinueWith(Vec<Item>)`, no-result tool skip, arbitrary `ToolResult` construction, or hidden context mutation.
|
||||
- Keep internal built-in module usage distinct from external-plugin authority approval.
|
||||
- Built-in modules may receive host handles according to host policy.
|
||||
- Future external plugins receive model-visible append handles only when the external-plugin authority model grants them.
|
||||
- Provide tests showing:
|
||||
- Hook actions remain flow-control only;
|
||||
- model-visible side effects use the SystemItem append handle;
|
||||
- no raw `Item` / raw history writer / raw event sender is exposed through public Hook context;
|
||||
- missing/unavailable handles do not produce invisible context injection.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Moving TaskStore/reminder state into the Task feature; that is tracked by `task-feature-own-store-reminder-hooks`.
|
||||
- TUI dialog/custom UI work.
|
||||
- Generic plugin event channels.
|
||||
- External plugin loading/WASM boundary implementation.
|
||||
- Changing ToolRegistry / PreToolCall permission behavior.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Hook contexts can carry host-owned typed handles for allowed side effects, starting with durable SystemItem append.
|
||||
- Hook return values remain event-specific flow-control actions.
|
||||
- Model-visible additions through Hooks are committed through the durable SystemItem path and are user-inspectable.
|
||||
- Public Hook APIs still do not expose raw `Item` injection, raw Pod/Worker/session internals, or generic UI/event payload channels.
|
||||
- The resulting API is sufficient for a later Task reminder Hook to append `SystemItem::TaskReminder` without Pod-specific Task logic in `PodInterceptor`.
|
||||
|
|
@ -0,0 +1 @@
|
|||
Added PreRequestContext and host-mediated SystemItemAppendHandle for pre-request hooks. Hook returns remain per-hook-point flow-control actions; the append handle has host-private construction and a narrow task-reminder request surface; queued system items commit through the existing LogEntry::SystemItem / Event::SystemItem path only on successful continue. Existing Task reminder ownership remains unchanged for the follow-up ticket. External review approved. Merge validation passed: cargo test -p pod hook --lib, cargo test -p pod --lib, cargo test -p llm-worker --lib, cargo fmt --check, cargo check --workspace --all-targets, ./tickets.sh doctor, git diff --check, nix build .#yoi, ./result/bin/yoi pod --help.
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
<!-- event: create author: tickets.sh at: 2026-06-05T00:48:07Z -->
|
||||
|
||||
## Created
|
||||
|
||||
Created by tickets.sh create.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: decision author: hare at: 2026-06-05T00:49:53Z -->
|
||||
|
||||
## Decision
|
||||
|
||||
# Investigation: Task state and Hook side-effect boundary
|
||||
|
||||
## Findings
|
||||
|
||||
- Hook action types are already separated per hook point after Hook hardening. The next design should preserve that: flow-control actions stay event-specific rather than becoming one global `HookDecision`.
|
||||
- Hook inputs are still summary structs, not contexts with host-created handles. That is the missing abstraction for feature-owned behavior that needs durable host side effects.
|
||||
- `TaskStore` is still owned by `Pod`, and `TaskReminderState`/reminder emission is still owned by `PodInterceptor`.
|
||||
- The Task built-in feature module currently contributes only the four Task tools and receives `TaskStore` from Pod. This is an incomplete internal-module boundary: Task-specific state still remains on the Pod side.
|
||||
- `SystemItem::TaskReminder` is currently appended through `PodInterceptor::pending_history_appends()`, which is the correct durable history direction but the wrong ownership location for Task-specific logic.
|
||||
|
||||
## Decision
|
||||
|
||||
Split follow-up into two steps:
|
||||
|
||||
1. Add Hook context host handles, especially a durable `SystemItem` append handle. Hook returns remain per-hook-point flow-control actions. No raw `Item` injection and no generic effect/event channel.
|
||||
2. Move TaskStore and Task reminder logic into the Task feature module, implemented as Task-owned tools plus hooks that use the host-provided SystemItem append handle.
|
||||
|
||||
This keeps Pod responsible for generic host surfaces and history authority, while Task owns Task-specific state and policy.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: plan author: hare at: 2026-06-05T01:01:20Z -->
|
||||
|
||||
## Plan
|
||||
|
||||
# Delegation intent: Hook context SystemItem append sink
|
||||
|
||||
## Intent
|
||||
|
||||
Implement the first step in the Task state/reminder cleanup sequence: add event-specific Hook context support with a host-mediated durable `SystemItem` append handle, while keeping Hook return actions as per-hook-point flow-control actions.
|
||||
|
||||
This is the prerequisite for moving TaskStore/reminder logic into the Task feature. Do not move TaskStore or Task reminders in this ticket.
|
||||
|
||||
## Worktree / branch
|
||||
|
||||
- worktree: `/home/hare/Projects/yoi/.worktree/hook-context-system-item-sink`
|
||||
- branch: `work/hook-context-system-item-sink`
|
||||
|
||||
## Requirements
|
||||
|
||||
- Evolve the public Pod Hook API so Hook handlers receive event-specific context values rather than only bare summary inputs where necessary.
|
||||
- Preserve the existing per-hook-point action/output types.
|
||||
- Do not collapse actions into one global `HookDecision`.
|
||||
- Add a host-created typed handle for durable model-visible `SystemItem` append.
|
||||
- Suggested naming: `SystemItemAppendHandle`, `SystemItemSink`, or equivalent.
|
||||
- Constructors/fields must stay host-private; feature/hook code can only use handles the host provides.
|
||||
- The handle must not expose raw `llm_worker::Item`, raw history writers, raw event senders, raw `Pod`, raw `Worker`, or `NotifyBuffer`.
|
||||
- The append path must use existing durable history semantics:
|
||||
- host-controlled pending append / commit path;
|
||||
- `LogEntry::SystemItem`;
|
||||
- `Event::SystemItem`;
|
||||
- model context visibility only after durable commit.
|
||||
- The initial approved system-item requests should be narrow.
|
||||
- Support what is needed for a future `TaskReminder` hook, and notification-like system items only if this falls out naturally from existing `SystemItem` machinery.
|
||||
- Do not introduce arbitrary `llm_worker::Item` append or generic plugin event channels.
|
||||
- Keep built-in internal modules distinct from external-plugin authority approval.
|
||||
- It is okay to add scaffolding so internal hooks can receive the handle by host policy.
|
||||
- Do not implement external-plugin approval or WASM imports in this ticket.
|
||||
- Preserve current observable behavior.
|
||||
- Current Task reminders should continue to be emitted by existing `PodInterceptor` logic until the follow-up ticket moves them.
|
||||
- Existing Hook tests and permission hook behavior must continue to pass.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Moving `TaskStore` ownership into the Task feature.
|
||||
- Moving `TaskReminderState` or `PodInterceptor` reminder logic.
|
||||
- TUI UI/dialog work.
|
||||
- Generic event channel / arbitrary UI payloads.
|
||||
- External plugin loading or package approval.
|
||||
- Changing ToolRegistry / PreToolCall permission behavior.
|
||||
- Reintroducing raw `Item` injection, `ContinueWith(Vec<Item>)`, no-result tool skip, or arbitrary `ToolResult` construction.
|
||||
|
||||
## Suggested files
|
||||
|
||||
- `crates/pod/src/hook.rs`
|
||||
- `crates/pod/src/ipc/interceptor.rs`
|
||||
- `crates/pod/src/pod.rs`
|
||||
- `crates/pod/src/controller.rs`
|
||||
- `crates/session-store/src/system_item.rs` or wherever `SystemItem` request/serialization is defined
|
||||
- existing Hook tests in `crates/pod/src/**`
|
||||
|
||||
## Validation
|
||||
|
||||
Run at least:
|
||||
|
||||
- focused Hook context / SystemItem sink tests added by this ticket
|
||||
- `cargo test -p pod hook --lib`
|
||||
- `cargo test -p pod --lib`
|
||||
- `cargo test -p llm-worker --lib`
|
||||
- `cargo check --workspace --all-targets`
|
||||
- `cargo fmt --check`
|
||||
- `./tickets.sh doctor`
|
||||
- `git diff --check`
|
||||
|
||||
Run `nix build .#yoi` if feasible.
|
||||
|
||||
## Escalate if
|
||||
|
||||
- Implementing the handle requires changing session/history commit semantics.
|
||||
- The only easy path is to expose raw `Item`, raw history writers, raw event senders, or `Pod`/`Worker` internals.
|
||||
- Hook action types would need to be merged into one generic return type.
|
||||
- Current Task reminder behavior would change before the Task ownership follow-up.
|
||||
|
||||
## Completion report
|
||||
|
||||
Report:
|
||||
|
||||
- worktree path / branch
|
||||
- commit hash
|
||||
- changed files
|
||||
- Hook context/action API changes
|
||||
- SystemItem append handle design and path to durable commit
|
||||
- tests added/updated
|
||||
- validation results
|
||||
- unresolved risks/follow-ups
|
||||
- whether ready for external review
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: review author: hare at: 2026-06-05T01:25:03Z status: approve -->
|
||||
|
||||
## Review: approve
|
||||
|
||||
# Review: hook-context-system-item-sink
|
||||
|
||||
## 1. Result: approve
|
||||
|
||||
Approve. I found no blockers for merging `04a4a73 pod: add hook system item sink` against the stated prerequisite scope.
|
||||
|
||||
## 2. Summary of implementation
|
||||
|
||||
The implementation changes the `PreLlmRequest` hook input from bare `PreRequestInfo` to `PreRequestContext`, while leaving hook return types event-specific and flow-control-only. `PreRequestContext` carries the existing request summary plus an optional `SystemItemAppendHandle`.
|
||||
|
||||
`SystemItemAppendHandle` is host-constructed (`pub(crate) fn new`), has private storage, and exposes only `append_task_reminder(...)`, which queues a typed `SystemItem::TaskReminder`. `PodInterceptor::pre_llm_request` creates a per-request queue only when a log writer exists, passes the handle through the hook context, and after all pre-request hooks continue successfully drains the queue, commits each item through the existing `SystemItemCommitter` / `LogEntry::SystemItem` path, and returns the rendered history items via the internal `PreRequestAction::ContinueWith` lane.
|
||||
|
||||
The existing Task reminder implementation remains in `PodInterceptor::pending_history_appends()` and Task ownership is not moved in this ticket.
|
||||
|
||||
## 3. Requirement-by-requirement assessment
|
||||
|
||||
- **Hook return actions remain per-hook-point flow-control types:** Satisfied. `HookPromptAction`, `HookPreRequestAction`, `HookPreToolAction`, `HookPostToolAction`, and `HookTurnEndAction` remain distinct. `HookPreRequestAction` still exposes only `Continue`, `Cancel`, and `Yield`; it does not expose `ContinueWith(Vec<Item>)`.
|
||||
- **Public API avoids raw internals and generic effects:** Satisfied for the changed Hook surface. The new handle does not expose raw `llm_worker::Item`, history writers, event senders, `Pod`, `Worker`, `NotifyBuffer`, internal interceptor actions, no-result skip, arbitrary `ToolResult` construction, or a generic event/UI channel. Existing pre-tool denial still maps an error string to an internal synthetic result, not an arbitrary public `ToolResult`.
|
||||
- **`SystemItemAppendHandle` host-private construction and narrow approved surface:** Satisfied. The constructor and backing queue are crate-private/private, and public hook code can only request a task-reminder system item through `append_task_reminder`.
|
||||
- **Durable model-visible append path:** Satisfied within existing semantics. Hook-queued items are committed through the existing `SystemItemCommitter` path as `LogEntry::SystemItem`, which in production publishes through the segment log sink as `Event::SystemItem`; only then are corresponding model-visible history items returned to the worker through the internal interceptor action.
|
||||
- **Successful-continue-only injection:** Satisfied by code. `pre_llm_request` returns immediately on the first non-continue hook action before draining the local queue, so queued hook items are dropped on cancel/yield and are committed/injected only when all hooks continue.
|
||||
- **Task reminder behavior preserved:** Satisfied. `TaskStore`, `TaskReminderState`, `task_reminder_system_item`, `pending_history_appends`, and task-tool reset behavior remain in `PodInterceptor`; the follow-up ticket remains responsible for moving Task ownership into the Task feature.
|
||||
- **Existing permission/hook behavior remains conceptually valid:** Satisfied. Tool hook contexts remain bounded summaries; public pre-tool actions still provide only continue/deny/abort/pause, with no no-result skip or arbitrary tool result path.
|
||||
- **Tests:** Mostly satisfied. Added tests cover handle queueing of an approved task reminder, context handle absence, commit plus returned system message behavior, and no-log-writer behavior. Existing tests continue to cover public hook action restrictions and current task reminder lanes. The only test gap I noted is explicit append-then-cancel/yield no-leak coverage, but the implementation path is straightforward enough that I do not consider this a merge blocker.
|
||||
- **No broad refactor / no external-plugin approval implementation:** Satisfied. The diff is limited to `crates/pod/src/hook.rs`, `crates/pod/src/ipc/interceptor.rs`, and a small `PreRequestContext` type update in `crates/pod/src/pod.rs`.
|
||||
|
||||
## 4. Blockers
|
||||
|
||||
None.
|
||||
|
||||
## 5. Non-blockers / follow-ups
|
||||
|
||||
- Add an explicit regression test for a pre-request hook that appends through `SystemItemAppendHandle` followed by a later hook returning `Cancel` or `Yield`, asserting that no commit and no `ContinueWith` occur. The code already behaves this way, but the intended no-leak invariant is important enough to lock down.
|
||||
- Before any external plugin/user-script hook surface is enabled, add per-hook or per-feature authority policy for whether `PreRequestContext::system_items()` is present. This ticket intentionally avoids external-plugin approval, and current built-in/internal hook usage is acceptable.
|
||||
|
||||
## 6. Validation assessed or rerun
|
||||
|
||||
Assessed:
|
||||
|
||||
- Reviewed ticket, delegation intent, investigation note, authority-context decision, and dependent Task ownership follow-up.
|
||||
- Reviewed `git diff develop...HEAD`; changed files are limited to:
|
||||
- `crates/pod/src/hook.rs`
|
||||
- `crates/pod/src/ipc/interceptor.rs`
|
||||
- `crates/pod/src/pod.rs`
|
||||
- Inspected the relevant public hook API, handle construction/surface, interceptor commit path, and task reminder preservation tests.
|
||||
|
||||
Rerun:
|
||||
|
||||
- `cd /home/hare/Projects/yoi/.worktree/hook-context-system-item-sink && git diff --check develop...HEAD` — passed with no output.
|
||||
|
||||
Not rerun:
|
||||
|
||||
- I did not rerun `cargo test`, `cargo check`, or `nix build` from this external-reviewer turn because the requested validation allowance was read-only and those commands would write build artifacts. I did not find a coder validation report in the ticket thread.
|
||||
|
||||
## 7. Residual risk
|
||||
|
||||
The remaining risk is in future authority granularity, not this prerequisite implementation: today the handle is present for pre-request hooks when a log writer exists, rather than being selected per installed feature/hook. That should be tightened before exposing hooks to untrusted external plugins. The existing `SystemItemCommitter` API also logs and drops commit failures rather than returning a result; this is pre-existing behavior shared by other SystemItem lanes, but it means disk-write failure handling is not made stronger by this ticket.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-06-05T01:26:06Z status: closed -->
|
||||
|
||||
## Closed
|
||||
|
||||
Added PreRequestContext and host-mediated SystemItemAppendHandle for pre-request hooks. Hook returns remain per-hook-point flow-control actions; the append handle has host-private construction and a narrow task-reminder request surface; queued system items commit through the existing LogEntry::SystemItem / Event::SystemItem path only on successful continue. Existing Task reminder ownership remains unchanged for the follow-up ticket. External review approved. Merge validation passed: cargo test -p pod hook --lib, cargo test -p pod --lib, cargo test -p llm-worker --lib, cargo fmt --check, cargo check --workspace --all-targets, ./tickets.sh doctor, git diff --check, nix build .#yoi, ./result/bin/yoi pod --help.
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
# Delegation intent: Task feature owns TaskStore and reminders
|
||||
|
||||
## Intent
|
||||
|
||||
Implement the second step in the Task feature cleanup sequence: move Task-specific state and reminder behavior out of `Pod` / `PodInterceptor` and into the built-in Task feature module.
|
||||
|
||||
The prerequisite `hook-context-system-item-sink` is closed. Use its `PreRequestContext` / `SystemItemAppendHandle` path for durable `SystemItem::TaskReminder` append. Pod should provide generic host surfaces; Task feature should own Task state and policy.
|
||||
|
||||
## Worktree / branch
|
||||
|
||||
- worktree: `/home/hare/Projects/yoi/.worktree/task-feature-own-store-reminder-hooks`
|
||||
- branch: `work/task-feature-own-store-reminder-hooks`
|
||||
|
||||
## Requirements
|
||||
|
||||
- Move TaskStore construction/ownership from `Pod` into the built-in Task feature module.
|
||||
- The Task feature should own the session-lifetime `tools::TaskStore` shared by Task tools and reminder hooks.
|
||||
- Pod should not keep a Task-specific store field merely because tools/reminders need it.
|
||||
- Move TaskReminderState and reminder decision logic out of `PodInterceptor` and into Task feature-owned hooks.
|
||||
- Use a tool hook to record Task tool usage.
|
||||
- Use a `PreLlmRequest` hook to evaluate inactivity/cooldown and append `SystemItem::TaskReminder` through `SystemItemAppendHandle`.
|
||||
- Preserve the current threshold/cooldown/body/source semantics.
|
||||
- Remove Task-specific checks from `PodInterceptor`, including task-tool-name special-casing for reminder state, once feature-owned hooks replace them.
|
||||
- Preserve current observable behavior:
|
||||
- TaskCreate / TaskUpdate / TaskGet / TaskList names, schemas, descriptions, outputs;
|
||||
- TaskStore snapshot/restore behavior;
|
||||
- task reminder emission timing/body/cooldown;
|
||||
- model-visible history path via `LogEntry::SystemItem` / `Event::SystemItem`;
|
||||
- normal ToolRegistry / PreToolCall permission path.
|
||||
- Audit all current `Pod::task_store` / `task_store` uses.
|
||||
- If Pod/session restore/compaction/TUI compatibility needs read access, route it through a Task feature-owned status/snapshot surface or a documented temporary façade that does not make Pod the owner.
|
||||
- Do not silently drop restore/snapshot/compaction behavior.
|
||||
- Keep external-plugin authority model out of this ticket except using the trusted built-in hook handle path already implemented.
|
||||
- Keep TUI UI changes out of scope.
|
||||
|
||||
## Important constraints
|
||||
|
||||
- Do not expose raw `llm_worker::Item`, raw history writers, raw event senders, raw `Pod`, raw `Worker`, or raw `NotifyBuffer` through the Task feature.
|
||||
- Do not reintroduce raw `ContinueWith(Vec<Item>)`, no-result tool skip, arbitrary `ToolResult` construction, generic event channels, or UI/dialog payloads.
|
||||
- Do not change external plugin loading, package approval, WASM, MCP, WorkItem, Memory, or Pod-management modules.
|
||||
- Do not remove the existing Task tools or change their model-visible metadata.
|
||||
|
||||
## Suggested files
|
||||
|
||||
- `crates/pod/src/feature/builtin/task.rs`
|
||||
- `crates/pod/src/feature.rs`
|
||||
- `crates/pod/src/hook.rs`
|
||||
- `crates/pod/src/ipc/interceptor.rs`
|
||||
- `crates/pod/src/pod.rs`
|
||||
- `crates/pod/src/controller.rs`
|
||||
- `crates/tools/src/task.rs`
|
||||
- any tests around TaskStore snapshot/restore and Task reminders
|
||||
|
||||
## Validation
|
||||
|
||||
Run at least:
|
||||
|
||||
- focused Task feature/reminder tests added or moved by this ticket
|
||||
- `cargo test -p pod hook --lib`
|
||||
- `cargo test -p pod --lib`
|
||||
- `cargo test -p llm-worker --lib`
|
||||
- `cargo check --workspace --all-targets`
|
||||
- `cargo fmt --check`
|
||||
- `./tickets.sh doctor`
|
||||
- `git diff --check`
|
||||
|
||||
Run `nix build .#yoi` if feasible.
|
||||
|
||||
## Escalate if
|
||||
|
||||
- Preserving TaskStore snapshot/restore requires TUI/protocol changes.
|
||||
- Removing Pod ownership would require a broad feature-service/status API beyond this ticket.
|
||||
- Current reminder semantics cannot be preserved through Hook order or SystemItem append timing.
|
||||
- You find any hidden dependency that requires TaskStore to remain Pod-owned.
|
||||
|
||||
## Completion report
|
||||
|
||||
Report:
|
||||
|
||||
- worktree path / branch
|
||||
- commit hash
|
||||
- changed files
|
||||
- where TaskStore is now owned
|
||||
- how Task reminder state/logic moved into Task feature hooks
|
||||
- how snapshot/restore/compaction behavior is preserved
|
||||
- evidence Pod/PodInterceptor no longer own or special-case Task state
|
||||
- tests/validation results
|
||||
- unresolved risks/follow-ups
|
||||
- whether ready for external review
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
# External rereview: task-feature-own-store-reminder-hooks
|
||||
|
||||
## 1. Result: approve
|
||||
|
||||
Approve. The prior blocker is fixed: hook-appended Task reminders are now folded into the effective request context before usage accounting and the second request-threshold compaction decision, and threshold-yield preserves the queued system item durably instead of dropping model-visible history.
|
||||
|
||||
## 2. Summary of fix reviewed
|
||||
|
||||
The fix adds an internal `PreRequestAction::YieldWith(Vec<Item>)` variant in `llm-worker` and updates `PodInterceptor::pre_llm_request()` to:
|
||||
|
||||
1. keep the existing pre-hook request-threshold check for the already-present context;
|
||||
2. run pre-request hooks and drain any host-mediated `SystemItemAppendHandle` appends;
|
||||
3. build an `effective_len = context.len() + system_items.len()`;
|
||||
4. run request-threshold compaction again against the post-append effective context;
|
||||
5. if the post-append threshold yields, commit the queued system items and return `YieldWith(items)` so `Worker` appends them to history before yielding;
|
||||
6. otherwise record usage with the effective post-append length and continue with the queued items.
|
||||
|
||||
This keeps the hook append boundary host-mediated while making the model-visible request context match the context used for usage tracking.
|
||||
|
||||
## 3. Prior-blocker assessment
|
||||
|
||||
- **Hook-appended `SystemItem::TaskReminder` included before usage accounting:** Fixed. `PodInterceptor::pre_llm_request()` now drains hook system items before the final `usage_tracker.note_request(...)` call and computes `effective_len` from `context.len() + system_items.len()`.
|
||||
- **`UsageTracker::note_request()` uses post-append context length:** Fixed. The call now passes `effective_len`, so a fired Task reminder is counted in the recorded request history length.
|
||||
- **Request-threshold compaction checks the post-append context:** Fixed. After hook drain, `estimated_tokens` is recomputed over a `Cow` that includes the queued items, and `request_threshold.should_compact(current_tokens)` runs again before the request is sent.
|
||||
- **Threshold yield preserves queued system items durably:** Fixed. On post-append threshold yield, `PodInterceptor` commits the queued system items, returns `PreRequestAction::YieldWith(items)`, and `Worker` appends those items into history before returning `WorkerResult::Yielded`. The next request therefore starts from durable/in-memory history that includes the reminder.
|
||||
- **`YieldWith(Vec<Item>)` does not expose public raw injection through `pod::hook`:** Acceptable. `YieldWith` is an internal `llm-worker` interceptor action, not a public `pod::hook` action. The public hook-facing append API remains the typed `SystemItemAppendHandle`; public hook types do not gain raw `Item` construction authority.
|
||||
|
||||
## 4. Rechecked requirements
|
||||
|
||||
- **TaskStore/reminder ownership remains in Task feature:** Still satisfied. `TaskStore` and `TaskReminderState` remain inside `crates/pod/src/feature/builtin/task.rs`; `Pod` keeps only the feature façade needed for installation, restore, rewind, and compaction snapshot/overview calls.
|
||||
- **No `PodInterceptor` Task special-casing returned:** Confirmed. Production `PodInterceptor` code does not branch on Task tool names and does not own Task reminder state; Task behavior is driven through feature hooks.
|
||||
- **Task tool metadata/behavior unchanged:** Confirmed by diff scope. The Task tool implementations and metadata remain in `crates/tools/src/task.rs`; the feature still registers `tools::task_tools(...)` with the shared feature-owned store.
|
||||
- **No raw `Item` exposure in public Hook API:** Confirmed. `SystemItemAppendHandle` exposes typed append methods such as `append_task_reminder`; the raw `Item` vectors remain inside worker/interceptor internals.
|
||||
- **No generic event channels or UI dialogs:** Confirmed in the reviewed diff. The fix is confined to worker/interceptor request flow and tests.
|
||||
- **Snapshot/restore/rewind semantics remain safe:** Still acceptable. `TaskFeature::restore_from_history()` mutates the existing shared store handle, preserving installed Task tool handles after restore/rewind. The prior focused restore test still passes.
|
||||
- **Tests cover fixed accounting/compaction path:** Satisfied. New/focused tests cover post-append usage length and post-append request-threshold yield preservation, in addition to the existing Task reminder and hook append tests.
|
||||
|
||||
## 5. Blockers
|
||||
|
||||
None.
|
||||
|
||||
## 6. Non-blockers / follow-ups
|
||||
|
||||
- `TaskFeature::from_history()` / `restore_from_history()` still accept raw `llm_worker::Item` slices as an internal restore façade. This remains acceptable for this ticket, but can be tightened in a later boundary-cleanup pass if feature restore APIs are made more typed.
|
||||
- The new `YieldWith(Vec<Item>)` is an internal escape hatch with raw `Item`s. It is justified here for durable preservation of host-created system items on yield, but future uses should stay interceptor-owned and should not be surfaced through public plugin/hook APIs.
|
||||
|
||||
## 7. Validation assessed or rerun
|
||||
|
||||
Reviewed:
|
||||
|
||||
- original review artifact;
|
||||
- ticket and delegation intent;
|
||||
- `git diff develop...HEAD` and the fix commit `960f2a3 fix: account hook system item appends`;
|
||||
- current `llm-worker`, `pod::ipc::interceptor`, Task feature, hook API, and relevant Pod restore/snapshot code.
|
||||
|
||||
Commands rerun from `/home/hare/Projects/yoi/.worktree/task-feature-own-store-reminder-hooks`:
|
||||
|
||||
- `cargo test -p pod task_reminder_hook_append_is_counted_in_usage_request_len --lib` — passed.
|
||||
- `cargo test -p pod pre_llm_request_yields_with_hook_appends_when_post_append_threshold_exceeded --lib` — passed.
|
||||
- `cargo test -p pod hook --lib` — passed.
|
||||
- `cargo test -p llm-worker --lib` — passed.
|
||||
- `cargo test -p pod task_reminder --lib` — passed.
|
||||
- `cargo test -p pod restore_from_history_keeps_existing_store_handle_for_installed_tools --lib` — passed.
|
||||
- `git diff --check develop...HEAD` — no whitespace errors reported.
|
||||
|
||||
I did not rerun full workspace tests or `nix build .#yoi` as part of this rereview.
|
||||
|
||||
## 8. Residual risk
|
||||
|
||||
Residual risk is low for this ticket. The main area to watch is future expansion of `YieldWith(Vec<Item>)`: it should remain an internal worker/interceptor mechanism for preserving already-authorized, host-created history appends across yield/compaction boundaries, not a public raw-history injection mechanism.
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
# External review: task-feature-own-store-reminder-hooks
|
||||
|
||||
## 1. Result: request changes
|
||||
|
||||
Request changes. The implementation substantially moves Task state and reminder policy into the built-in Task feature, but it changes the request-accounting/compaction semantics for the reminder append path: a hook-appended `SystemItem::TaskReminder` is added after the pre-request token/usage accounting has already observed the request context.
|
||||
|
||||
## 2. Summary of implementation
|
||||
|
||||
The commit introduces `TaskFeature` as the owner of the session-lifetime `tools::TaskStore` and `TaskReminderState` in `crates/pod/src/feature/builtin/task.rs`. The feature contributes the four Task tools plus two hooks:
|
||||
|
||||
- a `PreToolCall` hook that records `TaskCreate` / `TaskUpdate` usage;
|
||||
- a `PreLlmRequest` hook that checks active tasks, inactivity threshold, cooldown, and appends a typed Task reminder through `SystemItemAppendHandle`.
|
||||
|
||||
`Pod` now keeps a `task_feature: TaskFeature` compatibility/status façade for restore, rewind, and compaction snapshot needs, and `PodInterceptor` no longer owns `TaskStore` / `TaskReminderState` or special-cases Task tool names in `pending_history_appends()` / `pre_tool_call()`.
|
||||
|
||||
## 3. Requirement-by-requirement assessment
|
||||
|
||||
- **TaskStore owned by Task feature/module, not Pod:** Mostly satisfied. `TaskStore` is stored inside `TaskFeatureState`; `Pod` holds a `TaskFeature` façade, not a direct `TaskStore`.
|
||||
- **TaskReminderState and reminder decision logic owned by Task feature hooks:** Satisfied in structure. The threshold/cooldown/body decision logic moved to `TaskReminderPreRequestHook`; Task tool usage tracking moved to `TaskReminderToolUsageHook`.
|
||||
- **PodInterceptor no longer special-cases Task tool names or emits reminders from `pending_history_appends`:** Satisfied. Current `PodInterceptor` only drains notifications in `pending_history_appends()` and dispatches generic hooks in `pre_tool_call()`.
|
||||
- **Task tools use one shared session-lifetime store and preserve names/schemas/descriptions/outputs:** Appears satisfied. The feature registers the unchanged `tools::task_tools(self.state.task_store.clone())`, preserving the tool factories and shared store handle.
|
||||
- **Task reminder timing/body/cooldown/source semantics match previous behavior:** Body/source/counter rules are covered, but timing/accounting is not fully preserved; see blocker below.
|
||||
- **`SystemItem::TaskReminder` appended through `SystemItemAppendHandle`:** Satisfied. The Task hook uses `input.system_items().append_task_reminder(...)`; it does not construct raw `Item`s.
|
||||
- **Snapshot/restore/rewind/compaction behavior preserved:** Partially satisfied. Task snapshot/overview access routes through `TaskFeature`, and rewind uses `restore_from_history()` to mutate the existing store handle so installed tools do not become stale. However, request-time compaction/accounting behavior around fired reminders is changed; see blocker.
|
||||
- **Rewind/restore do not leave installed Task tool instances pointing at stale stores:** Satisfied by `TaskStore::replace_with()` through `TaskFeature::restore_from_history()`, and there is a focused unit test for this.
|
||||
- **Pod does not retain Task-specific ownership under another name:** Acceptable. The `task_feature` field is a Task-specific façade in `Pod`, but the business state is inside the feature module and the façade is used for restore/compaction compatibility.
|
||||
- **Normal ToolRegistry / PreToolCall permission path preserved:** Appears preserved. Task tools are still registered in the Worker tool registry, and Task usage tracking is a normal pre-tool hook after existing hook ordering rather than a bypass.
|
||||
- **No unrelated TUI/plugin/event/raw-handle/refactor scope creep:** No TUI changes or broad unrelated refactors found. One boundary note remains about raw history snapshots in the feature façade; see follow-ups.
|
||||
- **Tests cover ownership/reminder behavior sufficiently:** Unit coverage is good for feature-owned reminder state/body/source/cooldown and store-handle restore. It does not cover the integration point where hook-appended reminders interact with usage tracking and compaction accounting.
|
||||
|
||||
## 4. Blockers
|
||||
|
||||
### Blocker: hook-appended Task reminders are not included in pre-request usage/compaction accounting
|
||||
|
||||
The old Task reminder path appended the reminder from `PodInterceptor::pending_history_appends()`. `llm-worker` drains `pending_history_appends()` into persistent history before cloning `request_context`, so the reminder participated in the subsequent pre-request hooks, usage tracking, and request-threshold compaction check.
|
||||
|
||||
The new path appends the reminder from a `PreLlmRequest` hook:
|
||||
|
||||
- `TaskReminderPreRequestHook` queues the reminder through `SystemItemAppendHandle` in `crates/pod/src/feature/builtin/task.rs:204-231`.
|
||||
- `PodInterceptor::pre_llm_request()` computes `current_tokens` and constructs `PreRequestInfo { item_count: context.len(), ... }` before running hooks, then drains hook system items only after all hooks finish (`crates/pod/src/ipc/interceptor.rs:212-269`).
|
||||
- `UsageTrackingHook` records exactly that pre-append `item_count` (`crates/pod/src/pod.rs:224-227`).
|
||||
- `llm-worker` only extends `request_context` with `PreRequestAction::ContinueWith(items)` after `pre_llm_request()` returns (`crates/llm-worker/src/worker.rs:1170-1191`).
|
||||
|
||||
So when the Task reminder fires, the actual LLM request includes one more model-visible history item than the usage tracker recorded for that request. The request-threshold compaction check also ran before that item existed in `request_context`. This changes the previous observable accounting/compaction behavior and can skew future token estimates and compaction timing from the first reminder-fired request onward.
|
||||
|
||||
This needs to be fixed before merge. The fix should preserve the host-mediated `SystemItemAppendHandle` boundary, but the final request context and the recorded usage `history_len` must include the queued system item before the LLM call is accounted/sent, matching the old `pending_history_appends()` semantics.
|
||||
|
||||
## 5. Non-blockers / follow-ups
|
||||
|
||||
- `TaskFeature::from_history()` and `TaskFeature::restore_from_history()` still take raw `&[llm_worker::Item]`. This is a narrow internal restore façade rather than a mutable raw history handle, so I am not blocking on it here, but it is worth tightening later if the feature boundary is meant to avoid raw history representations entirely.
|
||||
- Add an integration-style test that installs the Task feature into the normal Pod/Interceptor path, fires a reminder, and asserts the request accounting/usage record uses the post-append context length.
|
||||
|
||||
## 6. Validation assessed or rerun
|
||||
|
||||
Reviewed:
|
||||
|
||||
- ticket and delegation intent;
|
||||
- prerequisite hook-context-system-item-sink ticket and investigation artifact;
|
||||
- `git diff develop...HEAD` for commit `c9cb2edc7e2b7d494bd20a245c0503fc91e58420`;
|
||||
- relevant current files in the implementation worktree.
|
||||
|
||||
Commands rerun from `/home/hare/Projects/yoi/.worktree/task-feature-own-store-reminder-hooks`:
|
||||
|
||||
- `cargo test -p pod task_reminder --lib` — passed (8 tests).
|
||||
- `cargo test -p pod task_management_tool_call_resets_reminder_inactivity_counter --lib` — passed.
|
||||
- `cargo test -p pod restore_from_history_keeps_existing_store_handle_for_installed_tools --lib` — passed.
|
||||
- `cargo test -p pod pre_llm_request_commits_hook_system_items_before_continue_with --lib` — passed.
|
||||
- `cargo test -p pod hook --lib` — passed (14 tests).
|
||||
- `git diff --check develop...HEAD` — no whitespace errors reported.
|
||||
|
||||
I did not rerun full `cargo test -p pod --lib`, workspace check, or `nix build .#yoi` as part of this external review.
|
||||
|
||||
## 7. Residual risk
|
||||
|
||||
After the blocker is fixed, the main residual risk is hook ordering around built-in Task hooks versus other pre-request hooks. The current design keeps hook append side effects queued and committed by the host, which is the right authority boundary, but tests should lock down where those queued items become visible for usage tracking, compaction thresholds, and the final LLM request.
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
---
|
||||
id: 20260605-004807-task-feature-own-store-reminder-hooks
|
||||
slug: task-feature-own-store-reminder-hooks
|
||||
title: Task: move TaskStore and reminders into Task feature
|
||||
status: closed
|
||||
kind: task
|
||||
priority: P1
|
||||
labels: [tasks, hooks, feature-registry, history]
|
||||
created_at: 2026-06-05T00:48:07Z
|
||||
updated_at: 2026-06-05T02:24:23Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Issue
|
||||
|
||||
`TaskCreate` / `TaskUpdate` / `TaskGet` / `TaskList` have been extracted into a built-in internal feature module, but Task state is still Pod-owned. `Pod` owns `TaskStore`, `PodInterceptor` owns `TaskReminderState`, and `PodInterceptor::pending_history_appends()` contains Task-specific reminder logic.
|
||||
|
||||
For the feature/module boundary to be meaningful, Task-specific state and behavior should live in the Task feature module. Pod should provide generic host surfaces: tool registration, hook dispatch, and durable `SystemItem` append. Pod should not know about TaskStore or Task reminder rules.
|
||||
|
||||
## Current findings
|
||||
|
||||
- `crates/pod/src/feature/builtin/task.rs` currently constructs the Task tool feature with a host-provided `tools::TaskStore`.
|
||||
- `crates/pod/src/pod.rs` still stores `task_store: tools::TaskStore` and `task_reminder_state: Arc<TaskReminderState>`.
|
||||
- `crates/pod/src/ipc/interceptor.rs` still stores `TaskStore` and `TaskReminderState`, detects task-management tool calls, and emits `SystemItem::TaskReminder` from `pending_history_appends()`.
|
||||
- `crates/pod/src/pod.rs` also uses TaskStore snapshots for session restore/compaction/reminder context. Those uses must be audited before removing Pod ownership.
|
||||
- TUI-facing Task visibility is intentionally out of scope here; if TUI needs a compatibility/status surface, create a follow-up instead of keeping Task ownership in Pod.
|
||||
|
||||
## Direction
|
||||
|
||||
Move TaskStore and Task reminder state into the Task feature module after `hook-context-system-item-sink` provides a host-mediated SystemItem append handle for Hook contexts.
|
||||
|
||||
Target shape:
|
||||
|
||||
- `TaskFeature` owns:
|
||||
- `tools::TaskStore`
|
||||
- Task reminder/cooldown state
|
||||
- Task tool contribution construction
|
||||
- hooks that track Task tool usage and decide when to emit reminders
|
||||
- Pod owns:
|
||||
- feature registry
|
||||
- Hook dispatch
|
||||
- ToolRegistry integration
|
||||
- durable SystemItem append/commit authority
|
||||
- generic history/session machinery
|
||||
- Pod does not own or special-case TaskStore / TaskReminderState.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Depend on or first implement `hook-context-system-item-sink`.
|
||||
- Move TaskStore construction/ownership from `Pod` into the built-in Task feature module.
|
||||
- Preserve session restore behavior by giving Task feature whatever history snapshot or restore input it needs, rather than keeping TaskStore in Pod.
|
||||
- Preserve one session-lifetime TaskStore shared by all Task tools and reminder hooks.
|
||||
- Move TaskReminderState and reminder decision logic out of `PodInterceptor` and into Task feature-owned hooks.
|
||||
- A tool hook records Task tool usage.
|
||||
- A `PreLlmRequest` hook evaluates inactivity/cooldown and appends `SystemItem::TaskReminder` through the host-provided SystemItem append handle.
|
||||
- Remove Task-specific checks from `PodInterceptor` such as direct `is_task_management_tool` handling for reminder state.
|
||||
- Preserve current observable behavior:
|
||||
- Task tool names/schemas/descriptions/outputs;
|
||||
- TaskStore snapshot/restore semantics;
|
||||
- task reminder threshold/cooldown/body/source behavior;
|
||||
- model-visible history path via `LogEntry::SystemItem` / `Event::SystemItem`;
|
||||
- normal ToolRegistry / PreToolCall permission path.
|
||||
- Audit compaction/session snapshot code that currently reads `self.task_store`.
|
||||
- Either route this through a Task feature status/snapshot service, or leave a documented temporary compatibility façade with no Task ownership in Pod.
|
||||
- Do not silently drop TaskStore snapshot/resume behavior.
|
||||
- Keep external-plugin authority model out of this ticket except insofar as the Hook SystemItem append handle is used by a trusted built-in module.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- TUI UI changes.
|
||||
- External plugin loading or package approval.
|
||||
- Generic event channels or dialogs.
|
||||
- Changing Task tool behavior or adding/removing Task tools.
|
||||
- Reworking Memory/WorkItem/Pod-management modules.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Task feature module owns TaskStore and Task reminder state.
|
||||
- Pod and PodInterceptor no longer contain Task-specific store/reminder state or task-tool special-casing.
|
||||
- Task reminder emission is implemented as Task feature Hook logic using host-mediated SystemItem append, not raw `Item` injection.
|
||||
- TaskStore snapshot/restore/compaction behavior is preserved or explicitly routed through a new feature-owned status/snapshot surface.
|
||||
- Existing Task tool and Task reminder tests are moved/updated and pass.
|
||||
- Workspace validation passes.
|
||||
|
|
@ -0,0 +1 @@
|
|||
Moved TaskStore and Task reminder state/logic into the built-in Task feature. Task tools share the feature-owned session store; Task feature hooks now record Task tool usage and append TaskReminder via SystemItemAppendHandle. Pod/PodInterceptor no longer own TaskStore/TaskReminderState or special-case Task tool reminder emission. Reminder append accounting was fixed so usage and request-threshold compaction see the post-append context. External review approved. Merge validation passed: cargo test -p pod task_reminder --lib, cargo test -p pod hook --lib, cargo test -p pod --lib, cargo test -p llm-worker --lib, cargo fmt --check, cargo check --workspace --all-targets, ./tickets.sh doctor, git diff --check, nix build .#yoi, ./result/bin/yoi pod --help.
|
||||
|
|
@ -0,0 +1,397 @@
|
|||
<!-- event: create author: tickets.sh at: 2026-06-05T00:48:07Z -->
|
||||
|
||||
## Created
|
||||
|
||||
Created by tickets.sh create.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: decision author: hare at: 2026-06-05T00:49:53Z -->
|
||||
|
||||
## Decision
|
||||
|
||||
# Investigation: Task state and Hook side-effect boundary
|
||||
|
||||
## Findings
|
||||
|
||||
- Hook action types are already separated per hook point after Hook hardening. The next design should preserve that: flow-control actions stay event-specific rather than becoming one global `HookDecision`.
|
||||
- Hook inputs are still summary structs, not contexts with host-created handles. That is the missing abstraction for feature-owned behavior that needs durable host side effects.
|
||||
- `TaskStore` is still owned by `Pod`, and `TaskReminderState`/reminder emission is still owned by `PodInterceptor`.
|
||||
- The Task built-in feature module currently contributes only the four Task tools and receives `TaskStore` from Pod. This is an incomplete internal-module boundary: Task-specific state still remains on the Pod side.
|
||||
- `SystemItem::TaskReminder` is currently appended through `PodInterceptor::pending_history_appends()`, which is the correct durable history direction but the wrong ownership location for Task-specific logic.
|
||||
|
||||
## Decision
|
||||
|
||||
Split follow-up into two steps:
|
||||
|
||||
1. Add Hook context host handles, especially a durable `SystemItem` append handle. Hook returns remain per-hook-point flow-control actions. No raw `Item` injection and no generic effect/event channel.
|
||||
2. Move TaskStore and Task reminder logic into the Task feature module, implemented as Task-owned tools plus hooks that use the host-provided SystemItem append handle.
|
||||
|
||||
This keeps Pod responsible for generic host surfaces and history authority, while Task owns Task-specific state and policy.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: plan author: hare at: 2026-06-05T01:01:20Z -->
|
||||
|
||||
## Plan
|
||||
|
||||
# Delegation intent: Hook context SystemItem append sink
|
||||
|
||||
## Intent
|
||||
|
||||
Implement the first step in the Task state/reminder cleanup sequence: add event-specific Hook context support with a host-mediated durable `SystemItem` append handle, while keeping Hook return actions as per-hook-point flow-control actions.
|
||||
|
||||
This is the prerequisite for moving TaskStore/reminder logic into the Task feature. Do not move TaskStore or Task reminders in this ticket.
|
||||
|
||||
## Worktree / branch
|
||||
|
||||
- worktree: `/home/hare/Projects/yoi/.worktree/hook-context-system-item-sink`
|
||||
- branch: `work/hook-context-system-item-sink`
|
||||
|
||||
## Requirements
|
||||
|
||||
- Evolve the public Pod Hook API so Hook handlers receive event-specific context values rather than only bare summary inputs where necessary.
|
||||
- Preserve the existing per-hook-point action/output types.
|
||||
- Do not collapse actions into one global `HookDecision`.
|
||||
- Add a host-created typed handle for durable model-visible `SystemItem` append.
|
||||
- Suggested naming: `SystemItemAppendHandle`, `SystemItemSink`, or equivalent.
|
||||
- Constructors/fields must stay host-private; feature/hook code can only use handles the host provides.
|
||||
- The handle must not expose raw `llm_worker::Item`, raw history writers, raw event senders, raw `Pod`, raw `Worker`, or `NotifyBuffer`.
|
||||
- The append path must use existing durable history semantics:
|
||||
- host-controlled pending append / commit path;
|
||||
- `LogEntry::SystemItem`;
|
||||
- `Event::SystemItem`;
|
||||
- model context visibility only after durable commit.
|
||||
- The initial approved system-item requests should be narrow.
|
||||
- Support what is needed for a future `TaskReminder` hook, and notification-like system items only if this falls out naturally from existing `SystemItem` machinery.
|
||||
- Do not introduce arbitrary `llm_worker::Item` append or generic plugin event channels.
|
||||
- Keep built-in internal modules distinct from external-plugin authority approval.
|
||||
- It is okay to add scaffolding so internal hooks can receive the handle by host policy.
|
||||
- Do not implement external-plugin approval or WASM imports in this ticket.
|
||||
- Preserve current observable behavior.
|
||||
- Current Task reminders should continue to be emitted by existing `PodInterceptor` logic until the follow-up ticket moves them.
|
||||
- Existing Hook tests and permission hook behavior must continue to pass.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Moving `TaskStore` ownership into the Task feature.
|
||||
- Moving `TaskReminderState` or `PodInterceptor` reminder logic.
|
||||
- TUI UI/dialog work.
|
||||
- Generic event channel / arbitrary UI payloads.
|
||||
- External plugin loading or package approval.
|
||||
- Changing ToolRegistry / PreToolCall permission behavior.
|
||||
- Reintroducing raw `Item` injection, `ContinueWith(Vec<Item>)`, no-result tool skip, or arbitrary `ToolResult` construction.
|
||||
|
||||
## Suggested files
|
||||
|
||||
- `crates/pod/src/hook.rs`
|
||||
- `crates/pod/src/ipc/interceptor.rs`
|
||||
- `crates/pod/src/pod.rs`
|
||||
- `crates/pod/src/controller.rs`
|
||||
- `crates/session-store/src/system_item.rs` or wherever `SystemItem` request/serialization is defined
|
||||
- existing Hook tests in `crates/pod/src/**`
|
||||
|
||||
## Validation
|
||||
|
||||
Run at least:
|
||||
|
||||
- focused Hook context / SystemItem sink tests added by this ticket
|
||||
- `cargo test -p pod hook --lib`
|
||||
- `cargo test -p pod --lib`
|
||||
- `cargo test -p llm-worker --lib`
|
||||
- `cargo check --workspace --all-targets`
|
||||
- `cargo fmt --check`
|
||||
- `./tickets.sh doctor`
|
||||
- `git diff --check`
|
||||
|
||||
Run `nix build .#yoi` if feasible.
|
||||
|
||||
## Escalate if
|
||||
|
||||
- Implementing the handle requires changing session/history commit semantics.
|
||||
- The only easy path is to expose raw `Item`, raw history writers, raw event senders, or `Pod`/`Worker` internals.
|
||||
- Hook action types would need to be merged into one generic return type.
|
||||
- Current Task reminder behavior would change before the Task ownership follow-up.
|
||||
|
||||
## Completion report
|
||||
|
||||
Report:
|
||||
|
||||
- worktree path / branch
|
||||
- commit hash
|
||||
- changed files
|
||||
- Hook context/action API changes
|
||||
- SystemItem append handle design and path to durable commit
|
||||
- tests added/updated
|
||||
- validation results
|
||||
- unresolved risks/follow-ups
|
||||
- whether ready for external review
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: plan author: hare at: 2026-06-05T01:27:27Z -->
|
||||
|
||||
## Plan
|
||||
|
||||
# Delegation intent: Task feature owns TaskStore and reminders
|
||||
|
||||
## Intent
|
||||
|
||||
Implement the second step in the Task feature cleanup sequence: move Task-specific state and reminder behavior out of `Pod` / `PodInterceptor` and into the built-in Task feature module.
|
||||
|
||||
The prerequisite `hook-context-system-item-sink` is closed. Use its `PreRequestContext` / `SystemItemAppendHandle` path for durable `SystemItem::TaskReminder` append. Pod should provide generic host surfaces; Task feature should own Task state and policy.
|
||||
|
||||
## Worktree / branch
|
||||
|
||||
- worktree: `/home/hare/Projects/yoi/.worktree/task-feature-own-store-reminder-hooks`
|
||||
- branch: `work/task-feature-own-store-reminder-hooks`
|
||||
|
||||
## Requirements
|
||||
|
||||
- Move TaskStore construction/ownership from `Pod` into the built-in Task feature module.
|
||||
- The Task feature should own the session-lifetime `tools::TaskStore` shared by Task tools and reminder hooks.
|
||||
- Pod should not keep a Task-specific store field merely because tools/reminders need it.
|
||||
- Move TaskReminderState and reminder decision logic out of `PodInterceptor` and into Task feature-owned hooks.
|
||||
- Use a tool hook to record Task tool usage.
|
||||
- Use a `PreLlmRequest` hook to evaluate inactivity/cooldown and append `SystemItem::TaskReminder` through `SystemItemAppendHandle`.
|
||||
- Preserve the current threshold/cooldown/body/source semantics.
|
||||
- Remove Task-specific checks from `PodInterceptor`, including task-tool-name special-casing for reminder state, once feature-owned hooks replace them.
|
||||
- Preserve current observable behavior:
|
||||
- TaskCreate / TaskUpdate / TaskGet / TaskList names, schemas, descriptions, outputs;
|
||||
- TaskStore snapshot/restore behavior;
|
||||
- task reminder emission timing/body/cooldown;
|
||||
- model-visible history path via `LogEntry::SystemItem` / `Event::SystemItem`;
|
||||
- normal ToolRegistry / PreToolCall permission path.
|
||||
- Audit all current `Pod::task_store` / `task_store` uses.
|
||||
- If Pod/session restore/compaction/TUI compatibility needs read access, route it through a Task feature-owned status/snapshot surface or a documented temporary façade that does not make Pod the owner.
|
||||
- Do not silently drop restore/snapshot/compaction behavior.
|
||||
- Keep external-plugin authority model out of this ticket except using the trusted built-in hook handle path already implemented.
|
||||
- Keep TUI UI changes out of scope.
|
||||
|
||||
## Important constraints
|
||||
|
||||
- Do not expose raw `llm_worker::Item`, raw history writers, raw event senders, raw `Pod`, raw `Worker`, or raw `NotifyBuffer` through the Task feature.
|
||||
- Do not reintroduce raw `ContinueWith(Vec<Item>)`, no-result tool skip, arbitrary `ToolResult` construction, generic event channels, or UI/dialog payloads.
|
||||
- Do not change external plugin loading, package approval, WASM, MCP, WorkItem, Memory, or Pod-management modules.
|
||||
- Do not remove the existing Task tools or change their model-visible metadata.
|
||||
|
||||
## Suggested files
|
||||
|
||||
- `crates/pod/src/feature/builtin/task.rs`
|
||||
- `crates/pod/src/feature.rs`
|
||||
- `crates/pod/src/hook.rs`
|
||||
- `crates/pod/src/ipc/interceptor.rs`
|
||||
- `crates/pod/src/pod.rs`
|
||||
- `crates/pod/src/controller.rs`
|
||||
- `crates/tools/src/task.rs`
|
||||
- any tests around TaskStore snapshot/restore and Task reminders
|
||||
|
||||
## Validation
|
||||
|
||||
Run at least:
|
||||
|
||||
- focused Task feature/reminder tests added or moved by this ticket
|
||||
- `cargo test -p pod hook --lib`
|
||||
- `cargo test -p pod --lib`
|
||||
- `cargo test -p llm-worker --lib`
|
||||
- `cargo check --workspace --all-targets`
|
||||
- `cargo fmt --check`
|
||||
- `./tickets.sh doctor`
|
||||
- `git diff --check`
|
||||
|
||||
Run `nix build .#yoi` if feasible.
|
||||
|
||||
## Escalate if
|
||||
|
||||
- Preserving TaskStore snapshot/restore requires TUI/protocol changes.
|
||||
- Removing Pod ownership would require a broad feature-service/status API beyond this ticket.
|
||||
- Current reminder semantics cannot be preserved through Hook order or SystemItem append timing.
|
||||
- You find any hidden dependency that requires TaskStore to remain Pod-owned.
|
||||
|
||||
## Completion report
|
||||
|
||||
Report:
|
||||
|
||||
- worktree path / branch
|
||||
- commit hash
|
||||
- changed files
|
||||
- where TaskStore is now owned
|
||||
- how Task reminder state/logic moved into Task feature hooks
|
||||
- how snapshot/restore/compaction behavior is preserved
|
||||
- evidence Pod/PodInterceptor no longer own or special-case Task state
|
||||
- tests/validation results
|
||||
- unresolved risks/follow-ups
|
||||
- whether ready for external review
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: review author: hare at: 2026-06-05T02:00:46Z status: request_changes -->
|
||||
|
||||
## Review: request changes
|
||||
|
||||
# External review: task-feature-own-store-reminder-hooks
|
||||
|
||||
## 1. Result: request changes
|
||||
|
||||
Request changes. The implementation substantially moves Task state and reminder policy into the built-in Task feature, but it changes the request-accounting/compaction semantics for the reminder append path: a hook-appended `SystemItem::TaskReminder` is added after the pre-request token/usage accounting has already observed the request context.
|
||||
|
||||
## 2. Summary of implementation
|
||||
|
||||
The commit introduces `TaskFeature` as the owner of the session-lifetime `tools::TaskStore` and `TaskReminderState` in `crates/pod/src/feature/builtin/task.rs`. The feature contributes the four Task tools plus two hooks:
|
||||
|
||||
- a `PreToolCall` hook that records `TaskCreate` / `TaskUpdate` usage;
|
||||
- a `PreLlmRequest` hook that checks active tasks, inactivity threshold, cooldown, and appends a typed Task reminder through `SystemItemAppendHandle`.
|
||||
|
||||
`Pod` now keeps a `task_feature: TaskFeature` compatibility/status façade for restore, rewind, and compaction snapshot needs, and `PodInterceptor` no longer owns `TaskStore` / `TaskReminderState` or special-cases Task tool names in `pending_history_appends()` / `pre_tool_call()`.
|
||||
|
||||
## 3. Requirement-by-requirement assessment
|
||||
|
||||
- **TaskStore owned by Task feature/module, not Pod:** Mostly satisfied. `TaskStore` is stored inside `TaskFeatureState`; `Pod` holds a `TaskFeature` façade, not a direct `TaskStore`.
|
||||
- **TaskReminderState and reminder decision logic owned by Task feature hooks:** Satisfied in structure. The threshold/cooldown/body decision logic moved to `TaskReminderPreRequestHook`; Task tool usage tracking moved to `TaskReminderToolUsageHook`.
|
||||
- **PodInterceptor no longer special-cases Task tool names or emits reminders from `pending_history_appends`:** Satisfied. Current `PodInterceptor` only drains notifications in `pending_history_appends()` and dispatches generic hooks in `pre_tool_call()`.
|
||||
- **Task tools use one shared session-lifetime store and preserve names/schemas/descriptions/outputs:** Appears satisfied. The feature registers the unchanged `tools::task_tools(self.state.task_store.clone())`, preserving the tool factories and shared store handle.
|
||||
- **Task reminder timing/body/cooldown/source semantics match previous behavior:** Body/source/counter rules are covered, but timing/accounting is not fully preserved; see blocker below.
|
||||
- **`SystemItem::TaskReminder` appended through `SystemItemAppendHandle`:** Satisfied. The Task hook uses `input.system_items().append_task_reminder(...)`; it does not construct raw `Item`s.
|
||||
- **Snapshot/restore/rewind/compaction behavior preserved:** Partially satisfied. Task snapshot/overview access routes through `TaskFeature`, and rewind uses `restore_from_history()` to mutate the existing store handle so installed tools do not become stale. However, request-time compaction/accounting behavior around fired reminders is changed; see blocker.
|
||||
- **Rewind/restore do not leave installed Task tool instances pointing at stale stores:** Satisfied by `TaskStore::replace_with()` through `TaskFeature::restore_from_history()`, and there is a focused unit test for this.
|
||||
- **Pod does not retain Task-specific ownership under another name:** Acceptable. The `task_feature` field is a Task-specific façade in `Pod`, but the business state is inside the feature module and the façade is used for restore/compaction compatibility.
|
||||
- **Normal ToolRegistry / PreToolCall permission path preserved:** Appears preserved. Task tools are still registered in the Worker tool registry, and Task usage tracking is a normal pre-tool hook after existing hook ordering rather than a bypass.
|
||||
- **No unrelated TUI/plugin/event/raw-handle/refactor scope creep:** No TUI changes or broad unrelated refactors found. One boundary note remains about raw history snapshots in the feature façade; see follow-ups.
|
||||
- **Tests cover ownership/reminder behavior sufficiently:** Unit coverage is good for feature-owned reminder state/body/source/cooldown and store-handle restore. It does not cover the integration point where hook-appended reminders interact with usage tracking and compaction accounting.
|
||||
|
||||
## 4. Blockers
|
||||
|
||||
### Blocker: hook-appended Task reminders are not included in pre-request usage/compaction accounting
|
||||
|
||||
The old Task reminder path appended the reminder from `PodInterceptor::pending_history_appends()`. `llm-worker` drains `pending_history_appends()` into persistent history before cloning `request_context`, so the reminder participated in the subsequent pre-request hooks, usage tracking, and request-threshold compaction check.
|
||||
|
||||
The new path appends the reminder from a `PreLlmRequest` hook:
|
||||
|
||||
- `TaskReminderPreRequestHook` queues the reminder through `SystemItemAppendHandle` in `crates/pod/src/feature/builtin/task.rs:204-231`.
|
||||
- `PodInterceptor::pre_llm_request()` computes `current_tokens` and constructs `PreRequestInfo { item_count: context.len(), ... }` before running hooks, then drains hook system items only after all hooks finish (`crates/pod/src/ipc/interceptor.rs:212-269`).
|
||||
- `UsageTrackingHook` records exactly that pre-append `item_count` (`crates/pod/src/pod.rs:224-227`).
|
||||
- `llm-worker` only extends `request_context` with `PreRequestAction::ContinueWith(items)` after `pre_llm_request()` returns (`crates/llm-worker/src/worker.rs:1170-1191`).
|
||||
|
||||
So when the Task reminder fires, the actual LLM request includes one more model-visible history item than the usage tracker recorded for that request. The request-threshold compaction check also ran before that item existed in `request_context`. This changes the previous observable accounting/compaction behavior and can skew future token estimates and compaction timing from the first reminder-fired request onward.
|
||||
|
||||
This needs to be fixed before merge. The fix should preserve the host-mediated `SystemItemAppendHandle` boundary, but the final request context and the recorded usage `history_len` must include the queued system item before the LLM call is accounted/sent, matching the old `pending_history_appends()` semantics.
|
||||
|
||||
## 5. Non-blockers / follow-ups
|
||||
|
||||
- `TaskFeature::from_history()` and `TaskFeature::restore_from_history()` still take raw `&[llm_worker::Item]`. This is a narrow internal restore façade rather than a mutable raw history handle, so I am not blocking on it here, but it is worth tightening later if the feature boundary is meant to avoid raw history representations entirely.
|
||||
- Add an integration-style test that installs the Task feature into the normal Pod/Interceptor path, fires a reminder, and asserts the request accounting/usage record uses the post-append context length.
|
||||
|
||||
## 6. Validation assessed or rerun
|
||||
|
||||
Reviewed:
|
||||
|
||||
- ticket and delegation intent;
|
||||
- prerequisite hook-context-system-item-sink ticket and investigation artifact;
|
||||
- `git diff develop...HEAD` for commit `c9cb2edc7e2b7d494bd20a245c0503fc91e58420`;
|
||||
- relevant current files in the implementation worktree.
|
||||
|
||||
Commands rerun from `/home/hare/Projects/yoi/.worktree/task-feature-own-store-reminder-hooks`:
|
||||
|
||||
- `cargo test -p pod task_reminder --lib` — passed (8 tests).
|
||||
- `cargo test -p pod task_management_tool_call_resets_reminder_inactivity_counter --lib` — passed.
|
||||
- `cargo test -p pod restore_from_history_keeps_existing_store_handle_for_installed_tools --lib` — passed.
|
||||
- `cargo test -p pod pre_llm_request_commits_hook_system_items_before_continue_with --lib` — passed.
|
||||
- `cargo test -p pod hook --lib` — passed (14 tests).
|
||||
- `git diff --check develop...HEAD` — no whitespace errors reported.
|
||||
|
||||
I did not rerun full `cargo test -p pod --lib`, workspace check, or `nix build .#yoi` as part of this external review.
|
||||
|
||||
## 7. Residual risk
|
||||
|
||||
After the blocker is fixed, the main residual risk is hook ordering around built-in Task hooks versus other pre-request hooks. The current design keeps hook append side effects queued and committed by the host, which is the right authority boundary, but tests should lock down where those queued items become visible for usage tracking, compaction thresholds, and the final LLM request.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: review author: hare at: 2026-06-05T02:23:00Z status: approve -->
|
||||
|
||||
## Review: approve
|
||||
|
||||
# External rereview: task-feature-own-store-reminder-hooks
|
||||
|
||||
## 1. Result: approve
|
||||
|
||||
Approve. The prior blocker is fixed: hook-appended Task reminders are now folded into the effective request context before usage accounting and the second request-threshold compaction decision, and threshold-yield preserves the queued system item durably instead of dropping model-visible history.
|
||||
|
||||
## 2. Summary of fix reviewed
|
||||
|
||||
The fix adds an internal `PreRequestAction::YieldWith(Vec<Item>)` variant in `llm-worker` and updates `PodInterceptor::pre_llm_request()` to:
|
||||
|
||||
1. keep the existing pre-hook request-threshold check for the already-present context;
|
||||
2. run pre-request hooks and drain any host-mediated `SystemItemAppendHandle` appends;
|
||||
3. build an `effective_len = context.len() + system_items.len()`;
|
||||
4. run request-threshold compaction again against the post-append effective context;
|
||||
5. if the post-append threshold yields, commit the queued system items and return `YieldWith(items)` so `Worker` appends them to history before yielding;
|
||||
6. otherwise record usage with the effective post-append length and continue with the queued items.
|
||||
|
||||
This keeps the hook append boundary host-mediated while making the model-visible request context match the context used for usage tracking.
|
||||
|
||||
## 3. Prior-blocker assessment
|
||||
|
||||
- **Hook-appended `SystemItem::TaskReminder` included before usage accounting:** Fixed. `PodInterceptor::pre_llm_request()` now drains hook system items before the final `usage_tracker.note_request(...)` call and computes `effective_len` from `context.len() + system_items.len()`.
|
||||
- **`UsageTracker::note_request()` uses post-append context length:** Fixed. The call now passes `effective_len`, so a fired Task reminder is counted in the recorded request history length.
|
||||
- **Request-threshold compaction checks the post-append context:** Fixed. After hook drain, `estimated_tokens` is recomputed over a `Cow` that includes the queued items, and `request_threshold.should_compact(current_tokens)` runs again before the request is sent.
|
||||
- **Threshold yield preserves queued system items durably:** Fixed. On post-append threshold yield, `PodInterceptor` commits the queued system items, returns `PreRequestAction::YieldWith(items)`, and `Worker` appends those items into history before returning `WorkerResult::Yielded`. The next request therefore starts from durable/in-memory history that includes the reminder.
|
||||
- **`YieldWith(Vec<Item>)` does not expose public raw injection through `pod::hook`:** Acceptable. `YieldWith` is an internal `llm-worker` interceptor action, not a public `pod::hook` action. The public hook-facing append API remains the typed `SystemItemAppendHandle`; public hook types do not gain raw `Item` construction authority.
|
||||
|
||||
## 4. Rechecked requirements
|
||||
|
||||
- **TaskStore/reminder ownership remains in Task feature:** Still satisfied. `TaskStore` and `TaskReminderState` remain inside `crates/pod/src/feature/builtin/task.rs`; `Pod` keeps only the feature façade needed for installation, restore, rewind, and compaction snapshot/overview calls.
|
||||
- **No `PodInterceptor` Task special-casing returned:** Confirmed. Production `PodInterceptor` code does not branch on Task tool names and does not own Task reminder state; Task behavior is driven through feature hooks.
|
||||
- **Task tool metadata/behavior unchanged:** Confirmed by diff scope. The Task tool implementations and metadata remain in `crates/tools/src/task.rs`; the feature still registers `tools::task_tools(...)` with the shared feature-owned store.
|
||||
- **No raw `Item` exposure in public Hook API:** Confirmed. `SystemItemAppendHandle` exposes typed append methods such as `append_task_reminder`; the raw `Item` vectors remain inside worker/interceptor internals.
|
||||
- **No generic event channels or UI dialogs:** Confirmed in the reviewed diff. The fix is confined to worker/interceptor request flow and tests.
|
||||
- **Snapshot/restore/rewind semantics remain safe:** Still acceptable. `TaskFeature::restore_from_history()` mutates the existing shared store handle, preserving installed Task tool handles after restore/rewind. The prior focused restore test still passes.
|
||||
- **Tests cover fixed accounting/compaction path:** Satisfied. New/focused tests cover post-append usage length and post-append request-threshold yield preservation, in addition to the existing Task reminder and hook append tests.
|
||||
|
||||
## 5. Blockers
|
||||
|
||||
None.
|
||||
|
||||
## 6. Non-blockers / follow-ups
|
||||
|
||||
- `TaskFeature::from_history()` / `restore_from_history()` still accept raw `llm_worker::Item` slices as an internal restore façade. This remains acceptable for this ticket, but can be tightened in a later boundary-cleanup pass if feature restore APIs are made more typed.
|
||||
- The new `YieldWith(Vec<Item>)` is an internal escape hatch with raw `Item`s. It is justified here for durable preservation of host-created system items on yield, but future uses should stay interceptor-owned and should not be surfaced through public plugin/hook APIs.
|
||||
|
||||
## 7. Validation assessed or rerun
|
||||
|
||||
Reviewed:
|
||||
|
||||
- original review artifact;
|
||||
- ticket and delegation intent;
|
||||
- `git diff develop...HEAD` and the fix commit `960f2a3 fix: account hook system item appends`;
|
||||
- current `llm-worker`, `pod::ipc::interceptor`, Task feature, hook API, and relevant Pod restore/snapshot code.
|
||||
|
||||
Commands rerun from `/home/hare/Projects/yoi/.worktree/task-feature-own-store-reminder-hooks`:
|
||||
|
||||
- `cargo test -p pod task_reminder_hook_append_is_counted_in_usage_request_len --lib` — passed.
|
||||
- `cargo test -p pod pre_llm_request_yields_with_hook_appends_when_post_append_threshold_exceeded --lib` — passed.
|
||||
- `cargo test -p pod hook --lib` — passed.
|
||||
- `cargo test -p llm-worker --lib` — passed.
|
||||
- `cargo test -p pod task_reminder --lib` — passed.
|
||||
- `cargo test -p pod restore_from_history_keeps_existing_store_handle_for_installed_tools --lib` — passed.
|
||||
- `git diff --check develop...HEAD` — no whitespace errors reported.
|
||||
|
||||
I did not rerun full workspace tests or `nix build .#yoi` as part of this rereview.
|
||||
|
||||
## 8. Residual risk
|
||||
|
||||
Residual risk is low for this ticket. The main area to watch is future expansion of `YieldWith(Vec<Item>)`: it should remain an internal worker/interceptor mechanism for preserving already-authorized, host-created history appends across yield/compaction boundaries, not a public raw-history injection mechanism.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-06-05T02:24:23Z status: closed -->
|
||||
|
||||
## Closed
|
||||
|
||||
Moved TaskStore and Task reminder state/logic into the built-in Task feature. Task tools share the feature-owned session store; Task feature hooks now record Task tool usage and append TaskReminder via SystemItemAppendHandle. Pod/PodInterceptor no longer own TaskStore/TaskReminderState or special-case Task tool reminder emission. Reminder append accounting was fixed so usage and request-threshold compaction see the post-append context. External review approved. Merge validation passed: cargo test -p pod task_reminder --lib, cargo test -p pod hook --lib, cargo test -p pod --lib, cargo test -p llm-worker --lib, cargo fmt --check, cargo check --workspace --all-targets, ./tickets.sh doctor, git diff --check, nix build .#yoi, ./result/bin/yoi pod --help.
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
# Decision: keep built-in Task feature inside pod for now
|
||||
|
||||
Task is a stateful built-in feature, not a low-level `tools` crate concern. The next step should keep the feature inside `pod` rather than creating a new crate.
|
||||
|
||||
Rationale:
|
||||
|
||||
- `pod::feature` / `pod::hook` are still defined in the `pod` crate.
|
||||
- Creating `builtin-features` now would either depend on `pod` or require premature extraction of a feature-api crate.
|
||||
- Feature-per-crate would create too many crates; a future `builtin-features` crate may be appropriate only after the API boundary is stable.
|
||||
- Moving Task domain state out of `tools` and into `pod::feature::builtin::task` fixes the immediate semantic split without forcing crate-boundary churn.
|
||||
|
||||
Desired result for this ticket:
|
||||
|
||||
- `tools` provides low-level generic tool helpers.
|
||||
- `pod::feature::builtin::task` owns TaskStore, Task types, Task tool implementations, Task reminders, and Task feature lifecycle.
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
# Delegation intent: move Task domain into pod built-in feature
|
||||
|
||||
## Intent
|
||||
|
||||
Move Task domain state and Task tool implementation out of the `tools` crate and into the `pod::feature::builtin::task` module. Keep this inside the `pod` crate for now; do not create a new built-in-features crate.
|
||||
|
||||
The goal is cohesion: Task is now a stateful built-in feature, so `TaskStore`, Task types, Task tools, reminder hooks, and snapshot/restore façade should live together.
|
||||
|
||||
## Worktree / branch
|
||||
|
||||
- worktree: `/home/hare/Projects/yoi/.worktree/task-domain-in-pod-feature`
|
||||
- branch: `work/task-domain-in-pod-feature`
|
||||
|
||||
## Requirements
|
||||
|
||||
- Move Task domain implementation from `crates/tools/src/task.rs` into the Pod built-in Task feature module.
|
||||
- If the file becomes large, prefer `crates/pod/src/feature/builtin/task/` with submodules such as `store.rs`, `tools.rs`, `reminder.rs`, and `mod.rs`.
|
||||
- A single `task.rs` file is acceptable if the change stays clear and maintainable.
|
||||
- Task feature should own:
|
||||
- `TaskStore`
|
||||
- `TaskEntry`
|
||||
- `TaskStatus`
|
||||
- `TaskSnapshot`
|
||||
- `TaskCreate` / `TaskUpdate` / `TaskGet` / `TaskList` tool implementations
|
||||
- reminder hooks/state already moved to the feature
|
||||
- snapshot/restore/rewind/compaction façade
|
||||
- Remove Task production API from `tools`.
|
||||
- Remove `tools::TaskStore`, `tools::TaskEntry`, `tools::TaskStatus`, `tools::TaskSnapshot`, and `tools::task_tools` re-exports unless a narrowly justified temporary test-only compatibility path is required.
|
||||
- Remove or update `tools::builtin_tools(..., task_store, ...)`; production tools should not imply Task belongs to `tools`.
|
||||
- Keep `tools::core_builtin_tools(...)` and non-Task low-level tools intact.
|
||||
- Update Pod Task feature code to use local Task types and local Task tool factories.
|
||||
- Move/port Task tests from `tools` to `pod` as needed.
|
||||
- Update `tools` integration tests so they no longer expect Task tools in `tools::builtin_tools` registration order.
|
||||
- Update TUI compatibility tests that currently depend on `tools` Task types.
|
||||
- Prefer local JSON fixtures or local mirrored structs in TUI tests.
|
||||
- Do not make TUI depend on `pod` just for tests unless there is a strong reason and it does not create an undesirable dependency.
|
||||
- Preserve observable behavior exactly:
|
||||
- tool names/schemas/descriptions;
|
||||
- tool outputs;
|
||||
- TaskStore replay/snapshot text;
|
||||
- Task reminder body/cooldown/source;
|
||||
- TUI parsing compatibility;
|
||||
- normal ToolRegistry / PreToolCall path.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Creating a new `builtin-features` crate.
|
||||
- Extracting `feature-api` from `pod`.
|
||||
- External plugin loading, WASM, package approval, sandbox authority work.
|
||||
- Moving other built-in tool groups.
|
||||
- TUI UI changes.
|
||||
- Changing Task semantics.
|
||||
|
||||
## Suggested files
|
||||
|
||||
- `crates/pod/src/feature/builtin/task.rs`
|
||||
- `crates/pod/src/feature/builtin.rs`
|
||||
- `crates/pod/src/feature.rs`
|
||||
- `crates/tools/src/task.rs`
|
||||
- `crates/tools/src/lib.rs`
|
||||
- `crates/tools/tests/integration.rs`
|
||||
- `crates/tools/tests/edge_cases.rs`
|
||||
- `crates/tui/src/task.rs`
|
||||
- `crates/tui/src/app.rs` / TUI task compatibility tests if needed
|
||||
|
||||
## Validation
|
||||
|
||||
Run at least:
|
||||
|
||||
- focused Task feature tests moved/updated by this ticket
|
||||
- `cargo test -p pod task --lib`
|
||||
- `cargo test -p tools --lib`
|
||||
- `cargo test -p tools --tests`
|
||||
- `cargo test -p tui task --lib` or relevant focused TUI task tests
|
||||
- `cargo test -p pod --lib`
|
||||
- `cargo test -p llm-worker --lib`
|
||||
- `cargo check --workspace --all-targets`
|
||||
- `cargo fmt --check`
|
||||
- `./tickets.sh doctor`
|
||||
- `git diff --check`
|
||||
|
||||
Run `nix build .#yoi` if feasible.
|
||||
|
||||
## Escalate if
|
||||
|
||||
- Removing `tools::builtin_tools` breaks important non-Pod production callers.
|
||||
- TUI compatibility checks require a shared Task schema crate to avoid duplication.
|
||||
- Moving Task tool implementation into `pod` creates an unexpected dependency cycle.
|
||||
- Preserving Task snapshot/replay semantics would require behavior changes.
|
||||
|
||||
## Completion report
|
||||
|
||||
Report:
|
||||
|
||||
- worktree path / branch
|
||||
- commit hash
|
||||
- changed files
|
||||
- final Task module layout
|
||||
- what remains in `tools` and why
|
||||
- evidence production code no longer uses `tools::TaskStore` / `tools::task_tools`
|
||||
- tests/validation results
|
||||
- unresolved risks/follow-ups
|
||||
- whether ready for external review
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
# Implementation report: task-domain-in-pod-feature
|
||||
|
||||
## Worktree / branch
|
||||
|
||||
- Worktree: `/home/hare/Projects/yoi/.worktree/task-domain-in-pod-feature`
|
||||
- Branch: `work/task-domain-in-pod-feature`
|
||||
|
||||
## Commits
|
||||
|
||||
- `5469335 refactor: move task feature into pod`
|
||||
- `c2ed71a docs: update tui task snapshot comments`
|
||||
|
||||
## Summary
|
||||
|
||||
Task domain state and Task tool implementations were moved out of the `tools` crate and into the Pod built-in Task feature module.
|
||||
|
||||
Final Task module layout:
|
||||
|
||||
- `crates/pod/src/feature/builtin/task/mod.rs`
|
||||
- Task feature contribution, reminder hook/state, snapshot/restore/rewind/compaction façade.
|
||||
- `crates/pod/src/feature/builtin/task/store.rs`
|
||||
- `TaskStore`, `TaskEntry`, `TaskStatus`, `TaskSnapshot`, replay/snapshot parsing/rendering.
|
||||
- `crates/pod/src/feature/builtin/task/tool_impl.rs`
|
||||
- `TaskCreate`, `TaskUpdate`, `TaskGet`, `TaskList` tool implementations and local `task_tools` factory.
|
||||
|
||||
`tools` now retains only non-Task core built-in tool plumbing. Production `tools::TaskStore`, `tools::TaskEntry`, `tools::TaskStatus`, `tools::TaskSnapshot`, `tools::task_tools`, and `tools::builtin_tools(..., task_store, ...)` were removed.
|
||||
|
||||
TUI task compatibility tests were changed to use local fixture JSON/text rather than depending on `tools` Task types. A follow-up cleanup commit updated stale comments in `crates/tui/src/task.rs` after review.
|
||||
|
||||
## Changed files
|
||||
|
||||
- `Cargo.lock`
|
||||
- `package.nix`
|
||||
- `crates/pod/src/feature/builtin/task/mod.rs`
|
||||
- `crates/pod/src/feature/builtin/task/store.rs`
|
||||
- `crates/pod/src/feature/builtin/task/tool_impl.rs`
|
||||
- `crates/pod/src/ipc/interceptor.rs`
|
||||
- `crates/tools/src/lib.rs`
|
||||
- `crates/tools/src/tracker.rs`
|
||||
- `crates/tools/tests/edge_cases.rs`
|
||||
- `crates/tools/tests/integration.rs`
|
||||
- `crates/tui/Cargo.toml`
|
||||
- `crates/tui/src/task.rs`
|
||||
|
||||
## Evidence
|
||||
|
||||
Search for remaining production `tools` Task APIs in the implementation worktree returned no matches:
|
||||
|
||||
```text
|
||||
rg "tools::(TaskStore|TaskEntry|TaskStatus|TaskSnapshot|task_tools|task::)|tools::task" crates -g '*.rs'
|
||||
```
|
||||
|
||||
The implementation diff includes no `.yoi/workflow` or old `.insomnia` workflow changes.
|
||||
|
||||
## Validation
|
||||
|
||||
Coder-reported validation passed:
|
||||
|
||||
- `cargo test -p pod task --lib`
|
||||
- `cargo test -p tools --lib`
|
||||
- `cargo test -p tools --tests`
|
||||
- `cargo test -p tui task --lib`
|
||||
- `cargo test -p pod --lib`
|
||||
- `cargo test -p llm-worker --lib`
|
||||
- `cargo check --workspace --all-targets`
|
||||
- `cargo fmt --check`
|
||||
- `./tickets.sh doctor`
|
||||
- `git diff --check`
|
||||
- `git diff --cached --check`
|
||||
- `nix build .#yoi`
|
||||
|
||||
Reviewer-rerun validation passed:
|
||||
|
||||
- `cargo test -p pod task --lib`
|
||||
- `cargo test -p tools --lib`
|
||||
- `cargo test -p tools --tests`
|
||||
- `cargo test -p tui task --lib`
|
||||
- `cargo test -p pod --lib`
|
||||
- `cargo test -p llm-worker --lib`
|
||||
- `cargo check --workspace --all-targets`
|
||||
- `cargo fmt --check`
|
||||
- `./tickets.sh doctor`
|
||||
- `git diff --check`
|
||||
- `nix build .#yoi --no-link`
|
||||
|
||||
Cleanup commit validation passed:
|
||||
|
||||
- `cargo test -p tui task --lib`
|
||||
- `cargo fmt --check`
|
||||
- `git diff --check`
|
||||
|
||||
## Review status
|
||||
|
||||
External sibling reviewer approved with no blockers. The only code/comment follow-up was handled by commit `c2ed71a`.
|
||||
|
||||
## Unresolved risks / follow-ups
|
||||
|
||||
The remaining residual risk is TUI fixture drift if the Pod-owned Task snapshot format changes without updating local TUI compatibility fixtures. This is accepted for this ticket because it avoids an undesirable production dependency from TUI back to Pod or tools.
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
# Review: task-domain-in-pod-feature
|
||||
|
||||
## 1. Result
|
||||
|
||||
approve
|
||||
|
||||
## 2. Summary of implementation
|
||||
|
||||
The implementation relocates the Task domain implementation out of `tools` and into `pod::feature::builtin::task`. The new Task feature module now owns the task store/domain types, task tool handlers, feature installation, and the existing reminder/snapshot/restore/rewind/compaction façade. The `tools` crate is reduced to the non-Task built-in tools and no longer exposes `tools::TaskStore`, `tools::task_tools`, or a `builtin_tools(..., task_store, ...)` API.
|
||||
|
||||
TUI task parsing remains local to `tui` and does not add a production dependency on either `pod` or `tools`; its tests use local snapshot JSON fixtures instead of importing the previous TaskStore type.
|
||||
|
||||
## 3. Requirement-by-requirement assessment
|
||||
|
||||
- **Task domain ownership under `pod::feature::builtin::task`: satisfied.** `TaskStore`, `TaskEntry`, `TaskStatus`, `TaskSnapshot`, task tool implementations, reminder hook/state, and snapshot/restore/rewind/compaction methods are cohesive under the Pod built-in Task feature module.
|
||||
- **No production Task APIs exposed from `tools`: satisfied.** The `tools` crate no longer has the Task module/API surface and `core_builtin_tools()` now installs only non-Task built-ins.
|
||||
- **Non-Task `tools` behavior remains intact: satisfied.** The remaining tool modules, exports, and tests are conceptually unchanged apart from removing Task-specific test coverage from `tools`.
|
||||
- **Task tool names/schemas/descriptions/outputs unchanged: satisfied.** The TaskCreate/TaskUpdate/TaskGet/TaskList definitions and handler response strings were moved without semantic changes.
|
||||
- **TaskStore replay/snapshot/restore and reminder behavior unchanged: satisfied.** The store logic and TaskFeature façade preserve the previous append-history, snapshot, restore, rewind, overview, reminder, and compaction behavior.
|
||||
- **TUI task compatibility without undesirable dependency: satisfied.** `tui` keeps its own typed compatibility reader and does not depend on `pod` or `tools` in production.
|
||||
- **Normal ToolRegistry / PreToolCall path unchanged: satisfied.** Task tools are still registered through the built-in feature contribution path and continue through the existing Worker ToolRegistry / PreToolCall policy path.
|
||||
- **No broad unrelated architecture changes: satisfied.** I did not find new crate-boundary/API extraction, plugin loading, authority-model, WorkItem/MCP, generic UI/event-channel, or broad refactor work in this diff.
|
||||
- **`package.nix` / `Cargo.lock`: acceptable.** The `Cargo.lock` change follows from removing the `tools` dev-dependency from `tui`; the `cargoHash` update is therefore expected. The conditional `fetchCargoVendor` static-crates patch is not part of the Task-domain move, but it is a narrow and safe packaging guard around an existing static-crates workaround, and `nix build .#yoi --no-link` passed with it.
|
||||
- **No `.yoi/workflow` or old `.insomnia` workflow changes included: satisfied.** The diff has no changed files under those paths.
|
||||
|
||||
## 4. Blockers
|
||||
|
||||
None.
|
||||
|
||||
## 5. Non-blockers / follow-ups
|
||||
|
||||
- `crates/tui/src/task.rs` still has a stale comment referring to `tools::render_snapshot` / `tools::TaskStore` ownership of the snapshot format. The code is fine, but the comment should be updated in a follow-up or before merge if the author is already revising the branch.
|
||||
- TUI compatibility tests now rely on local fixture JSON, which is appropriate for avoiding a production dependency, but it leaves some residual drift risk if the Pod-owned snapshot format changes without updating the fixture.
|
||||
|
||||
## 6. Validation assessed or rerun
|
||||
|
||||
Reviewed:
|
||||
|
||||
- Ticket, delegation intent, and decision artifact.
|
||||
- `git diff develop...HEAD` for the implementation branch.
|
||||
- Changed Task feature, tools, TUI, packaging, and lockfile files.
|
||||
- Search results for remaining `tools::TaskStore`, `tools::task_tools`, workflow, and `.insomnia` changes.
|
||||
|
||||
Reran from `/home/hare/Projects/yoi/.worktree/task-domain-in-pod-feature`:
|
||||
|
||||
- `cargo test -p pod task --lib`
|
||||
- `cargo test -p tools --lib`
|
||||
- `cargo test -p tools --tests`
|
||||
- `cargo test -p tui task --lib`
|
||||
- `cargo test -p pod --lib`
|
||||
- `cargo test -p llm-worker --lib`
|
||||
- `cargo check --workspace --all-targets`
|
||||
- `cargo fmt --check`
|
||||
- `./tickets.sh doctor`
|
||||
- `git diff --check`
|
||||
- `nix build .#yoi --no-link`
|
||||
|
||||
All rerun validation completed successfully.
|
||||
|
||||
## 7. Residual risk
|
||||
|
||||
The main residual risk is fixture drift in TUI task compatibility tests now that `tui` intentionally does not import the Pod-owned TaskStore type. That risk is acceptable for this ticket because the implementation preserves the existing serialized shape and avoids the undesirable production dependency.
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
---
|
||||
id: 20260605-025100-task-domain-in-pod-feature
|
||||
slug: task-domain-in-pod-feature
|
||||
title: Task: move Task domain out of tools into pod built-in feature
|
||||
status: closed
|
||||
kind: task
|
||||
priority: P1
|
||||
labels: [tasks, feature-registry, crate-boundary, tools]
|
||||
created_at: 2026-06-05T02:51:00Z
|
||||
updated_at: 2026-06-05T03:26:31Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Issue
|
||||
|
||||
Task is now a built-in feature module in `pod::feature::builtin::task`, and it owns Task reminder hooks and feature lifecycle behavior. However, its domain state and tool implementations still live in `crates/tools/src/task.rs` as `tools::TaskStore`, `tools::TaskEntry`, `tools::TaskStatus`, `tools::TaskSnapshot`, and `tools::task_tools(...)`.
|
||||
|
||||
That split is semantically wrong: `tools` should remain a low-level built-in tool helper crate, while Task is now a stateful built-in feature with session-lifetime state, restore/rewind/snapshot behavior, hooks, and model-visible reminder policy.
|
||||
|
||||
Keep the next step inside the `pod` crate. Do not create a new `builtin-features` crate yet. The larger crate-boundary move can wait until a `feature-api` crate exists and external plugin APIs are better established.
|
||||
|
||||
## Direction
|
||||
|
||||
Move Task domain and Task tool implementation from `tools` into the Pod built-in Task feature module.
|
||||
|
||||
Target shape:
|
||||
|
||||
- `crates/pod/src/feature/builtin/task.rs` or a nested `task/` module owns:
|
||||
- `TaskStore`
|
||||
- `TaskEntry`
|
||||
- `TaskStatus`
|
||||
- `TaskSnapshot`
|
||||
- Task tool implementations for `TaskCreate`, `TaskUpdate`, `TaskGet`, `TaskList`
|
||||
- Task reminder hooks and state
|
||||
- snapshot/restore/rewind/compaction façade
|
||||
- `crates/tools` owns only low-level generic tools and helper state such as filesystem tools, `ScopedFs`, `Tracker`, Bash, Web tools, etc.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Remove Task domain ownership from `tools`.
|
||||
- Prefer deleting `crates/tools/src/task.rs` if no non-Pod production caller needs it.
|
||||
- Remove `TaskStore`, `TaskEntry`, `TaskStatus`, `TaskSnapshot`, and `task_tools` re-exports from `tools` unless a carefully justified temporary compatibility path is needed.
|
||||
- Move Task tool implementations into the Pod built-in Task feature module.
|
||||
- Tool names, schemas, descriptions, outputs, and behavior must remain unchanged.
|
||||
- Preserve once-materialized tool identity and descriptor-approved contribution checks.
|
||||
- Keep `tools::core_builtin_tools(...)` for non-Task low-level tools.
|
||||
- Audit `tools::builtin_tools(...)`.
|
||||
- If only tests/legacy paths use it, either remove it or change those tests/callers to use `core_builtin_tools` plus the Task feature registry path.
|
||||
- Do not keep a production `tools::builtin_tools(..., task_store, ...)` API that implies Task belongs to `tools`.
|
||||
- Update Pod Task feature code to refer to local Task types, not `tools::TaskStore` / `tools::TaskEntry` / `tools::TaskStatus`.
|
||||
- Update tests.
|
||||
- Move TaskStore/tool tests from `tools` to `pod` where appropriate.
|
||||
- Update `tools` integration tests so they no longer assume Task tools are registered by `tools::builtin_tools`.
|
||||
- TUI tests currently use `tools` as a dev-dependency for Task compatibility checks. Either replace those checks with local JSON fixtures / shared literal schemas, or document and keep a test-only compatibility helper only if absolutely necessary.
|
||||
- Preserve current observable behavior:
|
||||
- Task tool API and output text;
|
||||
- TaskStore replay/snapshot/restore semantics;
|
||||
- Task reminder behavior;
|
||||
- TUI parsing compatibility;
|
||||
- normal ToolRegistry / PreToolCall permission path.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Creating a new `builtin-features` crate.
|
||||
- Extracting `pod::feature` / `pod::hook` into a separate feature-api crate.
|
||||
- External plugin loading, WASM, package approval, or sandbox authority work.
|
||||
- Moving Memory, WorkItem, Web, filesystem, or Pod-management tools.
|
||||
- TUI UI changes.
|
||||
- Changing Task tool names/schemas/behavior.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Task domain state and tool implementation no longer live in `crates/tools` as production API.
|
||||
- The built-in Task feature module is the owner of TaskStore, Task types, Task tools, and Task reminders.
|
||||
- `tools` remains a lower-level tool helper crate without Task feature state.
|
||||
- Production code does not call `tools::task_tools` or `tools::TaskStore`.
|
||||
- Tests are relocated/updated and continue to verify Task behavior and TUI compatibility.
|
||||
- Workspace validation passes.
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
Task domain consolidation into the Pod built-in Task feature is complete and merged.
|
||||
|
||||
Implementation commits:
|
||||
|
||||
- `5469335 refactor: move task feature into pod`
|
||||
- `c2ed71a docs: update tui task snapshot comments`
|
||||
- merge commit on `develop`: see `merge: move task domain into pod feature`
|
||||
|
||||
Summary:
|
||||
|
||||
- Moved Task domain types and store into `pod::feature::builtin::task`.
|
||||
- Moved Task tool implementations into the Pod Task feature module.
|
||||
- Removed production Task APIs from `tools`, including `tools::TaskStore`, Task type re-exports, `tools::task_tools`, and Task-bearing `builtin_tools(...)`.
|
||||
- Kept non-Task `tools::core_builtin_tools(...)` and tracker/non-Task tool behavior intact.
|
||||
- Preserved Task tool schema/output behavior, Task snapshot/replay semantics, reminder hooks/state, and snapshot/restore/rewind/compaction façade.
|
||||
- Updated TUI compatibility tests to use local fixtures without adding a production dependency on `pod` or `tools`.
|
||||
|
||||
Review:
|
||||
|
||||
- External sibling reviewer approved with no blockers.
|
||||
- Reviewer non-blocker about stale TUI comments was fixed before merge.
|
||||
- Remaining accepted residual risk: local TUI fixtures can drift if Pod-owned Task snapshot format changes.
|
||||
|
||||
Post-merge validation passed on main workspace:
|
||||
|
||||
- `cargo test -p pod task --lib`
|
||||
- `cargo test -p tools --lib`
|
||||
- `cargo test -p tools --tests`
|
||||
- `cargo test -p tui task --lib`
|
||||
- `cargo fmt --check`
|
||||
- `git diff --check`
|
||||
- `./tickets.sh doctor`
|
||||
- `cargo check --workspace --all-targets`
|
||||
- search for remaining production `tools` Task APIs returned no matches
|
||||
|
|
@ -0,0 +1,359 @@
|
|||
<!-- event: create author: tickets.sh at: 2026-06-05T02:51:00Z -->
|
||||
|
||||
## Created
|
||||
|
||||
Created by tickets.sh create.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: decision author: hare at: 2026-06-05T02:53:15Z -->
|
||||
|
||||
## Decision
|
||||
|
||||
# Decision: keep built-in Task feature inside pod for now
|
||||
|
||||
Task is a stateful built-in feature, not a low-level `tools` crate concern. The next step should keep the feature inside `pod` rather than creating a new crate.
|
||||
|
||||
Rationale:
|
||||
|
||||
- `pod::feature` / `pod::hook` are still defined in the `pod` crate.
|
||||
- Creating `builtin-features` now would either depend on `pod` or require premature extraction of a feature-api crate.
|
||||
- Feature-per-crate would create too many crates; a future `builtin-features` crate may be appropriate only after the API boundary is stable.
|
||||
- Moving Task domain state out of `tools` and into `pod::feature::builtin::task` fixes the immediate semantic split without forcing crate-boundary churn.
|
||||
|
||||
Desired result for this ticket:
|
||||
|
||||
- `tools` provides low-level generic tool helpers.
|
||||
- `pod::feature::builtin::task` owns TaskStore, Task types, Task tool implementations, Task reminders, and Task feature lifecycle.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: plan author: hare at: 2026-06-05T02:53:16Z -->
|
||||
|
||||
## Plan
|
||||
|
||||
# Delegation intent: move Task domain into pod built-in feature
|
||||
|
||||
## Intent
|
||||
|
||||
Move Task domain state and Task tool implementation out of the `tools` crate and into the `pod::feature::builtin::task` module. Keep this inside the `pod` crate for now; do not create a new built-in-features crate.
|
||||
|
||||
The goal is cohesion: Task is now a stateful built-in feature, so `TaskStore`, Task types, Task tools, reminder hooks, and snapshot/restore façade should live together.
|
||||
|
||||
## Worktree / branch
|
||||
|
||||
- worktree: `/home/hare/Projects/yoi/.worktree/task-domain-in-pod-feature`
|
||||
- branch: `work/task-domain-in-pod-feature`
|
||||
|
||||
## Requirements
|
||||
|
||||
- Move Task domain implementation from `crates/tools/src/task.rs` into the Pod built-in Task feature module.
|
||||
- If the file becomes large, prefer `crates/pod/src/feature/builtin/task/` with submodules such as `store.rs`, `tools.rs`, `reminder.rs`, and `mod.rs`.
|
||||
- A single `task.rs` file is acceptable if the change stays clear and maintainable.
|
||||
- Task feature should own:
|
||||
- `TaskStore`
|
||||
- `TaskEntry`
|
||||
- `TaskStatus`
|
||||
- `TaskSnapshot`
|
||||
- `TaskCreate` / `TaskUpdate` / `TaskGet` / `TaskList` tool implementations
|
||||
- reminder hooks/state already moved to the feature
|
||||
- snapshot/restore/rewind/compaction façade
|
||||
- Remove Task production API from `tools`.
|
||||
- Remove `tools::TaskStore`, `tools::TaskEntry`, `tools::TaskStatus`, `tools::TaskSnapshot`, and `tools::task_tools` re-exports unless a narrowly justified temporary test-only compatibility path is required.
|
||||
- Remove or update `tools::builtin_tools(..., task_store, ...)`; production tools should not imply Task belongs to `tools`.
|
||||
- Keep `tools::core_builtin_tools(...)` and non-Task low-level tools intact.
|
||||
- Update Pod Task feature code to use local Task types and local Task tool factories.
|
||||
- Move/port Task tests from `tools` to `pod` as needed.
|
||||
- Update `tools` integration tests so they no longer expect Task tools in `tools::builtin_tools` registration order.
|
||||
- Update TUI compatibility tests that currently depend on `tools` Task types.
|
||||
- Prefer local JSON fixtures or local mirrored structs in TUI tests.
|
||||
- Do not make TUI depend on `pod` just for tests unless there is a strong reason and it does not create an undesirable dependency.
|
||||
- Preserve observable behavior exactly:
|
||||
- tool names/schemas/descriptions;
|
||||
- tool outputs;
|
||||
- TaskStore replay/snapshot text;
|
||||
- Task reminder body/cooldown/source;
|
||||
- TUI parsing compatibility;
|
||||
- normal ToolRegistry / PreToolCall path.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Creating a new `builtin-features` crate.
|
||||
- Extracting `feature-api` from `pod`.
|
||||
- External plugin loading, WASM, package approval, sandbox authority work.
|
||||
- Moving other built-in tool groups.
|
||||
- TUI UI changes.
|
||||
- Changing Task semantics.
|
||||
|
||||
## Suggested files
|
||||
|
||||
- `crates/pod/src/feature/builtin/task.rs`
|
||||
- `crates/pod/src/feature/builtin.rs`
|
||||
- `crates/pod/src/feature.rs`
|
||||
- `crates/tools/src/task.rs`
|
||||
- `crates/tools/src/lib.rs`
|
||||
- `crates/tools/tests/integration.rs`
|
||||
- `crates/tools/tests/edge_cases.rs`
|
||||
- `crates/tui/src/task.rs`
|
||||
- `crates/tui/src/app.rs` / TUI task compatibility tests if needed
|
||||
|
||||
## Validation
|
||||
|
||||
Run at least:
|
||||
|
||||
- focused Task feature tests moved/updated by this ticket
|
||||
- `cargo test -p pod task --lib`
|
||||
- `cargo test -p tools --lib`
|
||||
- `cargo test -p tools --tests`
|
||||
- `cargo test -p tui task --lib` or relevant focused TUI task tests
|
||||
- `cargo test -p pod --lib`
|
||||
- `cargo test -p llm-worker --lib`
|
||||
- `cargo check --workspace --all-targets`
|
||||
- `cargo fmt --check`
|
||||
- `./tickets.sh doctor`
|
||||
- `git diff --check`
|
||||
|
||||
Run `nix build .#yoi` if feasible.
|
||||
|
||||
## Escalate if
|
||||
|
||||
- Removing `tools::builtin_tools` breaks important non-Pod production callers.
|
||||
- TUI compatibility checks require a shared Task schema crate to avoid duplication.
|
||||
- Moving Task tool implementation into `pod` creates an unexpected dependency cycle.
|
||||
- Preserving Task snapshot/replay semantics would require behavior changes.
|
||||
|
||||
## Completion report
|
||||
|
||||
Report:
|
||||
|
||||
- worktree path / branch
|
||||
- commit hash
|
||||
- changed files
|
||||
- final Task module layout
|
||||
- what remains in `tools` and why
|
||||
- evidence production code no longer uses `tools::TaskStore` / `tools::task_tools`
|
||||
- tests/validation results
|
||||
- unresolved risks/follow-ups
|
||||
- whether ready for external review
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: review author: hare at: 2026-06-05T03:22:31Z status: approve -->
|
||||
|
||||
## Review: approve
|
||||
|
||||
# Review: task-domain-in-pod-feature
|
||||
|
||||
## 1. Result
|
||||
|
||||
approve
|
||||
|
||||
## 2. Summary of implementation
|
||||
|
||||
The implementation relocates the Task domain implementation out of `tools` and into `pod::feature::builtin::task`. The new Task feature module now owns the task store/domain types, task tool handlers, feature installation, and the existing reminder/snapshot/restore/rewind/compaction façade. The `tools` crate is reduced to the non-Task built-in tools and no longer exposes `tools::TaskStore`, `tools::task_tools`, or a `builtin_tools(..., task_store, ...)` API.
|
||||
|
||||
TUI task parsing remains local to `tui` and does not add a production dependency on either `pod` or `tools`; its tests use local snapshot JSON fixtures instead of importing the previous TaskStore type.
|
||||
|
||||
## 3. Requirement-by-requirement assessment
|
||||
|
||||
- **Task domain ownership under `pod::feature::builtin::task`: satisfied.** `TaskStore`, `TaskEntry`, `TaskStatus`, `TaskSnapshot`, task tool implementations, reminder hook/state, and snapshot/restore/rewind/compaction methods are cohesive under the Pod built-in Task feature module.
|
||||
- **No production Task APIs exposed from `tools`: satisfied.** The `tools` crate no longer has the Task module/API surface and `core_builtin_tools()` now installs only non-Task built-ins.
|
||||
- **Non-Task `tools` behavior remains intact: satisfied.** The remaining tool modules, exports, and tests are conceptually unchanged apart from removing Task-specific test coverage from `tools`.
|
||||
- **Task tool names/schemas/descriptions/outputs unchanged: satisfied.** The TaskCreate/TaskUpdate/TaskGet/TaskList definitions and handler response strings were moved without semantic changes.
|
||||
- **TaskStore replay/snapshot/restore and reminder behavior unchanged: satisfied.** The store logic and TaskFeature façade preserve the previous append-history, snapshot, restore, rewind, overview, reminder, and compaction behavior.
|
||||
- **TUI task compatibility without undesirable dependency: satisfied.** `tui` keeps its own typed compatibility reader and does not depend on `pod` or `tools` in production.
|
||||
- **Normal ToolRegistry / PreToolCall path unchanged: satisfied.** Task tools are still registered through the built-in feature contribution path and continue through the existing Worker ToolRegistry / PreToolCall policy path.
|
||||
- **No broad unrelated architecture changes: satisfied.** I did not find new crate-boundary/API extraction, plugin loading, authority-model, WorkItem/MCP, generic UI/event-channel, or broad refactor work in this diff.
|
||||
- **`package.nix` / `Cargo.lock`: acceptable.** The `Cargo.lock` change follows from removing the `tools` dev-dependency from `tui`; the `cargoHash` update is therefore expected. The conditional `fetchCargoVendor` static-crates patch is not part of the Task-domain move, but it is a narrow and safe packaging guard around an existing static-crates workaround, and `nix build .#yoi --no-link` passed with it.
|
||||
- **No `.yoi/workflow` or old `.insomnia` workflow changes included: satisfied.** The diff has no changed files under those paths.
|
||||
|
||||
## 4. Blockers
|
||||
|
||||
None.
|
||||
|
||||
## 5. Non-blockers / follow-ups
|
||||
|
||||
- `crates/tui/src/task.rs` still has a stale comment referring to `tools::render_snapshot` / `tools::TaskStore` ownership of the snapshot format. The code is fine, but the comment should be updated in a follow-up or before merge if the author is already revising the branch.
|
||||
- TUI compatibility tests now rely on local fixture JSON, which is appropriate for avoiding a production dependency, but it leaves some residual drift risk if the Pod-owned snapshot format changes without updating the fixture.
|
||||
|
||||
## 6. Validation assessed or rerun
|
||||
|
||||
Reviewed:
|
||||
|
||||
- Ticket, delegation intent, and decision artifact.
|
||||
- `git diff develop...HEAD` for the implementation branch.
|
||||
- Changed Task feature, tools, TUI, packaging, and lockfile files.
|
||||
- Search results for remaining `tools::TaskStore`, `tools::task_tools`, workflow, and `.insomnia` changes.
|
||||
|
||||
Reran from `/home/hare/Projects/yoi/.worktree/task-domain-in-pod-feature`:
|
||||
|
||||
- `cargo test -p pod task --lib`
|
||||
- `cargo test -p tools --lib`
|
||||
- `cargo test -p tools --tests`
|
||||
- `cargo test -p tui task --lib`
|
||||
- `cargo test -p pod --lib`
|
||||
- `cargo test -p llm-worker --lib`
|
||||
- `cargo check --workspace --all-targets`
|
||||
- `cargo fmt --check`
|
||||
- `./tickets.sh doctor`
|
||||
- `git diff --check`
|
||||
- `nix build .#yoi --no-link`
|
||||
|
||||
All rerun validation completed successfully.
|
||||
|
||||
## 7. Residual risk
|
||||
|
||||
The main residual risk is fixture drift in TUI task compatibility tests now that `tui` intentionally does not import the Pod-owned TaskStore type. That risk is acceptable for this ticket because the implementation preserves the existing serialized shape and avoids the undesirable production dependency.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: implementation_report author: hare at: 2026-06-05T03:24:54Z -->
|
||||
|
||||
## Implementation report
|
||||
|
||||
# Implementation report: task-domain-in-pod-feature
|
||||
|
||||
## Worktree / branch
|
||||
|
||||
- Worktree: `/home/hare/Projects/yoi/.worktree/task-domain-in-pod-feature`
|
||||
- Branch: `work/task-domain-in-pod-feature`
|
||||
|
||||
## Commits
|
||||
|
||||
- `5469335 refactor: move task feature into pod`
|
||||
- `c2ed71a docs: update tui task snapshot comments`
|
||||
|
||||
## Summary
|
||||
|
||||
Task domain state and Task tool implementations were moved out of the `tools` crate and into the Pod built-in Task feature module.
|
||||
|
||||
Final Task module layout:
|
||||
|
||||
- `crates/pod/src/feature/builtin/task/mod.rs`
|
||||
- Task feature contribution, reminder hook/state, snapshot/restore/rewind/compaction façade.
|
||||
- `crates/pod/src/feature/builtin/task/store.rs`
|
||||
- `TaskStore`, `TaskEntry`, `TaskStatus`, `TaskSnapshot`, replay/snapshot parsing/rendering.
|
||||
- `crates/pod/src/feature/builtin/task/tool_impl.rs`
|
||||
- `TaskCreate`, `TaskUpdate`, `TaskGet`, `TaskList` tool implementations and local `task_tools` factory.
|
||||
|
||||
`tools` now retains only non-Task core built-in tool plumbing. Production `tools::TaskStore`, `tools::TaskEntry`, `tools::TaskStatus`, `tools::TaskSnapshot`, `tools::task_tools`, and `tools::builtin_tools(..., task_store, ...)` were removed.
|
||||
|
||||
TUI task compatibility tests were changed to use local fixture JSON/text rather than depending on `tools` Task types. A follow-up cleanup commit updated stale comments in `crates/tui/src/task.rs` after review.
|
||||
|
||||
## Changed files
|
||||
|
||||
- `Cargo.lock`
|
||||
- `package.nix`
|
||||
- `crates/pod/src/feature/builtin/task/mod.rs`
|
||||
- `crates/pod/src/feature/builtin/task/store.rs`
|
||||
- `crates/pod/src/feature/builtin/task/tool_impl.rs`
|
||||
- `crates/pod/src/ipc/interceptor.rs`
|
||||
- `crates/tools/src/lib.rs`
|
||||
- `crates/tools/src/tracker.rs`
|
||||
- `crates/tools/tests/edge_cases.rs`
|
||||
- `crates/tools/tests/integration.rs`
|
||||
- `crates/tui/Cargo.toml`
|
||||
- `crates/tui/src/task.rs`
|
||||
|
||||
## Evidence
|
||||
|
||||
Search for remaining production `tools` Task APIs in the implementation worktree returned no matches:
|
||||
|
||||
```text
|
||||
rg "tools::(TaskStore|TaskEntry|TaskStatus|TaskSnapshot|task_tools|task::)|tools::task" crates -g '*.rs'
|
||||
```
|
||||
|
||||
The implementation diff includes no `.yoi/workflow` or old `.insomnia` workflow changes.
|
||||
|
||||
## Validation
|
||||
|
||||
Coder-reported validation passed:
|
||||
|
||||
- `cargo test -p pod task --lib`
|
||||
- `cargo test -p tools --lib`
|
||||
- `cargo test -p tools --tests`
|
||||
- `cargo test -p tui task --lib`
|
||||
- `cargo test -p pod --lib`
|
||||
- `cargo test -p llm-worker --lib`
|
||||
- `cargo check --workspace --all-targets`
|
||||
- `cargo fmt --check`
|
||||
- `./tickets.sh doctor`
|
||||
- `git diff --check`
|
||||
- `git diff --cached --check`
|
||||
- `nix build .#yoi`
|
||||
|
||||
Reviewer-rerun validation passed:
|
||||
|
||||
- `cargo test -p pod task --lib`
|
||||
- `cargo test -p tools --lib`
|
||||
- `cargo test -p tools --tests`
|
||||
- `cargo test -p tui task --lib`
|
||||
- `cargo test -p pod --lib`
|
||||
- `cargo test -p llm-worker --lib`
|
||||
- `cargo check --workspace --all-targets`
|
||||
- `cargo fmt --check`
|
||||
- `./tickets.sh doctor`
|
||||
- `git diff --check`
|
||||
- `nix build .#yoi --no-link`
|
||||
|
||||
Cleanup commit validation passed:
|
||||
|
||||
- `cargo test -p tui task --lib`
|
||||
- `cargo fmt --check`
|
||||
- `git diff --check`
|
||||
|
||||
## Review status
|
||||
|
||||
External sibling reviewer approved with no blockers. The only code/comment follow-up was handled by commit `c2ed71a`.
|
||||
|
||||
## Unresolved risks / follow-ups
|
||||
|
||||
The remaining residual risk is TUI fixture drift if the Pod-owned Task snapshot format changes without updating local TUI compatibility fixtures. This is accepted for this ticket because it avoids an undesirable production dependency from TUI back to Pod or tools.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-06-05T03:26:31Z status: closed -->
|
||||
|
||||
## Closed
|
||||
|
||||
Task domain consolidation into the Pod built-in Task feature is complete and merged.
|
||||
|
||||
Implementation commits:
|
||||
|
||||
- `5469335 refactor: move task feature into pod`
|
||||
- `c2ed71a docs: update tui task snapshot comments`
|
||||
- merge commit on `develop`: see `merge: move task domain into pod feature`
|
||||
|
||||
Summary:
|
||||
|
||||
- Moved Task domain types and store into `pod::feature::builtin::task`.
|
||||
- Moved Task tool implementations into the Pod Task feature module.
|
||||
- Removed production Task APIs from `tools`, including `tools::TaskStore`, Task type re-exports, `tools::task_tools`, and Task-bearing `builtin_tools(...)`.
|
||||
- Kept non-Task `tools::core_builtin_tools(...)` and tracker/non-Task tool behavior intact.
|
||||
- Preserved Task tool schema/output behavior, Task snapshot/replay semantics, reminder hooks/state, and snapshot/restore/rewind/compaction façade.
|
||||
- Updated TUI compatibility tests to use local fixtures without adding a production dependency on `pod` or `tools`.
|
||||
|
||||
Review:
|
||||
|
||||
- External sibling reviewer approved with no blockers.
|
||||
- Reviewer non-blocker about stale TUI comments was fixed before merge.
|
||||
- Remaining accepted residual risk: local TUI fixtures can drift if Pod-owned Task snapshot format changes.
|
||||
|
||||
Post-merge validation passed on main workspace:
|
||||
|
||||
- `cargo test -p pod task --lib`
|
||||
- `cargo test -p tools --lib`
|
||||
- `cargo test -p tools --tests`
|
||||
- `cargo test -p tui task --lib`
|
||||
- `cargo fmt --check`
|
||||
- `git diff --check`
|
||||
- `./tickets.sh doctor`
|
||||
- `cargo check --workspace --all-targets`
|
||||
- search for remaining production `tools` Task APIs returned no matches
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -1,462 +0,0 @@
|
|||
# Public Pod-side API for Feature / Plugin Contributions
|
||||
|
||||
## 1. Summary recommendation
|
||||
|
||||
Introduce a `pod::feature` public API as the single Pod-side registration layer for built-in features and future external plugins. A feature module should declare its identity, requested capabilities, and contributions, then install those contributions only through typed host registrars for existing Pod/Worker surfaces: `ToolRegistry`, the hardened safe `pod::hook` surface, and host-owned notification/event/history append paths.
|
||||
|
||||
The registry should not become a second runtime, a plugin dispatcher tool, or a generic `Pod` mutation escape hatch. Feature state remains inside the feature module; the Pod owns only install metadata, diagnostics, granted host handles, and normal durable session/runtime surfaces.
|
||||
|
||||
Recommended placement: create `crates/pod/src/feature.rs` (or `crates/pod/src/feature/mod.rs` once it grows) and export it as `pod::feature`. Keep `llm-worker::Interceptor` internal; expose only hardened `pod::hook` types and contribution registrars.
|
||||
|
||||
## 2. Current relevant Pod/Worker surfaces
|
||||
|
||||
The design should build on these existing surfaces rather than bypassing them:
|
||||
|
||||
- `crates/pod/src/hook.rs`
|
||||
- Current public-ish hook layer wraps `llm_worker::Interceptor` with `HookRegistry`, `HookRegistryBuilder`, `Hook`, and per-event hook traits.
|
||||
- It already provides Pod-specific hook events such as pre-request, post-assistant, pre-tool-call, post-tool-call, and turn-end.
|
||||
- It is not yet safe enough as a public plugin API because some hook actions can carry raw `llm_worker::Item` values (`PreRequestAction::ContinueWith`, `TurnEndAction::ContinueWithMessages`). The feature API must depend on the post-hardening surface, not these raw item mutation forms.
|
||||
|
||||
- `crates/pod/src/ipc/interceptor.rs`
|
||||
- `PodInterceptor` is the bridge between Worker callbacks and Pod behavior.
|
||||
- It runs hooks, drains pending attachments/notifications, records memory/tool usage, and turns model-visible additions into committed `SystemItem` session log entries before appending them to Worker history.
|
||||
- This is the right place for host-mediated durable append paths; it is not a plugin API itself.
|
||||
|
||||
- `crates/pod/src/controller.rs`
|
||||
- Controller startup currently registers built-in Pod tools through ad hoc code paths.
|
||||
- The feature registry should replace those ad hoc registrations incrementally by installing contributions into the same worker/tool/hook surfaces during Pod construction.
|
||||
|
||||
- `crates/pod/src/pod.rs`
|
||||
- `Pod` owns the durable session log, metadata, runtime event channel, notification helpers, pending system attachments, scope, and Worker lifecycle.
|
||||
- It exposes internal methods that can append history or send alerts/events. The public feature API should not expose `Pod` or `Worker` directly; it should expose narrow sinks that route through these existing methods.
|
||||
|
||||
- `crates/pod/src/permission.rs`
|
||||
- Manifest tool permissions are enforced as a `PreToolCallHook`.
|
||||
- Feature tools must remain subject to the same PreToolCall permission path. Feature capability grants do not replace per-call tool permission.
|
||||
|
||||
- `crates/llm-worker/src/tool.rs` and `crates/llm-worker/src/tool_server.rs`
|
||||
- `ToolDefinition`, `Tool`, `ToolMeta`, `ToolResult`, `ToolOutput`, and `ToolServerHandle` define the normal tool execution path.
|
||||
- Tools registered here get normal schema exposure, execution, bounded output handling, and history result recording.
|
||||
- The public feature API should register `ToolDefinition`s into this registry rather than introducing a separate plugin dispatch layer.
|
||||
|
||||
- `crates/llm-worker/src/interceptor.rs`
|
||||
- The lower-level interceptor is powerful and Worker-oriented. It should remain internal because it can influence model request construction too directly.
|
||||
- Public features should use `pod::hook` only after that API has been narrowed to durable, auditable actions.
|
||||
|
||||
- `crates/tools/src/lib.rs`
|
||||
- Existing built-in tools already use shared tool abstractions and scoped filesystem/runtime handles.
|
||||
- Those tool constructors can become built-in feature contributions without changing model-visible tool names.
|
||||
|
||||
- `crates/pod/src/workflow/mod.rs`
|
||||
- Workflow invocation currently resolves user input segments into system items through the Pod's durable attachment path.
|
||||
- This is a useful pattern for feature-owned model-visible additions: resolve through a host-owned append path and commit what the model sees. It should not become a general plugin context injection mechanism.
|
||||
|
||||
## 3. Proposed public API shape
|
||||
|
||||
### Types/modules
|
||||
|
||||
Add a new module under `pod`:
|
||||
|
||||
```rust
|
||||
pub mod feature {
|
||||
pub mod capability;
|
||||
pub mod diagnostic;
|
||||
pub mod event;
|
||||
pub mod hook;
|
||||
pub mod registry;
|
||||
pub mod tool;
|
||||
|
||||
pub use capability::{CapabilityGrantSet, CapabilityRequest, HostCapability};
|
||||
pub use diagnostic::{FeatureDiagnostic, FeatureInstallReport};
|
||||
pub use registry::{FeatureDescriptor, FeatureId, FeatureInstallContext, FeatureModule, FeatureRegistryBuilder, FeatureRuntimeKind};
|
||||
pub use tool::ToolContribution;
|
||||
}
|
||||
```
|
||||
|
||||
Core trait and registry shape:
|
||||
|
||||
```rust
|
||||
pub trait FeatureModule: Send + Sync + 'static {
|
||||
fn descriptor(&self) -> FeatureDescriptor;
|
||||
|
||||
fn install(&self, ctx: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError>;
|
||||
}
|
||||
|
||||
pub struct FeatureDescriptor {
|
||||
pub id: FeatureId, // source-qualified identity, e.g. builtin:task
|
||||
pub display_name: String,
|
||||
pub version: Option<String>,
|
||||
pub runtime: FeatureRuntimeKind, // Builtin, ExternalProcess, McpBridge, WasmPlaceholder, DeclarativePlaceholder
|
||||
pub requested_capabilities: Vec<CapabilityRequest>,
|
||||
pub declared_tools: Vec<ToolDeclaration>,
|
||||
pub declared_hooks: Vec<HookDeclaration>,
|
||||
pub declared_event_channels: Vec<EventChannelDeclaration>,
|
||||
}
|
||||
|
||||
pub enum FeatureRuntimeKind {
|
||||
Builtin,
|
||||
ExternalProcess,
|
||||
McpBridge,
|
||||
WasmPlaceholder,
|
||||
DeclarativePlaceholder,
|
||||
}
|
||||
|
||||
pub struct FeatureInstallContext<'a> {
|
||||
// No Pod or Worker reference.
|
||||
pub feature_id: &'a FeatureId,
|
||||
pub grants: &'a CapabilityGrantSet,
|
||||
pub tools: ToolRegistrar<'a>,
|
||||
pub hooks: PublicHookRegistrar<'a>,
|
||||
pub notify: FeatureNotifySink<'a>,
|
||||
pub events: FeatureEventSink<'a>,
|
||||
pub diagnostics: FeatureDiagnosticSink<'a>,
|
||||
pub services: FeatureServiceProvider<'a>,
|
||||
}
|
||||
```
|
||||
|
||||
Important details:
|
||||
|
||||
- `FeatureDescriptor` is declarative and serializable. It is safe to show in diagnostics, profile previews, and `ListFeatures`-style future tooling.
|
||||
- `FeatureModule::install` is runtime code that wires stateful tool/hook implementations into host registrars.
|
||||
- `FeatureInstallContext` must not expose `Pod`, `Worker`, raw `ToolServerHandle`, raw `Interceptor`, raw `NotifyBuffer`, raw `LogWriter`, raw `event_tx`, or direct history mutation.
|
||||
- `FeatureServiceProvider` returns only host services backed by granted capabilities, for example scoped filesystem access, WorkItem store access, memory access, Pod orchestration handles, web provider handles, or secret references. It should return `Denied`/`Unavailable` diagnostics instead of exposing partial internals.
|
||||
|
||||
### Example registration snippet
|
||||
|
||||
This is illustrative shape, not proposed final exact Rust syntax:
|
||||
|
||||
```rust
|
||||
use pod::feature::{
|
||||
CapabilityRequest, FeatureDescriptor, FeatureId, FeatureInstallContext,
|
||||
FeatureModule, FeatureRuntimeKind, HostCapability, ToolContribution,
|
||||
};
|
||||
|
||||
pub struct WorkItemFeature {
|
||||
state: std::sync::Arc<WorkItemFeatureState>,
|
||||
}
|
||||
|
||||
impl FeatureModule for WorkItemFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
FeatureDescriptor::builder(FeatureId::builtin("work-item"))
|
||||
.display_name("WorkItem intake and routing")
|
||||
.runtime(FeatureRuntimeKind::Builtin)
|
||||
.request(CapabilityRequest::required(
|
||||
HostCapability::WorkItemStore { read: true, write: true },
|
||||
"create and update WorkItem records through host-owned ticket storage",
|
||||
))
|
||||
.request(CapabilityRequest::optional(
|
||||
HostCapability::EmitUserEvent,
|
||||
"surface routing diagnostics to the TUI/actionbar",
|
||||
))
|
||||
.tool("WorkItemCreate")
|
||||
.tool("WorkItemComment")
|
||||
.hook("work_item_intake_pre_tool_audit", pod::hook::HookPoint::PreToolCall)
|
||||
.event_channel("work-item")
|
||||
.build()
|
||||
}
|
||||
|
||||
fn install(&self, ctx: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
let store = ctx.services.work_item_store()?;
|
||||
|
||||
ctx.tools.register(ToolContribution::new(
|
||||
"WorkItemCreate",
|
||||
work_item_create_tool(store.clone(), self.state.clone()),
|
||||
))?;
|
||||
|
||||
ctx.hooks.pre_tool_call(
|
||||
"work_item_intake_pre_tool_audit",
|
||||
WorkItemAuditHook::new(self.state.clone()),
|
||||
)?;
|
||||
|
||||
ctx.events.declare_channel("work-item")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The feature keeps `WorkItemFeatureState`. The Pod keeps only registration records, diagnostics, and the normal host services it already owns.
|
||||
|
||||
### Tool contribution
|
||||
|
||||
A tool contribution should be a thin wrapper around `llm_worker::ToolDefinition` plus feature metadata:
|
||||
|
||||
```rust
|
||||
pub struct ToolContribution {
|
||||
pub feature_id: FeatureId,
|
||||
pub name: ToolName,
|
||||
pub definition: llm_worker::ToolDefinition,
|
||||
pub required_capabilities: Vec<HostCapability>,
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Register into the existing `ToolRegistry` / `ToolServerHandle`; do not add a plugin-dispatcher tool that multiplexes plugin calls outside normal tool history.
|
||||
- Preserve normal `PreToolCall` permission evaluation, tool-call history, result history, output truncation/bounding, and diagnostic behavior.
|
||||
- Host-controlled feature enablement decides whether a contributed tool is installed. Manifest/profile tool permission still decides whether a model may call it at runtime.
|
||||
- Duplicate tool names should be rejected during feature registry preflight with a diagnostic, not discovered later through a panic or undefined ordering.
|
||||
- Public feature identity should be source-qualified (`builtin:memory`, `project:foo`, `plugin:<digest>:bar`), while model-visible tool names should remain explicit stable names. Do not auto-prefix model tool names unless the project deliberately chooses a future namespacing policy.
|
||||
- Tool schemas/descriptions must be part of the normal `ToolDefinition` path so model-visible surfaces remain inspectable and bounded.
|
||||
- If a required host service is not granted or configured, the tool should not be registered; the install report should explain the skipped contribution.
|
||||
|
||||
### Hook contribution
|
||||
|
||||
Hook contribution must depend on the safe hook surface produced by `hook-public-surface-hardening`.
|
||||
|
||||
Recommended public hook principles:
|
||||
|
||||
- Public hooks register through `PublicHookRegistrar`, which wraps `HookRegistryBuilder` but exposes only hardened hook traits/actions.
|
||||
- Public hooks receive snapshots/views, not mutable Pod/Worker handles.
|
||||
- Public hook return values should be decisions such as continue, deny/rewrite a tool decision through a host-defined synthetic result path, emit diagnostics, or request a durable notification/history append through a host sink. They should not return raw `llm_worker::Item` vectors.
|
||||
- Public hooks must not be able to mutate request context, session history, or Worker state invisibly.
|
||||
- Permission enforcement hooks remain host/internal and run before feature hooks for `PreToolCall` so a feature cannot approve a denied tool call.
|
||||
- Hook ordering should be explicit and stable: internal safety hooks first, public feature hooks in registry order or declared priority bands, internal usage/accounting hooks where needed. Priority should be coarse, not arbitrary integer ordering that lets plugins fight for precedence.
|
||||
|
||||
Possible hardened hook action shape:
|
||||
|
||||
```rust
|
||||
pub enum PublicPreToolCallDecision {
|
||||
Continue,
|
||||
DenyWithSyntheticError { message: String },
|
||||
EmitDiagnostic { diagnostic: FeatureDiagnostic },
|
||||
}
|
||||
|
||||
pub trait PublicPreToolCallHook: Send + Sync {
|
||||
fn on_pre_tool_call(&self, event: PublicPreToolCallEvent<'_>) -> PublicPreToolCallDecision;
|
||||
}
|
||||
```
|
||||
|
||||
If a hook needs to add model-visible text, it should use `FeatureNotifySink::notify_model(...)` or another host-owned durable append API, not return an `Item`.
|
||||
|
||||
### Notification/event contribution
|
||||
|
||||
Expose two distinct sinks:
|
||||
|
||||
```rust
|
||||
pub struct FeatureNotifySink<'a> { /* host-owned */ }
|
||||
pub struct FeatureEventSink<'a> { /* host-owned */ }
|
||||
```
|
||||
|
||||
Recommended behavior:
|
||||
|
||||
- `FeatureNotifySink::notify_model(...)` creates a model-visible notification through the existing durable notification/system-item path. The host commits the corresponding `SystemItem` before it is appended to Worker history.
|
||||
- `FeatureNotifySink::notify_user(...)` or `FeatureEventSink::emit(...)` creates user-visible diagnostics/progress/action events through the existing alert/event path. These are not model-visible unless explicitly routed through `notify_model`.
|
||||
- Event payloads should be typed, bounded, and feature-identified. Avoid arbitrary JSON blobs as the first public API; allow an opaque bounded metadata field only if diagnostics require it.
|
||||
- Notifications and events should require explicit capabilities such as `EmitModelNotification` and `EmitUserEvent`.
|
||||
- Background feature tasks must use these sinks; they must not hold raw log writers or append directly to history.
|
||||
|
||||
Useful initial event shape:
|
||||
|
||||
```rust
|
||||
pub struct FeatureEvent {
|
||||
pub feature_id: FeatureId,
|
||||
pub level: FeatureEventLevel, // Info, Warn, Error
|
||||
pub channel: String, // e.g. "work-item"
|
||||
pub summary: String,
|
||||
pub detail: Option<String>,
|
||||
pub model_visible: bool, // false unless host routes through notify_model
|
||||
}
|
||||
```
|
||||
|
||||
`model_visible` should be host-controlled in practice: a feature may request model visibility, but the sink decides whether that capability is granted and records the durable append if it is.
|
||||
|
||||
### Capability request/grant/diagnostics
|
||||
|
||||
Capabilities are requested by descriptors and granted by the host. A feature may request a capability, but it must not assume the capability exists.
|
||||
|
||||
Initial capability categories:
|
||||
|
||||
```rust
|
||||
pub enum HostCapability {
|
||||
ContributeTool { name: ToolName },
|
||||
ContributeHook { point: pod::hook::HookPoint },
|
||||
EmitUserEvent,
|
||||
EmitModelNotification,
|
||||
ScopedFs { read: bool, write: bool, execute: bool },
|
||||
WorkItemStore { read: bool, write: bool },
|
||||
MemoryStore { read: bool, write: bool },
|
||||
PodManagement { spawn: bool, message: bool, restore: bool },
|
||||
Network { purpose: NetworkPurpose },
|
||||
SecretRef { id: String },
|
||||
}
|
||||
```
|
||||
|
||||
Important separation:
|
||||
|
||||
- Capability grants decide whether a feature may install and receive host services.
|
||||
- Tool permissions decide whether an installed tool call may execute for a specific Pod/run.
|
||||
- Scope permissions decide which filesystem paths or delegated Pod capabilities a host service may touch.
|
||||
|
||||
Diagnostics should be first-class:
|
||||
|
||||
```rust
|
||||
pub struct FeatureInstallReport {
|
||||
pub feature_id: FeatureId,
|
||||
pub enabled: bool,
|
||||
pub granted: Vec<HostCapability>,
|
||||
pub denied: Vec<CapabilityDenial>,
|
||||
pub installed_tools: Vec<ToolName>,
|
||||
pub installed_hooks: Vec<String>,
|
||||
pub skipped_contributions: Vec<SkippedContribution>,
|
||||
pub diagnostics: Vec<FeatureDiagnostic>,
|
||||
}
|
||||
```
|
||||
|
||||
Diagnostics must avoid secrets and must be safe for session logs, TUI display, and future `ListFeatures`/profile validation output.
|
||||
|
||||
## 4. State ownership model
|
||||
|
||||
Feature state belongs to the feature module.
|
||||
|
||||
- A feature may own `Arc<State>` and clone it into contributed tools, hooks, and background tasks.
|
||||
- The Pod registry stores descriptors, install reports, enabled/disabled status, and host-owned handles. It does not store feature business state.
|
||||
- Durable feature data must live in a feature-owned or host-granted store with an explicit API: WorkItem files through a WorkItem service, memory records through memory APIs, plugin config/state through a future plugin-state service, etc.
|
||||
- Session history is not feature storage. It is an audit/replay record of model-visible interactions and host-visible events.
|
||||
- A feature that needs restoration after process restart should reconstruct itself from its own durable store/config plus normal Pod metadata, not from private data hidden in Worker context.
|
||||
- Background tasks are allowed only if they communicate through granted sinks/services and have a defined shutdown/lifecycle policy owned by the host.
|
||||
|
||||
This model lets built-ins and plugins share the same contribution shape while keeping Pod runtime ownership clear.
|
||||
|
||||
## 5. Safety invariants / forbidden operations
|
||||
|
||||
Public features/plugins must not be able to perform these operations:
|
||||
|
||||
- Mutate prompt context directly.
|
||||
- Append, remove, reorder, or rewrite Worker history directly.
|
||||
- Insert model-visible text that is not committed through a durable host path.
|
||||
- Return raw `llm_worker::Item` values from public hooks.
|
||||
- Access raw `Worker`, raw `Pod`, raw `ToolServerHandle`, raw `llm_worker::Interceptor`, raw `NotifyBuffer`, raw session log writer, or raw event sender.
|
||||
- Register tools outside `ToolRegistry` or bypass normal tool-result history recording.
|
||||
- Bypass `PreToolCall` permission policy.
|
||||
- Grant themselves capabilities or infer grants from successful construction.
|
||||
- Mutate manifest/profile/scope state directly.
|
||||
- Perform filesystem/process/network/secret access outside granted host services.
|
||||
- Emit unbounded tool outputs, event payloads, diagnostics, or notification bodies.
|
||||
- Put secrets into diagnostics, session logs, model context, TUI output, or feature install reports.
|
||||
- Depend on MCP/WASM/package-distribution mechanics in the base Pod API.
|
||||
|
||||
Positive invariant: if the model can see a feature-produced fact, a future replay/resume must have a durable explanation for why that fact was present.
|
||||
|
||||
## 6. Placement and crate-boundary recommendation
|
||||
|
||||
Recommended placement:
|
||||
|
||||
- `crates/pod/src/feature.rs` or `crates/pod/src/feature/mod.rs`
|
||||
- public feature traits/types
|
||||
- feature registry builder
|
||||
- install reports/diagnostics
|
||||
- capability request/grant model
|
||||
- typed registrars/sinks
|
||||
|
||||
- `crates/pod/src/hook.rs`
|
||||
- remains the public hook module after hardening
|
||||
- should expose safe Pod-level hook traits/actions only
|
||||
- should not re-export `llm_worker::Interceptor` power
|
||||
|
||||
- `crates/llm-worker`
|
||||
- remains owner of generic LLM tools/interceptors/history machinery
|
||||
- should not depend on `pod::feature`
|
||||
|
||||
- `crates/tools`
|
||||
- remains a source of reusable tool implementations
|
||||
- built-in feature modules in `pod` can wrap these constructors into `ToolContribution`s
|
||||
|
||||
- Future external plugin crates/processes
|
||||
- should adapt into `FeatureDescriptor` + `FeatureModule` or a host-side adapter that produces equivalent contributions
|
||||
- should not be called directly by the Pod except through the registry/registrars
|
||||
|
||||
Install location in Pod startup:
|
||||
|
||||
1. Resolve manifest/profile and host capability policy.
|
||||
2. Construct `Pod` and internal safety surfaces.
|
||||
3. Install host/internal hooks such as manifest permission enforcement.
|
||||
4. Build and install enabled feature modules through `FeatureRegistryBuilder`.
|
||||
5. Flush/register tools through the existing Worker tool registry.
|
||||
6. Freeze/install the Pod interceptor and start normal run/attach behavior.
|
||||
|
||||
The exact sequencing can be adjusted to match current construction, but the invariant should hold: public feature hooks cannot precede host safety hooks, and feature tools must exist before the model receives the final tool schema for a run.
|
||||
|
||||
## 7. Migration path from current built-in registrations
|
||||
|
||||
Recommended migration is incremental and behavior-preserving:
|
||||
|
||||
1. Land hook public-surface hardening first.
|
||||
- Remove/replace public raw `Item`-carrying hook actions.
|
||||
- Define which hook decisions are safe for external contributors.
|
||||
|
||||
2. Add `pod::feature` with no behavior change.
|
||||
- Implement descriptors, capability grants, install reports, and registrars.
|
||||
- Initially register no external plugins.
|
||||
|
||||
3. Wrap current built-in tool registration as built-in feature modules.
|
||||
- Start with a small built-in feature whose state/services are already cleanly bounded.
|
||||
- Preserve existing tool names, schemas, and permission behavior.
|
||||
- Convert duplicate-name failures into registry diagnostics before flushing tools.
|
||||
|
||||
4. Move larger built-in groups behind feature modules.
|
||||
- Filesystem/process tools from `crates/tools`.
|
||||
- Memory tools.
|
||||
- Pod orchestration tools.
|
||||
- Task/WorkItem tools once their stores and hooks have explicit capabilities.
|
||||
- Web tools as configured provider-backed features.
|
||||
|
||||
5. Move built-in hook contributions only after safe hook semantics are stable.
|
||||
- Keep manifest permission enforcement as an internal host hook, not a feature hook.
|
||||
- Keep accounting/usage hooks internal unless they become genuine feature behavior.
|
||||
|
||||
6. Treat workflow/user-input expansion separately.
|
||||
- Workflow invocation already uses a durable system-item attachment pattern.
|
||||
- Do not expose arbitrary workflow-like context injection to plugins until there is a safe typed command/input-contribution API with durable append semantics.
|
||||
|
||||
7. Add profile/manifest enablement after built-ins work through the same registry.
|
||||
- Built-ins and external plugins should share descriptor/capability/install-report mechanics.
|
||||
- Host policy may grant built-ins by default, but built-ins should still declare what they use.
|
||||
|
||||
## 8. Impact on WorkItem / MCP / plugin distribution follow-ups
|
||||
|
||||
WorkItem / intake routing:
|
||||
|
||||
- WorkItem routing can become a built-in feature that contributes WorkItem tools, optional routing hooks, and user-visible action events.
|
||||
- It should request `WorkItemStore` and event/notification capabilities instead of reaching into ticket files ad hoc.
|
||||
- Model-visible routing hints or intake results must be committed through notification/history append paths.
|
||||
- This registry gives the WorkItem feature a clean way to install without making WorkItem a special Pod runtime mode.
|
||||
|
||||
MCP:
|
||||
|
||||
- MCP should be an adapter/runtime kind that produces normal `ToolContribution`s and possibly safe event diagnostics.
|
||||
- MCP tool calls must still pass through `ToolRegistry`, PreToolCall permission, output bounding, and history result recording.
|
||||
- MCP resources/prompts should not become invisible prompt injection. If exposed later, they should be explicit tools, user-invoked attachments, or durable notification/history appends.
|
||||
- MCP transport/session details are out of scope for the base API beyond the `FeatureRuntimeKind::McpBridge` placeholder.
|
||||
|
||||
Plugin distribution:
|
||||
|
||||
- Archive validation, cache extraction, signing/trust, WASM execution, external process supervision, and package update policy should remain separate follow-up designs.
|
||||
- Distribution mechanisms should eventually produce the same descriptor/capability/contribution objects as built-ins.
|
||||
- Capability grants are the host trust boundary; package installation alone must not grant runtime authority.
|
||||
|
||||
## 9. Open questions / risks
|
||||
|
||||
1. Tool naming policy is the highest-risk API decision.
|
||||
- Recommendation: feature identities are source-qualified, model-visible tool names stay explicit and stable, and collisions are rejected by the host.
|
||||
- Risk: external plugins may need namespacing later. Auto-prefixing now would avoid collisions but would also change model-facing ergonomics and diverge from current built-in tool names.
|
||||
|
||||
2. The exact safe hook action set must be settled by `hook-public-surface-hardening`.
|
||||
- Especially important: whether public pre-tool hooks may synthesize denials/results, and how durable append requests are represented.
|
||||
|
||||
3. Notification/event durability needs precise semantics.
|
||||
- User-visible events may be live-only, while model-visible notifications must be durable. The public API should make this distinction impossible to miss.
|
||||
|
||||
4. Capability granularity can easily become either too coarse or too noisy.
|
||||
- Start with coarse host-service capabilities plus normal tool permissions, then split only when real features need finer grants.
|
||||
|
||||
5. Runtime enable/disable is not designed here.
|
||||
- Initial registry should be install-at-startup. Hot reload or dynamic plugin enablement needs separate lifecycle, cleanup, and schema-refresh design.
|
||||
|
||||
6. Persistent plugin state needs a future host service.
|
||||
- The base API says state is feature-owned, but external plugins will still need a sanctioned durable state directory/store with migration/versioning rules.
|
||||
|
||||
7. Background tasks need lifecycle policy.
|
||||
- If external plugins can spawn tasks, the host must define shutdown, cancellation, panic handling, diagnostic routing, and whether task output may become model-visible.
|
||||
|
||||
8. Existing workflow/input expansion is close to the forbidden boundary.
|
||||
- It is safe only because it commits system items before model visibility. Any future plugin command/input contribution must preserve that durable replay property.
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
# Decision: separate internal feature modules from external-plugin authority
|
||||
|
||||
Internal modules extracted from Pod implementation files should not be treated as if they require the external-plugin permission model.
|
||||
|
||||
For an internal built-in module such as Task tools:
|
||||
|
||||
- the feature registry is an API/registration boundary;
|
||||
- descriptor-declared contributions are reconciled at install time;
|
||||
- normal ToolRegistry and PreToolCall permission behavior remains authoritative;
|
||||
- host state such as `TaskStore` can be passed by the Pod host constructor;
|
||||
- requested host authorities should normally be empty.
|
||||
|
||||
The external-plugin authority model remains necessary for sandbox/object-capability grants when plugin code receives dangerous host APIs such as filesystem, network, secrets, model-visible durable notification/history append, Pod-management façade, persistent state, or authority-bearing service access.
|
||||
|
||||
This split should be implemented separately from the Task tools extraction. The Task tools extraction should validate the contribution-only built-in module path without solving external plugin approval.
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# Decision: authority handles live in Hook contexts, not Hook return effects
|
||||
|
||||
The internal-module and external-plugin authority split should treat host-authority APIs as handles supplied by the host, including inside Hooks.
|
||||
|
||||
Implications:
|
||||
|
||||
- Hook return values remain per-hook-point flow-control actions.
|
||||
- Side effects such as durable model-visible SystemItem append are performed through typed host handles on event-specific Hook contexts.
|
||||
- Built-in internal modules may receive handles according to host policy without user-facing external-plugin approval.
|
||||
- Future external plugins receive only the handles allowed by their approved host authorities.
|
||||
- The main API should not be “return an effect and let the host reject it at runtime.” Rejection remains defense-in-depth for malformed calls, missing handles, bounds, and policy violations.
|
||||
- Do not model every authority combination as a distinct Hook context type. Use event-specific context types with authority-specific handles whose constructors are host-owned.
|
||||
|
||||
This preserves the clean distinction: contribution declarations are descriptor-locked; dangerous host APIs are represented by host-created handles; normal tool permission remains the per-call execution gate.
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
id: 20260604-234844-feature-api-authority-separation
|
||||
slug: feature-api-authority-separation
|
||||
title: Feature API: separate internal modules from external-plugin authority model
|
||||
status: open
|
||||
kind: task
|
||||
priority: P1
|
||||
labels: [plugin, feature-registry, permissions, architecture]
|
||||
created_at: 2026-06-04T23:48:44Z
|
||||
updated_at: 2026-06-05T00:49:53Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Issue
|
||||
|
||||
The first feature-registry slice intentionally placed contribution descriptors and future external-plugin host authorities in the same `pod::feature` module. That was acceptable as a scaffold, but it risks making simple internal module extraction look like it participates in the external-plugin permission model.
|
||||
|
||||
For internal modules that are merely moved outside a Pod implementation file, the registry should act as an API/registration boundary: descriptor-declared contributions, install reports, and normal ToolRegistry/Hook paths. It should not require or imply sandbox/user-approval authorities unless the module asks the host for dangerous external/plugin-style APIs.
|
||||
|
||||
## Direction
|
||||
|
||||
Separate the concepts in code and naming:
|
||||
|
||||
- **Contribution boundary**: tools, hooks, background task declarations, service providers, descriptor/digest locking, duplicate checks, install reports.
|
||||
- **Internal built-in module boundary**: in-process modules constructed by the host with explicit Rust handles such as `TaskStore`; normally no host-authority request/grant flow.
|
||||
- **External-plugin authority boundary**: user-approved sandbox/object-capability grants for host-provided APIs and handles such as filesystem, network, secrets, model-visible durable notification, Pod-management façade, persistent/plugin state, and authority-bearing service access.
|
||||
|
||||
The goal is not to remove the authority model. The goal is to prevent the authority model from being mixed into every internal module extraction.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Audit `pod::feature` names and APIs for places where external-plugin authority concepts are presented as required for internal built-in modules.
|
||||
- Make the API clearly support a contribution-only built-in module path.
|
||||
- Internal modules should be able to declare contributions and install without requesting authorities.
|
||||
- Descriptor/contribution reconciliation still applies.
|
||||
- Normal per-call tool permission / PreToolCall policy still applies.
|
||||
- Keep `HostAuthority` or equivalent only for host-provided dangerous authorities.
|
||||
- Do not reintroduce contribution-authority variants such as `ContributeTool`, `ContributeHook`, `RunBackgroundTask`, or `ProvideService`.
|
||||
- Consider whether the authority request/grant types should be renamed, nested, moved, or documented as external-plugin/sandbox-oriented.
|
||||
- Clarify how built-in modules receive in-process state/handles from the host without treating those constructor arguments as plugin authorities.
|
||||
- Clarify how future external plugins will get only approved host handles/services, without changing the internal module path.
|
||||
- Update tests or add focused tests where needed so a built-in module with zero requested authorities is a normal first-class path.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Implementing external plugin loading or package approval lock files.
|
||||
- Implementing a real user approval resolver.
|
||||
- Changing existing manifest/tool permission policy.
|
||||
- Extracting Memory or Pod management out of core.
|
||||
- Changing Task tool behavior.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- The code/design clearly distinguishes contribution registration from external-plugin host authority grants.
|
||||
- Internal built-in modules can be represented as contribution-only modules without authority boilerplate.
|
||||
- Authority model remains available for future external plugins and host-provided dangerous APIs.
|
||||
- Tests/documentation make it hard to confuse descriptor-approved contributions with sandbox authorities.
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
<!-- event: create author: tickets.sh at: 2026-06-04T23:48:44Z -->
|
||||
|
||||
## Created
|
||||
|
||||
Created by tickets.sh create.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: decision author: hare at: 2026-06-04T23:50:15Z -->
|
||||
|
||||
## Decision
|
||||
|
||||
# Decision: separate internal feature modules from external-plugin authority
|
||||
|
||||
Internal modules extracted from Pod implementation files should not be treated as if they require the external-plugin permission model.
|
||||
|
||||
For an internal built-in module such as Task tools:
|
||||
|
||||
- the feature registry is an API/registration boundary;
|
||||
- descriptor-declared contributions are reconciled at install time;
|
||||
- normal ToolRegistry and PreToolCall permission behavior remains authoritative;
|
||||
- host state such as `TaskStore` can be passed by the Pod host constructor;
|
||||
- requested host authorities should normally be empty.
|
||||
|
||||
The external-plugin authority model remains necessary for sandbox/object-capability grants when plugin code receives dangerous host APIs such as filesystem, network, secrets, model-visible durable notification/history append, Pod-management façade, persistent state, or authority-bearing service access.
|
||||
|
||||
This split should be implemented separately from the Task tools extraction. The Task tools extraction should validate the contribution-only built-in module path without solving external plugin approval.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: decision author: hare at: 2026-06-05T00:49:53Z -->
|
||||
|
||||
## Decision
|
||||
|
||||
# Decision: authority handles live in Hook contexts, not Hook return effects
|
||||
|
||||
The internal-module and external-plugin authority split should treat host-authority APIs as handles supplied by the host, including inside Hooks.
|
||||
|
||||
Implications:
|
||||
|
||||
- Hook return values remain per-hook-point flow-control actions.
|
||||
- Side effects such as durable model-visible SystemItem append are performed through typed host handles on event-specific Hook contexts.
|
||||
- Built-in internal modules may receive handles according to host policy without user-facing external-plugin approval.
|
||||
- Future external plugins receive only the handles allowed by their approved host authorities.
|
||||
- The main API should not be “return an effect and let the host reject it at runtime.” Rejection remains defense-in-depth for malformed calls, missing handles, bounds, and policy violations.
|
||||
- Do not model every authority combination as a distinct Hook context type. Use event-specific context types with authority-specific handles whose constructors are host-owned.
|
||||
|
||||
This preserves the clean distinction: contribution declarations are descriptor-locked; dangerous host APIs are represented by host-created handles; normal tool permission remains the per-call execution gate.
|
||||
|
||||
|
||||
---
|
||||
Loading…
Reference in New Issue
Block a user