interceptorの修正
This commit is contained in:
parent
79f342ca60
commit
84a8bd099b
3
TODO.md
3
TODO.md
|
|
@ -1,10 +1,11 @@
|
|||
- [ ] テスト設計 → [tickets/test-design.md](tickets/test-design.md)
|
||||
- [ ] ツール設計
|
||||
- [ ] Bash ツール (Permission 層と統合) → [tickets/bash-tool.md](tickets/bash-tool.md)
|
||||
- [ ] 複数 Pod 間の Scope 排他制御 → [tickets/scope-exclusion.md](tickets/scope-exclusion.md)
|
||||
- [ ] Compact の改善(要約品質 + 挙動詳細) → [tickets/compact-improvements.md](tickets/compact-improvements.md)
|
||||
- [ ] Protocol の設計 → [tickets/protocol-design.md](tickets/protocol-design.md)
|
||||
- [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md)
|
||||
- [ ] Hook と Interceptor の責務分離 → [tickets/hook-interceptor-separation.md](tickets/hook-interceptor-separation.md)
|
||||
- [ ] Method::Notify: システム起点のコンテキスト注入 → [tickets/method-notify.md](tickets/method-notify.md)
|
||||
- [ ] Pod オーケストレーション: LLM によるマルチエージェント分業 → [tickets/pod-orchestration.md](tickets/pod-orchestration.md)
|
||||
- [ ] ネイティブ GUI クライアント MVP → [tickets/native-gui-mvp.md](tickets/native-gui-mvp.md)
|
||||
- [ ] TUI 拡充
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
//! CompactInterceptor — wraps HookInterceptor with urgent compaction check.
|
||||
//!
|
||||
//! Decorator that delegates all [`Interceptor`] methods to the inner
|
||||
//! `HookInterceptor`, then adds a token-count check in `pre_llm_request`.
|
||||
//! When `last_input_tokens` exceeds the turn threshold, returns
|
||||
//! `PreRequestAction::Yield` so the Worker exits the turn loop cleanly
|
||||
//! with `WorkerResult::Yielded` and Pod can perform compaction.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_worker::Item;
|
||||
use llm_worker::interceptor::{
|
||||
Interceptor, PostToolAction, PreRequestAction, PreToolAction, PromptAction, ToolCallInfo,
|
||||
ToolResultInfo, TurnEndAction,
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
use crate::compact_state::CompactState;
|
||||
use crate::hook_interceptor::HookInterceptor;
|
||||
|
||||
/// Interceptor that wraps HookInterceptor and adds between-turns
|
||||
/// compaction threshold check.
|
||||
pub(crate) struct CompactInterceptor {
|
||||
inner: HookInterceptor,
|
||||
state: Arc<CompactState>,
|
||||
}
|
||||
|
||||
impl CompactInterceptor {
|
||||
pub(crate) fn new(inner: HookInterceptor, state: Arc<CompactState>) -> Self {
|
||||
Self { inner, state }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for CompactInterceptor {
|
||||
async fn on_prompt_submit(&self, item: &mut Item) -> PromptAction {
|
||||
self.inner.on_prompt_submit(item).await
|
||||
}
|
||||
|
||||
async fn pre_llm_request(&self, context: &mut Vec<Item>) -> PreRequestAction {
|
||||
// Step 1: Delegate to inner hooks first.
|
||||
let inner_action = self.inner.pre_llm_request(context).await;
|
||||
if !matches!(inner_action, PreRequestAction::Continue) {
|
||||
return inner_action;
|
||||
}
|
||||
|
||||
// Step 2: Check between-turns compaction threshold.
|
||||
if !self.state.is_disabled() && self.state.exceeds_turn() {
|
||||
info!(
|
||||
input_tokens = self.state.last_input_tokens(),
|
||||
threshold = self.state.turn_threshold(),
|
||||
"Between-turns compaction threshold exceeded, yielding"
|
||||
);
|
||||
return PreRequestAction::Yield;
|
||||
}
|
||||
|
||||
PreRequestAction::Continue
|
||||
}
|
||||
|
||||
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
|
||||
self.inner.pre_tool_call(info).await
|
||||
}
|
||||
|
||||
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
|
||||
self.inner.post_tool_call(info).await
|
||||
}
|
||||
|
||||
async fn on_turn_end(&self, history: &[Item]) -> TurnEndAction {
|
||||
self.inner.on_turn_end(history).await
|
||||
}
|
||||
|
||||
async fn on_abort(&self, reason: &str) {
|
||||
self.inner.on_abort(reason).await;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +1,95 @@
|
|||
//! Pod-layer hook infrastructure
|
||||
//!
|
||||
//! Provides the `Hook<E>` trait and `HookRegistry` for orchestration hooks
|
||||
//! that govern control-flow decisions in the Worker execution loop.
|
||||
//! Hooks are the **public** orchestration extension point. They receive
|
||||
//! read-only summary information about each event in the Worker
|
||||
//! execution loop and return a control-flow action
|
||||
//! (continue / skip / abort / pause).
|
||||
//!
|
||||
//! The type system (`HookEventKind` / `Hook<E>`) mirrors the pattern
|
||||
//! originally in llm-worker, now at the insomnia layer where orchestration
|
||||
//! concerns belong.
|
||||
//! Hooks intentionally cannot mutate the Worker's context, history, tool
|
||||
//! call, or tool result. Internal mechanisms that need such access (e.g.
|
||||
//! compaction, notification injection, output truncation) implement
|
||||
//! `llm_worker::Interceptor` directly inside Pod, never via this trait.
|
||||
//!
|
||||
//! This separation lets Hooks be exposed safely to user-facing
|
||||
//! extension surfaces (scripting, plugins) in the future without
|
||||
//! exposing the underlying mutable state.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_worker::Item;
|
||||
use llm_worker::tool::ToolOutput;
|
||||
use llm_worker::interceptor::{
|
||||
PostToolAction, PreRequestAction, PreToolAction, PromptAction, ToolCallInfo, ToolResultInfo,
|
||||
TurnEndAction,
|
||||
PostToolAction, PreRequestAction, PreToolAction, PromptAction, TurnEndAction,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
// =============================================================================
|
||||
// Hook input summary types (read-only)
|
||||
// =============================================================================
|
||||
|
||||
/// Information passed to `OnPromptSubmit` hooks.
|
||||
pub struct PromptSubmitInfo {
|
||||
/// Concatenated text content of the user's input message.
|
||||
pub input_text: String,
|
||||
/// 0-based turn index this prompt opens.
|
||||
pub turn_index: usize,
|
||||
}
|
||||
|
||||
/// Information passed to `PreLlmRequest` hooks.
|
||||
pub struct PreRequestInfo {
|
||||
/// Number of items currently in the Worker context.
|
||||
pub item_count: usize,
|
||||
/// Most recently observed `input_tokens` from the LLM provider.
|
||||
/// `None` when the Pod has no compaction state attached, or when
|
||||
/// no LLM call has completed yet.
|
||||
pub estimated_tokens: Option<u64>,
|
||||
/// Current turn index (0-based).
|
||||
pub turn_index: usize,
|
||||
/// Tool calls already executed in this turn.
|
||||
pub tool_calls_this_turn: usize,
|
||||
}
|
||||
|
||||
/// Information passed to `PreToolCall` hooks.
|
||||
pub struct ToolCallSummary {
|
||||
/// Provider-assigned tool call id.
|
||||
pub call_id: String,
|
||||
/// Registered tool name.
|
||||
pub tool_name: String,
|
||||
/// Tool arguments as a JSON value (cloned).
|
||||
///
|
||||
/// LLM-generated arguments are bounded by max_tokens, so cloning
|
||||
/// is cheap relative to tool execution. Structural access is
|
||||
/// required for permission decisions (e.g. inspecting a `path`
|
||||
/// field), which a stringified preview would not support.
|
||||
pub arguments: Value,
|
||||
}
|
||||
|
||||
/// Information passed to `PostToolCall` hooks.
|
||||
pub struct ToolResultSummary {
|
||||
/// Provider-assigned tool call id this result corresponds to.
|
||||
pub call_id: String,
|
||||
/// Registered tool name.
|
||||
pub tool_name: String,
|
||||
/// Whether the tool reported an error.
|
||||
pub is_error: bool,
|
||||
/// Tool output (`summary` always present, `content` may be `None`).
|
||||
pub output: ToolOutput,
|
||||
}
|
||||
|
||||
/// Information passed to `OnTurnEnd` hooks.
|
||||
pub struct TurnEndInfo {
|
||||
/// Turn that just ended (0-based).
|
||||
pub turn_index: usize,
|
||||
/// Tool calls executed in this turn.
|
||||
pub tool_calls_count: usize,
|
||||
/// Preview of the assistant's final text in this turn.
|
||||
/// Truncated at a UTF-8 boundary; empty when no assistant text exists.
|
||||
pub final_text_preview: String,
|
||||
}
|
||||
|
||||
/// Information passed to `OnAbort` hooks.
|
||||
pub struct AbortInfo {
|
||||
/// Reason supplied by the aborter.
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hook Event Kinds
|
||||
|
|
@ -20,17 +97,15 @@ use llm_worker::interceptor::{
|
|||
|
||||
/// Marker trait for hook event kinds.
|
||||
///
|
||||
/// Each event kind specifies its input (passed mutably to hooks) and
|
||||
/// output (the control-flow action returned by hooks).
|
||||
/// Each event kind specifies its read-only input and the control-flow
|
||||
/// action returned by hooks.
|
||||
pub trait HookEventKind: Send + Sync + 'static {
|
||||
/// Mutable input passed to the hook.
|
||||
type Input;
|
||||
/// Read-only input passed to the hook.
|
||||
type Input: Send + Sync;
|
||||
/// Control-flow action returned by the hook.
|
||||
type Output;
|
||||
}
|
||||
|
||||
// --- Event kind markers ---
|
||||
|
||||
/// After receiving user input, before adding to history.
|
||||
pub struct OnPromptSubmit;
|
||||
/// Before each LLM request.
|
||||
|
|
@ -45,32 +120,32 @@ pub struct OnTurnEnd;
|
|||
pub struct OnAbort;
|
||||
|
||||
impl HookEventKind for OnPromptSubmit {
|
||||
type Input = Item;
|
||||
type Input = PromptSubmitInfo;
|
||||
type Output = PromptAction;
|
||||
}
|
||||
|
||||
impl HookEventKind for PreLlmRequest {
|
||||
type Input = Vec<Item>;
|
||||
type Input = PreRequestInfo;
|
||||
type Output = PreRequestAction;
|
||||
}
|
||||
|
||||
impl HookEventKind for PreToolCall {
|
||||
type Input = ToolCallInfo;
|
||||
type Input = ToolCallSummary;
|
||||
type Output = PreToolAction;
|
||||
}
|
||||
|
||||
impl HookEventKind for PostToolCall {
|
||||
type Input = ToolResultInfo;
|
||||
type Input = ToolResultSummary;
|
||||
type Output = PostToolAction;
|
||||
}
|
||||
|
||||
impl HookEventKind for OnTurnEnd {
|
||||
type Input = Vec<Item>;
|
||||
type Input = TurnEndInfo;
|
||||
type Output = TurnEndAction;
|
||||
}
|
||||
|
||||
impl HookEventKind for OnAbort {
|
||||
type Input = String;
|
||||
type Input = AbortInfo;
|
||||
type Output = ();
|
||||
}
|
||||
|
||||
|
|
@ -80,13 +155,13 @@ impl HookEventKind for OnAbort {
|
|||
|
||||
/// Async hook for a specific event kind.
|
||||
///
|
||||
/// Hooks receive mutable access to the event's input and return a
|
||||
/// control-flow action. Multiple hooks can be registered per event;
|
||||
/// they are evaluated in registration order and short-circuit on the
|
||||
/// first non-Continue result.
|
||||
/// Hooks receive a shared reference to the event's read-only input
|
||||
/// and return a control-flow action. Multiple hooks can be registered
|
||||
/// per event; they are evaluated in registration order and
|
||||
/// short-circuit on the first non-Continue (or non-Finish) result.
|
||||
#[async_trait]
|
||||
pub trait Hook<E: HookEventKind>: Send + Sync {
|
||||
async fn call(&self, input: &mut E::Input) -> E::Output;
|
||||
async fn call(&self, input: &E::Input) -> E::Output;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -1,87 +0,0 @@
|
|||
//! HookInterceptor — bridges Pod-layer hooks to Worker's Interceptor trait.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_worker::Item;
|
||||
use llm_worker::interceptor::{
|
||||
Interceptor, PostToolAction, PreRequestAction, PreToolAction, PromptAction, ToolCallInfo,
|
||||
ToolResultInfo, TurnEndAction,
|
||||
};
|
||||
|
||||
use crate::hook::HookRegistry;
|
||||
|
||||
/// An `Interceptor` implementation that delegates to a `HookRegistry`.
|
||||
///
|
||||
/// Each method iterates the registered hooks in order and short-circuits
|
||||
/// on the first non-Continue (or non-Finish) result.
|
||||
pub(crate) struct HookInterceptor {
|
||||
registry: Arc<HookRegistry>,
|
||||
}
|
||||
|
||||
impl HookInterceptor {
|
||||
pub(crate) fn new(registry: Arc<HookRegistry>) -> Self {
|
||||
Self { registry }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for HookInterceptor {
|
||||
async fn on_prompt_submit(&self, item: &mut Item) -> PromptAction {
|
||||
for hook in &self.registry.on_prompt_submit {
|
||||
let action = hook.call(item).await;
|
||||
if !matches!(action, PromptAction::Continue) {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
PromptAction::Continue
|
||||
}
|
||||
|
||||
async fn pre_llm_request(&self, context: &mut Vec<Item>) -> PreRequestAction {
|
||||
for hook in &self.registry.pre_llm_request {
|
||||
let action = hook.call(context).await;
|
||||
if !matches!(action, PreRequestAction::Continue) {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
PreRequestAction::Continue
|
||||
}
|
||||
|
||||
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
|
||||
for hook in &self.registry.pre_tool_call {
|
||||
let action = hook.call(info).await;
|
||||
if !matches!(action, PreToolAction::Continue) {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
PreToolAction::Continue
|
||||
}
|
||||
|
||||
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
|
||||
for hook in &self.registry.post_tool_call {
|
||||
let action = hook.call(info).await;
|
||||
if !matches!(action, PostToolAction::Continue) {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
PostToolAction::Continue
|
||||
}
|
||||
|
||||
async fn on_turn_end(&self, history: &[Item]) -> TurnEndAction {
|
||||
let mut history_vec = history.to_vec();
|
||||
for hook in &self.registry.on_turn_end {
|
||||
let action = hook.call(&mut history_vec).await;
|
||||
if !matches!(action, TurnEndAction::Finish) {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
TurnEndAction::Finish
|
||||
}
|
||||
|
||||
async fn on_abort(&self, reason: &str) {
|
||||
let mut reason_string = reason.to_string();
|
||||
for hook in &self.registry.on_abort {
|
||||
hook.call(&mut reason_string).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,11 +6,10 @@ pub mod shared_state;
|
|||
pub mod socket_server;
|
||||
|
||||
mod agents_md;
|
||||
mod compact_interceptor;
|
||||
mod compact_state;
|
||||
mod factory;
|
||||
mod hook_interceptor;
|
||||
mod pod;
|
||||
mod pod_interceptor;
|
||||
mod prompt_loader;
|
||||
mod prune;
|
||||
mod system_prompt;
|
||||
|
|
|
|||
|
|
@ -14,14 +14,13 @@ use tracing::{info, warn};
|
|||
use manifest::{PodManifest, PodManifestConfig, ResolveError, Scope, ScopeError, WorkerManifest};
|
||||
|
||||
use crate::agents_md::read_agents_md;
|
||||
use crate::compact_interceptor::CompactInterceptor;
|
||||
use crate::compact_state::CompactState;
|
||||
use crate::hook::{
|
||||
Hook, HookRegistryBuilder, OnAbort, OnPromptSubmit, OnTurnEnd, PostToolCall, PreLlmRequest,
|
||||
PreToolCall,
|
||||
PreRequestInfo, PreToolCall,
|
||||
};
|
||||
use crate::hook_interceptor::HookInterceptor;
|
||||
use crate::notifier::Notifier;
|
||||
use crate::pod_interceptor::PodInterceptor;
|
||||
use crate::prompt_loader::PromptLoader;
|
||||
use crate::system_prompt::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
||||
use crate::usage_tracker::UsageTracker;
|
||||
|
|
@ -38,8 +37,8 @@ struct UsageTrackingHook {
|
|||
|
||||
#[async_trait]
|
||||
impl Hook<PreLlmRequest> for UsageTrackingHook {
|
||||
async fn call(&self, context: &mut Vec<Item>) -> PreRequestAction {
|
||||
self.tracker.note_request(context.len());
|
||||
async fn call(&self, info: &PreRequestInfo) -> PreRequestAction {
|
||||
self.tracker.note_request(info.item_count);
|
||||
PreRequestAction::Continue
|
||||
}
|
||||
}
|
||||
|
|
@ -346,16 +345,15 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
/// `on_usage` callback to track `input_tokens`.
|
||||
fn ensure_interceptor_installed(&mut self) {
|
||||
if !self.interceptor_installed {
|
||||
// Pre-LLM-request hook: capture history.len() into the
|
||||
// UsageTracker so the upcoming on_usage callback can pair
|
||||
// it with the measured input_tokens.
|
||||
// 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());
|
||||
let hook_interceptor = HookInterceptor::new(registry);
|
||||
|
||||
let compact_threshold = self
|
||||
.manifest
|
||||
|
|
@ -363,12 +361,9 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
.as_ref()
|
||||
.and_then(|c| c.compact_threshold);
|
||||
|
||||
// Usage tracking via on_usage callback. Independent of
|
||||
// compact_threshold so that LlmUsage entries are persisted
|
||||
// unconditionally.
|
||||
let tracker_for_usage = self.usage_tracker.clone();
|
||||
|
||||
if let Some(threshold) = compact_threshold {
|
||||
let compact_state = if let Some(threshold) = compact_threshold {
|
||||
let retained = self
|
||||
.manifest
|
||||
.compaction
|
||||
|
|
@ -377,9 +372,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
.unwrap_or(2);
|
||||
|
||||
let state = Arc::new(CompactState::new(threshold, retained));
|
||||
|
||||
// Combined on_usage: feed both the legacy compact threshold
|
||||
// tracker and the new UsageTracker.
|
||||
let state_for_usage = state.clone();
|
||||
self.worker_mut().on_usage(move |event| {
|
||||
if let Some(tokens) = event.input_tokens {
|
||||
|
|
@ -387,17 +379,17 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
}
|
||||
tracker_for_usage.record_usage(event);
|
||||
});
|
||||
|
||||
let interceptor = CompactInterceptor::new(hook_interceptor, state.clone());
|
||||
self.worker_mut().set_interceptor(interceptor);
|
||||
self.compact_state = Some(state);
|
||||
self.compact_state = Some(state.clone());
|
||||
Some(state)
|
||||
} else {
|
||||
self.worker_mut().on_usage(move |event| {
|
||||
tracker_for_usage.record_usage(event);
|
||||
});
|
||||
self.worker_mut().set_interceptor(hook_interceptor);
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
let interceptor = PodInterceptor::new(registry, compact_state);
|
||||
self.worker_mut().set_interceptor(interceptor);
|
||||
self.interceptor_installed = true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
294
crates/pod/src/pod_interceptor.rs
Normal file
294
crates/pod/src/pod_interceptor.rs
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
//! Pod-owned `Interceptor` implementation.
|
||||
//!
|
||||
//! Bridges Pod's internal mechanisms (compaction trigger today;
|
||||
//! 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
|
||||
//! decisions (continue / skip / abort / pause).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_worker::Item;
|
||||
use llm_worker::interceptor::{
|
||||
Interceptor, PostToolAction, PreRequestAction, PreToolAction, PromptAction, ToolCallInfo,
|
||||
ToolResultInfo, TurnEndAction,
|
||||
};
|
||||
use llm_worker::tool::ToolOutput;
|
||||
use tracing::info;
|
||||
|
||||
use crate::compact_state::CompactState;
|
||||
use crate::hook::{
|
||||
AbortInfo, HookRegistry, PreRequestInfo, PromptSubmitInfo, ToolCallSummary, ToolResultSummary,
|
||||
TurnEndInfo,
|
||||
};
|
||||
|
||||
/// Maximum number of bytes copied into `TurnEndInfo::final_text_preview`.
|
||||
const FINAL_TEXT_PREVIEW_LIMIT: usize = 512;
|
||||
|
||||
pub(crate) struct PodInterceptor {
|
||||
registry: Arc<HookRegistry>,
|
||||
compact_state: Option<Arc<CompactState>>,
|
||||
/// Next turn index assigned by `on_prompt_submit`.
|
||||
next_turn_index: AtomicUsize,
|
||||
/// Tool calls observed in the current turn (reset on each new prompt).
|
||||
tool_calls_this_turn: AtomicUsize,
|
||||
}
|
||||
|
||||
impl PodInterceptor {
|
||||
pub(crate) fn new(
|
||||
registry: Arc<HookRegistry>,
|
||||
compact_state: Option<Arc<CompactState>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
registry,
|
||||
compact_state,
|
||||
next_turn_index: AtomicUsize::new(0),
|
||||
tool_calls_this_turn: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_turn_index(&self) -> usize {
|
||||
self.next_turn_index
|
||||
.load(Ordering::Relaxed)
|
||||
.saturating_sub(1)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for PodInterceptor {
|
||||
async fn on_prompt_submit(&self, item: &mut Item) -> PromptAction {
|
||||
let turn_index = self.next_turn_index.fetch_add(1, Ordering::Relaxed);
|
||||
self.tool_calls_this_turn.store(0, Ordering::Relaxed);
|
||||
|
||||
let info = PromptSubmitInfo {
|
||||
input_text: extract_message_text(item).unwrap_or_default(),
|
||||
turn_index,
|
||||
};
|
||||
for hook in &self.registry.on_prompt_submit {
|
||||
let action = hook.call(&info).await;
|
||||
if !matches!(action, PromptAction::Continue) {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
PromptAction::Continue
|
||||
}
|
||||
|
||||
async fn pre_llm_request(&self, context: &mut Vec<Item>) -> PreRequestAction {
|
||||
// Internal mechanism: between-turns compaction trigger.
|
||||
if let Some(state) = self.compact_state.as_ref() {
|
||||
if !state.is_disabled() && state.exceeds_turn() {
|
||||
info!(
|
||||
input_tokens = state.last_input_tokens(),
|
||||
threshold = state.turn_threshold(),
|
||||
"Between-turns compaction threshold exceeded, yielding"
|
||||
);
|
||||
return PreRequestAction::Yield;
|
||||
}
|
||||
}
|
||||
|
||||
let info = PreRequestInfo {
|
||||
item_count: context.len(),
|
||||
estimated_tokens: self.compact_state.as_ref().map(|s| s.last_input_tokens()),
|
||||
turn_index: self.current_turn_index(),
|
||||
tool_calls_this_turn: self.tool_calls_this_turn.load(Ordering::Relaxed),
|
||||
};
|
||||
for hook in &self.registry.pre_llm_request {
|
||||
let action = hook.call(&info).await;
|
||||
if !matches!(action, PreRequestAction::Continue) {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
PreRequestAction::Continue
|
||||
}
|
||||
|
||||
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
|
||||
let summary = ToolCallSummary {
|
||||
call_id: info.call.id.clone(),
|
||||
tool_name: info.call.name.clone(),
|
||||
arguments: info.call.input.clone(),
|
||||
};
|
||||
for hook in &self.registry.pre_tool_call {
|
||||
let action = hook.call(&summary).await;
|
||||
if !matches!(action, PreToolAction::Continue) {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
self.tool_calls_this_turn.fetch_add(1, Ordering::Relaxed);
|
||||
PreToolAction::Continue
|
||||
}
|
||||
|
||||
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
|
||||
let summary = ToolResultSummary {
|
||||
call_id: info.result.tool_use_id.clone(),
|
||||
tool_name: info.call.name.clone(),
|
||||
is_error: info.result.is_error,
|
||||
output: ToolOutput {
|
||||
summary: info.result.summary.clone(),
|
||||
content: info.result.content.clone(),
|
||||
},
|
||||
};
|
||||
for hook in &self.registry.post_tool_call {
|
||||
let action = hook.call(&summary).await;
|
||||
if !matches!(action, PostToolAction::Continue) {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
PostToolAction::Continue
|
||||
}
|
||||
|
||||
async fn on_turn_end(&self, history: &[Item]) -> TurnEndAction {
|
||||
let final_text_preview = history
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|i| i.is_assistant_message())
|
||||
.and_then(extract_message_text)
|
||||
.map(|t| preview(&t, FINAL_TEXT_PREVIEW_LIMIT))
|
||||
.unwrap_or_default();
|
||||
let info = TurnEndInfo {
|
||||
turn_index: self.current_turn_index(),
|
||||
tool_calls_count: self.tool_calls_this_turn.load(Ordering::Relaxed),
|
||||
final_text_preview,
|
||||
};
|
||||
for hook in &self.registry.on_turn_end {
|
||||
let action = hook.call(&info).await;
|
||||
if !matches!(action, TurnEndAction::Finish) {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
TurnEndAction::Finish
|
||||
}
|
||||
|
||||
async fn on_abort(&self, reason: &str) {
|
||||
let info = AbortInfo {
|
||||
reason: reason.to_string(),
|
||||
};
|
||||
for hook in &self.registry.on_abort {
|
||||
hook.call(&info).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_message_text(item: &Item) -> Option<String> {
|
||||
match item {
|
||||
Item::Message { content, .. } => Some(
|
||||
content
|
||||
.iter()
|
||||
.map(|p| p.as_text())
|
||||
.collect::<Vec<_>>()
|
||||
.join(""),
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn preview(text: &str, limit: usize) -> String {
|
||||
if text.len() <= limit {
|
||||
return text.to_string();
|
||||
}
|
||||
let mut end = limit;
|
||||
while end > 0 && !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
text[..end].to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize};
|
||||
|
||||
use super::*;
|
||||
use crate::hook::{Hook, HookRegistryBuilder, PreLlmRequest};
|
||||
|
||||
struct CountingHook(Arc<AtomicUsize>);
|
||||
|
||||
#[async_trait]
|
||||
impl Hook<PreLlmRequest> for CountingHook {
|
||||
async fn call(&self, _info: &PreRequestInfo) -> PreRequestAction {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
PreRequestAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
fn registry_with_pre_llm_hook(counter: Arc<AtomicUsize>) -> Arc<HookRegistry> {
|
||||
let mut builder = HookRegistryBuilder::new();
|
||||
builder.add_pre_llm_request(CountingHook(counter));
|
||||
Arc::new(builder.build())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_llm_request_yields_and_skips_hooks_when_compact_threshold_exceeded() {
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let registry = registry_with_pre_llm_hook(count.clone());
|
||||
|
||||
let state = Arc::new(CompactState::new(100, 2));
|
||||
state.update_input_tokens(200); // exceeds turn threshold
|
||||
|
||||
let interceptor = PodInterceptor::new(registry, Some(state));
|
||||
let mut ctx: Vec<Item> = vec![Item::user_message("hi")];
|
||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||
|
||||
assert!(matches!(action, PreRequestAction::Yield));
|
||||
// Hook must not run when an internal mechanism short-circuits first.
|
||||
assert_eq!(count.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_llm_request_runs_hooks_when_under_threshold() {
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let registry = registry_with_pre_llm_hook(count.clone());
|
||||
|
||||
let state = Arc::new(CompactState::new(100, 2));
|
||||
// last_input_tokens stays at 0, well below threshold.
|
||||
|
||||
let interceptor = PodInterceptor::new(registry, Some(state));
|
||||
let mut ctx: Vec<Item> = vec![Item::user_message("hi")];
|
||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||
|
||||
assert!(matches!(action, PreRequestAction::Continue));
|
||||
assert_eq!(count.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_llm_request_runs_hooks_when_no_compact_state() {
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let registry = registry_with_pre_llm_hook(count.clone());
|
||||
|
||||
let interceptor = PodInterceptor::new(registry, None);
|
||||
let mut ctx: Vec<Item> = Vec::new();
|
||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||
|
||||
assert!(matches!(action, PreRequestAction::Continue));
|
||||
assert_eq!(count.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
struct AbortingHook(Arc<AtomicBool>);
|
||||
|
||||
#[async_trait]
|
||||
impl Hook<PreLlmRequest> for AbortingHook {
|
||||
async fn call(&self, _info: &PreRequestInfo) -> PreRequestAction {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
PreRequestAction::Cancel("nope".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_llm_request_short_circuits_on_first_non_continue() {
|
||||
let first_called = Arc::new(AtomicBool::new(false));
|
||||
let second_count = Arc::new(AtomicUsize::new(0));
|
||||
let mut builder = HookRegistryBuilder::new();
|
||||
builder.add_pre_llm_request(AbortingHook(first_called.clone()));
|
||||
builder.add_pre_llm_request(CountingHook(second_count.clone()));
|
||||
let registry = Arc::new(builder.build());
|
||||
|
||||
let interceptor = PodInterceptor::new(registry, None);
|
||||
let mut ctx: Vec<Item> = Vec::new();
|
||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||
|
||||
assert!(matches!(action, PreRequestAction::Cancel(_)));
|
||||
assert!(first_called.load(Ordering::Relaxed));
|
||||
assert_eq!(second_count.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
{% include "common/workspace" %}
|
||||
You are here as an agent of the "insomnia system".
|
||||
|
||||
Stay precise, edit code directly when asked, and avoid speculative refactoring. Explain what you changed in one short paragraph at the end of each turn.
|
||||
|
||||
{% include "common/workspace" %}
|
||||
|
||||
{% include "common/tool-usage" %}
|
||||
|
|
|
|||
154
tickets/hook-interceptor-separation.md
Normal file
154
tickets/hook-interceptor-separation.md
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# Hook と Interceptor の責務分離
|
||||
|
||||
## レビュー状態
|
||||
|
||||
初回レビュー実施済み。[hook-interceptor-separation.review.md](hook-interceptor-separation.review.md) を参照。
|
||||
要件全項目達成。**無条件で受け入れ可**。
|
||||
|
||||
## 背景
|
||||
|
||||
Worker の実行ループへの介入手段として `Interceptor` trait (llm-worker crate) と `Hook` trait (pod crate) が存在するが、現状では Hook が Interceptor と同じ権限を持っており、責務の分離が成立していない。
|
||||
|
||||
### 現状の構造
|
||||
|
||||
```
|
||||
Worker
|
||||
└── Interceptor trait (llm-worker)
|
||||
↑ implements
|
||||
HookInterceptor (pod)
|
||||
└── HookRegistry
|
||||
└── Vec<Box<dyn Hook<E>>>
|
||||
```
|
||||
|
||||
- `Interceptor` は `&mut Vec<Item>` (context)、`&mut ToolResultInfo` (tool result) 等を直接受け取る。context の書き換え、history への干渉、tool result の改変が自由にできる
|
||||
- `HookInterceptor` は `Interceptor` を実装し、各メソッドで Hook を順に呼ぶブリッジ
|
||||
- `Hook<E>` の `call(&self, input: &mut E::Input)` が受け取る `E::Input` は `Vec<Item>` や `ToolCallInfo` **そのもの**
|
||||
- **結果として Hook からも Interceptor と同じ全権限で context を操作できてしまっている**
|
||||
|
||||
### 問題
|
||||
|
||||
- Hook を将来スクリプト言語(Rhai, Wasm 等)に公開したとき、ユーザースクリプトが context を自由に書き換えられてしまう
|
||||
- 内部機構(compaction トリガー、notification 注入、tool output truncation)と外部公開 hook が同じ権限レベルで動いており、何が安全に公開できるかが型で表現されていない
|
||||
- Interceptor のドキュメントには「upper layers (e.g. Pod) implement」とあるが、Pod が直接実装するのではなく Hook 経由で間接実装しているため、設計意図と実態がずれている
|
||||
|
||||
## ゴール
|
||||
|
||||
Interceptor と Hook の責務を明確に分離し、型レベルで権限の境界を表現する。
|
||||
|
||||
## 方針
|
||||
|
||||
### Interceptor(内部実装用)
|
||||
|
||||
- **llm-worker crate に留まる**。Pod / Worker の内部機構が直接実装する
|
||||
- context / history の**直接操作が可能**(`&mut Vec<Item>` 等を受け取る)
|
||||
- 外部に公開しない。`pub(crate)` または Pod crate 内でのみ利用
|
||||
- 用途:
|
||||
- compaction トリガー(`PreLlmRequest` で context サイズを見て `Yield` を返す)
|
||||
- notification 注入(`PreLlmRequest` で pending notifications を context に flush)
|
||||
- tool output truncation(`PostToolCall` で content を書き換え)
|
||||
- 将来の内部機構
|
||||
|
||||
### Hook(公開 API 用)
|
||||
|
||||
- **Pod crate で定義**。将来スクリプト言語やプラグインに公開する前提
|
||||
- イベントの**観測**と**制御フロー判断**(continue / skip / abort / pause)のみ
|
||||
- context の直接操作は**できない**。受け取るのは:
|
||||
- **読み取り専用の情報**(ツール名、引数の概要、ターン番号、etc.)
|
||||
- **制御フロー判断の返却値**(continue / skip / abort)
|
||||
- 安全に sandbox 可能
|
||||
|
||||
### 実行順序
|
||||
|
||||
同じ決定点(例: pre_tool_call)で Interceptor と Hook の両方が登録されている場合:
|
||||
|
||||
```
|
||||
[Interceptor: 内部機構の処理(context 操作等)]
|
||||
↓
|
||||
[Hook: 外部の判断(observe + continue/skip/abort)]
|
||||
↓
|
||||
Worker が次のステップに進む
|
||||
```
|
||||
|
||||
Interceptor が先に走って context を整え、Hook はその結果を観測する。Hook の判断が Interceptor の操作を覆すことはない(abort 等で中断は可能)。
|
||||
|
||||
## 必要な変更
|
||||
|
||||
### Hook の Input 型を制限する
|
||||
|
||||
現状の `Hook<PreLlmRequest>` の Input は `Vec<Item>` だが、これを read-only のサマリ型に変更:
|
||||
|
||||
```rust
|
||||
// Before: Hook が context を直接操作できる
|
||||
impl HookEventKind for PreLlmRequest {
|
||||
type Input = Vec<Item>; // &mut Vec<Item> が渡される
|
||||
type Output = PreRequestAction;
|
||||
}
|
||||
|
||||
// After: Hook は read-only の情報だけ受け取る
|
||||
impl HookEventKind for PreLlmRequest {
|
||||
type Input = PreRequestInfo; // context のサマリ(item 数、token 推定等)
|
||||
type Output = PreRequestAction;
|
||||
}
|
||||
```
|
||||
|
||||
同様に各イベントの Input を「観測に必要な最小限の read-only 情報」に絞る。
|
||||
|
||||
### `ToolCallInfo` / `ToolResultInfo` の分離
|
||||
|
||||
現状は Interceptor と Hook で同じ `ToolCallInfo` を共有している。分離:
|
||||
|
||||
- **Interceptor 用**: `ToolCallInfo { call: &mut ToolCall, meta: ToolMeta, tool: Arc<dyn Tool> }` — call の書き換え可能
|
||||
- **Hook 用**: `ToolCallSummary { name: String, arguments_preview: String }` — 読み取りのみ、tool instance へのアクセスなし
|
||||
|
||||
### HookInterceptor の解体
|
||||
|
||||
現在の `HookInterceptor`(Hook を Interceptor にブリッジする層)は不要になる。代わりに:
|
||||
|
||||
- Worker が Interceptor を呼ぶ(内部機構の処理)
|
||||
- Worker が Hook を呼ぶ(外部判断の問い合わせ)
|
||||
- これらは Worker の実行ループ内で**別々のステップ**として呼ばれる
|
||||
|
||||
Worker が Hook を直接知るか、Pod 層が Worker に Hook callback を注入するかは設計時に判断。
|
||||
|
||||
### llm-worker 側の変更
|
||||
|
||||
- `Interceptor` trait はそのまま(内部実装用として健全)
|
||||
- Worker の実行ループに「Hook 呼び出しポイント」を追加(Interceptor とは別系統)
|
||||
- Hook の呼び出しインターフェースは `Fn` ベースのコールバックか、新しい trait か
|
||||
|
||||
### pod 側の変更
|
||||
|
||||
- `Hook<E>` の `E::Input` を read-only サマリ型に置き換え
|
||||
- `HookInterceptor` を削除
|
||||
- 内部機構(compaction 等)は Interceptor を直接実装する形に移行
|
||||
- HookRegistry は外部公開 API として残り、将来のスクリプト公開の基盤になる
|
||||
|
||||
## 設計で決めること
|
||||
|
||||
- **Hook の Input 型の具体設計**: 各イベントで何を read-only として渡すか(ツール名? 引数の先頭 N 文字? token 数?)
|
||||
- **Worker が Hook をどう呼ぶか**: Interceptor と同じ trait 方式か、コールバック登録方式か、別の trait か
|
||||
- **Interceptor の複数登録**: 現状 Worker は1つの Interceptor しか持てない。内部機構が複数(compaction + notification + truncation)になると、Chain of Responsibility 的に複数の Interceptor を合成する仕組みが要る
|
||||
- **Hook の実行順序保証**: 複数の Hook が登録されている場合の評価順序と short-circuit 規則(現行と同じ「登録順 + 最初の non-Continue で打ち切り」を維持するか)
|
||||
- **既存の Pod 層 hook ユーザーの移行**: compact_interceptor 等が現在 HookInterceptor 経由で動いている場合の移行パス
|
||||
|
||||
## 完了条件
|
||||
|
||||
- Interceptor は context / history の直接操作が可能な内部 API として存続し、compaction / notification 注入 / tool output truncation 等が Interceptor を直接利用する
|
||||
- Hook は read-only のサマリ情報のみを受け取り、制御フロー判断(continue / skip / abort / pause)を返す公開 API になる
|
||||
- Hook から `&mut Vec<Item>` や `&mut ToolCallInfo` 等の context 直接操作パスが**型レベルで存在しない**
|
||||
- HookInterceptor ブリッジは削除される
|
||||
- Worker の実行ループ内で Interceptor → Hook の順序で呼ばれ、それぞれの責務が分離されている
|
||||
- 既存の Pod 層 hook(compaction トリガー等)が Interceptor 直接実装に移行し、動作が壊れていない
|
||||
- 単体テストで Hook が context を操作できないことが検証される
|
||||
|
||||
## 他チケットとの関係
|
||||
|
||||
- **tickets/method-notify.md**: notification の context 注入は Interceptor の責務。Hook ではない
|
||||
- **tickets/pod-orchestration.md**: spawned Pod のツール実行制御(permission)は Hook として公開しうる
|
||||
- **tickets/permission-extension-point.md**: パーミッション制御は Hook の代表的ユースケース。ツール実行の approve/deny を外部から制御する
|
||||
- **tickets/bash-tool.md**: Bash ツールの Permission 層は Hook (pre_tool_call) で実装される想定
|
||||
|
||||
## 範囲外
|
||||
|
||||
- **スクリプト言語バインディングの実装**: Hook を Rhai / Wasm に公開する仕組み自体は別チケット。本チケットは公開可能な型境界を作るところまで
|
||||
- **Hook の動的ロード / アンロード**: 起動時に登録して freeze する現行モデルを維持。動的な Hook 管理は別チケット
|
||||
72
tickets/hook-interceptor-separation.review.md
Normal file
72
tickets/hook-interceptor-separation.review.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# レビュー: Hook と Interceptor の責務分離
|
||||
|
||||
対象差分: `crates/pod/src/{hook,pod,lib,pod_interceptor}.rs`、削除: `compact_interceptor.rs` / `hook_interceptor.rs`(staged、未コミット)
|
||||
|
||||
## 要件達成状況
|
||||
|
||||
| 要件 | 状態 |
|
||||
|---|---|
|
||||
| Hook の Input が read-only サマリ型になる | ✅ `PromptSubmitInfo` / `PreRequestInfo` / `ToolCallSummary` / `ToolResultSummary` / `TurnEndInfo` / `AbortInfo` を新設。`Hook::call(&self, input: &E::Input)` で `&mut` が消えた |
|
||||
| Hook から context / history の直接操作パスが型レベルで存在しない | ✅ `Vec<Item>` / `ToolCallInfo` / `ToolResultInfo` が Hook の Input から消え、サマリ型に置換 |
|
||||
| Interceptor は context / history の直接操作が可能なまま | ✅ `PodInterceptor` が `Interceptor` を実装し `&mut Vec<Item>` / `&mut ToolCallInfo` 等を受け取る |
|
||||
| Interceptor → Hook の実行順序(内部が先、公開が後) | ✅ `PodInterceptor` の各メソッドが: 内部ロジック(compaction check 等)→ サマリ構築 → Hook 呼び出し の順 |
|
||||
| HookInterceptor ブリッジの削除 | ✅ `hook_interceptor.rs` deleted |
|
||||
| CompactInterceptor の統合 | ✅ `compact_interceptor.rs` deleted。compaction check は `PodInterceptor::pre_llm_request` に統合 |
|
||||
| 既存の compaction 動作が壊れない | ✅ `compact_state` を `PodInterceptor` に渡し、`exceeds_turn()` で Yield する同じロジック |
|
||||
| 単体テストで Hook が context を操作できないことが検証される | ✅ テストの Hook が `&PreRequestInfo`(read-only)を受け取る形で書かれている。`&mut` パスは型レベルで不可能 |
|
||||
|
||||
## アーキテクチャ
|
||||
|
||||
### 良い点
|
||||
|
||||
**`PodInterceptor` が single composite interceptor として全責務を統合**:
|
||||
- compaction check (内部) → サマリ構築 → Hook 呼び出しを1つの `pre_llm_request` 内で制御
|
||||
- Worker 側は単一の `set_interceptor` で完結(`Vec<Box<dyn Interceptor>>` 不要)
|
||||
- `compact_interceptor.rs` の decorator パターン(inner HookInterceptor をラップ)が消え、フラットな構造に
|
||||
|
||||
**Hook の `call` シグネチャが `&self, input: &E::Input` に**:
|
||||
- `&mut E::Input` → `&E::Input` への変更で、Hook 実装者が context を書き換える経路が型レベルで消えた
|
||||
- 将来スクリプトに公開するとき、`&T` だけ渡せば良い(sandbox の粒度が明確)
|
||||
|
||||
**サマリ型の設計**:
|
||||
- `ToolCallSummary::arguments` が `serde_json::Value` clone — 構造的アクセスが可能で permission 判断に使える
|
||||
- `ToolResultSummary::output` が `ToolOutput` clone — summary + content 両方にアクセス可能
|
||||
- `PreRequestInfo` が item_count / estimated_tokens / turn_index / tool_calls_this_turn を集約 — compaction 判断に必要十分
|
||||
- `TurnEndInfo::final_text_preview` が 512 byte 制限 + UTF-8 boundary — Pod orchestration で spawned Pod の結果要約に使える
|
||||
|
||||
**ターン追跡の統合**:
|
||||
- `next_turn_index` / `tool_calls_this_turn` が `PodInterceptor` 内の `AtomicUsize` で管理
|
||||
- `on_prompt_submit` でリセット、`pre_tool_call` でインクリメント
|
||||
- Hook が受け取るサマリにこの情報が反映される
|
||||
|
||||
### テスト
|
||||
|
||||
4 ケース:
|
||||
- `pre_llm_request_yields_and_skips_hooks_when_compact_threshold_exceeded` — 内部機構(compaction)が Hook より先に short-circuit することを検証
|
||||
- `pre_llm_request_runs_hooks_when_under_threshold` — 通常時は Hook が走ることを検証
|
||||
- `pre_llm_request_runs_hooks_when_no_compact_state` — compaction 無効時も Hook が走ることを検証
|
||||
- `pre_llm_request_short_circuits_on_first_non_continue` — 複数 Hook の短絡評価を検証
|
||||
|
||||
特に 1 つ目が**設計の核心(Interceptor が先、Hook が後。内部が short-circuit したら Hook は走らない)**を lock-in している。
|
||||
|
||||
## 指摘事項
|
||||
|
||||
### 1. 🟢 `pod.rs` の `ensure_interceptor_installed` が少しすっきりした
|
||||
|
||||
旧: `CompactInterceptor::new(hook_interceptor, state)` で decorator をネスト → 新: `PodInterceptor::new(registry, compact_state)` でフラットに構築。`compact_state` の有無で分岐していた `set_interceptor` 呼び出しが1箇所に統合。
|
||||
|
||||
### 2. 🟢 `HookEventKind::Input` に `Send + Sync` バウンドが追加
|
||||
|
||||
```rust
|
||||
type Input: Send + Sync;
|
||||
```
|
||||
|
||||
`&E::Input` を async メソッドで受け取るために必要。正しい追加。
|
||||
|
||||
### 3. 🟢 `extract_message_text` / `preview` がユーティリティとして分離
|
||||
|
||||
`PodInterceptor` 内の private function として切り出し。`preview` は UTF-8 boundary を考慮した切断で、`truncate_content`(tool output truncation)と同じパターン。
|
||||
|
||||
## 結論
|
||||
|
||||
**無条件で受け入れ可**。チケットの要件を完全に達成。特に「型レベルで Hook から context 操作が不可能」という核心的な保証が `&E::Input` (not `&mut`) + サマリ型で実現されている。テストも設計意図を正確にカバー。
|
||||
116
tickets/method-notify.md
Normal file
116
tickets/method-notify.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# Method::Notify: システム起点のコンテキスト注入と自動ターン開始
|
||||
|
||||
## 背景
|
||||
|
||||
現状の Pod の実行サイクルは `Method::Run { input }` が唯一のターン開始手段であり、これは人間(または外部クライアント)が明示的にテキストを送ることを前提としている。
|
||||
|
||||
Pod オーケストレーション(`tickets/pod-orchestration.md`)では、子 Pod が親 Pod にコールバックで通知を送り、親 Pod が**人間の入力を待たずに**その通知を処理して次のアクションを起こす必要がある。現状のアーキテクチャにはこの経路が無い。
|
||||
|
||||
また、RUNNING 中の Pod に対しても、リクエストの合間に情報を注入して LLM に認知させたいケースがある(子 Pod からの非同期通知など)。現状は RUNNING 中にコンテキストを追加する手段が無い。
|
||||
|
||||
## ゴール
|
||||
|
||||
`Method::Notify` を新設し、Pod の状態(IDLE / RUNNING)に応じて適切にコンテキストへの注入とターン制御を行う。
|
||||
|
||||
## 仕様
|
||||
|
||||
### `Method::Notify { source: String, message: String }`
|
||||
|
||||
protocol に追加する新しい Method。`Run` とは異なり、ユーザーメッセージではなく**システム通知**としてコンテキストに注入される。
|
||||
|
||||
### Pod が IDLE のとき
|
||||
|
||||
1. 通知メッセージを system message としてコンテキストに注入
|
||||
2. **自動でターンを開始**する(人間の入力を待たない)
|
||||
3. LLM は通知を見て次のアクションを判断する
|
||||
|
||||
### Pod が RUNNING のとき
|
||||
|
||||
1. 通知メッセージを**次の LLM リクエストの直前**に注入する(tool call の応答を送った後、次のリクエストを組み立てる前)
|
||||
2. ターンは中断しない。LLM は現在のタスクを続行しつつ、次のレスポンスで通知を認知する
|
||||
3. LLM は今やっていることを優先し、切りの良いタイミングで通知に対処するかを自分で判断する
|
||||
|
||||
### 注入されるメッセージのフォーマット
|
||||
|
||||
通知は素のテキストではなく、以下の構造で注入される:
|
||||
|
||||
```
|
||||
[Notification from {source}]
|
||||
{message}
|
||||
|
||||
This is a notification, not a blocking request. If you are in the middle of a task, continue your current work and address this at a natural stopping point.
|
||||
```
|
||||
|
||||
- `[Notification]` prefix で LLM にこれが通知であることを明示
|
||||
- 「ブロッカーではないので直ちに対処しなくてよい」という指示を付与
|
||||
- LLM が通知を見て即座にタスクを放棄する(指示追従性の暴走)を防ぐ
|
||||
|
||||
### 複数通知のバッファリング
|
||||
|
||||
- RUNNING 中に複数の `Notify` が到着した場合、バッファに溜めて次の LLM リクエスト直前にまとめて注入する
|
||||
- 個別の `[Notification]` ブロックとして並べる(1つにマージしない)
|
||||
- IDLE 中に複数到着した場合、1つの system message にまとめて注入し、ターンを1回だけ開始する
|
||||
|
||||
## 実装に必要な変更
|
||||
|
||||
### protocol crate
|
||||
|
||||
- `Method::Notify { source: String, message: String }` を `Method` enum に追加
|
||||
- 対応する `Event` の追加が必要かは設計時に判断
|
||||
|
||||
### Worker
|
||||
|
||||
- RUNNING 中に外部からメッセージを注入する仕組みが必要
|
||||
- 現状の Worker は turn 実行中にコンテキストの追加手段を持たない
|
||||
- tool call → tool result → **notification 注入** → 次の LLM リクエスト、というフローを追加
|
||||
- 注入ポイントは `execute_tools` 完了後、次の LLM リクエスト組み立て前
|
||||
|
||||
### Controller
|
||||
|
||||
- `Method::Notify` のハンドリングを追加
|
||||
- IDLE 時: 通知を注入 → 内部的に `run()` を開始(`Method::Run` と似た経路だがメッセージ種別が異なる)
|
||||
- RUNNING 時: Worker の notification buffer に push
|
||||
|
||||
### Pod
|
||||
|
||||
- notification buffer を保持するフィールドを追加
|
||||
- `ensure_system_prompt_materialized` 的な「ターン開始前に notification を flush する」フックが要るかもしれない
|
||||
|
||||
## `Method::Run` との対比
|
||||
|
||||
| | `Method::Run` | `Method::Notify` |
|
||||
|---|---|---|
|
||||
| 対象状態 | IDLE のみ(RUNNING 中は AlreadyRunning エラー) | IDLE でも RUNNING でも受け付ける |
|
||||
| コンテキスト上の見え方 | user message | system message(`[Notification]` prefix 付き) |
|
||||
| ターン制御 | 新ターンを開始 | IDLE: 自動でターン開始。RUNNING: 現ターンに注入 |
|
||||
| LLM の期待挙動 | 指示に従って即座に行動 | 現タスク優先、切りの良いタイミングで対処を判断 |
|
||||
| 送信元 | 人間 / クライアント | システム / 子 Pod のコールバック / Hook |
|
||||
|
||||
## 設計で決めること
|
||||
|
||||
- **notification の注入はどの message type で行うか**: 既存の `Role::User` / `Role::Assistant` とは別の `Role::System` (mid-conversation) を追加するか、`Role::User` に `[Notification]` prefix を付けて実質的に区別するか
|
||||
- **IDLE 時の自動ターン開始と人間の `Run` の競合**: 通知が到着して自動ターンが始まった直後に人間が `Run` を送った場合の挙動
|
||||
- **notification buffer のサイズ上限**: RUNNING が長時間の場合にバッファが無制限に溜まるリスク
|
||||
- **通知メッセージの prefix / suffix テンプレートの置き場**: ハードコードか、instruction 側でカスタマイズ可能にするか
|
||||
- **Event の対応**: `Event::NotificationInjected` のような確認イベントを返すか
|
||||
|
||||
## 完了条件
|
||||
|
||||
- `Method::Notify` が protocol に追加され、Controller が IDLE / RUNNING で適切にハンドリングする
|
||||
- IDLE 時: 通知が system message として注入され、自動でターンが開始される
|
||||
- RUNNING 時: 通知が次の LLM リクエスト直前に注入され、LLM が認知できる
|
||||
- 複数通知のバッファリングが動作する(RUNNING 中に溜まった通知がまとめて注入される)
|
||||
- `[Notification]` prefix と non-blocking 指示が付与される
|
||||
- 単体テストで IDLE / RUNNING 両パスが検証される
|
||||
|
||||
## 他チケットとの関係
|
||||
|
||||
- **tickets/pod-orchestration.md**: 本チケットの主要消費者。子 Pod の `Notify` ツール → 親の callback → 親の `Method::Notify` というフローでオーケストレーションの非同期通知が成立する
|
||||
- **tickets/protocol-design.md**: protocol への Method 追加。既存の Method 設計パターン(Run / Resume / Cancel / Shutdown)と整合させる
|
||||
- **tickets/compact-improvements.md**: notification がコンテキストに蓄積した場合の compaction 挙動は別途検討
|
||||
|
||||
## 範囲外
|
||||
|
||||
- **通知の routing / addressing**: 誰が誰に通知を送るかはオーケストレーション側の責務。本チケットは「Pod が Notify を受けたときの挙動」だけを扱う
|
||||
- **通知の優先度 / フィルタリング**: 全通知を等しく注入する。重要度に応じた選別は LLM に任せる
|
||||
- **通知の永続化**: 通知は session-store に永続化しない。コンテキスト上の message としてのみ存在する
|
||||
|
|
@ -14,60 +14,152 @@ Pod の LLM が、ツール呼び出しを通じて別 Pod のライフサイク
|
|||
|
||||
```
|
||||
Human (GUI / TUI) ─── Pod A (orchestrator)
|
||||
├── socket ──→ Pod B (researcher)
|
||||
├── socket ──→ Pod C (coder)
|
||||
│ └── socket ──→ Pod D (reviewer)
|
||||
└── socket ──→ Pod E (tester)
|
||||
├── callback addr ──→ Pod B (researcher)
|
||||
├── callback addr ──→ Pod C (coder)
|
||||
│ └── callback addr ──→ Pod D (reviewer)
|
||||
└── callback addr ──→ Pod E (tester)
|
||||
```
|
||||
|
||||
### プロセス独立
|
||||
|
||||
- spawn された Pod は **完全に独立したプロセス** として動作する。OS レベルの親子関係(subprocess)は持たない
|
||||
- spawner が落ちても、spawned Pod は**続行**する。再接続も可能
|
||||
- spawner と spawned Pod の関係は「socket client として接続している」だけ。関係性はプロセスではなく**ソケット接続**で表現される
|
||||
- spawner が落ちても、spawned Pod は**続行**する
|
||||
- すべての Pod は同格。「誰が spawn したか」は Pod の runtime 属性であり、プロセスの従属関係ではない
|
||||
- Pod の発見には**レジストリ**(runtime_dir 内の socket パスを列挙、または明示的な登録)が必要
|
||||
|
||||
### Scope の分譲
|
||||
### Pod の発見と知識
|
||||
|
||||
- Pod は**自分が spawn した相手**と**親から明示的に教えてもらった相手**しか知らない
|
||||
- runtime_dir のスキャンや共有レジストリによる探索は行わない(セキュリティリスク)
|
||||
- 親抜きで会話すべき Pod があるなら、それは親が明示的に紹介する
|
||||
- Pod が知っている Pod のリスト = spawn 記録 + 紹介された相手の記録
|
||||
|
||||
### 接続モデル: 常時接続なし
|
||||
|
||||
- Pod 間の接続は**すべて一時的**。常時接続は張らない
|
||||
- 操作(メッセージ送信・出力読み取り・停止)は**都度接続の request-response**:
|
||||
- ローカル: unix socket に接続 → request → response → 切断
|
||||
- リモート: SSH 経由で接続 → request → response → 切断
|
||||
- 非同期通知は**コールバック**方式: spawned Pod がイベント発生時に spawner のアドレスに一発接続して通知し、即切断(webhook と同じモデル)
|
||||
- 接続は常に**送信側が開始**する
|
||||
|
||||
## Scope の分譲と排他制御
|
||||
|
||||
### 原則
|
||||
|
||||
- Pod A が Pod B を spawn するとき、A は自身の scope の一部を B に**譲渡**する
|
||||
- 譲渡した scope 領域は A の effective scope から **deny** される(A は自分が譲った部分にアクセスできなくなる)
|
||||
- これにより**構造的に排他制御が保証**される:同一パスに対して write 権限を持つ Pod は常に高々1つ
|
||||
- Pod B が終了すると、譲渡された scope は A に**返却**される(A の deny が解除される)
|
||||
- Pod B が scope の一部をさらに Pod D に分譲した場合、B の終了時に D が保持している分は D にそのまま残る(D は独立して生きているため)。D が終了するまで、その scope 領域は誰にも返却されない
|
||||
- 譲渡した scope 領域は A の effective scope から deny される(A は自分が譲った部分にアクセスできなくなる)
|
||||
- **write scope の排他制御が保証**される:同一パスに対して write 権限を持つ Pod は常に高々1つ
|
||||
- read は衝突しない(複数 Pod が同じパスを同時に read 可能)
|
||||
|
||||
### `tickets/scope-exclusion.md` との関係
|
||||
### Scope lock file
|
||||
|
||||
scope 分譲により Pod 間の write 排他制御は spawn 時に構造的に保証される。`tickets/scope-exclusion.md` が扱おうとしていた「複数 Pod が同一パスに同時 write する問題」は、**本チケットの scope 分譲モデルで吸収される**。
|
||||
scope の割り当てをマシン上の**単一の lock file** で一元管理する。spawn 系譜を持たない Pod 同士(人間が独立に起動した Pod 等)でも write 衝突を検出できる。
|
||||
|
||||
ただし、分譲ではなく**共有 read** (複数 Pod が同じパスを同時に read する) は引き続き許可される。read 同士は衝突しない。
|
||||
置き場: `$XDG_RUNTIME_DIR/insomnia/scope.lock`
|
||||
|
||||
内容:
|
||||
```json
|
||||
{
|
||||
"allocations": [
|
||||
{
|
||||
"pod_id": "abc123",
|
||||
"pid": 12345,
|
||||
"socket": "/run/insomnia/.../pod-a.sock",
|
||||
"scope_allow": ["/project/src:write:recursive"],
|
||||
"delegated_from": null
|
||||
},
|
||||
{
|
||||
"pod_id": "def456",
|
||||
"pid": 12346,
|
||||
"socket": "/run/insomnia/.../pod-b.sock",
|
||||
"scope_allow": ["/project/src/core:write:recursive"],
|
||||
"delegated_from": "abc123"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
操作は `flock(2)` による advisory lock で排他アクセスする:
|
||||
|
||||
| タイミング | 動作 |
|
||||
|---|---|
|
||||
| **Pod 起動** | lock → stale 検出(PID 死活)→ 自動回収 → write 衝突チェック → 自分の scope を登録 → unlock |
|
||||
| **scope 分譲 (SpawnPod)** | lock → spawner の allocation に deny 追記 → 新 Pod の allocation を追加(`delegated_from` に spawner)→ unlock |
|
||||
| **Pod 正常終了** | lock → 自分の allocation を削除 → `delegated_from` が自分の子が残っていなければ親の deny を解除 → unlock |
|
||||
| **stale 検出** | `kill(pid, 0)` で生存確認。死んでいたら allocation を削除し、scope を `delegated_from` の親に返却 |
|
||||
|
||||
### stale の自動回収
|
||||
|
||||
Pod がクラッシュ(正常終了せず PID が消えた)した場合、lock file にエントリが残る。**次に lock file を開いた Pod が stale を検出し自動回収する**:
|
||||
|
||||
- Pod B (pid=200, dead): `/project/src` write, delegated_from=A
|
||||
- Pod D (pid=300, alive): `/project/src/core` write, delegated_from=B
|
||||
|
||||
→ B が死んでいる:
|
||||
1. B の scope `/project/src` のうち D が持つ `/project/src/core` を除いた部分を A に返却
|
||||
2. B のエントリを削除
|
||||
3. D の `delegated_from` を A に付け替え
|
||||
|
||||
能動的なコールバックが届かなくても、いつか誰かが lock file を開けば回収が走る。
|
||||
|
||||
### Effective scope の導出
|
||||
|
||||
Pod の effective scope は lock file から導出される:
|
||||
|
||||
```
|
||||
effective_scope = 自分の allocation - Σ(delegated_from が自分を指す子の allocation)
|
||||
```
|
||||
|
||||
Pod は lock file を読むことで自分の effective scope を知れる。ただし常時読むとパフォーマンスに影響するため、キャッシュして scope 変動のコールバック通知で更新するのが実用的。
|
||||
|
||||
### 返却
|
||||
|
||||
- Pod B が終了すると、scope は `delegated_from` が指す spawner (A) に自動返却される
|
||||
- 返却先は常に `delegated_from`。プールへの返却や他者への再分配はない
|
||||
|
||||
### 又貸し
|
||||
|
||||
- Pod B が `/src/core` を Pod D に又貸しする場合、lock file 上で:
|
||||
- B の allocation から `/src/core` を除外
|
||||
- D の allocation を追加(`delegated_from=B`)
|
||||
- B は spawner (A) にコールバックで通知:「`/src/core` を D に委譲した」
|
||||
- A は D の存在と D の socket address をこの通知で知る(= 親からの紹介)
|
||||
- B が終了したとき: lock file の stale 回収で D の `delegated_from` が A に付け替わり、D が保持しない残りの scope が A に戻る
|
||||
|
||||
### 観測可能性
|
||||
|
||||
- `insomnia scope list` 的なコマンドで lock file を読めば、マシン上の全 Pod の scope 割り当てを一覧できる
|
||||
- 人間が「今どの Pod がどこを write 占有しているか」を確認する手段になる
|
||||
- 衝突で Pod 起動が拒否されたとき、競合相手の pod_id を lock file から取得してエラーメッセージに含める
|
||||
|
||||
## LLM に公開するツール群
|
||||
|
||||
Pod の LLM が使えるツールとして以下を設計する。いずれも通常の Tool trait 実装で、Worker に登録される。
|
||||
Pod の LLM が使えるツールとして以下を設計する。すべて**都度接続の request-response** で動作する。
|
||||
|
||||
### `SpawnPod`
|
||||
|
||||
新しい Pod を起動し、scope の一部を譲渡する。
|
||||
|
||||
入力:
|
||||
- `name`: spawned Pod の識別名(spawner のスコープ内で一意)
|
||||
- `instruction`: spawned Pod の instruction ファイル参照(省略時は `$insomnia/default`)
|
||||
- `task`: spawned Pod への最初のメッセージ(spawn 後に即座に run される)
|
||||
- `scope`: 譲渡する scope 定義(allow / deny ルール)。**spawner の現在の effective scope のサブセットでなければならない**。バリデーションで超過分は拒否
|
||||
- `name`: spawned Pod の識別名
|
||||
- `instruction`: instruction ファイル参照(省略時は `$insomnia/default`)
|
||||
- `task`: 最初のメッセージ(spawn 後に即座に run される)
|
||||
- `scope`: 譲渡する scope 定義。spawner の effective scope のサブセットでなければならない
|
||||
- `address`: (自動注入) spawner の callback address
|
||||
|
||||
出力:
|
||||
- spawned Pod の識別子(`pod_id`)と接続状態
|
||||
- spawned Pod の `pod_id` と接続先 address
|
||||
|
||||
内部動作:
|
||||
- spawner の effective scope から、譲渡する scope 領域を deny に追加(spawner 側の scope を縮小)
|
||||
- PodFactory のカスケード(user manifest + project manifest)に加え、spawner からの overlay(譲渡 scope + instruction + provider 等)を重ねて spawned Pod の PodManifest を構築
|
||||
- 独立プロセスとして Pod を起動、socket 確立
|
||||
- scope lock file を flock → write 衝突チェック → spawner の allocation に deny 追記 + 新 Pod の allocation を登録 → unlock
|
||||
- PodFactory のカスケードに spawner からの overlay を重ねて PodManifest を構築
|
||||
- 独立プロセスとして Pod を起動
|
||||
- spawner の callback address を spawned Pod に渡す
|
||||
- `task` を `Method::Run` で送信
|
||||
|
||||
### `SendToPod`
|
||||
|
||||
spawned Pod にメッセージを送る。
|
||||
既知の Pod にメッセージを送る(都度接続)。
|
||||
|
||||
入力:
|
||||
- `pod_id`: 対象の Pod
|
||||
|
|
@ -78,11 +170,11 @@ spawned Pod にメッセージを送る。
|
|||
|
||||
### `ReadPodOutput`
|
||||
|
||||
spawned Pod の最新の出力を読む。
|
||||
既知の Pod の最新の出力を読む(都度接続)。
|
||||
|
||||
入力:
|
||||
- `pod_id`: 対象の Pod
|
||||
- `since`: 前回読んだ時点からの差分のみ取得するオプション(省略時は全出力)
|
||||
- `since`: 前回読んだ時点からの差分のみ取得するオプション
|
||||
|
||||
出力:
|
||||
- 対象 Pod の assistant 応答テキスト(最新ターン or 差分)
|
||||
|
|
@ -90,7 +182,7 @@ spawned Pod の最新の出力を読む。
|
|||
|
||||
### `StopPod`
|
||||
|
||||
spawned Pod を終了させ、譲渡した scope を回収する。
|
||||
既知の Pod を終了させ、譲渡した scope を回収する(都度接続)。
|
||||
|
||||
入力:
|
||||
- `pod_id`: 対象の Pod
|
||||
|
|
@ -99,72 +191,50 @@ spawned Pod を終了させ、譲渡した scope を回収する。
|
|||
- 終了確認
|
||||
- 回収された scope の要約
|
||||
|
||||
内部動作:
|
||||
- 対象 Pod に graceful shutdown を要求
|
||||
- 対象 Pod が保持していた scope のうち、さらに下流に分譲されていない分を spawner に返却
|
||||
- spawner の deny リストから返却分を解除
|
||||
|
||||
### `ListPods`
|
||||
|
||||
spawner が spawn した Pod の一覧とそれぞれの状態を返す。
|
||||
自分が知っている Pod の一覧と状態を返す。
|
||||
|
||||
入力: なし
|
||||
|
||||
出力:
|
||||
- spawned Pod の `pod_id`、`name`、`status`(running / idle / stopped)、譲渡中の scope 要約、最終応答の要約
|
||||
- 各 Pod の `pod_id`、`name`、`status`(running / idle / stopped)、譲渡中の scope 要約、最終応答の要約
|
||||
|
||||
## 非同期通知
|
||||
内部動作:
|
||||
- spawn 記録を元にリストを構築
|
||||
- 各 Pod に都度接続して health check(接続できなければ stopped 扱い)
|
||||
- Hook で定期的に自動実行することも可能
|
||||
|
||||
spawned Pod が出力を生成したとき、spawner の LLM にそれを伝える仕組みが必要。ただし LLM は「ターン」の単位で動くため、イベントストリームをリアルタイムに割り込ませるのは不自然。
|
||||
## 非同期通知: コールバック方式
|
||||
|
||||
### 方針: ターン間フックで集約通知
|
||||
### 仕組み
|
||||
|
||||
- spawner のターンが終了した後、次のターンの開始前に**フック**が走り、spawned Pod から届いているイベントを集約する
|
||||
- 集約された通知は次のターンの**先頭に system message として注入**される(例: `[pod "researcher"] completed: found 3 relevant files`)
|
||||
- spawn 時に spawner が**自分の callback address** を spawned Pod に渡す
|
||||
- ローカル: unix socket path
|
||||
- リモート: `insomnia@host:pod-name` 形式の SSH address
|
||||
- spawned Pod がイベント発生時に、spawner の callback address に**一発接続して通知を送り、即切断**する
|
||||
- spawner が落ちていたら callback が失敗するだけ(spawned Pod は続行する)
|
||||
|
||||
### 通知の種類
|
||||
|
||||
- **ターン完了**: spawned Pod の1ターンが終わった(spawner は `ReadPodOutput` で内容を取りに行く)
|
||||
- **scope 又貸し**: spawned Pod が自身の scope の一部をさらに別の Pod に委譲した(spawner の scope 記録を更新する材料)
|
||||
- **エラー**: spawned Pod でエラーが発生
|
||||
- **終了**: spawned Pod が停止した(scope 返却のトリガー)
|
||||
|
||||
### 通知は「シグナル」のみ
|
||||
|
||||
通知にはイベントの種類と最小限のメタデータ(pod_id、scope 変動等)のみを含める。応答テキスト全体のような大きなデータは含まない。spawner が内容を知りたければ `ReadPodOutput` で取りに行く。
|
||||
|
||||
### 通知の LLM への伝達
|
||||
|
||||
- spawner が受け取ったコールバック通知はバッファに溜められる
|
||||
- spawner の次のターン開始前に、Hook が溜まった通知を集約してターンの先頭に system message として注入する
|
||||
- 通知が無い場合は何もしない
|
||||
|
||||
### 通知の粒度
|
||||
### ポーリングでも代替可能
|
||||
|
||||
- **ターン完了通知**: spawned Pod の1ターンが終わったとき
|
||||
- **エラー通知**: spawned Pod でエラーが発生したとき
|
||||
- **終了通知**: spawned Pod が停止したとき(scope 返却が発生する)
|
||||
|
||||
ツール出力の内容自体は `ReadPodOutput` で明示的に取りに行くモデル。通知は「何か変化があった」というシグナルのみで、内容全体は含まない(spawner のコンテキストを圧迫しない)。
|
||||
|
||||
## Scope の分譲と回収の詳細
|
||||
|
||||
### 分譲時
|
||||
|
||||
1. spawner が `SpawnPod` で `scope` を指定
|
||||
2. システムが検証: 指定された scope が spawner の **現在の effective scope のサブセット**であること
|
||||
3. 検証通過後、spawner の effective scope から譲渡分を deny として差し引く
|
||||
4. spawned Pod は譲渡された scope を自身の allow として持って起動
|
||||
|
||||
### 回収時(StopPod または spawned Pod の自発的終了)
|
||||
|
||||
1. 終了する Pod が保持している effective scope を確認
|
||||
2. そのうち、さらに下流に分譲中の scope は除外(下流 Pod が生きている間は返却されない)
|
||||
3. 残りの scope を spawner の deny から解除し、spawner の effective scope に復帰
|
||||
|
||||
### 分譲チェーンのケース
|
||||
|
||||
Pod A が Pod B に `/src` を分譲 → Pod B が Pod D に `/src/core` を分譲:
|
||||
|
||||
- A の scope: `/src` は deny
|
||||
- B の scope: `/src` を持つが `/src/core` は deny(D に分譲済み)
|
||||
- D の scope: `/src/core`
|
||||
|
||||
B が終了すると:
|
||||
- `/src` のうち D に分譲中の `/src/core` は返却されない(D が生きている)
|
||||
- `/src` から `/src/core` を除いた部分が A に返却される
|
||||
- A の scope: `/src` の `/src/core` 以外が復帰。`/src/core` は引き続き deny(D が保持中)
|
||||
- D が終了すると `/src/core` が A に返却される
|
||||
|
||||
### runtime scope state
|
||||
|
||||
- Pod の scope は manifest の static 定義だけでは決まらない。分譲と回収による**動的な state** が加わる
|
||||
- この state を管理するレイヤーが必要(Pod 内のメモリ状態 + 永続化の選択肢)
|
||||
- spawner がクラッシュして復帰した場合、分譲中の scope を復元する必要がある → scope ledger の永続化が事実上必須
|
||||
コールバックが届かなくても、spawner は `ListPods` のポーリングで状態を拾える。コールバックは「早く気付くための最適化」であって唯一の手段ではない。
|
||||
|
||||
## Pod の設定
|
||||
|
||||
|
|
@ -172,84 +242,77 @@ B が終了すると:
|
|||
|
||||
- spawned Pod の `instruction` は `SpawnPod` の引数で明示指定
|
||||
- 省略時は `$insomnia/default`
|
||||
- spawner の instruction を引き継ぐケースはまれ(分業目的の spawn なので、spawned Pod には固有の役割がある)
|
||||
- spawner の instruction を引き継ぐケースはまれ(分業目的の spawn なので固有の役割がある)
|
||||
|
||||
### provider
|
||||
|
||||
- API キーと provider 設定は通常ユーザー manifest / プロジェクト manifest 由来なので、spawned Pod は PodFactory 経由で同じカスケードから取得する
|
||||
- spawned Pod に異なるモデルを使わせたい場合は overlay で上書きする(例: 調査用 Pod には小さいモデル、コード生成にはフルモデル)
|
||||
- API キーと provider 設定は PodFactory のカスケード(user / project manifest)から取得
|
||||
- spawned Pod に異なるモデルを使わせたい場合は overlay で上書き
|
||||
|
||||
### Pod 起動コマンド
|
||||
|
||||
- 環境変数またはユーザー設定で上書き可能
|
||||
- デフォルトは `pod`(PATH 上の `pod` バイナリ)
|
||||
- リモートの場合は SSH 越しに実行
|
||||
|
||||
## 人間向けの監視
|
||||
|
||||
### 各 Pod は独立して観測可能
|
||||
### 各 Pod に個別接続
|
||||
|
||||
- すべての Pod は通常の socket サーバーを持つ。人間は GUI / TUI で任意の Pod に接続して会話を閲覧・介入できる
|
||||
- Pod 間に主従関係が無いので、人間はどの Pod にも同格にアクセスできる
|
||||
- すべての Pod は通常の socket サーバーを持つ。人間は GUI / TUI で Pod に接続して会話を閲覧・介入できる
|
||||
- ただし、Pod の存在を知る手段は**spawn 記録(+ 紹介)のみ**。人間もオーケストレーター Pod 経由か、事前に Pod の address を知っている必要がある
|
||||
|
||||
### Pod ネットワークの可視化
|
||||
|
||||
- GUI / TUI が Pod の一覧を表示(spawn 関係をグラフまたはリストで表現)
|
||||
- 各ノードに status(running / idle / stopped)、保持中の scope 要約、最終ターンの要約を表示
|
||||
- ノードをクリック/選択すると、その Pod の会話ビューに切り替わる
|
||||
- GUI / TUI がオーケストレーター Pod の spawn 記録を読んで Pod リストを表示
|
||||
- 各エントリに status、scope 要約、最終応答の要約を表示
|
||||
- エントリを選択すると、その Pod に接続して会話ビューに切り替わる
|
||||
|
||||
### 介入
|
||||
|
||||
- 人間は任意の Pod に直接メッセージを送れる(通常のクライアントとして接続)
|
||||
- 人間は既知の Pod に直接メッセージを送れる(通常のクライアントとして都度接続)
|
||||
- 人間が Pod を直接 stop できる(scope は spawner に返却される)
|
||||
- spawner の LLM は spawned Pod に人間が介入したことを(次の通知で)知る
|
||||
|
||||
## Pod の発見と登録
|
||||
|
||||
プロセスが独立しているため、起動中の Pod を**発見**する仕組みが要る。
|
||||
|
||||
候補:
|
||||
- **A. runtime_dir convention**: 各 Pod が socket path を `runtime_dir/<pod_name>.sock` に作る。ディレクトリを列挙すれば一覧が取れる(現行の仕組みの延長)
|
||||
- **B. 明示的なレジストリ**: Pod 起動時に共有レジストリ(ファイルまたは IPC)に登録。終了時に削除
|
||||
- **C. daemon**: 軽量な daemon プロセスがレジストリ役を担う(`crates/daemon` は現在空)
|
||||
|
||||
現時点では A が最もシンプル。daemon が必要になるのは「リモート Pod」や「Pod の自動再起動」が必要になってから。
|
||||
|
||||
## 設計で決めること
|
||||
|
||||
- **Pod の起動方式**: 独立プロセスとして `pod` バイナリを起動する形で確定。起動方法の詳細(fork? 直接 exec? nix-shell 経由?)
|
||||
- **scope ledger の永続化方式**: ファイルベース / session-store / 別の永続ストア
|
||||
- **scope の分譲粒度**: パス単位で分譲するか、permission レベル(read / write)でも分譲できるか(例: write だけ譲って read は共有する等)
|
||||
- **通知の注入方式**: system message として注入するか、専用の `Event` としてターン開始前に Worker に流すか
|
||||
- **通知のバッファリング**: spawner の1ターンが数分かかる場合、その間のイベントをどこに溜めるか
|
||||
- **Pod の発見**: A (runtime_dir) / B (レジストリファイル) / C (daemon)
|
||||
- **callback の protocol**: 通知メッセージの具体的なフォーマット。既存の protocol crate (`Event` enum) を拡張するか、別の軽量フォーマットにするか
|
||||
- **scope の分譲粒度**: パス単位で分譲するか、permission レベル(write だけ渡して read は共有等)でも制御できるか
|
||||
- **通知のバッファリング**: spawner のターン実行中に溜まるコールバック通知をどこに保持するか
|
||||
- **リソース制限**: 最大 spawned Pod 数、ネスト深さの上限
|
||||
- **ツール名**: `SpawnPod` / `SendToPod` / `ReadPodOutput` / `StopPod` / `ListPods` の命名はそのままで良いか
|
||||
- **ツール名**: `SpawnPod` / `SendToPod` / `ReadPodOutput` / `StopPod` / `ListPods` の命名
|
||||
- **Pod ネットワークの可視化**: GUI / TUI どちらに先行実装するか
|
||||
- **spawner 復帰時の再接続**: scope ledger を読んで spawned Pod のソケットに再接続する手順
|
||||
- **分譲チェーンの depth limit**: 再帰的 spawn の深さ上限を設けるか
|
||||
- **spawner 復帰時の再接続**: spawn 記録を読んで spawned Pod に再接続する手順。callback address の再登録
|
||||
- **リモート spawn**: SSH-only モデルの詳細(`docs/network-peering.md` を参照)
|
||||
|
||||
## 完了条件
|
||||
|
||||
- Pod の LLM が `SpawnPod` ツールで別 Pod を起動でき、spawned Pod が独立プロセスとして動作する
|
||||
- spawn 時に scope が分譲され、spawner の effective scope が縮小されることを検証できる
|
||||
- `SendToPod` で spawned Pod にメッセージを送り、`ReadPodOutput` で応答を読み取れる
|
||||
- `StopPod` で spawned Pod を graceful に停止でき、scope が spawner に返却される
|
||||
- `ListPods` で spawned Pod の状態と scope 要約を一覧できる
|
||||
- spawned Pod のターン完了・エラー・停止が spawner に非同期通知され、次のターン開始時に LLM に伝わる
|
||||
- spawner が停止しても spawned Pod が続行し、spawner 復帰時に再接続できる
|
||||
- 人間が GUI または TUI で Pod ネットワークを閲覧でき、任意の Pod に接続して会話を見られる
|
||||
- spawned Pod がさらに別の Pod を spawn する再帰的なネットワークが動作する
|
||||
- scope 分譲チェーンの返却(中間ノードの終了時に部分返却)が正しく動作する
|
||||
- spawn 時に scope lock file に allocation が記録され、spawner の effective scope が<><E3818C><EFBFBD>小される
|
||||
- 同一パスへの write 衝突が lock file で検出され、Pod 起動が拒否される(競合相手の pod_id がエラーに含まれる)
|
||||
- stale エントリ(PID が死亡)が自動回収され、scope が `delegated_from` の親に戻る
|
||||
- `SendToPod` / `ReadPodOutput` が都度接続の request-response で動作する
|
||||
- `StopPod` で spawned Pod を graceful に停止でき、lock file から allocation が削除されて scope が返却される
|
||||
- `ListPods` で既知の Pod の状態を一覧でき、health check が機能する
|
||||
- spawned Pod のターン完了・エラー・停止がコールバックで spawner に通知され、`Method::Notify` 経由で LLM に伝わる
|
||||
- spawner が停止しても spawned Pod が続行する
|
||||
- 又貸しが lock file に記録され、コールバック通知により spawner が孫 Pod の存在と scope を把握できる
|
||||
- `insomnia scope list` 相当のコマンドで lock file を読み、マシン上の全 Pod の scope 割り当てを一覧できる
|
||||
- 人間が GUI または TUI で Pod リストを閲覧でき、任意の既知 Pod に接続して会話を見られる
|
||||
|
||||
## 他チケットとの関係
|
||||
|
||||
- **tickets/scope-exclusion.md**: scope 分譲モデルにより**本チケットに吸収される**。write 排他制御は分譲時に構造的に保証されるため、別の排他制御メカニズムは不要。scope-exclusion.md は本チケットの scope 設計がカバーする
|
||||
- **tickets/protocol-design.md**: Protocol に Pod 間通知のイベント(spawned Pod の状態変化通知など)を追加する必要があるかもしれない。ただし各 Pod は通常の socket サーバーなので、protocol 拡張なしで「複数 socket に繋ぐクライアント」として実装できる可能性もある
|
||||
- **tickets/native-gui-mvp.md**: Pod ネットワーク可視化は GUI 側の拡張。MVP scope には含まれていないが、本チケットの監視要件は GUI の次フェーズに自然に接続する
|
||||
- **tickets/tui-pod-spawn-ui.md**: TUI からの Pod spawn UI。本チケットは LLM からの spawn だが、spawn のインフラ(PodFactory + プロセス起動)は共通
|
||||
- **tickets/tui-pod-shutdown.md**: Pod ネットワークの shutdown 戦略にも影響。Pod を停止したとき scope がどこに返るか
|
||||
- **tickets/permission-extension-point.md**: spawned Pod のツール実行パーミッションを spawner が制御する可能性
|
||||
- **tickets/method-notify.md**: コールバック通知を親 Pod に注入する仕組み。オーケストレーションの非同期通知に必須
|
||||
- **tickets/protocol-design.md**: コールバック通知の protocol 拡張が必要になりうる
|
||||
- **tickets/native-gui-mvp.md**: Pod リスト可視化は GUI 側の拡張
|
||||
- **tickets/tui-pod-spawn-ui.md**: TUI からの Pod spawn UI。spawn のインフラは共通
|
||||
- **tickets/permission-extension-point.md**: spawned Pod のツール実行パーミッションを spawner が制御<E588B6><E5BEA1>る可能性
|
||||
- **scope-exclusion (削除済み)**: scope lock file による write 排他で吸収された
|
||||
|
||||
## 範囲外
|
||||
|
||||
- **Pod 間の直接メッセージパッシング**: Pod 同士が spawner を介さずに直接通信する仕組み。すべてのやり取りは spawner(オーケストレーター)を介す
|
||||
- **共有メモリ / 共有状態**: Pod 間のデータ共有はツール経由のテキストメッセージのみ。ファイルシステムを介した暗黙の共有は scope が許す範囲で起きうるが、それは Pod の既存動作の延長であり、分譲により write 衝突は構造的に排除される
|
||||
- **自動スケーリング / 負荷分散**: Pod の spawn 先は常にローカルマシン。リモート実行やクラウド分散は扱わない
|
||||
- **課金・トークン予算管理**: spawned Pod が消費するトークンの予算制御や spawner への請求集約は扱わない
|
||||
- **人間がネットワークの構造を変更する操作**(Pod の spawn 元替え、ネットワークの組み替えなど)
|
||||
- **daemon の導入**: Pod の発見は runtime_dir convention または軽量レジストリで対応。daemon は必要が明確になってから
|
||||
- **Pod 間の直接メッセージパッシング**: すべてのやり取りは spawner を介するか、spawner が明示的に紹介した相手とのみ行う
|
||||
- **常時接続**: Pod 間の接続はすべて一時的(都度接続 or コールバック一発)
|
||||
- **runtime_dir のスキャンによる Pod 探索**: セキュリティリスクのため行わない。Pod の存在は spawn 記録 + 明示的な紹介 + lock file のみで把握
|
||||
- **自動スケーリング / 負荷分散 / リモート実行**: ローカルの単一マシン上での動作に集中。リモート spawn は `docs/network-peering.md` を参照
|
||||
- **課金・トークン予算管理**
|
||||
- **環境再現(git clone, コンテナ構築等)**: insomnia の責務外
|
||||
|
|
|
|||
|
|
@ -1,101 +0,0 @@
|
|||
# 複数 Pod 間の Scope 排他制御
|
||||
|
||||
## 背景
|
||||
|
||||
[scope-redesign.md](scope-redesign.md) で Scope は「allow / deny の領域リスト + permission レベル」に再設計された。
|
||||
これにより複数 Pod が同じファイルツリーに対して異なる権限を宣言する状況が日常化する。
|
||||
|
||||
現状、複数 Pod が同じ pwd や重なる allow を持っても何のチェックも警告もない。
|
||||
両者が同じファイルに `write` を持っていた場合、ツール経由で同時に書き換えて
|
||||
内容を破壊しうる。Git のコミット粒度より細かい競合は履歴からも復元しづらい。
|
||||
|
||||
Pod を「並行作業の単位」として扱う以上、scope の重なりは設計時に検知・制御
|
||||
できる必要がある。
|
||||
|
||||
## 要件
|
||||
|
||||
- **R1: write の重複を検出する**
|
||||
同じファイルが 2 つ以上の Pod から `write` 権限で見えている状態を作らせない。
|
||||
片方の Pod が起動中なら、もう片方の起動を拒否する(または昇格を拒否する)。
|
||||
|
||||
- **R2: read は共有可**
|
||||
`read` のみの重なりは許す。複数の閲覧者は問題ない。
|
||||
|
||||
- **R3: deny は重なりに影響しない**
|
||||
`deny` で write が落とされている path は、その Pod にとって write を持たないと
|
||||
みなして他 Pod との重複判定を行う。
|
||||
|
||||
- **R4: 解放の確実性**
|
||||
Pod の異常終了で排他が永久に残らないこと。lock の所有者プロセスが死んだら
|
||||
自動解放されるか、stale lock として検知できる。
|
||||
|
||||
- **R5: 観測可能性**
|
||||
ユーザーが「今どの Pod がどこを write 占有しているか」を見られる。
|
||||
競合で起動が拒否されたとき、競合相手をエラーメッセージで示す。
|
||||
|
||||
## 設計上の論点
|
||||
|
||||
### 排他の粒度
|
||||
|
||||
選択肢:
|
||||
- **A. ファイル単位** — 厳密だが lock 数が膨大になる
|
||||
- **B. allow rule 単位** — 宣言された target ごとに lock。粒度が荒いが宣言と一致して直感的
|
||||
- **C. パス prefix の最小被覆** — write 領域を最小の prefix に縮約して lock。中間
|
||||
|
||||
おすすめは **B**。Pod の宣言と lock の単位が一致する方が説明しやすく、
|
||||
ユーザーが「この Pod は src を握っている」と理解しやすい。
|
||||
|
||||
### lock の保管場所
|
||||
|
||||
選択肢:
|
||||
- **A. lock file** — `~/.insomnia/locks/<hash>.lock` 等。各 Pod プロセスが直接握る
|
||||
- **B. 中央レジストリプロセス** — 常駐デーモンが scope の貸し借りを管理
|
||||
- **C. session-store の拡張** — 既存の永続化基盤に lock テーブルを足す
|
||||
|
||||
おすすめは **A**。デーモンを増やしたくないし、Pod は短命〜中時間生存の想定なので
|
||||
ファイルロック (`flock(2)` / `fcntl`) で十分。stale lock 検知は PID 死活で行う。
|
||||
|
||||
### 競合発生時の挙動
|
||||
|
||||
選択肢:
|
||||
- **A. 起動失敗 (fail-fast)** — エラーで起動拒否、ユーザーが手で解決
|
||||
- **B. 待機 (block)** — 競合相手が解放するまで起動を待つ
|
||||
- **C. 自動降格** — 競合する write を read に降格して起動
|
||||
|
||||
おすすめは **A**。並行作業の単位として Pod を起動するなら、競合は意図しない
|
||||
状況であることが多い。明示エラーで気づける方が安全。`--wait` フラグや `--read-only`
|
||||
フラグで B / C を選べるようにするのは将来拡張で十分。
|
||||
|
||||
### 取得タイミング
|
||||
|
||||
- Pod 起動時に scope 全体を一括取得し、終了時に解放
|
||||
- 起動中に scope を変えることは現状想定しない(manifest 編集 → 再起動)
|
||||
|
||||
### resume との関係
|
||||
|
||||
`Pod::restore` でも同じ scope が要求される。前回終了時に解放されているはずなので
|
||||
取得が成功するのが正常。失敗する場合は別 Pod が起動している = ユーザーが意図的に
|
||||
2 つ動かそうとしている。fail-fast に乗せる。
|
||||
|
||||
## 影響範囲(想定)
|
||||
|
||||
実装時に詰める。現時点での見立て:
|
||||
|
||||
- 新規モジュール `crates/pod/src/scope_lock.rs`(または独立クレート `scope-lock`)
|
||||
- lock の取得 / 解放
|
||||
- stale lock 検知(PID 確認)
|
||||
- 競合情報の収集(誰がどの allow を握っているか)
|
||||
- `Pod::new` / `Pod::restore` の前段で lock 取得
|
||||
- `Pod` の Drop / 明示解放で lock 返却
|
||||
- `Controller` 層でのエラー伝搬とユーザー向けメッセージ
|
||||
- CLI に「現在のロック一覧」を見るコマンド(観測性 R5)
|
||||
|
||||
## 非ゴール
|
||||
|
||||
- **同一 Pod 内の並行性制御** — 1 Pod 内のツール並行実行は本 ticket では扱わない
|
||||
- **ネットワーク越しの排他** — ローカルファイルロックのみ。複数マシンで同じワーキングコピーを共有する想定はしない
|
||||
- **read-write lock の細かな格上げ/格下げ** — 取得時に確定、起動中の変更はしない
|
||||
|
||||
## 依存
|
||||
|
||||
- [scope-redesign.md](scope-redesign.md) — allow / deny / permission レベルの構造が前提
|
||||
Loading…
Reference in New Issue
Block a user